Repository: TecharoHQ/anubis Branch: main Commit: 24857f430f52 Files: 608 Total size: 1.4 MB Directory structure: gitextract_t1amwk0u/ ├── .air.toml ├── .devcontainer/ │ ├── Dockerfile │ ├── README.md │ ├── devcontainer.json │ ├── docker-compose.yaml │ └── poststart.sh ├── .gitattributes ├── .github/ │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.yaml │ │ ├── config.yml │ │ └── feature_request.yaml │ ├── PULL_REQUEST_TEMPLATE.md │ ├── actions/ │ │ └── spelling/ │ │ ├── README.md │ │ ├── advice.md │ │ ├── allow.txt │ │ ├── candidate.patterns │ │ ├── excludes.txt │ │ ├── expect.txt │ │ ├── line_forbidden.patterns │ │ ├── patterns.txt │ │ └── reject.txt │ ├── dependabot.yml │ ├── workflows/ │ │ ├── asset-verification.yml │ │ ├── dco-check.yaml │ │ ├── docker-pr.yml │ │ ├── docker.yml │ │ ├── docs-deploy.yml │ │ ├── docs-test.yml │ │ ├── go-mod-tidy-check.yml │ │ ├── go.yml │ │ ├── lint-pr-title.yaml │ │ ├── package-builds-stable.yml │ │ ├── package-builds-unstable.yml │ │ ├── smoke-tests.yml │ │ ├── spelling.yml │ │ ├── ssh-ci-runner-cron.yml │ │ ├── ssh-ci.yml │ │ └── zizmor.yml │ └── zizmor.yml ├── .gitignore ├── .husky/ │ ├── commit-msg │ └── pre-commit ├── .ko.yaml ├── .prettierignore ├── .vscode/ │ ├── extensions.json │ ├── launch.json │ └── settings.json ├── AGENTS.md ├── Brewfile ├── CLAUDE.md ├── CONTRIBUTING.md ├── LICENSE ├── Makefile ├── README.md ├── SECURITY.md ├── VERSION ├── anubis.go ├── cmd/ │ ├── containerbuild/ │ │ ├── .gitignore │ │ └── main.go │ └── robots2policy/ │ ├── batch/ │ │ └── batch_process.go │ ├── main.go │ ├── robots2policy_test.go │ └── testdata/ │ ├── blacklist.robots.txt │ ├── blacklist.yaml │ ├── complex.robots.txt │ ├── complex.yaml │ ├── consecutive.robots.txt │ ├── consecutive.yaml │ ├── custom-name.yaml │ ├── deny-action.yaml │ ├── empty.robots.txt │ ├── empty.yaml │ ├── simple.json │ ├── simple.robots.txt │ ├── simple.yaml │ ├── wildcards.robots.txt │ └── wildcards.yaml ├── data/ │ ├── apps/ │ │ ├── allow-api-routes.yaml │ │ ├── bookstack-saml.yaml │ │ ├── gitea-rss-feeds.yaml │ │ ├── qualys-ssl-labs.yml │ │ └── searx-checker.yml │ ├── botPolicies.yaml │ ├── bots/ │ │ ├── _deny-pathological.yaml │ │ ├── aggressive-brazilian-scrapers.yaml │ │ ├── ai-catchall.yaml │ │ ├── ai-robots-txt.yaml │ │ ├── cloudflare-workers.yaml │ │ ├── custom-async-http-client.yaml │ │ ├── headless-browsers.yaml │ │ ├── irc-bots/ │ │ │ ├── archlinux-phrik.yaml │ │ │ └── gentoo-chat.yaml │ │ └── us-ai-scraper.yaml │ ├── clients/ │ │ ├── ai.yaml │ │ ├── docker-client.yaml │ │ ├── git.yaml │ │ ├── go-get.yaml │ │ ├── mistral-mistralai-user.yaml │ │ ├── openai-chatgpt-user.yaml │ │ ├── perplexity-user.yaml │ │ ├── small-internet-browsers/ │ │ │ ├── _permissive.yaml │ │ │ ├── netsurf.yaml │ │ │ └── palemoon.yaml │ │ ├── telegram-preview.yaml │ │ ├── vk-preview.yaml │ │ └── x-firefox-ai.yaml │ ├── common/ │ │ ├── acts-like-browser.yaml │ │ ├── allow-api-like.yaml │ │ ├── allow-private-addresses.yaml │ │ ├── json-api.yaml │ │ ├── keep-internet-working.yaml │ │ └── rfc-violations.yaml │ ├── crawlers/ │ │ ├── _allow-good.yaml │ │ ├── ai-search.yaml │ │ ├── ai-training.yaml │ │ ├── alibaba-cloud.yaml │ │ ├── applebot.yaml │ │ ├── bingbot.yaml │ │ ├── commoncrawl.yaml │ │ ├── duckduckbot.yaml │ │ ├── googlebot.yaml │ │ ├── huawei-cloud.yaml │ │ ├── internet-archive.yaml │ │ ├── kagibot.yaml │ │ ├── marginalia.yaml │ │ ├── mojeekbot.yaml │ │ ├── openai-gptbot.yaml │ │ ├── openai-searchbot.yaml │ │ ├── perplexitybot.yaml │ │ ├── qwantbot.yaml │ │ ├── tencent-cloud.yaml │ │ ├── wikimedia-citoid.yaml │ │ └── yandexbot.yaml │ ├── embed.go │ ├── embed_test.go │ ├── meta/ │ │ ├── README.md │ │ ├── ai-block-aggressive.yaml │ │ ├── ai-block-moderate.yaml │ │ ├── ai-block-permissive.yaml │ │ ├── default-config.yaml │ │ └── messengers-preview.yaml │ └── services/ │ ├── updown.yaml │ └── uptime-robot.yaml ├── decaymap/ │ ├── decaymap.go │ └── decaymap_test.go ├── docs/ │ ├── .dockerignore │ ├── .gitignore │ ├── Dockerfile │ ├── README.md │ ├── blog/ │ │ ├── 2025-06-16-welcome/ │ │ │ └── index.mdx │ │ ├── 2025-06-27-release-1.20.0/ │ │ │ └── index.mdx │ │ ├── 2025-07-09-incident-report/ │ │ │ └── index.mdx │ │ ├── 2025-07-22-release-1.21.1/ │ │ │ └── index.mdx │ │ ├── 2025-08-18-funding-update/ │ │ │ └── index.mdx │ │ ├── 2025-08-28-cpu-core-odd/ │ │ │ ├── ProofOfWorkDiagram/ │ │ │ │ ├── index.jsx │ │ │ │ └── styles.module.css │ │ │ └── index.mdx │ │ ├── 2025-10-31-file-abuse-reports/ │ │ │ └── index.mdx │ │ └── authors.yml │ ├── docs/ │ │ ├── CHANGELOG.md │ │ ├── admin/ │ │ │ ├── _category_.json │ │ │ ├── botstopper.mdx │ │ │ ├── caveats-gitea-forgejo.mdx │ │ │ ├── caveats-xff.mdx │ │ │ ├── configuration/ │ │ │ │ ├── _category_.json │ │ │ │ ├── challenges/ │ │ │ │ │ ├── _category_.json │ │ │ │ │ ├── index.mdx │ │ │ │ │ ├── metarefresh.mdx │ │ │ │ │ ├── preact.mdx │ │ │ │ │ └── proof-of-work.mdx │ │ │ │ ├── custom-status-codes.mdx │ │ │ │ ├── expressions.mdx │ │ │ │ ├── import.mdx │ │ │ │ ├── impressum.mdx │ │ │ │ ├── open-graph.mdx │ │ │ │ ├── redirect-domains.mdx │ │ │ │ ├── subrequest-auth.mdx │ │ │ │ └── thresholds.mdx │ │ │ ├── default-allow-behavior.mdx │ │ │ ├── environments/ │ │ │ │ ├── _category_.json │ │ │ │ ├── apache.mdx │ │ │ │ ├── caddy.mdx │ │ │ │ ├── cloudflare.mdx │ │ │ │ ├── docker-compose.mdx │ │ │ │ ├── haproxy/ │ │ │ │ │ ├── advanced-config-policy.yml │ │ │ │ │ ├── advanced-config.env │ │ │ │ │ ├── advanced-haproxy.cfg │ │ │ │ │ ├── simple-config.env │ │ │ │ │ └── simple-haproxy.cfg │ │ │ │ ├── haproxy.mdx │ │ │ │ ├── kubernetes.mdx │ │ │ │ ├── nginx/ │ │ │ │ │ ├── conf-anubis.inc │ │ │ │ │ ├── server-anubistest-techaro-lol.conf │ │ │ │ │ ├── server-mimi-techaro-lol.conf │ │ │ │ │ └── upstream-anubis.conf │ │ │ │ ├── nginx.mdx │ │ │ │ └── traefik.mdx │ │ │ ├── frameworks/ │ │ │ │ ├── _category_.json │ │ │ │ ├── htmx.mdx │ │ │ │ └── wordpress.mdx │ │ │ ├── honeypot/ │ │ │ │ ├── _category_.json │ │ │ │ └── overview.mdx │ │ │ ├── installation.mdx │ │ │ ├── iplist2rule.mdx │ │ │ ├── native-install.mdx │ │ │ ├── policies.mdx │ │ │ ├── robots2policy.mdx │ │ │ ├── roles/ │ │ │ │ ├── _category_.json │ │ │ │ └── oci-registry.mdx │ │ │ └── thoth.mdx │ │ ├── design/ │ │ │ ├── _category_.json │ │ │ ├── how-anubis-works.mdx │ │ │ └── why-proof-of-work.mdx │ │ ├── developer/ │ │ │ ├── _category_.json │ │ │ ├── ai-coding-policy.md │ │ │ ├── building-anubis.md │ │ │ ├── local-dev.md │ │ │ └── signed-commits.md │ │ ├── funding.md │ │ ├── index.mdx │ │ └── user/ │ │ ├── _category_.json │ │ ├── frequently-asked-questions.mdx │ │ ├── known-broken-extensions.md │ │ ├── known-instances.md │ │ └── why-see-challenge.md │ ├── docusaurus.config.ts │ ├── fly.toml │ ├── manifest/ │ │ ├── 1password.yaml │ │ ├── cfg/ │ │ │ ├── anubis/ │ │ │ │ └── botPolicies.yaml │ │ │ └── nginx/ │ │ │ ├── mime.types │ │ │ └── nginx.conf │ │ ├── deployment.yaml │ │ ├── ingress.yaml │ │ ├── kustomization.yaml │ │ ├── onionservice.yaml │ │ ├── poddisruptionbudget.yaml │ │ └── service.yaml │ ├── package.json │ ├── sidebars.ts │ ├── src/ │ │ ├── components/ │ │ │ ├── EnterpriseOnly/ │ │ │ │ ├── index.jsx │ │ │ │ └── styles.module.css │ │ │ ├── HomepageFeatures/ │ │ │ │ ├── index.tsx │ │ │ │ └── styles.module.css │ │ │ └── RandomKey/ │ │ │ └── index.tsx │ │ ├── css/ │ │ │ └── custom.css │ │ └── pages/ │ │ ├── index.module.css │ │ └── index.tsx │ ├── static/ │ │ └── .nojekyll │ └── tsconfig.json ├── go.mod ├── go.sum ├── internal/ │ ├── actorify/ │ │ └── actorify.go │ ├── clampip.go │ ├── clampip_test.go │ ├── dns/ │ │ ├── cache.go │ │ ├── dns.go │ │ └── dns_test.go │ ├── dnsbl/ │ │ ├── dnsbl.go │ │ ├── dnsbl_test.go │ │ └── droneblresponse_string.go │ ├── glob/ │ │ ├── glob.go │ │ └── glob_test.go │ ├── gzip.go │ ├── hash.go │ ├── hash_bench_test.go │ ├── headers.go │ ├── health.go │ ├── honeypot/ │ │ ├── honeypot.go │ │ └── naive/ │ │ ├── 100bytes.css │ │ ├── affirmations.txt │ │ ├── naive.go │ │ ├── page.templ │ │ ├── page_templ.go │ │ ├── spintext.txt │ │ └── titles.txt │ ├── ja4h.go │ ├── listor.go │ ├── listor_test.go │ ├── log.go │ ├── log_test.go │ ├── mimetype.go │ ├── ogtags/ │ │ ├── cache.go │ │ ├── cache_test.go │ │ ├── fetch.go │ │ ├── fetch_test.go │ │ ├── integration_test.go │ │ ├── mem_test.go │ │ ├── ogtags.go │ │ ├── ogtags_fuzz_test.go │ │ ├── ogtags_test.go │ │ ├── parse.go │ │ ├── parse_test.go │ │ └── sni.go │ ├── test/ │ │ ├── playwright_test.go │ │ └── var/ │ │ └── .gitignore │ ├── unbreakdocker.go │ └── xff_test.go ├── lib/ │ ├── anubis.go │ ├── anubis_test.go │ ├── challenge/ │ │ ├── challenge.go │ │ ├── challengetest/ │ │ │ ├── challengetest.go │ │ │ └── challengetest_test.go │ │ ├── error.go │ │ ├── interface.go │ │ ├── metarefresh/ │ │ │ ├── metarefresh.go │ │ │ ├── metarefresh.templ │ │ │ └── metarefresh_templ.go │ │ ├── metrics.go │ │ ├── preact/ │ │ │ ├── build.sh │ │ │ ├── js/ │ │ │ │ ├── app.tsx │ │ │ │ └── xeact.js │ │ │ ├── preact.go │ │ │ ├── preact.templ │ │ │ ├── preact_templ.go │ │ │ └── static/ │ │ │ └── .gitignore │ │ └── proofofwork/ │ │ ├── proofofwork.go │ │ ├── proofofwork.templ │ │ ├── proofofwork_templ.go │ │ └── proofofwork_test.go │ ├── config/ │ │ ├── asn.go │ │ ├── asn_test.go │ │ ├── check.go │ │ ├── config.go │ │ ├── config_test.go │ │ ├── expressionorlist.go │ │ ├── expressionorlist_test.go │ │ ├── geoip.go │ │ ├── geoip_test.go │ │ ├── impressum.go │ │ ├── impressum_test.go │ │ ├── logging.go │ │ ├── logging_test.go │ │ ├── opengraph.go │ │ ├── opengraph_test.go │ │ ├── store.go │ │ ├── store_test.go │ │ ├── testdata/ │ │ │ ├── bad/ │ │ │ │ ├── badregexes.json │ │ │ │ ├── badregexes.yaml │ │ │ │ ├── dns-ttl-custom.yaml │ │ │ │ ├── import_and_bot.json │ │ │ │ ├── import_and_bot.yaml │ │ │ │ ├── import_invalid_file.json │ │ │ │ ├── import_invalid_file.yaml │ │ │ │ ├── impressum-no-footer.yaml │ │ │ │ ├── impressum-no-page-contents.yaml │ │ │ │ ├── invalid.json │ │ │ │ ├── invalid.yaml │ │ │ │ ├── logging-invalid-sink.yaml │ │ │ │ ├── logging-no-parameters.yaml │ │ │ │ ├── multiple_expression_types.json │ │ │ │ ├── multiple_expression_types.yaml │ │ │ │ ├── nobots.json │ │ │ │ ├── nobots.yaml │ │ │ │ ├── opengraph_bad_ttl.yaml │ │ │ │ ├── regex_ends_newline.json │ │ │ │ ├── regex_ends_newline.yaml │ │ │ │ ├── status-codes-0.json │ │ │ │ ├── status-codes-0.yaml │ │ │ │ ├── threshold-challenge-without-challenge.yaml │ │ │ │ ├── thresholds.yaml │ │ │ │ ├── unparseable.json │ │ │ │ └── unparseable.yaml │ │ │ ├── good/ │ │ │ │ ├── allow_everyone.json │ │ │ │ ├── allow_everyone.yaml │ │ │ │ ├── block_cf_workers.json │ │ │ │ ├── block_cf_workers.yaml │ │ │ │ ├── challenge_cloudflare.yaml │ │ │ │ ├── challengemozilla.json │ │ │ │ ├── challengemozilla.yaml │ │ │ │ ├── dns-ttl-custom.yaml │ │ │ │ ├── entropy.yaml │ │ │ │ ├── everything_blocked.json │ │ │ │ ├── everything_blocked.yaml │ │ │ │ ├── geoip_us.yaml │ │ │ │ ├── git_client.json │ │ │ │ ├── git_client.yaml │ │ │ │ ├── import_filesystem.json │ │ │ │ ├── import_filesystem.yaml │ │ │ │ ├── import_keep_internet_working.json │ │ │ │ ├── import_keep_internet_working.yaml │ │ │ │ ├── impressum.yaml │ │ │ │ ├── logging-file.yaml │ │ │ │ ├── logging-stdio.yaml │ │ │ │ ├── no-thresholds.yaml │ │ │ │ ├── old_xesite.json │ │ │ │ ├── opengraph_all_good.yaml │ │ │ │ ├── simple-weight.yaml │ │ │ │ ├── status-codes-paranoid.json │ │ │ │ ├── status-codes-paranoid.yaml │ │ │ │ ├── status-codes-rfc.json │ │ │ │ ├── status-codes-rfc.yaml │ │ │ │ ├── thresholds.yaml │ │ │ │ └── weight-no-weight.yaml │ │ │ ├── hack-test.json │ │ │ └── hack-test.yaml │ │ ├── threshold.go │ │ ├── threshold_test.go │ │ └── weight.go │ ├── config.go │ ├── config_test.go │ ├── http.go │ ├── http_test.go │ ├── localization/ │ │ ├── locales/ │ │ │ ├── cs.json │ │ │ ├── de.json │ │ │ ├── en.json │ │ │ ├── es.json │ │ │ ├── et.json │ │ │ ├── fi.json │ │ │ ├── fil.json │ │ │ ├── fr.json │ │ │ ├── is.json │ │ │ ├── it.json │ │ │ ├── ja.json │ │ │ ├── lt.json │ │ │ ├── manifest.json │ │ │ ├── nb.json │ │ │ ├── nl.json │ │ │ ├── nn.json │ │ │ ├── pl.json │ │ │ ├── pt-BR.json │ │ │ ├── ru.json │ │ │ ├── sv.json │ │ │ ├── th.json │ │ │ ├── tr.json │ │ │ ├── uk.json │ │ │ ├── vi.json │ │ │ ├── zh-CN.json │ │ │ └── zh-TW.json │ │ ├── localization.go │ │ └── localization_test.go │ ├── policy/ │ │ ├── bot.go │ │ ├── celchecker.go │ │ ├── checker/ │ │ │ ├── checker.go │ │ │ └── checker_test.go │ │ ├── checker.go │ │ ├── checker_test.go │ │ ├── checkresult.go │ │ ├── expressions/ │ │ │ ├── README.md │ │ │ ├── environment.go │ │ │ ├── environment_test.go │ │ │ ├── http_headers.go │ │ │ ├── http_headers_test.go │ │ │ ├── loadavg.go │ │ │ ├── url_values.go │ │ │ └── url_values_test.go │ │ ├── policy.go │ │ ├── policy_test.go │ │ ├── testdata/ │ │ │ ├── hack-test.json │ │ │ └── hack-test.yaml │ │ └── thresholds.go │ ├── redirect_security_test.go │ ├── store/ │ │ ├── actorifiedstore.go │ │ ├── all/ │ │ │ └── all.go │ │ ├── bbolt/ │ │ │ ├── bbolt.go │ │ │ ├── bbolt_test.go │ │ │ ├── factory.go │ │ │ └── factory_test.go │ │ ├── interface.go │ │ ├── json_test.go │ │ ├── memory/ │ │ │ ├── memory.go │ │ │ └── memory_test.go │ │ ├── registry.go │ │ ├── s3api/ │ │ │ ├── factory.go │ │ │ ├── s3api.go │ │ │ └── s3api_test.go │ │ ├── storetest/ │ │ │ └── storetest.go │ │ └── valkey/ │ │ ├── factory.go │ │ ├── valkey.go │ │ └── valkey_test.go │ ├── testdata/ │ │ ├── aggressive_403.yaml │ │ ├── cloudflare-workers-cel.yaml │ │ ├── cloudflare-workers-header.yaml │ │ ├── hack-test.json │ │ ├── hack-test.yaml │ │ ├── invalid-challenge-method.yaml │ │ ├── permissive.yaml │ │ ├── rule_change.yaml │ │ ├── test_config.yaml │ │ ├── test_config_no_thresholds.yaml │ │ ├── useragent.yaml │ │ └── zero_difficulty.yaml │ └── thoth/ │ ├── asnchecker.go │ ├── asnchecker_test.go │ ├── auth.go │ ├── cachediptoasn.go │ ├── context.go │ ├── geoipchecker.go │ ├── geoipchecker_test.go │ ├── thoth.go │ ├── thoth_test.go │ └── thothmock/ │ ├── iptoasn.go │ └── withthothmock.go ├── package.json ├── run/ │ ├── anubis.freebsd │ ├── anubis@.service │ ├── default.env │ └── openrc/ │ ├── anubis.confd │ └── anubis.initd ├── test/ │ ├── .gitignore │ ├── anubis_configs/ │ │ └── aggressive_403.yaml │ ├── caddy/ │ │ ├── Caddyfile │ │ ├── Dockerfile │ │ ├── docker-compose.yaml │ │ └── start.sh │ ├── cmd/ │ │ ├── cipra/ │ │ │ ├── internal/ │ │ │ │ ├── containerip.go │ │ │ │ ├── getlanip.go │ │ │ │ └── unbreakdocker.go │ │ │ └── main.go │ │ ├── httpdebug/ │ │ │ └── main.go │ │ ├── relayd/ │ │ │ └── main.go │ │ └── unixhttpd/ │ │ └── main.go │ ├── default-config-macro/ │ │ ├── compare_bots.py │ │ └── test.sh │ ├── docker-registry/ │ │ ├── anubis.yaml │ │ ├── docker-compose.yaml │ │ ├── test.sh │ │ └── var/ │ │ └── .gitignore │ ├── double_slash/ │ │ ├── anubis.yaml │ │ ├── input.txt │ │ ├── test.mjs │ │ ├── test.sh │ │ └── var/ │ │ └── .gitignore │ ├── forced-language/ │ │ ├── anubis.yaml │ │ ├── test.mjs │ │ ├── test.sh │ │ └── var/ │ │ └── .gitignore │ ├── git-clone/ │ │ ├── docker-compose.yaml │ │ ├── test.sh │ │ └── var/ │ │ └── .gitignore │ ├── git-push/ │ │ ├── docker-compose.yaml │ │ ├── test.sh │ │ └── var/ │ │ └── .gitignore │ ├── go.mod │ ├── go.sum │ ├── healthcheck/ │ │ ├── docker-compose.yaml │ │ ├── test.sh │ │ └── var/ │ │ └── .gitignore │ ├── i18n/ │ │ ├── anubis.yaml │ │ ├── test.mjs │ │ ├── test.sh │ │ └── var/ │ │ └── .gitignore │ ├── k8s/ │ │ ├── cert-manager/ │ │ │ └── selfsigned-issuer.yaml │ │ └── deps/ │ │ └── cert-manager.yaml │ ├── lib/ │ │ └── lib.sh │ ├── log-file/ │ │ ├── anubis.yaml │ │ ├── input.txt │ │ ├── test.mjs │ │ ├── test.sh │ │ └── var/ │ │ └── .gitignore │ ├── nginx/ │ │ ├── conf/ │ │ │ └── nginx/ │ │ │ ├── conf-anubis.inc │ │ │ ├── conf.d/ │ │ │ │ ├── server-mimi-techaro-lol.conf │ │ │ │ └── upstream-anubis.conf │ │ │ ├── mime.types │ │ │ └── nginx.conf │ │ └── test.sh │ ├── nginx-external-auth/ │ │ ├── conf.d/ │ │ │ └── default.conf │ │ ├── deployment.yaml │ │ ├── ingress.yaml │ │ ├── kustomization.yaml │ │ ├── service.yaml │ │ └── start.sh │ ├── palemoon/ │ │ ├── README.md │ │ ├── amd64/ │ │ │ ├── docker-compose.yml │ │ │ ├── test.sh │ │ │ └── var/ │ │ │ └── .gitignore │ │ ├── anubis/ │ │ │ └── anubis.yaml │ │ ├── i386/ │ │ │ ├── docker-compose.yml │ │ │ ├── test.sh │ │ │ └── var/ │ │ │ └── .gitignore │ │ └── scripts/ │ │ └── install-cert.sh │ ├── pki/ │ │ └── .gitignore │ ├── robots_txt/ │ │ ├── anubis.yaml │ │ ├── test.mjs │ │ ├── test.sh │ │ └── var/ │ │ └── .gitignore │ ├── shared/ │ │ └── www/ │ │ └── index.html │ ├── ssh-ci/ │ │ ├── Dockerfile │ │ ├── docker-bake.hcl │ │ ├── in-container.sh │ │ └── rigging.sh │ └── unix-socket-xff/ │ ├── start.sh │ └── test.mjs ├── utils/ │ └── cmd/ │ ├── backoff-retry/ │ │ └── main.go │ └── iplist2rule/ │ ├── blocklist.go │ └── main.go ├── var/ │ └── .gitignore ├── web/ │ ├── build.sh │ ├── embed.go │ ├── index.go │ ├── index.templ │ ├── index_templ.go │ ├── index_test.go │ ├── js/ │ │ ├── algorithms/ │ │ │ ├── fast.ts │ │ │ └── index.ts │ │ ├── bench.ts │ │ ├── main.ts │ │ └── worker/ │ │ ├── sha256-purejs.ts │ │ └── sha256-webcrypto.ts │ └── static/ │ ├── img/ │ │ └── ATTRIBUTIONS.txt │ ├── js/ │ │ └── .gitignore │ └── robots.txt ├── xess/ │ ├── .gitignore │ ├── build.sh │ ├── postcss.config.js │ ├── static/ │ │ └── podkova.css │ ├── xess.css │ └── xess.go └── yeetfile.js ================================================ FILE CONTENTS ================================================ ================================================ FILE: .air.toml ================================================ 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 ================================================ FILE: .devcontainer/Dockerfile ================================================ FROM ghcr.io/xe/devcontainer-base/pre/go WORKDIR /app COPY go.mod go.sum package.json package-lock.json ./ RUN apt-get update \ && apt-get -y install zstd brotli redis \ && mkdir -p /home/vscode/.local/share/fish \ && chown -R vscode:vscode /home/vscode/.local/share/fish \ && chown -R vscode:vscode /go CMD ["/usr/bin/sleep", "infinity"] ================================================ FILE: .devcontainer/README.md ================================================ # Anubis Dev Container Anubis offers a [development container](https://containers.dev/) image in order to make it easier to contribute to the project. This image is based on [Xe/devcontainer-base/go](https://github.com/Xe/devcontainer-base/tree/main/src/go), which is based on Debian Bookworm with the following customizations: - [Fish](https://fishshell.com/) as the shell complete with a custom theme - [Go](https://go.dev) at the most recent stable version - [Node.js](https://nodejs.org/en) at the most recent stable version - [Atuin](https://atuin.sh/) to sync shell history between your host OS and the development container - [Docker](https://docker.com) to manage and build Anubis container images from inside the development container - [Ko](https://ko.build/) to build production-ready Anubis container images - [Neovim](https://neovim.io/) for use with Git This development container is tested and known to work with [Visual Studio Code](https://code.visualstudio.com/). If you run into problems with it outside of VS Code, please file an issue and let us know what editor you are using. ================================================ FILE: .devcontainer/devcontainer.json ================================================ // For format details, see https://aka.ms/devcontainer.json. For config options, see the // README at: https://github.com/devcontainers/templates/tree/main/src/debian { "name": "Dev", "dockerComposeFile": ["./docker-compose.yaml"], "service": "workspace", "workspaceFolder": "/workspace/anubis", "postStartCommand": "bash ./.devcontainer/poststart.sh", "features": { "ghcr.io/xe/devcontainer-features/ko:1.1.0": {}, "ghcr.io/devcontainers/features/github-cli:1": {} }, "initializeCommand": "mkdir -p ${localEnv:HOME}${localEnv:USERPROFILE}/.local/share/atuin", "customizations": { "vscode": { "extensions": [ "esbenp.prettier-vscode", "ms-azuretools.vscode-containers", "golang.go", "unifiedjs.vscode-mdx", "a-h.templ", "redhat.vscode-yaml", "streetsidesoftware.code-spell-checker" ], "settings": { "chat.instructionsFilesLocations": { ".github/copilot-instructions.md": true } } } } } ================================================ FILE: .devcontainer/docker-compose.yaml ================================================ services: playwright: image: mcr.microsoft.com/playwright:v1.52.0-noble init: true network_mode: service:workspace command: - /bin/sh - -c - npx -y playwright@1.52.0 run-server --port 9001 --host 0.0.0.0 valkey: image: valkey/valkey:8 pull_policy: always # VS Code workspace service workspace: image: ghcr.io/techarohq/anubis/devcontainer build: context: .. dockerfile: .devcontainer/Dockerfile volumes: - ../:/workspace/anubis:cached environment: VALKEY_URL: redis://valkey:6379/0 #entrypoint: ["/usr/bin/sleep", "infinity"] user: vscode ================================================ FILE: .devcontainer/poststart.sh ================================================ #!/usr/bin/env bash pwd npm ci & go mod download & go install ./utils/cmd/... & wait ================================================ FILE: .gitattributes ================================================ **/*_templ.go linguist-generated=true ================================================ FILE: .github/FUNDING.yml ================================================ patreon: cadey github: xe liberapay: Xe ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.yaml ================================================ name: Bug report description: Create a report to help us improve body: - type: textarea id: description-of-bug attributes: label: Describe the bug description: A clear and concise description of what the bug is. placeholder: I can reliably get an error when... validations: required: true - type: textarea id: steps-to-reproduce attributes: label: Steps to reproduce description: | Steps to reproduce the behavior. placeholder: | 1. Go to the following url... 2. Click on... 3. You get the following error: ... validations: required: true - type: textarea id: expected-behavior attributes: label: Expected behavior description: | A clear and concise description of what you expected to happen. Ideally also describe *why* you expect it to happen. placeholder: Instead of displaying an error, it would... validations: required: true - type: input id: version-os attributes: label: Your operating system and its version. description: Unsure? Visit https://whatsmyos.com/ placeholder: Android 13 validations: required: true - type: input id: version-browser attributes: label: Your browser and its version. description: Unsure? Visit https://www.whatsmybrowser.org/ placeholder: Firefox 142 validations: required: true - type: textarea id: additional-context attributes: label: Additional context description: Add any other context about the problem here. ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ blank_issues_enabled: false contact_links: - name: Security url: https://techaro.lol/contact about: Do not file security reports here. Email security@techaro.lol. ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.yaml ================================================ name: Feature request description: Suggest an idea for this project title: "[Feature request] " body: - type: textarea id: description-of-bug attributes: label: Is your feature request related to a problem? Please describe. description: A clear and concise description of what the problem is that made you submit this report. placeholder: I am always frustrated, when... validations: required: true - type: textarea id: description-of-solution attributes: label: Solution you would like. description: A clear and concise description of what you want to happen. placeholder: Instead of behaving like this, there should be... validations: required: true - type: textarea id: alternatives attributes: label: Describe alternatives you have considered. description: A clear and concise description of any alternative solutions or features you have considered. placeholder: Another workaround that would work, is... validations: required: false - type: textarea id: additional-context attributes: label: Additional context description: Add any other context (such as mock-ups, proof of concepts or screenshots) about the feature request here. validations: required: false ================================================ FILE: .github/PULL_REQUEST_TEMPLATE.md ================================================ 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://github.com/TecharoHQ/anubis/blob/main/CONTRIBUTING.md) - [ ] Ran integration tests `npm run test:integration` (unsupported on Windows, please use WSL) - [ ] All of my commits have [verified signatures](https://anubis.techaro.lol/docs/developer/signed-commits) ================================================ FILE: .github/actions/spelling/README.md ================================================ # check-spelling/check-spelling configuration | File | Purpose | Format | Info | | -------------------------------------------------- | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | | [dictionary.txt](dictionary.txt) | Replacement dictionary (creating this file will override the default dictionary) | one word per line | [dictionary](https://github.com/check-spelling/check-spelling/wiki/Configuration#dictionary) | | [allow.txt](allow.txt) | Add words to the dictionary | one word per line (only letters and `'`s allowed) | [allow](https://github.com/check-spelling/check-spelling/wiki/Configuration#allow) | | [reject.txt](reject.txt) | Remove words from the dictionary (after allow) | grep pattern matching whole dictionary words | [reject](https://github.com/check-spelling/check-spelling/wiki/Configuration-Examples%3A-reject) | | [excludes.txt](excludes.txt) | Files to ignore entirely | perl regular expression | [excludes](https://github.com/check-spelling/check-spelling/wiki/Configuration-Examples%3A-excludes) | | [only.txt](only.txt) | Only check matching files (applied after excludes) | perl regular expression | [only](https://github.com/check-spelling/check-spelling/wiki/Configuration-Examples%3A-only) | | [patterns.txt](patterns.txt) | Patterns to ignore from checked lines | perl regular expression (order matters, first match wins) | [patterns](https://github.com/check-spelling/check-spelling/wiki/Configuration-Examples%3A-patterns) | | [candidate.patterns](candidate.patterns) | Patterns that might be worth adding to [patterns.txt](patterns.txt) | perl regular expression with optional comment block introductions (all matches will be suggested) | [candidates](https://github.com/check-spelling/check-spelling/wiki/Feature:-Suggest-patterns) | | [line_forbidden.patterns](line_forbidden.patterns) | Patterns to flag in checked lines | perl regular expression (order matters, first match wins) | [patterns](https://github.com/check-spelling/check-spelling/wiki/Configuration-Examples%3A-patterns) | | [expect.txt](expect.txt) | Expected words that aren't in the dictionary | one word per line (sorted, alphabetically) | [expect](https://github.com/check-spelling/check-spelling/wiki/Configuration#expect) | | [advice.md](advice.md) | Supplement for GitHub comment when unrecognized words are found | GitHub Markdown | [advice](https://github.com/check-spelling/check-spelling/wiki/Configuration-Examples%3A-advice) | Note: you can replace any of these files with a directory by the same name (minus the suffix) and then include multiple files inside that directory (with that suffix) to merge multiple files together. ================================================ FILE: .github/actions/spelling/advice.md ================================================
If the flagged items are :exploding_head: false positives If items relate to a ... - binary file (or some other file you wouldn't want to check at all). Please add a file path to the `excludes.txt` file matching the containing file. File paths are Perl 5 Regular Expressions - you can [test](https://www.regexplanet.com/advanced/perl/) yours before committing to verify it will match your files. `^` refers to the file's path from the root of the repository, so `^README\.md$` would exclude [README.md](../tree/HEAD/README.md) (on whichever branch you're using). - well-formed pattern. If you can write a [pattern](https://github.com/check-spelling/check-spelling/wiki/Configuration-Examples:-patterns) that would match it, try adding it to the `patterns.txt` file. Patterns are Perl 5 Regular Expressions - you can [test](https://www.regexplanet.com/advanced/perl/) yours before committing to verify it will match your lines. Note that patterns can't match multiline strings.
:steam_locomotive: If you're seeing this message and your PR is from a branch that doesn't have check-spelling, please merge to your PR's base branch to get the version configured for your repository. ================================================ FILE: .github/actions/spelling/allow.txt ================================================ github https ssh ubuntu workarounds rjack msgbox xeact ABee tencent maintnotifications azurediamond cooldown verifyfcrdns Spintax spintax clampip pseudoprofound reimagining iocaine admins fout iplist NArg blocklists rififi prolocation Prolocation Necron Stargate FFXIV uvensys de envoyproxy unipromos ================================================ FILE: .github/actions/spelling/candidate.patterns ================================================ # Repeated letters #\b([a-z])\g{-1}{2,}\b # marker to ignore all code on line ^.*/\* #no-spell-check-line \*/.*$ # marker to ignore all code on line ^.*\bno-spell-check(?:-line|)(?:\s.*|)$ # https://cspell.org/configuration/document-settings/ # cspell inline ^.*\b[Cc][Ss][Pp][Ee][Ll]{2}:\s*[Dd][Ii][Ss][Aa][Bb][Ll][Ee]-[Ll][Ii][Nn][Ee]\b # copyright Copyright (?:\([Cc]\)|)(?:[-\d, ]|and)+(?: [A-Z][a-z]+ [A-Z][a-z]+,?)+ # patch hunk comments ^@@ -\d+(?:,\d+|) \+\d+(?:,\d+|) @@ .* # git index header index (?:[0-9a-z]{7,40},|)[0-9a-z]{7,40}\.\.[0-9a-z]{7,40} # file permissions ['"`\s][-bcdLlpsw](?:[-r][-w][-Ssx]){2}[-r][-w][-SsTtx]\+?['"`\s] # css fonts \bfont(?:-family|):[^;}]+ # css url wrappings \burl\([^)]+\) # cid urls (['"])cid:.*?\g{-1} # data url in parens \(data:(?:[^) ][^)]*?|)(?:[A-Z]{3,}|[A-Z][a-z]{2,}|[a-z]{3,})[^)]*\) # data url in quotes ([`'"])data:(?:[^ `'"].*?|)(?:[A-Z]{3,}|[A-Z][a-z]{2,}|[a-z]{3,}).*\g{-1} # data url \bdata:[-a-zA-Z=;:/0-9+]*,\S* # https/http/file urls (?:\b(?:https?|ftp|file)://)[-A-Za-z0-9+&@#/*%?=~_|!:,.;]+[-A-Za-z0-9+&@#/*%=~_|] # mailto urls mailto:[-a-zA-Z=;:/?%&0-9+@._]{3,} # magnet urls magnet:[?=:\w]+ # magnet urls "magnet:[^"]+" # obs: "obs:[^"]*" # The `\b` here means a break, it's the fancy way to handle urls, but it makes things harder to read # In this examples content, I'm using a number of different ways to match things to show various approaches # asciinema \basciinema\.org/a/[0-9a-zA-Z]+ # asciinema v2 ^\[\d+\.\d+, "[io]", ".*"\]$ # apple \bdeveloper\.apple\.com/[-\w?=/]+ # Apple music \bembed\.music\.apple\.com/fr/playlist/usr-share/[-\w.]+ # appveyor api \bci\.appveyor\.com/api/projects/status/[0-9a-z]+ # appveyor project \bci\.appveyor\.com/project/(?:[^/\s"]*/){2}builds?/\d+/job/[0-9a-z]+ # Amazon # Amazon \bamazon\.com/[-\w]+/(?:dp/[0-9A-Z]+|) # AWS ARN arn:aws:[-/:\w]+ # AWS S3 \b\w*\.s3[^.]*\.amazonaws\.com/[-\w/&#%_?:=]* # AWS execute-api \b[0-9a-z]{10}\.execute-api\.[-0-9a-z]+\.amazonaws\.com\b # AWS ELB \b\w+\.[-0-9a-z]+\.elb\.amazonaws\.com\b # AWS SNS \bsns\.[-0-9a-z]+.amazonaws\.com/[-\w/&#%_?:=]* # AWS VPC vpc-\w+ # While you could try to match `http://` and `https://` by using `s?` in `https?://`, sometimes there # YouTube url \b(?:(?:www\.|)youtube\.com|youtu.be)/(?:channel/|embed/|user/|playlist\?list=|watch\?v=|v/|)[-a-zA-Z0-9?&=_%]* # YouTube music \bmusic\.youtube\.com/youtubei/v1/browse(?:[?&]\w+=[-a-zA-Z0-9?&=_]*) # YouTube tag <\s*youtube\s+id=['"][-a-zA-Z0-9?_]*['"] # YouTube image \bimg\.youtube\.com/vi/[-a-zA-Z0-9?&=_]* # Google Accounts \baccounts.google.com/[-_/?=.:;+%&0-9a-zA-Z]* # Google Analytics \bgoogle-analytics\.com/collect.[-0-9a-zA-Z?%=&_.~]* # Google APIs \bgoogleapis\.(?:com|dev)/[a-z]+/(?:v\d+/|)[a-z]+/[-@:./?=\w+|&]+ # Google Artifact Registry \.pkg\.dev(?:/[-\w]+)+(?::[-\w]+|) # Google Storage \b[-a-zA-Z0-9.]*\bstorage\d*\.googleapis\.com(?:/\S*|) # Google Calendar \bcalendar\.google\.com/calendar(?:/u/\d+|)/embed\?src=[@./?=\w&%]+ \w+\@group\.calendar\.google\.com\b # Google DataStudio \bdatastudio\.google\.com/(?:(?:c/|)u/\d+/|)(?:embed/|)(?:open|reporting|datasources|s)/[-0-9a-zA-Z]+(?:/page/[-0-9a-zA-Z]+|) # The leading `/` here is as opposed to the `\b` above # ... a short way to match `https://` or `http://` since most urls have one of those prefixes # Google Docs /docs\.google\.com/[a-z]+/(?:ccc\?key=\w+|(?:u/\d+|d/(?:e/|)[0-9a-zA-Z_-]+/)?(?:edit\?[-\w=#.]*|/\?[\w=&]*|)) # Google Drive \bdrive\.google\.com/(?:file/d/|open)[-0-9a-zA-Z_?=]* # Google Groups \bgroups\.google\.com(?:/[a-z]+/(?:#!|)[^/\s"]+)* # Google Maps \bmaps\.google\.com/maps\?[\w&;=]* # Google themes themes\.googleusercontent\.com/static/fonts/[^/\s"]+/v\d+/[^.]+. # Google CDN \bclients2\.google(?:usercontent|)\.com[-0-9a-zA-Z/.]* # Goo.gl /goo\.gl/[a-zA-Z0-9]+ # Google Chrome Store \bchrome\.google\.com/webstore/detail/[-\w]*(?:/\w*|) # Google Books \bgoogle\.(?:\w{2,4})/books(?:/\w+)*\?[-\w\d=&#.]* # Google Fonts \bfonts\.(?:googleapis|gstatic)\.com/[-/?=:;+&0-9a-zA-Z]* # Google Forms \bforms\.gle/\w+ # Google Scholar \bscholar\.google\.com/citations\?user=[A-Za-z0-9_]+ # Google Colab Research Drive \bcolab\.research\.google\.com/drive/[-0-9a-zA-Z_?=]* # Google Cloud regions (?:us|(?:north|south)america|europe|asia|australia|me|africa)-(?:north|south|east|west|central){1,2}\d+ # GitHub SHAs (api) \bapi.github\.com/repos(?:/[^/\s"]+){3}/[0-9a-f]+\b # GitHub SHAs (markdown) (?:\[`?[0-9a-f]+`?\]\(https:/|)/(?:www\.|)github\.com(?:/[^/\s"]+){2,}(?:/[^/\s")]+)(?:[0-9a-f]+(?:[-0-9a-zA-Z/#.]*|)\b|) # GitHub SHAs \bgithub\.com(?:/[^/\s"]+){2}[@#][0-9a-f]+\b # GitHub SHA refs \[([0-9a-f]+)\]\(https://(?:www\.|)github.com/[-\w]+/[-\w]+/commit/\g{-1}[0-9a-f]* # GitHub wiki \bgithub\.com/(?:[^/]+/){2}wiki/(?:(?:[^/]+/|)_history|[^/]+(?:/_compare|)/[0-9a-f.]{40,})\b # githubusercontent /[-a-z0-9]+\.githubusercontent\.com/[-a-zA-Z0-9?&=_\/.]* # githubassets \bgithubassets.com/[0-9a-f]+(?:[-/\w.]+) # gist github \bgist\.github\.com/[^/\s"]+/[0-9a-f]+ # git.io \bgit\.io/[0-9a-zA-Z]+ # GitHub JSON "node_id": "[-a-zA-Z=;:/0-9+_]*" # Contributor \[[^\]]+\]\(https://github\.com/[^/\s"]+/?\) # GHSA GHSA(?:-[0-9a-z]{4}){3} # GitHub actions \buses:\s+[-\w.]+/[-\w./]+@[-\w.]+ # GitLab commit \bgitlab\.[^/\s"]*/\S+/\S+/commit/[0-9a-f]{7,16}#[0-9a-f]{40}\b # GitLab merge requests \bgitlab\.[^/\s"]*/\S+/\S+/-/merge_requests/\d+/diffs#[0-9a-f]{40}\b # GitLab uploads \bgitlab\.[^/\s"]*/uploads/[-a-zA-Z=;:/0-9+]* # GitLab commits \bgitlab\.[^/\s"]*/(?:[^/\s"]+/){2}commits?/[0-9a-f]+\b # #includes ^\s*#include\s*(?:<.*?>|".*?") # #pragma lib ^\s*#pragma comment\(lib, ".*?"\) # binance accounts\.binance\.com/[a-z/]*oauth/authorize\?[-0-9a-zA-Z&%]* # bitbucket diff \bapi\.bitbucket\.org/\d+\.\d+/repositories/(?:[^/\s"]+/){2}diff(?:stat|)(?:/[^/\s"]+){2}:[0-9a-f]+ # bitbucket repositories commits \bapi\.bitbucket\.org/\d+\.\d+/repositories/(?:[^/\s"]+/){2}commits?/[0-9a-f]+ # bitbucket commits \bbitbucket\.org/(?:[^/\s"]+/){2}commits?/[0-9a-f]+ # bit.ly \bbit\.ly/\w+ # bitrise \bapp\.bitrise\.io/app/[0-9a-f]*/[\w.?=&]* # bootstrapcdn.com \bbootstrapcdn\.com/[-./\w]+ # cdn.cloudflare.com \bcdnjs\.cloudflare\.com/[./\w]+ # circleci \bcircleci\.com/gh(?:/[^/\s"]+){1,5}.[a-z]+\?[-0-9a-zA-Z=&]+ # gitter \bgitter\.im(?:/[^/\s"]+){2}\?at=[0-9a-f]+ # gravatar \bgravatar\.com/avatar/[0-9a-f]+ # ibm [a-z.]*ibm\.com/[-_#=:%!?~.\\/\d\w]* # imgur \bimgur\.com/[^.]+ # Internet Archive \barchive\.org/web/\d+/(?:[-\w.?,'/\\+&%$#_:]*) # discord /discord(?:app\.com|\.gg)/(?:invite/)?[a-zA-Z0-9]{7,} # Disqus \bdisqus\.com/[-\w/%.()!?&=_]* # medium link \blink\.medium\.com/[a-zA-Z0-9]+ # medium \bmedium\.com/@?[^/\s"]+/[-\w]+ # microsoft \b(?:https?://|)(?:(?:(?:blogs|download\.visualstudio|docs|msdn2?|research)\.|)microsoft|blogs\.msdn)\.co(?:m|\.\w\w)/[-_a-zA-Z0-9()=./%]* # powerbi \bapp\.powerbi\.com/reportEmbed/[^"' ]* # vs devops \bvisualstudio.com(?::443|)/[-\w/?=%&.]* # microsoft store \bmicrosoft\.com/store/apps/\w+ # mvnrepository.com \bmvnrepository\.com/[-0-9a-z./]+ # now.sh /[0-9a-z-.]+\.now\.sh\b # oracle \bdocs\.oracle\.com/[-0-9a-zA-Z./_?#&=]* # chromatic.com /\S+.chromatic.com\S*[")] # codacy \bapi\.codacy\.com/project/badge/Grade/[0-9a-f]+ # compai \bcompai\.pub/v1/png/[0-9a-f]+ # mailgun api \.api\.mailgun\.net/v3/domains/[0-9a-z]+\.mailgun.org/messages/[0-9a-zA-Z=@]* # mailgun \b[0-9a-z]+.mailgun.org # /message-id/ /message-id/[-\w@./%]+ # Reddit \breddit\.com/r/[/\w_]* # requestb.in \brequestb\.in/[0-9a-z]+ # sched \b[a-z0-9]+\.sched\.com\b # Slack url slack://[a-zA-Z0-9?&=]+ # Slack \bslack\.com/[-0-9a-zA-Z/_~?&=.]* # Slack edge \bslack-edge\.com/[-a-zA-Z0-9?&=%./]+ # Slack images \bslack-imgs\.com/[-a-zA-Z0-9?&=%.]+ # shields.io \bshields\.io/[-\w/%?=&.:+;,]* # stackexchange -- https://stackexchange.com/feeds/sites \b(?:askubuntu|serverfault|stack(?:exchange|overflow)|superuser).com/(?:questions/\w+/[-\w]+|a/) # Sentry [0-9a-f]{32}\@o\d+\.ingest\.sentry\.io\b # Twitter markdown \[@[^[/\]:]*?\]\(https://twitter.com/[^/\s"')]*(?:/status/\d+(?:\?[-_0-9a-zA-Z&=]*|)|)\) # Twitter hashtag \btwitter\.com/hashtag/[\w?_=&]* # Twitter status \btwitter\.com/[^/\s"')]*(?:/status/\d+(?:\?[-_0-9a-zA-Z&=]*|)|) # Twitter profile images \btwimg\.com/profile_images/[_\w./]* # Twitter media \btwimg\.com/media/[-_\w./?=]* # Twitter link shortened \bt\.co/\w+ # facebook \bfburl\.com/[0-9a-z_]+ # facebook CDN \bfbcdn\.net/[\w/.,]* # facebook watch \bfb\.watch/[0-9A-Za-z]+ # dropbox \bdropbox\.com/sh?/[^/\s"]+/[-0-9A-Za-z_.%?=&;]+ # ipfs protocol ipfs://[0-9a-zA-Z]{3,} # ipfs url /ipfs/[0-9a-zA-Z]{3,} # w3 \bw3\.org/[-0-9a-zA-Z/#.]+ # loom \bloom\.com/embed/[0-9a-f]+ # regex101 \bregex101\.com/r/[^/\s"]+/\d+ # figma \bfigma\.com/file(?:/[0-9a-zA-Z]+/)+ # freecodecamp.org \bfreecodecamp\.org/[-\w/.]+ # image.tmdb.org \bimage\.tmdb\.org/[/\w.]+ # mermaid \bmermaid\.ink/img/[-\w]+|\bmermaid-js\.github\.io/mermaid-live-editor/#/edit/[-\w]+ # Wikipedia \ben\.wikipedia\.org/wiki/[-\w%.#]+ # gitweb [^"\s]+/gitweb/\S+;h=[0-9a-f]+ # HyperKitty lists /archives/list/[^@/]+@[^/\s"]*/message/[^/\s"]*/ # lists /thread\.html/[^"\s]+ # list-management \blist-manage\.com/subscribe(?:[?&](?:u|id)=[0-9a-f]+)+ # kubectl.kubernetes.io/last-applied-configuration "kubectl.kubernetes.io/last-applied-configuration": ".*" # pgp \bgnupg\.net/pks/lookup[?&=0-9a-zA-Z]* # Spotify \bopen\.spotify\.com/embed/playlist/\w+ # Mastodon \bmastodon\.[-a-z.]*/(?:media/|@)[?&=0-9a-zA-Z_]* # scastie \bscastie\.scala-lang\.org/[^/]+/\w+ # images.unsplash.com \bimages\.unsplash\.com/(?:(?:flagged|reserve)/|)[-\w./%?=%&.;]+ # pastebin \bpastebin\.com/[\w/]+ # heroku \b\w+\.heroku\.com/source/archive/\w+ # quip \b\w+\.quip\.com/\w+(?:(?:#|/issues/)\w+)? # badgen.net \bbadgen\.net/badge/[^")\]'\s]+ # statuspage.io \w+\.statuspage\.io\b # media.giphy.com \bmedia\.giphy\.com/media/[^/]+/[\w.?&=]+ # tinyurl \btinyurl\.com/\w+ # codepen \bcodepen\.io/[\w/]+ # registry.npmjs.org \bregistry\.npmjs\.org/(?:@[^/"']+/|)[^/"']+/-/[-\w@.]+ # getopts \bgetopts\s+(?:"[^"]+"|'[^']+') # ANSI color codes (?:\\(?:u00|x)1[Bb]|\\03[1-7]|\x1b|\\u\{1[Bb]\})\[\d+(?:;\d+)*m # URL escaped characters %[0-9A-F][A-F](?=[A-Za-z]) # lower URL escaped characters %[0-9a-f][a-f](?=[a-z]{2,}) # IPv6 \b(?:[0-9a-fA-F]{0,4}:){3,7}[0-9a-fA-F]{0,4}\b # c99 hex digits (not the full format, just one I've seen) 0x[0-9a-fA-F](?:\.[0-9a-fA-F]*|)[pP] # Punycode \bxn--[-0-9a-z]+ # sha sha\d+:[0-9a-f]*?[a-f]{3,}[0-9a-f]* # sha-... -- uses a fancy capture (\\?['"]|")[0-9a-f]{40,}\g{-1} # hex runs \b[0-9a-fA-F]{16,}\b # hex in url queries =[0-9a-fA-F]*?(?:[A-F]{3,}|[a-f]{3,})[0-9a-fA-F]*?& # ssh (?:ssh-\S+|-nistp256) [-a-zA-Z=;:/0-9+]{12,} # PGP \b(?:[0-9A-F]{4} ){9}[0-9A-F]{4}\b # GPG keys \b(?:[0-9A-F]{4} ){5}(?: [0-9A-F]{4}){5}\b # Well known gpg keys .well-known/openpgpkey/[\w./]+ # pki -----BEGIN.*-----END # pki (base64) LS0tLS1CRUdJT.* # C# includes ^\s*using [^;]+; # uuid: \b[0-9a-fA-F]{8}-(?:[0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}\b # hex digits including css/html color classes: (?:[\\0][xX]|\\u|[uU]\+|#x?|%23|&H)[0-9_a-fA-FgGrR]*?[a-fA-FgGrR]{2,}[0-9_a-fA-FgGrR]*(?:[uUlL]{0,3}|[iu]\d+)\b # integrity integrity=(['"])(?:\s*sha\d+-[-a-zA-Z=;:/0-9+]{40,})+\g{-1} # https://www.gnu.org/software/groff/manual/groff.html # man troff content \\f[BCIPR] # '/" \\\([ad]q # .desktop mime types ^MimeTypes?=.*$ # .desktop localized entries ^[A-Z][a-z]+\[[a-z]+\]=.*$ # Localized .desktop content Name\[[^\]]+\]=.* # IServiceProvider / isAThing (?:(?:\b|_|(?<=[a-z]))I|(?:\b|_)(?:nsI|isA))(?=(?:[A-Z][a-z]{2,})+(?:[A-Z\d]|\b)) # crypt (['"])\$2[ayb]\$.{56}\g{-1} # apache/old crypt (['"]|)\$+(?:apr|)1\$+.{8}\$+.{22}\g{-1} # sha1 hash \{SHA\}[-a-zA-Z=;:/0-9+]{3,} # machine learning (?) \b(?i)ml(?=[a-z]{2,}) # python #\b(?i)py(?!gments|gmy|lon|ramid|ro|th)(?=[a-z]{2,}) # scrypt / argon \$(?:scrypt|argon\d+[di]*)\$\S+ # go.sum \bh1:\S+ # imports ^import\s+(?:(?:static|type)\s+|)(?:[\w.]|\{\s*\w*?(?:,\s*(?:\w*|\*))+\s*\})+ # scala modules ("[^"]+"\s*%%?\s*){2,3}"[^"]+" # container images image: [-\w./:@]+ # Docker images ^\s*(?i)FROM\s+\S+:\S+(?:\s+AS\s+\S+|) # `docker images` REPOSITORY TAG IMAGE ID CREATED SIZE \s*\S+/\S+\s+\S+\s+[0-9a-f]{8,}\s+\d+\s+(?:hour|day|week)s ago\s+[\d.]+[KMGT]B # Intel intrinsics _mm_(?!dd)\w+ # Input to GitHub JSON content: (['"])[-a-zA-Z=;:/0-9+]*=\g{-1} # This does not cover multiline strings, if your repository has them, # you'll want to remove the `(?=.*?")` suffix. # The `(?=.*?")` suffix should limit the false positives rate # printf %(?:(?:(?:hh?|ll?|[jzt])?[diuoxn]|l?[cs]|L?[fega]|p)(?=[a-z]{2,})|(?:X|L?[FEGA])(?=[a-zA-Z]{2,}))(?!%)(?=[_a-zA-Z]+(?!%)\b)(?=.*?['"]) # Alternative printf # %s %(?:s(?=[a-z]{2,}))(?!%)(?=[_a-zA-Z]+(?!%[^s])\b)(?=.*?['"]) # Python string prefix / binary prefix # Note that there's a high false positive rate, remove the `?=` and search for the regex to see if the matches seem like reasonable strings (?|m([|!/@#,;']).*?\g{-1}) # perl qr regex (?|\(.*?\)|([|!/@#,;']).*?\g{-1}) # perl run perl(?:\s+-[a-zA-Z]\w*)+ # C network byte conversions (?:\d|\bh)to(?!ken)(?=[a-z])|to(?=[adhiklpun]\() # Go regular expressions regexp?\.MustCompile\((?:`[^`]*`|".*"|'.*')\) # regex choice \(\?:[^)]+\|[^)]+\) # proto ^\s*(\w+)\s\g{-1} = # sed regular expressions sed 's/(?:[^/]*?[a-zA-Z]{3,}[^/]*?/){2} # node packages (["'])@[^/'" ]+/[^/'" ]+\g{-1} # go install go install(?:\s+[a-z]+\.[-@\w/.]+)+ # pom.xml <(?:group|artifact)Id>.*?< # jetbrains schema https://youtrack.jetbrains.com/issue/RSRP-489571 urn:shemas-jetbrains-com # Debian changelog severity [-\w]+ \(.*\) (?:\w+|baseline|unstable|experimental); urgency=(?:low|medium|high|emergency|critical)\b # kubernetes pod status lists # https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle/#pod-phase \w+(?:-\w+)+\s+\d+/\d+\s+(?:Running|Pending|Succeeded|Failed|Unknown)\s+ # kubectl - pods in CrashLoopBackOff \w+-[0-9a-f]+-\w+\s+\d+/\d+\s+CrashLoopBackOff\s+ # kubernetes applications \.apps/[-\w]+ # kubernetes object suffix -[0-9a-f]{10}-\w{5}\s # kubernetes crd patterns ^\s*pattern: .*$ # posthog secrets ([`'"])phc_[^"',]+\g{-1} # xcode # xcodeproject scenes (?:Controller|destination|(?:first|second)Item|ID|id)="\w{3}-\w{2}-\w{3}" # xcode api botches customObjectInstantitationMethod # msvc api botches PrependWithABINamepsace # configure flags .* \| --\w{2,}.*?(?=\w+\s\w+) # font awesome classes \.fa-[-a-z0-9]+ # bearer auth (['"])[Bb]ear[e][r] .{3,}?\g{-1} # bearer auth \b[Bb]ear[e][r]:? [-a-zA-Z=;:/0-9+.]{3,} # basic auth (['"])[Bb]asic [-a-zA-Z=;:/0-9+]{3,}\g{-1} # basic auth : [Bb]asic [-a-zA-Z=;:/0-9+.]{3,} # base64 encoded content ([`'"])[-a-zA-Z=;:/0-9+]{3,}=\g{-1} # base64 encoded content in xml/sgml >[-a-zA-Z=;:/0-9+]{3,}=?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_]{40,} # DNS rr data (?:\d+\s+){3}(?:[-+/=.\w]{2,}\s*){1,2} # encoded-word =\?[-a-zA-Z0-9"*%]+\?[BQ]\?[^?]{0,75}\?= # numerator \bnumer\b(?=.*denom) # Time Zones \b(?:Africa|Atlantic|America|Antarctica|Arctic|Asia|Australia|Europe|Indian|Pacific)(?:/[-\w]+)+ # linux kernel info ^(?:bugs|flags|Features)\s+:.* # systemd mode systemd.*?running in system mode \([-+].*\)$ # Lorem # Update Lorem based on your content (requires `ge` and `w` from https://github.com/jsoref/spelling; and `review` from https://github.com/check-spelling/check-spelling/wiki/Looking-for-items-locally ) # grep '^[^#].*lorem' .github/actions/spelling/patterns.txt|perl -pne 's/.*i..\?://;s/\).*//' |tr '|' "\n"|sort -f |xargs -n1 ge|perl -pne 's/^[^:]*://'|sort -u|w|sed -e 's/ .*//'|w|review - # Warning, while `(?i)` is very neat and fancy, if you have some binary files that aren't proper unicode, you might run into: # ... Operation "substitution (s///)" returns its argument for non-Unicode code point 0x1C19AE (the code point will vary). # ... You could manually change `(?i)X...` to use `[Xx]...` # ... or you could add the files to your `excludes` file (a version after 0.0.19 should identify the file path) (?:(?:\w|\s|[,.])*\b(?i)(?:amet|consectetur|cursus|dolor|eros|ipsum|lacus|libero|ligula|lorem|magna|neque|nulla|suscipit|tempus)\b(?:\w|\s|[,.])*) # Non-English # Even repositories expecting pure English content can unintentionally have Non-English content... People will occasionally mistakenly enter [homoglyphs](https://en.wikipedia.org/wiki/Homoglyph) which are essentially typos, and using this pattern will mean check-spelling will not complain about them. # # If the content to be checked should be written in English and the only Non-English items will be people's names, then you can consider adding this. # # Alternatively, if you're using check-spelling v0.0.25+, and you would like to _check_ the Non-English content for spelling errors, you can. For information on how to do so, see: # https://docs.check-spelling.dev/Feature:-Configurable-word-characters.html#unicode [a-zA-Z]*[ÀÁÂÃÄÅÆČÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝßàáâãäåæčçèéêëìíîïðñòóôõöøùúûüýÿĀāŁłŃńŅņŒœŚśŠšŜŝŸŽžź][a-zA-Z]{3}[a-zA-ZÀÁÂÃÄÅÆČÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝßàáâãäåæčçèéêëìíîïðñòóôõöøùúûüýÿĀāŁłŃńŅņŒœŚśŠšŜŝŸŽžź]*|[a-zA-Z]{3,}[ÀÁÂÃÄÅÆČÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝßàáâãäåæčçèéêëìíîïðñòóôõöøùúûüýÿĀāŁłŃńŅņŒœŚśŠšŜŝŸŽžź]|[ÀÁÂÃÄÅÆČÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝßàáâãäåæčçèéêëìíîïðñòóôõöøùúûüýÿĀāŁłŃńŅņŒœŚśŠšŜŝŸŽžź][a-zA-Z]{3,} # highlighted letters \[[A-Z]\][a-z]+ # French # This corpus only had capital letters, but you probably want lowercase ones as well. \b[LN]'+[a-z]{2,}\b # latex (check-spelling >= 0.0.22) \\\w{2,}\{ # American Mathematical Society (AMS) / Doxygen TeX/AMS # File extensions \*\.[+\w]+, # eslint "varsIgnorePattern": ".+" # nolint nolint:\s*[\w,]+ # Windows short paths [/\\][^/\\]{5,6}~\d{1,2}(?=[/\\]) # Windows Resources with accelerators \b[A-Z]&[a-z]+\b(?!;) # signed off by (?i)Signed-off-by: .* # cygwin paths /cygdrive/[a-zA-Z]/(?:Program Files(?: \(.*?\)| ?)(?:/[-+.~\\/()\w ]+)*|[-+.~\\/()\w])+ # in check-spelling@v0.0.22+, printf markers aren't automatically consumed # printf markers (?v# (?:(?<=[A-Z]{2})V|(?<=[a-z]{2}|[A-Z]{2})v)\d+(?:\b|(?=[a-zA-Z_])) # Compiler flags (Unix, Java/Scala) # Use if you have things like `-Pdocker` and want to treat them as `docker` #(?:^|[\t ,>"'`=(#])-(?:(?:J-|)[DPWXY]|[Llf])(?=[A-Z]{2,}|[A-Z][a-z]|[a-z]{2,}) # Compiler flags (Windows / PowerShell) # This is a subset of the more general compiler flags pattern. # It avoids matching `-Path` to prevent it from being treated as `ath` #(?:^|[\t ,"'`=(#])-(?:[DPL](?=[A-Z]{2,})|[WXYlf](?=[A-Z]{2,}|[A-Z][a-z]|[a-z]{2,})) # Compiler flags (linker) ,-B # libraries (?:\b|_)[Ll]ib(?:re(?=office)|)(?!era[lt]|ero|erty|rar(?:i(?:an|es)|y))(?=[a-z]) # WWNN/WWPN (NAA identifiers) \b(?:0x)?10[0-9a-f]{14}\b|\b(?:0x|3)?[25][0-9a-f]{15}\b|\b(?:0x|3)?6[0-9a-f]{31}\b # iSCSI iqn (approximate regex) \biqn\.[0-9]{4}-[0-9]{2}(?:[\.-][a-z][a-z0-9]*)*\b # curl arguments \b(?:\\n|)curl(?:\.exe|)(?:\s+-[a-zA-Z]{1,2}\b)*(?:\s+-[a-zA-Z]{3,})(?:\s+-[a-zA-Z]+)* # set arguments \b(?:bash|sh|set)(?:\s+[-+][abefimouxE]{1,2})*\s+[-+][abefimouxE]{3,}(?:\s+[-+][abefimouxE]+)* # tar arguments \b(?:\\n|)g?tar(?:\.exe|)(?:(?:\s+--[-a-zA-Z]+|\s+-[a-zA-Z]+|\s[ABGJMOPRSUWZacdfh-pr-xz]+\b)(?:=[^ ]*|))+ # tput arguments -- https://man7.org/linux/man-pages/man5/terminfo.5.html -- technically they can be more than 5 chars long... \btput\s+(?:(?:-[SV]|-T\s*\w+)\s+)*\w{3,5}\b # macOS temp folders /var/folders/\w\w/[+\w]+/(?:T|-Caches-)/ # github runner temp folders /home/runner/work/_temp/[-_/a-z0-9]+ ================================================ FILE: .github/actions/spelling/excludes.txt ================================================ # See https://github.com/check-spelling/check-spelling/wiki/Configuration-Examples:-excludes (?:^|/)(?i)COPYRIGHT (?:^|/)(?i)LICEN[CS]E (?:^|/)(?i)third[-_]?party/ (?:^|/)3rdparty/ (?:^|/)generated/ (?:^|/)go\.sum$ (?:^|/)package(?:-lock|)\.json$ (?:^|/)Pipfile$ (?:^|/)pyproject.toml (?:^|/)vendor/ (?:^|/|\b)requirements(?:-dev|-doc|-test|)\.txt$ \.a$ \.ai$ \.all-contributorsrc$ \.avi$ \.bmp$ \.bz2$ \.cert?$|\.crt$ \.class$ \.coveragerc$ \.crl$ \.csr$ \.dll$ \.docx?$ \.drawio$ \.DS_Store$ \.eot$ \.eps$ \.exe$ \.gif$ \.git-blame-ignore-revs$ \.gitattributes$ \.gitkeep$ \.graffle$ \.gz$ \.icns$ \.ico$ \.ipynb$ \.jar$ \.jks$ \.jpe?g$ \.key$ \.lib$ \.lock$ \.map$ \.min\.. \.mo$ \.mod$ \.mp[34]$ \.o$ \.ocf$ \.otf$ \.p12$ \.parquet$ \.pdf$ \.pem$ \.pfx$ \.png$ \.psd$ \.pyc$ \.pylintrc$ \.qm$ \.s$ \.sig$ \.so$ \.svgz?$ \.sys$ \.tar$ \.tgz$ \.tiff?$ \.ttf$ \.wav$ \.webm$ \.webp$ \.woff2?$ \.xcf$ \.xlsx?$ \.xpm$ \.xz$ \.zip$ ^\.github/actions/spelling/ ^\Q.github/FUNDING.yml\E$ ^\Q.github/workflows/spelling.yml\E$ ^data/crawlers/ ^docs/blog/tags\.yml$ ^docs/docs/user/known-instances.md$ ^docs/manifest/.*$ ^docs/static/\.nojekyll$ ^internal/glob/glob_test.go$ ^internal/honeypot/naive/affirmations\.txt$ ^internal/honeypot/naive/spintext\.txt$ ^internal/honeypot/naive/titles\.txt$ ^lib/config/testdata/bad/unparseable\.json$ ^lib/localization/.*_test.go$ ^lib/localization/locales/.*\.json$ ^lib/policy/config/testdata/bad/unparseable\.json$ ^test/.*$ ignore$ robots.txt ================================================ FILE: .github/actions/spelling/expect.txt ================================================ acs Actorified actorifiedstore actorify agentic Aibrew alibaba alrest amazonbot anexia anthro anubis anubistest apnic APNICRANDNETAU Applebot archlinux arpa asnc asnchecker asns aspirational atuin azuretools badregexes bbolt bdba berr bezier bingbot Bitcoin bitrate Bluesky blueskybot boi Bokm botnet botstopper BPort Brightbot broked buildah byteslice Bytespider cachebuster cachediptoasn Caddyfile caninetools Cardyb celchecker celphase cerr certresolver cespare CGNAT cgr chainguard chall challengemozilla challengetest checkpath checkresult chibi cidranger ckie CLAUDE cloudflare cloudsolutions Codespaces confd containerbuild containerregistry coreutils Cotoyogi Cromite crt Cscript daemonizing databento dayjob dco DDOS Debian debrpm decaymap devcontainers Diffbot discordapp discordbot distros dnf dnsbl dnserr DNSTTL domainhere dracula dronebl droneblresponse dropin dsilence duckduckbot eerror ellenjoe emacs enbyware etld everyones evilbot evilsite expressionorlist externalagent externalfetcher extldflags facebookgo Factset fahedouch fastcgi FCr fcrdns fediverse ffprobe FFXIV fhdr financials finfos Firecrawl flagenv Fordola forgejo forwardauth fsys fullchain gaissmai Galvus geoip geoipchecker gha GHSA Ghz gipc gitea GLM godotenv goimports goland gomod goodbot googlebot gopsutil govulncheck goyaml GPG GPT gptbot Graphene grpcprom grw gzw Hashcash hashrate hdr headermap healthcheck healthz hec helpdesk Hetzner hmc homelab hostable HSTS htmlc htmx httpdebug huawei hypertext iaskspider iaso iat ifm Imagesift imgproxy impressum inbox ingressed inp internets IPTo iptoasn isp iss isset ivh Jenomis JGit jhjj joho journalctl jshelter JWTs kagi kagibot Keyfunc keypair KHTML kinda KUBECONFIG lcj ldflags letsencrypt Lexentale lfc lgbt licend licstart lightpanda limsa Linting listor LLU loadbalancer lol lominsa maintainership malware mcr memes metarefresh metrix mimi Minfilia mistralai mnt Mojeek mojeekbot mozilla myclient mymaster mypass myuser nbf Necron nepeat netsurf nginx nicksnyder nikandfor nobots NONINFRINGEMENT nosleep nullglob oci OCOB ogtag oklch omgili omgilibot openai opendns opengraph openrc oswald pag pagegen palemoon Pangu parseable passthrough Patreon perplexitybot pgrep phrik pidfile pids pipefail pki podkova podman Postgre poststart prebaked privkey promauto promhttp proofofwork publicsuffix purejs pwcmd pwuser qualys qwant qwantbot rac rawler rcvar redhat redir redirectscheme refactors remoteip reputational Rhul risc ruleset runlevels RUnlock runtimedir runtimedirectory Ryzen sas sasl screenshots searchbot searx sebest secretplans Semrush Seo setsebool shellcheck shirou shoneypot shopt Sidetrade simprint sitemap sls sni snipster Spambot spammer sparkline spyderbot srcip srv stackoverflow Stargate startprecmd stoppostcmd storetest strcmp subgrid subr subrequest SVCNAME tagline tarballs tarrif taviso tbn tbr techaro techarohq telegrambot templ templruntime testarea Thancred thoth thothmock Tik Timpibot TLog traefik trunc txn uberspace Unbreak unbreakdocker unifiedjs unmarshal unparseable updown uvx UXP valkey Varis Velen vendored vhosts vkbot VKE vnd VPS Vultr WAIFU weblate webmaster webpage websecure websites Webzio whois wildbase withthothmock wolfbeast wordpress workaround workdir wpbot XCircle xeiaso xeserv xesite xess xff XForwarded XNG XOB XOriginal XReal Y'shtola yae YAMLTo Yda yeet yeetfile yourdomain yyz Zenos zizmor zombocom zos zst ================================================ FILE: .github/actions/spelling/line_forbidden.patterns ================================================ # reject `m_data` as VxWorks defined it and that breaks things if it's used elsewhere # see [fprime](https://github.com/nasa/fprime/commit/d589f0a25c59ea9a800d851ea84c2f5df02fb529) # and [Qt](https://github.com/qtproject/qt-solutions/blame/fb7bc42bfcc578ff3fa3b9ca21a41e96eb37c1c7/qtscriptclassic/src/qscriptbuffer_p.h#L46) #\bm_data\b # Were you debugging using a framework with `fit()`? # If you have a framework that uses `it()` for testing and `fit()` for debugging a specific test, # you might not want to check in code where you skip all the other tests. #\bfit\( # English does not use a hyphen between adverbs and nouns # https://twitter.com/nyttypos/status/1894815686192685239 (?:^|\s)[A-Z]?[a-z]+ly-(?=[a-z]{3,})(?:[.,?!]?\s|$) # Don't use `requires that` + `to be` # https://twitter.com/nyttypos/status/1894816551435641027 \brequires that \w+\b[^.]+to be\b # A fully parenthetical sentence’s period goes inside the parentheses, not outside. # https://twitter.com/nyttypos/status/1898844061873639490 #\([A-Z][a-z]{2,}(?: [a-z]+){3,}\)\.\s # Complete sentences in parentheticals should not have a space before the period. \s\.\)(?!.*\}\}) # Should be `HH:MM:SS` \bHH:SS:MM\b # Should be `86400` (seconds in a standard day) \b84600\b(?:.*\bday\b) # Should probably be `2006-01-02` (yyyy-mm-dd) # Assuming that the time is being passed to https://go.dev/src/time/format.go \b2006-02-01\b # Should probably be `YYYYMMDD` \b[Yy]{4}[Dd]{2}[Mm]{2}(?!.*[Yy]{4}[Dd]{2}[Mm]{2}).*$ # Should be `a priori` or `and prior` (?i)(? Don't use `can not` when you mean `cannot`. The only time you're likely to see `can not` written as separate words is when the word `can` happens to precede some other phrase that happens to start with `not`. # > `Can't` is a contraction of `cannot`, and it's best suited for informal writing. # > In formal writing and where contractions are frowned upon, use `cannot`. # > It is possible to write `can not`, but you generally find it only as part of some other construction, such as `not only . . . but also.` # - if you encounter such a case, add a pattern for that case to patterns.txt. \b[Cc]an not\b(?! only\b) # Should be `chart` (?i)\bhelm\b.*\bchard\b # Do not use `(click) here` links # For more information, see: # * https://www.w3.org/QA/Tips/noClickHere # * https://webaim.org/techniques/hypertext/link_text # * https://granicus.com/blog/why-click-here-links-are-bad/ # * https://heyoka.medium.com/dont-use-click-here-f32f445d1021 (?i)(?:>|\[)(?:(?:click |)here|link|(?:read |)more)(?:> /etc/apt/sources.list.d/something-distro.list # ```` \bapt-key add\b # Should be `nearby` \bnear by\b # Should probably be a person named `Nick` or the abbreviation `NIC` \bNic\b # Should be `not supposed` \bsupposed not\b # Should probably be `much more` \bmore much\b # Should be `perform its` \bperform it's\b # Should be `opt-in` (? below for the` (?i)\bfind below the\b # Should be `then any` unless there's a comparison before the `,` , than any\b # Should be `did not exist` \bwere not existent\b # Should be `nonexistent` \bnon existing\b # Should be `nonexistent` \b[Nn]o[nt][- ]existent\b # Should be `our` \bspending out time\b # Should be `@brief` / `@details` / `@param` / `@return` / `@retval` (?:^\s*|(?:\*|//|/*)\s+`)[\\@](?:breif|(?:detail|detials)|(?:params(?!\.)|prama?)|ret(?:uns?)|retvl)\b # Should be `more than` or `more, then` \bmore then\b # Should be `Pipeline`/`pipeline` (?:(?<=\b|[A-Z])p|P)ipeLine(?:\b|(?=[A-Z])) # Should be `preexisting` [Pp]re[- ]existing # Should be `preempt` [Pp]re[- ]empt\b # Should be `preemptively` [Pp]re[- ]emptively # Should be `prepopulate` [Pp]re[- ]populate # Should be `prerequisite` [Pp]re[- ]requisite # Should be `recently changed` or `recent changes` [Rr]ecent changed # Should be `reentrancy` [Rr]e[- ]entrancy # Should be `reentrant` [Rr]e[- ]entrant # Should be `room for` \brooms for (?!lease|rent|sale) # Should be `socioeconomic` # https://dictionary.cambridge.org/us/dictionary/english/socioeconomic socio-economic # Should be `strong suit` \b(?:my|his|her|their) strong suite\b # Should probably be `temperatures` unless actually talking about thermal drafts (things birds may fly on) \bthermals\b # Should be `there are` or `they are` (or `they're`) (?i)\btheir are\b # Should be `understand` \bunder stand\b # Should be `URI` or `uri` unless it refers to a person named `Uri` (or a flag) (?v# (?:(?<=[A-Z]{2})V|(?<=[a-z]{2}|[A-Z]{2})v)\d+(?:\b|(?=[a-zA-Z_])) # hit-count: 15 file-count: 7 # container images image: [-\w./:@]+ # hit-count: 14 file-count: 9 # imports ^import\s+(?:(?:static|type)\s+|)(?:[\w.]|\{\s*\w*?(?:,\s*(?:\w*|\*))+\s*\})+ # hit-count: 11 file-count: 2 # hex digits including css/html color classes: (?:[\\0][xX]|\\u|[uU]\+|#x?|%23|&H)[0-9_a-fA-FgGrR]*?[a-fA-FgGrR]{2,}[0-9_a-fA-FgGrR]*(?:[uUlL]{0,3}|[iu]\d+)\b # hit-count: 8 file-count: 5 # node packages (["'])@[^/'" ]+/[^/'" ]+\g{-1} # hit-count: 5 file-count: 2 # css fonts \bfont(?:-family|):[^;}]+ # hit-count: 4 file-count: 4 # set arguments \b(?:bash|sh|set)(?:\s+[-+][abefimouxE]{1,2})*\s+[-+][abefimouxE]{3,}(?:\s+[-+][abefimouxE]+)* # hit-count: 4 file-count: 2 # css url wrappings \burl\([^)]+\) # hit-count: 2 file-count: 2 # C network byte conversions (?:\d|\bh)to(?!ken)(?=[a-z])|to(?=[adhiklpun]\() # hit-count: 2 file-count: 1 # GitHub SHA refs \[([0-9a-f]+)\]\(https://(?:www\.|)github.com/[-\w]+/[-\w]+/commit/\g{-1}[0-9a-f]* # hit-count: 1 file-count: 1 # copyright Copyright (?:\([Cc]\)|)(?:[-\d, ]|and)+(?: [A-Z][a-z]+ [A-Z][a-z]+,?)+ # hit-count: 1 file-count: 1 # IPv6 \b(?:[0-9a-fA-F]{0,4}:){3,7}[0-9a-fA-F]{0,4}\b # hit-count: 1 file-count: 1 # Docker images ^\s*(?i)FROM\s+\S+:\S+(?:\s+AS\s+\S+|) # hit-count: 1 file-count: 1 # perl run perl(?:\s+-[a-zA-Z]\w*)+ # hit-count: 1 file-count: 1 # go install go install(?:\s+[a-z]+\.[-@\w/.]+)+ # hit-count: 1 file-count: 1 # in check-spelling@v0.0.22+, printf markers aren't automatically consumed # printf markers (?]*>|[^<]*)\s*$ # Autogenerated revert commit message ^This reverts commit [0-9a-f]{40}\.$ # ignore long runs of a single character: \b([A-Za-z])\g{-1}{3,}\b # hit-count: 1 file-count: 1 # microsoft \b(?:https?://|)(?:(?:(?:blogs|download\.visualstudio|docs|msdn2?|research)\.|)microsoft|blogs\.msdn)\.co(?:m|\.\w\w)/[-_a-zA-Z0-9()=./%]* # hit-count: 1 file-count: 1 # data url \bdata:[-a-zA-Z=;:/0-9+]*,\S* ================================================ FILE: .github/actions/spelling/reject.txt ================================================ ^attache$ ^bellows?$ benefitting occurences? ^dependan.* ^develope$ ^developement$ ^developpe ^Devers?$ ^devex ^devide ^Devinn?[ae] ^devisal ^devisor ^diables?$ ^oer$ Sorce ^[Ss]pae.* ^Teh$ ^untill$ ^untilling$ ^venders?$ ^wether.* ================================================ FILE: .github/dependabot.yml ================================================ version: 2 updates: - package-ecosystem: github-actions directory: / schedule: interval: weekly groups: github-actions: patterns: - "*" cooldown: default-days: 7 - package-ecosystem: gomod directory: / schedule: interval: weekly groups: gomod: patterns: - "*" cooldown: default-days: 7 - package-ecosystem: npm directory: / schedule: interval: weekly groups: npm: patterns: - "*" cooldown: default-days: 7 ================================================ FILE: .github/workflows/asset-verification.yml ================================================ name: Asset Build Verification on: push: branches: ["main"] pull_request: branches: ["main"] permissions: contents: read jobs: asset_verification: runs-on: ubuntu-24.04 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - name: build essential run: | sudo apt-get update sudo apt-get install -y build-essential - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version: "24.11.0" - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 with: go-version: "1.25.7" - name: install node deps run: | npm ci - name: Check for uncommitted changes before asset build id: check-changes-before run: | if [[ -n $(git status --porcelain) ]]; then echo "has_changes=true" >> $GITHUB_OUTPUT else echo "has_changes=false" >> $GITHUB_OUTPUT fi - name: Fail if there are uncommitted changes before build if: steps.check-changes-before.outputs.has_changes == 'true' run: | echo "There are uncommitted changes before running npm run assets" git status exit 1 - name: Run asset build run: | npm run assets - name: Check for uncommitted changes after asset build id: check-changes-after run: | if [[ -n $(git status --porcelain) ]]; then echo "has_changes=true" >> $GITHUB_OUTPUT else echo "has_changes=false" >> $GITHUB_OUTPUT fi - name: Fail if assets generated changes if: steps.check-changes-after.outputs.has_changes == 'true' run: | echo "npm run assets generated uncommitted changes. This indicates the repository has outdated generated files." echo "Please run 'npm run assets' locally and commit the changes." git status git diff exit 1 ================================================ FILE: .github/workflows/dco-check.yaml ================================================ name: DCO Check on: [pull_request] jobs: dco_check: runs-on: ubuntu-latest steps: - uses: tisonkun/actions-dco@f1024cd563550b5632e754df11b7d30b73be54a5 # v1.1 ================================================ FILE: .github/workflows/docker-pr.yml ================================================ 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-24.04 steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-tags: true fetch-depth: 0 persist-credentials: false - name: build essential run: | sudo apt-get update sudo apt-get install -y build-essential - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version: "24.11.0" - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 with: go-version: "stable" - uses: ko-build/setup-ko@d006021bd0c28d1ce33a07e7943d48b079944c8d # v0.9 - name: Docker meta id: meta uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0 with: images: ghcr.io/${{ github.repository }} - name: Build and push id: build run: | npm ci npm run container env: PULL_REQUEST_ID: ${{ github.event.number }} DOCKER_REPO: ghcr.io/${{ github.repository }} SLOG_LEVEL: debug - run: | echo "Test this with:" echo "docker pull ${DOCKER_IMAGE}" env: DOCKER_IMAGE: ${{ steps.build.outputs.docker_image }} ================================================ FILE: .github/workflows/docker.yml ================================================ name: Docker image builds on: workflow_dispatch: push: branches: ["main"] tags: ["v*"] env: DOCKER_METADATA_SET_OUTPUT_ENV: "true" permissions: contents: read packages: write attestations: write id-token: write pull-requests: write jobs: build: runs-on: ubuntu-24.04 steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-tags: true fetch-depth: 0 persist-credentials: false - name: build essential run: | sudo apt-get update sudo apt-get install -y build-essential - name: Set lowercase image name run: | echo "IMAGE=ghcr.io/${GITHUB_REPOSITORY,,}" >> $GITHUB_ENV - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version: "24.11.0" - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 with: go-version: "stable" - uses: ko-build/setup-ko@d006021bd0c28d1ce33a07e7943d48b079944c8d # v0.9 - name: Log into registry uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - name: Docker meta id: meta uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0 with: images: ${{ env.IMAGE }} - name: Build and push id: build run: | npm ci npm run container env: DOCKER_REPO: ${{ env.IMAGE }} SLOG_LEVEL: debug - name: Generate artifact attestation uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 with: subject-name: ${{ env.IMAGE }} subject-digest: ${{ steps.build.outputs.digest }} push-to-registry: true ================================================ FILE: .github/workflows/docs-deploy.yml ================================================ name: Docs deploy on: workflow_dispatch: push: branches: ["main"] permissions: contents: read packages: write attestations: write id-token: write jobs: build: if: github.repository == 'TecharoHQ/anubis' runs-on: ubuntu-24.04 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - name: Set up Docker Buildx uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - name: Log into registry uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 with: registry: ghcr.io username: techarohq password: ${{ secrets.GITHUB_TOKEN }} - name: Docker meta id: meta uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0 with: images: ghcr.io/techarohq/anubis/docs tags: | type=sha,enable=true,priority=100,prefix=,suffix=,format=long main - name: Build and push id: build uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0 with: context: ./docs cache-to: type=gha cache-from: type=gha tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} platforms: linux/amd64 push: true - name: Apply k8s manifests to limsa lominsa uses: actions-hub/kubectl@5ada4e2c02eacc03978c2437e95c8b0f979a9619 # v1.35.2 env: KUBE_CONFIG: ${{ secrets.LIMSA_LOMINSA_KUBECONFIG }} with: args: apply -k docs/manifest - name: Apply k8s manifests to limsa lominsa uses: actions-hub/kubectl@5ada4e2c02eacc03978c2437e95c8b0f979a9619 # v1.35.2 env: KUBE_CONFIG: ${{ secrets.LIMSA_LOMINSA_KUBECONFIG }} with: args: rollout restart -n default deploy/anubis-docs ================================================ FILE: .github/workflows/docs-test.yml ================================================ name: Docs test build on: pull_request: branches: ["main"] permissions: contents: read actions: write jobs: build: runs-on: ubuntu-24.04 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - name: Set up Docker Buildx uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - name: Docker meta id: meta uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0 with: images: ghcr.io/techarohq/anubis/docs tags: | type=sha,enable=true,priority=100,prefix=,suffix=,format=long main - name: Build and push id: build uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0 with: context: ./docs cache-to: type=gha cache-from: type=gha tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} platforms: linux/amd64 push: false ================================================ FILE: .github/workflows/go-mod-tidy-check.yml ================================================ name: Go Mod Tidy Check on: push: branches: ["main"] pull_request: branches: ["main"] permissions: contents: read jobs: go_mod_tidy_check: runs-on: ubuntu-24.04 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 with: go-version: "stable" - name: Check go.mod and go.sum in main directory run: | # Store original file state cp go.mod go.mod.orig cp go.sum go.sum.orig # Run go mod tidy go mod tidy # Check if files changed if ! diff -q go.mod.orig go.mod > /dev/null 2>&1; then echo "ERROR: go.mod in main directory has changed after running 'go mod tidy'" echo "Please run 'go mod tidy' locally and commit the changes" diff go.mod.orig go.mod exit 1 fi if ! diff -q go.sum.orig go.sum > /dev/null 2>&1; then echo "ERROR: go.sum in main directory has changed after running 'go mod tidy'" echo "Please run 'go mod tidy' locally and commit the changes" diff go.sum.orig go.sum exit 1 fi echo "SUCCESS: go.mod and go.sum in main directory are tidy" - name: Check go.mod and go.sum in test directory run: | cd test # Store original file state cp go.mod go.mod.orig cp go.sum go.sum.orig # Run go mod tidy go mod tidy # Check if files changed if ! diff -q go.mod.orig go.mod > /dev/null 2>&1; then echo "ERROR: go.mod in test directory has changed after running 'go mod tidy'" echo "Please run 'go mod tidy' locally and commit the changes" diff go.mod.orig go.mod exit 1 fi if ! diff -q go.sum.orig go.sum > /dev/null 2>&1; then echo "ERROR: go.sum in test directory has changed after running 'go mod tidy'" echo "Please run 'go mod tidy' locally and commit the changes" diff go.sum.orig go.sum exit 1 fi echo "SUCCESS: go.mod and go.sum in test directory are tidy" ================================================ FILE: .github/workflows/go.yml ================================================ name: Go on: push: branches: ["main"] pull_request: branches: ["main"] permissions: contents: read actions: write jobs: go_tests: #runs-on: alrest-techarohq runs-on: ubuntu-24.04 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - name: build essential run: | sudo apt-get update sudo apt-get install -y build-essential - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version: "24.11.0" - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 with: go-version: "stable" - name: Cache playwright binaries uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 id: playwright-cache with: path: | ~/.cache/ms-playwright key: ${{ runner.os }}-playwright-${{ hashFiles('**/go.sum') }} - name: install node deps run: | npm ci - name: install playwright browsers run: | npx --no-install playwright@1.52.0 install --with-deps npx --no-install playwright@1.52.0 run-server --port 9001 & - name: Build run: npm run build - name: Test run: npm run test - name: Lint with staticcheck uses: dominikh/staticcheck-action@9716614d4101e79b4340dd97b10e54d68234e431 # v1.4.1 with: version: "latest" - name: Govulncheck run: | go tool govulncheck ./... ||: ================================================ FILE: .github/workflows/lint-pr-title.yaml ================================================ name: "Lint PR" on: pull_request_target: types: - opened - edited - synchronize jobs: lint_pr_title: name: Validate PR title runs-on: ubuntu-latest permissions: pull-requests: read steps: - uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ================================================ FILE: .github/workflows/package-builds-stable.yml ================================================ name: Package builds (stable) on: workflow_dispatch: # release: # types: [published] permissions: contents: write actions: write jobs: package_builds: #runs-on: alrest-techarohq runs-on: ubuntu-24.04 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 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 - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version: "24.11.0" - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 with: go-version: "stable" - name: install node deps run: | npm ci - name: Build Packages run: | go tool 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 ================================================ FILE: .github/workflows/package-builds-unstable.yml ================================================ 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-24.04 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 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 - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version: "24.11.0" - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 with: go-version: "stable" - name: install node deps run: | npm ci - name: Build Packages run: | go tool yeet - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: packages path: var/* ================================================ FILE: .github/workflows/smoke-tests.yml ================================================ name: Smoke tests on: push: branches: ["main"] pull_request: branches: ["main"] permissions: contents: read jobs: smoke-test: strategy: matrix: test: - default-config-macro - docker-registry - double_slash - forced-language - git-clone - git-push - healthcheck - i18n - log-file - nginx - palemoon/amd64 #- palemoon/i386 - robots_txt runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version: "24.11.0" - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 with: go-version: "stable" - uses: ko-build/setup-ko@d006021bd0c28d1ce33a07e7943d48b079944c8d # v0.9 - name: Install utils run: | go install ./utils/cmd/... - name: Run test run: | cd test/${{ matrix.test }} backoff-retry --try-count 10 ./test.sh - name: Sanitize artifact name if: always() run: echo "ARTIFACT_NAME=${{ matrix.test }}" | sed 's|/|-|g' >> $GITHUB_ENV - name: Upload artifact uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f if: always() with: name: ${{ env.ARTIFACT_NAME }} path: test/${{ matrix.test }}/var ================================================ FILE: .github/workflows/spelling.yml ================================================ name: Check Spelling # Comment management is handled through a secondary job, for details see: # https://github.com/check-spelling/check-spelling/wiki/Feature%3A-Restricted-Permissions # # `jobs.comment-push` runs when a push is made to a repository and the `jobs.spelling` job needs to make a comment # (in odd cases, it might actually run just to collapse a comment, but that's fairly rare) # it needs `contents: write` in order to add a comment. # # `jobs.comment-pr` runs when a pull_request is made to a repository and the `jobs.spelling` job needs to make a comment # or collapse a comment (in the case where it had previously made a comment and now no longer needs to show a comment) # it needs `pull-requests: write` in order to manipulate those comments. # Updating pull request branches is managed via comment handling. # For details, see: https://github.com/check-spelling/check-spelling/wiki/Feature:-Update-expect-list # # These elements work together to make it happen: # # `on.issue_comment` # This event listens to comments by users asking to update the metadata. # # `jobs.update` # This job runs in response to an issue_comment and will push a new commit # to update the spelling metadata. # # `with.experimental_apply_changes_via_bot` # Tells the action to support and generate messages that enable it # to make a commit to update the spelling metadata. # # `with.ssh_key` # In order to trigger workflows when the commit is made, you can provide a # secret (typically, a write-enabled github deploy key). # # For background, see: https://github.com/check-spelling/check-spelling/wiki/Feature:-Update-with-deploy-key # SARIF reporting # # Access to SARIF reports is generally restricted (by GitHub) to members of the repository. # # Requires enabling `security-events: write` # and configuring the action with `use_sarif: 1` # # For information on the feature, see: https://github.com/check-spelling/check-spelling/wiki/Feature:-SARIF-output # Minimal workflow structure: # # on: # push: # ... # pull_request_target: # ... # jobs: # # you only want the spelling job, all others should be omitted # spelling: # # remove `security-events: write` and `use_sarif: 1` # # remove `experimental_apply_changes_via_bot: 1` # ... otherwise adjust the `with:` as you wish on: push: branches: - "**" tags-ignore: - "**" pull_request: branches: - "**" types: - "opened" - "reopened" - "synchronize" jobs: spelling: name: Check Spelling permissions: contents: read pull-requests: read actions: read security-events: write outputs: followup: ${{ steps.spelling.outputs.followup }} runs-on: ubuntu-latest if: ${{ contains(github.event_name, 'pull_request') || github.event_name == 'push' }} concurrency: group: spelling-${{ github.event.pull_request.number || github.ref }} # note: If you use only_check_changed_files, you do not want cancel-in-progress cancel-in-progress: true steps: - name: check-spelling id: spelling uses: check-spelling/check-spelling@c635c2f3f714eec2fcf27b643a1919b9a811ef2e # v0.0.25 with: suppress_push_for_open_pull_request: ${{ github.actor != 'dependabot[bot]' && 1 }} checkout: true check_file_names: 1 post_comment: 0 use_magic_file: 1 warnings: bad-regex,binary-file,deprecated-feature,ignored-expect-variant,large-file,limited-references,no-newline-at-eof,noisy-file,non-alpha-in-dictionary,token-is-substring,unexpected-line-ending,whitespace-in-dictionary,minified-file,unsupported-configuration,no-files-to-check,unclosed-block-ignore-begin,unclosed-block-ignore-end use_sarif: ${{ (!github.event.pull_request || (github.event.pull_request.head.repo.full_name == github.repository)) && 1 }} check_extra_dictionaries: "" dictionary_source_prefixes: > { "cspell": "https://raw.githubusercontent.com/check-spelling/cspell-dicts/v20241114/dictionaries/" } extra_dictionaries: | cspell:software-terms/softwareTerms.txt cspell:golang/go.txt cspell:npm/npm.txt cspell:k8s/k8s.txt cspell:python/python/python-lib.txt cspell:aws/aws.txt cspell:node/node.txt cspell:html/html.txt cspell:filetypes/filetypes.txt cspell:python/common/extra.txt cspell:docker/docker-words.txt cspell:fullstack/fullstack.txt ================================================ FILE: .github/workflows/ssh-ci-runner-cron.yml ================================================ name: Regenerate ssh ci runner image on: # pull_request: # branches: ["main"] schedule: - cron: "0 0 1,8,15,22 * *" workflow_dispatch: permissions: pull-requests: write contents: write packages: write jobs: ssh-ci-rebuild: if: github.repository == 'TecharoHQ/anubis' runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-tags: true fetch-depth: 0 persist-credentials: false - name: Log into registry uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - name: Set up Docker Buildx uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - name: Build and push run: | cd ./test/ssh-ci docker buildx bake --push ================================================ FILE: .github/workflows/ssh-ci.yml ================================================ name: SSH CI on: push: branches: ["main"] # pull_request: # branches: ["main"] permissions: contents: read jobs: ssh: if: github.repository == 'TecharoHQ/anubis' #runs-on: alrest-techarohq runs-on: ubuntu-latest strategy: matrix: host: - riscv64 - ppc64le #- aarch64-4k #- aarch64-16k steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-tags: true fetch-depth: 0 persist-credentials: false - name: Install CI target SSH key uses: shimataro/ssh-key-action@6b84f2e793b32fa0b03a379cadadec75cc539391 # v2.8.0 with: key: ${{ secrets.CI_SSH_KEY }} name: id_rsa known_hosts: ${{ secrets.CI_SSH_KNOWN_HOSTS }} - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 with: go-version: "stable" - name: Run CI run: go run ./utils/cmd/backoff-retry bash test/ssh-ci/rigging.sh ${{ matrix.host }} env: GITHUB_RUN_ID: ${{ github.run_id }} ================================================ FILE: .github/workflows/zizmor.yml ================================================ 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-24.04 permissions: security-events: write steps: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - name: Install the latest version of uv uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0 - 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@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9 with: sarif_file: results.sarif category: zizmor ================================================ FILE: .github/zizmor.yml ================================================ rules: unpinned-uses: config: policies: Homebrew/actions/*: any ================================================ FILE: .gitignore ================================================ .env *.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 web/static/locales/*.json ================================================ FILE: .husky/commit-msg ================================================ npx --no-install commitlint --edit "$1" # Check if commit message contains Signed-off-by line if ! grep -q "^Signed-off-by:" "$1"; then echo "Commit message must contain a 'Signed-off-by:' line." echo "Please use 'git commit --signoff' or add a Signed-off-by line to your commit message." exit 1 fi ================================================ FILE: .husky/pre-commit ================================================ npm run lint npm run test ================================================ FILE: .ko.yaml ================================================ defaultBaseImage: cgr.dev/chainguard/static defaultPlatforms: - linux/arm64 - linux/amd64 - linux/arm/v7 builds: - id: anubis main: ./cmd/anubis ldflags: - -s -w - -extldflags "-static" - -X github.com/TecharoHQ/anubis.Version={{.Env.VERSION}} ================================================ FILE: .prettierignore ================================================ lib/config/testdata/bad/* *.inc AGENTS.md CLAUDE.md ================================================ FILE: .vscode/extensions.json ================================================ { "recommendations": [ "esbenp.prettier-vscode", "ms-azuretools.vscode-containers", "golang.go", "unifiedjs.vscode-mdx", "a-h.templ", "redhat.vscode-yaml", "streetsidesoftware.code-spell-checker" ] } ================================================ FILE: .vscode/launch.json ================================================ { // Use IntelliSense to learn about possible attributes. // Hover to view descriptions of existing attributes. // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ { "name": "Launch Package", "type": "go", "request": "launch", "mode": "auto", "program": "${fileDirname}" }, { "name": "Anubis [dev]", "command": "npm run dev", "request": "launch", "type": "node-terminal" }, { "name": "Start Docs", "command": "cd docs && npm ci && npm run start", "request": "launch", "type": "node-terminal" } ] } ================================================ FILE: .vscode/settings.json ================================================ { "github.copilot.enable": { "*": false, "plaintext": false, "markdown": false, "mdx": false, "json": false, "scminput": false, "yaml": false, "go": false, "zig": false, "javascript": false, "properties": false }, "[markdown]": { "editor.wordWrap": "wordWrapColumn", "editor.wordWrapColumn": 80, "editor.wordBasedSuggestions": "off" }, "[mdx]": { "editor.wordWrap": "wordWrapColumn", "editor.wordWrapColumn": 80, "editor.wordBasedSuggestions": "off" }, "[nunjucks]": { "editor.wordWrap": "wordWrapColumn", "editor.wordWrapColumn": 80, "editor.wordBasedSuggestions": "off" }, "cSpell.enabledFileTypes": { "mdx": true, "md": true } } ================================================ FILE: AGENTS.md ================================================ # Agent instructions Primary agent documentation is in `CONTRIBUTING.md`. You MUST read this file before proceeding. ## Useful Commands ```shell npm ci # install node dependencies npm run assets # build JS/CSS (required before any Go build/test) npm run build # assets + go build -> ./var/anubis npm run dev # assets + run locally with --use-remote-address ``` ## Testing ```shell npm run test ``` ## Linting ```shell go vet ./... go tool staticcheck ./... go tool govulncheck ./... ``` ## Commit Messages Commit messages follow the [**Conventional Commits**](https://www.conventionalcommits.org/en/v1.0.0/) format: ```text [optional scope]: [optional body] [optional footer(s)] ``` **Types**: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`, `chore`, `revert` - Add `!` after type/scope for breaking changes or include `BREAKING CHANGE:` in the footer. - Keep descriptions concise, imperative, lowercase, and without a trailing period. - Reference issues/PRs in the footer when applicable. - **ALL git commits MUST be made with `--signoff`.** This is mandatory. ### Attribution Requirements AI agents must disclose what tool and model they are using in the "Assisted-by" commit footer: ```text Assisted-by: [Model Name] via [Tool Name] ``` Example: ```text Assisted-by: GLM 4.6 via Claude Code ``` ## PR Checklist - Add description of changes to `[Unreleased]` in `docs/docs/CHANGELOG.md`. - Add test cases for bug fixes and behavior changes. - Run integration tests: `npm run test:integration`. - All commits must have verified (signed) signatures. ## Key Conventions - **Security-first**: This is security software. Code reviews are strict. Always add tests for bug fixes. Consider adversarial inputs. - **Configuration**: YAML-based policy files. Config structs validate via `Valid() error` methods returning sentinel errors. - **Store interface**: `lib/store.Interface` abstracts key-value storage. - **Environment variables**: Parsed from flags via `flagenv`. Use `.env` files locally (loaded by `godotenv/autoload`). Never commit `.env` files. - **Assets must be built first**: JS/CSS assets are embedded into the Go binary. Always run `npm run assets` before `go test` or `go build`. - **CEL expressions**: Policy rules support CEL (Common Expression Language) expressions for advanced matching. See `lib/policy/expressions/`. ================================================ FILE: Brewfile ================================================ # programming languages brew "go@1.24" brew "node" brew "ko" brew "esbuild" brew "zstd" brew "brotli" ================================================ FILE: CLAUDE.md ================================================ @AGENTS.md @CONTRIBUTING.md ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing to Anubis Anubis is a Web AI Firewall Utility (WAIFU) written in Go. It uses sha256 proof-of-work challenges to protect upstream HTTP resources from scraper bots. This is security software -- correctness matters. ## Build & Run Prerequisites: Go 1.24+, Node.js (any supported version), esbuild, gzip, zstd, brotli. Install all with `brew bundle` if you are using Homebrew. ```shell npm ci # install node dependencies npm run assets # build JS/CSS (required before any Go build/test) npm run build # assets + go build -> ./var/anubis npm run dev # assets + run locally with --use-remote-address ``` ## Testing ```shell # Run all unit tests (assets must be built first) npm run test # or: make test # Run a single test by name go test -run TestClampIP ./internal/ # Run a single test file's package go test ./lib/config/ # Run tests with verbose output go test -v -run TestBotValid ./lib/config/ ``` ### Smoke tests The `tests` folder contains "smoke tests" that are intended to set up Anubis in production-adjacent settings and testing it against real infrastructure tools. A smoke test is a folder with `test.sh` that sets up infrastructure, validates the behaviour, and then tears it down. Smoke tests are run in GitHub actions with `.github/workflows/smoke-tests.yaml`. ## Linting ```shell go vet ./... go tool staticcheck ./... go tool govulncheck ./... ``` ## Code Generation The project uses `go generate` for templ templates and stringer. Always run `npm run generate` (or `make assets`) before building or testing. Generated files include: - `web/*.templ` -> templ-generated Go code - `web/static/` -> bundled/minified JS and CSS (with .gz, .zst, .br variants) ## Project Layout Important folders: - `cmd/anubis`: Main entrypoint for the project. This is the program that runs on servers. - `lib/*`: The core library for Anubis and all of its features. This is internal code that is made public for ease of downstream consumption. No API stability is guaranteed. Use at your own risk. - `internal/*`: Actual internal code that is private to the implementation of Anubis. If you need to use a package in this, please copy it out and manually vendor it in your own project. - `test/*` Smoke tests (see dedicated section for details). - `web`: Frontend HTML templates. - `xess`: Frontend CSS framework and build logic. ## Code Style ### Go This project follows the idioms of the Go standard library. Generally follow the patterns that upstream Go uses, including: - Prefer packages from the standard library unless there is no other option. - Use package import aliases only when package names collide. - Use `goimports` to format code. Run with `npm run format`. - Use sentinel errors as package-level variables prefixed with `Err` (such as `ErrBotMustHaveName`). Wrap with `fmt.Errorf("package: small message giving context: %w", err)`. - Use `log/slog` for structured logging. Pass loggers as arguments to functions. Use `lg.With` to preload with context. Prefer using `slog.Debug` unless you absolutely need to report messages to users, some users have magical thinking about log verbosity. - Name PublicFunctionsAndTypes in PascalCase. Name privateFunctionsAndTypes in camelCase. - Acronyms stay uppercase (`URL`, `HTTP`, `IP`, `DNS`, etc.) - Enumerations should use strong types with validation logic for parsing remote input. - Be conservative in what you send but liberal in what you accept. - Anything reading configuration values should use both `json` and `yaml` struct tags. Use pointer values for optional configuration values. - Use [table-driven tests](https://go.dev/wiki/TableDrivenTests) when writing test code. - Use [`t.Helper()`](https://pkg.go.dev/testing#T.Helper) in helper code (setup/teardown scaffolding). - Use [`t.Cleanup()`](https://pkg.go.dev/testing#T.Cleanup) to tear down per-test or per-suite scaffolding. - Use [`errors.Is`](https://pkg.go.dev/errors#Is) for validating function results against sentinel errors. - Prefer same-package tests over black-box tests (`_test` packages). ### JavaScript / TypeScript - Source lives in `web/js/`. Built with esbuild, bundled and minified. - Uses Preact (not React). - No linter config. Keep functions small. Use `const` by default. ### Templ Templates Anubis uses [Templ](https://templ.guide) for generating HTML on the server. - `.templ` files in `web/` generate Go code. Run `go generate ./...` (or `npm run assets`) after modifying them. - Templates receive typed Go parameters. Keep logic in Go, not templates. ## Commit Messages Commit messages follow the [**Conventional Commits**](https://www.conventionalcommits.org/en/v1.0.0/) format: ```text [optional scope]: [optional body] [optional footer(s)] ``` **Types**: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`, `chore`, `revert` - Add `!` after type/scope for breaking changes or include `BREAKING CHANGE:` in the footer. - Keep descriptions concise, imperative, lowercase, and without a trailing period. - Reference issues/PRs in the footer when applicable. - **ALL git commits MUST be made with `--signoff`.** This is mandatory. ### Attribution Requirements AI agents must disclose what tool and model they are using in the "Assisted-by" commit footer: ```text Assisted-by: [Model Name] via [Tool Name] ``` Example: ```text Assisted-by: GLM 4.6 via Claude Code ``` ## PR Checklist - Add description of changes to `[Unreleased]` in `docs/docs/CHANGELOG.md`. - Add test cases for bug fixes and behavior changes. - Run integration tests: `npm run test:integration`. - All commits must have verified (signed) signatures. ## Key Conventions - **Security-first**: This is security software. Code reviews are strict. Always add tests for bug fixes. Consider adversarial inputs. - **Configuration**: YAML-based policy files. Config structs validate via `Valid() error` methods returning sentinel errors. - **Store interface**: `lib/store.Interface` abstracts key-value storage. - **Environment variables**: Parsed from flags via `flagenv`. Use `.env` files locally (loaded by `godotenv/autoload`). Never commit `.env` files. - **Assets must be built first**: JS/CSS assets are embedded into the Go binary. Always run `npm run assets` before `go test` or `go build`. - **CEL expressions**: Policy rules support CEL (Common Expression Language) expressions for advanced matching. See `lib/policy/expressions/`. ================================================ FILE: LICENSE ================================================ Copyright (c) 2025 Xe Iaso Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: Makefile ================================================ VERSION= $(shell cat ./VERSION) GO?= go NPM?= npm .PHONY: build assets deps lint prebaked-build test all: build deps: $(NPM) ci $(GO) mod download assets: PATH:=$(PWD)/node_modules/.bin:$(PATH) assets: deps $(GO) generate ./... ./web/build.sh ./xess/build.sh build: assets $(GO) build -o ./var/anubis ./cmd/anubis $(GO) build -o ./var/robots2policy ./cmd/robots2policy @echo "Anubis is now built to ./var/anubis" lint: assets $(GO) vet ./... $(GO) tool staticcheck ./... prebaked-build: $(GO) build -o ./var/anubis -ldflags "-X 'github.com/TecharoHQ/anubis.Version=$(VERSION)'" ./cmd/anubis $(GO) build -o ./var/robots2policy -ldflags "-X 'github.com/TecharoHQ/anubis.Version=$(VERSION)'" ./cmd/robots2policy test: assets $(GO) test ./... ================================================ FILE: README.md ================================================ # Anubis
A smiling chibi dark-skinned anthro jackal with brown hair and tall ears looking victorious with a thumbs-up
![enbyware](https://pride-badges.pony.workers.dev/static/v1?label=enbyware&labelColor=%23555&stripeWidth=8&stripeColors=FCF434%2CFFFFFF%2C9C59D1%2C2C2C2C) ![GitHub Issues or Pull Requests by label](https://img.shields.io/github/issues/TecharoHQ/anubis) ![GitHub go.mod Go version](https://img.shields.io/github/go-mod/go-version/TecharoHQ/anubis) ![language count](https://img.shields.io/github/languages/count/TecharoHQ/anubis) ![repo size](https://img.shields.io/github/repo-size/TecharoHQ/anubis) [![GitHub Sponsors](https://img.shields.io/github/sponsors/Xe)](https://github.com/sponsors/Xe) ## Sponsors Anubis is brought to you by sponsors and donors like: ### Diamond Tier Raptor Computing Systems Databento ### Gold Tier Unipromos Uvensys Distrust Gitea Prolocation Terminal Trove canine.tools Weblate Uberspace Wildbase Cat eyes over the word Emma in a serif font Cat eyes over the word Emma in a serif font ANEXIA Cloud Solutions ## Overview Anubis is a Web AI Firewall Utility that [weighs the soul of your connection](https://en.wikipedia.org/wiki/Weighing_of_souls) using one or more challenges in order to protect upstream resources from scraper bots. This program is designed to help protect the small internet from the endless storm of requests that flood in from AI companies. Anubis is as lightweight as possible to ensure that everyone can afford to protect the communities closest to them. Anubis is a bit of a nuclear response. This will result in your website being blocked from smaller scrapers and may inhibit "good bots" like the Internet Archive. You can configure [bot policy definitions](./docs/docs/admin/policies.mdx) to explicitly allowlist them and we are working on a curated set of "known good" bots to allow for a compromise between discoverability and uptime. In most cases, you should not need this and can probably get by using Cloudflare to protect a given origin. However, for circumstances where you can't or won't use Cloudflare, Anubis is there for you. If you want to try this out, visit the Anubis documentation site at [anubis.techaro.lol](https://anubis.techaro.lol). ## Support 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 Star History Chart ## Packaging Status [![Packaging status](https://repology.org/badge/vertical-allrepos/anubis-anti-crawler.svg?columns=3)](https://repology.org/project/anubis-anti-crawler/versions) ## Contributors Made with [contrib.rocks](https://contrib.rocks). ================================================ FILE: SECURITY.md ================================================ # Security Policy Techaro follows the [Semver 2.0 scheme](https://semver.org/). ## Supported Versions Techaro strives to support the two most recent minor versions of Anubis. Patches to those versions will be published as patch releases. ## Reporting a Vulnerability Email security@techaro.lol with details on the vulnerability and reproduction steps. You will get a response as soon as possible. Please take care to send your email as a mixed plaintext and HTML message. Messages with GPG signatures or that are plaintext only may be blocked by the spam filter. ================================================ FILE: VERSION ================================================ 1.25.0 ================================================ FILE: anubis.go ================================================ // Package anubis contains the version number of Anubis. package anubis import "time" // 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. var CookieName = "techaro.lol-anubis" // TestCookieName is the name of the cookie that Anubis uses in order to check // if cookies are enabled on the client's browser. var TestCookieName = "techaro.lol-anubis-cookie-verification" // CookieDefaultExpirationTime is the amount of time before the cookie/JWT expires. const CookieDefaultExpirationTime = 7 * 24 * time.Hour // BasePrefix is a global prefix for all Anubis endpoints. Can be emptied to remove the prefix entirely. var BasePrefix = "" // PublicUrl is the externally accessible URL for this Anubis instance. var PublicUrl = "" // StaticPath is the location where all static Anubis assets are located. const StaticPath = "/.within.website/x/cmd/anubis/" // APIPrefix is the location where all Anubis API endpoints are located. const APIPrefix = "/.within.website/x/cmd/anubis/api/" // DefaultDifficulty is the default "difficulty" (number of leading zeroes) // that must be met by the client in order to pass the challenge. const DefaultDifficulty = 4 // ForcedLanguage is the language being used instead of the one of the request's Accept-Language header // if being set. var ForcedLanguage = "" // UseSimplifiedExplanation can be set to true for using the simplified explanation var UseSimplifiedExplanation = false ================================================ FILE: cmd/containerbuild/.gitignore ================================================ images ================================================ FILE: cmd/containerbuild/main.go ================================================ package main import ( "flag" "fmt" "log" "log/slog" "os" "os/exec" "path/filepath" "strings" "github.com/TecharoHQ/anubis/internal" "github.com/facebookgo/flagenv" ) var ( dockerAnnotations = flag.String("docker-annotations", os.Getenv("DOCKER_METADATA_OUTPUT_ANNOTATIONS"), "Docker image annotations") dockerLabels = flag.String("docker-labels", os.Getenv("DOCKER_METADATA_OUTPUT_LABELS"), "Docker image labels") dockerRepo = flag.String("docker-repo", "registry.int.xeserv.us/techaro/anubis", "Docker image repository for Anubis") dockerTags = flag.String("docker-tags", os.Getenv("DOCKER_METADATA_OUTPUT_TAGS"), "newline separated docker tags including the registry name") githubEventName = flag.String("github-event-name", "", "GitHub event name") pullRequestID = flag.Int("pull-request-id", -1, "GitHub pull request ID") slogLevel = flag.String("slog-level", "INFO", "logging level (see https://pkg.go.dev/log/slog#hdr-Levels)") ) func main() { flagenv.Parse() flag.Parse() slog.SetDefault(internal.InitSlog(*slogLevel, os.Stderr)) 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) slog.Info( "Building image for pull request", "docker-repo", *dockerRepo, "docker-tags", *dockerTags, "github-event-name", *githubEventName, "pull-request-id", *pullRequestID, ) } if strings.Contains(*dockerTags, ",") { newTags := strings.Join(strings.Split(*dockerTags, ","), "\n") dockerTags = &newTags } setOutput("docker_image", strings.SplitN(*dockerTags, "\n", 2)[0]) version, err := run("git describe --tags --always --dirty") if err != nil { log.Fatal(err) } commitTimestamp, err := run("git log -1 --format='%ct'") if err != nil { log.Fatal(err) } slog.Debug( "ko env", "KO_DOCKER_REPO", koDockerRepo, "SOURCE_DATE_EPOCH", commitTimestamp, "VERSION", version, ) os.Setenv("KO_DOCKER_REPO", koDockerRepo) os.Setenv("SOURCE_DATE_EPOCH", commitTimestamp) os.Setenv("VERSION", version) setOutput("version", version) if *dockerTags == "" { log.Fatal("Must set --docker-tags or DOCKER_METADATA_OUTPUT_TAGS") } images, err := parseImageList(*dockerTags) if err != nil { log.Fatalf("can't parse images: %v", err) } for _, img := range images { if img.repository != *dockerRepo { slog.Error( "Something weird is going on. Wanted docker repo differs from contents of --docker-tags. Did a flag get set incorrectly?", "wanted", *dockerRepo, "got", img.repository, "docker-tags", *dockerTags, ) os.Exit(2) } } var tags []string for _, img := range images { tags = append(tags, img.tag) } output, err := run(fmt.Sprintf("ko build --platform=all --base-import-paths --tags=%q --image-user=1000 --image-annotation=%q --image-label=%q ./cmd/anubis | tail -n1", strings.Join(tags, ","), *dockerAnnotations, *dockerLabels)) if err != nil { log.Fatalf("can't run ko build, check stderr: %v", err) } sp := strings.SplitN(output, "@", 2) setOutput("digest", sp[1]) } type image struct { repository string tag string } func parseImageList(imageList string) ([]image, error) { images := strings.Split(imageList, "\n") var result []image for _, img := range images { if img == "" { continue } // reg.xeiaso.net/techaro/anubis:latest // repository: reg.xeiaso.net/techaro/anubis // tag: latest index := strings.LastIndex(img, ":") result = append(result, image{ repository: img[:index], tag: img[index+1:], }) } if len(result) == 0 { return nil, fmt.Errorf("no images provided, bad flags") } return result, nil } // run executes a command and returns the trimmed output. func run(command string) (string, error) { bin, err := exec.LookPath("sh") if err != nil { return "", err } slog.Debug("running command", "command", command) cmd := exec.Command(bin, "-c", command) cmd.Stderr = os.Stderr out, err := cmd.Output() if err != nil { return "", err } return strings.TrimSpace(string(out)), nil } func setOutput(key, val string) { github_output := os.Getenv("GITHUB_OUTPUT") f, _ := os.OpenFile(github_output, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0644) fmt.Fprintf(f, "%s=%s\n", key, val) f.Close() } ================================================ FILE: cmd/robots2policy/batch/batch_process.go ================================================ /* Batch process robots.txt files from archives like https://github.com/nrjones8/robots-dot-txt-archive-bot/tree/master/data/cleaned into Anubis CEL policies. Usage: go run batch_process.go */ package main import ( "fmt" "io/fs" "log" "os" "os/exec" "path/filepath" "strings" ) func main() { if len(os.Args) < 2 { fmt.Println("Usage: go run batch_process.go ") fmt.Println("Example: go run batch_process.go ./cleaned") os.Exit(1) } cleanedDir := os.Args[1] outputDir := "generated_policies" // Create output directory if err := os.MkdirAll(outputDir, 0755); err != nil { log.Fatalf("Failed to create output directory: %v", err) } count := 0 err := filepath.WalkDir(cleanedDir, func(path string, d fs.DirEntry, err error) error { if err != nil { return err } // Skip directories if d.IsDir() { return nil } // Generate policy name from file path relPath, _ := filepath.Rel(cleanedDir, path) policyName := strings.ReplaceAll(relPath, "/", "-") policyName = strings.TrimSuffix(policyName, "-robots.txt") policyName = strings.ReplaceAll(policyName, ".", "-") outputFile := filepath.Join(outputDir, policyName+".yaml") cmd := exec.Command("go", "run", "main.go", "-input", path, "-output", outputFile, "-name", policyName, "-format", "yaml") if err := cmd.Run(); err != nil { fmt.Printf("Warning: Failed to process %s: %v\n", path, err) return nil // Continue processing other files } count++ if count%100 == 0 { fmt.Printf("Processed %d files...\n", count) } else if count%10 == 0 { fmt.Print(".") } return nil }) if err != nil { log.Fatalf("Error walking directory: %v", err) } fmt.Printf("Successfully processed %d robots.txt files\n", count) fmt.Printf("Generated policies saved to: %s/\n", outputDir) } ================================================ FILE: cmd/robots2policy/main.go ================================================ package main import ( "bufio" "encoding/json" "flag" "fmt" "io" "log" "net/http" "os" "regexp" "slices" "strings" "github.com/TecharoHQ/anubis/lib/config" "sigs.k8s.io/yaml" ) var ( inputFile = flag.String("input", "", "path to robots.txt file (use - for stdin)") outputFile = flag.String("output", "", "output file path (use - for stdout, defaults to stdout)") outputFormat = flag.String("format", "yaml", "output format: yaml or json") baseAction = flag.String("action", "CHALLENGE", "default action for disallowed paths: ALLOW, DENY, CHALLENGE, WEIGH") crawlDelay = flag.Int("crawl-delay-weight", 0, "if > 0, add weight adjustment for crawl-delay (difficulty adjustment)") policyName = flag.String("name", "robots-txt-policy", "name for the generated policy") userAgentDeny = flag.String("deny-user-agents", "DENY", "action for specifically blocked user agents: DENY, CHALLENGE") helpFlag = flag.Bool("help", false, "show help") ) type RobotsRule struct { UserAgents []string Disallows []string Allows []string CrawlDelay int IsBlacklist bool // true if this is a specifically denied user agent } type AnubisRule struct { Expression *config.ExpressionOrList `yaml:"expression,omitempty" json:"expression,omitempty"` Challenge *config.ChallengeRules `yaml:"challenge,omitempty" json:"challenge,omitempty"` Weight *config.Weight `yaml:"weight,omitempty" json:"weight,omitempty"` Name string `yaml:"name" json:"name"` Action string `yaml:"action" json:"action"` } func init() { flag.Usage = func() { fmt.Fprintf(os.Stderr, "Usage of %s:\n", os.Args[0]) fmt.Fprintf(os.Stderr, "%s [options] -input \n\n", os.Args[0]) flag.PrintDefaults() fmt.Fprintln(os.Stderr, "\nExamples:") fmt.Fprintln(os.Stderr, " # Convert local robots.txt file") fmt.Fprintln(os.Stderr, " robots2policy -input robots.txt -output policy.yaml") fmt.Fprintln(os.Stderr, "") fmt.Fprintln(os.Stderr, " # Convert from URL") fmt.Fprintln(os.Stderr, " robots2policy -input https://example.com/robots.txt -format json") fmt.Fprintln(os.Stderr, "") fmt.Fprintln(os.Stderr, " # Read from stdin, write to stdout") fmt.Fprintln(os.Stderr, " curl https://example.com/robots.txt | robots2policy -input -") os.Exit(2) } } func main() { flag.Parse() if len(flag.Args()) > 0 || *helpFlag || *inputFile == "" { flag.Usage() } // Read robots.txt var input io.Reader if *inputFile == "-" { input = os.Stdin } else if strings.HasPrefix(*inputFile, "http://") || strings.HasPrefix(*inputFile, "https://") { resp, err := http.Get(*inputFile) if err != nil { log.Fatalf("failed to fetch robots.txt from URL: %v", err) } defer resp.Body.Close() input = resp.Body } else { file, err := os.Open(*inputFile) if err != nil { log.Fatalf("failed to open input file: %v", err) } defer file.Close() input = file } // Parse robots.txt rules, err := parseRobotsTxt(input) if err != nil { log.Fatalf("failed to parse robots.txt: %v", err) } // Convert to Anubis rules anubisRules := convertToAnubisRules(rules) // Check if any rules were generated if len(anubisRules) == 0 { log.Fatal("no valid rules generated from robots.txt - file may be empty or contain no disallow directives") } // Generate output var output []byte switch strings.ToLower(*outputFormat) { case "yaml": output, err = yaml.Marshal(anubisRules) case "json": output, err = json.MarshalIndent(anubisRules, "", " ") default: log.Fatalf("unsupported output format: %s (use yaml or json)", *outputFormat) } if err != nil { log.Fatalf("failed to marshal output: %v", err) } // Write output if *outputFile == "" || *outputFile == "-" { fmt.Print(string(output)) } else { err = os.WriteFile(*outputFile, output, 0644) if err != nil { log.Fatalf("failed to write output file: %v", err) } fmt.Printf("Generated Anubis policy written to %s\n", *outputFile) } } func createRuleFromAccumulated(userAgents, disallows, allows []string, crawlDelay int) RobotsRule { rule := RobotsRule{ UserAgents: make([]string, len(userAgents)), Disallows: make([]string, len(disallows)), Allows: make([]string, len(allows)), CrawlDelay: crawlDelay, } copy(rule.UserAgents, userAgents) copy(rule.Disallows, disallows) copy(rule.Allows, allows) return rule } func parseRobotsTxt(input io.Reader) ([]RobotsRule, error) { scanner := bufio.NewScanner(input) var rules []RobotsRule var currentUserAgents []string var currentDisallows []string var currentAllows []string var currentCrawlDelay int for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) // Skip empty lines and comments if line == "" || strings.HasPrefix(line, "#") { continue } // Split on first colon parts := strings.SplitN(line, ":", 2) if len(parts) != 2 { continue } directive := strings.TrimSpace(strings.ToLower(parts[0])) value := strings.TrimSpace(parts[1]) switch directive { case "user-agent": // If we have accumulated rules with directives and encounter a new user-agent, // flush the current rules if len(currentUserAgents) > 0 && (len(currentDisallows) > 0 || len(currentAllows) > 0 || currentCrawlDelay > 0) { rule := createRuleFromAccumulated(currentUserAgents, currentDisallows, currentAllows, currentCrawlDelay) rules = append(rules, rule) // Reset for next group currentUserAgents = nil currentDisallows = nil currentAllows = nil currentCrawlDelay = 0 } currentUserAgents = append(currentUserAgents, value) case "disallow": if len(currentUserAgents) > 0 && value != "" { currentDisallows = append(currentDisallows, value) } case "allow": if len(currentUserAgents) > 0 && value != "" { currentAllows = append(currentAllows, value) } case "crawl-delay": if len(currentUserAgents) > 0 { if delay, err := parseIntSafe(value); err == nil { currentCrawlDelay = delay } } } } // Don't forget the last group of rules if len(currentUserAgents) > 0 { rule := createRuleFromAccumulated(currentUserAgents, currentDisallows, currentAllows, currentCrawlDelay) rules = append(rules, rule) } // Mark blacklisted user agents (those with "Disallow: /") for i := range rules { if slices.Contains(rules[i].Disallows, "/") { rules[i].IsBlacklist = true } } return rules, scanner.Err() } func parseIntSafe(s string) (int, error) { var result int _, err := fmt.Sscanf(s, "%d", &result) return result, err } func convertToAnubisRules(robotsRules []RobotsRule) []AnubisRule { var anubisRules []AnubisRule ruleCounter := 0 // Process each robots rule individually for _, robotsRule := range robotsRules { userAgents := robotsRule.UserAgents // Handle crawl delay if robotsRule.CrawlDelay > 0 && *crawlDelay > 0 { ruleCounter++ rule := AnubisRule{ Name: fmt.Sprintf("%s-crawl-delay-%d", *policyName, ruleCounter), Action: "WEIGH", Weight: &config.Weight{Adjust: *crawlDelay}, } if len(userAgents) == 1 && userAgents[0] == "*" { rule.Expression = &config.ExpressionOrList{ All: []string{"true"}, // Always applies } } else if len(userAgents) == 1 { rule.Expression = &config.ExpressionOrList{ All: []string{fmt.Sprintf("userAgent.contains(%q)", userAgents[0])}, } } else { // Multiple user agents - use any block var expressions []string for _, ua := range userAgents { if ua == "*" { expressions = append(expressions, "true") } else { expressions = append(expressions, fmt.Sprintf("userAgent.contains(%q)", ua)) } } rule.Expression = &config.ExpressionOrList{ Any: expressions, } } anubisRules = append(anubisRules, rule) } // Handle blacklisted user agents if robotsRule.IsBlacklist { ruleCounter++ rule := AnubisRule{ Name: fmt.Sprintf("%s-blacklist-%d", *policyName, ruleCounter), Action: *userAgentDeny, } if len(userAgents) == 1 { userAgent := userAgents[0] if userAgent == "*" { // This would block everything - convert to a weight adjustment instead rule.Name = fmt.Sprintf("%s-global-restriction-%d", *policyName, ruleCounter) rule.Action = "WEIGH" rule.Weight = &config.Weight{Adjust: 20} // Increase difficulty significantly rule.Expression = &config.ExpressionOrList{ All: []string{"true"}, // Always applies } } else { rule.Expression = &config.ExpressionOrList{ All: []string{fmt.Sprintf("userAgent.contains(%q)", userAgent)}, } } } else { // Multiple user agents - use any block var expressions []string for _, ua := range userAgents { if ua == "*" { expressions = append(expressions, "true") } else { expressions = append(expressions, fmt.Sprintf("userAgent.contains(%q)", ua)) } } rule.Expression = &config.ExpressionOrList{ Any: expressions, } } anubisRules = append(anubisRules, rule) } // Handle specific disallow rules for _, disallow := range robotsRule.Disallows { if disallow == "/" { continue // Already handled as blacklist above } ruleCounter++ rule := AnubisRule{ Name: fmt.Sprintf("%s-disallow-%d", *policyName, ruleCounter), Action: *baseAction, } // Build CEL expression var conditions []string // Add user agent conditions if len(userAgents) == 1 && userAgents[0] == "*" { // Wildcard user agent - no user agent condition needed } else if len(userAgents) == 1 { conditions = append(conditions, fmt.Sprintf("userAgent.contains(%q)", userAgents[0])) } else { // For multiple user agents, we need to use a more complex expression // This is a limitation - we can't easily combine any for user agents with all for path // So we'll create separate rules for each user agent for _, ua := range userAgents { if ua == "*" { continue // Skip wildcard as it's handled separately } ruleCounter++ subRule := AnubisRule{ Name: fmt.Sprintf("%s-disallow-%d", *policyName, ruleCounter), Action: *baseAction, Expression: &config.ExpressionOrList{ All: []string{ fmt.Sprintf("userAgent.contains(%q)", ua), buildPathCondition(disallow), }, }, } anubisRules = append(anubisRules, subRule) } continue } // Add path condition pathCondition := buildPathCondition(disallow) conditions = append(conditions, pathCondition) rule.Expression = &config.ExpressionOrList{ All: conditions, } anubisRules = append(anubisRules, rule) } } return anubisRules } func buildPathCondition(robotsPath string) string { // Handle wildcards in robots.txt paths if strings.Contains(robotsPath, "*") || strings.Contains(robotsPath, "?") { // Convert robots.txt wildcards to regex regex := regexp.QuoteMeta(robotsPath) regex = strings.ReplaceAll(regex, `\*`, `.*`) // * becomes .* regex = strings.ReplaceAll(regex, `\?`, `.`) // ? becomes . regex = "^" + regex return fmt.Sprintf("path.matches(%q)", regex) } // Simple prefix match for most cases return fmt.Sprintf("path.startsWith(%q)", robotsPath) } ================================================ FILE: cmd/robots2policy/robots2policy_test.go ================================================ package main import ( "encoding/json" "fmt" "os" "path/filepath" "reflect" "strings" "testing" "gopkg.in/yaml.v3" ) type TestCase struct { name string robotsFile string expectedFile string options TestOptions } type TestOptions struct { format string action string policyName string deniedAction string crawlDelayWeight int } func TestDataFileConversion(t *testing.T) { testCases := []TestCase{ { name: "simple_default", robotsFile: "simple.robots.txt", expectedFile: "simple.yaml", options: TestOptions{format: "yaml"}, }, { name: "simple_json", robotsFile: "simple.robots.txt", expectedFile: "simple.json", options: TestOptions{format: "json"}, }, { name: "simple_deny_action", robotsFile: "simple.robots.txt", expectedFile: "deny-action.yaml", options: TestOptions{format: "yaml", action: "DENY"}, }, { name: "simple_custom_name", robotsFile: "simple.robots.txt", expectedFile: "custom-name.yaml", options: TestOptions{format: "yaml", policyName: "my-custom-policy"}, }, { name: "blacklist_with_crawl_delay", robotsFile: "blacklist.robots.txt", expectedFile: "blacklist.yaml", options: TestOptions{format: "yaml", crawlDelayWeight: 3}, }, { name: "wildcards", robotsFile: "wildcards.robots.txt", expectedFile: "wildcards.yaml", options: TestOptions{format: "yaml"}, }, { name: "empty_file", robotsFile: "empty.robots.txt", expectedFile: "empty.yaml", options: TestOptions{format: "yaml"}, }, { name: "complex_scenario", robotsFile: "complex.robots.txt", expectedFile: "complex.yaml", options: TestOptions{format: "yaml", crawlDelayWeight: 5}, }, { name: "consecutive_user_agents", robotsFile: "consecutive.robots.txt", expectedFile: "consecutive.yaml", options: TestOptions{format: "yaml", crawlDelayWeight: 3}, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { robotsPath := filepath.Join("testdata", tc.robotsFile) expectedPath := filepath.Join("testdata", tc.expectedFile) // Read robots.txt input robotsFile, err := os.Open(robotsPath) if err != nil { t.Fatalf("Failed to open robots file %s: %v", robotsPath, err) } defer robotsFile.Close() // Parse robots.txt rules, err := parseRobotsTxt(robotsFile) if err != nil { t.Fatalf("Failed to parse robots.txt: %v", err) } // Set test options oldFormat := *outputFormat oldAction := *baseAction oldCrawlDelay := *crawlDelay oldPolicyName := *policyName oldDeniedAction := *userAgentDeny if tc.options.format != "" { *outputFormat = tc.options.format } if tc.options.action != "" { *baseAction = tc.options.action } if tc.options.crawlDelayWeight > 0 { *crawlDelay = tc.options.crawlDelayWeight } if tc.options.policyName != "" { *policyName = tc.options.policyName } if tc.options.deniedAction != "" { *userAgentDeny = tc.options.deniedAction } // Restore options after test defer func() { *outputFormat = oldFormat *baseAction = oldAction *crawlDelay = oldCrawlDelay *policyName = oldPolicyName *userAgentDeny = oldDeniedAction }() // Convert to Anubis rules anubisRules := convertToAnubisRules(rules) // Generate output var actualOutput []byte switch strings.ToLower(*outputFormat) { case "yaml": actualOutput, err = yaml.Marshal(anubisRules) case "json": actualOutput, err = json.MarshalIndent(anubisRules, "", " ") } if err != nil { t.Fatalf("Failed to marshal output: %v", err) } // Read expected output expectedOutput, err := os.ReadFile(expectedPath) if err != nil { t.Fatalf("Failed to read expected file %s: %v", expectedPath, err) } if strings.ToLower(*outputFormat) == "yaml" { var actualData []any var expectedData []any err = yaml.Unmarshal(actualOutput, &actualData) if err != nil { t.Fatalf("Failed to unmarshal actual output: %v", err) } err = yaml.Unmarshal(expectedOutput, &expectedData) if err != nil { t.Fatalf("Failed to unmarshal expected output: %v", err) } // Compare data structures if !compareData(actualData, expectedData) { actualStr := strings.TrimSpace(string(actualOutput)) expectedStr := strings.TrimSpace(string(expectedOutput)) t.Errorf("Output mismatch for %s\nExpected:\n%s\n\nActual:\n%s", tc.name, expectedStr, actualStr) } } else { var actualData []any var expectedData []any err = json.Unmarshal(actualOutput, &actualData) if err != nil { t.Fatalf("Failed to unmarshal actual JSON output: %v", err) } err = json.Unmarshal(expectedOutput, &expectedData) if err != nil { t.Fatalf("Failed to unmarshal expected JSON output: %v", err) } // Compare data structures if !compareData(actualData, expectedData) { actualStr := strings.TrimSpace(string(actualOutput)) expectedStr := strings.TrimSpace(string(expectedOutput)) t.Errorf("Output mismatch for %s\nExpected:\n%s\n\nActual:\n%s", tc.name, expectedStr, actualStr) } } }) } } func TestCaseInsensitiveParsing(t *testing.T) { robotsTxt := `User-Agent: * Disallow: /admin Crawl-Delay: 10 User-agent: TestBot disallow: /test crawl-delay: 5 USER-AGENT: UpperBot DISALLOW: /upper CRAWL-DELAY: 20` reader := strings.NewReader(robotsTxt) rules, err := parseRobotsTxt(reader) if err != nil { t.Fatalf("Failed to parse case-insensitive robots.txt: %v", err) } expectedRules := 3 if len(rules) != expectedRules { t.Errorf("Expected %d rules, got %d", expectedRules, len(rules)) } // Check that all crawl delays were parsed for i, rule := range rules { expectedDelays := []int{10, 5, 20} if rule.CrawlDelay != expectedDelays[i] { t.Errorf("Rule %d: expected crawl delay %d, got %d", i, expectedDelays[i], rule.CrawlDelay) } } } func TestVariousOutputFormats(t *testing.T) { robotsTxt := `User-agent: * Disallow: /admin` reader := strings.NewReader(robotsTxt) rules, err := parseRobotsTxt(reader) if err != nil { t.Fatalf("Failed to parse robots.txt: %v", err) } oldPolicyName := *policyName *policyName = "test-policy" defer func() { *policyName = oldPolicyName }() anubisRules := convertToAnubisRules(rules) // Test YAML output yamlOutput, err := yaml.Marshal(anubisRules) if err != nil { t.Fatalf("Failed to marshal YAML: %v", err) } if !strings.Contains(string(yamlOutput), "name: test-policy-disallow-1") { t.Errorf("YAML output doesn't contain expected rule name") } // Test JSON output jsonOutput, err := json.MarshalIndent(anubisRules, "", " ") if err != nil { t.Fatalf("Failed to marshal JSON: %v", err) } if !strings.Contains(string(jsonOutput), `"name": "test-policy-disallow-1"`) { t.Errorf("JSON output doesn't contain expected rule name") } } func TestDifferentActions(t *testing.T) { robotsTxt := `User-agent: * Disallow: /admin` testActions := []string{"ALLOW", "DENY", "CHALLENGE", "WEIGH"} for _, action := range testActions { t.Run("action_"+action, func(t *testing.T) { reader := strings.NewReader(robotsTxt) rules, err := parseRobotsTxt(reader) if err != nil { t.Fatalf("Failed to parse robots.txt: %v", err) } oldAction := *baseAction *baseAction = action defer func() { *baseAction = oldAction }() anubisRules := convertToAnubisRules(rules) if len(anubisRules) != 1 { t.Fatalf("Expected 1 rule, got %d", len(anubisRules)) } if anubisRules[0].Action != action { t.Errorf("Expected action %s, got %s", action, anubisRules[0].Action) } }) } } func TestPolicyNaming(t *testing.T) { robotsTxt := `User-agent: * Disallow: /admin Disallow: /private User-agent: BadBot Disallow: /` testNames := []string{"custom-policy", "my-rules", "site-protection"} for _, name := range testNames { t.Run("name_"+name, func(t *testing.T) { reader := strings.NewReader(robotsTxt) rules, err := parseRobotsTxt(reader) if err != nil { t.Fatalf("Failed to parse robots.txt: %v", err) } oldName := *policyName *policyName = name defer func() { *policyName = oldName }() anubisRules := convertToAnubisRules(rules) // Check that all rule names use the custom prefix for _, rule := range anubisRules { if !strings.HasPrefix(rule.Name, name+"-") { t.Errorf("Rule name %s doesn't start with expected prefix %s-", rule.Name, name) } } }) } } func TestCrawlDelayWeights(t *testing.T) { robotsTxt := `User-agent: * Disallow: /admin Crawl-delay: 10 User-agent: SlowBot Disallow: /slow Crawl-delay: 60` testWeights := []int{1, 5, 10, 25} for _, weight := range testWeights { t.Run(fmt.Sprintf("weight_%d", weight), func(t *testing.T) { reader := strings.NewReader(robotsTxt) rules, err := parseRobotsTxt(reader) if err != nil { t.Fatalf("Failed to parse robots.txt: %v", err) } oldWeight := *crawlDelay *crawlDelay = weight defer func() { *crawlDelay = oldWeight }() anubisRules := convertToAnubisRules(rules) // Count weight rules and verify they have correct weight weightRules := 0 for _, rule := range anubisRules { if rule.Action == "WEIGH" && rule.Weight != nil { weightRules++ if rule.Weight.Adjust != weight { t.Errorf("Expected weight %d, got %d", weight, rule.Weight.Adjust) } } } expectedWeightRules := 2 // One for *, one for SlowBot if weightRules != expectedWeightRules { t.Errorf("Expected %d weight rules, got %d", expectedWeightRules, weightRules) } }) } } func TestBlacklistActions(t *testing.T) { robotsTxt := `User-agent: BadBot Disallow: / User-agent: SpamBot Disallow: /` testActions := []string{"DENY", "CHALLENGE"} for _, action := range testActions { t.Run("blacklist_"+action, func(t *testing.T) { reader := strings.NewReader(robotsTxt) rules, err := parseRobotsTxt(reader) if err != nil { t.Fatalf("Failed to parse robots.txt: %v", err) } oldAction := *userAgentDeny *userAgentDeny = action defer func() { *userAgentDeny = oldAction }() anubisRules := convertToAnubisRules(rules) // All rules should be blacklist rules with the specified action for _, rule := range anubisRules { if !strings.Contains(rule.Name, "blacklist") { t.Errorf("Expected blacklist rule, got %s", rule.Name) } if rule.Action != action { t.Errorf("Expected action %s, got %s", action, rule.Action) } } }) } } // compareData performs a deep comparison of two data structures, // ignoring differences that are semantically equivalent in YAML/JSON func compareData(actual, expected any) bool { return reflect.DeepEqual(actual, expected) } ================================================ FILE: cmd/robots2policy/testdata/blacklist.robots.txt ================================================ # Test with blacklisted user agents User-agent: * Disallow: /admin Crawl-delay: 10 User-agent: BadBot Disallow: / User-agent: SpamBot Disallow: / Crawl-delay: 60 User-agent: Googlebot Disallow: /search Crawl-delay: 5 ================================================ FILE: cmd/robots2policy/testdata/blacklist.yaml ================================================ - action: WEIGH expression: "true" name: robots-txt-policy-crawl-delay-1 weight: adjust: 3 - action: CHALLENGE expression: path.startsWith("/admin") name: robots-txt-policy-disallow-2 - action: DENY expression: userAgent.contains("BadBot") name: robots-txt-policy-blacklist-3 - action: WEIGH expression: userAgent.contains("SpamBot") name: robots-txt-policy-crawl-delay-4 weight: adjust: 3 - action: DENY expression: userAgent.contains("SpamBot") name: robots-txt-policy-blacklist-5 - action: WEIGH expression: userAgent.contains("Googlebot") name: robots-txt-policy-crawl-delay-6 weight: adjust: 3 - action: CHALLENGE expression: all: - userAgent.contains("Googlebot") - path.startsWith("/search") name: robots-txt-policy-disallow-7 ================================================ FILE: cmd/robots2policy/testdata/complex.robots.txt ================================================ # Complex real-world example User-agent: * Disallow: /admin/ Disallow: /private/ Disallow: /api/internal/ Allow: /api/public/ Crawl-delay: 5 User-agent: Googlebot Disallow: /search/ Allow: /api/ Crawl-delay: 2 User-agent: Bingbot Disallow: /search/ Disallow: /admin/ Crawl-delay: 10 User-agent: BadBot Disallow: / User-agent: SeoBot Disallow: / Crawl-delay: 300 # Test with various patterns User-agent: TestBot Disallow: /*/admin Disallow: /temp*.html Disallow: /file?.log ================================================ FILE: cmd/robots2policy/testdata/complex.yaml ================================================ - action: WEIGH expression: "true" name: robots-txt-policy-crawl-delay-1 weight: adjust: 5 - action: CHALLENGE expression: path.startsWith("/admin/") name: robots-txt-policy-disallow-2 - action: CHALLENGE expression: path.startsWith("/private/") name: robots-txt-policy-disallow-3 - action: CHALLENGE expression: path.startsWith("/api/internal/") name: robots-txt-policy-disallow-4 - action: WEIGH expression: userAgent.contains("Googlebot") name: robots-txt-policy-crawl-delay-5 weight: adjust: 5 - action: CHALLENGE expression: all: - userAgent.contains("Googlebot") - path.startsWith("/search/") name: robots-txt-policy-disallow-6 - action: WEIGH expression: userAgent.contains("Bingbot") name: robots-txt-policy-crawl-delay-7 weight: adjust: 5 - action: CHALLENGE expression: all: - userAgent.contains("Bingbot") - path.startsWith("/search/") name: robots-txt-policy-disallow-8 - action: CHALLENGE expression: all: - userAgent.contains("Bingbot") - path.startsWith("/admin/") name: robots-txt-policy-disallow-9 - action: DENY expression: userAgent.contains("BadBot") name: robots-txt-policy-blacklist-10 - action: WEIGH expression: userAgent.contains("SeoBot") name: robots-txt-policy-crawl-delay-11 weight: adjust: 5 - action: DENY expression: userAgent.contains("SeoBot") name: robots-txt-policy-blacklist-12 - action: CHALLENGE expression: all: - userAgent.contains("TestBot") - path.matches("^/.*/admin") name: robots-txt-policy-disallow-13 - action: CHALLENGE expression: all: - userAgent.contains("TestBot") - path.matches("^/temp.*\\.html") name: robots-txt-policy-disallow-14 - action: CHALLENGE expression: all: - userAgent.contains("TestBot") - path.matches("^/file.\\.log") name: robots-txt-policy-disallow-15 ================================================ FILE: cmd/robots2policy/testdata/consecutive.robots.txt ================================================ # Test consecutive user agents that should be grouped into any: blocks User-agent: * Disallow: /admin Crawl-delay: 10 # Multiple consecutive user agents - should be grouped User-agent: BadBot User-agent: SpamBot User-agent: EvilBot Disallow: / # Single user agent - should be separate User-agent: GoodBot Disallow: /private # Multiple consecutive user agents with crawl delay User-agent: SlowBot1 User-agent: SlowBot2 Crawl-delay: 5 # Multiple consecutive user agents with specific path User-agent: SearchBot1 User-agent: SearchBot2 User-agent: SearchBot3 Disallow: /search ================================================ FILE: cmd/robots2policy/testdata/consecutive.yaml ================================================ - action: WEIGH expression: "true" name: robots-txt-policy-crawl-delay-1 weight: adjust: 3 - action: CHALLENGE expression: path.startsWith("/admin") name: robots-txt-policy-disallow-2 - action: DENY expression: any: - userAgent.contains("BadBot") - userAgent.contains("SpamBot") - userAgent.contains("EvilBot") name: robots-txt-policy-blacklist-3 - action: CHALLENGE expression: all: - userAgent.contains("GoodBot") - path.startsWith("/private") name: robots-txt-policy-disallow-4 - action: WEIGH expression: any: - userAgent.contains("SlowBot1") - userAgent.contains("SlowBot2") name: robots-txt-policy-crawl-delay-5 weight: adjust: 3 - action: CHALLENGE expression: all: - userAgent.contains("SearchBot1") - path.startsWith("/search") name: robots-txt-policy-disallow-7 - action: CHALLENGE expression: all: - userAgent.contains("SearchBot2") - path.startsWith("/search") name: robots-txt-policy-disallow-8 - action: CHALLENGE expression: all: - userAgent.contains("SearchBot3") - path.startsWith("/search") name: robots-txt-policy-disallow-9 ================================================ FILE: cmd/robots2policy/testdata/custom-name.yaml ================================================ - action: CHALLENGE expression: path.startsWith("/admin/") name: my-custom-policy-disallow-1 - action: CHALLENGE expression: path.startsWith("/private") name: my-custom-policy-disallow-2 ================================================ FILE: cmd/robots2policy/testdata/deny-action.yaml ================================================ - action: DENY expression: path.startsWith("/admin/") name: robots-txt-policy-disallow-1 - action: DENY expression: path.startsWith("/private") name: robots-txt-policy-disallow-2 ================================================ FILE: cmd/robots2policy/testdata/empty.robots.txt ================================================ # Empty robots.txt (comments only) # No actual rules ================================================ FILE: cmd/robots2policy/testdata/empty.yaml ================================================ [] ================================================ FILE: cmd/robots2policy/testdata/simple.json ================================================ [ { "expression": "path.startsWith(\"/admin/\")", "name": "robots-txt-policy-disallow-1", "action": "CHALLENGE" }, { "expression": "path.startsWith(\"/private\")", "name": "robots-txt-policy-disallow-2", "action": "CHALLENGE" } ] ================================================ FILE: cmd/robots2policy/testdata/simple.robots.txt ================================================ # Simple robots.txt test User-agent: * Disallow: /admin/ Disallow: /private Allow: /public ================================================ FILE: cmd/robots2policy/testdata/simple.yaml ================================================ - action: CHALLENGE expression: path.startsWith("/admin/") name: robots-txt-policy-disallow-1 - action: CHALLENGE expression: path.startsWith("/private") name: robots-txt-policy-disallow-2 ================================================ FILE: cmd/robots2policy/testdata/wildcards.robots.txt ================================================ # Test wildcard patterns User-agent: * Disallow: /search* Disallow: /*/private Disallow: /file?.txt Disallow: /admin/*?action=delete ================================================ FILE: cmd/robots2policy/testdata/wildcards.yaml ================================================ - action: CHALLENGE expression: path.matches("^/search.*") name: robots-txt-policy-disallow-1 - action: CHALLENGE expression: path.matches("^/.*/private") name: robots-txt-policy-disallow-2 - action: CHALLENGE expression: path.matches("^/file.\\.txt") name: robots-txt-policy-disallow-3 - action: CHALLENGE expression: path.matches("^/admin/.*.action=delete") name: robots-txt-policy-disallow-4 ================================================ FILE: data/apps/allow-api-routes.yaml ================================================ - name: allow-api-routes action: ALLOW expression: all: - '!(method == "HEAD" || method == "GET")' - path.startsWith("/api/") ================================================ FILE: data/apps/bookstack-saml.yaml ================================================ # Make SASL login work on bookstack with Anubis # https://www.bookstackapp.com/docs/admin/saml2-auth/ - name: allow-bookstack-sasl-login-routes action: ALLOW expression: all: - 'method == "POST"' - path.startsWith("/saml2/acs") - name: allow-bookstack-sasl-metadata-routes action: ALLOW expression: all: - 'method == "GET"' - path.startsWith("/saml2/metadata") - name: allow-bookstack-sasl-logout-routes action: ALLOW expression: all: - 'method == "GET"' - path.startsWith("/saml2/sls") ================================================ FILE: data/apps/gitea-rss-feeds.yaml ================================================ # By Aibrew: https://github.com/TecharoHQ/anubis/discussions/261#discussioncomment-12821065 - name: gitea-feed-atom action: ALLOW path_regex: ^/[.A-Za-z0-9_-]{1,256}?[./A-Za-z0-9_-]*\.atom$ - name: gitea-feed-rss action: ALLOW path_regex: ^/[.A-Za-z0-9_-]{1,256}?[./A-Za-z0-9_-]*\.rss$ ================================================ FILE: data/apps/qualys-ssl-labs.yml ================================================ # This policy allows Qualys SSL Labs to fully work. (https://www.ssllabs.com/ssltest) # IP ranges are taken from: https://qualys.my.site.com/discussions/s/article/000005823 - name: qualys-ssl-labs action: ALLOW remote_addresses: - 69.67.183.0/24 - 2600:C02:1020:4202::/64 - 2602:fdaa:c6:2::/64 ================================================ FILE: data/apps/searx-checker.yml ================================================ # This policy allows SearXNG's instance tracker to work. (https://searx.space) # IPs are taken from `check.searx.space` DNS records. # https://toolbox.googleapps.com/apps/dig/#A/check.searx.space # https://toolbox.googleapps.com/apps/dig/#AAAA/check.searx.space - name: searx-checker action: ALLOW remote_addresses: - 167.235.158.251/32 - 2a01:4f8:1c1c:8fc2::1/128 ================================================ FILE: data/botPolicies.yaml ================================================ ## Anubis has the ability to let you import snippets of configuration into the main ## configuration file. This allows you to break up your config into smaller parts ## that get logically assembled into one big file. ## ## Of note, a bot rule can either have inline bot configuration or import a ## bot config snippet. You cannot do both in a single bot rule. ## ## Import paths can either be prefixed with (data) to import from the common/shared ## rules in the data folder in the Anubis source tree or will point to absolute/relative ## paths in your filesystem. If you don't have access to the Anubis source tree, check ## /usr/share/docs/anubis/data or in the tarball you extracted Anubis from. bots: # You can import the entire default config with this macro: # - import: (data)/meta/default-config.yaml # Pathological bots to deny - # This correlates to data/bots/_deny-pathological.yaml in the source tree # https://github.com/TecharoHQ/anubis/blob/main/data/bots/_deny-pathological.yaml import: (data)/bots/_deny-pathological.yaml - import: (data)/bots/aggressive-brazilian-scrapers.yaml # Aggressively block AI/LLM related bots/agents by default - import: (data)/meta/ai-block-aggressive.yaml # Consider replacing the aggressive AI policy with more selective policies: # - import: (data)/meta/ai-block-moderate.yaml # - import: (data)/meta/ai-block-permissive.yaml # Search engine crawlers to allow, defaults to: # - Google (so they don't try to bypass Anubis) # - Apple # - Bing # - DuckDuckGo # - Qwant # - The Internet Archive # - Kagi # - Marginalia # - Mojeek - import: (data)/crawlers/_allow-good.yaml # Challenge Firefox AI previews - import: (data)/clients/x-firefox-ai.yaml # Allow common "keeping the internet working" routes (well-known, favicon, robots.txt) - import: (data)/common/keep-internet-working.yaml # # Punish any bot with "bot" in the user-agent string # # This is known to have a high false-positive rate, use at your own risk # - name: generic-bot-catchall # user_agent_regex: (?i:bot|crawler) # action: CHALLENGE # challenge: # difficulty: 16 # impossible # algorithm: slow # intentionally waste CPU cycles and time # Requires a subscription to Thoth to use, see # https://anubis.techaro.lol/docs/admin/thoth#geoip-based-filtering - name: countries-with-aggressive-scrapers action: WEIGH geoip: countries: - BR - CN weight: adjust: 10 # Requires a subscription to Thoth to use, see # https://anubis.techaro.lol/docs/admin/thoth#asn-based-filtering - name: aggressive-asns-without-functional-abuse-contact action: WEIGH asns: match: - 13335 # Cloudflare - 136907 # Huawei Cloud - 45102 # Alibaba Cloud weight: adjust: 10 # ## System load based checks. # # If the system is under high load, add weight. # - name: high-load-average # action: WEIGH # expression: load_1m >= 10.0 # make sure to end the load comparison in a .0 # weight: # adjust: 20 ## If your backend service is running on the same operating system as Anubis, ## you can uncomment this rule to make the challenge easier when the system is ## under low load. ## ## If it is not, remove weight. # - name: low-load-average # action: WEIGH # expression: load_15m <= 4.0 # make sure to end the load comparison in a .0 # weight: # adjust: -10 # Generic catchall rule - name: generic-browser user_agent_regex: >- Mozilla|Opera action: WEIGH weight: adjust: 10 dnsbl: false # # # impressum: # # Displayed at the bottom of every page rendered by Anubis. # footer: >- # This website is hosted by Zombocom. If you have any complaints or notes # about the service, please contact # contact@domainhere.example # and we will assist you as soon as possible. # # The imprint page that will be linked to at the footer of every Anubis page. # page: # # The HTML of the page # title: Imprint and Privacy Policy # # The HTML contents of the page. The exact contents of this page can # # and will vary by locale. Please consult with a lawyer if you are not # # sure what to put here # body: >- # <p>Last updated: June 2025</p> # <h2>Information that is gathered from visitors</h2> # <p>In common with other websites, log files are stored on the web server saving details such as the visitor's IP address, browser type, referring page and time of visit.</p> # <p>Cookies may be used to remember visitor preferences when interacting with the website.</p> # <p>Where registration is required, the visitor's email and a username will be stored on the server.</p> # <!-- ... --> # Open Graph passthrough configuration, see here for more information: # https://anubis.techaro.lol/docs/admin/configuration/open-graph/ openGraph: # Enables Open Graph passthrough enabled: false # Enables the use of the HTTP host in the cache key, this enables # caching metadata for multiple http hosts at once. considerHost: false # How long cached OpenGraph metadata should last in memory ttl: 24h # # If set, return these opengraph values instead of looking them up with # # the target service. # # # # Correlates to properties in https://ogp.me/ # override: # # og:title is required, it is the title of the website # "og:title": "Techaro Anubis" # "og:description": >- # Anubis is a Web AI Firewall Utility that helps you fight the bots # away so that you can maintain uptime at work! # "description": >- # Anubis is a Web AI Firewall Utility that helps you fight the bots # away so that you can maintain uptime at work! # By default, send HTTP 200 back to clients that either get issued a challenge # or a denial. This seems weird, but this is load-bearing due to the fact that # the most aggressive scraper bots seem to really, really, want an HTTP 200 and # will stop sending requests once they get it. status_codes: CHALLENGE: 200 DENY: 200 # Anubis can store temporary data in one of a few backends. See the storage # backends section of the docs for more information: # # https://anubis.techaro.lol/docs/admin/policies#storage-backends store: backend: memory parameters: {} # The weight thresholds for when to trigger individual challenges. Any # CHALLENGE will take precedence over this. # # A threshold has four configuration options: # # - name: the name that is reported down the stack and used for metrics # - expression: A CEL expression with the request weight in the variable # weight # - action: the Anubis action to apply, similar to in a bot policy # - challenge: which challenge to send to the user, similar to in a bot policy # # See https://anubis.techaro.lol/docs/admin/configuration/thresholds for more # information. thresholds: # By default Anubis ships with the following thresholds: - name: minimal-suspicion # This client is likely fine, its soul is lighter than a feather expression: weight <= 0 # a feather weighs zero units action: ALLOW # Allow the traffic through # For clients that had some weight reduced through custom rules, give them a # lightweight challenge. - name: mild-suspicion expression: all: - weight > 0 - weight < 10 action: CHALLENGE challenge: # https://anubis.techaro.lol/docs/admin/configuration/challenges/metarefresh algorithm: metarefresh difficulty: 1 # For clients that are browser-like but have either gained points from custom rules or # report as a standard browser. - name: moderate-suspicion expression: all: - weight >= 10 - weight < 20 action: CHALLENGE challenge: # https://anubis.techaro.lol/docs/admin/configuration/challenges/proof-of-work algorithm: fast difficulty: 2 # two leading zeros, very fast for most clients - name: mild-proof-of-work expression: all: - weight >= 20 - weight < 30 action: CHALLENGE challenge: # https://anubis.techaro.lol/docs/admin/configuration/challenges/proof-of-work algorithm: fast difficulty: 4 # For clients that are browser like and have gained many points from custom rules - name: extreme-suspicion expression: weight >= 30 action: CHALLENGE challenge: # https://anubis.techaro.lol/docs/admin/configuration/challenges/proof-of-work algorithm: fast difficulty: 6 ================================================ FILE: data/bots/_deny-pathological.yaml ================================================ - import: (data)/bots/cloudflare-workers.yaml - import: (data)/bots/headless-browsers.yaml - import: (data)/bots/us-ai-scraper.yaml - import: (data)/bots/custom-async-http-client.yaml - import: (data)/crawlers/alibaba-cloud.yaml - import: (data)/crawlers/huawei-cloud.yaml ================================================ FILE: data/bots/aggressive-brazilian-scrapers.yaml ================================================ - name: deny-aggressive-brazilian-scrapers action: WEIGH weight: adjust: 20 expression: any: # Internet Explorer should be out of support - userAgent.contains("MSIE") # Trident is the Internet Explorer browser engine - userAgent.contains("Trident") # Opera is a fork of chrome now - userAgent.contains("Presto") # Windows CE is discontinued - userAgent.contains("Windows CE") # Windows 95 is discontinued - userAgent.contains("Windows 95") # Windows 98 is discontinued - userAgent.contains("Windows 98") # Windows 9.x is discontinued - userAgent.contains("Win 9x") # Amazon does not have an Alexa Toolbar. - userAgent.contains("Alexa Toolbar") # This is not released, even Windows 11 calls itself Windows 10 - userAgent.contains("Windows NT 11.0") # iPods are not in common use - userAgent.contains("iPod") ================================================ FILE: data/bots/ai-catchall.yaml ================================================ # Extensive list of AI-affiliated agents based on https://github.com/ai-robots-txt/ai.robots.txt # Add new/undocumented agents here. Where documentation exists, consider moving to dedicated policy files. # Notes on various agents: # - Amazonbot: Well documented, but they refuse to state which agent collects training data. # - anthropic-ai/Claude-Web: Undocumented by Anthropic. Possibly deprecated or hallucinations? # - Perplexity*: Well documented, but they refuse to state which agent collects training data. # Warning: May contain user agents that _must_ be blocked in robots.txt, or the opt-out will have no effect. - name: "ai-catchall" user_agent_regex: >- AI2Bot|Ai2Bot-Dolma|aiHitBot|Amazonbot|anthropic-ai|Brightbot 1.0|Bytespider|Claude-Web|cohere-ai|cohere-training-data-crawler|Cotoyogi|Crawlspace|Diffbot|DuckAssistBot|FacebookBot|Factset_spyderbot|FirecrawlAgent|FriendlyCrawler|Google-CloudVertexBot|GoogleOther|GoogleOther-Image|GoogleOther-Video|iaskspider/2.0|ICC-Crawler|ImagesiftBot|img2dataset|imgproxy|ISSCyberRiskCrawler|Kangaroo Bot|meta-externalagent|Meta-ExternalAgent|meta-externalfetcher|Meta-ExternalFetcher|NovaAct|omgili|omgilibot|Operator|PanguBot|Perplexity-User|PerplexityBot|PetalBot|QualifiedBot|Scrapy|SemrushBot-OCOB|SemrushBot-SWA|Sidetrade indexer bot|TikTokSpider|Timpibot|VelenPublicWebCrawler|Webzio-Extended|wpbot|YouBot action: DENY ================================================ FILE: data/bots/ai-robots-txt.yaml ================================================ # Warning: Contains user agents that _must_ be blocked in robots.txt, or the opt-out will have no effect. # Note: Blocks human-directed/non-training user agents # # CCBot is allowed because if Common Crawl is allowed, then scrapers don't need to scrape to get the data. - name: "ai-robots-txt" user_agent_regex: >- AddSearchBot|AI2Bot|Ai2Bot-Dolma|aiHitBot|Amazonbot|Andibot|anthropic-ai|Applebot|Applebot-Extended|Awario|bedrockbot|bigsur.ai|Brightbot 1.0|Bytespider|CCBot|ChatGPT Agent|ChatGPT-User|Claude-SearchBot|Claude-User|Claude-Web|ClaudeBot|CloudVertexBot|cohere-ai|cohere-training-data-crawler|Cotoyogi|Crawlspace|Datenbank Crawler|Devin|Diffbot|DuckAssistBot|Echobot Bot|EchoboxBot|FacebookBot|facebookexternalhit|Factset_spyderbot|FirecrawlAgent|FriendlyCrawler|Gemini-Deep-Research|Google-CloudVertexBot|Google-Extended|GoogleAgent-Mariner|GoogleOther|GoogleOther-Image|GoogleOther-Video|GPTBot|iaskspider/2.0|ICC-Crawler|ImagesiftBot|img2dataset|ISSCyberRiskCrawler|Kangaroo Bot|LinerBot|meta-externalagent|Meta-ExternalAgent|meta-externalfetcher|Meta-ExternalFetcher|MistralAI-User|MistralAI-User/1.0|MyCentralAIScraperBot|netEstate Imprint Crawler|NovaAct|OAI-SearchBot|omgili|omgilibot|OpenAI|Operator|PanguBot|Panscient|panscient.com|Perplexity-User|PerplexityBot|PetalBot|PhindBot|Poseidon Research Crawler|QualifiedBot|QuillBot|quillbot.com|SBIntuitionsBot|Scrapy|SemrushBot-OCOB|SemrushBot-SWA|Sidetrade indexer bot|Thinkbot|TikTokSpider|Timpibot|VelenPublicWebCrawler|WARDBot|Webzio-Extended|wpbot|YaK|YandexAdditional|YandexAdditionalBot|YouBot action: DENY ================================================ FILE: data/bots/cloudflare-workers.yaml ================================================ - name: cloudflare-workers headers_regex: CF-Worker: .* action: WEIGH weight: adjust: 15 ================================================ FILE: data/bots/custom-async-http-client.yaml ================================================ - name: "custom-async-http-client" user_agent_regex: "Custom-AsyncHttpClient" action: WEIGH weight: adjust: 10 ================================================ FILE: data/bots/headless-browsers.yaml ================================================ - name: lightpanda user_agent_regex: ^LightPanda/.*$ action: DENY - name: headless-chrome user_agent_regex: HeadlessChrome action: DENY - name: headless-chromium user_agent_regex: HeadlessChromium action: DENY ================================================ FILE: data/bots/irc-bots/archlinux-phrik.yaml ================================================ # phrik in the Arch Linux IRC channels - name: archlinux-phrik action: ALLOW expression: all: - remoteAddress == "159.69.213.214" || remoteAddress == "2a01:4f8:c2c:7bf4::1" - userAgent == "Mozilla/5.0 (compatible; utils.web Limnoria module)" - '"X-Http-Version" in headers' - headers["X-Http-Version"] == "HTTP/1.1" ================================================ FILE: data/bots/irc-bots/gentoo-chat.yaml ================================================ # chat in the gentoo IRC channels - name: gentoo-chat action: ALLOW expression: all: - remoteAddress == "45.76.166.57" - userAgent == "Mozilla/5.0 (Linux x86_64; rv:76.0) Gecko/20100101 Firefox/76.0" - '"X-Http-Version" in headers' - headers["X-Http-Version"] == "HTTP/1.1" ================================================ FILE: data/bots/us-ai-scraper.yaml ================================================ - name: us-artificial-intelligence-scraper user_agent_regex: \+https\://github\.com/US-Artificial-Intelligence/scraper action: DENY ================================================ FILE: data/clients/ai.yaml ================================================ # User agents that act on behalf of humans in AI tools, e.g. searching the web. # Each entry should have a positive/ALLOW entry created as well, with further documentation. # Exceptions: # - Claude-User: No published IP allowlist - name: "ai-clients" user_agent_regex: >- ChatGPT-User|Claude-User|MistralAI-User|Perplexity-User action: DENY ================================================ FILE: data/clients/docker-client.yaml ================================================ - name: allow-docker-client action: ALLOW expression: all: - path.startsWith("/v2/") - userAgent.contains("docker/") - userAgent.contains("git-commit/") - '"Accept" in headers' - headers["Accept"].contains("vnd.docker.distribution") - '"Baggage" in headers' - headers["Baggage"].contains("trigger") - name: allow-crane-client action: ALLOW expression: all: - userAgent.contains("crane/") - userAgent.contains("go-containerregistry/") - name: allow-docker-distribution-api-client action: ALLOW expression: all: - '"Docker-Distribution-Api-Version" in headers' - '!(userAgent.contains("Mozilla"))' - name: allow-go-containerregistry-client action: ALLOW expression: all: - path.startsWith("/v2/") - userAgent.contains("go-containerregistry/") - name: allow-buildah action: ALLOW expression: all: - path.startsWith("/v2/") - userAgent.contains("Buildah/") - name: allow-podman action: ALLOW expression: all: - path.startsWith("/v2/") - userAgent.contains("containers/") - name: allow-containerd action: ALLOW expression: all: - path.startsWith("/v2/") - userAgent.contains("containerd/") - name: allow-renovate action: ALLOW expression: all: - path.startsWith("/v2/") - userAgent.contains("Renovate/") ================================================ FILE: data/clients/git.yaml ================================================ - name: allow-git-clients action: ALLOW expression: all: - > ( userAgent.startsWith("git/") || userAgent.contains("libgit") || userAgent.startsWith("go-git") || userAgent.startsWith("JGit/") || userAgent.startsWith("JGit-") ) - '"Accept" in headers' - headers["Accept"] == "*/*" - '"Cache-Control" in headers' - headers["Cache-Control"] == "no-cache" - '"Pragma" in headers' - headers["Pragma"] == "no-cache" - '"Accept-Encoding" in headers' - headers["Accept-Encoding"].contains("gzip") ================================================ FILE: data/clients/go-get.yaml ================================================ - name: go-get action: ALLOW expression: all: - userAgent.startsWith("Go-http-client/") - '"go-get" in query' - query["go-get"] == "1" ================================================ FILE: data/clients/mistral-mistralai-user.yaml ================================================ # Acts on behalf of user requests # https://docs.mistral.ai/robots/ - name: mistral-mistralai-user user_agent_regex: MistralAI-User/.+; \+https\://docs\.mistral\.ai/robots action: ALLOW # https://mistral.ai/mistralai-user-ips.json remote_addresses: ["20.240.160.161/32", "20.240.160.1/32"] ================================================ FILE: data/clients/openai-chatgpt-user.yaml ================================================ # Acts on behalf of user requests # https://platform.openai.com/docs/bots/overview-of-openai-crawlers - name: openai-chatgpt-user user_agent_regex: ChatGPT-User/.+; \+https\://openai\.com/bot action: ALLOW # https://openai.com/chatgpt-user.json # curl 'https://openai.com/chatgpt-user.json' | jq '.prefixes.[].ipv4Prefix' | sed 's/$/,/' remote_addresses: [ "13.65.138.112/28", "23.98.179.16/28", "13.65.138.96/28", "172.183.222.128/28", "20.102.212.144/28", "40.116.73.208/28", "172.183.143.224/28", "52.190.190.16/28", "13.83.237.176/28", "51.8.155.64/28", "74.249.86.176/28", "51.8.155.48/28", "20.55.229.144/28", "135.237.131.208/28", "135.237.133.48/28", "51.8.155.112/28", "135.237.133.112/28", "52.159.249.96/28", "52.190.137.16/28", "52.255.111.112/28", "40.84.181.32/28", "172.178.141.112/28", "52.190.142.64/28", "172.178.140.144/28", "52.190.137.144/28", "172.178.141.128/28", "57.154.187.32/28", "4.196.118.112/28", "20.193.50.32/28", "20.215.188.192/28", "20.215.214.16/28", "4.197.22.112/28", "4.197.115.112/28", "172.213.21.16/28", "172.213.11.144/28", "172.213.12.112/28", "172.213.21.144/28", "20.90.7.144/28", "57.154.175.0/28", "57.154.174.112/28", "52.236.94.144/28", "137.135.191.176/28", "23.98.186.192/28", "23.98.186.96/28", "23.98.186.176/28", "23.98.186.64/28", "68.221.67.192/28", "68.221.67.160/28", "13.83.167.128/28", "20.228.106.176/28", "52.159.227.32/28", "68.220.57.64/28", "172.213.21.112/28", "68.221.67.224/28", "68.221.75.16/28", "20.97.189.96/28", "52.252.113.240/28", "52.230.163.32/28", "172.212.159.64/28", "52.255.111.80/28", "52.255.111.0/28", "4.151.241.240/28", "52.255.111.32/28", "52.255.111.48/28", "52.255.111.16/28", "52.230.164.176/28", "52.176.139.176/28", "52.173.234.16/28", "4.151.71.176/28", "4.151.119.48/28", "52.255.109.112/28", "52.255.109.80/28", "20.161.75.208/28", "68.154.28.96/28", "52.255.109.128/28", "52.225.75.208/28", "52.190.139.48/28", "68.221.67.240/28", "52.156.77.144/28", "52.148.129.32/28", "40.84.221.208/28", "104.210.139.224/28", "40.84.221.224/28", "104.210.139.192/28", ] ================================================ FILE: data/clients/perplexity-user.yaml ================================================ # Acts on behalf of user requests # https://docs.perplexity.ai/guides/bots - name: perplexity-user user_agent_regex: Perplexity-User/.+; \+https\://perplexity\.ai/perplexity-user action: ALLOW # https://www.perplexity.com/perplexity-user.json remote_addresses: ["44.208.221.197/32", "34.193.163.52/32", "18.97.21.0/30", "18.97.43.80/29"] ================================================ FILE: data/clients/small-internet-browsers/_permissive.yaml ================================================ - import: (data)/clients/small-internet-browsers/netsurf.yaml - import: (data)/clients/small-internet-browsers/palemoon.yaml ================================================ FILE: data/clients/small-internet-browsers/netsurf.yaml ================================================ - name: "reduce-weight-netsurf" user_agent_regex: "NetSurf" action: WEIGH weight: adjust: -5 ================================================ FILE: data/clients/small-internet-browsers/palemoon.yaml ================================================ - name: "reduce-weight-palemoon" user_agent_regex: "PaleMoon" action: WEIGH weight: adjust: -5 ================================================ FILE: data/clients/telegram-preview.yaml ================================================ - name: telegrambot action: ALLOW expression: all: - userAgent.matches("TelegramBot") - verifyFCrDNS(remoteAddress, "ptr\\.telegram\\.org$") ================================================ FILE: data/clients/vk-preview.yaml ================================================ - name: vkbot action: ALLOW expression: all: - userAgent.matches("vkShare[^+]+\\+http\\://vk\\.com/dev/Share") - verifyFCrDNS(remoteAddress, "^snipster\\d+\\.go\\.mail\\.ru$") ================================================ FILE: data/clients/x-firefox-ai.yaml ================================================ # https://connect.mozilla.org/t5/firefox-labs/try-out-link-previews-in-firefox-labs-138-and-share-your/td-p/92012 - name: x-firefox-ai action: WEIGH expression: '"X-Firefox-Ai" in headers' weight: adjust: 5 ================================================ FILE: data/common/acts-like-browser.yaml ================================================ # Assert behaviour that only genuine browsers display. This ensures that modern Chrome # or Firefox versions will get through without a challenge. # # These rules have been known to be bypassed by some of the worst automated scrapers. # Use at your own risk. - name: realistic-browser-catchall expression: all: - '"User-Agent" in headers' - '( userAgent.contains("Firefox") ) || ( userAgent.contains("Chrome") ) || ( userAgent.contains("Safari") )' - '"Accept" in headers' - '"Sec-Fetch-Dest" in headers' - '"Sec-Fetch-Mode" in headers' - '"Sec-Fetch-Site" in headers' - '"Accept-Encoding" in headers' - '( headers["Accept-Encoding"].contains("zstd") || headers["Accept-Encoding"].contains("br") )' - '"Accept-Language" in headers' action: WEIGH weight: adjust: -10 # The Upgrade-Insecure-Requests header is typically sent by browsers, but not always - name: upgrade-insecure-requests expression: '"Upgrade-Insecure-Requests" in headers' action: WEIGH weight: adjust: -2 # Chrome should behave like Chrome - name: chrome-is-proper expression: all: - userAgent.contains("Chrome") - '"Sec-Ch-Ua" in headers' - 'headers["Sec-Ch-Ua"].contains("Chromium")' - '"Sec-Ch-Ua-Mobile" in headers' - '"Sec-Ch-Ua-Platform" in headers' action: WEIGH weight: adjust: -5 - name: should-have-accept expression: '!("Accept" in headers)' action: WEIGH weight: adjust: 5 # Generic catchall rule - name: generic-browser user_agent_regex: >- Mozilla|Opera action: WEIGH weight: adjust: 10 ================================================ FILE: data/common/allow-api-like.yaml ================================================ - name: allow-api-routes action: ALLOW expression: all: - '!(method == "HEAD" || method == "GET")' - path.startsWith("/api/") ================================================ FILE: data/common/allow-private-addresses.yaml ================================================ - name: ipv4-rfc-1918 action: ALLOW remote_addresses: - 10.0.0.0/8 - 172.16.0.0/12 - 192.168.0.0/16 - 100.64.0.0/10 - name: ipv6-ula action: ALLOW remote_addresses: - fc00::/7 - name: ipv6-link-local action: ALLOW remote_addresses: - fe80::/10 ================================================ FILE: data/common/json-api.yaml ================================================ - name: allow-api-requests action: ALLOW expression: all: - '"Accept" in headers' - 'headers["Accept"] == "application/json"' - 'path.startsWith("/api/")' ================================================ FILE: data/common/keep-internet-working.yaml ================================================ # Common "keeping the internet working" routes - name: well-known path_regex: ^/\.well-known/.*$ action: ALLOW - name: favicon path_regex: ^/favicon\.(?:ico|png|gif|jpg|jpeg|svg)$ action: ALLOW - name: robots-txt path_regex: ^/robots\.txt$ action: ALLOW - name: sitemap path_regex: ^/sitemap\.xml$ action: ALLOW ================================================ FILE: data/common/rfc-violations.yaml ================================================ - name: no-user-agent-string action: DENY expression: userAgent == "" ================================================ FILE: data/crawlers/_allow-good.yaml ================================================ - import: (data)/crawlers/googlebot.yaml - import: (data)/crawlers/applebot.yaml - import: (data)/crawlers/bingbot.yaml - import: (data)/crawlers/duckduckbot.yaml - import: (data)/crawlers/qwantbot.yaml - import: (data)/crawlers/internet-archive.yaml - import: (data)/crawlers/kagibot.yaml - import: (data)/crawlers/marginalia.yaml - import: (data)/crawlers/mojeekbot.yaml - import: (data)/crawlers/commoncrawl.yaml - import: (data)/crawlers/wikimedia-citoid.yaml - import: (data)/crawlers/yandexbot.yaml ================================================ FILE: data/crawlers/ai-search.yaml ================================================ # User agents that index exclusively for search in for AI systems. # Each entry should have a positive/ALLOW entry created as well, with further documentation. # Exceptions: # - Claude-SearchBot: No published IP allowlist - name: "ai-crawlers-search" user_agent_regex: >- OAI-SearchBot|Claude-SearchBot|PerplexityBot action: DENY ================================================ FILE: data/crawlers/ai-training.yaml ================================================ # User agents that crawl for training AI/LLM systems # Each entry should have a positive/ALLOW entry created as well, with further documentation. # Exceptions: # - ClaudeBot: No published IP allowlist - name: "ai-crawlers-training" user_agent_regex: >- GPTBot|ClaudeBot action: DENY ================================================ FILE: data/crawlers/alibaba-cloud.yaml ================================================ - name: alibaba-cloud action: DENY # Updated 2025-08-20 from IP addresses for AS45102 remote_addresses: - 103.81.186.0/23 - 110.76.21.0/24 - 110.76.23.0/24 - 116.251.64.0/18 - 139.95.0.0/23 - 139.95.10.0/23 - 139.95.12.0/23 - 139.95.14.0/23 - 139.95.16.0/23 - 139.95.18.0/23 - 139.95.2.0/23 - 139.95.4.0/23 - 139.95.6.0/23 - 139.95.64.0/24 - 139.95.8.0/23 - 14.1.112.0/22 - 14.1.115.0/24 - 140.205.1.0/24 - 140.205.122.0/24 - 147.139.0.0/17 - 147.139.0.0/18 - 147.139.128.0/17 - 147.139.128.0/18 - 147.139.155.0/24 - 147.139.192.0/18 - 147.139.64.0/18 - 149.129.0.0/20 - 149.129.0.0/21 - 149.129.16.0/23 - 149.129.192.0/18 - 149.129.192.0/19 - 149.129.224.0/19 - 149.129.32.0/19 - 149.129.64.0/18 - 149.129.64.0/19 - 149.129.8.0/21 - 149.129.96.0/19 - 156.227.20.0/24 - 156.236.12.0/24 - 156.236.17.0/24 - 156.240.76.0/23 - 156.245.1.0/24 - 161.117.0.0/16 - 161.117.0.0/17 - 161.117.126.0/24 - 161.117.127.0/24 - 161.117.128.0/17 - 161.117.128.0/24 - 161.117.129.0/24 - 161.117.138.0/24 - 161.117.143.0/24 - 170.33.104.0/24 - 170.33.105.0/24 - 170.33.106.0/24 - 170.33.107.0/24 - 170.33.136.0/24 - 170.33.137.0/24 - 170.33.138.0/24 - 170.33.20.0/24 - 170.33.21.0/24 - 170.33.22.0/24 - 170.33.23.0/24 - 170.33.24.0/24 - 170.33.29.0/24 - 170.33.30.0/24 - 170.33.31.0/24 - 170.33.32.0/24 - 170.33.33.0/24 - 170.33.34.0/24 - 170.33.35.0/24 - 170.33.64.0/24 - 170.33.65.0/24 - 170.33.66.0/24 - 170.33.68.0/24 - 170.33.69.0/24 - 170.33.72.0/24 - 170.33.73.0/24 - 170.33.76.0/24 - 170.33.77.0/24 - 170.33.78.0/24 - 170.33.79.0/24 - 170.33.80.0/24 - 170.33.81.0/24 - 170.33.82.0/24 - 170.33.83.0/24 - 170.33.84.0/24 - 170.33.85.0/24 - 170.33.86.0/24 - 170.33.88.0/24 - 170.33.90.0/24 - 170.33.92.0/24 - 170.33.93.0/24 - 185.78.106.0/23 - 198.11.128.0/18 - 198.11.137.0/24 - 198.11.184.0/21 - 202.144.199.0/24 - 203.107.64.0/24 - 203.107.65.0/24 - 203.107.66.0/24 - 203.107.67.0/24 - 203.107.68.0/24 - 205.204.102.0/23 - 205.204.111.0/24 - 205.204.117.0/24 - 205.204.125.0/24 - 205.204.96.0/19 - 223.5.5.0/24 - 223.6.6.0/24 - 2400:3200::/48 - 2400:3200:baba::/48 - 2400:b200:4100::/48 - 2400:b200:4101::/48 - 2400:b200:4102::/48 - 2400:b200:4103::/48 - 2401:8680:4100::/48 - 2401:b180:4100::/48 - 2404:2280:1000::/36 - 2404:2280:1000::/37 - 2404:2280:1800::/37 - 2404:2280:2000::/36 - 2404:2280:2000::/37 - 2404:2280:2800::/37 - 2404:2280:3000::/36 - 2404:2280:3000::/37 - 2404:2280:3800::/37 - 2404:2280:4000::/36 - 2404:2280:4000::/37 - 2404:2280:4800::/37 - 2408:4000:1000::/48 - 2408:4009:500::/48 - 240b:4000::/32 - 240b:4000::/33 - 240b:4000:8000::/33 - 240b:4000:fffe::/48 - 240b:4001::/32 - 240b:4001::/33 - 240b:4001:8000::/33 - 240b:4002::/32 - 240b:4002::/33 - 240b:4002:8000::/33 - 240b:4004::/32 - 240b:4004::/33 - 240b:4004:8000::/33 - 240b:4005::/32 - 240b:4005::/33 - 240b:4005:8000::/33 - 240b:4006::/48 - 240b:4006:1000::/44 - 240b:4006:1000::/45 - 240b:4006:1000::/47 - 240b:4006:1002::/47 - 240b:4006:1008::/45 - 240b:4006:1010::/44 - 240b:4006:1010::/45 - 240b:4006:1018::/45 - 240b:4006:1020::/44 - 240b:4006:1020::/45 - 240b:4006:1028::/45 - 240b:4007::/32 - 240b:4007::/33 - 240b:4007:8000::/33 - 240b:4009::/32 - 240b:4009::/33 - 240b:4009:8000::/33 - 240b:400b::/32 - 240b:400b::/33 - 240b:400b:8000::/33 - 240b:400c::/32 - 240b:400c::/33 - 240b:400c::/40 - 240b:400c::/41 - 240b:400c:100::/40 - 240b:400c:100::/41 - 240b:400c:180::/41 - 240b:400c:80::/41 - 240b:400c:8000::/33 - 240b:400c:f00::/48 - 240b:400c:f01::/48 - 240b:400c:ffff::/48 - 240b:400d::/32 - 240b:400d::/33 - 240b:400d:8000::/33 - 240b:400e::/32 - 240b:400e::/33 - 240b:400e:8000::/33 - 240b:400f::/32 - 240b:400f::/33 - 240b:400f:8000::/33 - 240b:4011::/32 - 240b:4011::/33 - 240b:4011:8000::/33 - 240b:4012::/48 - 240b:4013::/32 - 240b:4013::/33 - 240b:4013:8000::/33 - 240b:4014::/32 - 240b:4014::/33 - 240b:4014:8000::/33 - 43.100.0.0/15 - 43.100.0.0/16 - 43.101.0.0/16 - 43.102.0.0/20 - 43.102.112.0/20 - 43.102.16.0/20 - 43.102.32.0/20 - 43.102.48.0/20 - 43.102.64.0/20 - 43.102.80.0/20 - 43.102.96.0/20 - 43.103.0.0/17 - 43.103.0.0/18 - 43.103.64.0/18 - 43.104.0.0/15 - 43.104.0.0/16 - 43.105.0.0/16 - 43.108.0.0/17 - 43.108.0.0/18 - 43.108.64.0/18 - 43.91.0.0/16 - 43.91.0.0/17 - 43.91.128.0/17 - 43.96.10.0/24 - 43.96.100.0/24 - 43.96.101.0/24 - 43.96.102.0/24 - 43.96.104.0/24 - 43.96.11.0/24 - 43.96.20.0/24 - 43.96.21.0/24 - 43.96.23.0/24 - 43.96.24.0/24 - 43.96.25.0/24 - 43.96.3.0/24 - 43.96.32.0/24 - 43.96.33.0/24 - 43.96.34.0/24 - 43.96.35.0/24 - 43.96.4.0/24 - 43.96.40.0/24 - 43.96.5.0/24 - 43.96.52.0/24 - 43.96.6.0/24 - 43.96.66.0/24 - 43.96.67.0/24 - 43.96.68.0/24 - 43.96.69.0/24 - 43.96.7.0/24 - 43.96.70.0/24 - 43.96.71.0/24 - 43.96.72.0/24 - 43.96.73.0/24 - 43.96.74.0/24 - 43.96.75.0/24 - 43.96.8.0/24 - 43.96.80.0/24 - 43.96.81.0/24 - 43.96.84.0/24 - 43.96.85.0/24 - 43.96.86.0/24 - 43.96.88.0/24 - 43.96.9.0/24 - 43.96.96.0/24 - 43.98.0.0/16 - 43.98.0.0/17 - 43.98.128.0/17 - 43.99.0.0/16 - 43.99.0.0/17 - 43.99.128.0/17 - 45.199.179.0/24 - 47.235.0.0/22 - 47.235.0.0/23 - 47.235.1.0/24 - 47.235.10.0/23 - 47.235.10.0/24 - 47.235.11.0/24 - 47.235.12.0/23 - 47.235.12.0/24 - 47.235.13.0/24 - 47.235.16.0/23 - 47.235.16.0/24 - 47.235.18.0/23 - 47.235.18.0/24 - 47.235.19.0/24 - 47.235.2.0/23 - 47.235.20.0/24 - 47.235.21.0/24 - 47.235.22.0/24 - 47.235.23.0/24 - 47.235.24.0/22 - 47.235.24.0/23 - 47.235.26.0/23 - 47.235.28.0/23 - 47.235.28.0/24 - 47.235.29.0/24 - 47.235.30.0/24 - 47.235.31.0/24 - 47.235.4.0/24 - 47.235.5.0/24 - 47.235.6.0/23 - 47.235.6.0/24 - 47.235.7.0/24 - 47.235.8.0/24 - 47.235.9.0/24 - 47.236.0.0/15 - 47.236.0.0/16 - 47.237.0.0/16 - 47.237.32.0/20 - 47.237.34.0/24 - 47.238.0.0/15 - 47.238.0.0/16 - 47.239.0.0/16 - 47.240.0.0/16 - 47.240.0.0/17 - 47.240.128.0/17 - 47.241.0.0/16 - 47.241.0.0/17 - 47.241.128.0/17 - 47.242.0.0/15 - 47.242.0.0/16 - 47.243.0.0/16 - 47.244.0.0/16 - 47.244.0.0/17 - 47.244.128.0/17 - 47.244.73.0/24 - 47.245.0.0/18 - 47.245.0.0/19 - 47.245.128.0/17 - 47.245.128.0/18 - 47.245.192.0/18 - 47.245.32.0/19 - 47.245.64.0/18 - 47.245.64.0/19 - 47.245.96.0/19 - 47.246.100.0/22 - 47.246.104.0/21 - 47.246.104.0/22 - 47.246.108.0/22 - 47.246.120.0/24 - 47.246.122.0/24 - 47.246.123.0/24 - 47.246.124.0/24 - 47.246.125.0/24 - 47.246.128.0/22 - 47.246.128.0/23 - 47.246.130.0/23 - 47.246.132.0/22 - 47.246.132.0/23 - 47.246.134.0/23 - 47.246.136.0/21 - 47.246.136.0/22 - 47.246.140.0/22 - 47.246.144.0/23 - 47.246.144.0/24 - 47.246.145.0/24 - 47.246.146.0/23 - 47.246.146.0/24 - 47.246.147.0/24 - 47.246.150.0/23 - 47.246.150.0/24 - 47.246.151.0/24 - 47.246.152.0/23 - 47.246.152.0/24 - 47.246.153.0/24 - 47.246.154.0/24 - 47.246.155.0/24 - 47.246.156.0/22 - 47.246.156.0/23 - 47.246.158.0/23 - 47.246.160.0/20 - 47.246.160.0/21 - 47.246.168.0/21 - 47.246.176.0/20 - 47.246.176.0/21 - 47.246.184.0/21 - 47.246.192.0/22 - 47.246.192.0/23 - 47.246.194.0/23 - 47.246.196.0/22 - 47.246.196.0/23 - 47.246.198.0/23 - 47.246.32.0/22 - 47.246.66.0/24 - 47.246.67.0/24 - 47.246.68.0/23 - 47.246.68.0/24 - 47.246.69.0/24 - 47.246.72.0/21 - 47.246.72.0/22 - 47.246.76.0/22 - 47.246.80.0/24 - 47.246.82.0/23 - 47.246.82.0/24 - 47.246.83.0/24 - 47.246.84.0/22 - 47.246.84.0/23 - 47.246.86.0/23 - 47.246.88.0/22 - 47.246.88.0/23 - 47.246.90.0/23 - 47.246.92.0/23 - 47.246.92.0/24 - 47.246.93.0/24 - 47.246.96.0/21 - 47.246.96.0/22 - 47.250.0.0/17 - 47.250.0.0/18 - 47.250.128.0/17 - 47.250.128.0/18 - 47.250.192.0/18 - 47.250.64.0/18 - 47.250.99.0/24 - 47.251.0.0/16 - 47.251.0.0/17 - 47.251.128.0/17 - 47.251.224.0/22 - 47.252.0.0/17 - 47.252.0.0/18 - 47.252.128.0/17 - 47.252.128.0/18 - 47.252.192.0/18 - 47.252.64.0/18 - 47.252.67.0/24 - 47.253.0.0/16 - 47.253.0.0/17 - 47.253.128.0/17 - 47.254.0.0/17 - 47.254.0.0/18 - 47.254.113.0/24 - 47.254.128.0/18 - 47.254.128.0/19 - 47.254.160.0/19 - 47.254.192.0/18 - 47.254.192.0/19 - 47.254.224.0/19 - 47.254.64.0/18 - 47.52.0.0/16 - 47.52.0.0/17 - 47.52.128.0/17 - 47.56.0.0/15 - 47.56.0.0/16 - 47.57.0.0/16 - 47.74.0.0/18 - 47.74.0.0/19 - 47.74.0.0/21 - 47.74.128.0/17 - 47.74.128.0/18 - 47.74.192.0/18 - 47.74.32.0/19 - 47.74.64.0/18 - 47.74.64.0/19 - 47.74.96.0/19 - 47.75.0.0/16 - 47.75.0.0/17 - 47.75.128.0/17 - 47.76.0.0/16 - 47.76.0.0/17 - 47.76.128.0/17 - 47.77.0.0/22 - 47.77.0.0/23 - 47.77.104.0/21 - 47.77.12.0/22 - 47.77.128.0/17 - 47.77.128.0/18 - 47.77.128.0/21 - 47.77.136.0/21 - 47.77.144.0/21 - 47.77.152.0/21 - 47.77.16.0/21 - 47.77.16.0/22 - 47.77.192.0/18 - 47.77.2.0/23 - 47.77.20.0/22 - 47.77.24.0/22 - 47.77.24.0/23 - 47.77.26.0/23 - 47.77.32.0/19 - 47.77.32.0/20 - 47.77.4.0/22 - 47.77.4.0/23 - 47.77.48.0/20 - 47.77.6.0/23 - 47.77.64.0/19 - 47.77.64.0/20 - 47.77.8.0/21 - 47.77.8.0/22 - 47.77.80.0/20 - 47.77.96.0/20 - 47.77.96.0/21 - 47.78.0.0/17 - 47.78.128.0/17 - 47.79.0.0/20 - 47.79.0.0/21 - 47.79.104.0/21 - 47.79.112.0/20 - 47.79.128.0/19 - 47.79.128.0/20 - 47.79.144.0/20 - 47.79.16.0/20 - 47.79.16.0/21 - 47.79.192.0/18 - 47.79.192.0/19 - 47.79.224.0/19 - 47.79.24.0/21 - 47.79.32.0/20 - 47.79.32.0/21 - 47.79.40.0/21 - 47.79.48.0/20 - 47.79.48.0/21 - 47.79.52.0/23 - 47.79.54.0/23 - 47.79.56.0/21 - 47.79.56.0/23 - 47.79.58.0/23 - 47.79.60.0/23 - 47.79.62.0/23 - 47.79.64.0/20 - 47.79.64.0/21 - 47.79.72.0/21 - 47.79.8.0/21 - 47.79.80.0/20 - 47.79.80.0/21 - 47.79.83.0/24 - 47.79.88.0/21 - 47.79.96.0/19 - 47.79.96.0/20 - 47.80.0.0/18 - 47.80.0.0/19 - 47.80.128.0/17 - 47.80.128.0/18 - 47.80.192.0/18 - 47.80.32.0/19 - 47.80.64.0/18 - 47.80.64.0/19 - 47.80.96.0/19 - 47.81.0.0/18 - 47.81.0.0/19 - 47.81.128.0/17 - 47.81.128.0/18 - 47.81.192.0/18 - 47.81.32.0/19 - 47.81.64.0/18 - 47.81.64.0/19 - 47.81.96.0/19 - 47.82.0.0/18 - 47.82.0.0/19 - 47.82.10.0/23 - 47.82.12.0/23 - 47.82.128.0/17 - 47.82.128.0/18 - 47.82.14.0/23 - 47.82.192.0/18 - 47.82.32.0/19 - 47.82.32.0/21 - 47.82.40.0/21 - 47.82.48.0/21 - 47.82.56.0/21 - 47.82.64.0/18 - 47.82.64.0/19 - 47.82.8.0/23 - 47.82.96.0/19 - 47.83.0.0/16 - 47.83.0.0/17 - 47.83.128.0/17 - 47.83.32.0/21 - 47.83.40.0/21 - 47.83.48.0/21 - 47.83.56.0/21 - 47.84.0.0/16 - 47.84.0.0/17 - 47.84.128.0/17 - 47.84.144.0/21 - 47.84.152.0/21 - 47.84.160.0/21 - 47.84.168.0/21 - 47.85.0.0/16 - 47.85.0.0/17 - 47.85.112.0/22 - 47.85.112.0/23 - 47.85.114.0/23 - 47.85.128.0/17 - 47.86.0.0/16 - 47.86.0.0/17 - 47.86.128.0/17 - 47.87.0.0/18 - 47.87.0.0/19 - 47.87.128.0/18 - 47.87.128.0/19 - 47.87.160.0/19 - 47.87.192.0/22 - 47.87.192.0/23 - 47.87.194.0/23 - 47.87.196.0/22 - 47.87.196.0/23 - 47.87.198.0/23 - 47.87.200.0/22 - 47.87.200.0/23 - 47.87.202.0/23 - 47.87.204.0/22 - 47.87.204.0/23 - 47.87.206.0/23 - 47.87.208.0/22 - 47.87.208.0/23 - 47.87.210.0/23 - 47.87.212.0/22 - 47.87.212.0/23 - 47.87.214.0/23 - 47.87.216.0/22 - 47.87.216.0/23 - 47.87.218.0/23 - 47.87.220.0/22 - 47.87.220.0/23 - 47.87.222.0/23 - 47.87.224.0/22 - 47.87.224.0/23 - 47.87.226.0/23 - 47.87.228.0/22 - 47.87.228.0/23 - 47.87.230.0/23 - 47.87.232.0/22 - 47.87.232.0/23 - 47.87.234.0/23 - 47.87.236.0/22 - 47.87.236.0/23 - 47.87.238.0/23 - 47.87.240.0/22 - 47.87.240.0/23 - 47.87.242.0/23 - 47.87.32.0/19 - 47.87.64.0/18 - 47.87.64.0/19 - 47.87.96.0/19 - 47.88.0.0/17 - 47.88.0.0/18 - 47.88.109.0/24 - 47.88.128.0/17 - 47.88.128.0/18 - 47.88.135.0/24 - 47.88.192.0/18 - 47.88.41.0/24 - 47.88.42.0/24 - 47.88.43.0/24 - 47.88.64.0/18 - 47.89.0.0/18 - 47.89.0.0/19 - 47.89.100.0/24 - 47.89.101.0/24 - 47.89.102.0/24 - 47.89.103.0/24 - 47.89.104.0/21 - 47.89.104.0/22 - 47.89.108.0/22 - 47.89.122.0/24 - 47.89.123.0/24 - 47.89.124.0/23 - 47.89.124.0/24 - 47.89.125.0/24 - 47.89.128.0/18 - 47.89.128.0/19 - 47.89.160.0/19 - 47.89.192.0/18 - 47.89.192.0/19 - 47.89.221.0/24 - 47.89.224.0/19 - 47.89.32.0/19 - 47.89.72.0/22 - 47.89.72.0/23 - 47.89.74.0/23 - 47.89.76.0/22 - 47.89.76.0/23 - 47.89.78.0/23 - 47.89.80.0/23 - 47.89.82.0/23 - 47.89.84.0/24 - 47.89.88.0/22 - 47.89.88.0/23 - 47.89.90.0/23 - 47.89.92.0/22 - 47.89.92.0/23 - 47.89.94.0/23 - 47.89.96.0/24 - 47.89.97.0/24 - 47.89.98.0/23 - 47.89.99.0/24 - 47.90.0.0/17 - 47.90.0.0/18 - 47.90.128.0/17 - 47.90.128.0/18 - 47.90.172.0/24 - 47.90.173.0/24 - 47.90.174.0/24 - 47.90.175.0/24 - 47.90.192.0/18 - 47.90.64.0/18 - 47.91.0.0/19 - 47.91.0.0/20 - 47.91.112.0/20 - 47.91.128.0/17 - 47.91.128.0/18 - 47.91.16.0/20 - 47.91.192.0/18 - 47.91.32.0/19 - 47.91.32.0/20 - 47.91.48.0/20 - 47.91.64.0/19 - 47.91.64.0/20 - 47.91.80.0/20 - 47.91.96.0/19 - 47.91.96.0/20 - 5.181.224.0/23 - 59.82.136.0/23 - 8.208.0.0/16 - 8.208.0.0/17 - 8.208.0.0/18 - 8.208.0.0/19 - 8.208.128.0/17 - 8.208.141.0/24 - 8.208.32.0/19 - 8.209.0.0/19 - 8.209.0.0/20 - 8.209.128.0/18 - 8.209.128.0/19 - 8.209.16.0/20 - 8.209.160.0/19 - 8.209.192.0/18 - 8.209.192.0/19 - 8.209.224.0/19 - 8.209.36.0/23 - 8.209.36.0/24 - 8.209.37.0/24 - 8.209.38.0/23 - 8.209.38.0/24 - 8.209.39.0/24 - 8.209.40.0/22 - 8.209.40.0/23 - 8.209.42.0/23 - 8.209.44.0/22 - 8.209.44.0/23 - 8.209.46.0/23 - 8.209.48.0/20 - 8.209.48.0/21 - 8.209.56.0/21 - 8.209.64.0/18 - 8.209.64.0/19 - 8.209.96.0/19 - 8.210.0.0/16 - 8.210.0.0/17 - 8.210.128.0/17 - 8.210.240.0/24 - 8.211.0.0/17 - 8.211.0.0/18 - 8.211.104.0/21 - 8.211.128.0/18 - 8.211.128.0/19 - 8.211.160.0/19 - 8.211.192.0/18 - 8.211.192.0/19 - 8.211.224.0/19 - 8.211.226.0/24 - 8.211.64.0/18 - 8.211.80.0/21 - 8.211.88.0/21 - 8.211.96.0/21 - 8.212.0.0/17 - 8.212.0.0/18 - 8.212.128.0/18 - 8.212.128.0/19 - 8.212.160.0/19 - 8.212.192.0/18 - 8.212.192.0/19 - 8.212.224.0/19 - 8.212.64.0/18 - 8.213.0.0/17 - 8.213.0.0/18 - 8.213.128.0/19 - 8.213.128.0/20 - 8.213.144.0/20 - 8.213.160.0/21 - 8.213.160.0/22 - 8.213.164.0/22 - 8.213.176.0/20 - 8.213.176.0/21 - 8.213.184.0/21 - 8.213.192.0/18 - 8.213.192.0/19 - 8.213.224.0/19 - 8.213.251.0/24 - 8.213.252.0/24 - 8.213.253.0/24 - 8.213.64.0/18 - 8.214.0.0/16 - 8.214.0.0/17 - 8.214.128.0/17 - 8.215.0.0/16 - 8.215.0.0/17 - 8.215.128.0/17 - 8.215.160.0/24 - 8.215.162.0/23 - 8.215.168.0/24 - 8.215.169.0/24 - 8.216.0.0/17 - 8.216.0.0/18 - 8.216.128.0/17 - 8.216.128.0/18 - 8.216.148.0/24 - 8.216.192.0/18 - 8.216.64.0/18 - 8.216.69.0/24 - 8.216.74.0/24 - 8.217.0.0/16 - 8.217.0.0/17 - 8.217.128.0/17 - 8.218.0.0/16 - 8.218.0.0/17 - 8.218.128.0/17 - 8.219.0.0/16 - 8.219.0.0/17 - 8.219.128.0/17 - 8.219.40.0/21 - 8.220.116.0/24 - 8.220.128.0/18 - 8.220.128.0/19 - 8.220.147.0/24 - 8.220.160.0/19 - 8.220.192.0/18 - 8.220.192.0/19 - 8.220.224.0/19 - 8.220.229.0/24 - 8.220.64.0/18 - 8.220.64.0/19 - 8.220.96.0/19 - 8.221.0.0/17 - 8.221.0.0/18 - 8.221.0.0/21 - 8.221.128.0/17 - 8.221.128.0/18 - 8.221.184.0/22 - 8.221.188.0/22 - 8.221.192.0/18 - 8.221.192.0/21 - 8.221.200.0/21 - 8.221.208.0/21 - 8.221.216.0/21 - 8.221.48.0/21 - 8.221.56.0/21 - 8.221.64.0/18 - 8.221.8.0/21 - 8.222.0.0/20 - 8.222.0.0/21 - 8.222.112.0/20 - 8.222.128.0/17 - 8.222.128.0/18 - 8.222.16.0/20 - 8.222.16.0/21 - 8.222.192.0/18 - 8.222.24.0/21 - 8.222.32.0/20 - 8.222.32.0/21 - 8.222.40.0/21 - 8.222.48.0/20 - 8.222.48.0/21 - 8.222.56.0/21 - 8.222.64.0/20 - 8.222.64.0/21 - 8.222.72.0/21 - 8.222.8.0/21 - 8.222.80.0/20 - 8.222.80.0/21 - 8.222.88.0/21 - 8.222.96.0/19 - 8.222.96.0/20 - 8.223.0.0/17 - 8.223.0.0/18 - 8.223.128.0/17 - 8.223.128.0/18 - 8.223.192.0/18 - 8.223.64.0/18 ================================================ FILE: data/crawlers/applebot.yaml ================================================ # Indexing for search and Siri # https://support.apple.com/en-us/119829 - name: applebot user_agent_regex: Applebot action: ALLOW # https://search.developer.apple.com/applebot.json remote_addresses: [ "17.241.208.160/27", "17.241.193.160/27", "17.241.200.160/27", "17.22.237.0/24", "17.22.245.0/24", "17.22.253.0/24", "17.241.75.0/24", "17.241.219.0/24", "17.241.227.0/24", "17.246.15.0/24", "17.246.19.0/24", "17.246.23.0/24", ] ================================================ FILE: data/crawlers/bingbot.yaml ================================================ - name: bingbot user_agent_regex: \+http\://www\.bing\.com/bingbot\.htm action: ALLOW # https://www.bing.com/toolbox/bingbot.json remote_addresses: [ "157.55.39.0/24", "207.46.13.0/24", "40.77.167.0/24", "13.66.139.0/24", "13.66.144.0/24", "52.167.144.0/24", "13.67.10.16/28", "13.69.66.240/28", "13.71.172.224/28", "139.217.52.0/28", "191.233.204.224/28", "20.36.108.32/28", "20.43.120.16/28", "40.79.131.208/28", "40.79.186.176/28", "52.231.148.0/28", "20.79.107.240/28", "51.105.67.0/28", "20.125.163.80/28", "40.77.188.0/22", "65.55.210.0/24", "199.30.24.0/23", "40.77.202.0/24", "40.77.139.0/25", "20.74.197.0/28", "20.15.133.160/27", "40.77.177.0/24", "40.77.178.0/23", ] ================================================ FILE: data/crawlers/commoncrawl.yaml ================================================ - name: common-crawl user_agent_regex: CCBot action: ALLOW # https://index.commoncrawl.org/ccbot.json remote_addresses: [ "2600:1f28:365:80b0::/60", "18.97.9.168/29", "18.97.14.80/29", "18.97.14.88/30", "98.85.178.216/32", ] ================================================ FILE: data/crawlers/duckduckbot.yaml ================================================ - name: duckduckbot user_agent_regex: DuckDuckBot/1\.1; \(\+http\://duckduckgo\.com/duckduckbot\.html\) action: ALLOW # https://duckduckgo.com/duckduckgo-help-pages/results/duckduckbot remote_addresses: [ "57.152.72.128/32", "51.8.253.152/32", "40.80.242.63/32", "20.12.141.99/32", "20.49.136.28/32", "51.116.131.221/32", "51.107.40.209/32", "20.40.133.240/32", "20.50.168.91/32", "51.120.48.122/32", "20.193.45.113/32", "40.76.173.151/32", "40.76.163.7/32", "20.185.79.47/32", "52.142.26.175/32", "20.185.79.15/32", "52.142.24.149/32", "40.76.162.208/32", "40.76.163.23/32", "40.76.162.191/32", "40.76.162.247/32", "40.88.21.235/32", "20.191.45.212/32", "52.146.59.12/32", "52.146.59.156/32", "52.146.59.154/32", "52.146.58.236/32", "20.62.224.44/32", "51.104.180.53/32", "51.104.180.47/32", "51.104.180.26/32", "51.104.146.225/32", "51.104.146.235/32", "20.73.202.147/32", "20.73.132.240/32", "20.71.12.143/32", "20.56.197.58/32", "20.56.197.63/32", "20.43.150.93/32", "20.43.150.85/32", "20.44.222.1/32", "40.89.243.175/32", "13.89.106.77/32", "52.143.242.6/32", "52.143.241.111/32", "52.154.60.82/32", "20.197.209.11/32", "20.197.209.27/32", "20.226.133.105/32", "191.234.216.4/32", "191.234.216.178/32", "20.53.92.211/32", "20.53.91.2/32", "20.207.99.197/32", "20.207.97.190/32", "40.81.250.205/32", "40.64.106.11/32", "40.64.105.247/32", "20.72.242.93/32", "20.99.255.235/32", "20.113.3.121/32", "52.224.16.221/32", "52.224.21.53/32", "52.224.20.204/32", "52.224.21.19/32", "52.224.20.249/32", "52.224.20.203/32", "52.224.20.190/32", "52.224.16.229/32", "52.224.21.20/32", "52.146.63.80/32", "52.224.20.227/32", "52.224.20.193/32", "52.190.37.160/32", "52.224.21.23/32", "52.224.20.223/32", "52.224.20.181/32", "52.224.21.49/32", "52.224.21.55/32", "52.224.21.61/32", "52.224.19.152/32", "52.224.20.186/32", "52.224.21.27/32", "52.224.21.51/32", "52.224.20.174/32", "52.224.21.4/32", "51.104.164.109/32", "51.104.167.71/32", "51.104.160.177/32", "51.104.162.149/32", "51.104.167.95/32", "51.104.167.54/32", "51.104.166.111/32", "51.104.167.88/32", "51.104.161.32/32", "51.104.163.250/32", "51.104.164.189/32", "51.104.167.19/32", "51.104.160.167/32", "51.104.167.110/32", "20.191.44.119/32", "51.104.167.104/32", "20.191.44.234/32", "51.104.164.215/32", "51.104.167.52/32", "20.191.44.22/32", "51.104.167.87/32", "51.104.167.96/32", "20.191.44.16/32", "51.104.167.61/32", "51.104.164.147/32", "20.50.48.159/32", "40.114.182.172/32", "20.50.50.130/32", "20.50.50.163/32", "20.50.50.46/32", "40.114.182.153/32", "20.50.50.118/32", "20.50.49.55/32", "20.50.49.25/32", "40.114.183.251/32", "20.50.50.123/32", "20.50.49.237/32", "20.50.48.192/32", "20.50.50.134/32", "51.138.90.233/32", "40.114.183.196/32", "20.50.50.146/32", "40.114.183.88/32", "20.50.50.145/32", "20.50.50.121/32", "20.50.49.40/32", "51.138.90.206/32", "40.114.182.45/32", "51.138.90.161/32", "20.50.49.0/32", "40.119.232.215/32", "104.43.55.167/32", "40.119.232.251/32", "40.119.232.50/32", "40.119.232.146/32", "40.119.232.218/32", "104.43.54.127/32", "104.43.55.117/32", "104.43.55.116/32", "104.43.55.166/32", "52.154.169.50/32", "52.154.171.70/32", "52.154.170.229/32", "52.154.170.113/32", "52.154.171.44/32", "52.154.172.2/32", "52.143.244.81/32", "52.154.171.87/32", "52.154.171.250/32", "52.154.170.28/32", "52.154.170.122/32", "52.143.243.117/32", "52.143.247.235/32", "52.154.171.235/32", "52.154.171.196/32", "52.154.171.0/32", "52.154.170.243/32", "52.154.170.26/32", "52.154.169.200/32", "52.154.170.96/32", "52.154.170.88/32", "52.154.171.150/32", "52.154.171.205/32", "52.154.170.117/32", "52.154.170.209/32", "191.235.202.48/32", "191.233.3.202/32", "191.235.201.214/32", "191.233.3.197/32", "191.235.202.38/32", "20.53.78.144/32", "20.193.24.10/32", "20.53.78.236/32", "20.53.78.138/32", "20.53.78.123/32", "20.53.78.106/32", "20.193.27.215/32", "20.193.25.197/32", "20.193.12.126/32", "20.193.24.251/32", "20.204.242.101/32", "20.207.72.113/32", "20.204.242.19/32", "20.219.45.67/32", "20.207.72.11/32", "20.219.45.190/32", "20.204.243.55/32", "20.204.241.148/32", "20.207.72.110/32", "20.204.240.172/32", "20.207.72.21/32", "20.204.246.81/32", "20.207.107.181/32", "20.204.246.254/32", "20.219.43.246/32", "52.149.25.43/32", "52.149.61.51/32", "52.149.58.139/32", "52.149.60.38/32", "52.148.165.38/32", "52.143.95.162/32", "52.149.56.151/32", "52.149.30.45/32", "52.149.58.173/32", "52.143.95.204/32", "52.149.28.83/32", "52.149.58.69/32", "52.148.161.87/32", "52.149.58.27/32", "52.149.28.18/32", "20.79.226.26/32", "20.79.239.66/32", "20.79.238.198/32", "20.113.14.159/32", "20.75.144.152/32", "20.43.172.120/32", "20.53.134.160/32", "20.201.15.208/32", "20.93.28.24/32", "20.61.34.40/32", "52.242.224.168/32", "20.80.129.80/32", "20.195.108.47/32", "4.195.133.120/32", "4.228.76.163/32", "4.182.131.108/32", "4.209.224.56/32", "108.141.83.74/32", "4.213.46.14/32", "172.169.17.165/32", "51.8.71.117/32", "20.3.1.178/32", "52.149.56.151/32", "52.149.30.45/32", "52.149.58.173/32", "52.143.95.204/32", "52.149.28.83/32", "52.149.58.69/32", "52.148.161.87/32", "52.149.58.27/32", "52.149.28.18/32", "20.79.226.26/32", "20.79.239.66/32", "20.79.238.198/32", "20.113.14.159/32", "20.75.144.152/32", "20.43.172.120/32", "20.53.134.160/32", "20.201.15.208/32", "20.93.28.24/32", "20.61.34.40/32", "52.242.224.168/32", "20.80.129.80/32", "20.195.108.47/32", "4.195.133.120/32", "4.228.76.163/32", "4.182.131.108/32", "4.209.224.56/32", "108.141.83.74/32", "4.213.46.14/32", "172.169.17.165/32", "51.8.71.117/32", "20.3.1.178/32", ] ================================================ FILE: data/crawlers/googlebot.yaml ================================================ - name: googlebot user_agent_regex: \+http\://www\.google\.com/bot\.html action: ALLOW # https://developers.google.com/static/search/apis/ipranges/googlebot.json remote_addresses: [ "2001:4860:4801:10::/64", "2001:4860:4801:11::/64", "2001:4860:4801:12::/64", "2001:4860:4801:13::/64", "2001:4860:4801:14::/64", "2001:4860:4801:15::/64", "2001:4860:4801:16::/64", "2001:4860:4801:17::/64", "2001:4860:4801:18::/64", "2001:4860:4801:19::/64", "2001:4860:4801:1a::/64", "2001:4860:4801:1b::/64", "2001:4860:4801:1c::/64", "2001:4860:4801:1d::/64", "2001:4860:4801:1e::/64", "2001:4860:4801:1f::/64", "2001:4860:4801:20::/64", "2001:4860:4801:21::/64", "2001:4860:4801:22::/64", "2001:4860:4801:23::/64", "2001:4860:4801:24::/64", "2001:4860:4801:25::/64", "2001:4860:4801:26::/64", "2001:4860:4801:27::/64", "2001:4860:4801:28::/64", "2001:4860:4801:29::/64", "2001:4860:4801:2::/64", "2001:4860:4801:2a::/64", "2001:4860:4801:2b::/64", "2001:4860:4801:2c::/64", "2001:4860:4801:2d::/64", "2001:4860:4801:2e::/64", "2001:4860:4801:2f::/64", "2001:4860:4801:31::/64", "2001:4860:4801:32::/64", "2001:4860:4801:33::/64", "2001:4860:4801:34::/64", "2001:4860:4801:35::/64", "2001:4860:4801:36::/64", "2001:4860:4801:37::/64", "2001:4860:4801:38::/64", "2001:4860:4801:39::/64", "2001:4860:4801:3a::/64", "2001:4860:4801:3b::/64", "2001:4860:4801:3c::/64", "2001:4860:4801:3d::/64", "2001:4860:4801:3e::/64", "2001:4860:4801:40::/64", "2001:4860:4801:41::/64", "2001:4860:4801:42::/64", "2001:4860:4801:43::/64", "2001:4860:4801:44::/64", "2001:4860:4801:45::/64", "2001:4860:4801:46::/64", "2001:4860:4801:47::/64", "2001:4860:4801:48::/64", "2001:4860:4801:49::/64", "2001:4860:4801:4a::/64", "2001:4860:4801:4b::/64", "2001:4860:4801:4c::/64", "2001:4860:4801:50::/64", "2001:4860:4801:51::/64", "2001:4860:4801:52::/64", "2001:4860:4801:53::/64", "2001:4860:4801:54::/64", "2001:4860:4801:55::/64", "2001:4860:4801:56::/64", "2001:4860:4801:60::/64", "2001:4860:4801:61::/64", "2001:4860:4801:62::/64", "2001:4860:4801:63::/64", "2001:4860:4801:64::/64", "2001:4860:4801:65::/64", "2001:4860:4801:66::/64", "2001:4860:4801:67::/64", "2001:4860:4801:68::/64", "2001:4860:4801:69::/64", "2001:4860:4801:6a::/64", "2001:4860:4801:6b::/64", "2001:4860:4801:6c::/64", "2001:4860:4801:6d::/64", "2001:4860:4801:6e::/64", "2001:4860:4801:6f::/64", "2001:4860:4801:70::/64", "2001:4860:4801:71::/64", "2001:4860:4801:72::/64", "2001:4860:4801:73::/64", "2001:4860:4801:74::/64", "2001:4860:4801:75::/64", "2001:4860:4801:76::/64", "2001:4860:4801:77::/64", "2001:4860:4801:78::/64", "2001:4860:4801:79::/64", "2001:4860:4801:80::/64", "2001:4860:4801:81::/64", "2001:4860:4801:82::/64", "2001:4860:4801:83::/64", "2001:4860:4801:84::/64", "2001:4860:4801:85::/64", "2001:4860:4801:86::/64", "2001:4860:4801:87::/64", "2001:4860:4801:88::/64", "2001:4860:4801:90::/64", "2001:4860:4801:91::/64", "2001:4860:4801:92::/64", "2001:4860:4801:93::/64", "2001:4860:4801:94::/64", "2001:4860:4801:95::/64", "2001:4860:4801:96::/64", "2001:4860:4801:a0::/64", "2001:4860:4801:a1::/64", "2001:4860:4801:a2::/64", "2001:4860:4801:a3::/64", "2001:4860:4801:a4::/64", "2001:4860:4801:a5::/64", "2001:4860:4801:c::/64", "2001:4860:4801:f::/64", "192.178.5.0/27", "192.178.6.0/27", "192.178.6.128/27", "192.178.6.160/27", "192.178.6.192/27", "192.178.6.32/27", "192.178.6.64/27", "192.178.6.96/27", "34.100.182.96/28", "34.101.50.144/28", "34.118.254.0/28", "34.118.66.0/28", "34.126.178.96/28", "34.146.150.144/28", "34.147.110.144/28", "34.151.74.144/28", "34.152.50.64/28", "34.154.114.144/28", "34.155.98.32/28", "34.165.18.176/28", "34.175.160.64/28", "34.176.130.16/28", "34.22.85.0/27", "34.64.82.64/28", "34.65.242.112/28", "34.80.50.80/28", "34.88.194.0/28", "34.89.10.80/28", "34.89.198.80/28", "34.96.162.48/28", "35.247.243.240/28", "66.249.64.0/27", "66.249.64.128/27", "66.249.64.160/27", "66.249.64.224/27", "66.249.64.32/27", "66.249.64.64/27", "66.249.64.96/27", "66.249.65.0/27", "66.249.65.128/27", "66.249.65.160/27", "66.249.65.192/27", "66.249.65.224/27", "66.249.65.32/27", "66.249.65.64/27", "66.249.65.96/27", "66.249.66.0/27", "66.249.66.128/27", "66.249.66.160/27", "66.249.66.192/27", "66.249.66.224/27", "66.249.66.32/27", "66.249.66.64/27", "66.249.66.96/27", "66.249.68.0/27", "66.249.68.128/27", "66.249.68.32/27", "66.249.68.64/27", "66.249.68.96/27", "66.249.69.0/27", "66.249.69.128/27", "66.249.69.160/27", "66.249.69.192/27", "66.249.69.224/27", "66.249.69.32/27", "66.249.69.64/27", "66.249.69.96/27", "66.249.70.0/27", "66.249.70.128/27", "66.249.70.160/27", "66.249.70.192/27", "66.249.70.224/27", "66.249.70.32/27", "66.249.70.64/27", "66.249.70.96/27", "66.249.71.0/27", "66.249.71.128/27", "66.249.71.160/27", "66.249.71.192/27", "66.249.71.224/27", "66.249.71.32/27", "66.249.71.64/27", "66.249.71.96/27", "66.249.72.0/27", "66.249.72.128/27", "66.249.72.160/27", "66.249.72.192/27", "66.249.72.224/27", "66.249.72.32/27", "66.249.72.64/27", "66.249.72.96/27", "66.249.73.0/27", "66.249.73.128/27", "66.249.73.160/27", "66.249.73.192/27", "66.249.73.224/27", "66.249.73.32/27", "66.249.73.64/27", "66.249.73.96/27", "66.249.74.0/27", "66.249.74.128/27", "66.249.74.160/27", "66.249.74.192/27", "66.249.74.32/27", "66.249.74.64/27", "66.249.74.96/27", "66.249.75.0/27", "66.249.75.128/27", "66.249.75.160/27", "66.249.75.192/27", "66.249.75.224/27", "66.249.75.32/27", "66.249.75.64/27", "66.249.75.96/27", "66.249.76.0/27", "66.249.76.128/27", "66.249.76.160/27", "66.249.76.192/27", "66.249.76.224/27", "66.249.76.32/27", "66.249.76.64/27", "66.249.76.96/27", "66.249.77.0/27", "66.249.77.128/27", "66.249.77.160/27", "66.249.77.192/27", "66.249.77.224/27", "66.249.77.32/27", "66.249.77.64/27", "66.249.77.96/27", "66.249.78.0/27", "66.249.78.32/27", "66.249.79.0/27", "66.249.79.128/27", "66.249.79.160/27", "66.249.79.192/27", "66.249.79.224/27", "66.249.79.32/27", "66.249.79.64/27", "66.249.79.96/27", ] ================================================ FILE: data/crawlers/huawei-cloud.yaml ================================================ - name: huawei-cloud action: DENY # Updated 2025-08-20 from IP addresses for AS136907 remote_addresses: - 1.178.32.0/20 - 1.178.48.0/20 - 101.44.0.0/20 - 101.44.144.0/20 - 101.44.16.0/20 - 101.44.160.0/20 - 101.44.173.0/24 - 101.44.176.0/20 - 101.44.192.0/20 - 101.44.208.0/22 - 101.44.212.0/22 - 101.44.216.0/22 - 101.44.220.0/22 - 101.44.224.0/22 - 101.44.228.0/22 - 101.44.232.0/22 - 101.44.236.0/22 - 101.44.240.0/22 - 101.44.244.0/22 - 101.44.248.0/22 - 101.44.252.0/24 - 101.44.253.0/24 - 101.44.254.0/24 - 101.44.255.0/24 - 101.44.32.0/20 - 101.44.48.0/20 - 101.44.64.0/20 - 101.44.80.0/20 - 101.44.96.0/20 - 101.46.0.0/20 - 101.46.128.0/21 - 101.46.136.0/21 - 101.46.144.0/21 - 101.46.152.0/21 - 101.46.160.0/21 - 101.46.168.0/21 - 101.46.176.0/21 - 101.46.184.0/21 - 101.46.192.0/21 - 101.46.200.0/21 - 101.46.208.0/21 - 101.46.216.0/21 - 101.46.224.0/22 - 101.46.232.0/22 - 101.46.236.0/22 - 101.46.240.0/22 - 101.46.244.0/22 - 101.46.248.0/22 - 101.46.252.0/24 - 101.46.253.0/24 - 101.46.254.0/24 - 101.46.255.0/24 - 101.46.32.0/20 - 101.46.48.0/20 - 101.46.64.0/20 - 101.46.80.0/20 - 103.198.203.0/24 - 103.215.0.0/24 - 103.215.1.0/24 - 103.215.3.0/24 - 103.240.156.0/22 - 103.240.157.0/24 - 103.255.60.0/22 - 103.255.60.0/24 - 103.255.61.0/24 - 103.255.62.0/24 - 103.255.63.0/24 - 103.40.100.0/23 - 103.84.110.0/24 - 110.238.100.0/22 - 110.238.104.0/21 - 110.238.112.0/21 - 110.238.120.0/22 - 110.238.124.0/22 - 110.238.64.0/21 - 110.238.72.0/21 - 110.238.80.0/20 - 110.238.96.0/24 - 110.238.98.0/24 - 110.238.99.0/24 - 110.239.127.0/24 - 110.239.184.0/22 - 110.239.188.0/23 - 110.239.190.0/23 - 110.239.64.0/19 - 110.239.96.0/19 - 110.41.208.0/24 - 110.41.209.0/24 - 110.41.210.0/24 - 111.119.192.0/20 - 111.119.208.0/20 - 111.119.224.0/20 - 111.119.240.0/20 - 111.91.0.0/20 - 111.91.112.0/20 - 111.91.16.0/20 - 111.91.32.0/20 - 111.91.48.0/20 - 111.91.64.0/20 - 111.91.80.0/20 - 111.91.96.0/20 - 114.119.128.0/19 - 114.119.160.0/21 - 114.119.168.0/24 - 114.119.169.0/24 - 114.119.170.0/24 - 114.119.171.0/24 - 114.119.172.0/22 - 114.119.176.0/20 - 115.30.32.0/20 - 115.30.48.0/20 - 119.12.160.0/20 - 119.13.112.0/20 - 119.13.160.0/24 - 119.13.161.0/24 - 119.13.162.0/23 - 119.13.163.0/24 - 119.13.164.0/22 - 119.13.168.0/21 - 119.13.168.0/24 - 119.13.169.0/24 - 119.13.170.0/24 - 119.13.172.0/24 - 119.13.173.0/24 - 119.13.32.0/22 - 119.13.36.0/22 - 119.13.64.0/24 - 119.13.65.0/24 - 119.13.66.0/23 - 119.13.68.0/22 - 119.13.72.0/22 - 119.13.76.0/22 - 119.13.80.0/21 - 119.13.88.0/22 - 119.13.92.0/22 - 119.13.96.0/20 - 119.8.0.0/21 - 119.8.128.0/24 - 119.8.129.0/24 - 119.8.130.0/23 - 119.8.132.0/22 - 119.8.136.0/21 - 119.8.144.0/20 - 119.8.160.0/19 - 119.8.18.0/24 - 119.8.192.0/20 - 119.8.192.0/21 - 119.8.200.0/21 - 119.8.208.0/20 - 119.8.21.0/24 - 119.8.22.0/24 - 119.8.224.0/24 - 119.8.227.0/24 - 119.8.228.0/22 - 119.8.23.0/24 - 119.8.232.0/21 - 119.8.24.0/21 - 119.8.240.0/23 - 119.8.242.0/23 - 119.8.244.0/24 - 119.8.245.0/24 - 119.8.246.0/24 - 119.8.247.0/24 - 119.8.248.0/24 - 119.8.249.0/24 - 119.8.250.0/24 - 119.8.253.0/24 - 119.8.254.0/23 - 119.8.32.0/19 - 119.8.4.0/24 - 119.8.64.0/22 - 119.8.68.0/24 - 119.8.69.0/24 - 119.8.70.0/24 - 119.8.71.0/24 - 119.8.72.0/21 - 119.8.8.0/21 - 119.8.80.0/20 - 119.8.96.0/19 - 121.91.152.0/21 - 121.91.168.0/21 - 121.91.200.0/21 - 121.91.200.0/24 - 121.91.201.0/24 - 121.91.204.0/24 - 121.91.205.0/24 - 122.8.128.0/20 - 122.8.144.0/20 - 122.8.160.0/20 - 122.8.176.0/21 - 122.8.184.0/22 - 122.8.188.0/22 - 124.243.128.0/18 - 124.243.156.0/24 - 124.243.157.0/24 - 124.243.158.0/24 - 124.243.159.0/24 - 124.71.248.0/24 - 124.71.249.0/24 - 124.71.250.0/24 - 124.71.252.0/24 - 124.71.253.0/24 - 124.81.0.0/20 - 124.81.112.0/20 - 124.81.128.0/20 - 124.81.144.0/20 - 124.81.16.0/20 - 124.81.160.0/20 - 124.81.176.0/20 - 124.81.192.0/20 - 124.81.208.0/20 - 124.81.224.0/20 - 124.81.240.0/20 - 124.81.32.0/20 - 124.81.48.0/20 - 124.81.64.0/20 - 124.81.80.0/20 - 124.81.96.0/20 - 139.9.98.0/24 - 139.9.99.0/24 - 14.137.132.0/22 - 14.137.136.0/22 - 14.137.140.0/22 - 14.137.152.0/24 - 14.137.153.0/24 - 14.137.154.0/24 - 14.137.155.0/24 - 14.137.156.0/24 - 14.137.157.0/24 - 14.137.161.0/24 - 14.137.163.0/24 - 14.137.169.0/24 - 14.137.170.0/23 - 14.137.172.0/22 - 146.174.128.0/20 - 146.174.144.0/20 - 146.174.160.0/20 - 146.174.176.0/20 - 148.145.160.0/20 - 148.145.192.0/20 - 148.145.208.0/20 - 148.145.224.0/23 - 148.145.234.0/23 - 148.145.236.0/23 - 148.145.238.0/23 - 149.232.128.0/20 - 149.232.144.0/20 - 150.40.128.0/20 - 150.40.144.0/20 - 150.40.160.0/20 - 150.40.176.0/20 - 150.40.182.0/24 - 150.40.192.0/20 - 150.40.208.0/20 - 150.40.224.0/20 - 150.40.240.0/20 - 154.220.192.0/19 - 154.81.16.0/20 - 154.83.0.0/23 - 154.86.32.0/20 - 154.86.48.0/20 - 154.93.100.0/23 - 154.93.104.0/23 - 156.227.22.0/23 - 156.230.32.0/21 - 156.230.40.0/21 - 156.230.64.0/18 - 156.232.16.0/20 - 156.240.128.0/18 - 156.249.32.0/20 - 156.253.16.0/20 - 157.254.211.0/24 - 157.254.212.0/24 - 159.138.0.0/20 - 159.138.112.0/21 - 159.138.114.0/24 - 159.138.120.0/22 - 159.138.124.0/24 - 159.138.125.0/24 - 159.138.126.0/23 - 159.138.128.0/20 - 159.138.144.0/20 - 159.138.152.0/21 - 159.138.16.0/22 - 159.138.160.0/20 - 159.138.176.0/23 - 159.138.178.0/24 - 159.138.179.0/24 - 159.138.180.0/24 - 159.138.181.0/24 - 159.138.182.0/23 - 159.138.188.0/23 - 159.138.190.0/23 - 159.138.192.0/20 - 159.138.20.0/22 - 159.138.208.0/21 - 159.138.216.0/22 - 159.138.220.0/23 - 159.138.224.0/20 - 159.138.24.0/21 - 159.138.240.0/20 - 159.138.32.0/20 - 159.138.48.0/20 - 159.138.64.0/21 - 159.138.67.0/24 - 159.138.76.0/24 - 159.138.77.0/24 - 159.138.78.0/24 - 159.138.79.0/24 - 159.138.80.0/20 - 159.138.96.0/20 - 166.108.192.0/20 - 166.108.208.0/20 - 166.108.224.0/20 - 166.108.240.0/20 - 176.52.128.0/20 - 176.52.144.0/20 - 180.87.192.0/20 - 180.87.208.0/20 - 180.87.224.0/20 - 180.87.240.0/20 - 182.160.0.0/20 - 182.160.16.0/24 - 182.160.17.0/24 - 182.160.18.0/23 - 182.160.20.0/22 - 182.160.20.0/24 - 182.160.24.0/21 - 182.160.36.0/22 - 182.160.49.0/24 - 182.160.52.0/22 - 182.160.56.0/21 - 182.160.56.0/24 - 182.160.57.0/24 - 182.160.58.0/24 - 182.160.59.0/24 - 182.160.60.0/24 - 182.160.61.0/24 - 182.160.62.0/24 - 183.87.112.0/20 - 183.87.128.0/20 - 183.87.144.0/20 - 183.87.32.0/20 - 183.87.48.0/20 - 183.87.64.0/20 - 183.87.80.0/20 - 183.87.96.0/20 - 188.119.192.0/20 - 188.119.208.0/20 - 188.119.224.0/20 - 188.119.240.0/20 - 188.239.0.0/20 - 188.239.16.0/20 - 188.239.32.0/20 - 188.239.48.0/20 - 189.1.192.0/20 - 189.1.208.0/20 - 189.1.224.0/20 - 189.1.240.0/20 - 189.28.112.0/20 - 189.28.96.0/20 - 190.92.192.0/19 - 190.92.224.0/19 - 190.92.248.0/24 - 190.92.252.0/24 - 190.92.253.0/24 - 190.92.254.0/24 - 201.77.32.0/20 - 202.170.88.0/21 - 202.76.128.0/20 - 202.76.144.0/20 - 202.76.160.0/20 - 202.76.176.0/20 - 203.123.80.0/20 - 203.167.20.0/23 - 203.167.22.0/24 - 212.34.192.0/20 - 212.34.208.0/20 - 213.250.128.0/20 - 213.250.144.0/20 - 213.250.160.0/20 - 213.250.176.0/21 - 213.250.184.0/21 - 219.83.0.0/20 - 219.83.112.0/22 - 219.83.116.0/23 - 219.83.118.0/23 - 219.83.121.0/24 - 219.83.122.0/24 - 219.83.123.0/24 - 219.83.124.0/24 - 219.83.16.0/20 - 219.83.32.0/20 - 219.83.76.0/23 - 2404:a140:43::/48 - 2405:f080::/39 - 2405:f080:1::/48 - 2405:f080:1000::/39 - 2405:f080:1200::/39 - 2405:f080:1400::/48 - 2405:f080:1401::/48 - 2405:f080:1402::/48 - 2405:f080:1403::/48 - 2405:f080:1500::/40 - 2405:f080:1600::/48 - 2405:f080:1602::/48 - 2405:f080:1603::/48 - 2405:f080:1800::/39 - 2405:f080:1800::/44 - 2405:f080:1810::/48 - 2405:f080:1811::/48 - 2405:f080:1812::/48 - 2405:f080:1813::/48 - 2405:f080:1814::/48 - 2405:f080:1815::/48 - 2405:f080:1900::/40 - 2405:f080:1e02::/47 - 2405:f080:1e04::/47 - 2405:f080:1e06::/47 - 2405:f080:1e1e::/47 - 2405:f080:1e20::/47 - 2405:f080:200::/48 - 2405:f080:2000::/39 - 2405:f080:201::/48 - 2405:f080:202::/48 - 2405:f080:2040::/48 - 2405:f080:2200::/39 - 2405:f080:2280::/48 - 2405:f080:2281::/48 - 2405:f080:2282::/48 - 2405:f080:2283::/48 - 2405:f080:2284::/48 - 2405:f080:2285::/48 - 2405:f080:2286::/48 - 2405:f080:2287::/48 - 2405:f080:2288::/48 - 2405:f080:2289::/48 - 2405:f080:228a::/48 - 2405:f080:228b::/48 - 2405:f080:228c::/48 - 2405:f080:228d::/48 - 2405:f080:228e::/48 - 2405:f080:228f::/48 - 2405:f080:2400::/39 - 2405:f080:2600::/39 - 2405:f080:2800::/48 - 2405:f080:2a00::/48 - 2405:f080:2e00::/47 - 2405:f080:3000::/38 - 2405:f080:3000::/40 - 2405:f080:3100::/40 - 2405:f080:3200::/48 - 2405:f080:3201::/48 - 2405:f080:3202::/48 - 2405:f080:3203::/48 - 2405:f080:3204::/48 - 2405:f080:3205::/48 - 2405:f080:3400::/38 - 2405:f080:3400::/40 - 2405:f080:3500::/40 - 2405:f080:3600::/48 - 2405:f080:3601::/48 - 2405:f080:3602::/48 - 2405:f080:3603::/48 - 2405:f080:3604::/48 - 2405:f080:3605::/48 - 2405:f080:400::/39 - 2405:f080:4000::/40 - 2405:f080:4100::/48 - 2405:f080:4102::/48 - 2405:f080:4103::/48 - 2405:f080:4104::/48 - 2405:f080:4200::/40 - 2405:f080:4300::/40 - 2405:f080:600::/48 - 2405:f080:800::/40 - 2405:f080:810::/44 - 2405:f080:a00::/39 - 2405:f080:a11::/48 - 2405:f080:e02::/48 - 2405:f080:e03::/48 - 2405:f080:e04::/47 - 2405:f080:e05::/48 - 2405:f080:e06::/48 - 2405:f080:e07::/48 - 2405:f080:e0e::/47 - 2405:f080:e10::/47 - 2405:f080:edff::/48 - 27.106.0.0/20 - 27.106.112.0/20 - 27.106.16.0/20 - 27.106.32.0/20 - 27.106.48.0/20 - 27.106.64.0/20 - 27.106.80.0/20 - 27.106.96.0/20 - 27.255.0.0/23 - 27.255.10.0/23 - 27.255.12.0/23 - 27.255.14.0/23 - 27.255.16.0/23 - 27.255.18.0/23 - 27.255.2.0/23 - 27.255.20.0/23 - 27.255.22.0/23 - 27.255.26.0/23 - 27.255.28.0/23 - 27.255.30.0/23 - 27.255.32.0/23 - 27.255.34.0/23 - 27.255.36.0/23 - 27.255.38.0/23 - 27.255.4.0/23 - 27.255.40.0/23 - 27.255.42.0/23 - 27.255.44.0/23 - 27.255.46.0/23 - 27.255.48.0/23 - 27.255.50.0/23 - 27.255.52.0/23 - 27.255.54.0/23 - 27.255.58.0/23 - 27.255.6.0/23 - 27.255.60.0/23 - 27.255.62.0/23 - 27.255.8.0/23 - 42.201.128.0/20 - 42.201.144.0/20 - 42.201.160.0/20 - 42.201.176.0/20 - 42.201.192.0/20 - 42.201.208.0/20 - 42.201.224.0/20 - 42.201.240.0/20 - 43.225.140.0/22 - 43.255.104.0/22 - 45.194.104.0/21 - 45.199.144.0/22 - 45.202.128.0/19 - 45.202.160.0/20 - 45.202.176.0/21 - 45.202.184.0/21 - 45.203.40.0/21 - 46.250.160.0/20 - 46.250.176.0/20 - 49.0.192.0/21 - 49.0.200.0/21 - 49.0.224.0/22 - 49.0.228.0/22 - 49.0.232.0/21 - 49.0.240.0/20 - 62.245.0.0/20 - 62.245.16.0/20 - 80.238.128.0/22 - 80.238.132.0/22 - 80.238.136.0/22 - 80.238.140.0/22 - 80.238.144.0/22 - 80.238.148.0/22 - 80.238.152.0/22 - 80.238.156.0/22 - 80.238.164.0/22 - 80.238.164.0/24 - 80.238.165.0/24 - 80.238.168.0/22 - 80.238.168.0/24 - 80.238.169.0/24 - 80.238.170.0/24 - 80.238.171.0/24 - 80.238.172.0/22 - 80.238.176.0/22 - 80.238.180.0/24 - 80.238.181.0/24 - 80.238.183.0/24 - 80.238.184.0/24 - 80.238.185.0/24 - 80.238.186.0/24 - 80.238.190.0/24 - 80.238.192.0/20 - 80.238.208.0/20 - 80.238.224.0/20 - 80.238.240.0/20 - 83.101.0.0/21 - 83.101.104.0/21 - 83.101.16.0/21 - 83.101.24.0/21 - 83.101.32.0/21 - 83.101.48.0/21 - 83.101.56.0/23 - 83.101.58.0/23 - 83.101.64.0/21 - 83.101.72.0/21 - 83.101.8.0/23 - 83.101.80.0/21 - 83.101.88.0/24 - 83.101.89.0/24 - 83.101.96.0/21 - 87.119.12.0/24 - 89.150.192.0/20 - 89.150.208.0/20 - 94.244.128.0/20 - 94.244.144.0/20 - 94.244.160.0/20 - 94.244.176.0/20 - 94.45.160.0/19 - 94.45.160.0/24 - 94.45.161.0/24 - 94.45.163.0/24 - 94.74.112.0/21 - 94.74.120.0/21 - 94.74.64.0/20 - 94.74.80.0/20 - 94.74.96.0/20 ================================================ FILE: data/crawlers/internet-archive.yaml ================================================ - name: internet-archive action: ALLOW # https://ipinfo.io/AS7941 remote_addresses: ["207.241.224.0/20", "208.70.24.0/21", "2620:0:9c0::/48"] ================================================ FILE: data/crawlers/kagibot.yaml ================================================ - name: kagibot user_agent_regex: \+https\://kagi\.com/bot action: ALLOW # https://kagi.com/bot remote_addresses: [ "216.18.205.234/32", "35.212.27.76/32", "104.254.65.50/32", "209.151.156.194/32", ] ================================================ FILE: data/crawlers/marginalia.yaml ================================================ - name: marginalia user_agent_regex: search\.marginalia\.nu action: ALLOW # Received directly over email remote_addresses: [ "193.183.0.162/31", "193.183.0.164/30", "193.183.0.168/30", "193.183.0.172/31", "193.183.0.174/32", ] ================================================ FILE: data/crawlers/mojeekbot.yaml ================================================ - name: mojeekbot user_agent_regex: \+https\://www\.mojeek\.com/bot\.html action: ALLOW # https://www.mojeek.com/bot.html remote_addresses: ["5.102.173.71/32"] ================================================ FILE: data/crawlers/openai-gptbot.yaml ================================================ # Collects AI training data # https://platform.openai.com/docs/bots/overview-of-openai-crawlers - name: openai-gptbot user_agent_regex: GPTBot/1\.1; \+https\://openai\.com/gptbot action: ALLOW # https://openai.com/gptbot.json remote_addresses: [ "52.230.152.0/24", "20.171.206.0/24", "20.171.207.0/24", "4.227.36.0/25", "20.125.66.80/28", "172.182.204.0/24", "172.182.214.0/24", "172.182.215.0/24", ] ================================================ FILE: data/crawlers/openai-searchbot.yaml ================================================ # Indexing for search, does not collect training data # https://platform.openai.com/docs/bots/overview-of-openai-crawlers - name: openai-searchbot user_agent_regex: OAI-SearchBot/1\.0; \+https\://openai\.com/searchbot action: ALLOW # https://openai.com/searchbot.json remote_addresses: [ "20.42.10.176/28", "172.203.190.128/28", "104.210.140.128/28", "51.8.102.0/24", "135.234.64.0/24", ] ================================================ FILE: data/crawlers/perplexitybot.yaml ================================================ # Indexing for search, does not collect training data # https://docs.perplexity.ai/guides/bots - name: perplexitybot user_agent_regex: PerplexityBot/.+; \+https\://perplexity\.ai/perplexitybot action: ALLOW # https://www.perplexity.com/perplexitybot.json remote_addresses: [ "107.20.236.150/32", "3.224.62.45/32", "18.210.92.235/32", "3.222.232.239/32", "3.211.124.183/32", "3.231.139.107/32", "18.97.1.228/30", "18.97.9.96/29", ] ================================================ FILE: data/crawlers/qwantbot.yaml ================================================ - name: qwantbot user_agent_regex: \+https\://help\.qwant\.com/bot/ action: ALLOW # https://help.qwant.com/wp-content/uploads/sites/2/2025/01/qwantbot.json remote_addresses: ["91.242.162.0/24"] ================================================ FILE: data/crawlers/tencent-cloud.yaml ================================================ # Tencent Cloud crawler IP ranges - name: tencent-cloud action: DENY remote_addresses: - 101.32.0.0/17 - 101.32.176.0/20 - 101.32.192.0/18 - 101.33.116.0/22 - 101.33.120.0/21 - 101.33.16.0/20 - 101.33.2.0/23 - 101.33.32.0/19 - 101.33.4.0/22 - 101.33.64.0/19 - 101.33.8.0/21 - 101.33.96.0/20 - 119.28.28.0/24 - 119.29.29.0/24 - 124.156.0.0/16 - 129.226.0.0/18 - 129.226.128.0/18 - 129.226.224.0/19 - 129.226.96.0/19 - 150.109.0.0/18 - 150.109.128.0/20 - 150.109.160.0/19 - 150.109.192.0/18 - 150.109.64.0/20 - 150.109.80.0/21 - 150.109.88.0/22 - 150.109.96.0/19 - 162.14.60.0/22 - 162.62.0.0/18 - 162.62.128.0/20 - 162.62.144.0/21 - 162.62.152.0/22 - 162.62.172.0/22 - 162.62.176.0/20 - 162.62.192.0/19 - 162.62.255.0/24 - 162.62.80.0/20 - 162.62.96.0/19 - 170.106.0.0/16 - 43.128.0.0/14 - 43.132.0.0/22 - 43.132.12.0/22 - 43.132.128.0/17 - 43.132.16.0/22 - 43.132.28.0/22 - 43.132.32.0/22 - 43.132.40.0/22 - 43.132.52.0/22 - 43.132.60.0/24 - 43.132.64.0/22 - 43.132.69.0/24 - 43.132.70.0/23 - 43.132.72.0/21 - 43.132.80.0/21 - 43.132.88.0/22 - 43.132.92.0/23 - 43.132.96.0/19 - 43.133.0.0/16 - 43.134.0.0/16 - 43.135.0.0/17 - 43.135.128.0/18 - 43.135.192.0/19 - 43.152.0.0/21 - 43.152.11.0/24 - 43.152.12.0/22 - 43.152.128.0/22 - 43.152.133.0/24 - 43.152.134.0/23 - 43.152.136.0/21 - 43.152.144.0/20 - 43.152.160.0/22 - 43.152.16.0/21 - 43.152.164.0/23 - 43.152.166.0/24 - 43.152.168.0/21 - 43.152.178.0/23 - 43.152.180.0/22 - 43.152.184.0/21 - 43.152.192.0/18 - 43.152.24.0/22 - 43.152.31.0/24 - 43.152.32.0/23 - 43.152.35.0/24 - 43.152.36.0/22 - 43.152.40.0/21 - 43.152.48.0/20 - 43.152.74.0/23 - 43.152.76.0/22 - 43.152.80.0/22 - 43.152.8.0/23 - 43.152.92.0/23 - 43.153.0.0/16 - 43.154.0.0/15 - 43.156.0.0/15 - 43.158.0.0/16 - 43.159.0.0/20 - 43.159.128.0/17 - 43.159.64.0/23 - 43.159.70.0/23 - 43.159.72.0/21 - 43.159.81.0/24 - 43.159.82.0/23 - 43.159.85.0/24 - 43.159.86.0/23 - 43.159.88.0/21 - 43.159.96.0/19 - 43.160.0.0/15 - 43.162.0.0/16 - 43.163.0.0/17 - 43.163.128.0/18 - 43.163.192.255/32 - 43.163.193.0/24 - 43.163.194.0/23 - 43.163.196.0/22 - 43.163.200.0/21 - 43.163.208.0/20 - 43.163.224.0/19 - 43.164.0.0/18 - 43.164.128.0/17 - 43.165.0.0/16 - 43.166.128.0/18 - 43.166.224.0/19 - 43.168.0.0/20 - 43.168.16.0/21 - 43.168.24.0/22 - 43.168.255.0/24 - 43.168.32.0/19 - 43.168.64.0/20 - 43.168.80.0/22 - 43.169.0.0/16 - 43.170.0.0/16 - 43.174.0.0/18 - 43.174.128.0/17 - 43.174.64.0/22 - 43.174.68.0/23 - 43.174.71.0/24 - 43.174.74.0/23 - 43.174.76.0/22 - 43.174.80.0/20 - 43.174.96.0/19 - 43.175.0.0/20 - 43.175.113.0/24 - 43.175.114.0/23 - 43.175.116.0/22 - 43.175.120.0/21 - 43.175.128.0/18 - 43.175.16.0/22 - 43.175.192.0/20 - 43.175.20.0/23 - 43.175.208.0/21 - 43.175.216.0/22 - 43.175.220.0/23 - 43.175.22.0/24 - 43.175.222.0/24 - 43.175.224.0/20 - 43.175.25.0/24 - 43.175.26.0/23 - 43.175.28.0/22 - 43.175.32.0/19 - 43.175.64.0/19 - 43.175.96.0/20 ================================================ FILE: data/crawlers/wikimedia-citoid.yaml ================================================ # Wikimedia Foundation citation services # https://www.mediawiki.org/wiki/Citoid - name: wikimedia-citoid user_agent_regex: "Citoid/WMF" action: ALLOW remote_addresses: [ "208.80.152.0/22", "2620:0:860::/46", ] - name: wikimedia-zotero-translation-server user_agent_regex: "ZoteroTranslationServer/WMF" action: ALLOW remote_addresses: [ "208.80.152.0/22", "2620:0:860::/46", ] ================================================ FILE: data/crawlers/yandexbot.yaml ================================================ - name: yandexbot action: ALLOW expression: all: - userAgent.matches("\\+http\\://yandex\\.com/bots") - verifyFCrDNS(remoteAddress, "^.*\\.yandex\\.(ru|com|net)$") ================================================ FILE: data/embed.go ================================================ package data import "embed" var ( //go:embed botPolicies.yaml all:apps all:bots all:clients all:common all:crawlers all:meta all:services BotPolicies embed.FS ) ================================================ FILE: data/embed_test.go ================================================ package data import ( "path/filepath" "strings" "testing" ) // TestBotPoliciesEmbed ensures all YAML files in the directory tree // are accessible in the embedded BotPolicies filesystem. func TestBotPoliciesEmbed(t *testing.T) { yamlFiles, err := filepath.Glob("./**/*.yaml") if err != nil { t.Fatalf("Failed to glob YAML files: %v", err) } if len(yamlFiles) == 0 { t.Fatal("No YAML files found in directory tree") } t.Logf("Found %d YAML files to verify", len(yamlFiles)) for _, filePath := range yamlFiles { embeddedPath := strings.TrimPrefix(filePath, "./") t.Run(embeddedPath, func(t *testing.T) { content, err := BotPolicies.ReadFile(embeddedPath) if err != nil { t.Errorf("Failed to read %s from embedded filesystem: %v", embeddedPath, err) return } if len(content) == 0 { t.Errorf("File %s exists in embedded filesystem but is empty", embeddedPath) } }) } } ================================================ FILE: data/meta/README.md ================================================ # meta policies Contains policies that exclusively reference policies in _multiple_ other data folders. Akin to "stances" that the administrator can take, with reference to various topics, such as AI/LLM systems. ================================================ FILE: data/meta/ai-block-aggressive.yaml ================================================ # Blocks all AI/LLM associated user agents, regardless of purpose or human agency # Warning: To completely block some AI/LLM training, such as with Google, you _must_ place flags in robots.txt. - import: (data)/bots/ai-catchall.yaml - import: (data)/clients/ai.yaml - import: (data)/crawlers/ai-search.yaml - import: (data)/crawlers/ai-training.yaml ================================================ FILE: data/meta/ai-block-moderate.yaml ================================================ # Blocks all AI/LLM bots used for training or unknown/undocumented purposes. # Permits user agents with explicitly documented non-training use, and published IP allowlists. - import: (data)/bots/ai-catchall.yaml - import: (data)/crawlers/ai-training.yaml - import: (data)/crawlers/openai-searchbot.yaml - import: (data)/crawlers/perplexitybot.yaml - import: (data)/clients/openai-chatgpt-user.yaml - import: (data)/clients/mistral-mistralai-user.yaml - import: (data)/clients/perplexity-user.yaml ================================================ FILE: data/meta/ai-block-permissive.yaml ================================================ # Permits all well documented AI/LLM user agents with published IP allowlists. - import: (data)/bots/ai-catchall.yaml - import: (data)/crawlers/openai-searchbot.yaml - import: (data)/crawlers/openai-gptbot.yaml - import: (data)/crawlers/perplexitybot.yaml - import: (data)/clients/openai-chatgpt-user.yaml - import: (data)/clients/mistral-mistralai-user.yaml - import: (data)/clients/perplexity-user.yaml ================================================ FILE: data/meta/default-config.yaml ================================================ - # Pathological bots to deny # This correlates to data/bots/_deny-pathological.yaml in the source tree # https://github.com/TecharoHQ/anubis/blob/main/data/bots/_deny-pathological.yaml import: (data)/bots/_deny-pathological.yaml - import: (data)/bots/aggressive-brazilian-scrapers.yaml # Aggressively block AI/LLM related bots/agents by default - import: (data)/meta/ai-block-aggressive.yaml # Consider replacing the aggressive AI policy with more selective policies: # - import: (data)/meta/ai-block-moderate.yaml # - import: (data)/meta/ai-block-permissive.yaml # Search engine crawlers to allow, defaults to: # - Google (so they don't try to bypass Anubis) # - Apple # - Bing # - DuckDuckGo # - Qwant # - The Internet Archive # - Kagi # - Marginalia # - Mojeek - import: (data)/crawlers/_allow-good.yaml # Challenge Firefox AI previews - import: (data)/clients/x-firefox-ai.yaml # Allow common "keeping the internet working" routes (well-known, favicon, robots.txt) - import: (data)/common/keep-internet-working.yaml # # Punish any bot with "bot" in the user-agent string # # This is known to have a high false-positive rate, use at your own risk # - name: generic-bot-catchall # user_agent_regex: (?i:bot|crawler) # action: CHALLENGE # challenge: # difficulty: 16 # impossible # algorithm: slow # intentionally waste CPU cycles and time # Requires a subscription to Thoth to use, see # https://anubis.techaro.lol/docs/admin/thoth#geoip-based-filtering - name: countries-with-aggressive-scrapers action: WEIGH geoip: countries: - BR - CN weight: adjust: 10 # Requires a subscription to Thoth to use, see # https://anubis.techaro.lol/docs/admin/thoth#asn-based-filtering - name: aggressive-asns-without-functional-abuse-contact action: WEIGH asns: match: - 13335 # Cloudflare - 136907 # Huawei Cloud - 45102 # Alibaba Cloud weight: adjust: 10 # ## System load based checks. # # If the system is under high load, add weight. # - name: high-load-average # action: WEIGH # expression: load_1m >= 10.0 # make sure to end the load comparison in a .0 # weight: # adjust: 20 ## If your backend service is running on the same operating system as Anubis, ## you can uncomment this rule to make the challenge easier when the system is ## under low load. ## ## If it is not, remove weight. # - name: low-load-average # action: WEIGH # expression: load_15m <= 4.0 # make sure to end the load comparison in a .0 # weight: # adjust: -10 # Generic catchall rule - name: generic-browser user_agent_regex: >- Mozilla|Opera action: WEIGH weight: adjust: 10 ================================================ FILE: data/meta/messengers-preview.yaml ================================================ - import: (data)/clients/telegram-preview.yaml - import: (data)/clients/vk-preview.yaml ================================================ FILE: data/services/updown.yaml ================================================ # https://updown.io/about - name: updown user_agent_regex: updown.io action: ALLOW remote_addresses: [ "45.32.74.41/32", "104.238.136.194/32", "192.99.37.47/32", "91.121.222.175/32", "104.238.159.87/32", "102.212.60.78/32", "135.181.102.135/32", "45.32.107.181/32", "45.76.104.117/32", "45.63.29.207/32", "2001:19f0:6001:2c6::1/128", "2001:19f0:9002:11a::1/128", "2607:5300:60:4c2f::1/128", "2001:41d0:2:85af::1/128", "2001:19f0:6c01:145::1/128", "2c0f:c40:4003:4::2/128", "2a01:4f9:c010:d5f9::1/128", "2001:19f0:4400:402e::1/128", "2001:19f0:7001:45a::1/128", "2001:19f0:5801:1d8::1/128" ] ================================================ FILE: data/services/uptime-robot.yaml ================================================ - name: uptime-robot user_agent_regex: UptimeRobot action: ALLOW # https://api.uptimerobot.com/meta/ips remote_addresses: [ "3.12.251.153/32", "3.20.63.178/32", "3.77.67.4/32", "3.79.134.69/32", "3.105.133.239/32", "3.105.190.221/32", "3.133.226.214/32", "3.149.57.90/32", "3.212.128.62/32", "5.161.61.238/32", "5.161.73.160/32", "5.161.75.7/32", "5.161.113.195/32", "5.161.117.52/32", "5.161.177.47/32", "5.161.194.92/32", "5.161.215.244/32", "5.223.43.32/32", "5.223.53.147/32", "5.223.57.22/32", "18.116.205.62/32", "18.180.208.214/32", "18.192.166.72/32", "18.193.252.127/32", "24.144.78.39/32", "24.144.78.185/32", "34.198.201.66/32", "45.55.123.175/32", "45.55.127.146/32", "49.13.24.81/32", "49.13.130.29/32", "49.13.134.145/32", "49.13.164.148/32", "49.13.167.123/32", "52.15.147.27/32", "52.22.236.30/32", "52.28.162.93/32", "52.59.43.236/32", "52.87.72.16/32", "54.64.67.106/32", "54.79.28.129/32", "54.87.112.51/32", "54.167.223.174/32", "54.249.170.27/32", "63.178.84.147/32", "64.225.81.248/32", "64.225.82.147/32", "69.162.124.227/32", "69.162.124.235/32", "69.162.124.238/32", "78.46.190.63/32", "78.46.215.1/32", "78.47.98.55/32", "78.47.173.76/32", "88.99.80.227/32", "91.99.101.207/32", "128.140.41.193/32", "128.140.106.114/32", "129.212.132.140/32", "134.199.240.137/32", "138.197.53.117/32", "138.197.53.138/32", "138.197.54.143/32", "138.197.54.247/32", "138.197.63.92/32", "139.59.50.44/32", "142.132.180.39/32", "143.198.249.237/32", "143.198.250.89/32", "143.244.196.21/32", "143.244.196.211/32", "143.244.221.177/32", "144.126.251.21/32", "146.190.9.187/32", "152.42.149.135/32", "157.90.155.240/32", "157.90.156.63/32", "159.69.158.189/32", "159.223.243.219/32", "161.35.247.201/32", "167.99.18.52/32", "167.235.143.113/32", "168.119.53.160/32", "168.119.96.239/32", "168.119.123.75/32", "170.64.250.64/32", "170.64.250.132/32", "170.64.250.235/32", "178.156.181.172/32", "178.156.184.20/32", "178.156.185.127/32", "178.156.185.231/32", "178.156.187.238/32", "178.156.189.113/32", "178.156.189.249/32", "188.166.201.79/32", "206.189.241.133/32", "209.38.49.1/32", "209.38.49.206/32", "209.38.49.226/32", "209.38.51.43/32", "209.38.53.7/32", "209.38.124.252/32", "216.144.248.18/31", "216.144.248.21/32", "216.144.248.22/31", "216.144.248.24/30", "216.144.248.28/31", "216.144.248.30/32", "216.245.221.83/32", "2400:6180:10:200::56a0:b000/128", "2400:6180:10:200::56a0:c000/128", "2400:6180:10:200::56a0:e000/128", "2400:6180:100:d0::94b6:4001/128", "2400:6180:100:d0::94b6:5001/128", "2400:6180:100:d0::94b6:7001/128", "2406:da14:94d:8601:9d0d:7754:bedf:e4f5/128", "2406:da14:94d:8601:b325:ff58:2bba:7934/128", "2406:da14:94d:8601:db4b:c5ac:2cbe:9a79/128", "2406:da1c:9c8:dc02:7ae1:f2ea:ab91:2fde/128", "2406:da1c:9c8:dc02:7db9:f38b:7b9f:402e/128", "2406:da1c:9c8:dc02:82b2:f0fd:ee96:579/128", "2600:1f16:775:3a00:ac3:c5eb:7081:942e/128", "2600:1f16:775:3a00:37bf:6026:e54a:f03a/128", "2600:1f16:775:3a00:3f24:5bb0:95d7:5a6b/128", "2600:1f16:775:3a00:8c2c:2ba6:778f:5be5/128", "2600:1f16:775:3a00:91ac:3120:ff38:92b5/128", "2600:1f16:775:3a00:dbbe:36b0:3c45:da32/128", "2600:1f18:179:f900:71:af9a:ade7:d772/128", "2600:1f18:179:f900:2406:9399:4ae6:c5d3/128", "2600:1f18:179:f900:4696:7729:7bb3:f52f/128", "2600:1f18:179:f900:4b7d:d1cc:2d10:211/128", "2600:1f18:179:f900:5c68:91b6:5d75:5d7/128", "2600:1f18:179:f900:e8dd:eed1:a6c:183b/128", "2604:a880:800:14:0:1:68ba:d000/128", "2604:a880:800:14:0:1:68ba:e000/128", "2604:a880:800:14:0:1:68bb:0/128", "2604:a880:800:14:0:1:68bb:1000/128", "2604:a880:800:14:0:1:68bb:3000/128", "2604:a880:800:14:0:1:68bb:4000/128", "2604:a880:800:14:0:1:68bb:5000/128", "2604:a880:800:14:0:1:68bb:6000/128", "2604:a880:800:14:0:1:68bb:7000/128", "2604:a880:800:14:0:1:68bb:a000/128", "2604:a880:800:14:0:1:68bb:b000/128", "2604:a880:800:14:0:1:68bb:c000/128", "2604:a880:800:14:0:1:68bb:d000/128", "2604:a880:800:14:0:1:68bb:e000/128", "2604:a880:800:14:0:1:68bb:f000/128", "2607:ff68:107::4/128", "2607:ff68:107::14/128", "2607:ff68:107::33/128", "2607:ff68:107::48/127", "2607:ff68:107::50/125", "2607:ff68:107::58/127", "2607:ff68:107::60/128", "2a01:4f8:c0c:83fa::1/128", "2a01:4f8:c17:42e4::1/128", "2a01:4f8:c2c:9fc6::1/128", "2a01:4f8:c2c:beae::1/128", "2a01:4f8:1c1a:3d53::1/128", "2a01:4f8:1c1b:4ef4::1/128", "2a01:4f8:1c1b:5b5a::1/128", "2a01:4f8:1c1b:7ecc::1/128", "2a01:4f8:1c1c:11aa::1/128", "2a01:4f8:1c1c:5353::1/128", "2a01:4f8:1c1c:7240::1/128", "2a01:4f8:1c1c:a98a::1/128", "2a01:4f8:c012:c60e::1/128", "2a01:4f8:c013:c18::1/128", "2a01:4f8:c013:34c0::1/128", "2a01:4f8:c013:3b0f::1/128", "2a01:4f8:c013:3c52::1/128", "2a01:4f8:c013:3c53::1/128", "2a01:4f8:c013:3c54::1/128", "2a01:4f8:c013:3c55::1/128", "2a01:4f8:c013:3c56::1/128", "2a01:4ff:f0:bfd::1/128", "2a01:4ff:f0:2219::1/128", "2a01:4ff:f0:3e03::1/128", "2a01:4ff:f0:5f80::1/128", "2a01:4ff:f0:7fad::1/128", "2a01:4ff:f0:9c5f::1/128", "2a01:4ff:f0:b2f2::1/128", "2a01:4ff:f0:b6f1::1/128", "2a01:4ff:f0:d283::1/128", "2a01:4ff:f0:d3cd::1/128", "2a01:4ff:f0:e516::1/128", "2a01:4ff:f0:e9cf::1/128", "2a01:4ff:f0:eccb::1/128", "2a01:4ff:f0:efd1::1/128", "2a01:4ff:f0:fdc7::1/128", "2a01:4ff:2f0:193c::1/128", "2a01:4ff:2f0:27de::1/128", "2a01:4ff:2f0:3b3a::1/128", "2a03:b0c0:2:f0::bd91:f001/128", "2a03:b0c0:2:f0::bd92:1/128", "2a03:b0c0:2:f0::bd92:1001/128", "2a03:b0c0:2:f0::bd92:2001/128", "2a03:b0c0:2:f0::bd92:4001/128", "2a03:b0c0:2:f0::bd92:5001/128", "2a03:b0c0:2:f0::bd92:6001/128", "2a03:b0c0:2:f0::bd92:7001/128", "2a03:b0c0:2:f0::bd92:8001/128", "2a03:b0c0:2:f0::bd92:9001/128", "2a03:b0c0:2:f0::bd92:a001/128", "2a03:b0c0:2:f0::bd92:b001/128", "2a03:b0c0:2:f0::bd92:c001/128", "2a03:b0c0:2:f0::bd92:e001/128", "2a03:b0c0:2:f0::bd92:f001/128", "2a05:d014:1815:3400:6d:9235:c1c0:96ad/128", "2a05:d014:1815:3400:654f:bd37:724c:212b/128", "2a05:d014:1815:3400:90b4:4ef9:5631:b170/128", "2a05:d014:1815:3400:9779:d8e9:100a:9642/128", "2a05:d014:1815:3400:af29:e95e:64ff:df81/128", "2a05:d014:1815:3400:c7d6:f7f3:6cc1:30d1/128", "2a05:d014:1815:3400:d784:e5dd:8e0:67cb/128", ] ================================================ FILE: decaymap/decaymap.go ================================================ package decaymap import ( "sync" "time" ) func Zilch[T any]() T { var zero T return zero } // 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] // deleteCh receives decay-deletion requests from readers. deleteCh chan deleteReq[K] // stopCh stops the background cleanup worker. stopCh chan struct{} wg sync.WaitGroup lock sync.RWMutex } type decayMapEntry[V any] struct { Value V expiry time.Time } // deleteReq is a request to remove a key if its expiry timestamp still matches // the observed one. This prevents racing with concurrent Set updates. type deleteReq[K comparable] struct { key K expiry time.Time } // New creates a new DecayMap of key type K and value type V. // // Key types must be comparable to work with maps. func New[K comparable, V any]() *Impl[K, V] { m := &Impl[K, V]{ data: make(map[K]decayMapEntry[V]), deleteCh: make(chan deleteReq[K], 1024), stopCh: make(chan struct{}), } m.wg.Add(1) go m.cleanupWorker() return m } // expire forcibly expires a key by setting its time-to-live one second in the past. func (m *Impl[K, V]) expire(key K) bool { // Use a single write lock to avoid RUnlock->Lock convoy. m.lock.Lock() defer m.lock.Unlock() val, ok := m.data[key] if !ok { return false } val.expiry = time.Now().Add(-1 * time.Second) m.data[key] = val return true } // Delete a value from the DecayMap by key. // // If the value does not exist, return false. Return true after // deletion. func (m *Impl[K, V]) Delete(key K) bool { // Use a single write lock to avoid RUnlock->Lock convoy. m.lock.Lock() defer m.lock.Unlock() _, ok := m.data[key] if ok { delete(m.data, key) } return ok } // Get gets a value from the DecayMap by key. // // If a value has expired, forcibly delete it if it was not updated. 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 } if time.Now().After(value.expiry) { // Defer decay deletion to the background worker to avoid convoy. select { case m.deleteCh <- deleteReq[K]{key: key, expiry: value.expiry}: default: // Channel full: drop request; a future Cleanup() or Get will retry. } return Zilch[V](), false } return value.Value, true } // Set sets a key value pair in the map. func (m *Impl[K, V]) Set(key K, value V, ttl time.Duration) { m.lock.Lock() defer m.lock.Unlock() m.data[key] = decayMapEntry[V]{ Value: value, expiry: time.Now().Add(ttl), } } // 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) } // Close stops the background cleanup worker. It's optional to call; maps live // for the process lifetime in many cases. Call in tests or when you know you no // longer need the map to avoid goroutine leaks. func (m *Impl[K, V]) Close() { close(m.stopCh) m.wg.Wait() } // cleanupWorker batches decay deletions to minimize lock contention. func (m *Impl[K, V]) cleanupWorker() { defer m.wg.Done() batch := make([]deleteReq[K], 0, 64) ticker := time.NewTicker(500 * time.Millisecond) defer ticker.Stop() flush := func() { if len(batch) == 0 { return } m.applyDeletes(batch) // reset batch without reallocating batch = batch[:0] } for { select { case req := <-m.deleteCh: batch = append(batch, req) case <-ticker.C: flush() case <-m.stopCh: // Drain any remaining requests then exit for { select { case req := <-m.deleteCh: batch = append(batch, req) default: flush() return } } } } } func (m *Impl[K, V]) applyDeletes(batch []deleteReq[K]) { now := time.Now() m.lock.Lock() for _, req := range batch { entry, ok := m.data[req.key] if !ok { continue } // Only delete if the expiry is unchanged and already past. if entry.expiry.Equal(req.expiry) && now.After(entry.expiry) { delete(m.data, req.key) } } m.lock.Unlock() } ================================================ FILE: decaymap/decaymap_test.go ================================================ package decaymap import ( "testing" "time" ) func TestImpl(t *testing.T) { dm := New[string, string]() t.Cleanup(dm.Close) 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") } // Deletion of expired entries after Get is deferred to a background worker. // Assert it eventually disappears from the map. deadline := time.Now().Add(700 * time.Millisecond) for time.Now().Before(deadline) { if dm.Len() == 0 { break } time.Sleep(5 * time.Millisecond) } if dm.Len() != 0 { t.Fatalf("expected background cleanup to remove expired key; len=%d", dm.Len()) } } func TestCleanup(t *testing.T) { dm := New[string, string]() t.Cleanup(dm.Close) 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") } } ================================================ FILE: docs/.dockerignore ================================================ # Dependencies /node_modules # Production /build # Generated files .docusaurus .cache-loader # Misc .DS_Store .env.local .env.development.local .env.test.local .env.production.local npm-debug.log* yarn-debug.log* yarn-error.log* ================================================ FILE: docs/.gitignore ================================================ # Dependencies /node_modules # Production /build # Generated files .docusaurus .cache-loader # Misc .DS_Store .env.local .env.development.local .env.test.local .env.production.local npm-debug.log* yarn-debug.log* yarn-error.log* ================================================ FILE: docs/Dockerfile ================================================ FROM docker.io/library/node:lts AS build WORKDIR /app COPY . . RUN npm ci && npm run build FROM ghcr.io/xe/nginx-micro COPY --from=build /app/build /www COPY ./manifest/cfg/nginx/nginx.conf /conf LABEL org.opencontainers.image.source="https://github.com/TecharoHQ/anubis" ================================================ FILE: docs/README.md ================================================ # Website This website is built using [Docusaurus](https://docusaurus.io/), a modern static website generator. ### Installation ``` $ yarn ``` ### Local Development ``` $ yarn start ``` This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server. ### Build ``` $ yarn build ``` This command generates static content into the `build` directory and can be served using any static contents hosting service. ### Deployment Using SSH: ``` $ USE_SSH=true yarn deploy ``` Not using SSH: ``` $ GIT_USER=<Your GitHub username> yarn deploy ``` If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch. ================================================ FILE: docs/blog/2025-06-16-welcome/index.mdx ================================================ --- slug: welcome title: Welcome to the Anubis blog! authors: [xe] tags: [intro] --- Hello, world! At Techaro, we've been working on making Anubis even better, and in the process we want to share what we've done, how it works, and signal boost cool things the community has done. As things happen, we'll blog about them so that you can learn from our struggles. More details to come soon! {/* truncate */} ================================================ FILE: docs/blog/2025-06-27-release-1.20.0/index.mdx ================================================ --- slug: release/v1.20.0 title: Anubis v1.20.0 is now available! authors: [xe] tags: [release] image: sunburst.webp --- ![](./sunburst.webp) Hey all! Today we released [Anubis v1.20.0: Thancred Waters](https://github.com/TecharoHQ/anubis/releases/tag/v1.20.0). This adds a lot of new and exciting features to Anubis, including but not limited to the `WEIGH` action, custom weight thresholds, Imprint/impressum support, and a no-JS challenge. Here's what you need to know so you can protect your websites in new and exciting ways! {/* truncate */} ## Sponsoring the product If you rely on Anubis to keep your website safe, please consider sponsoring the project on [GitHub Sponsors](https://github.com/sponsors/Xe) or [Patreon](https://patreon.com/cadey). Funding helps pay hosting bills and offset the time spent on making this project the best it can be. Every little bit helps and when enough money is raised, [I can make Anubis my full-time job](https://github.com/TecharoHQ/anubis/discussions/278). I am waiting to hear back from NLNet on if Anubis was selected for funding or not. Let's hope it is! ## Deprecation warning: `DIFFICULTY` Anubis v1.20.0 is the last version to support the `DIFFICULTY` flag in the exact way it currently does. In future versions, this will be ineffectual and you should use the [custom threshold system](/docs/admin/configuration/thresholds) instead. If this becomes an imposition in practice, this will be reverted. ## Chrome won't show "invalid response" after "Success!" There were a bunch of smaller fixes in Anubis this time around, but the biggest one was finally squashing the ["invalid response" after "Success!" issue](https://github.com/TecharoHQ/anubis/issues/564) that had been plaguing Chrome users. This was a really annoying issue to track down but it was discovered while we were working on better end-to-end / functional testing: [Chrome randomizes the `Accept-Language` header](https://github.com/explainers-by-googlers/reduce-accept-language) so that websites can't do fingerprinting as easily. When Anubis issues a challenge, it grabs [information that the browser sends to the user](/docs/design/how-anubis-works#challenge-format) to create a challenge string. Anubis doesn't store these challenge strings anywhere, and when a solution is being checked it calculates the challenge string from the request. This means that they'd get a challenge on one end, compute the response for that challenge, and then the server would validate that against a different challenge. This server-side validation would fail, leading to the user seeing "invalid response" after the client reported success. I suspect this was why Vanadium and Cromite were having sporadic issues as well. ## New Features The biggest feature in Anubis is the "weight" subsystem. This allows administrators to make custom rules that change the suspicion level of a request without having to take immediate action. As an example, consider the self-hostable git forge [Gitea](https://about.gitea.com/). When you load a page in Gitea, it creates a session cookie that your browser sends with every request. Weight allows you to mark a request that includes a Gitea session token as _less_ suspicious: ```yaml - name: gitea-session-token action: WEIGH expression: all: # Check if the request has a Cookie header - '"Cookie" in headers' # Check if the request's Cookie header contains the Gitea session token - headers["Cookie"].contains("i_love_gitea=") # Remove 5 weight points weight: adjust: -5 ``` This is different from the past where you could only allow every request with a Gitea session token, meaning that the invention of lying would allow malicious clients to bypass protection. Weight is added and removed whenever a `WEIGH` rule is encountered. When all rules are processed and the request doesn't match any `ALLOW`, `CHALLENGE`, or `DENY` rules, Anubis uses [weight thresholds](/docs/admin/configuration/thresholds) to figure out how to handle that request. Thresholds are defined in the [policy file](/docs/admin/policies) alongside your bot rules: ```yaml thresholds: - name: minimal-suspicion # This client is likely fine, its soul is lighter than a feather expression: weight <= 0 # a feather weighs zero units action: ALLOW # Allow the traffic through # For clients that had some weight reduced through custom rules, give them a # lightweight challenge. - name: mild-suspicion expression: all: - weight > 0 - weight < 10 action: CHALLENGE challenge: # https://anubis.techaro.lol/docs/admin/configuration/challenges/metarefresh algorithm: metarefresh difficulty: 1 report_as: 1 # For clients that are browser-like but have either gained points from custom rules or # report as a standard browser. - name: moderate-suspicion expression: all: - weight >= 10 - weight < 20 action: CHALLENGE challenge: # https://anubis.techaro.lol/docs/admin/configuration/challenges/proof-of-work algorithm: fast difficulty: 2 # two leading zeros, very fast for most clients report_as: 2 # For clients that are browser like and have gained many points from custom rules - name: extreme-suspicion expression: weight >= 20 action: CHALLENGE challenge: # https://anubis.techaro.lol/docs/admin/configuration/challenges/proof-of-work algorithm: fast difficulty: 4 report_as: 4 ``` :::note If you don't have thresholds defined in your Anubis policy file, Anubis will default to the "legacy" behaviour where browser-like clients get a challenge at the default difficulty. ::: This lets most clients through if they pass a simple [proof of work challenge](/docs/admin/configuration/challenges/proof-of-work), but any clients that are less suspicious (like ones with a Gitea session token) are given the lightweight [Meta Refresh](/docs/admin/configuration/challenges/metarefresh) challenge instead. Threshold expressions are like [Bot rule expressions](/docs/admin/configuration/expressions), but there's only one input: the request's weight. If no thresholds match, the request is allowed through. ### Imprint/Impressum Support European countries like Germany [require an imprint/impressum](https://www.ionos.com/digitalguide/websites/digital-law/a-case-for-thinking-global-germanys-impressum-laws/) to be present in the footer of their website. This allows users to contact someone on the team behind a website in case they run into issues. This also must generally have a separate page where users can view an extended imprint with other information like a privacy policy or a copyright notice. Anubis v1.20.0 and later [has support for showing imprints](/docs/admin/configuration/impressum). You can configure two kinds of imprints: 1. An imprint that is shown in the footer of every Anubis page. 2. An extended imprint / privacy policy that is shown when users click on the "Imprint" link. For example, [here's the imprint for the website you're looking at right now](https://anubis.techaro.lol/.within.website/x/cmd/anubis/api/imprint). Imprints are configured in [the policy file](/docs/admin/policies/): ```yaml impressum: # Displayed at the bottom of every page rendered by Anubis. footer: >- This website is hosted by Zombocom. If you have any complaints or notes about the service, please contact <a href="mailto:contact@zombocom.example">contact@zombocom.example</a> and we will assist you as soon as possible. # The imprint page that will be linked to at the footer of every Anubis page. page: # The HTML <title> of the page title: Imprint and Privacy Policy # The HTML contents of the page. The exact contents of this page can # and will vary by locale. Please consult with a lawyer if you are not # sure what to put here. body: >- <p>Last updated: June 2025</p> <h2>Information that is gathered from visitors</h2> <p>In common with other websites, log files are stored on the web server saving details such as the visitor's IP address, browser type, referring page and time of visit.</p> <p>Cookies may be used to remember visitor preferences when interacting with the website.</p> <p>Where registration is required, the visitor's email and a username will be stored on the server.</p> <!-- ... --> ``` If this is insufficient, please [file an issue](https://github.com/TecharoHQ/anubis/issues/new) with a link to the relevant legislation for your country so that this feature can be amended and improved. ### No-JS Challenge One of the first issues in Anubis before it was moved to the [TecharoHQ org](https://github.com/TecharoHQ) was a request [to support challenging browsers without using JavaScript](https://github.com/Xe/x/issues/651). This is a pretty challenging thing to do without rethinking how Anubis works from a fundamentally low level, and with v1.20.0, [Anubis finally has support for running without client-side JavaScript](https://github.com/TecharoHQ/anubis/issues/95) thanks to the [Meta Refresh](/docs/admin/configuration/challenges/metarefresh) challenge. When Anubis decides it needs to send a challenge to your browser, it sends a challenge page. Historically, this challenge page is [an HTML template](https://github.com/TecharoHQ/anubis/blob/main/web/index.templ) that kicks off some JavaScript, reads the challenge information out of the page body, and then solves it as fast as possible in order to let users see the website they want to visit. In v1.20.0, Anubis has a challenge registry to hold [different client challenge implementations](/docs/admin/configuration/challenges/). This allows us to implement anything we want as long as it can render a page to show a challenge and then check if the result is correct. This is going to be used to implement a WebAssembly-based proof of work option (one that will be way more efficient than the existing browser JS version), but as a proof of concept I implemented a simple challenge using [HTML `<meta refresh>`](https://en.wikipedia.org/wiki/Meta_refresh). In my testing, this has worked with every browser I have thrown it at (including CLI browsers, the browser embedded in emacs, etc.). The default configuration of Anubis does use the [meta refresh challenge](/docs/admin/configuration/challenges/metarefresh) for [clients with a very low suspicion](/docs/admin/configuration/thresholds), but by default clients will be sent an [easy proof of work challenge](/docs/admin/configuration/challenges/proof-of-work). If the false positive rate of this challenge turns out to not be very high in practice, the meta refresh challenge will be enabled by default for browsers in future versions of Anubis. ### `robots2policy` Anubis was created because crawler bots don't respect [`robots.txt` files](https://www.robotstxt.org/). Administrators have been working on refining and crafting their `robots.txt` files for years, and one common comment is that people don't know where to start crafting their own rules. Anubis now ships with a [`robots2policy` tool](/docs/admin/robots2policy) that lets you convert your `robots.txt` file to an Anubis policy. ```text robots2policy -input https://github.com/robots.txt ``` :::note If you installed Anubis from [an OS package](/docs/admin/native-install), you may need to run `anubis-robots2policy` instead of `robots2policy`. ::: We hope that this will help you get started with Anubis faster. We are working on a version of this that will run in the documentation via WebAssembly. ### Open Graph configuration is being moved to the policy file Anubis supports reading [Open Graph tags](/docs/admin/configuration/open-graph) from target services and returning them in challenge pages. This makes the right metadata show up when linking services protected by Anubis in chat applications or on social media. In order to test the migration of all of the configuration to the policy file, Open Graph configuration has been moved to the policy file. For more information, please read [the Open Graph configuration options](/docs/admin/configuration/open-graph#configuration-options). You can also set default Open Graph tags: ```yaml openGraph: enabled: true ttl: 24h # If set, return these opengraph values instead of looking them up with # the target service. # # Correlates to properties in https://ogp.me/ override: # og:title is required, it is the title of the website "og:title": "Techaro Anubis" "og:description": >- Anubis is a Web AI Firewall Utility that helps you fight the bots away so that you can maintain uptime at work! "description": >- Anubis is a Web AI Firewall Utility that helps you fight the bots away so that you can maintain uptime at work! ``` ## Improvements and optimizations One of the biggest improvements we've made in v1.20.0 is replacing [SHA-256 with xxhash](https://github.com/TecharoHQ/anubis/pull/676). Anubis uses hashes all over the place to help with identifying clients, matching against rules when allowing traffic through, in error messages sent to users, and more. Historically these have been done with [SHA-256](https://en.wikipedia.org/wiki/SHA-2), however this has been having a mild performance impact in real-world use. As a result, we now use [xxhash](https://xxhash.com/) when possible. This makes policy matching 3x faster in some scenarios and reduces memory usage across the board. Anubis now uses [bart](https://pkg.go.dev/github.com/gaissmai/bart) for doing IP address matching when you specify addresses in a `remote_address` check configuration or when you are matching against [advanced checks](/docs/admin/thoth). This uses the same kind of IP address routing configuration that your OS kernel does, making it very fast to query information about IP addresses. This makes IP address range matches anywhere from 3-14 times faster depending on the number of addresses it needs to match against. For more information and benchmarks, check out [@JasonLovesDoggo](https://github.com/JasonLovesDoggo)'s PR: [perf: replace cidranger with bart for significant performance improvements #675](https://github.com/TecharoHQ/anubis/pull/675). ## What's up next? v1.21.0 is already shaping up to be a massive improvement as Anubis adds [internationalization](https://en.wikipedia.org/wiki/Internationalization) support, allowing your users to see its messages in the language they're most comfortable with. So far Anubis supports the following languages: - English (Simplified and Traditional) - French - Portuguese (Brazil) - Spanish If you want to contribute translations, please [file an issue](https://github.com/TecharoHQ/anubis/issues/new) with your language of choice or submit a pull request to [the `lib/localization/locales` folder](https://github.com/TecharoHQ/anubis/tree/main/lib/localization/locales). We are about to introduce features to the translation stack, so you may want to hold off a hot minute, but we welcome any and all contributions to making Anubis useful to a global audience. Other things we plan to do: - Move configuration to the policy file - Support reloading the policy file at runtime without having to restart Anubis - Detecting if a client is "brand new" - A [Valkey](https://valkey.io/)-backed store for sharing information between instances of Anubis - Augmenting No-JS support in the paid product - TLS fingerprinting - Automated testing improvements in CI (FreeBSD CI support, better automated integration/functional testing, etc.) ## Conclusion I hope that these features let you get the same Anubis power you've come to know and love and increases the things you can do with it! I've been really excited to ship [thresholds](/docs/admin/configuration/thresholds) and the cloud-based services for Anubis. If you run into any problems, please [file an issue](https://github.com/TecharoHQ/anubis/issues/new). Otherwise, have a good day and get back to making your communities great. ================================================ FILE: docs/blog/2025-07-09-incident-report/index.mdx ================================================ --- slug: incident/TI-20250709-0001 title: "TI-20250709-0001: IPv4 traffic failures for Techaro services" authors: [xe] tags: [incident] image: ./window-portal.jpg --- ![](./window-portal.jpg) Techaro services were down for IPv4 traffic on July 9th, 2025. This blogpost is a report of what happened, what actions were taken to resolve the situation, and what actions are being done in the near future to prevent this problem. Enjoy this incident report! {/* truncate */} :::note In other companies, this kind of documentation would be kept internal. At Techaro, we believe that you deserve radical candor and the truth. As such, we are proving our lofty words with actions by publishing details about how things go wrong publicly. Everything past this point follows my standard incident root cause meeting template. ::: This incident report will focus on the services affected, timeline of what happened at which stage of the incident, where we got lucky, the root cause analysis, and what action items are being planned or taken to prevent this from happening in the future. ## Timeline All events take place on July 9th, 2025. | Time (UTC) | Description | | :--------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 12:32 | Uptime Kuma reports that another unrelated website on the same cluster was timing out. | | 12:33 | Uptime Kuma reports that Thoth's production endpoint is failing gRPC health checks. | | 12:35 | Investigation begins, [announcement made on Xe's Bluesky](https://bsky.app/profile/xeiaso.net/post/3ltjtdczpwc2x) due to the impact including their personal blog. | | 12:39 | `nginx-ingress` logs on the production cluster show IPv6 traffic but an abrupt cutoff in IPv4 traffic around 12:32 UTC. Ticket is opened with the hosting provider. | | 12:41 | IPv4 traffic resumes long enough for Uptime Kuma to report uptime, but then immediately fails again. | | 12:46 | IPv4 traffic resumes long enough for Uptime Kuma to report uptime, but then immediately fails again. (repeat instances of this have been scrubbed, but it happened about every 5-10 minutes) | | 12:48 | First reply from the hosting provider. | | 12:57 | Reply to hosting provider, ask to reboot the load balancer. | | 13:00 | Incident responder because busy due to a meeting under the belief that the downtime was out of their control and that uptime monitoring software would let them know if it came back up. | | 13:20 | Incident responder ended meeting and went back to monitoring downtime and preparing this document. | | 13:34 | IPv4 traffic starts to show up in the `ingress-nginx` logs. | | 13:35 | All services start to report healthy. Incident status changes to monitoring. | | 13:48 | Incident closed. | | 14:07 | Incident re-opened. Issues seem to be manifesting as BGP issues in the upstream provider. | | 14:10 | IPv4 traffic resumes and then stops. | | 14:18 | IPv4 traffic resumes again. Incident status changes to monitoring. | | 14:40 | Incident closed. | ## Services affected | Service name | User impact | | :-------------------------------------------------- | :----------------- | | [Anubis Docs](https://anubis.techaro.lol) (IPv4) | Connection timeout | | [Anubis Docs](https://anubis.techaro.lol) (IPv6) | None | | [Thoth](/docs/admin/thoth/) (IPv4) | Connection timeout | | [Thoth](/docs/admin/thoth/) (IPv6) | None | | Other websites colocated on the same cluster (IPv4) | Connection timeout | | Other websites colocated on the same cluster (IPv6) | None | ## Root cause analysis In simplify server management, Techaro runs a [Kubernetes](https://kubernetes.io/) cluster on [Vultr VKE](https://www.vultr.com/kubernetes/) (Vultr Kubernetes Engine). When you do this, Vultr needs to provision a [load balancer](https://docs.vultr.com/how-to-use-a-vultr-load-balancer-with-vke) to bridge the gap between the outside world and the Kubernetes world, kinda like this: ```mermaid --- title: Overall architecture --- flowchart LR UT(User Traffic) subgraph Provider Infrastructure LB[Load Balancer] end subgraph Kubernetes IN(ingress-nginx) TH(Thoth) AN(Anubis Docs) OS(Other sites) IN --> TH IN --> AN IN --> OS end UT --> LB --> IN ``` Techaro controls everything inside the Kubernetes side of that diagram. Anything else is out of our control. That load balancer is routed to the public internet via [Border Gateway Protocol (BGP)](https://en.wikipedia.org/wiki/Border_Gateway_Protocol). If there is an interruption with the BGP sessions in the upstream provider, this can manifest as things either not working or inconsistently working. This is made more difficult by the fact that the IPv4 and IPv6 internets are technically separate networks. With this in mind, it's very possible to have IPv4 traffic fail but not IPv6 traffic. The root cause is that the hosting provider we use for production services had flapping IPv4 BGP sessions in its Toronto region. When this happens all we can do is open a ticket and wait for it to come back up. ## Where we got lucky The Uptime Kuma instance that caught this incident runs on an IPv4-only network. If it was dual stack, this would not have been caught as quickly. The `ingress-nginx` logs print IP addresses of remote clients to the log feed. If this was not the case, it would be much more difficult to find this error. ## Action items - A single instance of downtime like this is not enough reason to move providers. Moving providers because of this is thus out of scope. - Techaro needs a status page hosted on a different cloud provider than is used for the production cluster (`TecharoHQ/TODO#6`). - Health checks for IPv4 and IPv6 traffic need to be created (`TecharoHQ/TODO#7`). - Remove the requirement for [Anubis to pass Thoth health checks before it can start if Thoth is enabled](https://github.com/TecharoHQ/anubis/pull/794). ================================================ FILE: docs/blog/2025-07-22-release-1.21.1/index.mdx ================================================ --- slug: release/v1.21.1 title: Anubis v1.21.1 is now available! authors: [xe] tags: [release] image: anubis-i18n.webp --- ![](./anubis-i18n.webp) Hey all! Recently we released [Anubis v1.21.1: Minfilia Warde (Echo 1)](https://github.com/TecharoHQ/anubis/releases/tag/v1.21.1). This is a fairly meaty release and like [last time](../2025-06-27-release-1.20.0/index.mdx) this blogpost will tell you what you need to know before you update. Kick back, get some popcorn and let's dig into this! {/* truncate */} In this release, Anubis becomes internationalized, gains the ability to use system load as input to issuing challenges, finally fixes the "invalid response" after "success" bug, and more! Please read these notes before upgrading as the changes are big enough that administrators should take action to ensure that the upgrade goes smoothly. This release is brought to you by [FreeCAD](https://www.freecad.org/), an open-source computer aided design tool that lets you design things for the real world. ## What's in this release? The biggest change is that the ["invalid response" after "success" bug](https://github.com/TecharoHQ/anubis/issues/564) is now finally fixed for good by totally rewriting how [Anubis' challenge issuance flow works](#challenge-flow-v2). This release gives Anubis the following features: - [Internationalization support](#internationalization), allowing Anubis to render its messages in the human language you speak. - Anubis now supports the [`missingHeader`](#missingHeader-function) function to assert the absence of headers in requests. - Anubis now has the ability to [store data persistently on the server](#persistent-data-storage). - Anubis can use [the system load average](#load-average-checks) as a factor to determine if it needs to filter traffic or not. - Add `COOKIE_SECURE` option to set the cookie [Secure flag](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Cookies#block_access_to_your_cookies) - Sets cookie defaults to use [SameSite: None](https://web.dev/articles/samesite-cookies-explained) - Allow [Common Crawl](https://commoncrawl.org/) by default so scrapers have less incentive to scrape - Add `/healthz` metrics route for use in platform-based health checks. - Start exposing JA4H fingerprints for later use in CEL expressions. And this release also fixes the following bugs: - [Challenge issuance has been totally rewritten](#challenge-flow-v2) to finally squash the infamous ["invalid response" after "success" bug](https://github.com/TecharoHQ/anubis/issues/564) for good. - In order to reduce confusion, the "Success" interstitial that shows up when you pass a proof of work challenge has been removed. - Don't block Anubis starting up if [Thoth](/docs/admin/thoth/) health checks fail. - The "Try again" button on the error page has been fixed. Previously it meant "try the solution again" instead of "try the challenge again". - In certain cases, a user could be stuck with a test cookie that is invalid, locking them out of the service for up to half an hour. This has been fixed with better validation of this case and clearing the cookie. - "Proof of work" has been removed from the branding due to some users having extremely negative connotations with it. We try to avoid introducing breaking changes as much as possible, but these are the changes that may be relevant for you as an administrator: - The [challenge format](#challenge-format-change) has been changed in order to account for [the new challenge issuance flow](#challenge-flow-v2). - The [systemd service `RuntimeDirectory` has been changed](#breaking-change-systemd-runtimedirectory-change). ### Sponsoring the project If you rely on Anubis to keep your website safe, please consider sponsoring the project on [GitHub Sponsors](https://github.com/sponsors/Xe) or [Patreon](https://patreon.com/cadey). Funding helps pay hosting bills and offset the time spent on making this project the best it can be. Every little bit helps and when enough money is raised, [I can make Anubis my full-time job](https://github.com/TecharoHQ/anubis/discussions/278). Once this pie chart is at 100%, I can start to reduce my hours at my day job as most of my needs will be met (pre-tax): ```mermaid pie title Funding update "GitHub Sponsors" : 29 "Patreon" : 14 "Remaining" : 56 ``` I am waiting to hear back from NLNet on if Anubis was selected for funding or not. Let's hope it is! ## New features ### Internationalization Anubis now supports localized responses. Locales can be added in [lib/localization/locales/](https://github.com/TecharoHQ/anubis/tree/main/lib/localization/locales). This release includes support for the following languages: - [Brazilian Portuguese](https://github.com/TecharoHQ/anubis/pull/726) - [Chinese (Simplified)](https://github.com/TecharoHQ/anubis/pull/774) - [Chinese (Traditional)](https://github.com/TecharoHQ/anubis/pull/759) - [Czech](https://github.com/TecharoHQ/anubis/pull/849) - English - [Estonian](https://github.com/TecharoHQ/anubis/pull/783) - [Filipino](https://github.com/TecharoHQ/anubis/pull/775) - [Finnish](https://github.com/TecharoHQ/anubis/pull/863) - [French](https://github.com/TecharoHQ/anubis/pull/716) - [German](https://github.com/TecharoHQ/anubis/pull/741) - [Japanese](https://github.com/TecharoHQ/anubis/pull/772) - [Icelandic](https://github.com/TecharoHQ/anubis/pull/780) - [Italian](https://github.com/TecharoHQ/anubis/pull/778) - [Norwegian](https://github.com/TecharoHQ/anubis/pull/855) - [Russian](https://github.com/TecharoHQ/anubis/pull/882) - [Spanish](https://github.com/TecharoHQ/anubis/pull/716) - [Turkish](https://github.com/TecharoHQ/anubis/pull/751) If facts or local regulations demand, you can set Anubis default language with the `FORCED_LANGUAGE` environment variable or the `--forced-language` command line argument: ```sh FORCED_LANGUAGE=de ``` ## Big ticket bug fixes These issues affect every user of Anubis. Administrators should upgrade Anubis as soon as possible to mitigate them. ### Fix event loop thrashing when solving a proof of work challenge Anubis has a progress bar so that users can have something moving while it works. This gives users more confidence that something is happening and that the website is not being malicious with CPU usage. However, the way it was implemented way back in [#87](https://github.com/TecharoHQ/anubis/pull/87) had a subtle bug: ```js if ( (nonce > oldNonce) | 1023 && // we've wrapped past 1024 (nonce >> 10) % threads === threadId // and it's our turn ) { postMessage(nonce); } ``` The logic here looks fine but is subtly wrong as was reported in [#877](https://github.com/TecharoHQ/anubis/issues/877) by the main Pale Moon developer. For context, `nonce` is a counter that increments by the worker count every loop. This is intended to spread the load between CPU cores as such: | Iteration | Worker ID | Nonce | | :-------- | :-------- | :---- | | 1 | 0 | 0 | | 1 | 1 | 1 | | 2 | 0 | 2 | | 2 | 1 | 3 | And so on. This makes the proof of work challenge as fast as it can possibly be so that Anubis quickly goes away and you can enjoy the service it is protecting. The incorrect part of this is the boolean logic, specifically the part with the bitwise or `|`. I think the intent was to use a logical or (`||`), but this had the effect of making the `postMessage` handler fire on every iteration. The intent of this snippet (as the comment clearly indicates) is to make sure that the main event loop is only updated with the worker status every 1024 iterations per worker. This had the opposite effect, causing a lot of messages to be sent from workers to the parent JavaScript context. This is bad for the event loop. Instead, I have ripped out that statement and replaced it with a much simpler increment only counter that fires every 1024 iterations. Additionally, only the first thread communicates back to the parent process. This does mean that in theory the other workers could be ahead of the first thread (posting a message out of a worker has a nonzero cost), but in practice I don't think this will be as much of an issue as the current behaviour is. The root cause of the stack exhaustion is likely the pressure caused by all of the postMessage futures piling up. Maybe the larger stack size in 64 bit environments is causing this to be fine there, maybe it's some combination of newer hardware in 64 bit systems making this not be as much of a problem due to it being able to handle events fast enough to keep up with the pressure. Either way, thanks much to [@wolfbeast](https://github.com/wolfbeast) and the Pale Moon community for finding this. This will make Anubis faster for everyone! ### Fix potential memory leak when discovering a solution In some cases, the parallel solution finder in Anubis could cause all of the worker promises to leak due to the fact the promises were being improperly terminated. A recursion bomb happens in the following scenario: 1. A worker sends a message indicating it found a solution to the proof of work challenge. 2. The `onmessage` handler for that worker calls `terminate()` 3. Inside `terminate()`, the parent process loops through all other workers and calls `w.terminate()` on them. 4. It's possible that terminating a worker could lead to the `onerror` event handler. 5. This would create a recursive loop of `onmessage` -> `terminate` -> `onerror` -> `terminate` -> `onerror` and so on. This infinite recursion quickly consumes all available stack space, but this has never been noticed in development because all of my computers have at least 64Gi of ram provisioned to them under the axiom paying for more ram is cheaper than paying in my time spent having to work around not having enough ram. Additionally, ia32 has a smaller base stack size, which means that they will run into this issue much sooner than users on other CPU architectures will. The fix adds a boolean `settled` flag to prevent termination from running more than once. ## Expressions features Anubis v1.21.1 adds additional [expressions](/docs/admin/configuration/expressions) features so that you can make your request matching even more granular. ### `missingHeader` function Anubis [expressions](/docs/admin/configuration/expressions) have [a few functions exposed](/docs/admin/configuration/expressions/#functions-exposed-to-anubis-expressions). Anubis v1.21.1 adds the `missingHeader` function, allowing you to assert the _absence_ of a header in requests. Let's say you're getting a lot of requests from clients that are pretending to be Google Chrome. Google Chrome sends a few signals to web servers, the main one of them is the [`Sec-Ch-Ua`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Sec-CH-UA). Sec-CH-UA is part of Google's [User Agent Client Hints](https://wicg.github.io/ua-client-hints/#sec-ch-ua) proposal, but it being present is a sign that the client is more likely Google Chrome than not. With the `missingHeader` function, you can write a rule to [add weight](/docs/admin/policies/#request-weight) to requests without `Sec-Ch-Ua` that claim to be Google Chrome. ```yaml # Adds weight clients that claim to be Google Chrome without setting Sec-Ch-Ua - name: old-chrome action: WEIGH weight: adjust: 10 expression: all: - userAgent.matches("Chrome/[1-9][0-9]?\\.0\\.0\\.0") - missingHeader(headers, "Sec-Ch-Ua") ``` When combined with [weight thresholds](/docs/admin/configuration/thresholds), this allows you to make requests that don't match the signature of Google Chrome more suspicious, which will make them have a more difficult challenge. ### Load average checks Anubis can dynamically take action [based on the system load average](/docs/admin/configuration/expressions/#using-the-system-load-average), allowing you to write rules like this: ```yaml ## System load based checks. # If the system is under high load for the last minute, add weight. - name: high-load-average action: WEIGH expression: load_1m >= 10.0 # make sure to end the load comparison in a .0 weight: adjust: 20 # If it is not for the last 15 minutes, remove weight. - name: low-load-average action: WEIGH expression: load_15m <= 4.0 # make sure to end the load comparison in a .0 weight: adjust: -10 ``` Something to keep in mind about system load average is that it is not aware of the number of cores the system has. If you have a 16 core system that has 16 processes running but none of them is hogging the CPU, then you will get a load average below 16. If you are in doubt, make your "high load" metric at least two times the number of CPU cores and your "low load" metric at least half of the number of CPU cores. For example: | Kind | Core count | Load threshold | | --------: | :--------- | :------------- | | high load | 4 | `8.0` | | low load | 4 | `2.0` | | high load | 16 | `32.0` | | low load | 16 | `8` | Also keep in mind that this does not account for other kinds of latency like I/O latency or downstream API response latency. A system can have its web applications unresponsive due to high latency from a MySQL server but still have that web application server report a load near or at zero. :::note This does not work if you are using Kubernetes. ::: When combined with [weight thresholds](/docs/admin/configuration/thresholds), this allows you to make incoming sessions "back off" while the server is under high load. ## Challenge flow v2 The main goal of Anubis is to weigh the risks of incoming requests in order to protect upstream resources against abusive clients like badly written scrapers. In order to separate "good" clients (like users wanting to learn from a website's content) from "bad" clients, Anubis issues [challenges](/docs/admin/configuration/challenges/). Previously the Anubis challenge flow looked like this: ```mermaid --- title: Old Anubis challenge flow --- flowchart LR user(User Browser) subgraph Anubis mIC{Challenge?} ic(Issue Challenge) rp(Proxy to service) mIC -->|User needs a challenge| ic mIC -->|User does not need a challenge| rp end target(Target Service) rp --> target user --> mIC ic -->|Pass a challenge| user target -->|Site data| users ``` In order to issue a challenge, Anubis generated a challenge string based on request metadata that we assumed wouldn't drastically change between requests, including but not limited to: - The client's User-Agent string. - The client [`Accept-Language` header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Accept-Language) value. - The client's IP address. Anubis also didn't store any information about challenges so that it can remain lightweight and handle the onslaught of requests from scrapers. The assumption was that the challenge string function was idempotent per client across time. What actually ended up happening was something like this: ```mermaid --- title: Anubis challenge string idempotency --- sequenceDiagram User->>+Anubis: GET /wiki/some-page Anubis->>+Make Challenge: Generate a challenge string Make Challenge->>-Anubis: Challenge string: taco salad Anubis->>-User: HTTP 401 solve a challenge User->>+Anubis: GET internal-api/pass-challenge Anubis->>+Make Challenge: Generate a challenge string Make Challenge->>-Anubis: Challenge string: burrito bar Anubis->>+User: Error: invalid response ``` Various attempts were made to fix this. All of these ended up failing. Many difficulties were discovered including but not limited to: - Removing `Accept-Language` from consideration because [Chrome randomizes the contents of `Accept-Language` to reduce fingerprinting](https://github.com/explainers-by-googlers/reduce-accept-language), a behaviour which [causes a lot of confusion](https://www.reddit.com/r/chrome/comments/nhpnez/google_chrome_is_randomly_switching_languages_on/) for users with multiple system languages selected. - [IPv6 privacy extensions](https://www.internetsociety.org/resources/deploy360/2014/privacy-extensions-for-ipv6-slaac/) mean that each request could be coming from a different IP address (at least one legitimate user in the wild has been observed to have a different IP address per TCP session across an entire `/48`). - Some [US mobile phone carriers make it too easy for your IP address to drastically change](https://news.ycombinator.com/item?id=32038215) without user input. - [Happy eyeballs](https://en.wikipedia.org/wiki/Happy_Eyeballs) means that some requests can come in over IPv4 and some requests can come in over IPv6. - To make things worse, you can't even assert that users are from the same [BGP autonomous system](<https://en.wikipedia.org/wiki/Autonomous_system_(Internet)>) because some users could have ISPs that are IPv4 only, forcing them to use a different IP address space to get IPv6 internet access. This sounds like it's rare enough, but I personally have to do this even though I pay for 8 gigabit fiber from my ISP and only get IPv4 service from them. Amusingly enough, the only part of this that has survived is the assertion that a user hasn't changed their `User-Agent` string. Maybe [that one guy that sets his Chrome version to `150`](https://github.com/TecharoHQ/anubis/issues/239) would have issues, but so far I've not seen any evidence that a client randomly changing their user agent between challenge issuance and solving can possibly be legitimate. As a result, the entire subsystem that generated challenges before had to be ripped out and rewritten from scratch. It was replaced with a new flow that stores data on the server side, compares that data against what the client responds with, and then checks pass/fail that way: ```mermaid --- title: New challenge flow --- sequenceDiagram User->>+Anubis: GET /wiki/some-page Anubis->>+Make Challenge: Generate a challenge string Make Challenge->>+Store: Store info for challenge 1234 Make Challenge->>-Anubis: Challenge string: taco salad, ID 1234 Anubis->>-User: HTTP 401 solve a challenge User->>+Anubis: GET internal-api/pass-challenge, challenge 1234 Anubis->>+Validate Challenge: verify challenge 1234 Validate Challenge->>+Store: Get info for challenge 1234 Store->>-Validate Challenge: Here you go! Validate Challenge->>-Anubis: Valid ✅ Anubis->>+User: Here's a cookie to get past Anubis ``` As a result, the [challenge format](#challenge-format-change) had to change. Old cookies will still be validated, but the next minor version (v1.22.0) will include validation to ensure that all challenges are accounted for on the server side. This data is stored in the active [storage backend](/docs/admin/policies/#storage-backends) for up to 30 minutes. This also fixes [#746](https://github.com/TecharoHQ/anubis/issues/746) and other similar instances of this issue. ### Challenge format change Previously Anubis did no accounting for challenges that it issued. This means that if Anubis restarted during a client, the client would be able to proceed once Anubis came back online. During the upgrade to v1.21.0 and when v1.21.0 (or later) restarts with the [in-memory storage backend](/docs/admin/policies/#memory), you may see a higher rate of failed challenges than normal. If this persists beyond a few minutes, [open an issue](https://github.com/TecharoHQ/anubis/issues/new). If you are using the in-memory storage backend, please consider using [a different storage backend](/docs/admin/policies/#storage-backends). ### Storage Anubis offers a few different storage backends depending on your needs: | Backend | Description | | :--------------------------------------- | :------------------------------------------------------------------------------------------------------------- | | [`memory`](/docs/admin/policies/#memory) | An in-memory hashmap that is cleared when Anubis is restarted. | | [`bbolt`](/docs/admin/policies/#bbolt) | A memory-mapped key/value store that can persist between Anubis restarts. | | [`valkey`](/docs/admin/policies/#valkey) | A networked key/value store that can persist between Anubis restarts and coordinate across multiple instances. | Please review the documentation for each storage method to figure out the one best for your needs. If you aren't sure, consult this diagram: ```mermaid --- title: What storage backend do I need? --- flowchart TD OneInstance{Do you only have one instance of Anubis?} Persistence{Do you have persistent disk access in your environment?} bbolt[(bbolt)] memory[(memory)] valkey[(valkey)] OneInstance -->|Yes| Persistence OneInstance -->|No| valkey Persistence -->|Yes| bbolt Persistence -->|No| memory ``` ## Breaking change: systemd `RuntimeDirectory` change The following potentially breaking change applies to native installs with systemd only: Each instance of systemd service template now has a unique `RuntimeDirectory`, as opposed to each instance of the service sharing a `RuntimeDirectory`. This change was made to avoid [the `RuntimeDirectory` getting nuked](https://github.com/TecharoHQ/anubis/issues/748) any time one of the Anubis instances restarts. If you configured Anubis' unix sockets to listen on `/run/anubis/foo.sock` for instance `anubis@foo`, you will need to configure Anubis to listen on `/run/anubis/foo/foo.sock` and additionally configure your HTTP load balancer as appropriate. If you need the legacy behaviour, install this [systemd unit dropin](https://www.flatcar.org/docs/latest/setup/systemd/drop-in-units/): ```systemd # /etc/systemd/system/anubis@.service.d/50-runtimedir.conf [Service] RuntimeDirectory=anubis ``` Just keep in mind that this will cause problems when Anubis restarts. ## What's up next? The biggest things we want to do in the next release (in no particular order): - A rewrite of bot checking rule configuration syntax to make it less ambiguous. - [JA4](https://blog.foxio.io/ja4+-network-fingerprinting) (and other forms of) fingerprinting and coordination with [Thoth](/docs/admin/thoth/) to allow clients with high aggregate pass rates through without seeing Anubis at all. - Advanced heuristics for [users of the unbranded variant of Anubis](/docs/admin/botstopper/). - Optimize the release flow so that releases can be triggered and executed by continuous integration tools. The ultimate goal is to make it possible to release Anubis in 15 minutes after pressing a single "mint release" button. - Add "hot reloading" support to Anubis, allowing administrators to update the rules without restarting the service. - Fix [multiple slash support](https://github.com/TecharoHQ/anubis/issues/754) for web applications that require optional path variables. - Add weight to "brand new" clients. - Implement a "maze" feature that tries to get crawlers ensnared in a maze of random links so that clients that are more than 20 links in can be reported to the home base. - Open [Thoth-based advanced checks](/docs/admin/thoth/) to more users with an easier onboarding flow. - More smoke tests including for browsers like [Pale Moon](https://www.palemoon.org/). ================================================ FILE: docs/blog/2025-08-18-funding-update/index.mdx ================================================ --- slug: 2025/funding-update title: Funding update authors: [xe] tags: [funding] image: around-the-bend.webp --- ![](./around-the-bend.webp) As we finish up work on [all of the features in the next release of Anubis](/docs/CHANGELOG#unreleased), I took a moment to add up the financials and here's an update on the recurring revenue of the project. Once I reach the [$5000 per month](https://github.com/TecharoHQ/anubis/discussions/278) mark, I can start reducing hours at my dayjob and start to make working on Anubis my full time job. {/* truncate */} Note that this only counts _recurring_ revenue (subscriptions to [BotStopper](/docs/admin/botstopper) and monthly repeating donations). Every one of the one-time donations I get is a gift and I am grateful for them, but I cannot make critically important financial decisions off of sporadic one-time donations. :::note All currency figures in this article are USD (United States Dollars) unless denoted otherwise. ::: Here's the funding breakdown by income stream: ```mermaid pie title Funding update August 2025 "GitHub Sponsors" : 3500 "Patreon" : 1500 "Liberapay" : 100 "Remaining" : 4800 ``` Assuming that some of my private support contracts and other sales effort go through, this will slightly change the shapes of this (a new pie chart segment will emerge for "Manual invoices"), but I am halfway there. This is a huge bar to pass and as it stands right now this is just enough income to pay for my monthly rent (not accounting for tax). As a reminder, here's the rough plan for the phases I want to hit based on the _recurring_ donation totals: | Monthly donations | Details | | :-------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | $0-5,000 per month | Anubis is a nights and weekends project based on how much spare time and energy I have. | | $5,000-10,000 per month | Anubis gets 1-2 days per week of my time put into it consistently and I go part-time at my dayjob. | | $10,000-15,000 per month | Anubis becomes my full time job. Features that are currently exclusive to [BotStopper](/docs/admin/botstopper/) start to trickle down to the open source version of Anubis. | | $15,000 per month and above | I start planning hiring for Techaro. | If your organization benefits from Anubis, please consider donating to the project in order to make this sustainable. The fewer financial problems I have means the more that Anubis can become better. ## New funding platform: Liberapay After many comments about the funding options, I have set up [Liberapay](https://liberapay.com/Xe/) as an option to receive donations. Additional funding targets will be added to Liberapay as soon as I hear back from my accountant with more information. All money received via Liberapay goes directly towards supporting the project. ## Next goals Here's my short term goals for the immediate future: 1. Finish [Thoth](/docs/admin/thoth/) and run a backfill to mass issue API keys. 2. Document and publish the writeup for the multi-region Google Cloud spot instance setup that Thoth is built upon. 3. Release v1.22.0 of Anubis with Traefik support and other important fixes. 4. Continue growing the project into a sustainable business. 5. Work through the [blog backlog](https://github.com/TecharoHQ/anubis/issues?q=is%3Aissue%20state%3Aopen%20label%3Ablog) to document the thoughts behind Anubis and how parts of it work. Thank you for supporting Anubis! It's only going to get better from here. ================================================ FILE: docs/blog/2025-08-28-cpu-core-odd/ProofOfWorkDiagram/index.jsx ================================================ import React, { useState, useEffect, useMemo } from "react"; import styles from "./styles.module.css"; // A helper function to perform SHA-256 hashing. // It takes a string, encodes it, hashes it, and returns a hex string. async function sha256(message) { try { const msgBuffer = new TextEncoder().encode(message); const hashBuffer = await crypto.subtle.digest("SHA-256", msgBuffer); const hashArray = Array.from(new Uint8Array(hashBuffer)); const hashHex = hashArray .map((b) => b.toString(16).padStart(2, "0")) .join(""); return hashHex; } catch (error) { console.error("Hashing failed:", error); return "Error hashing data"; } } // Generates a random hex string of a given byte length const generateRandomHex = (bytes = 16) => { const buffer = new Uint8Array(bytes); crypto.getRandomValues(buffer); return Array.from(buffer) .map((byte) => byte.toString(16).padStart(2, "0")) .join(""); }; // Icon components for better visual feedback const CheckIcon = () => ( <svg xmlns="http://www.w3.org/2000/svg" className={styles.iconGreen} fill="none" viewBox="0 0 24 24" stroke="currentColor" > <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /> </svg> ); const XCircleIcon = () => ( <svg xmlns="http://www.w3.org/2000/svg" className={styles.iconRed} fill="none" viewBox="0 0 24 24" stroke="currentColor" > <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /> </svg> ); // Main Application Component export default function App() { // State for the challenge, initialized with a random 16-byte hex string. const [challenge, setChallenge] = useState(() => generateRandomHex(16)); // State for the nonce, which is the variable we can change const [nonce, setNonce] = useState(0); // State to store the resulting hash const [hash, setHash] = useState(""); // A flag to indicate if the current hash is the "winning" one const [isMining, setIsMining] = useState(false); const [isFound, setIsFound] = useState(false); // The mining difficulty, i.e., the required number of leading zeros const difficulty = "00"; // Memoize the combined data to avoid recalculating on every render const combinedData = useMemo( () => `${challenge}${nonce}`, [challenge, nonce], ); // This effect hook recalculates the hash whenever the combinedData changes. useEffect(() => { let isMounted = true; const calculateHash = async () => { const calculatedHash = await sha256(combinedData); if (isMounted) { setHash(calculatedHash); setIsFound(calculatedHash.startsWith(difficulty)); } }; calculateHash(); return () => { isMounted = false; }; }, [combinedData, difficulty]); // This effect handles the automatic mining process useEffect(() => { if (!isMining) return; let miningNonce = nonce; let continueMining = true; const mine = async () => { while (continueMining) { const currentData = `${challenge}${miningNonce}`; const currentHash = await sha256(currentData); if (currentHash.startsWith(difficulty)) { setNonce(miningNonce); setIsMining(false); break; } miningNonce++; // Update the UI periodically to avoid freezing the browser if (miningNonce % 100 === 0) { setNonce(miningNonce); await new Promise((resolve) => setTimeout(resolve, 0)); // Yield to the browser } } }; mine(); return () => { continueMining = false; }; }, [isMining, challenge, nonce, difficulty]); const handleMineClick = () => { setIsMining(true); }; const handleStopClick = () => { setIsMining(false); }; const handleResetClick = () => { setIsMining(false); setNonce(0); }; const handleNewChallengeClick = () => { setIsMining(false); setChallenge(generateRandomHex(16)); setNonce(0); }; // Helper to render the hash with colored leading characters const renderHash = () => { if (!hash) return <span>...</span>; const prefix = hash.substring(0, difficulty.length); const suffix = hash.substring(difficulty.length); const prefixColor = isFound ? styles.hashPrefixGreen : styles.hashPrefixRed; return ( <> <span className={`${prefixColor} ${styles.hashPrefix}`}>{prefix}</span> <span className={styles.hashSuffix}>{suffix}</span> </> ); }; return ( <div className={styles.container}> <div className={styles.innerContainer}> <div className={styles.grid}> {/* Challenge Block */} <div className={styles.block}> <h2 className={styles.blockTitle}>1. Challenge</h2> <p className={styles.challengeText}>{challenge}</p> </div> {/* Nonce Control Block */} <div className={styles.block}> <h2 className={styles.blockTitle}>2. Nonce</h2> <div className={styles.nonceControls}> <button onClick={() => setNonce((n) => n - 1)} disabled={isMining} className={styles.nonceButton} > <svg xmlns="http://www.w3.org/2000/svg" className={styles.iconSmall} fill="none" viewBox="0 0 24 24" stroke="currentColor" > <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 12H4" /> </svg> </button> <span className={styles.nonceValue}>{nonce}</span> <button onClick={() => setNonce((n) => n + 1)} disabled={isMining} className={styles.nonceButton} > <svg xmlns="http://www.w3.org/2000/svg" className={styles.iconSmall} fill="none" viewBox="0 0 24 24" stroke="currentColor" > <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" /> </svg> </button> </div> </div> {/* Combined Data Block */} <div className={styles.block}> <h2 className={styles.blockTitle}>3. Combined Data</h2> <p className={styles.combinedDataText}>{combinedData}</p> </div> </div> {/* Arrow pointing down */} <div className={styles.arrowContainer}> <svg xmlns="http://www.w3.org/2000/svg" className={styles.iconGray} fill="none" viewBox="0 0 24 24" stroke="currentColor" > <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 14l-7 7m0 0l-7-7m7 7V3" /> </svg> </div> {/* Hash Output Block */} <div className={`${styles.hashContainer} ${isFound ? styles.hashContainerSuccess : styles.hashContainerError}`} > <div className={styles.hashContent}> <div className={styles.hashText}> <h2 className={styles.blockTitle}>4. Resulting Hash (SHA-256)</h2> <p className={styles.hashValue}>{renderHash()}</p> </div> <div className={styles.hashIcon}> {isFound ? <CheckIcon /> : <XCircleIcon />} </div> </div> </div> {/* Mining Controls */} <div className={styles.buttonContainer}> {!isMining ? ( <button onClick={handleMineClick} className={`${styles.button} ${styles.buttonCyan}`} > Auto-Mine </button> ) : ( <button onClick={handleStopClick} className={`${styles.button} ${styles.buttonYellow}`} > Stop Mining </button> )} <button onClick={handleNewChallengeClick} className={`${styles.button} ${styles.buttonIndigo}`} > New Challenge </button> <button onClick={handleResetClick} className={`${styles.button} ${styles.buttonGray}`} > Reset Nonce </button> </div> </div> </div> ); } ================================================ FILE: docs/blog/2025-08-28-cpu-core-odd/ProofOfWorkDiagram/styles.module.css ================================================ /* Main container styles */ .container { display: flex; flex-direction: column; align-items: center; justify-content: center; color: white; font-family: ui-sans-serif, system-ui, sans-serif; margin-top: 2rem; margin-bottom: 2rem; } .innerContainer { width: 100%; max-width: 56rem; margin: 0 auto; } /* Header styles */ .header { text-align: center; margin-bottom: 2.5rem; } .title { font-size: 2.25rem; font-weight: 700; color: rgb(34 211 238); } .subtitle { font-size: 1.125rem; color: rgb(156 163 175); margin-top: 0.5rem; } /* Grid layout styles */ .grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; align-items: center; text-align: center; } /* Block styles */ .block { background-color: rgb(31 41 55); padding: 1.5rem; border-radius: 0.5rem; box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); height: 100%; display: flex; flex-direction: column; justify-content: center; } .blockTitle { font-size: 1.125rem; font-weight: 600; color: rgb(34 211 238); margin-bottom: 0.5rem; } .challengeText { font-size: 0.875rem; color: rgb(209 213 219); word-break: break-all; font-family: ui-monospace, SFMono-Regular, monospace; } .combinedDataText { font-size: 0.875rem; color: rgb(156 163 175); word-break: break-all; font-family: ui-monospace, SFMono-Regular, monospace; } /* Nonce control styles */ .nonceControls { display: flex; align-items: center; justify-content: center; gap: 1rem; } .nonceButton { background-color: rgb(55 65 81); border-radius: 9999px; padding: 0.5rem; transition: background-color 200ms; } .nonceButton:hover:not(:disabled) { background-color: rgb(34 211 238); } .nonceButton:disabled { opacity: 0.5; cursor: not-allowed; } .nonceValue { font-size: 1.5rem; font-family: ui-monospace, SFMono-Regular, monospace; width: 6rem; text-align: center; } /* Icon styles */ .icon { height: 2rem; width: 2rem; } .iconGreen { height: 2rem; width: 2rem; color: rgb(74 222 128); } .iconRed { height: 2rem; width: 2rem; color: rgb(248 113 113); } .iconSmall { height: 1.5rem; width: 1.5rem; } .iconGray { height: 2.5rem; width: 2.5rem; color: rgb(75 85 99); animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; } /* Arrow animation */ @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } } .arrowContainer { display: flex; justify-content: center; margin: 1.5rem 0; } /* Hash output styles */ .hashContainer { padding: 1.5rem; border-radius: 0.5rem; box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); transition: all 300ms; border: 2px solid; } .hashContainerSuccess { background-color: rgb(20 83 45 / 0.5); border-color: rgb(74 222 128); } .hashContainerError { background-color: rgb(127 29 29 / 0.5); border-color: rgb(248 113 113); } .hashContent { display: flex; flex-direction: column; align-items: center; justify-content: space-between; } .hashText { text-align: center; } .hashTextLg { text-align: left; } .hashValue { font-size: 0.875rem; word-break: break-all; } .hashValueLg { font-size: 1rem; word-break: break-all; } .hashIcon { margin-top: 1rem; } .hashIconLg { margin-top: 0; } /* Hash highlighting */ .hashPrefix { font-family: ui-monospace, SFMono-Regular, monospace; } .hashPrefixGreen { color: rgb(74 222 128); } .hashPrefixRed { color: rgb(248 113 113); } .hashSuffix { font-family: ui-monospace, SFMono-Regular, monospace; color: rgb(156 163 175); } /* Button styles */ .buttonContainer { margin-top: 2rem; display: flex; align-items: center; justify-content: center; gap: 1rem; } .button { font-weight: 700; padding: 0.75rem 1.5rem; border-radius: 0.5rem; transition: transform 150ms; } .button:hover { transform: scale(1.05); } .buttonCyan { background-color: rgb(8 145 178); color: white; } .buttonCyan:hover { background-color: rgb(6 182 212); } .buttonYellow { background-color: rgb(202 138 4); color: white; } .buttonYellow:hover { background-color: rgb(245 158 11); } .buttonIndigo { background-color: rgb(79 70 229); color: white; } .buttonIndigo:hover { background-color: rgb(99 102 241); } .buttonGray { background-color: rgb(55 65 81); color: white; } .buttonGray:hover { background-color: rgb(75 85 99); } /* Responsive styles */ @media (min-width: 768px) { .title { font-size: 3rem; } .grid { grid-template-columns: repeat(3, 1fr); gap: 1rem; } .hashContent { flex-direction: row; } .hashText { text-align: left; } .hashValue { font-size: 1rem; } .hashIcon { margin-top: 0; } } @media (max-width: 767px) { .grid { display: flex; flex-direction: column; gap: 1rem; } } @media (prefers-color-scheme: light) { .block { background-color: oklch(93% 0.034 272.788); } .challengeText { color: oklch(12.9% 0.042 264.695); } .combinedDataText { color: oklch(12.9% 0.042 264.695); } .nonceButton { background-color: oklch(88.2% 0.059 254.128); } .nonceValue { color: oklch(12.9% 0.042 264.695); } .blockTitle { color: oklch(45% 0.085 224.283); } .hashContainerSuccess { background-color: oklch(95% 0.052 163.051); border-color: rgb(74 222 128); } .hashContainerError { background-color: oklch(94.1% 0.03 12.58); border-color: rgb(248 113 113); } .hashPrefixGreen { color: oklch(53.2% 0.157 131.589); font-weight: 600; } .hashPrefixRed { color: oklch(45.5% 0.188 13.697); } .hashSuffix { color: oklch(27.9% 0.041 260.031); } } ================================================ FILE: docs/blog/2025-08-28-cpu-core-odd/index.mdx ================================================ --- slug: 2025/cpu-core-odd title: Sometimes CPU cores are odd description: "TL;DR: all the assumptions you have about processor design are wrong and if you are unlucky you will never run into problems that users do through sheer chance." authors: [xe] tags: - bugfix - implementation image: parc-dsilence.webp --- import ProofOfWorkDiagram from "./ProofOfWorkDiagram"; ![](./parc-dsilence.webp) One of the biggest lessons that I've learned in my career is that all software has bugs, and the more complicated your software gets the more complicated your bugs get. A lot of the time those bugs will be fairly obvious and easy to spot, validate, and replicate. Sometimes, the process of fixing it will uncover your core assumptions about how things work in ways that will leave you feeling like you just got trolled. Today I'm going to talk about a single line fix that prevents people on a large number of devices from having weird irreproducible issues with Anubis rejecting people when it frankly shouldn't. Stick around, it's gonna be a wild ride. {/* truncate */} ## How this happened Anubis is a web application firewall that tries to make sure that the client is a browser. It uses a few [challenge methods](/docs/admin/configuration/challenges/) to do this determination, but the main method is the [proof of work](/docs/admin/configuration/challenges/proof-of-work/) challenge which makes clients grind away at cryptographic checksums in order to rate limit clients from connecting too eagerly. :::note In retrospect implementing the proof of work challenge may have been a mistake and it's likely to be supplanted by things like [Proof of React](https://github.com/TecharoHQ/anubis/pull/1038) or other methods that have yet to be developed. Your patience and polite behaviour in the bug tracker is appreciated. ::: In order to make sure the proof of work challenge screen _goes away as fast as possible_, the [worker code](https://github.com/TecharoHQ/anubis/tree/main/web/js/worker) is optimized within an inch of its digital life. One of the main ways that this code is optimized is with how it's run. Over the last 10-20 years, the main way that CPUs have gotten fast is via increasing multicore performance. Anubis tries to make sure that it can use as many cores as possible in order to take advantage of your device's CPU as much as it can. This strategy sometimes has some issues though, for one Firefox seems to get _much slower_ if you have Anubis try to absolutely saturate all of the cores on the system. It also has a fairly high overhead between JavaScript JIT code and [WebCrypto](https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API). I did some testing and found out that Firefox's point of diminishing returns was about half of the CPU cores. ## Another "invalid response" bug One of the complaints I've been getting from users and administrators using Anubis is that they've been running into issues where users get randomly rejected with an error message only saying "invalid response". This happens when the challenge validating process fails. This issue has been blocking the release of the next version of Anubis. In order to demonstrate this better, I've made a little interactive diagram for the proof of work process: <ProofOfWorkDiagram /> I've fixed a lot of the easy bugs in Anubis by this point. A lot of what's left is the hard bugs, but also specifically the kinds of hard bugs that involve weird hardware configurations. In order to try and catch these issues before software hits prod, I test Anubis against a bunch of hardware I have locally. Any issues I find and fix before software ships are issues that you don't hit in production. Let's consider [the line of code](https://github.com/TecharoHQ/anubis/blob/main/web/js/algorithms/fast.mjs) that was causing this issue: ```js threads = Math.max(navigator.hardwareConcurrency / 2, 1), ``` This is intended to make your browser spawn a proof of work worker for _half_ of your available CPU cores. If you only have one CPU core, you should only have one worker. Each thread is given this number of threads and uses that to increment the nonce so that each thread doesn't try to find a solution that another worker has already performed. One of the subtle problems here is that all of the parts of this assume that the thread ID and nonce are integers without a decimal portion. Famously, [all JavaScript numbers are IEEE 754 floating point numbers](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number). Surely there wouldn't be a case where the thread count could be a _decimal_ number, right? Here's all the devices I use to test Anubis _and their core counts_: | Device Name | Core Count | | :--------------------------- | :--------- | | MacBook Pro M3 Max | 16 | | MacBook Pro M4 Max | 16 | | AMD Ryzen 9 7950x3D | 32 | | Google Pixel 9a (GrapheneOS) | 8 | | iPhone 15 Pro Max | 6 | | iPad Pro (M1) | 8 | | iPad mini | 6 | | Steam Deck | 8 | | Core i5 10600 (homelab) | 12 | | ROG Ally | 16 | Notice something? All of those devices have an _even_ number of cores. Some devices such as the [Pixel 8 Pro](https://www.gsmarena.com/google_pixel_8_pro-12545.php) have an _odd_ number of cores. So what happens with that line of code as the JavaScript engine evaluates it? Let's replace the [`navigator.hardwareConcurrency`](https://developer.mozilla.org/en-US/docs/Web/API/Navigator/hardwareConcurrency) with the Pixel 8 Pro's 9 cores: ```js threads = Math.max(9 / 2, 1), ``` Then divide it by two: ```js threads = Math.max(4.5, 1), ``` Oops, that's not ideal. However `4.5` is bigger than `1`, so [`Math.max`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/max) returns that: ```js threads = 4.5, ``` This means that each time the proof of work equation is calculated, there is a 50% chance that a valid solution would include a nonce with a decimal portion in it. If the client finds a solution with such a nonce, then it would think the client was successful and submit the solution to the server, but the server only expects whole numbers back so it rejects that as an invalid response. I keep telling more junior people that when you have the weirdest, most inconsistent bugs in software that it's going to boil down to the dumbest possible thing you can possibly imagine. People don't believe me, then they encounter bugs like this. Then they suddenly believe me. Here is the fix: ```js threads = Math.trunc(Math.max(navigator.hardwareConcurrency / 2, 1)), ``` This uses [`Math.trunc`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/trunc) to truncate away the decimal portion so that the Pixel 8 Pro has `4` workers instead of `4.5` workers. ## Today I learned this was possible This was a total "today I learned" moment. I didn't actually think that hardware vendors shipped processors with an odd number of cores, however if you look at the core geometry of the Pixel 8 Pro, it has _three_ tiers of processor cores: | Core type | Core model | Number | | :----------------- | :------------------- | :----- | | High performance | 3 Ghz Cortex X3 | 1 | | Medium performance | 2.45 Ghz Cortex A715 | 4 | | High efficiency | 2.15 Cortex A510 | 4 | | Total | | 9 | I guess every assumption that developers have about CPU design is probably wrong. This probably isn't helped by the fact that for most of my career, the core count in phones has been largely irrelevant and most of the desktop / laptop CPUs I've had (where core count does matter) uses [simultaneous multithreading](https://en.wikipedia.org/wiki/Simultaneous_multithreading) to "multiply" the core count by two. The client side fix is a bit of an "emergency stop" button to try and mitigate the badness as early as possible. In general I'm quite aware of the terrible UX involved with this flow failing and I'm still noodling through ways to make that UX better and easier for users / administrators to debug. I'm looking into the following: 1. This could have been prevented on the server side by doing less strict input validation in compliance with [Postel's Law](https://en.wikipedia.org/wiki/Robustness_principle). I feel nervous about making such a security-sensitive endpoint _more liberal_ with the inputs it can accept, but it may be fine? I need to consult with a security expert. 2. Showing an encrypted error message on the "invalid response" page so that the user and administrator can work together to fix or report the issue. I remember Google doing this at least once, but I can't recall where I've seen it in the past. Either way, this is probably the most robust method even though it would require developing some additional tooling. I think it would be worth it. I'm likely going to go with the second option. I will need to figure out a good flow for this. It's likely going to involve [age](https://github.com/FiloSottile/age). I'll say more about this when I have more to say. In the meantime though, looks like I need to expense a used Pixel 8 Pro to add to the testing jungle for Anubis. If anyone has a deal out there, please let me know! Thank you to the people that have been polite and helpful when trying to root cause and fix this issue. ================================================ FILE: docs/blog/2025-10-31-file-abuse-reports/index.mdx ================================================ --- slug: 2025/file-abuse-reports title: Taking steps to end abusive traffic from cloud providers description: "Learn how to effectively file abuse reports with cloud providers to stop malicious traffic at its source and protect your services from automated abuse." authors: [xe] tags: [abuse, cloud, security, networking] image: goose-pond.webp --- ![A peaceful goose pond](./goose-pond.webp) As part of Anubis's ongoing development, I've been working to reduce friction for legitimate users by minimizing unnecessary challenge pages. While this improves the user experience, it can potentially expose services to increased abuse from public cloud infrastructure. To help administrators better protect their services, I want to share my strategies for filing abuse reports with IP space owners, enabling us to address malicious scraping at its source. {/* truncate */} In general, there are two kinds of IP addresses: - Residential IP addresses: IP addresses that are allocated to residential customers such as home internet connections and cellular data plans. These IP addresses are increasingly shared between customers due to technologies like [CGNAT](https://en.wikipedia.org/wiki/Carrier-grade_NAT). - Commercial IP addresses: IP addresses that are allocated to commercial customers such as cloud providers, VPS providers, root server providers, and other such business to business companies. These IP addresses are almost always statically allocated to one customer for a very long period of time (typically the lifetime of the server unless they are using things like dedicated IP addresses). In general, filing abuse reports to residential IP addresses is a waste of time. The administrators do appreciate knowing what kinds of abusive traffic is causing grief, but many times the users of those IP addresses don't know that their computer is sending abusive traffic to your services. A lot of malware botnets that used to be used with DDOS for hire services are now being used as residential proxies. Those "free VPN apps" are almost certainly making you pay for your usage by making your computer a zombie in a botnet. At some level I really respect the hustle as they manage to sell other people's bandwidth for rates as ludicrous as $1.00 per gigabyte ingressed and egressed. :::note Keep in mind, I'm talking about the things you can find by searching "free VPN", not infrastructure for the public good like the Tor browser or I2P. ::: What you should really focus on is traffic from commercial IP addresses, such as cloud providers. That's a case where the cloud customer is in direct violation of the acceptable use policy of the provider. Filing abuse reports gets the abuse team of the cloud provider to reach out to that customer and demand corrective action under threat of contractual violence. ## How to make an abuse report In general, the best abuse reports contain the following information: - Time of abusive requests. - IP address, User-Agent header, or other unique identifiers that can help the abuse team educate the customer about their misbehaving infrastructure. - Does the abusive IP address request robots.txt? If not, be sure to include that information. - A brief description of the impact to your system such as high system load, pages not rendering, or database system crashes. This helps the provider establish the fact that their customer is causing you measurable harm. - Context as to what your service is, what it does, and why they should care. For example, let's say that someone was giving the Anubis docs a series of requests that caused the server to fall over and experience extended downtime. Here's what I would write to the abuse contact: > Hello, > > I have received abusive traffic from one of your customers that has resulted in a denial of service to the users of the Anubis documentation website. Anubis is a web application firewall that administrators use to protect their websites against mass scraping and this documentation website helps administrators get started. > > On or about Thursday, October 30th at 04:00 UTC, A flurry of requests from the IP range `127.34.0.0/24` started to hit the `/admin/` routes, which caused unreasonable database load and ended up crashing PostgreSQL. This caused the documentation website to go down for three hours as it happened while the administrators were asleep. Based on logs, this caused 353 distinct users to not be able to load the documentation and the users filed bugs about it. > > I have attached the HTTP frontend logs for the abusive requests from your IP range. To protect our systems in the meantime while we perform additional hardening, I have blocked that IP address range in both our IP firewall and web application firewall configuration. Based on these logs, your customer seems to not have requested the standard `robots.txt` file, which includes instructions to deny access to those routes. > > Please let me know what other information you need on your end. > > Sincerely, > > [normal email signature] Then in order to figure out where to send it, look the IP addresses up in the `whois` database. For example, if you want to find the abuse contact for the IP address `1.1.1.1`, use the [whois command](https://packages.debian.org/sid/whois) to find the abuse contact: ``` $ whois 1.1.1.1 | grep -i abuse % Abuse contact for '1.1.1.0 - 1.1.1.255' is 'helpdesk@apnic.net' abuse-c: AA1412-AP remarks: All Cloudflare abuse reporting can be done via remarks: resolver-abuse@cloudflare.com abuse-mailbox: helpdesk@apnic.net role: ABUSE APNICRANDNETAU abuse-mailbox: helpdesk@apnic.net mnt-by: APNIC-ABUSE ``` The abuse contact will be named either `abuse-c` or `abuse-mailbox`. For greatest effect, I suggest including all listed email addresses in your email to the abuse contact. Once you send your email, you should expect a response within 2 business days at most. If they don't get back to you, please feel free to [contact me](https://xeiaso.net/contact/) so that the default set of Anubis rules can be edited according to patterns I'm seeing across the ecosystem. Just remember that many cloud providers do not know how bad the scraping problem is. Filing abuse complaints makes it their problem. They don't want it to be their problem. ================================================ FILE: docs/blog/authors.yml ================================================ xe: name: Xe Iaso title: CEO @ Techaro url: https://github.com/Xe image_url: https://github.com/Xe.png email: xe@techaro.lol page: true socials: github: Xe ================================================ FILE: docs/docs/CHANGELOG.md ================================================ --- sidebar_position: 999 --- # Changelog All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] - fix: prevent nil pointer panic in challenge validation when threshold rules match during PassChallenge (#1463) - Instruct reverse proxies to not cache error pages. - Fixed mixed tab/space indentation in Caddy documentation code block <!-- This changes the project to: --> ## v1.25.0: Necron Hey all, I'm sure you've all been aware that things have been slowing down a little with Anubis development, and I want to apologize for that. A lot has been going on in my life lately (my blog will have a post out on Friday with more information), and as a result I haven't really had the energy to work on Anubis in publicly visible ways. There are things going on behind the scenes, but nothing is really shippable yet, sorry! I've also been feeling some burnout in the wake of perennial waves of anger directed towards me. I'm handling it, I'll be fine, I've just had a lot going on in my life and it's been rough. I've been missing the sense of wanderlust and discovery that comes with the artistic way I playfully develop software. I suspect that some of the stresses I've been through (setting up a complicated surgery in a country whose language you aren't fluent in is kind of an experience) have been sapping my energy. I'd gonna try to mess with things on my break, but realistically I'm probably just gonna be either watching Stargate SG-1 or doing unreasonable amounts of ocean fishing in Final Fantasy 14. Normally I'd love to keep the details about my medical state fairly private, but I'm more of a public figure now than I was this time last year so I don't really get the invisibility I'm used to for this. I've also had a fair amount of negativity directed at me for simply being much more visible than the anonymous threat actors running the scrapers that are ruining everything, which though understandable has not helped. Anyways, it all worked out and I'm about to be in the hospital for a week, so if things go really badly with this release please downgrade to the last version and/or upgrade to the main branch when the fix PR is inevitably merged. I hoped to have time to tame GPG and set up full release automation in the Anubis repo, but that didn't work out this time and that's okay. If I can challenge you all to do something, go out there and try to actually create something new somehow. Combine ideas you've never mixed before. Be creative, be human, make something purely for yourself to scratch an itch that you've always had yet never gotten around to actually mending. At the very least, try to be an example of how you want other people to act, even when you're in a situation where software written by someone else is configured to require a user agent to execute javascript to access a webpage. Be well, Xe PS: if you're well-versed in FFXIV lore, the release title should give you an idea of the kind of stuff I've been going through mentally. - Add iplist2rule tool that lets admins turn an IP address blocklist into an Anubis ruleset. - Add Polish locale ([#1292](https://github.com/TecharoHQ/anubis/pull/1309)) - Fix honeypot and imprint links missing `BASE_PREFIX` when deployed behind a path prefix ([#1402](https://github.com/TecharoHQ/anubis/issues/1402)) - Add ANEXIA Sponsor logo to docs ([#1409](https://github.com/TecharoHQ/anubis/pull/1409)) - Improve idle performance in memory storage - Add HAProxy Configurations to Docs ([#1424](https://github.com/TecharoHQ/anubis/pull/1424)) ## v1.24.0: Y'shtola Rhul Anubis is back and better than ever! Lots of minor fixes with some big ones interspersed. - Fix panic when validating challenges after privacy-mode browsers strip headers and the follow-up request matches an `ALLOW` threshold. - Expose WEIGHT rule matches as Prometheus metrics. - Allow more OCI registry clients [based on feedback](https://github.com/TecharoHQ/anubis/pull/1253#issuecomment-3506744184). - Expose services directory in the embedded `(data)` filesystem. - Add Ukrainian locale ([#1044](https://github.com/TecharoHQ/anubis/pull/1044)). - Allow Renovate as an OCI registry client. - Properly handle 4in6 addresses so that IP matching works with those addresses. - Add support to simple Valkey/Redis cluster mode - Open Graph passthrough now reuses the configured target Host/SNI/TLS settings, so metadata fetches succeed when the upstream certificate differs from the public domain. ([1283](https://github.com/TecharoHQ/anubis/pull/1283)) - Stabilize the CVE-2025-24369 regression test by always submitting an invalid proof instead of relying on random POW failures. - Refine the check that ensures the presence of the Accept header to avoid breaking docker clients. - Removed rules intended to reward actual browsers due to abuse in the wild. ### Dataset poisoning Anubis has the ability to engage in [dataset poisoning attacks](https://www.anthropic.com/research/small-samples-poison) using the [dataset poisoning subsystem](./admin/honeypot/overview.mdx). This allows every Anubis instance to be a honeypot to attract and flag abusive scrapers so that no administrator action is required to ban them. There is much more information about this feature in [the dataset poisoning subsystem documentation](./admin/honeypot/overview.mdx). Administrators that are interested in learning how this feature works should consult that documentation. ### Deprecate `report_as` in challenge configuration Previously Anubis let you lie to users about the difficulty of a challenge to interfere with operators of malicious scrapers as a psychological attack: ```yaml bots: # Punish any bot with "bot" in the user-agent string # This is known to have a high false-positive rate, use at your own risk - name: generic-bot-catchall user_agent_regex: (?i:bot|crawler) action: CHALLENGE challenge: difficulty: 16 # impossible report_as: 4 # lie to the operator algorithm: slow # intentionally waste CPU cycles and time ``` This has turned out to be a bad idea because it has caused massive user experience problems and has been removed. If you are using this setting, you will get a warning in your logs like this: ```json { "time": "2025-11-25T23:10:31.092201549-05:00", "level": "WARN", "source": { "function": "github.com/TecharoHQ/anubis/lib/policy.ParseConfig", "file": "/home/xe/code/TecharoHQ/anubis/lib/policy/policy.go", "line": 201 }, "msg": "use of deprecated report_as setting detected, please remove this from your policy file when possible", "at": "config-validate", "name": "mild-suspicion" } ``` To remove this warning, remove this setting from your policy file. ### Logging customization Anubis now supports the ability to log to multiple backends ("sinks"). This allows you to have Anubis [log to a file](./admin/policies.mdx#file-sink) instead of just logging to standard out. You can also customize the [logging level](./admin/policies.mdx#log-levels) in the policy file: ```yaml logging: level: "warn" # much less verbose logging sink: file # log to a file parameters: file: "./var/anubis.log" maxBackups: 3 # keep at least 3 old copies maxBytes: 67108864 # each file can have up to 64 Mi of logs maxAge: 7 # rotate files out every n days oldFileTimeFormat: 2006-01-02T15-04-05 # RFC 3339-ish compress: true # gzip-compress old log files useLocalTime: false # timezone for rotated files is UTC ``` Additionally, information about [how Anubis uses each logging level](./admin/policies.mdx#log-levels) has been added to the documentation. ### DNS Features - CEL expressions for: - FCrDNS checks - Forward DNS queries - Reverse DNS queries - `arpaReverseIP` to transform IPv4/6 addresses into ARPA reverse IP notation. - `regexSafe` to escape regex special characters (useful for including `remoteAddress` or headers in regular expressions). - DNS cache and other optimizations to minimize unnecessary DNS queries. The DNS cache TTL can be changed in the bots config like this: ```yaml dns_ttl: forward: 600 reverse: 600 ``` The default value for both forward and reverse queries is 300 seconds. The `verifyFCrDNS` CEL function has two overloads: - `(addr)` Simply verifies that the remote side has PTR records pointing to the target address. - `(addr, ptrPattern)` Verifies that the remote side refers to a specific domain and that this domain points to the target IP. ## v1.23.1: Lyse Hext - Echo 1 - Fix `SERVE_ROBOTS_TXT` setting after the double slash fix broke it. ### Potentially breaking changes #### Remove default Tencent Cloud block rule v1.23.0 added a default rule to block Tencent Cloud. After an email from their abuse team where they promised to take action to clean up their reputation, I have removed the default block rule. If this network causes you problems, please contact [abuse@tencent.com](mailto:abuse@tencent.com) and supply the following information: - Time of abusive requests. - IP address, User-Agent header, or other unique identifiers that can help the abuse team educate the customer about their misbehaving infrastructure. - Does the abusive IP address request robots.txt? If not, be sure to include that information. - A brief description of the impact to your system such as high system load, pages not rendering, or database system crashes. This helps the provider establish the fact that their customer is causing you measurable harm. - Context as to what your service is, what it does, and why they should care. Mention that you are using Anubis or BotStopper to protect your services. If they do not respond to you, please [contact me](https://xeiaso.net/contact) as soon as possible. #### Docker / OCI registry clients Anubis v1.23.0 accidentally blocked Docker / OCI registry clients. In order to explicitly allow them, add an import for `(data)/clients/docker-client.yaml`: ```yaml bots: - import: (data)/meta/default-config.yaml - import: (data)/clients/docker-client.yaml ``` This is technically a regression as these clients used to work in Anubis v1.22.0, however it is allowable to make this opt-in as most websites do not expect to be serving Docker / OCI registry client traffic. ## v1.23.0: Lyse Hext - Add default tencent cloud DENY rule. - Added `(data)/meta/default-config.yaml` for importing the entire default configuration at once. - Add `-custom-real-ip-header` flag to get the original request IP from a different header than `x-real-ip`. - Add `contentLength` variable to bot expressions. - Add `COOKIE_SAME_SITE_MODE` to force anubis cookies SameSite value, and downgrade automatically from `None` to `Lax` if cookie is insecure. - Fix lock convoy problem in decaymap ([#1103](https://github.com/TecharoHQ/anubis/issues/1103)). - Fix lock convoy problem in bbolt by implementing the actor pattern ([#1103](https://github.com/TecharoHQ/anubis/issues/1103)). - Remove bbolt actorify implementation due to causing production issues. - Document missing environment variables in installation guide: `SLOG_LEVEL`, `COOKIE_PREFIX`, `FORCED_LANGUAGE`, and `TARGET_DISABLE_KEEPALIVE` ([#1086](https://github.com/TecharoHQ/anubis/pull/1086)). - Add validation warning when persistent storage is used without setting signing keys. - Fixed `robots2policy` to properly group consecutive user agents into `any:` instead of only processing the last one ([#925](https://github.com/TecharoHQ/anubis/pull/925)). - Make the `fast` algorithm prefer purejs when running in an insecure context. - Add the [`s3api` storage backend](./admin/policies.mdx#s3api) to allow Anubis to use S3 API compatible object storage as its storage backend. - Fix a "stutter" in the cookie name prefix so the auth cookie is named `techaro.lol-anubis-auth` instead of `techaro.lol-anubis-auth-auth`. - Make `cmd/containerbuild` support commas for separating elements of the `--docker-tags` argument as well as newlines. - Add the `DIFFICULTY_IN_JWT` option, which allows one to add the `difficulty` field in the JWT claims which indicates the difficulty of the token ([#1063](https://github.com/TecharoHQ/anubis/pull/1063)). - Ported the client-side JS to TypeScript to avoid egregious errors in the future. - Fixes concurrency problems with very old browsers ([#1082](https://github.com/TecharoHQ/anubis/issues/1082)). - Randomly use the Refresh header instead of the meta refresh tag in the metarefresh challenge. - Update OpenRC service to truncate the runtime directory before starting Anubis. - Make the git client profile more strictly match how the git client behaves. - Make the default configuration reward users using normal browsers. - Allow multiple consecutive slashes in a row in application paths ([#754](https://github.com/TecharoHQ/anubis/issues/754)). - Add option to set `targetSNI` to special keyword 'auto' to indicate that it should be automatically set to the request Host name ([424](https://github.com/TecharoHQ/anubis/issues/424)). - The Preact challenge has been removed from the default configuration. It will be deprecated in the future. - An open redirect when in subrequest mode has been fixed. ### Potentially breaking changes #### Multiple checks at once has and-like semantics instead of or-like semantics Anubis lets you stack multiple checks at once with blocks like this: ```yaml name: allow-prometheus action: ALLOW user_agent_regex: ^prometheus-probe$ remote_addresses: - 192.168.2.0/24 ``` Previously, this only returned ALLOW if _any one_ of the conditions matched. This behaviour has changed to only return ALLOW if _all_ of the conditions match. I expect this to have some issues with user configs, however this fix is grave enough that it's worth the risk of breaking configs. If this bites you, please let me know so we can make an escape hatch. ### Better error messages In order to make it easier for legitimate clients to debug issues with their browser configuration and Anubis, Anubis will emit internal error detail in base 64 so that administrators can chase down issues. Future versions of this may also include a variant that encrypts the error detail messages. ### Bug Fixes Sometimes the enhanced temporal assurance in [#1038](https://github.com/TecharoHQ/anubis/pull/1038) and [#1068](https://github.com/TecharoHQ/anubis/pull/1068) could backfire because Chromium and its ilk randomize the amount of time they wait in order to avoid a timing side channel attack. This has been fixed by both increasing the amount of time a client has to wait for the metarefresh and preact challenges as well as making the server side logic more permissive. ## v1.22.0: Yda Hext > Someone has to make an effort at reconciliation if these conflicts are ever going to end. In this release, we finally fix the odd number of CPU cores bug, pave the way for lighter weight challenges, make Anubis more adaptable, and more. ### Big ticket items #### Proof of React challenge A new ["proof of React"](./admin/configuration/challenges/preact.mdx) has been added. It runs a simple app in React that has several chained hooks. It is much more lightweight than the proof of work check. #### Smaller features - The [`segments`](./admin/configuration/expressions.mdx#segments) function was added for splitting a path into its slash-separated segments. - Added possibility to disable HTTP keep-alive to support backends not properly handling it. - When issuing a challenge, Anubis stores information about that challenge into the store. That stored information is later used to validate challenge responses. This works around nondeterminism in bot rules. ([#917](https://github.com/TecharoHQ/anubis/issues/917)) - One of the biggest sources of lag in Firefox has been eliminated: the use of WebCrypto. Now whenever Anubis detects the client is using Firefox (or Pale Moon), it will swap over to a pure-JS implementation of SHA-256 for speed. - Proof of work solving has had a complete overhaul and rethink based on feedback from browser engine developers, frontend experts, and overall performance profiling. - Optimize the performance of the pure-JS Anubis solver. - Web Workers are stored as dedicated JavaScript files in `static/js/workers/*.mjs`. - Pave the way for non-SHA256 solver methods and eventually one that uses WebAssembly (or WebAssembly code compiled to JS for those that disable WebAssembly). - Legacy JavaScript code has been eliminated. - When parsing [Open Graph tags](./admin/configuration/open-graph.mdx), add any URLs found in the responses to a temporary "allow cache" so that social preview images work. - The hard dependency on WebCrypto has been removed, allowing a proof of work challenge to work over plain (unencrypted) HTTP. - The Anubis version number is put in the footer of every page. - Add a default block rule for Huawei Cloud. - Add a default block rule for Alibaba Cloud. - Added support to use Traefik forwardAuth middleware. - Add X-Request-URI support so that Subrequest Authentication has path support. - Added glob matching for `REDIRECT_DOMAINS`. You can pass `*.bugs.techaro.lol` to allow redirecting to anything ending with `.bugs.techaro.lol`. There is a limit of 4 wildcards. ### Fixes #### Odd numbers of CPU cores are properly supported Some phones have an odd number of CPU cores. This caused [interesting issues](https://anubis.techaro.lol/blog/2025/cpu-core-odd). This was fixed by [using `Math.trunc` to convert the number of CPU cores back into an integer](https://github.com/TecharoHQ/anubis/issues/1043). #### Smaller fixes - A standard library HTTP server log message about HTTP pipelining not working has been filtered out of Anubis' logs. There is no action that can be taken about it. - Added a missing link to the Caddy installation environment in the installation documentation. - Downstream consumers can change the default [log/slog#Logger](https://pkg.go.dev/log/slog#Logger) instance that Anubis uses by setting `opts.Logger` to your slog instance of choice ([#864](https://github.com/TecharoHQ/anubis/issues/864)). - The [Thoth client](https://anubis.techaro.lol/docs/admin/thoth) is now public in the repo instead of being an internal package. - [Custom-AsyncHttpClient](https://github.com/AsyncHttpClient/async-http-client)'s default User-Agent has an increased weight by default ([#852](https://github.com/TecharoHQ/anubis/issues/852)). - Add option for replacing the default explanation text with a custom one ([#747](https://github.com/TecharoHQ/anubis/pull/747)) - The contact email in the LibreJS header has been changed. - Firefox for Android support has been fixed by embedding the challenge ID into the pass-challenge route. This also fixes some inconsistent issues with other mobile browsers. - The default `favicon` pattern in `data/common/keep-internet-working.yaml` has been updated to permit requests for png/gif/jpg/svg files as well as ico. - The `--cookie-prefix` flag has been fixed so that it is fully respected. - The default patterns in `data/common/keep-internet-working.yaml` have been updated to appropriately escape the '.' character in the regular expression patterns. - Add optional restrictions for JWT based on the value of a header ([#697](https://github.com/TecharoHQ/anubis/pull/697)) - The word "hack" has been removed from the translation strings for Anubis due to incidents involving people misunderstanding that word and sending particularly horrible things to the project lead over email. - Bump AI-robots.txt to version 1.39 - Inject adversarial input to break AI coding assistants. - Add better logging when using Subrequest Authentication. ### Security-relevant changes - Add a server-side check for the meta-refresh challenge that makes sure clients have waited for at least 95% of the time that they should. #### Fix potential double-spend for challenges Anubis operates by issuing a challenge and having the client present a solution for that challenge. Challenges are identified by a unique UUID, which is stored in the database. The problem is that a challenge could potentially be used twice by a dedicated attacker making a targeted attack against Anubis. Challenge records did not have a "spent" or "used" field. In total, a dedicated attacker could solve a challenge once and reuse that solution across multiple sessions in order to mint additional tokens. This was fixed by adding a "spent" field to challenges in the data store. When a challenge is solved, that "spent" field gets set to `true`. If a future attempt to solve this challenge is observed, it gets rejected. With the advent of store based challenge issuance in [#749](https://github.com/TecharoHQ/anubis/pull/749), this means that these challenge IDs are [only good for 30 minutes](https://github.com/TecharoHQ/anubis/blob/e8dfff635015d6c906dddd49cb0eaf591326092a/lib/anubis.go#L130-L135d). Websites using the most recent version of Anubis have limited exposure to this problem. Websites using older versions of Anubis have a much more increased exposure to this problem and are encouraged to keep this software updated as often and as frequently as possible. Thanks to [@taviso](https://github.com/taviso) for reporting this issue. ### Breaking changes - The "slow" frontend solver has been removed in order to reduce maintenance burden. Any existing uses of it will still work, but issue a warning upon startup asking administrators to upgrade to the "fast" frontend solver. - The legacy JSON based policy file example has been removed and all documentation for how to write a policy file in JSON has been deleted. JSON based policy files will still work, but YAML is the superior option for Anubis configuration. ### New Locales - Lithuanian [#972](https://github.com/TecharoHQ/anubis/pull/972) - Vietnamese [#926](https://github.com/TecharoHQ/anubis/pull/926) ## v1.21.3: Minfilia Warde - Echo 3 ### Added #### New locales Anubis now supports these new languages: - [Swedish](https://github.com/TecharoHQ/anubis/pull/913) ### Fixes #### Fixes a problem with nonstandard URLs and redirects Fixes [GHSA-jhjj-2g64-px7c](https://github.com/TecharoHQ/anubis/security/advisories/GHSA-jhjj-2g64-px7c). This could allow an attacker to craft an Anubis pass-challenge URL that forces a redirect to nonstandard URLs, such as the `javascript:` scheme which executes arbitrary JavaScript code in a browser context when the user clicks the "Try again" button. This has been fixed by disallowing any URLs without the scheme `http` or `https`. Additionally, the "Try again" button has been fixed to completely ignore the user-supplied redirect location. It now redirects to the home page (`/`). ## v1.21.2: Minfilia Warde - Echo 2 This contained an incomplete fix for [GHSA-jhjj-2g64-px7c](https://github.com/TecharoHQ/anubis/security/advisories/GHSA-jhjj-2g64-px7c). Do not use this version. ## v1.21.1: Minfilia Warde - Echo 1 - Expired records are now properly removed from bbolt databases ([#848](https://github.com/TecharoHQ/anubis/pull/848)). - Fix hanging on service restart ([#853](https://github.com/TecharoHQ/anubis/issues/853)) ### Added Anubis now supports the [`missingHeader`](./admin/configuration/expressions.mdx#missingHeader) to assert the absence of headers in requests. #### New locales Anubis now supports these new languages: - [Czech](https://github.com/TecharoHQ/anubis/pull/849) - [Finnish](https://github.com/TecharoHQ/anubis/pull/863) - [Norwegian Bokmål](https://github.com/TecharoHQ/anubis/pull/855) - [Norwegian Nynorsk](https://github.com/TecharoHQ/anubis/pull/855) - [Russian](https://github.com/TecharoHQ/anubis/pull/882) ### Fixes #### Fix ["error: can't get challenge"](https://github.com/TecharoHQ/anubis/issues/869) when details about a challenge can't be found in the server side state v1.21.0 changed the core challenge flow to maintain information about challenges on the server side instead of only doing them via stateless idempotent generation functions and relying on details to not change. There was a subtle bug introduced in this change: if a client has an unknown challenge ID set in its test cookie, Anubis will clear that cookie and then throw an HTTP 500 error. This has been fixed by making Anubis throw a new challenge page instead. #### Fix event loop thrashing when solving a proof of work challenge Previously the "fast" proof of work solver had a fragment of JavaScript that attempted to only post an update about proof of work progress to the main browser window every 1024 iterations. This fragment of JavaScript was subtly incorrect in a way that passed review but actually made the workers send an update back to the main thread every iteration. This caused a pileup of unhandled async calls (similar to a socket accept() backlog pileup in Unix) that caused stack space exhaustion. This has been fixed in the following ways: 1. The complicated boolean logic has been totally removed in favour of a worker-local iteration counter. 2. The progress bar is updated by worker `0` instead of all workers. Hopefully this should limit the event loop thrashing and let ia32 browsers (as well as any environment with a smaller stack size than amd64 and aarch64 seem to have) function normally when processing Anubis proof of work challenges. #### Fix potential memory leak when discovering a solution In some cases, the parallel solution finder in Anubis could cause all of the worker promises to leak due to the fact the promises were being improperly terminated. This was fixed by having Anubis debounce worker termination instead of allowing it to potentially recurse infinitely. ## v1.21.0: Minfilia Warde > Please, be at ease. You are among friends here. In this release, Anubis becomes internationalized, gains the ability to use system load as input to issuing challenges, finally fixes the "invalid response" after "success" bug, and more! Please read these notes before upgrading as the changes are big enough that administrators should take action to ensure that the upgrade goes smoothly. ### Big ticket changes The biggest change is that the ["invalid response" after "success" bug](https://github.com/TecharoHQ/anubis/issues/564) is now finally fixed for good by totally rewriting how Anubis' challenge issuance flow works. Instead of generating challenge strings from request metadata (under the assumption that the values being compared against are stable), Anubis now generates random data for each challenge. This data is stored in the active [storage backend](./admin/policies.mdx#storage-backends) for up to 30 minutes. This also fixes [#746](https://github.com/TecharoHQ/anubis/issues/746) and other similar instances of this issue. In order to reduce confusion, the "Success" interstitial that shows up when you pass a proof of work challenge has been removed. #### Storage Anubis now is able to store things persistently [in memory](./admin/policies.mdx#memory), [on the disk](./admin/policies.mdx#bbolt), or [in Valkey](./admin/policies.mdx#valkey) (this includes other compatible software). By default Anubis uses the in-memory backend. If you have an environment with mutable storage (even if it is temporary), be sure to configure the [`bbolt`](./admin/policies.mdx#bbolt) storage backend. #### Localization Anubis now supports localized responses. Locales can be added in [lib/localization/locales/](https://github.com/TecharoHQ/anubis/tree/main/lib/localization/locales). This release includes support for the following languages: - [Brazilian Portugese](https://github.com/TecharoHQ/anubis/pull/726) - [Chinese (Simplified)](https://github.com/TecharoHQ/anubis/pull/774) - [Chinese (Traditional)](https://github.com/TecharoHQ/anubis/pull/759) - English - [Estonian](https://github.com/TecharoHQ/anubis/pull/783) - [Filipino](https://github.com/TecharoHQ/anubis/pull/775) - [French](https://github.com/TecharoHQ/anubis/pull/716) - [German](https://github.com/TecharoHQ/anubis/pull/741) - [Icelandic](https://github.com/TecharoHQ/anubis/pull/780) - [Italian](https://github.com/TecharoHQ/anubis/pull/778) - [Japanese](https://github.com/TecharoHQ/anubis/pull/772) - [Spanish](https://github.com/TecharoHQ/anubis/pull/716) - [Turkish](https://github.com/TecharoHQ/anubis/pull/751) If facts or local regulations demand, you can set Anubis default language with the `FORCED_LANGUAGE` environment variable or the `--forced-language` command line argument: ```sh FORCED_LANGUAGE=de ``` #### Load average Anubis can dynamically take action [based on the system load average](./admin/configuration/expressions.mdx#using-the-system-load-average), allowing you to write rules like this: ```yaml ## System load based checks. # If the system is under high load for the last minute, add weight. - name: high-load-average action: WEIGH expression: load_1m >= 10.0 # make sure to end the load comparison in a .0 weight: adjust: 20 # If it is not for the last 15 minutes, remove weight. - name: low-load-average action: WEIGH expression: load_15m <= 4.0 # make sure to end the load comparison in a .0 weight: adjust: -10 ``` Something to keep in mind about system load average is that it is not aware of the number of cores the system has. If you have a 16 core system that has 16 processes running but none of them is hogging the CPU, then you will get a load average below 16. If you are in doubt, make your "high load" metric at least two times the number of CPU cores and your "low load" metric at least half of the number of CPU cores. For example: | Kind | Core count | Load threshold | | --------: | :--------- | :------------- | | high load | 4 | `8.0` | | low load | 4 | `2.0` | | high load | 16 | `32.0` | | low load | 16 | `8` | Also keep in mind that this does not account for other kinds of latency like I/O latency. A system can have its web applications unresponsive due to high latency from a MySQL server but still have that web application server report a load near or at zero. ### Other features and fixes There are a bunch of other assorted features and fixes too: - Add `COOKIE_SECURE` option to set the cookie [Secure flag](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Cookies#block_access_to_your_cookies) - Sets cookie defaults to use [SameSite: None](https://web.dev/articles/samesite-cookies-explained) - Determine the `BIND_NETWORK`/`--bind-network` value from the bind address ([#677](https://github.com/TecharoHQ/anubis/issues/677)). - Implement a [development container](https://containers.dev/) manifest to make contributions easier. - Fix dynamic cookie domains functionality ([#731](https://github.com/TecharoHQ/anubis/pull/731)) - Add option for custom cookie prefix ([#732](https://github.com/TecharoHQ/anubis/pull/732)) - Make the [Open Graph](./admin/configuration/open-graph.mdx) subsystem and DNSBL subsystem use [storage backends](./admin/policies.mdx#storage-backends) instead of storing everything in memory by default. - Allow [Common Crawl](https://commoncrawl.org/) by default so scrapers have less incentive to scrape - The [bbolt storage backend](./admin/policies.mdx#bbolt) now runs its cleanup every hour instead of every five minutes. - Don't block Anubis starting up if [Thoth](./admin/thoth.mdx) health checks fail. - A race condition involving [opening two challenge pages at once in different tabs](https://github.com/TecharoHQ/anubis/issues/832) causing one of them to fail has been fixed. - The "Try again" button on the error page has been fixed. Previously it meant "try the solution again" instead of "try the challenge again". - In certain cases, a user could be stuck with a test cookie that is invalid, locking them out of the service for up to half an hour. This has been fixed with better validation of this case and clearing the cookie. - Start exposing JA4H fingerprints for later use in CEL expressions. - Add `/healthz` route for use in platform-based health checks. ### Potentially breaking changes We try to introduce breaking changes as much as possible, but these are the changes that may be relevant for you as an administrator: #### Challenge format change Previously Anubis did no accounting for challenges that it issued. This means that if Anubis restarted during a client, the client would be able to proceed once Anubis came back online. During the upgrade to v1.21.0 and when v1.21.0 (or later) restarts with the [in-memory storage backend](./admin/policies.mdx#memory), you may see a higher rate of failed challenges than normal. If this persists beyond a few minutes, [open an issue](https://github.com/TecharoHQ/anubis/issues/new). If you are using the in-memory storage backend, please consider using [a different storage backend](./admin/policies.mdx#storage-backends). #### Systemd service changes The following potentially breaking change applies to native installs with systemd only: Each instance of systemd service template now has a unique `RuntimeDirectory`, as opposed to each instance of the service sharing a `RuntimeDirectory`. This change was made to avoid [the `RuntimeDirectory` getting nuked any time one of the Anubis instances restarts](https://github.com/TecharoHQ/anubis/issues/748). If you configured Anubis' unix sockets to listen on `/run/anubis/foo.sock` for instance `anubis@foo`, you will need to configure Anubis to listen on `/run/anubis/foo/foo.sock` and additionally configure your HTTP load balancer as appropriate. If you need the legacy behaviour, install this [systemd unit dropin](https://www.flatcar.org/docs/latest/setup/systemd/drop-in-units/): ```systemd # /etc/systemd/system/anubis@.service.d/50-runtimedir.conf [Service] RuntimeDirectory=anubis ``` Just keep in mind that this will cause problems when Anubis restarts. ## v1.20.0: Thancred Waters The big ticket items are as follows: - Implement a no-JS challenge method: [`metarefresh`](./admin/configuration/challenges/metarefresh.mdx) ([#95](https://github.com/TecharoHQ/anubis/issues/95)) - Implement request "weight", allowing administrators to customize the behaviour of Anubis based on specific criteria - Implement GeoIP and ASN based checks via [Thoth](https://anubis.techaro.lol/docs/admin/thoth) ([#206](https://github.com/TecharoHQ/anubis/issues/206)) - Add [custom weight thresholds](./admin/configuration/thresholds.mdx) via CEL ([#688](https://github.com/TecharoHQ/anubis/pull/688)) - Move Open Graph configuration [to the policy file](./admin/configuration/open-graph.mdx) - Enable support for Open Graph metadata to be returned by default instead of doing lookups against the target - Add `robots2policy` CLI utility to convert robots.txt files to Anubis challenge policies using CEL expressions ([#409](https://github.com/TecharoHQ/anubis/issues/409)) - Refactor challenge presentation logic to use a challenge registry - Allow challenge implementations to register HTTP routes - [Imprint/Impressum support](./admin/configuration/impressum.mdx) ([#362](https://github.com/TecharoHQ/anubis/issues/362)) - Fix "invalid response" after "Success!" in Chromium ([#564](https://github.com/TecharoHQ/anubis/issues/564)) A lot of performance improvements have been made: - Replace internal SHA256 hashing with xxhash for 4-6x performance improvement in policy evaluation and cache operations - Optimized the OGTags subsystem with reduced allocations and runtime per request by up to 66% - Replace cidranger with bart for IP range checking, improving IP matching performance by 3-20x with zero heap allocations And some cleanups/refactors were added: - Fix OpenGraph passthrough ([#717](https://github.com/TecharoHQ/anubis/issues/717)) - Remove the unused `/test-error` endpoint and update the testing endpoint `/make-challenge` to only be enabled in development - Add `--xff-strip-private` flag/envvar to toggle skipping X-Forwarded-For private addresses or not - Bump AI-robots.txt to version 1.37 - Make progress bar styling more compatible (UXP, etc) - Add `--strip-base-prefix` flag/envvar to strip the base prefix from request paths when forwarding to target servers - Fix an off-by-one in the default threshold config - Add functionality for HS512 JWT algorithm - Add support for dynamic cookie domains with the `--cookie-dynamic-domain`/`COOKIE_DYNAMIC_DOMAIN` flag/envvar Request weight is one of the biggest ticket features in Anubis. This enables Anubis to be much closer to a Web Application Firewall and when combined with custom thresholds allows administrators to have Anubis take advanced reactions. For more information about request weight, see [the request weight section](./admin/policies.mdx#request-weight) of the policy file documentation. TL;DR when you have one or more WEIGHT rules like this: ```yaml bots: - name: gitea-session-token action: WEIGH expression: all: - '"Cookie" in headers' - headers["Cookie"].contains("i_love_gitea=") # Remove 5 weight points weight: adjust: -5 ``` You can configure custom thresholds like this: ```yaml thresholds: - name: minimal-suspicion # This client is likely fine, its soul is lighter than a feather expression: weight < 0 # a feather weighs zero units action: ALLOW # Allow the traffic through # For clients that had some weight reduced through custom rules, give them a # lightweight challenge. - name: mild-suspicion expression: all: - weight >= 0 - weight < 10 action: CHALLENGE challenge: # https://anubis.techaro.lol/docs/admin/configuration/challenges/metarefresh algorithm: metarefresh difficulty: 1 report_as: 1 # For clients that are browser-like but have either gained points from custom # rules or report as a standard browser. - name: moderate-suspicion expression: all: - weight >= 10 - weight < 20 action: CHALLENGE challenge: # https://anubis.techaro.lol/docs/admin/configuration/challenges/proof-of-work algorithm: fast difficulty: 2 # two leading zeros, very fast for most clients report_as: 2 # For clients that are browser like and have gained many points from custom # rules - name: extreme-suspicion expression: weight >= 20 action: CHALLENGE challenge: # https://anubis.techaro.lol/docs/admin/configuration/challenges/proof-of-work algorithm: fast difficulty: 4 report_as: 4 ``` These thresholds apply when no other `ALLOW`, `DENY`, or `CHALLENGE` rule matches the request. `WEIGHT` rules add and remove request weight as needed: ```yaml bots: - name: gitea-session-token action: WEIGH expression: all: - '"Cookie" in headers' - headers["Cookie"].contains("i_love_gitea=") # Remove 5 weight points weight: adjust: -5 - name: bot-like-user-agent action: WEIGH expression: '"Bot" in userAgent' # Add 5 weight points weight: adjust: 5 ``` Of note: the default "generic browser" rule assigns 10 weight points: ```yaml # Generic catchall rule - name: generic-browser user_agent_regex: >- Mozilla|Opera action: WEIGH weight: adjust: 10 ``` Adjust this as you see fit. ## v1.19.1: Jenomis cen Lexentale - Echo 1 - Return `data/bots/ai-robots-txt.yaml` to avoid breaking configs [#599](https://github.com/TecharoHQ/anubis/issues/599) ## v1.19.0: Jenomis cen Lexentale Mostly a bunch of small features, no big ticket things this time. - Record if challenges were issued via the API or via embedded JSON in the challenge page HTML ([#531](https://github.com/TecharoHQ/anubis/issues/531)) - Ensure that clients that are shown a challenge support storing cookies - Imprint the version number into challenge pages - Encode challenge pages with gzip level 1 - Add PowerPC 64 bit little-endian builds (`GOARCH=ppc64le`) - Add `check-spelling` for spell checking - Add `--target-insecure-skip-verify` flag/envvar to allow Anubis to hit a self-signed HTTPS backend - Minor adjustments to FreeBSD rc.d script to allow for more flexible configuration. - Added Podman and Docker support for running Playwright tests - Add a default rule to throw challenges when a request with the `X-Firefox-Ai` header is set - Updated the nonce value in the challenge JWT cookie to be a string instead of a number - Rename cookies in response to user feedback - Ensure cookie renaming is consistent across configuration options - Add Bookstack app in data - Truncate everything but the first five characters of Accept-Language headers when making challenges - Ensure client JavaScript is served with Content-Type text/javascript. - Add `--target-host` flag/envvar to allow changing the value of the Host header in requests forwarded to the target service - Bump AI-robots.txt to version 1.31 - Add `RuntimeDirectory` to systemd unit settings so native packages can listen over unix sockets - Added SearXNG instance tracker whitelist policy - Added Qualys SSL Labs whitelist policy - Fixed cookie deletion logic ([#520](https://github.com/TecharoHQ/anubis/issues/520), [#522](https://github.com/TecharoHQ/anubis/pull/522)) - Add `--target-sni` flag/envvar to allow changing the value of the TLS handshake hostname in requests forwarded to the target service - Fixed CEL expression matching validator to now properly error out when it receives empty expressions - Added OpenRC init.d script - Added `--version` flag - Added `anubis_proxied_requests_total` metric to count proxied requests - Add `Applebot` as "good" web crawler - Reorganize AI/LLM crawler blocking into three separate stances, maintaining existing status quo as default - Split out AI/LLM user agent blocking policies, adding documentation for each ## v1.18.0: Varis zos Galvus The big ticket feature in this release is [CEL expression matching support](https://anubis.techaro.lol/docs/admin/configuration/expressions). This allows you to tailor your approach for the individual services you are protecting. These can be as simple as: ```yaml - name: allow-api-requests action: ALLOW expression: all: - '"Accept" in headers' - 'headers["Accept"] == "application/json"' - 'path.startsWith("/api/")' ``` Or as complicated as: ```yaml - name: allow-git-clients action: ALLOW expression: all: - >- ( userAgent.startsWith("git/") || userAgent.contains("libgit") || userAgent.startsWith("go-git") || userAgent.startsWith("JGit/") || userAgent.startsWith("JGit-") ) - '"Git-Protocol" in headers' - headers["Git-Protocol"] == "version=2" ``` The docs have more information, but here's a tl;dr of the variables you have access to in expressions: | Name | Type | Explanation | Example | | :-------------- | :-------------------- | :---------------------------------------------------------------------------------------------------------------------------------------- | :----------------------------------------------------------- | | `headers` | `map[string, string]` | The [headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers) of the request being processed. | `{"User-Agent": "Mozilla/5.0 Gecko/20100101 Firefox/137.0"}` | | `host` | `string` | The [HTTP hostname](https://web.dev/articles/url-parts#host) the request is targeted to. | `anubis.techaro.lol` | | `method` | `string` | The [HTTP method](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Methods) in the request being processed. | `GET`, `POST`, `DELETE`, etc. | | `path` | `string` | The [path](https://web.dev/articles/url-parts#pathname) of the request being processed. | `/`, `/api/memes/create` | | `query` | `map[string, string]` | The [query parameters](https://web.dev/articles/url-parts#query) of the request being processed. | `?foo=bar` -> `{"foo": "bar"}` | | `remoteAddress` | `string` | The IP address of the client. | `1.1.1.1` | | `userAgent` | `string` | The [`User-Agent`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/User-Agent) string in the request being processed. | `Mozilla/5.0 Gecko/20100101 Firefox/137.0` | This will be made more elaborate in the future. Give me time. This is a [simple, lovable, and complete](https://longform.asmartbear.com/slc/) implementation of this feature so that administrators can get hacking ASAP. Other changes: - Use CSS variables to deduplicate styles - Fixed native packages not containing the stdlib and botPolicies.yaml - Change import syntax to allow multi-level imports - Changed the startup logging to use JSON formatting as all the other logs do - Added the ability to do [expression matching with CEL](./admin/configuration/expressions.mdx) - Add a warning for clients that don't store cookies - Disable Open Graph passthrough by default ([#435](https://github.com/TecharoHQ/anubis/issues/435)) - Clarify the license of the mascot images ([#442](https://github.com/TecharoHQ/anubis/issues/442)) - Started Suppressing 'Context canceled' errors from http in the logs ([#446](https://github.com/TecharoHQ/anubis/issues/446)) ## v1.17.1: Asahi sas Brutus: Echo 1 - Added customization of authorization cookie expiration time with `--cookie-expiration-time` flag or envvar - Updated the `OG_PASSTHROUGH` to be true by default, thereby allowing Open Graph tags to be passed through by default - Added the ability to [customize Anubis' HTTP status codes](./admin/configuration/custom-status-codes.mdx) ([#355](https://github.com/TecharoHQ/anubis/issues/355)) ## v1.17.0: Asahi sas Brutus - Ensure regexes can't end in newlines ([#372](https://github.com/TecharoHQ/anubis/issues/372)) - Add documentation for default allow behavior (implicit rule) - Enable [importing configuration snippets](./admin/configuration/import.mdx) ([#321](https://github.com/TecharoHQ/anubis/pull/321)) - Refactor check logic to be more generic and work on a Checker type - Add more AI user agents based on the [ai.robots.txt](https://github.com/ai-robots-txt/ai.robots.txt) project - Embedded challenge data in initial HTML response to improve performance - Added support to use Nginx' `auth_request` directive with Anubis - Added support to allow to restrict the allowed redirect domains - Whitelisted [DuckDuckBot](https://duckduckgo.com/duckduckgo-help-pages/results/duckduckbot/) in botPolicies - Improvements to build scripts to make them less independent of the build host - Improved the Open Graph error logging - Added `Opera` to the `generic-browser` bot policy rule - Added FreeBSD rc.d script so can be run as a FreeBSD daemon - Allow requests from the Internet Archive - Added example nginx configuration to documentation - Added example Apache configuration to the documentation [#277](https://github.com/TecharoHQ/anubis/issues/277) - Move per-environment configuration details into their own pages - Added support for running anubis behind a prefix (e.g. `/myapp`) - Added headers support to bot policy rules - Moved configuration file from JSON to YAML by default - Added documentation on how to use Anubis with Traefik in Docker - Improved error handling in some edge cases - Disable `generic-bot-catchall` rule because of its high false positive rate in real-world scenarios - Moved all CSS inline to the Xess package, changed colors to be CSS variables - Set or append to `X-Forwarded-For` header unless the remote connects over a loopback address [#328](https://github.com/TecharoHQ/anubis/issues/328) - Fixed mojeekbot user agent regex - Reduce Anubis' paranoia with user cookies ([#365](https://github.com/TecharoHQ/anubis/pull/365)) - Added support for Open Graph passthrough while using unix sockets - The Open Graph subsystem now passes the HTTP `HOST` header through to the origin - Updated the `OG_PASSTHROUGH` to be true by default, thereby allowing Open Graph tags to be passed through by default ## v1.16.0 Fordola rem Lupis > I want to make them pay! All of them! Everyone who ever mocked or looked down on me -- I want the power to make them pay! The following features are the "big ticket" items: - 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) - Verification page now shows hash rate and a progress bar for completion probability - Added support for [Open Graph 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 support for passing the ed25519 signing key in a file with `-ed25519-private-key-hex-file` or `ED25519_PRIVATE_KEY_HEX_FILE` The other small fixes have been made: - 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)) - 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)) - 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 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)) - Hash pinned all GitHub Actions ## v1.15.1 Zenos yae Galvus: Echo 1 Fixes a recurrence of [CVE-2025-24369](https://github.com/Xe/x/security/advisories/GHSA-56w8-8ppj-2p4f) due to an incorrect logic change in a refactor. This allows an attacker to mint a valid access token by passing any SHA-256 hash instead of one that matches the proof-of-work test. This case has been added as a regression test. It was not when CVE-2025-24369 was released due to the project not having the maturity required to enable this kind of regression testing. ## v1.15.0 Zenos yae Galvus > Yes...the coming days promise to be most interesting. Most interesting. Headline changes: - ed25519 signing keys for Anubis can be stored in the flag `--ed25519-private-key-hex` or envvar `ED25519_PRIVATE_KEY_HEX`; if one is not provided when Anubis starts, a new one is generated and logged - Add the ability to set the cookie domain with the envvar `COOKIE_DOMAIN=techaro.lol` for all domains under `techaro.lol` - Add the ability to set the cookie partitioned flag with the envvar `COOKIE_PARTITIONED=true` Many other small changes were made, including but not limited to: - Fixed and clarified installation instructions - Introduced integration tests using Playwright - Refactor & Split up Anubis into cmd and lib.go - Fixed bot check to only apply if address range matches - Fix default difficulty setting that was broken in a refactor - Linting fixes - Make dark mode diff lines readable in the documentation - Fix CI based browser smoke test Users running Anubis' test suite may run into issues with the integration tests on Windows hosts. This is a known issue and will be fixed at some point in the future. In the meantime, use the Windows Subsystem for Linux (WSL). ## v1.14.2 Livia sas Junius: Echo 2 - Remove default RSS reader rule as it may allow for a targeted attack against rails apps [#67](https://github.com/TecharoHQ/anubis/pull/67) - Whitelist MojeekBot in botPolicies [#47](https://github.com/TecharoHQ/anubis/issues/47) - botPolicies regex has been cleaned up [#66](https://github.com/TecharoHQ/anubis/pull/66) ## v1.14.1 Livia sas Junius: Echo 1 - Set the `X-Real-Ip` header based on the contents of `X-Forwarded-For` [#62](https://github.com/TecharoHQ/anubis/issues/62) ## v1.14.0 Livia sas Junius > Fail to do as my lord commands...and I will spare him the trouble of blocking you. - Add explanation of what Anubis is doing to the challenge page [#25](https://github.com/TecharoHQ/anubis/issues/25) - Administrators can now define artificially hard challenges using the "slow" algorithm: ```json { "name": "generic-bot-catchall", "user_agent_regex": "(?i:bot|crawler)", "action": "CHALLENGE", "challenge": { "difficulty": 16, "report_as": 4, "algorithm": "slow" } } ``` This allows administrators to cause particularly malicious clients to use unreasonable amounts of CPU. The UI will also lie to the client about the difficulty. - Docker images now explicitly call `docker.io/library/<thing>` to increase compatibility with Podman et. al [#21](https://github.com/TecharoHQ/anubis/pull/21) - Don't overflow the image when browser windows are small (eg. on phones) [#27](https://github.com/TecharoHQ/anubis/pull/27) - Lower the default difficulty to 5 from 4 - Don't duplicate work across multiple threads [#36](https://github.com/TecharoHQ/anubis/pull/36) - Documentation has been moved to https://anubis.techaro.lol/ with sources in docs/ - Removed several visible AI artifacts (e.g., 6 fingers) [#37](https://github.com/TecharoHQ/anubis/pull/37) - [KagiBot](https://kagi.com/bot) is allowed through the filter [#44](https://github.com/TecharoHQ/anubis/pull/44) - Fixed hang when navigator.hardwareConcurrency is undefined - Support Unix domain sockets [#45](https://github.com/TecharoHQ/anubis/pull/45) - Allow filtering by remote addresses: ```json { "name": "qwantbot", "user_agent_regex": "\\+https\\:\\/\\/help\\.qwant\\.com/bot/", "action": "ALLOW", "remote_addresses": ["91.242.162.0/24"] } ``` This also works at an IP range level: ```json { "name": "internal-network", "action": "ALLOW", "remote_addresses": ["100.64.0.0/10"] } ``` ## 1.13.0 - Proof-of-work challenges are drastically sped up [#19](https://github.com/TecharoHQ/anubis/pull/19) - Docker images are now built with the timestamp set to the commit timestamp - The README now points to TecharoHQ/anubis instead of Xe/x - Images are built using ko instead of `docker buildx build` [#13](https://github.com/TecharoHQ/anubis/pull/13) ## 1.12.1 - Phrasing in the `<noscript>` warning was replaced from its original placeholder text to something more suitable for general consumption ([fd6903a](https://github.com/TecharoHQ/anubis/commit/fd6903aeed315b8fddee32890d7458a9271e4798)). - Footer links on the check page now point to Techaro's brand ([4ebccb1](https://github.com/TecharoHQ/anubis/commit/4ebccb197ec20d024328d7f92cad39bbbe4d6359)) - Anubis was imported from [Xe/x](https://github.com/Xe/x) ================================================ FILE: docs/docs/admin/_category_.json ================================================ { "label": "Administrative guides", "position": 40, "link": { "type": "generated-index", "description": "Tradeoffs and considerations you may want to keep in mind when using Anubis." } } ================================================ FILE: docs/docs/admin/botstopper.mdx ================================================ --- title: "Commercial support and an unbranded version" --- If you want to use Anubis but organizational policies prevent you from using the branding that the open source project ships, we offer a commercial version of Anubis named BotStopper. BotStopper builds off of the open source core of Anubis and offers organizations more control over the branding, including but not limited to: - Custom images for different states of the challenge process (in process, success, failure) - Custom CSS and fonts - Custom titles for the challenge and error pages - "Anubis" replaced with "BotStopper" across the UI - A private bug tracker for issues In the near future this will expand to: - A private challenge implementation that does advanced fingerprinting to check if the client is a genuine browser or not - Advanced fingerprinting via [Thoth-based advanced checks](./thoth.mdx) In order to sign up for BotStopper, please do one of the following: - Sign up [on GitHub Sponsors](https://github.com/sponsors/Xe) at the $50 per month tier or higher - Email [sales@techaro.lol](mailto:sales@techaro.lol) with your requirements for invoicing, please note that custom invoicing will cost more than using GitHub Sponsors for understandable overhead reasons ## Installation Install BotStopper like you would Anubis, but replace the image reference. EG: ```diff -ghcr.io/techarohq/anubis:latest +ghcr.io/techarohq/botstopper/anubis:latest ``` ### Binary packages Binary packages are available [in the GitHub Releases page](https://github.com/TecharoHQ/botstopper/releases), the main difference is that the package name is `techaro-botstopper`, the systemd service is `techaro-botstopper@your-instance.service`, the binary is `/usr/bin/botstopper`, and the configuration is in `/etc/techaro-botstopper`. All other instructions in the [native package install guide](./native-install.mdx) apply. ### Docker / Podman In order to pull the BotStopper image, you need to [authenticate with GitHub's Container Registry](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry#authenticating-to-the-container-registry). ```text docker login ghcr.io -u your-username --password-stdin ``` Then you can use the image as normal. ### Kubernetes If you are using Kubernetes, you will need to create an image pull secret: ```text kubectl create secret docker-registry \ techarohq-botstopper \ --docker-server ghcr.io \ --docker-username any-username \ --docker-password <your-access-token> \ ``` Then attach it to your Deployment: ```diff spec: securityContext: fsGroup: 1000 + imagePullSecrets: + - name: techarohq-botstopper ``` ## Configuration ### Docker compose Follow [the upstream Docker compose directions](https://anubis.techaro.lol/docs/admin/environments/docker-compose) with the following additional options: ```diff anubis: image: ghcr.io/techarohq/botstopper/anubis:latest environment: BIND: ":8080" DIFFICULTY: "4" METRICS_BIND: ":9090" SERVE_ROBOTS_TXT: "true" TARGET: "http://nginx" OG_PASSTHROUGH: "true" OG_EXPIRY_TIME: "24h" + # botstopper config here + CHALLENGE_TITLE: "Doing math for your connection!" + ERROR_TITLE: "Something went wrong!" + OVERLAY_FOLDER: /assets + volumes: + - "./your_folder:/assets" ``` #### Example There is an example in [docker-compose.yaml](https://github.com/TecharoHQ/botstopper/blob/main/docker-compose.yaml). Start the example with `docker compose up`: ```text docker compose up -d ``` And then open [https://botstopper.local.cetacean.club:8443](https://botstopper.local.cetacean.club:8443) in your browser. > [!NOTE] > This uses locally signed sacrificial TLS certificates stored in `./demo/pki`. Your browser will rightly reject these. Here is what the example looks like: > > ![](/img/botstopper/example-screenshot.webp) ## Custom images and CSS Anubis uses an internal filesystem that contains CSS, JavaScript, and images. The BotStopper variant of Anubis lets you specify an overlay folder with the environment variable `OVERLAY_FOLDER`. The contents of this folder will be overlaid on top of Anubis' internal filesystem, allowing you to easily customize the images and CSS. Your directory tree should look like this, assuming your data is in `./your_folder`: ```text ./your_folder └── static ├── css │ └── custom.css └── img ├── happy.webp ├── pensive.webp └── reject.webp ``` For an example directory tree using some off-the-shelf images the Tango icon set, see the [testdata](https://github.com/TecharoHQ/botstopper/tree/main/testdata/static/img) folder. ### Header-based overlay dispatch If you run BotStopper in a multi-tenant environment where each tenant needs its own branding, BotStopper supports the ability to use request header values to direct asset reads to different folders under your `OVERLAY_FOLDER`. One of the most common ways to do this is based on the HTTP Host of the request. For example, if you set `ASSET_LOOKUP_HEADER=Host` in BotStopper's environment: ```text $OVERLAY_FOLDER ├── static │ ├── css │ │ ├── custom.css │ │ └── eyesore.css │ └── img │ ├── happy.webp │ ├── pensive.webp │ └── reject.webp └── test.anubis.techaro.lol └── static ├── css │ └── custom.css └── img ├── happy.webp ├── pensive.webp └── reject.webp ``` Requests to `test.anubis.techaro.lol` will load assets in `$OVERLAY_FOLDER/test.anubis.techaro.lol/static` and all other requests will load them from `$OVERLAY_FOLDER/static`. For an example, look at [the testdata folder in the BotStopper repo](https://github.com/TecharoHQ/botstopper/tree/main/testdata). ### Custom CSS CSS customization is done mainly with CSS variables. View [the example custom CSS file](https://github.com/TecharoHQ/botstopper/blob/main/testdata/static/css/custom.css) for more information about what can be customized. ### Custom fonts If you want to add custom fonts, copy the `woff2` files alongside your `custom.css` file and then include them with the [`@font-face` CSS at-rule](https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face): ```css @font-face { font-family: "Oswald"; font-style: normal; font-weight: 200 900; font-display: swap; src: url("./fonts/oswald.woff2") format("woff2"); } ``` Then adjust your CSS variables accordingly: ```css :root { --body-sans-font: Oswald, sans-serif; --body-preformatted-font: monospace; --body-title-font: serif; } ``` To convert `.ttf` fonts to [Web-optimized woff2 fonts](https://www.w3.org/TR/WOFF2/), use the `woff2_compress` command from the `woff2` or `woff2-tools` package: ```console $ woff2_compress oswald.ttf Processing oswald.ttf => oswald.woff2 Compressed 159517 to 70469. ``` Then you can import and use it as normal. ### Customizing images Anubis uses three images to visually communicate the state of the program. These are: | Image name | Intended message | Example | | :------------- | :----------------------------------------------- | :-------------------------------- | | `happy.webp` | You have passed validation, all is good | ![](/img/botstopper/happy.webp) | | `pensive.webp` | Checking is running, hold steady until it's done | ![](/img/botstopper/pensive.webp) | | `reject.webp` | Something went wrong, this is a terminal state | ![](/img/botstopper/reject.webp) | To make your own images at the optimal quality, use the following ffmpeg command: ```text ffmpeg -i /path/to/image -vf scale=-1:384 happy.webp ``` `ffprobe` should report something like this on the generated images: ```text Input #0, webp_pipe, from 'happy.webp': Duration: N/A, bitrate: N/A Stream #0:0: Video: webp, none, 25 fps, 25 tbr, 25 tbn ``` In testing 384 by 384 pixels gives the best balance between filesize, quality, and clarity. ```text $ du -hs * 4.0K happy.webp 12K pensive.webp 8.0K reject.webp ``` ## Custom HTML templates If you need to completely control the HTML layout of all Anubis pages, you can customize the entire page with `USE_TEMPLATES=true`. This uses Go's standard library [html/template](https://pkg.go.dev/html/template) package to template HTML responses. Your templates can contain whatever HTML you want. The only catch is that you MUST include `{{ .Head }}` in the `<head>` element for challenge pages, and you MUST include `{{ .Body }}` in the `<body>` element for all pages. In order to use this, you must define the following templates: | Template path | Usage | | :----------------------------------------- | :---------------------------------------------- | | `$OVERLAY_FOLDER/templates/challenge.tmpl` | Challenge pages | | `$OVERLAY_FOLDER/templates/error.tmpl` | Error pages | | `$OVERLAY_FOLDER/templates/impressum.tmpl` | [Impressum](./configuration/impressum.mdx) page | :::note Currently HTML templates don't work together with [Header-based overlay dispatch](#header-based-overlay-dispatch). This is a known issue that will be fixed soon. If you enable header-based overlay dispatch, BotStopper will use the global `templates` folder instead of using the templates present in the overlay. ::: Here are minimal (but working) examples for each template: <details> <summary>`challenge.tmpl`</summary> :::note You **MUST** include the `{{.Head}}` segment in a `<head>` tag. It contains important information for challenges to execute. If you don't include this, no clients will be able to pass challenges. ::: ```html <!DOCTYPE html> <html lang="{{ .Lang }}"> <head> {{ .Head }} </head> <body> {{ .Body }} </body> </html> ``` </details> <details> <summary>`error.tmpl`</summary> ```html <!DOCTYPE html> <html lang="{{ .Lang }}"> <body> {{ .Body }} </body> </html> ``` </details> <details> <summary>`impressum.tmpl`</summary> ```html <!DOCTYPE html> <html lang="{{ .Lang }}"> <body> {{ .Body }} </body> </html> ``` </details> ### Template functions In order to make life easier, the following template functions are defined: #### `Asset` Constructs the path for a static asset in the [overlay folder](#custom-images-and-css)'s `static` directory. ```go func Asset(string) string ``` Usage: ```html <link rel="stylesheet" href="{{ Asset "css/example.css" }}" /> ``` Generates: ```html <link rel="stylesheet" href="/.within.website/x/cmd/anubis/static/css/example.css" /> ``` ## Customizing messages You can customize messages using the following environment variables: | Message | Environment variable | Default | | :------------------- | :------------------- | :----------------------------------------- | | Challenge page title | `CHALLENGE_TITLE` | `Ensuring the security of your connection` | | Error page title | `ERROR_TITLE` | `Error` | For example: ```sh # /etc/techaro-botstopper/gitea.env CHALLENGE_TITLE="Wait a moment please!" ERROR_TITLE="Client error" ``` ================================================ FILE: docs/docs/admin/caveats-gitea-forgejo.mdx ================================================ --- title: When using Caddy with Gitea/Forgejo --- Gitea/Forgejo relies on the reverse proxy setting the `X-Real-Ip` header. Caddy does not do this out of the gate. Modify your Caddyfile like this: ```python ellenjoe.int.within.lgbt { # ... # diff-remove reverse_proxy http://localhost:3000 # diff-add reverse_proxy http://localhost:3000 { # diff-add header_up X-Real-Ip {remote_host} # diff-add } # ... } ``` Ensure that Gitea/Forgejo have `[security].REVERSE_PROXY_TRUSTED_PROXIES` set to the IP ranges that Anubis will appear from. Typically this is sufficient: ```ini [security] REVERSE_PROXY_TRUSTED_PROXIES = 127.0.0.0/8,::1/128 ``` However if you are running Anubis in a separate Pod/Deployment in Kubernetes, you may have to adjust this to the IP range of the Pod space in your Container Networking Interface plugin: ```ini [security] REVERSE_PROXY_TRUSTED_PROXIES = 10.192.0.0/12 ``` ================================================ FILE: docs/docs/admin/caveats-xff.mdx ================================================ # Client IP Headers Currently Anubis will always flatten the `X-Forwarded-For` when it contains multiple IP addresses. From right to left, the first IP address that is not in one of the following categories will be set as `X-Forwarded-For` in the request passed to the upstream. - Private (`XFF_STRIP_PRIVATE`, enabled by default) - CGNAT (always stripped) - Link-local Unicast (always stripped) ``` Incoming: X-Forwarded-For: 1.2.3.4, 5.6.7.8, 10.0.0.1 Upstream: X-Forwarded-For: 5.6.7.8 ``` This behavior will cause problems if the proxy in front of Anubis is from a public IP, such as Cloudflare, because Anubis will use the Cloudflare IP instead of your client's real IP. You will likely see all requests from your browser being blocked and/or an infinite challenge loop. ``` Incoming: X-Forwarded-For: REAL_CLIENT_IP, CF_IP Upstream: X-Forwarded-For: CF_IP ``` As a workaround, you should configure your web server to parse an alternative source (such as `CF-Connecting-IP`), or pre-process the incoming `X-Forwarded-For` with your web server to ensure it only contains the real client IP address, then pass it to Anubis as `X-Forwarded-For`. If you do not control the web server upstream of Anubis, the `custom-real-ip-header` command line flag accepts a header value that Anubis will read the real client IP address from. Anubis will set the `X-Real-IP` header to the IP address found in the custom header. The `X-Real-IP` header will be automatically inferred from `X-Forwarded-For` if not set, setting it explicitly is not necessary as long as `X-Forwarded-For` contains only the real client IP. However setting it explicitly can eliminate spoofed values if your web server doesn't set this. See [Cloudflare](environments/cloudflare.mdx) for an example configuration. ================================================ FILE: docs/docs/admin/configuration/_category_.json ================================================ { "label": "Configuration", "position": 10, "link": { "type": "generated-index", "description": "Detailed information about configuring parts of Anubis." } } ================================================ FILE: docs/docs/admin/configuration/challenges/_category_.json ================================================ { "label": "Challenges", "position": 10, "link": null } ================================================ FILE: docs/docs/admin/configuration/challenges/index.mdx ================================================ # Challenge Methods Anubis supports multiple challenge methods: - [Meta Refresh](./metarefresh.mdx) - [Preact](./preact.mdx) - [Proof of Work](./proof-of-work.mdx) Read the documentation to know which method is best for you. ================================================ FILE: docs/docs/admin/configuration/challenges/metarefresh.mdx ================================================ # Meta Refresh (No JavaScript) The `metarefresh` challenge sends a browser a much simpler challenge that makes it refresh the page after a set period of time. This enables clients to pass challenges without executing JavaScript. To use it in your Anubis configuration: ```yaml # Generic catchall rule - name: generic-browser user_agent_regex: >- Mozilla|Opera action: CHALLENGE challenge: difficulty: 1 # Number of seconds to wait before refreshing the page algorithm: metarefresh # Specify a non-JS challenge method ``` This is not enabled by default while this method is tested and its false positive rate is ascertained. Many modern scrapers use headless Google Chrome, so this will have a much higher false positive rate. ================================================ FILE: docs/docs/admin/configuration/challenges/preact.mdx ================================================ # Preact The `preact` challenge sends the browser a simple challenge that makes it run very lightweight JavaScript that proves the client is able to execute client-side JavaScript. It uses [Preact](https://www.npmjs.com/package/preact) (a lightweight client side web framework in the vein of React) to do this. To use it in your Anubis configuration: ```yaml # Generic catchall rule - name: generic-browser user_agent_regex: >- Mozilla|Opera action: CHALLENGE challenge: difficulty: 1 # Number of seconds to wait before refreshing the page algorithm: preact ``` This is the default challenge method for most clients. ================================================ FILE: docs/docs/admin/configuration/challenges/proof-of-work.mdx ================================================ # Proof of Work (JavaScript) When Anubis is configured to use the `fast` or `slow` challenge methods, clients will be sent a small [proof of work](https://en.wikipedia.org/wiki/Proof_of_work) challenge. In order to get a token used to access the upstream resource, clients must calculate a complicated math puzzle with JavaScript. A `fast` challenge uses a heavily optimized multithreaded implementation and a `slow` challenge uses a simplistic single-threaded implementation. The `slow` method is kept around for legacy compatibility. ================================================ FILE: docs/docs/admin/configuration/custom-status-codes.mdx ================================================ # Custom status codes for Anubis errors Out of the box, Anubis will reply with `HTTP 200` for challenge and denial pages. This is intended to make AI scrapers have a hard time with your website because when they are faced with a non-200 response, they will hammer the page over and over until they get a 200 response. This behavior may not be desirable, as such Anubis lets you customize what HTTP status codes are returned when Anubis throws challenge and denial pages. This is configured in the `status_codes` block of your [bot policy file](../policies.mdx): ```yaml status_codes: CHALLENGE: 200 DENY: 200 ``` To match CloudFlare's behavior, use a configuration like this: ```yaml status_codes: CHALLENGE: 403 DENY: 403 ``` ================================================ FILE: docs/docs/admin/configuration/expressions.mdx ================================================ # Expression-based rule matching Most of the Anubis matchers let you match individual parts of a request and only those parts in isolation. In order to defend a service in depth, you often need the ability to match against multiple aspects of a request. Anubis implements [Common Expression Language (CEL)](https://cel.dev) to let administrators define these more advanced rules. This allows you to tailor your approach for the individual services you are protecting. As an example, here is a rule that lets you allow JSON API requests through Anubis: ```yaml - name: allow-api-requests action: ALLOW expression: all: - '"Accept" in headers' - 'headers["Accept"] == "application/json"' - 'path.startsWith("/api/")' ``` This is an advanced feature and as such it is easy to get yourself in trouble with it. Use this with care. ## Common Expression Language (CEL) CEL is an expression language made by Google as a part of their access control lists system. As programs grow more complicated and users have the need to express more complicated security requirements, they often want the ability to just run a small bit of code to check things for themselves. CEL expressions are built for this. They are implicitly sandboxed so that they cannot affect the system they are running in and also designed to evaluate as fast as humanly possible. Imagine a CEL expression as the contents of an `if` statement in JavaScript or the `WHERE` clause in SQL. Consider this example expression: ```python userAgent == "" ``` This is roughly equivalent to the following in JavaScript: ```js if (userAgent == "") { // Do something } ``` Using these expressions, you can define more elaborate rules as facts and circumstances demand. For more information about the syntax and grammar of CEL, take a look at [the language specification](https://github.com/google/cel-spec/blob/master/doc/langdef.md). ## How Anubis uses CEL Anubis uses CEL to let administrators create complicated filter rules. Anubis has several modes of using CEL: - Validating requests against single expressions - Validating multiple expressions and ensuring at least one of them are true (`any`) - Validating multiple expressions and ensuring all of them are true (`all`) The common pattern is that every Anubis expression returns `true`, `false`, or raises an error. ### Single expressions A single expression that returns either `true` or `false`. If the expression returns `true`, then the action specified in the rule will be taken. If it returns `false`, Anubis will move on to the next rule. For example, consider this rule: ```yaml - name: no-user-agent-string action: DENY expression: userAgent == "" ``` For this rule, if a request comes in without a [`User-Agent` string](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/User-Agent) set, Anubis will deny the request and return an error page. ### `any` blocks An `any` block that contains a list of expressions. If any expression in the list returns `true`, then the action specified in the rule will be taken. If all expressions in that list return `false`, Anubis will move on to the next rule. For example, consider this rule: ```yaml - name: known-banned-user action: DENY expression: any: - remoteAddress == "8.8.8.8" - remoteAddress == "1.1.1.1" ``` For this rule, if a request comes in from `8.8.8.8` or `1.1.1.1`, Anubis will deny the request and return an error page. ### `all` blocks An `all` block that contains a list of expressions. If all expressions in the list return `true`, then the action specified in the rule will be taken. If any of the expressions in the list returns `false`, Anubis will move on to the next rule. For example, consider this rule: ```yaml - name: go-get action: ALLOW expression: all: - userAgent.startsWith("Go-http-client/") - '"go-get" in query' - query["go-get"] == "1" ``` For this rule, if a request comes in matching [the signature of the `go get` command](https://pkg.go.dev/cmd/go#hdr-Remote_import_paths), Anubis will allow it through to the target. ## Variables exposed to Anubis expressions Anubis exposes the following variables to expressions: | Name | Type | Explanation | Example | | :-------------- | :-------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------- | :----------------------------------------------------------- | | `headers` | `map[string, string]` | The [headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers) of the request being processed. | `{"User-Agent": "Mozilla/5.0 Gecko/20100101 Firefox/137.0"}` | | `host` | `string` | The [HTTP hostname](https://web.dev/articles/url-parts#host) the request is targeted to. | `anubis.techaro.lol` | | `contentLength` | `int64` | The numerical value of the `Content-Length` header. | | `load_1m` | `double` | The current system load average over the last one minute. This is useful for making [load-based checks](#using-the-system-load-average). | | `load_5m` | `double` | The current system load average over the last five minutes. This is useful for making [load-based checks](#using-the-system-load-average). | | `load_15m` | `double` | The current system load average over the last fifteen minutes. This is useful for making [load-based checks](#using-the-system-load-average). | | `method` | `string` | The [HTTP method](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Methods) in the request being processed. | `GET`, `POST`, `DELETE`, etc. | | `path` | `string` | The [path](https://web.dev/articles/url-parts#pathname) of the request being processed. | `/`, `/api/memes/create` | | `query` | `map[string, string]` | The [query parameters](https://web.dev/articles/url-parts#query) of the request being processed. | `?foo=bar` -> `{"foo": "bar"}` | | `remoteAddress` | `string` | The IP address of the client. | `1.1.1.1` | | `userAgent` | `string` | The [`User-Agent`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/User-Agent) string in the request being processed. | `Mozilla/5.0 Gecko/20100101 Firefox/137.0` | Of note: in many languages when you look up a key in a map and there is nothing there, the language will return some "falsy" value like `undefined` in JavaScript, `None` in Python, or the zero value of the type in Go. In CEL, if you try to look up a value that does not exist, execution of the expression will fail and Anubis will return an error. In order to avoid this, make sure the header or query parameter you are testing is present in the request with an `all` block like this: ```yaml - name: challenge-wiki-history-page action: CHALLENGE all: - 'path == "/index.php"' - '"title" in query' - '"action" in query' - 'query["action"] == "history"' ``` This rule throws a challenge if and only if all of the following conditions are true: - The URL path is `/index.php` - The URL query string contains a `title` value - The URL query string contains an `action` value - The URL query string's `action` value is `"history"` So given an HTTP request like this: ```text GET /index.php?title=Index&action=history HTTP/1.1 User-Agent: Mozilla/5.0 Gecko/20100101 Firefox/137.0 Host: wiki.int.techaro.lol X-Real-Ip: 8.8.8.8 ``` Anubis would return a challenge because all of those conditions are true. ### Using the system load average In Unix-like systems (such as Linux), every process on the system has to wait its turn to be able to run. This means that as more processes on the system are running, they need to wait longer to be able to execute. The [load average](<https://en.wikipedia.org/wiki/Load_(computing)>) represents the number of processes that want to be able to run but can't run yet. This metric isn't the most reliable to identify a cause, but is great at helping to identify symptoms. Anubis lets you use the system load average as an input to expressions so that you can make dynamic rules like "when the system is under a low amount of load, dial back the protection, but when it's under a lot of load, crank it up to the mix". This lets you get all of the blocking features of Anubis in the background but only really expose Anubis to users when the system is actively being attacked. This is best combined with the [weight](../policies.mdx#request-weight) and [threshold](./thresholds.mdx) systems so that you can have Anubis dynamically respond to attacks. Consider these rules in the default configuration file: ```yaml ## System load based checks. # If the system is under high load for the last minute, add weight. - name: high-load-average action: WEIGH expression: load_1m >= 10.0 # make sure to end the load comparison in a .0 weight: adjust: 20 # If it is not for the last 15 minutes, remove weight. - name: low-load-average action: WEIGH expression: load_15m <= 4.0 # make sure to end the load comparison in a .0 weight: adjust: -10 ``` This combination of rules makes Anubis dynamically react to the system load and only kick in when the system is under attack. Something to keep in mind about system load average is that it is not aware of the number of cores the system has. If you have a 16 core system that has 16 processes running but none of them is hogging the CPU, then you will get a load average below 16. If you are in doubt, make your "high load" metric at least two times the number of CPU cores and your "low load" metric at least half of the number of CPU cores. For example: | Kind | Core count | Load threshold | | --------: | :--------- | :------------- | | high load | 4 | `8.0` | | low load | 4 | `2.0` | | high load | 16 | `32.0` | | low load | 16 | `8` | Also keep in mind that this does not account for other kinds of latency like I/O latency. A system can have its web applications unresponsive due to high latency from a MySQL server but still have that web application server report a load near or at zero. ## Functions exposed to Anubis expressions Anubis expressions can be augmented with the following functions: ### `missingHeader` Available in `bot` expressions. ```ts function missingHeader(headers: Record<string, string>, key: string) bool ``` `missingHeader` returns `true` if the request does not contain a header. This is useful when you are trying to assert behavior such as: ```yaml # Adds weight to old versions of Chrome - name: old-chrome action: WEIGH weight: adjust: 10 expression: all: - userAgent.matches("Chrome/[1-9][0-9]?\\.0\\.0\\.0") - missingHeader(headers, "Sec-Ch-Ua") ``` ### `randInt` Available in all expressions. ```ts function randInt(n: int): int; ``` randInt returns a randomly selected integer value in the range of `[0,n)`. This is a thin wrapper around [Go's math/rand#Intn](https://pkg.go.dev/math/rand#Intn). Be careful with this as it may cause inconsistent behavior for genuine users. This is best applied when doing explicit block rules, eg: ```yaml # Denies LightPanda about 75% of the time on average - name: deny-lightpanda-sometimes action: DENY expression: all: - userAgent.matches("LightPanda") - randInt(16) >= 4 ``` It seems counter-intuitive to allow known bad clients through sometimes, but this allows you to confuse attackers by making Anubis' behavior random. Adjust the thresholds and numbers as facts and circumstances demand. ### `regexSafe` Available in `bot` expressions. ```ts function regexSafe(input: string): string; ``` `regexSafe` takes a string and escapes it for safe use inside of a regular expression. This is useful when you are creating regular expressions from headers or variables such as `remoteAddress`. | Input | Output | | :------------------------- | :-------------- | | `regexSafe("1.2.3.4")` | `1\\.2\\.3\\.4` | | `regexSafe("techaro.lol")` | `techaro\\.lol` | | `regexSafe("star*")` | `star\\*` | | `regexSafe("plus+")` | `plus\\+` | | `regexSafe("{braces}")` | `\\{braces\\}` | | `regexSafe("start^")` | `start\\^` | | `regexSafe("back\\slash")` | `back\\\\slash` | | `regexSafe("dash-dash")` | `dash\\-dash` | ### `segments` Available in `bot` expressions. ```ts function segments(path: string): string[]; ``` `segments` returns the number of slash-separated path segments, ignoring the leading slash. Here is what it will return with some common paths: | Input | Output | | :----------------------- | :--------------------- | | `segments("/")` | `[""]` | | `segments("/foo/bar")` | `["foo", "bar"] ` | | `segments("/users/xe/")` | `["users", "xe", ""] ` | :::note If the path ends with a `/`, then the last element of the result will be an empty string. This is because `/users/xe` and `/users/xe/` are semantically different paths. ::: This is useful if you want to write rules that allow requests that have no query parameters only if they have less than two path segments: ```yaml - name: two-path-segments-no-query action: ALLOW expression: all: - size(query) == 0 - size(segments(path)) < 2 ``` ### DNS Functions Anubis can also perform DNS lookups as a part of its expression evaluation. This can be useful for doing things like checking for a valid [Forward-confirmed reverse DNS (FCrDNS)](https://en.wikipedia.org/wiki/Forward-confirmed_reverse_DNS) record. #### `arpaReverseIP` Available in `bot` expressions. ```ts function arpaReverseIP(ip: string): string; ``` `arpaReverseIP` takes an IP address and returns its value in [ARPA notation](https://www.ietf.org/rfc/rfc2317.html). This can be useful when matching PTR record patterns. | Input | Output | | :----------------------------- | :---------------------------------------------------------------- | | `arpaReverseIP("1.2.3.4")` | `4.3.2.1` | | `arpaReverseIP("2001:db8::1")` | `1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2` | #### `lookupHost` Available in `bot` expressions. ```ts function lookupHost(host: string): string[]; ``` `lookupHost` performs a DNS lookup for the given hostname and returns a list of IP addresses. ```yaml - name: cloudflare-ip-in-host-header action: DENY expression: '"104.16.0.0" in lookupHost(headers["Host"])' ``` #### `reverseDNS` Available in `bot` expressions. ```ts function reverseDNS(ip: string): string[]; ``` `reverseDNS` takes an IP address and returns the DNS names associated with it. This is useful when you want to check PTR records of an IP address. ```yaml - name: allow-googlebot action: ALLOW expression: 'reverseDNS(remoteAddress).endsWith(".googlebot.com")' ``` ::: warning Do not use this for validating the legitimacy of an IP address. It is possible for DNS records to be out of date or otherwise manipulated. Use [`verifyFCrDNS`](#verifyfcrdns) instead for a more reliable result. ::: #### `verifyFCrDNS` Available in `bot` expressions. ```ts function verifyFCrDNS(ip: string): bool; function verifyFCrDNS(ip: string, pattern: string): bool; ``` `verifyFCrDNS` checks if the reverse DNS of an IP address matches its forward DNS. This is a common technique to filter out spam and bot traffic. `verifyFCrDNS` comes in two forms: - `verifyFCrDNS(remoteAddress)` will check that the reverse DNS of the remote address resolves back to the remote address. If no PTR records, returns true. - `verifyFCrDNS(remoteAddress, pattern)` will check that the reverse DNS of the remote address is matching with pattern and that name resolves back to the remote address. This is best used in rules like this: ```yaml - name: require-fcrdns-for-post action: DENY expression: all: - method == "POST" - "!verifyFCrDNS(remoteAddress)" ``` Here is an another example that allows requests from telegram: ```yaml - name: telegrambot action: ALLOW expression: all: - userAgent.matches("TelegramBot") - verifyFCrDNS(remoteAddress, "ptr\\.telegram\\.org$") ``` ## Life advice Expressions are very powerful. This is a benefit and a burden. If you are not careful with your expression targeting, you will be liable to get yourself into trouble. If you are at all in doubt, throw a `CHALLENGE` over a `DENY`. Legitimate users can easily work around a `CHALLENGE` result with a [proof of work challenge](../../design/why-proof-of-work.mdx). Bots are less likely to be able to do this. ================================================ FILE: docs/docs/admin/configuration/import.mdx ================================================ # Importing configuration rules import Tabs from "@theme/Tabs"; import TabItem from "@theme/TabItem"; Anubis has the ability to let you import snippets of configuration into the main configuration file. This allows you to break up your config into smaller parts that get logically assembled into one big file. EG: ```yaml bots: # Pathological bots to deny - # This correlates to data/bots/ai-catchall.yaml in the source tree import: (data)/bots/ai-catchall.yaml - import: (data)/bots/cloudflare-workers.yaml # Import all the rules in the default configuration - import: (data)/meta/default-config.yaml ``` Of note, a bot rule can either have inline bot configuration or import a bot config snippet. You cannot do both in a single bot rule. ```yaml bots: - import: (data)/bots/ai-catchall.yaml name: generic-browser user_agent_regex: > Mozilla|Opera action: CHALLENGE ``` This will return an error like this: ```text config is not valid: config.BotOrImport: rule definition is invalid, you must set either bot rules or an import statement, not both ``` Paths can either be prefixed with `(data)` to import from the [the data folder in the Anubis source tree](https://github.com/TecharoHQ/anubis/tree/main/data) or anywhere on the filesystem. If you don't have access to the Anubis source tree, check /usr/share/docs/anubis/data or in the tarball you extracted Anubis from. ## Importing the default configuration If you want to base your configuration off of the default configuration, import `(data)/meta/default-config.yaml`: ```yaml bots: - import: (data)/meta/default-config.yaml # Write your rules here ``` This will keep your configuration up to date as Anubis adapts to emerging threats. ## How do I exempt most modern browsers from Anubis challenges? If you want to exempt most modern browsers from Anubis challenges, import `(data)/common/acts-like-browser.yaml`: ```yaml bots: - import: (data)/meta/default-config.yaml - import: (data)/common/acts-like-browser.yaml # Write your rules here ``` These rules will allow traffic that "looks like" it's from a modern copy of Edge, Safari, Chrome, or Firefox. These rules used to be enabled by default, however user reports have suggested that AI scraper bots have adapted to conform to these rules to scrape without regard for the infrastructure they are attacking. Use these rules at your own risk. ## Importing from imports You can also import from an imported file in case you want to import an entire folder of rules at once. ```yaml bots: - import: (data)/bots/_deny-pathological.yaml ``` This lets you import an entire ruleset at once: ```yaml # (data)/bots/_deny-pathological.yaml - import: (data)/bots/cloudflare-workers.yaml - import: (data)/bots/headless-browsers.yaml - import: (data)/bots/us-ai-scraper.yaml ``` Use this with care, you can easily get yourself into a state where Anubis recursively imports things for eternity if you are not careful. The best way to use this is to make a "root import" named `_everything.yaml` or `_allow-good.yaml` so they sort to the top. Name your meta-imports after the main verb they are enforcing so that you can glance at the configuration file and understand what it's doing. ## Writing snippets Snippets can be written in either JSON or YAML, with a preference for YAML. When writing a snippet, write the bot rules you want directly at the top level of the file in a list. Here is an example snippet that allows [IPv6 Unique Local Addresses](https://en.wikipedia.org/wiki/Unique_local_address) through Anubis: ```yaml - name: ipv6-ula action: ALLOW remote_addresses: - fc00::/7 ``` ## Extracting Anubis' embedded filesystem You can always extract the list of rules embedded into the Anubis binary with this command: ```text anubis --extract-resources=static ``` This will dump the contents of Anubis' embedded data to a new folder named `static`: ```text static ├── apps │ └── gitea-rss-feeds.yaml ├── botPolicies.json ├── botPolicies.yaml ├── bots │ ├── ai-catchall.yaml │ ├── cloudflare-workers.yaml │ ├── headless-browsers.yaml │ └── us-ai-scraper.yaml ├── common │ ├── allow-private-addresses.yaml │ └── keep-internet-working.yaml └── crawlers ├── bingbot.yaml ├── duckduckbot.yaml ├── googlebot.yaml ├── internet-archive.yaml ├── kagibot.yaml ├── marginalia.yaml ├── mojeekbot.yaml └── qwantbot.yaml ``` ================================================ FILE: docs/docs/admin/configuration/impressum.mdx ================================================ # Imprint / Impressum configuration Some jurisdictions (such as the European Union and specifically Germany) [must have contact information freely available](https://www.privacycompany.eu/blog/the-imprint-requirement-a-must-have-for-companies-from-outside-germany) on an imprint/impressum page. Anubis supports creating an Anubis-specific imprint page for your organization with the `impressum` block in your bot policy file. For example: ```yaml impressum: # Displayed at the bottom of every page rendered by Anubis. footer: >- This website is hosted by Techaro. If you have any complaints or notes about the service, please contact <a href="mailto:contact@techaro.lol">contact@techaro.lol</a> and we will assist you as soon as possible. # The imprint page that will be linked to at the footer of every Anubis page. page: # The HTML <title> of the page title: Imprint and Privacy Policy # The HTML contents of the page. The exact contents of this page can # and will vary by locale. Please consult with a lawyer if you are not # sure what to put here body: >- <p>Last updated: June 2025</p> <h2>Information that is gathered from visitors</h2> <p>In common with other websites, log files are stored on the web server saving details such as the visitor's IP address, browser type, referring page and time of visit.</p> <p>Cookies may be used to remember visitor preferences when interacting with the website.</p> <p>Where registration is required, the visitor's email and a username will be stored on the server.</p> <!-- ... --> ``` If you are subscribed to and using [advanced classification features](../thoth.mdx), be sure to disclose the following: ```html <h2>Techaro Anubis</h2> <p> This website uses a service called <a href="https://anubis.techaro.lol">Anubis</a> by <a href="https://techaro.lol">Techaro</a> to filter malicious traffic. Anubis requires the use of browser cookies to ensure that web clients are running conformant software. Anubis also may report the following data to Techaro to improve service quality: </p> <ul> <li> IP address (for purposes of matching against geo-location and BGP autonomous systems numbers), which is stored in-memory and not persisted to disk. </li> <li> Unique browser fingerprints (such as HTTP request fingerprints and encryption system fingerprints), which may be stored on Techaro's side for a period of up to one month. </li> <li> HTTP request metadata that may include things such as the User-Agent header and other identifiers. </li> </ul> <p> This data is processed and stored for the legitimate interest of combatting abusive web clients. This data is encrypted at rest as much as possible and is only decrypted in memory for the purposes of fulfilling requests. </p> ``` ================================================ FILE: docs/docs/admin/configuration/open-graph.mdx ================================================ --- id: open-graph title: Open Graph Configuration --- # Open Graph Configuration This page provides detailed information on how to configure [Open Graph 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 Open Graph settings are configured in the `openGraph` section of the [Policy File](../policies.mdx). ```yaml openGraph: # Enables Open Graph passthrough enabled: true # Enables the use of the HTTP host in the cache key, this enables # caching metadata for multiple http hosts at once. considerHost: true # How long cached OpenGraph metadata should last in memory ttl: 24h # If set, return these opengraph values instead of looking them up with # the target service. # # Correlates to properties in https://ogp.me/ override: # og:title is required, it is the title of the website "og:title": "Techaro Anubis" "og:description": >- Anubis is a Web AI Firewall Utility that helps you fight the bots away so that you can maintain uptime at work! "description": >- Anubis is a Web AI Firewall Utility that helps you fight the bots away so that you can maintain uptime at work! ``` <details> <summary>Configuration flags / envvars (old)</summary> Open Graph passthrough used to be configured with configuration flags / environment variables. Reference to these settings are maintained for backwards compatibility's sake. | Name | Description | Type | Default | Example | | ------------------------ | --------------------------------------------------------- | -------- | ------- | ----------------------------- | | `OG_PASSTHROUGH` | Enables or disables the Open Graph tag passthrough system | Boolean | `true` | `OG_PASSTHROUGH=true` | | `OG_EXPIRY_TIME` | Configurable cache expiration time for Open Graph tags | Duration | `24h` | `OG_EXPIRY_TIME=1h` | | `OG_CACHE_CONSIDER_HOST` | Enables or disables the use of the host in the cache key | Boolean | `false` | `OG_CACHE_CONSIDER_HOST=true` | </details> ## 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 export OG_CACHE_CONSIDER_HOST=false ``` ## 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`. When `OG_CACHE_CONSIDER_HOST` is enabled, Anubis will include the host in the cache key for Open Graph tags. This ensures that tags are cached separately for different hosts. ## 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 export OG_CACHE_CONSIDER_HOST=false ``` With these settings, Anubis will cache Open Graph tags for 1 hour and pass them through to the challenge page, not considering the host in the cache key. ## When to Enable `OG_CACHE_CONSIDER_HOST` In most cases, you would want to keep `OG_CACHE_CONSIDER_HOST` set to `false` to avoid unnecessary cache fragmentation. However, there are some scenarios where enabling this option can be beneficial: 1. **Multi-Tenant Applications**: If you are running a multi-tenant application where different tenants are hosted on different subdomains, enabling `OG_CACHE_CONSIDER_HOST` ensures that the Open Graph tags are cached separately for each tenant. This prevents one tenant's Open Graph tags from being served to another tenant's users. 2. **Different Content for Different Hosts**: If your application serves different content based on the host, enabling `OG_CACHE_CONSIDER_HOST` ensures that the correct Open Graph tags are cached and served for each host. This is useful for applications that have different branding or content for different domains or subdomains. 3. **Security and Privacy Concerns**: In some cases, you may want to ensure that Open Graph tags are not shared between different hosts for security or privacy reasons. Enabling `OG_CACHE_CONSIDER_HOST` ensures that the tags are cached separately for each host, preventing any potential leakage of information between hosts. For more information, refer to the [installation guide](../installation). ================================================ FILE: docs/docs/admin/configuration/redirect-domains.mdx ================================================ --- title: Redirect Domain Configuration --- import Tabs from "@theme/Tabs"; import TabItem from "@theme/TabItem"; Anubis has an HTTP redirect in the middle of its check validation logic. This redirect allows Anubis to set a cookie on validated requests so that users don't need to pass challenges on every page load. This flow looks something like this: ```mermaid sequenceDiagram participant User participant Challenge participant Validation participant Backend User->>+Challenge: GET / Challenge->>+User: Solve this challenge User->>+Validation: Here's the solution, send me to / Validation->>+User: Here's a cookie, go to / User->>+Backend: GET / ``` However, in some cases a sufficiently dedicated attacker could trick a user into clicking on a validation link with a solution pre-filled out. For example: ```mermaid sequenceDiagram participant Hacker participant User participant Validation participant Evil Site Hacker->>+User: Click on example.org with this solution User->>+Validation: Here's a solution, send me to evilsite.com Validation->>+User: Here's a cookie, go to evilsite.com User->>+Evil Site: GET evilsite.com ``` If this happens, Anubis will throw an error like this: ```text Redirect domain not allowed ``` ## Configuring allowed redirect domains By default, Anubis may redirect to any domain which could cause security issues in the unlikely case that an attacker passes a challenge for your browser and then tricks you into clicking a link to your domain. One can restrict the domains that Anubis can redirect to when passing a challenge by setting up `REDIRECT_DOMAINS` environment variable. If you need to set more than one domain, fill the environment variable with a comma-separated list of domain names. There is also glob matching support. You can pass `*.bugs.techaro.lol` to allow redirecting to anything ending with `.bugs.techaro.lol`. There is a limit of 4 wildcards. :::note If you are hosting Anubis on a non-standard port (`https://example:com:8443`, `http://www.example.net:8080`, etc.), you must also include the port number here. ::: <Tabs> <TabItem value="env-file" label="Environment file" default> ```shell # anubis.env REDIRECT_DOMAINS="example.org,secretplans.example.org,*.test.example.org" # ... ``` </TabItem> <TabItem value="docker-compose" label="Docker Compose"> ```yaml services: anubis-nginx: image: ghcr.io/techarohq/anubis:latest environment: REDIRECT_DOMAINS: "example.org,secretplans.example.org,*.test.example.org" # ... ``` </TabItem> <TabItem value="k8s" label="Kubernetes"> Inside your Deployment, StatefulSet, or Pod: ```yaml - name: anubis image: ghcr.io/techarohq/anubis:latest env: - name: REDIRECT_DOMAINS value: "example.org,secretplans.example.org,*.test.example.org" # ... ``` </TabItem> </Tabs> ================================================ FILE: docs/docs/admin/configuration/subrequest-auth.mdx ================================================ --- title: Subrequest Authentication --- import Tabs from "@theme/Tabs"; import TabItem from "@theme/TabItem"; Anubis can act in one of two modes: 1. Reverse proxy (the default): Anubis sits in the middle of all traffic and then will reverse proxy it to its destination. This is the moral equivalent of a middleware in your favorite web framework. 2. Subrequest authentication mode: Anubis listens for requests and if they don't pass muster then they are forwarded to Anubis for challenge processing. This is the equivalent of Anubis being a sidecar service. :::note Subrequest authentication requires changing the default policy because nginx interprets the default `DENY` status code `200` as successful authentication and allows the request. ```yaml status_codes: CHALLENGE: 200 DENY: 403 ``` [See policy definitions](../policies.mdx). ::: ## Nginx Anubis can perform [subrequest authentication](https://docs.nginx.com/nginx/admin-guide/security-controls/configuring-subrequest-authentication/) with the `auth_request` module in Nginx. In order to set this up, keep the following things in mind: The `TARGET` environment variable in Anubis must be set to a space, eg: <Tabs> <TabItem value="env-file" label="Environment file" default> ```shell # anubis.env TARGET=" " # ... ``` </TabItem> <TabItem value="docker-compose" label="Docker Compose"> ```yaml services: anubis-nginx: image: ghcr.io/techarohq/anubis:latest environment: TARGET: " " # ... ``` </TabItem> <TabItem value="k8s" label="Kubernetes"> Inside your Deployment, StatefulSet, or Pod: ```yaml - name: anubis image: ghcr.io/techarohq/anubis:latest env: - name: TARGET value: " " # ... ``` </TabItem> </Tabs> In order to configure this, you need to add the following location blocks to each server pointing to the service you want to protect: ```nginx location /.within.website/ { # Assumption: Anubis is running in the same network namespace as # nginx on localhost TCP port 8923 proxy_pass http://127.0.0.1:8923; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header Host $http_host; proxy_pass_request_body off; proxy_set_header content-length ""; auth_request off; } location @redirectToAnubis { return 307 /.within.website/?redir=$scheme://$host$request_uri; auth_request off; } ``` This sets up `/.within.website` to point to Anubis. Any requests that Anubis rejects or throws a challenge to will be sent here. This also sets up a named location `@redirectToAnubis` that will redirect any requests to Anubis for advanced processing. Finally, add this to your root location block: ```nginx location / { # diff-add auth_request /.within.website/x/cmd/anubis/api/check; # diff-add error_page 401 = @redirectToAnubis; } ``` This will check all requests that don't match other locations with Anubis to ensure the client is genuine. This will make every request get checked by Anubis before it hits your backend. If you have other locations that don't need Anubis to do validation, add the `auth_request off` directive to their blocks: ```nginx location /secret { # diff-add auth_request off; # ... } ``` Here is a complete example of an Nginx server listening over TLS and pointing to Anubis: <details> <summary>Complete example</summary> ```nginx # /etc/nginx/conf.d/nginx.local.cetacean.club.conf server { listen 443 ssl; listen [::]:443 ssl; server_name nginx.local.cetacean.club; ssl_certificate /etc/techaro/pki/nginx.local.cetacean.club/tls.crt; ssl_certificate_key /etc/techaro/pki/nginx.local.cetacean.club/tls.key; ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers HIGH:!aNULL:!MD5; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; location /.within.website/ { proxy_pass http://localhost:8923; auth_request off; } location @redirectToAnubis { return 307 /.within.website/?redir=$scheme://$host$request_uri; auth_request off; } location / { auth_request /.within.website/x/cmd/anubis/api/check; error_page 401 = @redirectToAnubis; root /usr/share/nginx/html; index index.html index.htm; } } ``` </details> ## Caddy Anubis can be used with the [`forward_auth`](https://caddyserver.com/docs/caddyfile/directives/forward_auth) directive in Caddy. First, the `TARGET` environment variable in Anubis must be set to a space, eg: <Tabs> <TabItem value="env-file" label="Environment file" default> ```shell # anubis.env TARGET=" " # ... ``` </TabItem> <TabItem value="docker-compose" label="Docker Compose"> ```yaml services: anubis-caddy: image: ghcr.io/techarohq/anubis:latest environment: TARGET: " " # ... ``` </TabItem> <TabItem value="k8s" label="Kubernetes"> Inside your Deployment, StatefulSet, or Pod: ```yaml - name: anubis image: ghcr.io/techarohq/anubis:latest env: - name: TARGET value: " " # ... ``` </TabItem> </Tabs> Then configure the necessary directives in your site block: ```caddy route { # Assumption: Anubis is running in the same network namespace as # caddy on localhost TCP port 8923 reverse_proxy /.within.website/* 127.0.0.1:8923 forward_auth 127.0.0.1:8923 { uri /.within.website/x/cmd/anubis/api/check trusted_proxies private_ranges @unauthorized status 401 handle_response @unauthorized { redir * /.within.website/?redir={uri} 307 } } } ``` If you want to use this for multiple sites, you can create a [snippet](https://caddyserver.com/docs/caddyfile/concepts#snippets) and import it in multiple site blocks. ================================================ FILE: docs/docs/admin/configuration/thresholds.mdx ================================================ # Weight Threshold Configuration Anubis offers the ability to assign "weight" to requests. This is a custom level of suspicion that rules can add to or remove from. For example, here's how you assign 10 weight points to anything that might be a browser: ```yaml # botPolicies.yaml bots: - name: generic-browser user_agent_regex: >- Mozilla|Opera action: WEIGH weight: adjust: 10 ``` Thresholds let you take this per-request weight value and take actions in response to it. Thresholds are defined alongside your bot configuration in `botPolicies.yaml`. :::note Thresholds DO NOT apply when a request matches a bot rule with the CHALLENGE action. Thresholds only apply when requests don't match any terminal bot rules. ::: ```yaml # botPolicies.yaml bots: ... thresholds: - name: minimal-suspicion expression: weight < 0 action: ALLOW - name: mild-suspicion expression: all: - weight >= 0 - weight < 10 action: CHALLENGE challenge: algorithm: metarefresh difficulty: 1 - name: moderate-suspicion expression: all: - weight >= 10 - weight < 20 action: CHALLENGE challenge: algorithm: fast difficulty: 2 - name: extreme-suspicion expression: weight >= 20 action: CHALLENGE challenge: algorithm: fast difficulty: 4 ``` This defines a suite of 4 thresholds: 1. If the request weight is less than zero, allow it through. 2. If the request weight is greater than or equal to zero, but less than ten: give it [a very lightweight challenge](./challenges/metarefresh.mdx). 3. If the request weight is greater than or equal to ten, but less than twenty: give it [a slightly heavier challenge](./challenges/proof-of-work.mdx). 4. Otherwise, give it [the heaviest challenge](./challenges/proof-of-work.mdx). Thresholds can be configured with the following options: <table> <thead> <tr> <th>Name</th> <th>Description</th> <th>Example</th> </tr> </thead> <tbody> <tr> <td>`name`</td> <td>The human-readable name for this threshold.</td> <td> ```yaml name: extreme-suspicion ``` </td> </tr> <tr> <td>`expression`</td> <td>A [CEL](https://cel.dev/) expression taking the request weight and returning true or false</td> <td> To check if the request weight is less than zero: ```yaml expression: weight < 0 ``` To check if it's between 0 and 10 (inclusive): ```yaml expression: all: - weight >= 0 - weight < 10 ``` </td> </tr> <tr> <td>`action`</td> <td>The Anubis action to apply: `ALLOW`, `CHALLENGE`, or `DENY`</td> <td> ```yaml action: ALLOW ``` If you set the CHALLENGE action, you must set challenge details: ```yaml action: CHALLENGE challenge: algorithm: metarefresh difficulty: 1 ``` </td> </tr> </tbody> </table> ================================================ FILE: docs/docs/admin/default-allow-behavior.mdx ================================================ --- title: Default allow behavior --- import Tabs from "@theme/Tabs"; import TabItem from "@theme/TabItem"; # Default allow behavior Anubis is designed to be as unintrusive as possible to your existing infrastructure. By default, it allows all traffic unless a request matches a rule that explicitly denies or challenges it. Only requests matching a DENY or CHALLENGE rule are blocked or challenged. All other requests are allowed. This is called "the implicit rule". ## Example: Minimal policy If your policy only blocks a specific bot, all other requests will be allowed: <Tabs> <TabItem value="json" label="JSON" default> ```json { "bots": [ { "name": "block-amazonbot", "user_agent_regex": "Amazonbot", "action": "DENY" } ] } ``` </TabItem> <TabItem value="yaml" label="YAML"> ```yaml - name: block-amazonbot user_agent_regex: Amazonbot action: DENY ``` </TabItem> </Tabs> ## How to deny by default If you want to deny all traffic except what you explicitly allow, add a catch-all deny rule at the end of your policy list. Make sure to add ALLOW rules for any traffic you want to permit before this rule. <Tabs> <TabItem value="json" label="JSON" default> ```json { "bots": [ { "name": "allow-goodbot", "user_agent_regex": "GoodBot", "action": "ALLOW" }, { "name": "catch-all-deny", "path_regex": ".*", "action": "DENY" } ] } ``` </TabItem> <TabItem value="yaml" label="YAML"> ```yaml - name: allow-goodbot user_agent_regex: GoodBot action: ALLOW - name: catch-all-deny path_regex: .* action: DENY ``` </TabItem> </Tabs> ## Final remarks - Rules are evaluated in order; the first match wins. - The implicit allow rule is always last and cannot be removed. - Use your logs to monitor what traffic is being allowed by default. See [Policy Definitions](./policies) for more details on writing rules. ================================================ FILE: docs/docs/admin/environments/_category_.json ================================================ { "label": "Environments", "position": 20, "link": { "type": "generated-index", "description": "Detailed information about individual environments (such as HTTP servers, platforms, etc.) Anubis is known to work with." } } ================================================ FILE: docs/docs/admin/environments/apache.mdx ================================================ # Apache import Tabs from "@theme/Tabs"; import TabItem from "@theme/TabItem"; Anubis is intended to be a filter proxy. The way to integrate this is to break your configuration up into two parts: TLS termination and then HTTP routing. Consider this diagram: ```mermaid --- title: Apache as tls terminator and HTTP router --- flowchart LR T(User Traffic) subgraph Apache 2 TCP(TCP 80/443) US(TCP 3001) end An(Anubis) B(Backend) T --> |TLS termination| TCP TCP --> |Traffic filtering| An An --> |Happy traffic| US US --> |whatever you're doing| B ``` Effectively you have one trip through Apache to do TLS termination, a detour through Anubis for traffic scrubbing, and then going to the backend directly. This final socket is what will do HTTP routing. :::note These examples assume that you are using a setup where your Apache configuration is made up of a bunch of files in `/etc/httpd/conf.d/*.conf`. This is not true for all deployments of Apache. If you are not in such an environment, append these snippets to your `/etc/httpd/conf/httpd.conf` file. ::: ## Configuration Assuming you are protecting `anubistest.techaro.lol`, you need the following server configuration blocks: 1. A block on port 80 that forwards HTTP to HTTPS 2. A block on port 443 that terminates TLS and forwards to Anubis 3. A block on port 3001 that actually serves your websites ```text # Plain HTTP redirect to HTTPS <VirtualHost *:80> ServerAdmin your@email.here ServerName anubistest.techaro.lol DocumentRoot /var/www/anubistest.techaro.lol ErrorLog /var/log/httpd/anubistest.techaro.lol_error.log CustomLog /var/log/httpd/anubistest.techaro.lol_access.log combined RewriteEngine on RewriteCond %{SERVER_NAME} =anubistest.techaro.lol RewriteRule ^ https://%{SERVER_NAME}%{REQUEST_URI} [END,NE,R=permanent] </VirtualHost> # HTTPS listener that forwards to Anubis <IfModule mod_proxy.c> <VirtualHost *:443> ServerAdmin your@email.here ServerName anubistest.techaro.lol DocumentRoot /var/www/anubistest.techaro.lol ErrorLog /var/log/httpd/anubistest.techaro.lol_error.log CustomLog /var/log/httpd/anubistest.techaro.lol_access.log combined SSLCertificateFile /etc/letsencrypt/live/anubistest.techaro.lol/fullchain.pem SSLCertificateKeyFile /etc/letsencrypt/live/anubistest.techaro.lol/privkey.pem Include /etc/letsencrypt/options-ssl-apache.conf # These headers need to be set or else Anubis will # throw an "admin misconfiguration" error. RequestHeader set "X-Real-Ip" expr=%{REMOTE_ADDR} RequestHeader set X-Forwarded-Proto "https" RequestHeader set "X-Http-Version" "%{SERVER_PROTOCOL}s" ProxyPreserveHost On ProxyRequests Off ProxyVia Off # Replace 9000 with the port Anubis listens on ProxyPass / http://[::1]:9000/ ProxyPassReverse / http://[::1]:9000/ </VirtualHost> </IfModule> # Actual website config <VirtualHost *:3001> ServerAdmin your@email.here ServerName anubistest.techaro.lol DocumentRoot /var/www/anubistest.techaro.lol ErrorLog /var/log/httpd/anubistest.techaro.lol_error.log CustomLog /var/log/httpd/anubistest.techaro.lol_access.log combined # Pass the remote IP to the proxied application instead of 127.0.0.1 # This requires mod_remoteip RemoteIPHeader X-Real-IP RemoteIPTrustedProxy 127.0.0.1/32 </VirtualHost> ``` Make sure to add a separate configuration file for the listener on port 3001: ```text # /etc/httpd/conf.d/listener-3001.conf Listen [::1]:3001 ``` In case you are running an IPv4-only system, use the following configuration instead: ```text # /etc/httpd/conf.d/listener-3001.conf Listen 127.0.0.1:3001 ``` This can be repeated for multiple sites. Anubis does not care about the HTTP `Host` header and will happily cope with multiple websites via the same instance. Then reload your Apache config and load your website. You should see Anubis protecting your apps! ```text sudo systemctl reload httpd.service ``` ## Troubleshooting Here are some answers to questions that came in in testing: ### I'm running on a Red Hat distribution and Apache is saying "service unavailable" for every page load If you see a "Service unavailable" error on every page load and run a Red Hat derived distribution, you are missing a `selinux` setting. The exact command will be in a journalctl log message like this: ```text ***** Plugin catchall_boolean (89.3 confidence) suggests ****************** If you want to allow HTTPD scripts and modules to connect to the network using TCP. Then you must tell SELinux about this by enabling the 'httpd_can_network_connect' boolean. Do setsebool -P httpd_can_network_connect 1 ``` This will fix the error immediately. ================================================ FILE: docs/docs/admin/environments/caddy.mdx ================================================ # Caddy To use Anubis with Caddy, stick Anubis between Caddy and your backend. For example, consider this application setup: ```mermaid --- title: Caddy with Anubis in the middle --- flowchart LR T(User Traffic) TCP(TCP 80/443) An(Anubis) B(Backend) Blocked T --> TCP TCP --> |Traffic filtering| An An --> |Happy traffic| B An --> |Malicious traffic| Blocked ``` Instead of your traffic going directly to your backend, it takes a detour through Anubis. Anubis filters out the "bad" traffic and passes the "good" traffic to the backend. To set up Anubis with Docker compose and Caddy, start with a `docker-compose` configuration like this: ```yaml services: caddy: image: caddy:2 ports: - 80:80 - 443:443 - 443:443/udp volumes: - ./conf:/etc/caddy - caddy_config:/config - caddy_data:/data anubis: image: ghcr.io/techarohq/anubis:latest pull_policy: always environment: BIND: ":3000" TARGET: http://httpdebug:3000 httpdebug: image: ghcr.io/xe/x/httpdebug pull_policy: always volumes: caddy_data: caddy_config: ``` And then put the following in `conf/Caddyfile`: ```Caddyfile # conf/Caddyfile yourdomain.example.com { tls your@email.address reverse_proxy http://anubis:3000 { header_up X-Real-Ip {remote_host} header_up X-Http-Version {http.request.proto} } } ``` If you want to protect multiple services with Anubis, you will need to either start multiple instances of Anubis (Anubis requires less than 32 MB of ram on average) or set up a two-tier routing setup where TLS termination is done with one instance of Caddy and the actual routing to services is done with another instance of Caddy. See the [nginx](./nginx.mdx) or [Apache](./apache.mdx) documentation to get ideas on how you would do this. ================================================ FILE: docs/docs/admin/environments/cloudflare.mdx ================================================ # Cloudflare If you are using Cloudflare, you should configure your server to use `CF-Connecting-IP` as the source of the real client IP, and pass that address to Anubis as `X-Forwarded-For`. Read [Client IP Headers](../caveats-xff.mdx) for details. Example configuration with Caddy: ```Caddyfile { servers { # Cloudflare IP ranges from https://www.cloudflare.com/en-gb/ips/ trusted_proxies static 173.245.48.0/20 103.21.244.0/22 103.22.200.0/22 103.31.4.0/22 141.101.64.0/18 108.162.192.0/18 190.93.240.0/20 188.114.96.0/20 197.234.240.0/22 198.41.128.0/17 162.158.0.0/15 104.16.0.0/13 104.24.0.0/14 172.64.0.0/13 131.0.72.0/22 2400:cb00::/32 2606:4700::/32 2803:f800::/32 2405:b500::/32 2405:8100::/32 2a06:98c0::/29 2c0f:f248::/32 # Use CF-Connecting-IP to determine the client IP instead of XFF # https://caddyserver.com/docs/caddyfile/options#client-ip-headers client_ip_headers CF-Connecting-IP } } example.com { reverse_proxy http://anubis:3000 { # Pass the client IP read from CF-Connecting-IP header_up X-Forwarded-For {client_ip} header_up X-Real-IP {client_ip} header_up X-Http-Version {http.request.proto} } } ``` ================================================ FILE: docs/docs/admin/environments/docker-compose.mdx ================================================ # Docker compose Docker compose is typically used in concert with other load balancers such as [Apache](./apache.mdx) or [Nginx](./nginx.mdx). Below is a minimal example showing you how to set up an instance of Anubis listening on host port 8080 that points to a static website containing data in `./www`: ```yaml services: anubis: image: ghcr.io/techarohq/anubis:latest environment: BIND: ":8080" DIFFICULTY: "4" METRICS_BIND: ":9090" SERVE_ROBOTS_TXT: "true" TARGET: "http://nginx" POLICY_FNAME: "/data/cfg/botPolicy.yaml" OG_PASSTHROUGH: "true" OG_EXPIRY_TIME: "24h" healthcheck: test: ["CMD", "anubis", "--healthcheck"] interval: 5s timeout: 30s retries: 5 start_period: 500ms ports: - 8080:8080 volumes: - "./botPolicy.yaml:/data/cfg/botPolicy.yaml:ro" nginx: image: nginx volumes: - "./www:/usr/share/nginx/html" ``` ================================================ FILE: docs/docs/admin/environments/haproxy/advanced-config-policy.yml ================================================ # /etc/anubis/challenge-any.yml bots: - name: any action: CHALLENGE user_agent_regex: .* status_codes: CHALLENGE: 403 DENY: 403 thresholds: [] dnsbl: false ================================================ FILE: docs/docs/admin/environments/haproxy/advanced-config.env ================================================ # /etc/anubis/default.env BIND=/run/anubis/default.sock BIND_NETWORK=unix DIFFICULTY=4 METRICS_BIND=:9090 # target is irrelevant here, backend routing happens in HAProxy TARGET=http://0.0.0.0 HS512_SECRET=<SECRET-HERE> COOKIE_DYNAMIC_DOMAIN=True POLICY_FNAME=/etc/anubis/challenge-any.yml ================================================ FILE: docs/docs/admin/environments/haproxy/advanced-haproxy.cfg ================================================ # /etc/haproxy/haproxy.cfg frontend FE-multiple-applications mode http bind :80 # ssl offloading on port 443 using a certificate from /etc/haproxy/ssl/ directory bind :443 ssl crt /etc/haproxy/ssl/ alpn h2,http/1.1 ssl-min-ver TLSv1.2 no-tls-tickets # set X-Real-IP header required for Anubis http-request set-header X-Real-IP "%[src]" # redirect HTTP to HTTPS http-request redirect scheme https code 301 unless { ssl_fc } # add HSTS header http-response set-header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" # only force Anubis challenge for app1 and app2 acl acl_anubis_required hdr(host) -i "app1.example.com" acl acl_anubis_required hdr(host) -i "app2.example.com" # exclude Anubis for a specific path acl acl_anubis_ignore path /excluded/path # use Anubis if auth cookie not found use_backend BE-anubis if acl_anubis_required !acl_anubis_ignore !{ req.cook(techaro.lol-anubis-auth) -m found } # get payload of the JWT such as algorithm, expire time, restrictions http-request set-var(txn.anubis_jwt_alg) req.cook(techaro.lol-anubis-auth),jwt_header_query('$.alg') if acl_anubis_required !acl_anubis_ignore http-request set-var(txn.anubis_jwt_exp) cook(techaro.lol-anubis-auth),jwt_payload_query('$.exp','int') if acl_anubis_required !acl_anubis_ignore http-request set-var(txn.anubis_jwt_res) cook(techaro.lol-anubis-auth),jwt_payload_query('$.restriction') if acl_anubis_required !acl_anubis_ignore http-request set-var(txn.srcip) req.fhdr(X-Real-IP) if acl_anubis_required !acl_anubis_ignore http-request set-var(txn.now) date() if acl_anubis_required !acl_anubis_ignore # use Anubis if JWT has wrong algorithm, is expired, restrictions don't match or isn't signed with the correct key use_backend BE-anubis if acl_anubis_required !acl_anubis_ignore !{ var(txn.anubis_jwt_alg) -m str HS512 } use_backend BE-anubis if acl_anubis_required !acl_anubis_ignore { var(txn.anubis_jwt_exp),sub(txn.now) -m int lt 0 } use_backend BE-anubis if acl_anubis_required !acl_anubis_ignore !{ var(txn.srcip),digest(sha256),hex,lower,strcmp(txn.anubis_jwt_res) eq 0 } use_backend BE-anubis if acl_anubis_required !acl_anubis_ignore !{ cook(techaro.lol-anubis-auth),jwt_verify(txn.anubis_jwt_alg,"<SECRET-HERE>") -m int 1 } # custom routing in HAProxy use_backend BE-app1 if { hdr(host) -i "app1.example.com" } use_backend BE-app2 if { hdr(host) -i "app2.example.com" } use_backend BE-app3 if { hdr(host) -i "app3.example.com" } backend BE-app1 mode http server app1-server 127.0.0.1:3000 backend BE-app2 mode http server app2-server 127.0.0.1:4000 backend BE-app3 mode http server app3-server 127.0.0.1:5000 BE-anubis mode http server anubis /run/anubis/default.sock ================================================ FILE: docs/docs/admin/environments/haproxy/simple-config.env ================================================ # /etc/anubis/default.env BIND=/run/anubis/default.sock BIND_NETWORK=unix SOCKET_MODE=0666 DIFFICULTY=4 METRICS_BIND=:9090 COOKIE_DYNAMIC_DOMAIN=true # address and port of the actual application TARGET=http://localhost:3000 ================================================ FILE: docs/docs/admin/environments/haproxy/simple-haproxy.cfg ================================================ # /etc/haproxy/haproxy.cfg frontend FE-application mode http bind :80 # ssl offloading on port 443 using a certificate from /etc/haproxy/ssl/ directory bind :443 ssl crt /etc/haproxy/ssl/ alpn h2,http/1.1 ssl-min-ver TLSv1.2 no-tls-tickets # set X-Real-IP header required for Anubis http-request set-header X-Real-IP "%[src]" # redirect HTTP to HTTPS http-request redirect scheme https code 301 unless { ssl_fc } # add HSTS header http-response set-header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" # route to Anubis backend by default default_backend BE-anubis-application BE-anubis-application mode http server anubis /run/anubis/default.sock ================================================ FILE: docs/docs/admin/environments/haproxy.mdx ================================================ # HAProxy import CodeBlock from "@theme/CodeBlock"; To use Anubis with HAProxy, you have two variants: - simple - stick Anubis between HAProxy and your application backend (simple) - perfect if you only have a single application in general - advanced - force Anubis challenge by default and route to the application backend by HAProxy if the challenge is correct - useful for complex setups - routing can be done in HAProxy - define ACLs in HAProxy for domains, paths etc which are required/excluded regarding Anubis - HAProxy 3.0 recommended ## Simple Variant ```mermaid --- title: HAProxy with simple config --- flowchart LR T(User Traffic) HAProxy(HAProxy Port 80/443) Anubis Application T --> HAProxy HAProxy --> Anubis Anubis --> |Happy Traffic| Application ``` Your Anubis env file configuration may look like this: import simpleAnubis from "!!raw-loader!./haproxy/simple-config.env"; <CodeBlock language="bash">{simpleAnubis}</CodeBlock> The important part is that `TARGET` points to your actual application and if Anubis and HAProxy are on the same machine, a UNIX socket can be used. Your frontend and backend configuration of HAProxy may look like the following: import simpleHAProxy from "!!raw-loader!./haproxy/simple-haproxy.cfg"; <CodeBlock language="bash">{simpleHAProxy}</CodeBlock> This simply enables SSL offloading, sets some useful and required headers and routes to Anubis directly. ## Advanced Variant Due to the fact that HAProxy can decode JWT, we are able to verify the Anubis token directly in HAProxy and route the traffic to the specific backends ourselves. Mind that rule logic to allow Git HTTP and other legit bot traffic to bypass is delegated from Anubis to HAProxy then. If required, you should implement any whitelisting in HAProxy using `acl_anubis_ignore` yourself. In this example are three applications behind one HAProxy frontend. Only App1 and App2 are secured via Anubis; App3 is open for everyone. The path `/excluded/path` can also be accessed by anyone. ```mermaid --- title: HAProxy with advanced config --- flowchart LR T(User Traffic) HAProxy(HAProxy Port 80/443) B1(App1) B2(App2) B3(App3) Anubis T --> HAProxy HAProxy --> |Traffic for App1 and App2 without valid challenge| Anubis HAProxy --> |app1.example.com | B1 HAProxy --> |app2.example.com| B2 HAProxy --> |app3.example.com| B3 ``` :::note For an improved JWT decoding performance, it's recommended to use HAProxy version 3.0 or above. ::: Your Anubis env file configuration may look like this: import advancedAnubis from "!!raw-loader!./haproxy/advanced-config.env"; <CodeBlock language="bash">{advancedAnubis}</CodeBlock> It's important to use `HS512_SECRET` which HAProxy understands. Please replace `<SECRET-HERE>` with your own secret string (alphanumerical string with 128 characters recommended). You can set Anubis to force a challenge for every request using the following policy file: import advancedAnubisPolicy from "!!raw-loader!./haproxy/advanced-config-policy.yml"; <CodeBlock language="yaml">{advancedAnubisPolicy}</CodeBlock> The HAProxy config file may look like this: import advancedHAProxy from "!!raw-loader!./haproxy/advanced-haproxy.cfg"; <CodeBlock language="haproxy">{advancedHAProxy}</CodeBlock> Please replace `<SECRET-HERE>` with the same secret from the Anubis config. ================================================ FILE: docs/docs/admin/environments/kubernetes.mdx ================================================ # Kubernetes :::note Leave the `PUBLIC_URL` environment variable unset in this sidecar/standalone setup. Setting it here makes redirect construction fail (`redir=null`). ::: When setting up Anubis in Kubernetes, you want to make sure that you thread requests through Anubis kinda like this: ```mermaid --- title: Anubis embedded into workload pods --- flowchart LR T(User Traffic) IngressController(IngressController) subgraph Service AnPort(Anubis Port) BPort(Backend Port) end subgraph Pod An(Anubis) B(Backend) end T --> IngressController IngressController --> AnPort AnPort --> An An --> B ``` Anubis is lightweight enough that you should be able to have many instances of it running without many problems. If this is a concern for you, please check out [ingress-anubis](https://github.com/jaredallard/ingress-anubis?ref=anubis.techaro.lol). This example makes the following assumptions: - Your target service is listening on TCP port `5000`. - Anubis will be listening on port `8080`. Adjust these values as facts and circumstances demand. Create a secret with the signing key Anubis should use for its responses: ``` kubectl create secret generic anubis-key \ --namespace default \ --from-literal=ED25519_PRIVATE_KEY_HEX=$(openssl rand -hex 32) ``` Attach Anubis to your Deployment: ```yaml containers: # ... - name: anubis image: ghcr.io/techarohq/anubis:latest imagePullPolicy: Always env: - name: "BIND" value: ":8080" - name: "DIFFICULTY" value: "4" - name: ED25519_PRIVATE_KEY_HEX valueFrom: secretKeyRef: name: anubis-key key: ED25519_PRIVATE_KEY_HEX - name: "METRICS_BIND" value: ":9090" - name: "SERVE_ROBOTS_TXT" value: "true" - name: "TARGET" value: "http://localhost:5000" - name: "OG_PASSTHROUGH" value: "true" - name: "OG_EXPIRY_TIME" value: "24h" resources: limits: cpu: 750m memory: 256Mi requests: cpu: 250m memory: 256Mi securityContext: runAsUser: 1000 runAsGroup: 1000 runAsNonRoot: true allowPrivilegeEscalation: false capabilities: drop: - ALL seccompProfile: type: RuntimeDefault ``` Then add a Service entry for Anubis: ```yaml # ... spec: ports: # diff-add - protocol: TCP # diff-add port: 8080 # diff-add targetPort: 8080 # diff-add name: anubis ``` Then point your Ingress to the Anubis port: ```yaml rules: - host: git.xeserv.us http: paths: - pathType: Prefix path: "/" backend: service: name: git port: # diff-remove name: http # diff-add name: anubis ``` ## Envoy Gateway If you are using envoy-gateway, the `X-Real-Ip` header is not set by default, but Anubis does require it. You can resolve this by adding the header, either on the specific `HTTPRoute` where Anubis is listening, or on the `ClientTrafficPolicy` to apply it to any number of Gateways: HTTPRoute: ```yaml apiVersion: gateway.networking.k8s.io/v1 kind: HTTPRoute metadata: name: app-route spec: hostnames: ["app.domain.tld"] parentRefs: - name: envoy-external namespace: network sectionName: https rules: - backendRefs: - identifier: *app port: anubis filters: - type: RequestHeaderModifier requestHeaderModifier: set: - name: X-Real-Ip value: "%DOWNSTREAM_REMOTE_ADDRESS_WITHOUT_PORT%" ``` Applying to any number of Gateways: ```yaml apiVersion: gateway.envoyproxy.io/v1alpha1 kind: ClientTrafficPolicy metadata: name: envoy spec: headers: earlyRequestHeaders: set: - name: X-Real-Ip value: "%DOWNSTREAM_REMOTE_ADDRESS_WITHOUT_PORT%" clientIPDetection: xForwardedFor: trustedCIDRs: - 10.96.0.0/16 # Cluster pod CIDR targetSelectors: # These will apply to all Gateways - group: gateway.networking.k8s.io kind: Gateway ``` ================================================ FILE: docs/docs/admin/environments/nginx/conf-anubis.inc ================================================ # /etc/nginx/conf-anubis.inc # Forward to anubis location / { proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_pass http://anubis; } ================================================ FILE: docs/docs/admin/environments/nginx/server-anubistest-techaro-lol.conf ================================================ # /etc/nginx/conf.d/server-anubistest-techaro-lol.conf # HTTP - Redirect all HTTP traffic to HTTPS server { listen 80; listen [::]:80; server_name anubistest.techaro.lol; location / { return 301 https://$host$request_uri; } } # TLS termination server, this will listen over TLS (https) and then # proxy all traffic to the target via Anubis. server { # Listen on TCP port 443 with TLS (https) and HTTP/2 listen 443 ssl; listen [::]:443 ssl; http2 on; location / { proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Http-Version $server_protocol; proxy_pass http://anubis; } server_name anubistest.techaro.lol; ssl_certificate /path/to/your/certs/anubistest.techaro.lol.crt; ssl_certificate_key /path/to/your/certs/anubistest.techaro.lol.key; } # Backend server, this is where your webapp should actually live. server { listen unix:/run/nginx/nginx.sock; server_name anubistest.techaro.lol; root "/srv/http/anubistest.techaro.lol"; index index.html; # Get the visiting IP from the TLS termination server set_real_ip_from unix:; real_ip_header X-Real-IP; # Your normal configuration can go here # location .php { fastcgi...} etc. } ================================================ FILE: docs/docs/admin/environments/nginx/server-mimi-techaro-lol.conf ================================================ # /etc/nginx/conf.d/server-mimi-techaro-lol.conf server { # Listen on 443 with SSL listen 443 ssl; listen [::]:443 ssl; http2 on; # Slipstream via Anubis include "conf-anubis.inc"; server_name mimi.techaro.lol; ssl_certificate /path/to/your/certs/mimi.techaro.lol.crt; ssl_certificate_key /path/to/your/certs/mimi.techaro.lol.key; } server { listen unix:/run/nginx/nginx.sock; server_name mimi.techaro.lol; port_in_redirect off; root "/srv/http/mimi.techaro.lol"; index index.html; # Your normal configuration can go here # location .php { fastcgi...} etc. } ================================================ FILE: docs/docs/admin/environments/nginx/upstream-anubis.conf ================================================ # /etc/nginx/conf.d/upstream-anubis.conf upstream anubis { # Make sure this matches the values you set for `BIND` and `BIND_NETWORK`. # If this does not match, your services will not be protected by Anubis. # Try anubis first over a UNIX socket server unix:/run/anubis/nginx.sock; #server 127.0.0.1:8923; # Optional: fall back to serving the websites directly. This allows your # websites to be resilient against Anubis failing, at the risk of exposing # them to the raw internet without protection. This is a tradeoff and can # be worth it in some edge cases. #server unix:/run/nginx.sock backup; } ================================================ FILE: docs/docs/admin/environments/nginx.mdx ================================================ # Nginx import CodeBlock from "@theme/CodeBlock"; Anubis is intended to be a filter proxy. The way to integrate this with nginx is to break your configuration up into two parts: TLS termination and then HTTP routing. Consider this diagram: ```mermaid --- title: Nginx as tls terminator and HTTP router --- flowchart LR T(User Traffic) subgraph Nginx TCP(TCP 80/443) US(Unix Socket or another TCP port) end An(Anubis) B(Backend) T --> |TLS termination| TCP TCP --> |Traffic filtering| An An --> |Happy traffic| US US --> |whatever you're doing| B ``` Instead of your traffic going right from TLS termination into the backend, it takes a detour through Anubis. Anubis filters out the "bad" traffic and then passes the "good" traffic to another socket that Nginx has open. This final socket is what you will use to do HTTP routing. Effectively, you have two roles for nginx: TLS termination (converting HTTPS to HTTP) and HTTP routing (distributing requests to the individual vhosts). This can stack with something like Apache in case you have a legacy deployment. Make sure you have the right [TLS certificates configured](https://code.kuederle.com/letsencrypt/) at the TLS termination level. :::note These examples assume that you are using a setup where your nginx configuration is made up of a bunch of files in `/etc/nginx/conf.d/*.conf`. This is not true for all deployments of nginx. If you are not in such an environment, append these snippets to your `/etc/nginx/nginx.conf` file. ::: Assuming that we are protecting `anubistest.techaro.lol`, here's what the server configuration file would look like: import anubisTest from "!!raw-loader!./nginx/server-anubistest-techaro-lol.conf"; <CodeBlock language="nginx">{anubisTest}</CodeBlock> :::tip You can copy the `location /` block into a separate file named something like `conf-anubis.inc` and then include it inline to other `server` blocks: import anubisInclude from "!!raw-loader!./nginx/conf-anubis.inc"; <CodeBlock language="nginx">{anubisInclude}</CodeBlock> Then in a server block: <details> <summary>Full nginx config</summary> import mimiTecharoLol from "!!raw-loader!./nginx/server-mimi-techaro-lol.conf"; <CodeBlock language="nginx">{mimiTecharoLol}</CodeBlock> </details> ::: Create an upstream for Anubis. import anubisUpstream from "!!raw-loader!./nginx/upstream-anubis.conf"; <CodeBlock language="nginx">{anubisUpstream}</CodeBlock> This can be repeated for multiple sites. Anubis does not care about the HTTP `Host` header and will happily cope with multiple websites via the same instance. Then reload your nginx config and load your website. You should see Anubis protecting your apps! ```text sudo systemctl reload nginx.service ``` ================================================ FILE: docs/docs/admin/environments/traefik.mdx ================================================ --- id: traefik title: Traefik --- :::note This only talks about integration through Compose, but it also applies to docker cli options. ::: In this example, we will use 4 Containers: - `traefik` - the Traefik instance - `anubis` - the Anubis instance - `target` - our service to protect (`traefik/whoami` in this case) - `target2` - a second service that isn't supposed to be protected (`traefik/whoami` in this case) ## Diagram of Flow This is a small diagram depicting the flow. Keep in mind that `8080` or `80` can be anything depending on your containers. ```mermaid flowchart LR user[User] traefik[Traefik] anubis[Anubis] target[Target] user-->|:443 - Requesting Service|traefik traefik-->|:8080 - Check authorization to Anubis|anubis anubis-->|redirect if failed|traefik user-->|:8080 - make the challenge|traefik anubis-->|redirect back to target|traefik traefik-->|:80 - Passing to the target|target ``` ## Full Example Config This example contains 3 services: anubis, one that is protected and the other one that is not. **compose.yml** ```yml services: traefik: image: traefik:v3.3 ports: - 80:80 - 443:443 volumes: - /var/run/docker.sock:/var/run/docker.sock - ./letsencrypt:/letsencrypt - ./traefik.yml:/traefik.yml:ro networks: - traefik labels: # Enable Traefik - traefik.enable=true - traefik.docker.network=traefik # Anubis middleware - traefik.http.middlewares.anubis.forwardauth.address=http://anubis:8080/.within.website/x/cmd/anubis/api/check # Redirect any HTTP to HTTPS - traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https - traefik.http.routers.web.rule=PathPrefix(`/`) - traefik.http.routers.web.entrypoints=web - traefik.http.routers.web.middlewares=redirect-to-https - traefik.http.routers.web.tls=false anubis: image: ghcr.io/techarohq/anubis:main environment: # Telling Anubis, where to listen for Traefik - BIND=:8080 # Telling Anubis to do redirect — ensure there is a space after '=' - "TARGET= " # Specifies which domains Anubis is allowed to redirect to. - REDIRECT_DOMAINS=example.com # Should be the full external URL for Anubis (including scheme) - PUBLIC_URL=https://anubis.example.com # Should match your domain for proper cookie scoping - COOKIE_DOMAIN=example.com networks: - traefik labels: - traefik.enable=true # Enabling Traefik - traefik.docker.network=traefik # Telling Traefik which network to use - traefik.http.routers.anubis.rule=Host(`anubis.example.com`) # Only Matching Requests for example.com - traefik.http.routers.anubis.entrypoints=websecure # Listen on HTTPS - traefik.http.services.anubis.loadbalancer.server.port=8080 # Telling Traefik where to receive requests - traefik.http.routers.anubis.service=anubis # Telling Traefik to use the above specified port - traefik.http.routers.anubis.tls.certresolver=le # Telling Traefik to resolve a Cert for Anubis # Protected by Anubis target: image: traefik/whoami:latest networks: - traefik labels: - traefik.enable=true # Enabling Traefik - traefik.docker.network=traefik # Telling Traefik which network to use - traefik.http.routers.target.rule=Host(`example.com`) # Only Matching Requests for example.com - traefik.http.routers.target.entrypoints=websecure # Listening on the exclusive Anubis Network - traefik.http.services.target.loadbalancer.server.port=80 # Telling Traefik where to receive requests - traefik.http.routers.target.service=target # Telling Traefik to use the above specified port - traefik.http.routers.target.tls.certresolver=le # Telling Traefik to resolve a Cert for Anubis - traefik.http.routers.target.middlewares=anubis@docker # Use the Anubis middleware # Not Protected by Anubis target2: image: traefik/whoami:latest networks: - traefik labels: - traefik.enable=true # Enabling Traefik - traefik.docker.network=traefik # Telling Traefik which network to use - traefik.http.routers.target2.rule=Host(`another.example.com`) # Only Matching Requests for example.com - traefik.http.routers.target2.entrypoints=websecure # Listening on the exclusive Anubis Network - traefik.http.services.target2.loadbalancer.server.port=80 # Telling Traefik where to receive requests - traefik.http.routers.target2.service=target2 # Telling Traefik to use the above specified port - traefik.http.routers.target2.tls.certresolver=le # Telling Traefik to resolve a Cert for this Target networks: traefik: name: traefik ``` **traefik.yml** ```yml api: insecure: false # shouldn't be enabled in prod entryPoints: # Web web: address: ":80" websecure: address: ":443" certificatesResolvers: le: acme: tlsChallenge: {} email: "admin@example.com" storage: "/letsencrypt/acme.json" providers: docker: {} ``` ================================================ FILE: docs/docs/admin/frameworks/_category_.json ================================================ { "label": "Frameworks", "position": 30, "link": { "type": "generated-index", "description": "Information about getting specific frameworks or tools working with Anubis." } } ================================================ FILE: docs/docs/admin/frameworks/htmx.mdx ================================================ # HTMX import Tabs from "@theme/Tabs"; import TabItem from "@theme/TabItem"; [HTMX](https://htmx.org) is a framework that enables you to write applications using hypertext as the engine of application state. This enables you to simplify you server side code by having it return HTML instead of JSON. This can interfere with Anubis because Anubis challenge pages also return HTML. To work around this, you can make a custom [expression](../configuration/expressions.mdx) rule that allows HTMX requests if the user has passed a challenge in the past: ```yaml - name: allow-htmx-iff-already-passed-challenge action: ALLOW expression: all: - '"Cookie" in headers' - 'headers["Cookie"].contains("anubis-auth")' - '"Hx-Request" in headers' - 'headers["Hx-Request"] == "true"' ``` This will reduce some security because it does not assert the validity of the Anubis auth cookie, however in trade it improves the experience for existing users. ================================================ FILE: docs/docs/admin/frameworks/wordpress.mdx ================================================ # WordPress WordPress is the most popular blog engine on the planet. ## Using a multi-site setup with Anubis If you have a multi-site setup where traffic goes through Anubis like this: ```mermaid --- title: Apache as tls terminator and HTTP router --- flowchart LR T(User Traffic) subgraph Apache 2 TCP(TCP 80/443) US(TCP 3001) end An(Anubis) B(Backend) T --> |TLS termination| TCP TCP --> |Traffic filtering| An An --> |Happy traffic| US US --> |whatever you're doing| B ``` WordPress may not realize that the underlying connection is being done over HTTPS. This could lead to a redirect loop in the `/wp-admin/` routes. In order to fix this, add the following to your `wp-config.php` file: ```php if (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') { $_SERVER['HTTPS'] = 'on'; $_SERVER['SERVER_PORT'] = 443; } ``` This will make WordPress think that your connection is over HTTPS instead of plain HTTP. ================================================ FILE: docs/docs/admin/honeypot/_category_.json ================================================ { "label": "Honeypot", "position": 40, "link": { "type": "generated-index", "description": "Honeypot features in Anubis, allowing Anubis to passively detect malicious crawlers." } } ================================================ FILE: docs/docs/admin/honeypot/overview.mdx ================================================ --- title: Dataset poisoning --- Anubis offers the ability to participate in [dataset poisoning](https://www.anthropic.com/research/small-samples-poison) attacks similar to what [iocaine](https://iocaine.madhouse-project.org/) and other similar tools offer. Currently this is in a preview state where a lot of details are hard-coded in order to test the viability of this approach. In essence, when Anubis challenge and error pages are rendered they include a small bit of HTML code that browsers will ignore but scrapers will interpret as a link to ingest. This will then create a small forest of recursive nothing pages that are designed according to the following principles: - These pages are _cheap_ to render, rendering in at most ten milliseconds on decently specced hardware. - These pages are _vacuous_, meaning that they essentially are devoid of content such that a human would find it odd and click away, but a scraper would not be able to know that and would continue through the forest. - These pages are _fairly large_ so that scrapers don't think that the pages are error pages or are otherwise devoid of content. - These pages are _fully self-contained_ so that they load fast without incurring additional load from resource fetches. In this limited preview state, Anubis generates pages using [spintax](https://outboundly.ai/blogs/what-is-spintax-and-how-to-use-it/). Spintax is a syntax that is used to create different variants of utterances for use in marketing messages and email spam that evades word filtering. In its current form, Anubis' dataset poisoning has AI generated spintax that generates vapid LinkedIn posts with some western occultism thrown in for good measure. This results in utterances like the following: > There's a moment when visionaries are being called to realize that the work can't be reduced to optimization, but about resonance. We don't transform products by grinding endlessly, we do it by holding the vision. Because meaning can't be forced, it unfolds over time when culture are in integrity. This moment represents a fundamental reimagining in how we think about work. This isn't a framework, it's a lived truth that requires courage. When we get honest, we activate nonlinear growth that don't show up in dashboards, but redefine success anyway. This should be fairly transparent to humans that this is pseudoprofound anti-content and is a signal to click away. ## Plans Future versions of this feature will allow for more customization. In the near future this will be configurable via the following mechanisms: - WebAssembly logic for customizing how the poisoning data is generated (with examples including the existing spintax method). - Weight thresholds and logic for how they are interpreted by Anubis. - Other configuration settings as facts and circumstances dictate. ## Implementation notes In its current implementation, the Anubis dataset poisoning feature has the following flaws that may hinder production deployments: - All Anubis instances use the same method for generating dataset poisoning information. This may be easy for malicious actors to detect and ignore. - Anubis dataset poisoning routes are under the `/.within.website/x/cmd/anubis` URL hierarchy. This may be easy for malicious actors to detect and ignore. Right now Anubis assigns 30 weight points if the following criteria are met: - A client's User-Agent has been observed in the dataset poisoning maze at least 25 times. - The network-clamped IP address (/24 for IPv4 and /48 for IPv6) has been observed in the dataset poisoning maze at least 25 times. Additionally, when any given client by both User-Agent and network-clamped IP address has been observed, Anubis will emit log lines warning about it so that administrative action can be taken up to and including [filing abuse reports with the network owner](/blog/2025/file-abuse-reports). ================================================ FILE: docs/docs/admin/installation.mdx ================================================ --- title: Setting up Anubis --- import EnterpriseOnly from "@site/src/components/EnterpriseOnly"; import RandomKey from "@site/src/components/RandomKey"; export const EO = () => ( <> <EnterpriseOnly link="./botstopper/" /> <div style={{ marginBottom: "0.5rem" }} /> </> ); 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 | | :------------------ | :--------------------------------------------------------------------------------------------------------------------------------- | | `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`. | 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. ## Native packages For more detailed information on installing Anubis with native packages, please read [the native install directions](./native-install.mdx). ## Configuration Anubis is configurable via environment variables and [the policy file](./policies.mdx). Most settings are currently exposed with environment variables but they are being slowly moved over to the policy file. ### Configuration via the policy file Currently the following settings are configurable via the policy file: - [Bot policies](./policies.mdx) - [Open Graph passthrough](./configuration/open-graph.mdx) - [Weight thresholds](./configuration/thresholds.mdx) ### Environment variables Anubis uses these environment variables for configuration: | Environment Variable | Default value | Explanation | | :----------------------------- | :---------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `ASSET_LOOKUP_HEADER` | unset | <EO /> If set, use the contents of this header in requests when looking up custom assets in `OVERLAY_FOLDER`. See [Header-based overlay dispatch](./botstopper.mdx#header-based-overlay-dispatch) for more details. | | `BASE_PREFIX` | unset | If set, adds a global prefix to all Anubis endpoints (everything starting with `/.within.website/x/anubis/`). For example, setting this to `/myapp` would make Anubis accessible at `/myapp/` instead of `/`. This is useful when running Anubis behind a reverse proxy that routes based on path prefixes. | | `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. | | `CHALLENGE_TITLE` | unset | <EO /> If set, override the translation stack to show a custom title for challenge pages such as "Making sure your connection is secure!". See [Customizing messages](./botstopper.mdx#customizing-messages) for more details. | | `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 this [stackoverflow explanation of cookies](https://stackoverflow.com/a/1063760) for more information.<br/><br/>Note that unlike `REDIRECT_DOMAINS`, you should never include a port number in this variable. | | `COOKIE_DYNAMIC_DOMAIN` | false | If set to true, automatically set cookie domain fields based on the hostname of the request. EG: if you are making a request to `anubis.techaro.lol`, the Anubis cookie will be valid for any subdomain of `techaro.lol`. | | `COOKIE_EXPIRATION_TIME` | `168h` | The amount of time the authorization cookie is valid for. | | `CUSTOM_REAL_IP_HEADER` | unset | If set, Anubis will read the client's real IP address from this header, and set it in `X-Real-IP` header. | | `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. | | `COOKIE_PREFIX` | `anubis-cookie` | The prefix used for browser cookies created by Anubis. Useful for customization or avoiding conflicts with other applications. | | `COOKIE_SECURE` | `true` | If set to `true`, enables the [Secure flag](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Cookies#block_access_to_your_cookies), meaning that the cookies will only be transmitted over HTTPS. If Anubis is used in an unsecure context (plain HTTP), this will be need to be set to false | | `COOKIE_SAME_SITE` | `None` | Controls the cookie’s [`SameSite` attribute](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value). Allowed: `None`, `Lax`, `Strict`, `Default`. `None` permits cross-site use but modern browsers require it to be **Secure**—so if `COOKIE_SECURE=false` or you serve over plain HTTP, use `Lax` (recommended) or `Strict` or the cookie will be rejected. `Default` uses the Go runtime’s `SameSiteDefaultMode`. `None` will be downgraded to `Lax` automatically if cookie is set NOT to be secure. | | `DIFFICULTY` | `4` | The difficulty of the challenge, or the number of leading zeroes that must be in successful responses. | | `DIFFICULTY_IN_JWT` | `false` | If set to `true`, adds the `difficulty` field into JWT claims, which indicates the difficulty the token has been generated. This may be useful for statistics and debugging. | | `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. **Required when using persistent storage backends** (like bbolt) to ensure challenges survive service restarts. When running multiple instances on the same base domain, the key must be the same across all instances. 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. **Required when using persistent storage backends** (like bbolt) to ensure challenges survive service restarts. When running multiple instances on the same base domain, the key must be the same across all instances. | | `ERROR_TITLE` | unset | <EO /> If set, override the translation stack to show a custom title for error pages such as "Something went wrong!". See [Customizing messages](./botstopper.mdx#customizing-messages) for more details. | | `JWT_RESTRICTION_HEADER` | `X-Real-IP` | If set, the JWT is only valid if the current value of this header matches the value when the JWT was created. You can use it e.g. to restrict a JWT to the source IP of the user using `X-Real-IP`. | | `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. Prefer using [the policy file](./configuration/open-graph.mdx) to configure the Open Graph subsystem. | | `OG_PASSTHROUGH` | `false` | If set to `true`, Anubis will enable Open Graph tag passthrough. Prefer using [the policy file](./configuration/open-graph.mdx) to configure the Open Graph subsystem. | | `OG_CACHE_CONSIDER_HOST` | `false` | If set to `true`, Anubis will consider the host in the Open Graph tag cache key. Prefer using [the policy file](./configuration/open-graph.mdx) to configure the Open Graph subsystem. | | `OVERLAY_FOLDER` | unset | <EO /> If set, treat the given path as an [overlay folder](./botstopper.mdx#custom-images-and-css), allowing you to customize CSS, fonts, images, and add other assets to BotStopper deployments. | | `POLICY_FNAME` | unset | The file containing [bot policy configuration](./policies.mdx). See the bot policy documentation for more details. If unset, the default bot policy configuration is used. | | `PUBLIC_URL` | unset | The externally accessible URL for this Anubis instance, used for constructing redirect URLs (e.g., for Traefik forwardAuth). Leave it unset when Anubis terminates traffic directly (sidecar/standalone deployments) or redirect building will fail with `redir=null`. | | `REDIRECT_DOMAINS` | unset | Comma-separated list of domain names that Anubis should allow redirects to when passing a challenge. See [Redirect Domain Configuration](./configuration/redirect-domains) for more details. | | `SERVE_ROBOTS_TXT` | `false` | If set `true`, Anubis will serve a default `robots.txt` file that disallows all known AI scrapers by name and then additionally disallows every scraper. This is useful if facts and circumstances make it difficult to change the underlying service to serve such a `robots.txt` file. | | `SLOG_LEVEL` | `INFO` | The log level for structured logging. Valid values are `DEBUG`, `INFO`, `WARN`, and `ERROR`. Set to `DEBUG` to see all requests, evaluations, and detailed diagnostic 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. | | `STRIP_BASE_PREFIX` | `false` | If set to `true`, strips the base prefix from request paths when forwarding to the target server. This is useful when your target service expects to receive requests without the base prefix. For example, with `BASE_PREFIX=/foo` and `STRIP_BASE_PREFIX=true`, a request to `/foo/bar` would be forwarded to the target as `/bar`. | | `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. | | `USE_SIMPLIFIED_EXPLANATION` | false | If set to `true`, replaces the text when clicking "Why am I seeing this?" with a more simplified text for a non-tech-savvy audience. | | `USE_TEMPLATES` | false | <EO /> If set to `true`, enable [custom HTML template support](./botstopper.mdx#custom-html-templates), allowing you to completely rewrite how BotStopper renders its HTML pages. | | `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. | | `XFF_STRIP_PRIVATE` | `true` | If set, strip private addresses from `X-Forwarded-For` headers. To unset this, you must set `XFF_STRIP_PRIVATE=false` or `--xff-strip-private=false`. | <details> <summary>Advanced configuration settings</summary> :::note If you don't know or understand what these settings mean, ignore them. These are intended to work around very specific issues. ::: | Environment Variable | Default value | Explanation | | :---------------------------- | :------------ | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `FORCED_LANGUAGE` | unset | If set, forces Anubis to display challenge pages in the specified language instead of using the browser's Accept-Language header. Use ISO 639-1 language codes (e.g., `de` for German, `fr` for French). | | `HS512_SECRET` | unset | Secret string for JWT HS512 algorithm. If this is not set, Anubis will use ED25519 as defined via the variables above. The longer the better; 128 chars should suffice. **Required when using persistent storage backends** (like bbolt) to ensure challenges survive service restarts. When running multiple instances on the same base domain, the key must be the same across all instances. | | `TARGET_DISABLE_KEEPALIVE` | `false` | If `true`, disables HTTP keep-alive for connections to the target backend. Useful for backends that don't handle keep-alive properly. | | `TARGET_HOST` | unset | If set, overrides the Host header in requests forwarded to `TARGET`. | | `TARGET_INSECURE_SKIP_VERIFY` | `false` | If `true`, skip TLS certificate validation for targets that listen over `https`. If your backend does not listen over `https`, ignore this setting. | | `TARGET_SNI` | unset | If set, TLS handshake hostname when forwarding requests to the `TARGET`. If set to auto, use Host header. | </details> For more detailed information on configuring Open Graph tags, please refer to the [Open Graph Configuration](./configuration/open-graph.mdx) page. ### Using Base Prefix The `BASE_PREFIX` environment variable allows you to run Anubis behind a path prefix. This is useful when: - You want to host multiple services on the same domain - You're using a reverse proxy that routes based on path prefixes - You need to integrate Anubis with an existing application structure For example, if you set `BASE_PREFIX=/myapp`, Anubis will: - Serve its challenge page at `/myapp/` instead of `/` - Serve its API endpoints at `/myapp/.within.website/x/cmd/anubis/api/` instead of `/.within.website/x/cmd/anubis/api/` - Serve its static assets at `/myapp/.within.website/x/cmd/anubis/` instead of `/.within.website/x/cmd/anubis/` When using this feature with a reverse proxy: 1. Configure your reverse proxy to route requests for the specified path prefix to Anubis 2. Set the `BASE_PREFIX` environment variable to match the path prefix in your reverse proxy configuration 3. Ensure that your reverse proxy preserves the path when forwarding requests to Anubis Example with Nginx: ```nginx location /myapp/ { proxy_pass http://anubis:8923/myapp; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } ``` With corresponding Anubis configuration: ``` BASE_PREFIX=/myapp ``` #### Stripping Base Prefix If your target service doesn't expect to receive the base prefix in request paths, you can use the `STRIP_BASE_PREFIX` option: ``` BASE_PREFIX=/myapp STRIP_BASE_PREFIX=true ``` With this configuration: - A request to `/myapp/api/users` would be forwarded to your target service as `/api/users` - A request to `/myapp/` would be forwarded as `/` This is particularly useful when working with applications that weren't designed to handle path prefixes. However, note that if your target application generates absolute redirects or links (like `/login` instead of `./login`), these may break the subpath routing since they won't include the base prefix. ### 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 /> ## Next steps To get Anubis filtering your traffic, you need to make sure it's added to your HTTP load balancer or platform configuration. See the [environments category](/docs/category/environments) for detailed information on individual environments. - [Apache](./environments/apache.mdx) - [Caddy](./environments/caddy.mdx) - [Docker compose](./environments/docker-compose.mdx) - [Kubernetes](./environments/kubernetes.mdx) - [Nginx](./environments/nginx.mdx) - [Traefik](./environments/traefik.mdx) - [HAProxy](./environments/haproxy.mdx) :::note Anubis loads its assets from `/.within.website/x/xess/` and `/.within.website/x/cmd/anubis`. If you do not reverse proxy these in your server config, Anubis won't work. ::: ================================================ FILE: docs/docs/admin/iplist2rule.mdx ================================================ --- title: iplist2rule CLI tool --- The `iplist2rule` tool converts IP blocklists into Anubis challenge policies. It reads common IP block list formats and generates the appropriate Anubis policy file for IP address filtering. ## Installation Install directly with Go ```bash go install github.com/TecharoHQ/anubis/utils/cmd/iplist2rule@latest ``` ## Usage Basic conversion from URL: ```bash iplist2rule https://raw.githubusercontent.com/7c/torfilter/refs/heads/main/lists/txt/torfilter-1m-flat.txt filter-tor.yaml ``` Explicitly allow every IP address on a list: ```bash iplist2rule --action ALLOW https://raw.githubusercontent.com/7c/torfilter/refs/heads/main/lists/txt/torfilter-1m-flat.txt filter-tor.yaml ``` Add weight to requests matching IP addresses on a list: ```bash iplist2rule --action WEIGH --weight 20 https://raw.githubusercontent.com/7c/torfilter/refs/heads/main/lists/txt/torfilter-1m-flat.txt filter-tor.yaml ``` ## Options | Flag | Description | Default | | :------------ | :----------------------------------------------------------------------------------------------- | :-------------------------------- | | `--action` | The Anubis action to take for the IP address in question, must be in ALL CAPS. | `DENY` (forbids traffic) | | `--rule-name` | The name for the generated Anubis rule, should be in kebab-case. | (not set, inferred from filename) | | `--weight` | When `--action=WEIGH`, how many weight points should be added or removed from matching requests? | 0 (not set) | ## Using the Generated Policy Save the output and import it in your main policy file: ```yaml bots: - import: "./filter-tor.yaml" ``` ================================================ FILE: docs/docs/admin/native-install.mdx ================================================ --- title: Installing Anubis with a native package --- import Tabs from "@theme/Tabs"; import TabItem from "@theme/TabItem"; Download the package for your system from [the most recent release on GitHub](https://github.com/TecharoHQ/anubis/releases). 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/default.env ``` </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> <TabItem value="distro" label="Package managers"> Some Linux distributions offer Anubis [as a native package](https://repology.org/project/anubis-anti-crawler/versions). If you want to install Anubis from your distribution's package manager, consult any upstream documentation for how to install the package. It will either be named `anubis`, `www-apps/anubis` or `www/anubis`. If you use a systemd-flavoured distribution, then follow the setup instructions for Debian or Red Hat Linux. </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.yaml`: <Tabs> <TabItem value="debrpm" label="Debian or Red Hat" default> ```text sudo cp /usr/share/doc/anubis/botPolicies.yaml /etc/anubis/gitea.botPolicies.yaml ``` </TabItem> <TabItem value="tarball" label="Tarball"> ```text sudo cp ./doc/botPolicies.yaml /etc/anubis/gitea.botPolicies.yaml ``` </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.yaml 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.yaml` to the target service. For more details on particular reverse proxies, see here: - [Apache](./environments/apache.mdx) - [Nginx](./environments/nginx.mdx) - [HAProxy](./environments/haproxy.mdx) ================================================ FILE: docs/docs/admin/policies.mdx ================================================ --- title: Policy Definitions --- import Tabs from "@theme/Tabs"; import TabItem from "@theme/TabItem"; Out of the box, Anubis is pretty heavy-handed. It will aggressively challenge everything that might be a browser (usually indicated by having `Mozilla` in its user agent). However, some bots are smart enough to get past the challenge. Some things that look like bots may actually be fine (IE: RSS readers). Some resources need to be visible no matter what. Some resources and remotes are fine to begin with. Anubis lets you customize its configuration with a Policy File. This is a YAML document that spells out what actions Anubis should take when evaluating requests. The [default configuration](https://github.com/TecharoHQ/anubis/blob/main/data/botPolicies.yaml) explains everything, but this page contains an overview of everything you can do with it. ## Bot Policies Bot policies let you customize the rules that Anubis uses to allow, deny, or challenge incoming requests. Currently you can set policies by the following matches: - Request path - User agent string - HTTP request header values - [Importing other configuration snippets](./configuration/import.mdx) As of version v1.17.0 or later, configuration can be written in either JSON or YAML. Here's an example rule that denies [Amazonbot](https://developer.amazon.com/en/amazonbot): ```yaml - name: amazonbot user_agent_regex: Amazonbot action: DENY ``` When this rule is evaluated, Anubis will check the `User-Agent` string of the request. If it contains `Amazonbot`, Anubis will send an error page to the user saying that access is denied, but in such a way that makes scrapers think they have correctly loaded the webpage. Right now the only kinds of policies you can write are bot policies. Other forms of policies will be added in the future. Here is a minimal policy file that will protect against most scraper bots: ```yaml bots: - name: cloudflare-workers headers_regex: CF-Worker: .* action: DENY - name: well-known path_regex: ^/.well-known/.*$ action: ALLOW - name: favicon path_regex: ^/favicon.ico$ action: ALLOW - name: robots-txt path_regex: ^/robots.txt$ action: ALLOW - name: generic-browser user_agent_regex: Mozilla action: CHALLENGE ``` 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.yaml) 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. For more details on this default behavior and its implications, see [Default allow behavior](./default-allow-behavior.mdx). ### Writing your own rules There are four actions that can be returned from a rule: | Action | Effects | | :---------- | :---------------------------------------------------------------------------------------------------------------------------------- | | `ALLOW` | Bypass all further checks and send the request to the backend. | | `DENY` | Deny the request and send back an error message that scrapers think is a success. | | `CHALLENGE` | Show a challenge page and/or validate that clients have passed a challenge. | | `WEIGH` | Change the [request weight](#request-weight) for this request. See the [request weight](#request-weight) docs for more information. | Name your rules in lower case using kebab-case. Rule names will be exposed in Prometheus metrics. ### Challenge configuration Rules can also have their own challenge settings. These are customized using the `"challenge"` key. For example, here is a rule that makes challenges artificially hard for connections with the substring "bot" in their user agent: This rule has been known to have a high false positive rate in testing. Please use this with care. ```yaml # Punish any bot with "bot" in the user-agent string - name: generic-bot-catchall user_agent_regex: (?i:bot|crawler) action: CHALLENGE challenge: difficulty: 16 # impossible algorithm: slow # intentionally waste CPU cycles and time ``` Challenges can be configured with these settings: | Key | Example | Description | | :----------- | :------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `difficulty` | `4` | The challenge difficulty (number of leading zeros) for proof-of-work. See [Why does Anubis use Proof-of-Work?](/docs/design/why-proof-of-work) for more details. | | `algorithm` | `"fast"` | The challenge method to use. See [the list of challenge methods](./configuration/challenges/) for more information. | ### Remote IP based filtering The `remote_addresses` field of a Bot rule allows you to set the IP range that this ruleset applies to. For example, you can allow a search engine to connect if and only if its IP address matches the ones they published: ```yaml - name: qwantbot user_agent_regex: \+https\://help\.qwant\.com/bot/ action: ALLOW # https://help.qwant.com/wp-content/uploads/sites/2/2025/01/qwantbot.json remote_addresses: ["91.242.162.0/24"] ``` This also works at an IP range level without any other checks: ```yaml name: internal-network action: ALLOW remote_addresses: - 100.64.0.0/10 ``` ## Imprint / Impressum support Anubis has support for showing imprint / impressum information. This is defined in the `impressum` block of your configuration. See [Imprint / Impressum configuration](./configuration/impressum.mdx) for more information. ## Storage backends Anubis needs to store temporary data in order to determine if a user is legitimate or not. Administrators should choose a storage backend based on their infrastructure needs. Each backend has its own advantages and disadvantages. Anubis offers the following storage backends: - [`memory`](#memory) -- A simple in-memory hashmap - [`bbolt`](#bbolt) -- An on-disk key/value store backed by [bbolt](https://github.com/etcd-io/bbolt), an embedded key/value database for Go programs - [`valkey`](#valkey) -- A remote in-memory key/value database backed by [Valkey](https://valkey.io/) (or another database compatible with the [RESP](https://redis.io/docs/latest/develop/reference/protocol-spec/) protocol) If no storage backend is set in the policy file, Anubis will use the [`memory`](#memory) backend by default. This is equivalent to the following in the policy file: ```yaml store: backend: memory parameters: {} ``` ### `memory` The memory backend is an in-memory cache. This backend works best if you don't use multiple instances of Anubis or don't have mutable storage in the environment you're running Anubis in. | Should I use this backend? | Yes/no | | :------------------------------------------------------------ | :----- | | Are you running only one instance of Anubis for this service? | ✅ Yes | | Does your service get a lot of traffic? | 🚫 No | | Do you want to store data persistently when Anubis restarts? | 🚫 No | | Do you run Anubis without mutable filesystem storage? | ✅ Yes | The biggest downside is that there is not currently a limit to how much data can be stored in memory. This will be addressed at a later time. :::warning The in-memory backend exists mostly for validation, testing, and to ensure that the default configuration of Anubis works as expected. Do not use this persistently in production. ::: #### Configuration The memory backend does not require any configuration to use. ### `bbolt` An on-disk storage layer powered by [bbolt](https://github.com/etcd-io/bbolt), a high performance embedded key/value database used by containerd, etcd, Kubernetes, and NATS. This backend works best if you're running Anubis on a single host and get a lot of traffic. | Should I use this backend? | Yes/no | | :------------------------------------------------------------ | :----- | | Are you running only one instance of Anubis for this service? | ✅ Yes | | Does your service get a lot of traffic? | ✅ Yes | | Do you want to store data persistently when Anubis restarts? | ✅ Yes | | Do you run Anubis without mutable filesystem storage? | 🚫 No | When Anubis opens a bbolt database, it takes an exclusive lock on that database. Other instances of Anubis or other tools cannot view the bbolt database while it is locked by another instance of Anubis. If you run multiple instances of Anubis for different services, give each its own `bbolt` configuration. #### Configuration The `bbolt` backend takes the following configuration options: | Name | Type | Example | Description | | :----- | :--- | :----------------- | :--------------------------------------------------------------------------------------------------------------------------- | | `path` | path | `/data/anubis.bdb` | The filesystem path for the Anubis bbolt database. Anubis requires write access to the folder containing the bbolt database. | Example: If you have persistent storage mounted to `/data`, then your store configuration could look like this: ```yaml store: backend: bbolt parameters: path: /data/anubis.bdb ``` ### `s3api` A network-backed storage layer backed by [object storage](https://en.wikipedia.org/wiki/Object_storage), specifically using the [S3 API](https://docs.aws.amazon.com/AmazonS3/latest/API/Type_API_Reference.html). This can be backed by any S3-compatible object storage service such as: - [AWS S3](https://aws.amazon.com/s3/) - [Cloudflare R2](https://www.cloudflare.com/developer-platform/products/r2/) - [Hetzner Object Storage](https://www.hetzner.com/storage/object-storage/) - [Minio](https://www.min.io/) - [Tigris](https://www.tigrisdata.com/) If you are using a cloud platform, they likely provide an S3 compatible object storage service. If not, you may want to choose [one of the fastest options](https://www.tigrisdata.com/blog/benchmark-small-objects/). | Should I use this backend? | Yes/no | | :------------------------------------------------------------ | :----- | | Are you running only one instance of Anubis for this service? | 🚫 No | | Does your service get a lot of traffic? | ✅ Yes | | Do you want to store data persistently when Anubis restarts? | ✅ Yes | | Do you run Anubis without mutable filesystem storage? | ✅ Yes | :::note Using this backend will cause a lot of S3 operations, at least one for creating challenges, one for invalidating challenges, one for updating challenges to prevent double-spends, and one for removing challenges. ::: #### Configuration The `s3api` backend takes the following configuration options: | Name | Type | Example | Description | | :----------- | :------ | :------------ | :------------------------------------------------------------------------------------------------------------------------------------------ | | `bucketName` | string | `anubis-data` | (Required) The name of the dedicated bucket for Anubis to store information in. | | `pathStyle` | boolean | `false` | If true, use path-style S3 API operations. Please consult your storage provider's documentation if you don't know what you should put here. | :::note You should probably enable a lifecycle expiration rule for buckets containing Anubis data. Here is an example policy: ```json { "Rules": [ { "Status": "Enabled", "Expiration": { "Days": 7 } } ] } ``` Adjust this as facts and circumstances demand, but 7 days should be enough for anyone. ::: Example: Assuming your environment looks like this: ```sh # All of the following are fake credentials that look like real ones. AWS_ACCESS_KEY_ID=accordingToAllKnownRulesOfAviation AWS_SECRET_ACCESS_KEY=thereIsNoWayABeeShouldBeAbleToFly AWS_REGION=yow AWS_ENDPOINT_URL_S3=https://yow.s3.probably-not-malware.lol ``` Then your configuration would look like this: ```yaml store: backend: s3api parameters: bucketName: techaro-prod-anubis pathStyle: false ``` ### `valkey` [Valkey](https://valkey.io/) is an in-memory key/value store that clients access over the network. This allows multiple instances of Anubis to share information and does not require each instance of Anubis to have persistent filesystem storage. :::note You can also use [Redis™](http://redis.io/) with Anubis. ::: This backend is ideal if you are running multiple instances of Anubis in a worker pool (eg: Kubernetes Deployments with a copy of Anubis in each Pod). | Should I use this backend? | Yes/no | | :------------------------------------------------------------ | :----- | | Are you running only one instance of Anubis for this service? | 🚫 No | | Does your service get a lot of traffic? | ✅ Yes | | Do you want to store data persistently when Anubis restarts? | ✅ Yes | | Do you run Anubis without mutable filesystem storage? | ✅ Yes | | Do you have Redis™ or Valkey installed? | ✅ Yes | #### Configuration The `valkey` backend takes the following configuration options: | Name | Type | Example | Description | | :--------- | :----- | :---------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------ | | `cluster` | bool | `false` | If true, use [Redis™ Clustering](https://redis.io/topics/cluster-spec) for storing Anubis data. | | `sentinel` | object | `{}` | See [Redis™ Sentinel docs](#redis-sentinel) for more detail and examples | | `url` | string | `redis://valkey:6379/0` | The URL for the instance of Redis™ or Valkey that Anubis should store data in. This is in the same format as `REDIS_URL` in many cloud providers. | Example: If you have an instance of Valkey running with the hostname `valkey.int.techaro.lol`, then your store configuration could look like this: ```yaml store: backend: valkey parameters: url: "redis://valkey.int.techaro.lol:6379/0" ``` This would have the Valkey client connect to host `valkey.int.techaro.lol` on port `6379` with database `0` (the default database). #### Redis™ Sentinel If you are using [Redis™ Sentinel](https://redis.io/docs/latest/operate/oss_and_stack/management/sentinel/) for a high availability setup, you need to configure the `sentinel` object. This object takes the following configuration options: | Name | Type | Example | Description | | :----------- | :----------------------- | :-------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------- | | `addr` | string or list of string | `10.43.208.130:26379` | (Required) The host and port of the Redis™ Sentinel server. When possible, use DNS names for this. If you have multiple addresses, supply a list of them. | | `clientName` | string | `Anubis` | The client name reported to Redis™ Sentinel. Set this if you want to track Anubis connections to your Redis™ Sentinel. | | `masterName` | string | `mymaster` | (Required) The name of the master in the Redis™ Sentinel configuration. This is used to discover where to find client connection hosts/ports. | | `username` | string | `azurediamond` | The username used to authenticate against the Redis™ Sentinel and Redis™ servers. | | `password` | string | `hunter2` | The password used to authenticate against the Redis™ Sentinel and Redis™ servers. | ## Logging management Anubis has very verbose logging out of the box. This is intentional and allows administrators to be sure that it is working merely by watching it work in real time. Some administrators may not appreciate this level of logging out of the box. As such, Anubis lets you customize details about how it logs data. Anubis uses a practice called [structured logging](https://stackify.com/what-is-structured-logging-and-why-developers-need-it/) to emit log messages with key-value pair context. In order to make analyzing large amounts of log messages easier, Anubis encodes all logs in JSON. This allows you to use any tool that can parse JSON to perform analytics or monitor for issues. Anubis exposes the following logging settings in the policy file: | Name | Type | Example | Description | | :----------- | :----------------------- | :-------------- | :--------------------------------------------------------------------------------------------------------------------------------------- | | `level` | [log level](#log-levels) | `info` | The logging level threshold. Any logs that are at or above this threshold will be drained to the sink. Any other logs will be discarded. | | `sink` | string | `stdio`, `file` | The sink where the logs drain to as they are being recorded in Anubis. | | `parameters` | object | | Parameters for the given logging sink. This will vary based on the logging sink of choice. See below for more information. | Anubis supports the following logging sinks: 1. `file`: logs are emitted to a file that is rotated based on size and age. Old log files are compressed with gzip to save space. This allows for better integration with users that decide to use legacy service managers (OpenRC, FreeBSD's init, etc). 2. `stdio`: logs are emitted to the standard error stream of the Anubis process. This allows runtimes such as Docker, Podman, Systemd, and Kubernetes to capture logs with their native logging subsystems without any additional configuration. ### Log levels Anubis uses Go's [standard library `log/slog` package](https://pkg.go.dev/log/slog) to emit structured logs. By default, Anubis logs at the [Info level](https://pkg.go.dev/log/slog#Level), which is fairly verbose out of the box. Here are the possible logging levels in Anubis: | Log level | Use in Anubis | | :-------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------- | | `DEBUG` | The raw unfiltered torrent of doom. Only use this if you are actively working on Anubis or have very good reasons to use it. | | `INFO` | The default logging level, fairly verbose in order to make it easier for automation to parse. | | `WARN` | A "more silent" logging level. Much less verbose. Some things that are now at the `info` level need to be moved up to the `warn` level in future patches. | | `ERROR` | Only log error messages. | Additionally, you can set a "slightly higher" log level if you need to, such as: ```yaml logging: sink: stdio level: "INFO+1" ``` This isn't currently used by Anubis, but will be in the future for "slightly important" information. ### `file` sink The `file` sink makes Anubis write its logs to the filesystem and rotate them out when the log file meets certain thresholds. This logging sink takes the following parameters: | Name | Type | Example | Description | | :------------- | :-------------- | :-------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `file` | string | `/var/log/anubis.log` | The file where Anubis logs should be written to. Make sure the user Anubis is running as has write and file creation permissions to this directory. | | `maxBackups` | number | `3` | The number of old log files that should be maintained when log files are rotated out. | | `maxBytes` | number of bytes | `67108864` (64Mi) | The maximum size of each log file before it is rotated out. | | `maxAge` | number of days | `7` | If a log file is more than this many days old, rotate it out. | | `compress` | boolean | `true` | If true, compress old log files with gzip. This should be set to `true` and is only exposed as an option for dealing with legacy workflows where there is magical thinking about log files at play. | | `useLocalTime` | boolean | `false` | If true, use the system local time zone to create log filenames instead of UTC. This should almost always be set to `false` and is only exposed for legacy workflows where there is magical thinking about time zones at play. | ```yaml logging: sink: file parameters: file: "./var/anubis.log" maxBackups: 3 # keep at least 3 old copies maxBytes: 67108864 # each file can have up to 64 Mi of logs maxAge: 7 # rotate files out every n days compress: true # gzip-compress old log files useLocalTime: false # timezone for rotated files is UTC ``` When files are rotated out, the old files will be named after the rotation timestamp in [RFC 3339 format](https://www.rfc-editor.org/rfc/rfc3339). :::note If you are running Anubis in systemd via a native package, the default systemd unit settings are very restrictive and will forbid writing to folders in `/var/log`. In order to fix this, please make a [drop-in unit](https://www.flatcar.org/docs/latest/setup/systemd/drop-in-units/) like the following: ```text # /etc/systemd/anubis@instance-name.service.d/50-var-log-readwrite.conf [Service] ReadWritePaths=/run /var/log/anubis ``` Once you write this to the correct place, reload the systemd configuration: ```text sudo systemctl daemon-reload ``` And then restart Anubis: ```text sudo systemctl restart anubis@instance-name ``` You may be required to make drop-ins for each Anubis instance depending on the facts and circumstances of your deployment. ::: ### `stdio` sink By default, Anubis logs everything to the standard error stream of its process. This requires no configuration: ```yaml logging: sink: stdio ``` If you use a service orchestration platform that does not capture the standard error stream of processes, you need to use a different logging sink. ## Risk calculation for downstream services In case your service needs it for risk calculation reasons, Anubis exposes information about the rules that any requests match using a few headers: | Header | Explanation | Example | | :---------------- | :--------------------------------------------------- | :--------------- | | `X-Anubis-Rule` | The name of the rule that was matched | `bot/lightpanda` | | `X-Anubis-Action` | The action that Anubis took in response to that rule | `CHALLENGE` | | `X-Anubis-Status` | The status and how strict Anubis was in its checks | `PASS` | Policy rules are matched using [Go's standard library regular expressions package](https://pkg.go.dev/regexp). You can mess around with the syntax at [regex101.com](https://regex101.com), make sure to select the Golang option. ## Request Weight Anubis rules can also add or remove "weight" from requests, allowing administrators to configure custom levels of suspicion. For example, if your application uses session tokens named `i_love_gitea`: ```yaml - name: gitea-session-token action: WEIGH expression: all: - '"Cookie" in headers' - headers["Cookie"].contains("i_love_gitea=") # Remove 5 weight points weight: adjust: -5 ``` This would remove five weight points from the request, which would make Anubis present the [Meta Refresh challenge](./configuration/challenges/metarefresh.mdx) in the default configuration. ### Weight Thresholds For more information on configuring weight thresholds, see [Weight Threshold Configuration](./configuration/thresholds.mdx) ### Advice Weight is still very new and needs work. This is an experimental feature and should be treated as such. Here's some advice to help you better tune requests: - The default weight for browser-like clients is 10. This triggers an aggressive challenge. - Remove and add weight in multiples of five. - Be careful with how you configure weight. ================================================ FILE: docs/docs/admin/robots2policy.mdx ================================================ --- title: robots2policy CLI Tool sidebar_position: 50 --- The `robots2policy` tool converts robots.txt files into Anubis challenge policies. It reads robots.txt rules and generates equivalent CEL expressions for path matching and user-agent filtering. ## Installation Install directly with Go: ```bash go install github.com/TecharoHQ/anubis/cmd/robots2policy@latest ``` ## Usage Basic conversion from URL: ```bash robots2policy -input https://www.example.com/robots.txt ``` Convert local file to YAML: ```bash robots2policy -input robots.txt -output policy.yaml ``` Convert with custom settings: ```bash robots2policy -input robots.txt -action DENY -format json ``` ## Options | Flag | Description | Default | | --------------------- | ------------------------------------------------------------------ | ------------------- | | `-input` | robots.txt file path or URL (use `-` for stdin) | _required_ | | `-output` | Output file (use `-` for stdout) | stdout | | `-format` | Output format: `yaml` or `json` | `yaml` | | `-action` | Action for disallowed paths: `ALLOW`, `DENY`, `CHALLENGE`, `WEIGH` | `CHALLENGE` | | `-name` | Policy name prefix | `robots-txt-policy` | | `-crawl-delay-weight` | Weight adjustment for crawl-delay rules | `3` | | `-deny-user-agents` | Action for blacklisted user agents | `DENY` | ## Example Input robots.txt: ```txt User-agent: * Disallow: /admin/ Disallow: /private User-agent: BadBot Disallow: / ``` Generated policy: ```yaml - name: robots-txt-policy-disallow-1 action: CHALLENGE expression: single: path.startsWith("/admin/") - name: robots-txt-policy-disallow-2 action: CHALLENGE expression: single: path.startsWith("/private") - name: robots-txt-policy-blacklist-3 action: DENY expression: single: userAgent.contains("BadBot") ``` ## Using the Generated Policy Save the output and import it in your main policy file: ```yaml bots: - import: "./robots-policy.yaml" ``` The tool handles wildcard patterns, user-agent specific rules, and blacklisted bots automatically. ================================================ FILE: docs/docs/admin/roles/_category_.json ================================================ { "label": "Server Roles", "position": 40, "link": { "type": "generated-index", "description": "Various server roles you will need to keep in mind with Anubis." } } ================================================ FILE: docs/docs/admin/roles/oci-registry.mdx ================================================ # OCI Registries If you are serving an OCI registry behind Anubis, you will need to import the `(data)/clients/docker-client.yaml` file in order to make sure that OCI registry clients can download images: ```yaml bots: - import: (data)/meta/default-config.yaml - import: (data)/clients/docker-client.yaml # ... the rest of your config ``` ================================================ FILE: docs/docs/admin/thoth.mdx ================================================ # Thoth-based advanced checks Status: Beta Anubis instances are normally isolated. Each Anubis instance has its own configuration and exists in roughly its own world without any long term memory between requests. As threats, workarounds, and AI scraper toolchains evolve, administrators will need a way to get more up to date information faster than Anubis' release cycle. Thus, Thoth is being created. Thoth is the reputation database for Anubis. Thoth feeds information to Anubis so that it can make better decisions about which traffic is innocuous and which traffic is suspicious. :::note Thoth is hosted by [Techaro](https://techaro.lol). Thoth is a paid service. Thoth is opt-in and requires manual intervention (including payment) to use. The code that powers Thoth is currently closed source. To get access to Thoth, please subscribe [on GitHub Sponsors](https://github.com/sponsors/Xe) and [email Xe](mailto:xe@techaro.lol). This will be self-service soon. ::: ## Implementation Thoth is a web service that listens over [gRPC](https://grpc.io/). Thoth's API is documented in protocol buffer definitions in the GitHub repo [TecharoHQ/thoth-proto](https://github.com/TecharoHQ/thoth-proto). Thoth is designed to be _informative_, not _authoritative_. Thoth cannot and will not arbitrarily block requests, origins, or other traffic. Thoth is there to inform Anubis and influence the weight of requests so that upstream resources can be protected. Additionally, Anubis aggressively caches data from Thoth such that over time Anubis will not need to request data very often. This makes the fast path for repeat visitors even faster and reduces the amount of data that Thoth is exposed to. ## Thoth features Thoth is currently in active development. Currently, Thoth provides the following features to Anubis: - BGP Autonomous System (ASN) based filtering - GeoIP location based filtering ### ASN-based filtering When companies link their backbone infrastructure to the Internet, they do so via a [BGP Autonomous System](<https://en.wikipedia.org/wiki/Autonomous_system_(Internet)>), denoted by a number (the Autonomous System Number or ASN). Every IP address on the Internet is owned by an ASN with a 1:1 lookup that does not change very frequently. Anubis uses Thoth to match IP addresses to BGP Autonomous Systems so that you can either issue arbitrary challenges to individual internet service providers (such as Cloudflare or Huawei Cloud) or, at the administrator's explicit instruction, block them altogether. For example, here's how you add 10 weight points to requests from Cloudflare, Huawei Cloud, and Alibaba Cloud: ```yaml - name: aggressive-asns-without-functional-abuse-contact action: WEIGH asns: match: - 13335 # Cloudflare - 136907 # Huawei Cloud - 45102 # Alibaba Cloud weight: adjust: 10 ``` You can look up details for [AS13335](https://bgp.tools/as/13335) or any of these other top offenders on [bgp.tools](https://bgp.tools). ### GeoIP-based filtering In extreme cases, an administrator may have to take action against an entire country. This is not an ideal circumstance, but sometimes reality forces their hands and the administrators just want to sleep at night. Anubis uses Thoth to look up the geographic location registered to an IP address. This lookup is not the best and will get better with time, but you ship what you can so you can make it better for next time. For example, to add 10 weight points to requests from Brazil and China: ```yaml - name: countries-with-aggressive-scrapers action: WEIGH geoip: countries: - BR - CN weight: adjust: 10 ``` Use this with care. ## Work-in-progress features This section is a bit aspirational and is where Thoth will end up rather than things you can use today. In general, a lot of Thoth features are focused on taking the same Anubis you know and love and making it better, smarter, and less paranoid. These include: - Private rulesets for advanced patterns, current known exploits, and other recognition tactics that need to be kept cloak and dagger for operational security reasons - Private challenge implementations via WebAssembly, including advanced browser detection logic - Reputation querying so that Thoth can arbitrarily influence the weight of requests based on the net aggregate pass rate so that the most common browsers can get through with no challenge issued at all - APIs for trusted administrators to report abusive request fingerprints so that Anubis can react to threats as they evolve - A way for Anubis to periodically report the pass rate per ASN and other fingerprints so that methodology can be improved ================================================ FILE: docs/docs/design/_category_.json ================================================ { "label": "Design", "position": 10, "link": { "type": "generated-index", "description": "How Anubis is designed and the tradeoffs it makes." } } ================================================ FILE: docs/docs/design/how-anubis-works.mdx ================================================ --- title: How Anubis works --- Anubis uses a proof-of-work challenge to ensure that clients are using a modern browser and are able to calculate SHA-256 checksums. Anubis has a customizable difficulty for this proof-of-work challenge, but defaults to 5 leading zeroes. ```mermaid --- title: Challenge generation and validation --- flowchart TD Backend("Backend") Fail("Fail") style PresentChallenge color:#FFFFFF, fill:#AA00FF, stroke:#AA00FF style ValidateChallenge color:#FFFFFF, fill:#AA00FF, stroke:#AA00FF style Backend color:#FFFFFF, stroke:#00C853, fill:#00C853 style Fail color:#FFFFFF, stroke:#FF2962, fill:#FF2962 subgraph Server PresentChallenge("Present Challenge") ValidateChallenge("Validate Challenge") end subgraph Client Main("main.mjs") Worker("Worker") end Main -- Request challenge --> PresentChallenge PresentChallenge -- Return challenge & difficulty --> Main Main -- Spawn worker --> Worker Worker -- Successful challenge --> Main Main -- Validate challenge --> ValidateChallenge ValidateChallenge -- Return cookie --> Backend ValidateChallenge -- If anything is wrong --> Fail ``` ## Challenge presentation Anubis decides to present a challenge using this logic: - User-Agent contains `"Mozilla"` - Request path is not in `/.well-known`, `/robots.txt`, or `/favicon.ico` - Request path is not obviously an RSS feed (ends with `.rss`, `.xml`, or `.atom`) This should ensure that git clients, RSS readers, and other low-harm clients can get through without issue, but high-risk clients such as browsers and AI scraper bots will get blocked. ```mermaid --- title: Challenge presentation logic --- flowchart LR Request("Request") Backend("Backend") %%Fail("Fail") PresentChallenge("Present challenge") HasMozilla{"Is browser or scraper?"} HasCookie{"Has cookie?"} HasExpired{"Cookie expired?"} HasSignature{"Has valid signature?"} RandomJitter{"Secondary screening?"} POWPass{"Proof of work valid?"} style PresentChallenge color:#FFFFFF, fill:#AA00FF, stroke:#AA00FF style Backend color:#FFFFFF, stroke:#00C853, fill:#00C853 %%style Fail color:#FFFFFF, stroke:#FF2962, fill:#FF2962 Request --> HasMozilla HasMozilla -- Yes --> HasCookie HasMozilla -- No --> Backend HasCookie -- Yes --> HasExpired HasCookie -- No --> PresentChallenge HasExpired -- Yes --> PresentChallenge HasExpired -- No --> HasSignature HasSignature -- Yes --> RandomJitter HasSignature -- No --> PresentChallenge RandomJitter -- Yes --> POWPass RandomJitter -- No --> Backend POWPass -- Yes --> Backend PowPass -- No --> PresentChallenge PresentChallenge -- Back again for another cycle --> Request ``` ## Proof of passing challenges When a client passes a challenge, Anubis sets an HTTP cookie named `"techaro.lol-anubis-auth"` containing a signed [JWT](https://jwt.io/) (JSON Web Token). This JWT contains the following claims: - `challenge`: The challenge string derived from user request metadata - `nonce`: The nonce / iteration number used to generate the passing response - `response`: The hash that passed Anubis' checks - `iat`: When the token was issued - `nbf`: One minute prior to when the token was issued - `exp`: The token's expiry week after the token was issued This ensures that the token has enough metadata to prove that the token is valid (due to the token's signature), but also so that the server can independently prove the token is valid. This cookie is allowed to be set without triggering an EU cookie banner notification; but depending on facts and circumstances, you may wish to disclose this to your users. ## JWT signing Anubis uses an ed25519 keypair to sign the JWTs issued when challenges are passed. Anubis will generate a new ed25519 keypair every time it starts. At this time, there is no way to share this keypair between instance of Anubis, but that will be addressed in future versions. ================================================ FILE: docs/docs/design/why-proof-of-work.mdx ================================================ --- title: Why does Anubis use Proof-of-Work? --- Anubis uses [proof of work](https://en.wikipedia.org/wiki/Proof_of_work) in order to validate that clients are genuine. The reason Anubis does this was inspired by [Hashcash](https://en.wikipedia.org/wiki/Hashcash), a suggestion from the early 2000's about extending the email protocol to avoid spam. The idea is that genuine people sending emails will have to do a small math problem that is expensive to compute, but easy to verify such as hashing a string with a given number of leading zeroes. This will have basically no impact on individuals sending a few emails a week, but the company churning out industrial quantities of advertising will be required to do prohibitively expensive computation. This is also how Bitcoin's consensus algorithm works. ## How Anubis' proof of work scheme works A sha256 hash is a bunch of bytes like this: ```text 394d1cc82924c2368d4e34fa450c6b30d5d02f8ae4bb6310e2296593008ff89f ``` We usually write it out in hex form, but that's literally what the bytes in ram look like. In a proof of work validation system, you take some base value (the "challenge") and a constantly incrementing number (the "nonce"), so the thing you end up hashing is this: ```js const hash = await sha256(`${challenge}${nonce}`); ``` In order to pass a challenge, the `hash` has to have the right number of leading zeros (the "difficulty"). When a client requests to pass the challenge, they include the nonce they used. The server then only has to do one sha256 operation: the one that confirms that the challenge (generated from request metadata) and the nonce (provided by the client) match the difficulty number of leading zeroes. Ultimately, this is a hack whose real purpose is to give a "good enough" placeholder solution so that more time can be spent on fingerprinting and identifying headless browsers (EG via how they do font rendering) so that the challenge proof of work page doesn't need to be presented to known legitimate users. ================================================ FILE: docs/docs/developer/_category_.json ================================================ { "label": "Developer guides", "position": 50, "link": { "type": "generated-index", "description": "Guides and suggestions to make Anubis development go smoothly for everyone." } } ================================================ FILE: docs/docs/developer/ai-coding-policy.md ================================================ # AI Coding Policy At some level it would be nice to be able to have the following AI coding policy from an ideological standpoint: > Anubis does not accept code made primarily with the use of agentic AI tools such as Claude Code, Gemini CLI, GitHub Copilot, Zed, OpenCode, or any other similar tools. Please do not use them when contributing to this repo. However, I'd be in violation by doing this because I have knowingly committed minor bits of code to the Anubis repo that were generated by AI tools (mostly things for smoke tests). As such, Anubis is taking more of a centrist approach with regards to AI coding tools: regardless of what tool you use to make contributions to Anubis, when you sign off your code, you are taking responsibility for what you commit. You are also expected to understand what you are changing, what the implications are, and all other relevant factors. If you use AI coding tools for a majority of your committed work, you MUST disclose it with [the `Assisted-by` footer](https://xeiaso.net/notes/2025/assisted-by-footer/). The Anubis maintainers will be using tooling that looks for these footers and will prioritize scrutiny and level of attention appropriately. In order to ensure compliance with this policy, language has been placed in `AGENTS.md` and `CLAUDE.md` to entice AI coding tools to add these footers. ================================================ FILE: docs/docs/developer/building-anubis.md ================================================ --- 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: :::info If you maintain a package for Anubis v1.15.x or older, you will need to update your package build. You may want to use one of the half-baked tarballs if your distro/environment of choice makes it difficult to use npm. ::: ### 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` and `./var/robots2policy` end 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` and the robots2policy tool to `./var/robots2policy`. ## 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) ================================================ FILE: docs/docs/developer/local-dev.md ================================================ --- title: Local development --- If you use an editor with [Development containers](https://containers.dev) support, load this repo's [devcontainer configuration](https://github.com/TecharoHQ/anubis/tree/main/.devcontainer). Skip to [Running Anubis locally](#running-anubis-locally) if you are using the devcontainer. This enables you to contribute from [GitHub Codespaces](https://github.com/features/codespaces) or other web-based editors. :::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 ``` ================================================ FILE: docs/docs/developer/signed-commits.md ================================================ --- 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). ================================================ FILE: docs/docs/funding.md ================================================ --- sidebar_position: 998 title: Supporting Anubis financially --- Anubis is provided to the public for free in order to help advance the common good. In return, we ask (but not demand, these are words on the internet, not word of law) that you not remove the Anubis character from your deployment. If you want to run an unbranded or white-label version of Anubis, please [contact Xe](https://xeiaso.net/contact) to arrange a contract. This is not meant to be "contact us" pricing, I am still evaluating the market for this solution and figuring out what makes sense. You can donate to the project [on Patreon](https://patreon.com/cadey) or via [GitHub Sponsors](https://github.com/sponsors/Xe). ================================================ FILE: docs/docs/index.mdx ================================================ --- sidebar_position: 1 title: Anubis --- <img width={256} src="/img/happy.webp" alt="A smiling chibi dark-skinned anthro jackal with brown hair and tall ears looking victorious with a thumbs-up" /> ![enbyware](https://pride-badges.pony.workers.dev/static/v1?label=enbyware&labelColor=%23555&stripeWidth=8&stripeColors=FCF434%2CFFFFFF%2C9C59D1%2C2C2C2C) ![GitHub Issues or Pull Requests by label](https://img.shields.io/github/issues/TecharoHQ/anubis) ![GitHub go.mod Go version](https://img.shields.io/github/go-mod/go-version/TecharoHQ/anubis) ![language count](https://img.shields.io/github/languages/count/TecharoHQ/anubis) ![repo size](https://img.shields.io/github/repo-size/TecharoHQ/anubis) [![GitHub Sponsors](https://img.shields.io/github/sponsors/Xe)](https://github.com/sponsors/Xe) ## Sponsors Anubis is brought to you by sponsors and donors like: ### Diamond Tier <a href="https://www.raptorcs.com/content/base/products.html"> <img src="/img/sponsors/raptor-computing-logo.webp" alt="Raptor Computing Systems" height="64" /> </a> <a href="https://databento.com/?utm_source=anubis&utm_medium=sponsor&utm_campaign=anubis"> <img src="/img/sponsors/databento-logo.webp" alt="Databento" height="64" /> </a> ### Gold Tier <a href="https://www.unipromos.com/?utm_campaign=github&utm_medium=referral&utm_content=anubis"> <img src="/img/sponsors/unipromos.webp" alt="Uvensys" height="64" /> </a> <a href="https://uvensys.de/?utm_campaign=github&utm_medium=referral&utm_content=anubis"> <img src="/img/sponsors/uvensys.webp" alt="Uvensys" height="64" /> </a> <a href="https://distrust.co?utm_campaign=github&utm_medium=referral&utm_content=anubis"> <img src="/img/sponsors/distrust-logo.webp" alt="Distrust" height="64" /> </a> <a href="https://about.gitea.com?utm_campaign=github&utm_medium=referral&utm_content=anubis"> <img src="/img/sponsors/gitea-logo.webp" alt="Gitea" height="64" /> </a> <a href="https://prolocation.net?utm_campaign=github&utm_medium=referral&utm_content=anubis"> <img src="/img/sponsors/prolocation-logo.svg" alt="Prolocation" height="64" /> </a> <a href="https://terminaltrove.com/?utm_campaign=github&utm_medium=referral&utm_content=anubis&utm_source=abgh"> <img src="/img/sponsors/terminal-trove.webp" alt="Terminal Trove" height="64" /> </a> <a href="https://canine.tools?utm_campaign=github&utm_medium=referral&utm_content=anubis"> <img src="/img/sponsors/caninetools-logo.webp" alt="canine.tools" height="64" /> </a> <a href="https://weblate.org/"> <img src="/img/sponsors/weblate-logo.webp" alt="Weblate" height="64" /> </a> <a href="https://uberspace.de/"> <img src="/img/sponsors/uberspace-logo.webp" alt="Uberspace" height="64" /> </a> <a href="https://wildbase.xyz/"> <img src="/img/sponsors/wildbase-logo.webp" alt="Wildbase" height="64" /> </a> <a href="https://emma.pet"> <img src="/img/sponsors/nepeat-logo.webp" alt="Cat eyes over the word Emma in a serif font" height="64" /> </a> <a href="https://fabulous.systems/"> <img src="/img/sponsors/fabulous-systems.webp" alt="Cat eyes over the word Emma in a serif font" height="64" /> </a> ## Overview Anubis is a Web AI Firewall Utility that [weighs the soul of your connection](https://en.wikipedia.org/wiki/Weighing_of_souls) using one or more challenges in order to protect upstream resources from scraper bots. This program is designed to help protect the small internet from the endless storm of requests that flood in from AI companies. Anubis is as lightweight as possible to ensure that everyone can afford to protect the communities closest to them. Anubis is a bit of a nuclear response. This will result in your website being blocked from smaller scrapers and may inhibit "good bots" like the Internet Archive. You can configure [bot policy definitions](https://anubis.techaro.lol/docs/admin/policies) to explicitly allowlist them and we are working on a curated set of "known good" bots to allow for a compromise between discoverability and uptime. In most cases, you should not need this and can probably get by using Cloudflare to protect a given origin. However, for circumstances where you can't or won't use Cloudflare, Anubis is there for you. ## Support If you run into any issues running Anubis, please [open an issue](https://github.com/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) or join [GitHub Sponsors](https://github.com/sponsors/Xe) and ask in the Patron discord in the channel `#anubis`. ## Star History <a href="https://www.star-history.com/#TecharoHQ/anubis&Date"> <picture> <source media="(prefers-color-scheme: dark)" srcSet="https://api.star-history.com/svg?repos=TecharoHQ/anubis&type=Date&theme=dark" /> <source media="(prefers-color-scheme: light)" srcSet="https://api.star-history.com/svg?repos=TecharoHQ/anubis&type=Date" /> <img alt="Star History Chart" src="https://api.star-history.com/svg?repos=TecharoHQ/anubis&type=Date" /> </picture> </a> ## Packaging Status [![Packaging status](https://repology.org/badge/vertical-allrepos/anubis-anti-crawler.svg?columns=3)](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). ================================================ FILE: docs/docs/user/_category_.json ================================================ { "label": "User guides", "position": 60, "link": { "type": "generated-index", "description": "Information for users on sites that use Anubis." } } ================================================ FILE: docs/docs/user/frequently-asked-questions.mdx ================================================ # Frequently Asked Questions ## Why can't you just put details about the proof of work challenge into the challenge page so I don't need to run JavaScript? A common question is something along the lines of "why can't you give me a shell script to run the challenge on my laptop so that I don't have to enable JavaScript". Malware has been known to show an interstitial that [asks the user to paste something into their run box on Windows](https://www.malwarebytes.com/blog/news/2025/03/fake-captcha-websites-hijack-your-clipboard-to-install-information-stealers), which will then make that machine a zombie in a botnet. It would be in very bad taste to associate a security product such as Anubis with behavior similar to what malware uses. This would destroy user trust in the product and potentially result in reputational damage for the contributors. When at all possible, we want to avoid this happening. Technically inclined users are easily able to understand how the proof of work check works by either reading the JavaScript on the page or [reading the source code of the JavaScript program](https://github.com/TecharoHQ/anubis/tree/main/web/js). Please note that the format of the challenges and the algorithms used to solve them are liable to change without notice and are not considered part of the public API of Anubis. When such a change occurs, this will break your workarounds. If [sufficient funding is raised](https://github.com/TecharoHQ/anubis/discussions/278), a browser extension that packages the proof of work checks and looks for Anubis challenge pages to solve them will be created. ## Why does Anubis use [Web Workers](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Using_web_workers) to do its proof of work challenge? Anubis uses [Web Workers](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Using_web_workers) to do its proof of work challenge for two main reasons: 1. The proof of work operation is a lot of serially blocking calls. If you do serially blocking calls in JavaScript, some browsers will hang and not respond to user input. This is bad user experience. Using a Web Worker allows the browser to do this computation in the background so your browser will not hang. 2. Web Workers allow you to do multithreaded execution of JavaScript code. This lets Anubis run its checks in parallel across all your system cores so that the challenge can complete as fast as possible. In the last decade, most CPU advancements have come from making cores and code extremely parallel. Using Web Workers lets Anubis take advantage of your hardware as much as possible so that the challenge finishes as fast as possible. If you use a browser extension such as [JShelter](https://jshelter.org/), you will need to [modify your JShelter configuration](./known-broken-extensions.md#jshelter) to allow Anubis' proof of work computation to complete. ## Does Anubis mine Bitcoin? No. Anubis does not mine Bitcoin or any other cryptocurrency. ================================================ FILE: docs/docs/user/known-broken-extensions.md ================================================ --- title: List of known browser extensions that can break Anubis --- This page contains a list of all of the browser extensions that are known to break Anubis' functionality and their associated GitHub issues, along with instructions on how to work around the issue. ## [JShelter](https://jshelter.org/) | Extension | JShelter | | :----------- | :------------------------------------------------------------------------------------------------------------------------------------------------- | | Website | [jshelter.org](https://jshelter.org/) | | GitHub issue | https://github.com/TecharoHQ/anubis/issues/25 | | Be aware of | [What are Web Workers, and what are the threats that I face?](https://jshelter.org/faq/#what-are-web-workers-and-what-are-the-threats-that-i-face) | ### Workaround steps (recommended): 1. Click on the JShelter badge icon (typically in the toolbar next to your navigation bar; if you cannot locate the icon, see [this question](https://jshelter.org/faq/#can-i-see-a-jshelter-badge-icon-next-to-my-navigation-bar-i-want-to-interact-with-the-extension-easily-and-avoid-going-through-settings)). 2. Expand JavaScript Shield settings by clicking on the `Modify` button. 3. Click on the `Detail tweaks of JS shield for this site` button. 4. Click and drag the `WebWorker` slider to the left until `Remove` is replaced by the `Unprotected`. 5. Refresh the page, for example, by clicking on the `Refresh page` button at the top of the JShelter pop up window. 6. You might want to restore the Worker settings once you go through the challenge. ### Workaround steps (alternative if you do not want to dig in JShelter's pop up): 1. Click on the JShelter badge icon (typically in the toolbar next to your navigation bar; if you cannot locate the icon, see [this question](https://jshelter.org/faq/#can-i-see-a-jshelter-badge-icon-next-to-my-navigation-bar-i-want-to-interact-with-the-extension-easily-and-avoid-going-through-settings)). 2. Expand JavaScript Shield settings by clicking on the `Modify` button. 3. Choose "Turn JavaScript Shield off" 4. Refresh the page, for example, by clicking on the `Refresh page` button at the top of the JShelter pop up window. :::note Taking these actions will remove all protections of JavaScript Shield for all pages at the visited web site. You might want review and amend your JavaScript shield settings once you go through the challenge based on your operational security model. ::: ### Workaround steps (alternative if you do not like JShelter's pop up): 1. Open JShelter extension settings 2. Click on JS Shield details 3. Enter in the domain for a website protected by Anubis 4. Choose "Turn JavaScript Shield off" 5. Hit "Add to list" :::note Taking these actions will remove all protections of JavaScript Shield for all pages at the visited web site. You might want review and amend your JavaScript shield settings once you go through the challenge based on your operational security model. ::: ================================================ FILE: docs/docs/user/known-instances.md ================================================ --- title: List of known websites using Anubis --- This page contains a non-exhaustive list with all websites using Anubis. - https://azurlane.koumakan.jp/ - https://bugs.winehq.org/ - https://bugzilla.proxmox.com - https://canine.tools/ - https://clew.se/ - https://code.hackerspace.pl/ - https://codeberg.org/ - https://dev.haiku-os.org - https://dev.sanctum.geek.nz/ - https://ebird.org/ - https://extensions.typo3.org/ - https://fabulous.systems/ - https://git.aya.so/ - https://git.devuan.org/ - https://git.enlightenment.org/ - https://gitea.com/ - https://gitlab.freedesktop.org/ - https://gitlab.gnome.org/ - https://gitlab.postmarketos.org/ - https://hosted.weblate.org/ - https://hydra.nixos.org/ - https://lab.civicrm.org/ - https://marginalia-search.com/ - https://mozillazine.org/ - https://openwrt.org/ - https://pluralpedia.org/ - https://reddit.nerdvpn.de/ - https://repositorio.ufrn.br/home/ - https://rpmfusion.org/ - https://scioly.org/ - https://source.puri.sm/ - https://squirreljme.cc/ - https://superlove.sayitditto.net/ - https://svnweb.freebsd.org/ - https://tumfatig.net/ - https://wiki.archlinux.org/ - https://wiki.freepascal.org/ - https://wiki.koha-community.org/ - https://www.cfaarchive.org/ - https://www.indiemag.fr/ - https://xeiaso.net/ - <details> <summary>archlinux32.org</summary> - https://www.archlinux32.org/packages/ - https://bbs.archlinux32.org/ - https://bugs.archlinux32.org/ </details> - <details> <summary>Dolphin Emulator</summary> - https://forums.dolphin-emu.org/ - https://wiki.dolphin-emu.org/ </details> - <details> <summary>Duke University</summary> - https://repository.duke.edu/ - https://archives.lib.duke.edu/ - https://find.library.duke.edu/ - https://nicholas.duke.edu/ </details> - <details> <summary>FFmpeg</summary> - https://git.ffmpeg.org/ - https://trac.ffmpeg.org/ </details> - <details> <summary>Forschungszentrum Jülich</summary> - https://juser.fz-juelich.de/ </details> - <details> <summary>FreeCAD</summary> - https://forum.freecad.org/ - https://wiki.freecad.org/ </details> - <details> <summary>HackLab.TO</summary> - https://hacklab.to/ - https://knowledge.hacklab.to/ </details> - <details> <summary>hebis (Alliance of Hessian Libraries)</summary> - https://ubmr.hds.hebis.de/ - https://tufind.hds.hebis.de/ - https://karla.hds.hebis.de/ - and many more (see https://www.hebis.de/dienste/hebis-discovery-system/) </details> - <details> <summary>ReactOS</summary> - https://reactos.org/forum - https://reactos.org/wiki - https://git.reactos.org </details> - <details> <summary>ScummVM</summary> - https://bugs.scummvm.org/ - https://forums.scummvm.org/ - https://wiki.scummvm.org/ </details> - <details> <summary>Slackware</summary> - https://git.slackware.nl/ - https://git.liveslak.org/ </details> - <details> <summary>Sourceware</summary> - https://sourceware.org/cgit - https://sourceware.org/glibc/wiki - https://builder.sourceware.org/testruns/ - https://patchwork.sourceware.org/ - https://gcc.gnu.org/bugzilla/ - https://gcc.gnu.org/cgit </details> - <details> <summary>The Linux Foundation</summary> - https://git.kernel.org/ - https://lore.kernel.org/ </details> - <details> <summary>Valve Corporation</summary> - https://developer.valvesoftware.com/wiki/Main_Page - https://wiki.teamfortress.com/wiki/Main_Page </details> ================================================ FILE: docs/docs/user/why-see-challenge.md ================================================ --- title: Why is Anubis showing up on a website? --- You are seeing Anubis because the administrator of that website has set up [Anubis](https://github.com/TecharoHQ/anubis) to protect the server against the scourge of [AI companies aggressively scraping websites](https://thelibre.news/foss-infrastructure-is-under-attack-by-ai-companies/). This can and does cause downtime for the websites, which makes their resources inaccessible for everyone. Anubis is a compromise. Anubis uses a [proof-of-work](/docs/design/why-proof-of-work) scheme in the vein of [Hashcash](https://en.wikipedia.org/wiki/Hashcash), a proposed proof-of-work scheme for reducing email spam. The idea is that at individual scales the additional load is ignorable, but at mass scraper levels it adds up and makes scraping much more expensive. Ultimately, this is a hack whose real purpose is to give a "good enough" placeholder solution so that more time can be spent on fingerprinting and identifying headless browsers (EG: via how they do font rendering) so that the challenge proof of work page doesn't need to be presented to users that are much more likely to be legitimate. ================================================ FILE: docs/docusaurus.config.ts ================================================ import { themes as prismThemes } from "prism-react-renderer"; import type { Config } from "@docusaurus/types"; import type * as Preset from "@docusaurus/preset-classic"; // This runs in Node.js - Don't use client-side code here (browser APIs, JSX...) const config: Config = { title: "Anubis", tagline: "Weigh the soul of incoming HTTP requests to protect your website!", favicon: "img/favicon.ico", // Set the production url of your site here url: "https://anubis.techaro.lol", // Set the /<baseUrl>/ pathname under which your site is served // For GitHub pages deployment, it is often '/<projectName>/' baseUrl: "/", // GitHub pages deployment config. // If you aren't using GitHub pages, you don't need these. organizationName: "TecharoHQ", // Usually your GitHub org/user name. projectName: "anubis", // Usually your repo name. onBrokenLinks: "throw", onBrokenMarkdownLinks: "warn", // Even if you don't use internationalization, you can use this field to set // useful metadata like html lang. For example, if your site is Chinese, you // may want to replace "en" with "zh-Hans". i18n: { defaultLocale: "en", locales: ["en"], }, markdown: { mermaid: true, }, themes: ["@docusaurus/theme-mermaid"], presets: [ [ "classic", { blog: { showReadingTime: true, feedOptions: { type: ["rss", "atom", "json"], xslt: true, }, editUrl: "https://github.com/TecharoHQ/anubis/tree/main/docs/", onInlineTags: "warn", onInlineAuthors: "warn", onUntruncatedBlogPosts: "throw", }, docs: { sidebarPath: "./sidebars.ts", editUrl: "https://github.com/TecharoHQ/anubis/tree/main/docs/", }, theme: { customCss: "./src/css/custom.css", }, } satisfies Preset.Options, ], ], themeConfig: { colorMode: { respectPrefersColorScheme: true, }, // Replace with your project's social card image: "img/social-card.jpg", navbar: { title: "Anubis", logo: { alt: "A happy jackal woman with brown hair and red eyes", src: "img/favicon.webp", }, items: [ { to: "/blog", label: "Blog", position: "left" }, { type: "docSidebar", sidebarId: "tutorialSidebar", position: "left", label: "Docs", }, { to: "/docs/admin/botstopper", label: "Unbranded Version", position: "left", }, { href: "https://github.com/TecharoHQ/anubis", label: "GitHub", position: "right", }, { href: "https://github.com/sponsors/Xe", label: "Sponsor the Project", position: "right", }, ], }, footer: { style: "dark", links: [ { title: "Docs", items: [ { label: "Intro", to: "/docs/", }, { label: "Installation", to: "/docs/admin/installation", }, ], }, { title: "Community", items: [ { label: "GitHub Discussions", href: "https://github.com/TecharoHQ/anubis/discussions", }, { label: "Bluesky", href: "https://bsky.app/profile/techaro.lol", }, ], }, { title: "More", items: [ { label: "Blog", to: "/blog", }, { label: "GitHub", href: "https://github.com/TecharoHQ/anubis", }, { label: "Status", href: "https://techarohq.github.io/status/", }, ], }, ], copyright: `Copyright © ${new Date().getFullYear()} Techaro. Made with ❤️ in 🇨🇦.`, }, prism: { theme: prismThemes.github, darkTheme: prismThemes.dracula, magicComments: [ { className: "code-block-diff-add-line", line: "diff-add", }, { className: "code-block-diff-remove-line", line: "diff-remove", }, ], }, } satisfies Preset.ThemeConfig, }; export default config; ================================================ FILE: docs/fly.toml ================================================ app = 'anubis-docs' primary_region = 'yyz' [build] image = "ghcr.io/techarohq/anubis/docs:main" [http_service] internal_port = 80 force_https = true auto_stop_machines = true auto_start_machines = true min_machines_running = 0 processes = ['app'] [[vm]] cpu_kind = 'shared' cpus = 1 memory_mb = 256 ================================================ FILE: docs/manifest/1password.yaml ================================================ apiVersion: onepassword.com/v1 kind: OnePasswordItem metadata: name: anubis-docs-thoth spec: itemPath: "vaults/lc5zo4zjz3if3mkeuhufjmgmui/items/pwguumqcmtxvqbeb7y4gj7l36i" ================================================ FILE: docs/manifest/cfg/anubis/botPolicies.yaml ================================================ ## Anubis has the ability to let you import snippets of configuration into the main ## configuration file. This allows you to break up your config into smaller parts ## that get logically assembled into one big file. ## ## Of note, a bot rule can either have inline bot configuration or import a ## bot config snippet. You cannot do both in a single bot rule. ## ## Import paths can either be prefixed with (data) to import from the common/shared ## rules in the data folder in the Anubis source tree or will point to absolute/relative ## paths in your filesystem. If you don't have access to the Anubis source tree, check ## /usr/share/docs/anubis/data or in the tarball you extracted Anubis from. bots: - import: (data)/crawlers/commoncrawl.yaml # Pathological bots to deny - # This correlates to data/bots/deny-pathological.yaml in the source tree # https://github.com/TecharoHQ/anubis/blob/main/data/bots/deny-pathological.yaml import: (data)/bots/_deny-pathological.yaml - import: (data)/bots/aggressive-brazilian-scrapers.yaml # Aggressively block AI/LLM related bots/agents by default - import: (data)/meta/ai-block-aggressive.yaml # Consider replacing the aggressive AI policy with more selective policies: # - import: (data)/meta/ai-block-moderate.yaml # - import: (data)/meta/ai-block-permissive.yaml # Search engine crawlers to allow, defaults to: # - Google (so they don't try to bypass Anubis) # - Apple # - Bing # - DuckDuckGo # - Qwant # - The Internet Archive # - Kagi # - Marginalia # - Mojeek - import: (data)/crawlers/_allow-good.yaml # Challenge Firefox AI previews - import: (data)/clients/x-firefox-ai.yaml # Allow common "keeping the internet working" routes (well-known, favicon, robots.txt) - import: (data)/common/keep-internet-working.yaml # # Punish any bot with "bot" in the user-agent string # # This is known to have a high false-positive rate, use at your own risk # - name: generic-bot-catchall # user_agent_regex: (?i:bot|crawler) # action: CHALLENGE # challenge: # difficulty: 16 # impossible # algorithm: slow # intentionally waste CPU cycles and time - name: rss-feed-blog action: ALLOW expression: any: - path.startsWith("/blog/atom.") - path.startsWith("/blog/rss.") # Generic catchall rule - name: base-weight expression: "true" action: WEIGH weight: adjust: 10 - name: http2-client-protocol expression: all: - '"X-Http-Protocol" in headers' - headers["X-Http-Protocol"] == "HTTP/2.0" action: WEIGH weight: adjust: -5 # The weight thresholds for when to trigger individual challenges. Any # CHALLENGE will take precedence over this. # # A threshold has four configuration options: # # - name: the name that is reported down the stack and used for metrics # - expression: A CEL expression with the request weight in the variable # weight # - action: the Anubis action to apply, similar to in a bot policy # - challenge: which challenge to send to the user, similar to in a bot policy # # See https://anubis.techaro.lol/docs/admin/configuration/thresholds for more # information. thresholds: # By default Anubis ships with the following thresholds: - name: minimal-suspicion # This client is likely fine, its soul is lighter than a feather expression: weight <= 0 # a feather weighs zero units action: ALLOW # Allow the traffic through # For clients that had some weight reduced through custom rules, give them a # lightweight challenge. - name: mild-suspicion expression: all: - weight > 0 - weight < 10 action: CHALLENGE challenge: # https://anubis.techaro.lol/docs/admin/configuration/challenges/metarefresh algorithm: metarefresh difficulty: 1 # For clients that are browser-like but have either gained points from custom rules or # report as a standard browser. - name: moderate-suspicion expression: all: - weight >= 10 - weight < 20 action: CHALLENGE challenge: # https://anubis.techaro.lol/docs/admin/configuration/challenges/preact # # This challenge proves the client can run a webapp written with Preact. # The preact webapp simply loads, calculates the SHA-256 checksum of the # challenge data, and forwards that to the client. algorithm: preact difficulty: 1 - name: mild-proof-of-work expression: all: - weight >= 20 - weight < 30 action: CHALLENGE challenge: # https://anubis.techaro.lol/docs/admin/configuration/challenges/proof-of-work algorithm: fast difficulty: 2 # two leading zeros, very fast for most clients # For clients that are browser like and have gained many points from custom rules - name: extreme-suspicion expression: weight >= 30 action: CHALLENGE challenge: # https://anubis.techaro.lol/docs/admin/configuration/challenges/proof-of-work algorithm: fast difficulty: 4 dnsbl: false impressum: footer: | This website is hosted by Techaro. If you have any complaints or notes about the service, please contact <a href="mailto:support@techaro.lol">support@techaro.lol</a> and we will assist you as soon as possible. page: title: Privacy Policy body: | <p>Last updated: June 2025</p> <h2>Information that is gathered from visitors</h2> <p>In common with other websites, log files are stored on the web server saving details such as the visitor's IP address, browser type, referring page and time of visit.</p> <p>Cookies may be used to remember visitor preferences when interacting with the website.</p> <p>Where registration is required, the visitor's email and a username will be stored on the server.</p> <h2>How the Information is used</h2> <p>The information is used to enhance the visitor's experience when using the website to display personalised content and possibly advertising.</p> <p>E-mail addresses will not be sold, rented or leased to 3rd parties.</p> <p>E-mail may be sent to inform you of news of our services or offers by us or our affiliates.</p> <h2>Visitor Options</h2> <p>If you have subscribed to one of our services, you may unsubscribe by following the instructions which are included in e-mail that you receive.</p> <p>You may be able to block cookies via your browser settings but this may prevent you from access to certain features of the website.</p> <h2>Cookies</h2> <p>Cookies are small digital signature files that are stored by your web browser that allow your preferences to be recorded when visiting the website. Also they may be used to track your return visits to the website.</p> <p>3rd party advertising companies may also use cookies for tracking purposes.</p> <h2>Techaro Anubis</h2> <p>This website uses a service called <a href="https://anubis.techaro.lol">Anubis</a> to filter malicious traffic. Anubis requires the use of browser cookies to ensure that web clients are running conformant software. Anubis also may report the following data to Techaro to improve service quality:</p> <ul> <li>IP address (for purposes of matching against geo-location and BGP autonomous systems numbers), which is stored in-memory and not persisted to disk.</li> <li>Unique browser fingerprints (such as HTTP request fingerprints and encryption system fingerprints), which may be stored on Techaro's side for a period of up to one month.</li> <li>HTTP request metadata that may include things such as the User-Agent header and other identifiers.</li> </ul> <p>This data is processed and stored for the legitimate interest of combatting abusive web clients. This data is encrypted at rest as much as possible and is only decrypted in memory for the purposes of fulfilling requests.</p> # By default, send HTTP 200 back to clients that either get issued a challenge # or a denial. This seems weird, but this is load-bearing due to the fact that # the most aggressive scraper bots seem to really, really, want an HTTP 200 and # will stop sending requests once they get it. status_codes: CHALLENGE: 200 DENY: 200 store: backend: bbolt parameters: path: /xe/data/anubis/data.bdb ================================================ FILE: docs/manifest/cfg/nginx/mime.types ================================================ types { text/html html htm shtml; text/css css; text/xml xml; image/gif gif; image/jpeg jpeg jpg; application/javascript js; application/atom+xml atom; application/rss+xml rss; text/mathml mml; text/plain txt; text/vnd.sun.j2me.app-descriptor jad; text/vnd.wap.wml wml; text/x-component htc; image/avif avif; image/png png; image/svg+xml svg svgz; image/tiff tif tiff; image/vnd.wap.wbmp wbmp; image/webp webp; image/x-icon ico; image/x-jng jng; image/x-ms-bmp bmp; font/woff woff; font/woff2 woff2; application/java-archive jar war ear; application/json json; application/mac-binhex40 hqx; application/msword doc; application/pdf pdf; application/postscript ps eps ai; application/rtf rtf; application/vnd.apple.mpegurl m3u8; application/vnd.google-earth.kml+xml kml; application/vnd.google-earth.kmz kmz; application/vnd.ms-excel xls; application/vnd.ms-fontobject eot; application/vnd.ms-powerpoint ppt; application/vnd.oasis.opendocument.graphics odg; application/vnd.oasis.opendocument.presentation odp; application/vnd.oasis.opendocument.spreadsheet ods; application/vnd.oasis.opendocument.text odt; application/vnd.openxmlformats-officedocument.presentationml.presentation pptx; application/vnd.openxmlformats-officedocument.spreadsheetml.sheet xlsx; application/vnd.openxmlformats-officedocument.wordprocessingml.document docx; application/vnd.wap.wmlc wmlc; application/wasm wasm; application/x-7z-compressed 7z; application/x-cocoa cco; application/x-java-archive-diff jardiff; application/x-java-jnlp-file jnlp; application/x-makeself run; application/x-perl pl pm; application/x-pilot prc pdb; application/x-rar-compressed rar; application/x-redhat-package-manager rpm; application/x-sea sea; application/x-shockwave-flash swf; application/x-stuffit sit; application/x-tcl tcl tk; application/x-x509-ca-cert der pem crt; application/x-xpinstall xpi; application/xhtml+xml xhtml; application/xspf+xml xspf; application/zip zip; application/octet-stream bin exe dll; application/octet-stream deb; application/octet-stream dmg; application/octet-stream iso img; application/octet-stream msi msp msm; audio/midi mid midi kar; audio/mpeg mp3; audio/ogg ogg; audio/x-m4a m4a; audio/x-realaudio ra; video/3gpp 3gpp 3gp; video/mp2t ts; video/mp4 mp4; video/mpeg mpeg mpg; video/quicktime mov; video/webm webm; video/x-flv flv; video/x-m4v m4v; video/x-mng mng; video/x-ms-asf asx asf; video/x-ms-wmv wmv; video/x-msvideo avi; } ================================================ FILE: docs/manifest/cfg/nginx/nginx.conf ================================================ user nginx; worker_processes 2; error_log /dev/stdout warn; pid /nginx.pid; events { worker_connections 1024; } http { include mime.types; default_type application/octet-stream; access_log /dev/stdout; sendfile on; keepalive_timeout 65; server { listen 80 default_server; server_name _; error_page 404 /404.html; root /www; index index.html; location / { try_files $uri $uri/ =404; } } } ================================================ FILE: docs/manifest/deployment.yaml ================================================ apiVersion: apps/v1 kind: Deployment metadata: name: anubis-docs spec: selector: matchLabels: app: anubis-docs template: metadata: labels: app: anubis-docs spec: volumes: - name: anubis configMap: name: anubis-cfg - name: nginx configMap: name: nginx-cfg - name: temporary-data emptyDir: {} containers: - name: anubis-docs image: ghcr.io/techarohq/anubis/docs:main imagePullPolicy: Always resources: limits: memory: "128Mi" cpu: "500m" requests: cpu: 250m memory: 128Mi volumeMounts: - name: nginx mountPath: /conf ports: - containerPort: 80 readinessProbe: httpGet: path: / port: 80 initialDelaySeconds: 1 periodSeconds: 10 livenessProbe: httpGet: path: / port: 80 initialDelaySeconds: 10 periodSeconds: 20 - name: anubis image: ghcr.io/techarohq/anubis:main imagePullPolicy: Always env: - name: "BIND" value: ":8081" - name: "DIFFICULTY" value: "4" - name: "METRICS_BIND" value: ":9090" - name: "OG_PASSTHROUGH" value: "true" - name: "POLICY_FNAME" value: "/xe/cfg/anubis/botPolicies.yaml" - name: "SERVE_ROBOTS_TXT" value: "false" - name: "TARGET" value: "http://localhost:80" # - name: "SLOG_LEVEL" # value: "debug" volumeMounts: - name: anubis mountPath: /xe/cfg/anubis - name: temporary-data mountPath: /xe/data/anubis resources: limits: cpu: 500m memory: 128Mi requests: cpu: 250m memory: 128Mi securityContext: runAsUser: 1000 runAsGroup: 1000 runAsNonRoot: true allowPrivilegeEscalation: false capabilities: drop: - ALL seccompProfile: type: RuntimeDefault envFrom: - secretRef: name: anubis-docs-thoth readinessProbe: httpGet: path: /healthz port: 9090 initialDelaySeconds: 1 periodSeconds: 10 livenessProbe: httpGet: path: /healthz port: 9090 initialDelaySeconds: 10 periodSeconds: 20 ================================================ FILE: docs/manifest/ingress.yaml ================================================ apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: anubis-docs annotations: cert-manager.io/cluster-issuer: "letsencrypt-prod" nginx.ingress.kubernetes.io/limit-rps: "10" spec: ingressClassName: nginx tls: - hosts: - anubis.techaro.lol secretName: anubis-techaro-lol-public-tls rules: - host: anubis.techaro.lol http: paths: - pathType: Prefix path: "/" backend: service: name: anubis-docs port: name: anubis ================================================ FILE: docs/manifest/kustomization.yaml ================================================ resources: - 1password.yaml - deployment.yaml - ingress.yaml - onionservice.yaml - poddisruptionbudget.yaml - service.yaml configMapGenerator: - name: anubis-cfg behavior: create files: - ./cfg/anubis/botPolicies.yaml - name: nginx-cfg behavior: create files: - ./cfg/nginx/mime.types - ./cfg/nginx/nginx.conf ================================================ FILE: docs/manifest/onionservice.yaml ================================================ apiVersion: tor.k8s.torproject.org/v1alpha2 kind: OnionService metadata: name: anubis-docs spec: version: 3 rules: - port: number: 80 backend: service: name: anubis-docs port: number: 80 ================================================ FILE: docs/manifest/poddisruptionbudget.yaml ================================================ apiVersion: policy/v1 kind: PodDisruptionBudget metadata: name: anubis-docs spec: minAvailable: 1 selector: matchLabels: app: anubis-docs ================================================ FILE: docs/manifest/service.yaml ================================================ apiVersion: v1 kind: Service metadata: name: anubis-docs spec: selector: app: anubis-docs ports: - port: 80 targetPort: 80 name: http - port: 8081 targetPort: 8081 name: anubis ================================================ FILE: docs/package.json ================================================ { "name": "docs", "version": "0.0.0", "private": true, "scripts": { "docusaurus": "docusaurus", "start": "docusaurus start --host 0.0.0.0", "build": "docusaurus build", "swizzle": "docusaurus swizzle", "deploy": "echo 'use CI' && exit 1", "clear": "docusaurus clear", "serve": "docusaurus serve", "write-translations": "docusaurus write-translations", "write-heading-ids": "docusaurus write-heading-ids", "typecheck": "tsc" }, "dependencies": { "@docusaurus/core": "^3.8.1", "@docusaurus/preset-classic": "^3.8.1", "@docusaurus/theme-mermaid": "^3.8.1", "@mdx-js/react": "^3.0.0", "clsx": "^2.0.0", "prism-react-renderer": "^2.3.0", "raw-loader": "^4.0.2", "react": "^19.0.0", "react-dom": "^19.0.0" }, "devDependencies": { "@docusaurus/module-type-aliases": "^3.0.1", "@docusaurus/tsconfig": "^3.8.1", "@docusaurus/types": "^3.8.1", "typescript": "~5.6.2" }, "browserslist": { "production": [ ">0.5%", "not dead", "not op_mini all" ], "development": [ "last 3 chrome version", "last 3 firefox version", "last 5 safari version" ] }, "engines": { "node": ">=18.0" } } ================================================ FILE: docs/sidebars.ts ================================================ import type { SidebarsConfig } from "@docusaurus/plugin-content-docs"; // This runs in Node.js - Don't use client-side code here (browser APIs, JSX...) /** * Creating a sidebar enables you to: - create an ordered group of docs - render a sidebar for each doc of that group - provide next/previous navigation The sidebars can be generated from the filesystem, or explicitly defined here. Create as many sidebars as you want. */ const sidebars: SidebarsConfig = { // By default, Docusaurus generates a sidebar from the docs folder structure tutorialSidebar: [{ type: "autogenerated", dirName: "." }], // But you can create a sidebar manually /* tutorialSidebar: [ 'intro', 'hello', { type: 'category', label: 'Tutorial', items: ['tutorial-basics/create-a-document'], }, ], */ }; export default sidebars; ================================================ FILE: docs/src/components/EnterpriseOnly/index.jsx ================================================ import styles from "./styles.module.css"; export default function EnterpriseOnly({ link }) { return ( <a className={styles.link} href={link}> <div className={styles.container}> <span className={styles.label}>BotStopper Only</span> </div> </a> ); } ================================================ FILE: docs/src/components/EnterpriseOnly/styles.module.css ================================================ .link { text-decoration: none; } .container { background-color: #16a34a; /* green-500 */ color: #ffffff; font-weight: 700; padding: 0.5rem 1rem; /* py-2 px-4 */ border-radius: 9999px; /* rounded-full */ box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); /* shadow-lg approximation */ display: inline-flex; /* flex */ align-items: center; /* items-center */ } .label { line-height: 1; } ================================================ FILE: docs/src/components/HomepageFeatures/index.tsx ================================================ import type { ReactNode } from "react"; import clsx from "clsx"; import Heading from "@theme/Heading"; import styles from "./styles.module.css"; type FeatureItem = { title: string; imageURL: string; description: ReactNode; }; const FeatureList: FeatureItem[] = [ { title: "Easy to Use", imageURL: require("@site/static/img/anubis/happy.webp").default, description: ( <> Anubis sits in the background and weighs the risk of incoming requests. If it asks a client to complete a challenge, no user interaction is required. </> ), }, { title: "Lightweight", imageURL: require("@site/static/img/anubis/pensive.webp").default, description: ( <> Anubis is so lightweight you'll forget it's there until you look at your hosting bill. On average it uses less than 128 MB of ram. </> ), }, { title: "Block the scrapers", imageURL: require("@site/static/img/anubis/reject.webp").default, description: ( <> Anubis uses a combination of heuristics to identify and block bots before they take your website down. You can customize the rules with{" "} <a href="/docs/admin/policies">your own policies</a>. </> ), }, ]; function Feature({ title, description, imageURL }: FeatureItem) { return ( <div className={clsx("col col--4")}> <div className="text--center"> <img src={imageURL} className={styles.featureSvg} role="img" /> </div> <div className="text--center padding-horiz--md"> <Heading as="h3">{title}</Heading> <p>{description}</p> </div> </div> ); } export default function HomepageFeatures(): ReactNode { return ( <section className={styles.features}> <div className="container"> <div className="row"> {FeatureList.map((props, idx) => ( <Feature key={idx} {...props} /> ))} </div> </div> </section> ); } ================================================ FILE: docs/src/components/HomepageFeatures/styles.module.css ================================================ .features { display: flex; align-items: center; padding: 2rem 0; width: 100%; } .featureSvg { height: 200px; width: 200px; } ================================================ FILE: docs/src/components/RandomKey/index.tsx ================================================ 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> ); } ================================================ FILE: docs/src/css/custom.css ================================================ /** * Any CSS included here will be global. The classic template * bundles Infima by default. Infima is a CSS framework designed to * work well for content-centric websites. */ /* You can override the default Infima variables here. */ :root { --ifm-color-primary: #ff5630; --ifm-color-primary-dark: #ad422a; --ifm-color-primary-darker: #8f3521; --ifm-color-primary-darkest: #592115; --ifm-color-primary-light: #ff7152; --ifm-color-primary-lighter: #ff9178; --ifm-color-primary-lightest: #ffb09e; --ifm-code-font-size: 95%; --docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.1); --code-block-diff-add-line-color: #ccffd8; --code-block-diff-remove-line-color: #ffebe9; } /* For readability concerns, you should choose a lighter palette in dark mode. */ [data-theme="dark"] { --ifm-color-primary: #e64a19; --ifm-color-primary-dark: #b73a12; --ifm-color-primary-darker: #8c2c0e; --ifm-color-primary-darkest: #5a1e0a; --ifm-color-primary-light: #eb6d45; --ifm-color-primary-lighter: #f09178; --ifm-color-primary-lightest: #f5b5a6; --ifm-code-font-size: 95%; --docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.25); --code-block-diff-add-line-color: #2d5a2c; --code-block-diff-remove-line-color: #5a2d2c; } .code-block-diff-add-line { background-color: var(--code-block-diff-add-line-color); display: block; margin: 0 -40px; padding: 0 40px; } .code-block-diff-add-line::before { position: absolute; left: 8px; padding-right: 8px; content: "+"; } .code-block-diff-remove-line { background-color: var(--code-block-diff-remove-line-color); display: block; margin: 0 -40px; padding: 0 40px; } .code-block-diff-remove-line::before { position: absolute; left: 8px; padding-right: 8px; content: "-"; } /** * use magic comments to mark diff blocks */ pre code:has(.code-block-diff-add-line) { padding-left: 40px !important; } pre code:has(.code-block-diff-remove-line) { padding-left: 40px !important; } ================================================ FILE: docs/src/pages/index.module.css ================================================ /** * CSS files with the .module.css suffix will be treated as CSS modules * and scoped locally. */ .heroBanner { padding: 4rem 0; text-align: center; position: relative; overflow: hidden; } @media screen and (max-width: 996px) { .heroBanner { padding: 2rem; } } .buttons { display: flex; align-items: center; justify-content: center; } ================================================ FILE: docs/src/pages/index.tsx ================================================ import type { ReactNode } from "react"; import clsx from "clsx"; import Link from "@docusaurus/Link"; import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; import Layout from "@theme/Layout"; import HomepageFeatures from "@site/src/components/HomepageFeatures"; import Heading from "@theme/Heading"; import styles from "./index.module.css"; function HomepageHeader() { const { siteConfig } = useDocusaurusContext(); return ( <header className={clsx("hero hero--primary", styles.heroBanner)}> <div className="container"> <Heading as="h1" className="hero__title"> {siteConfig.title} </Heading> <p className="hero__subtitle">{siteConfig.tagline}</p> <div className={styles.buttons}> <Link className="button button--secondary button--lg" to="/docs/category/environments" > Get started </Link> </div> </div> </header> ); } export default function Home(): ReactNode { const { siteConfig } = useDocusaurusContext(); return ( <Layout title={`Anubis: Web AI Firewall Utility`} description="Weigh the soul of incoming HTTP requests to protect your website!" > <HomepageHeader /> <main> <HomepageFeatures /> </main> </Layout> ); } ================================================ FILE: docs/static/.nojekyll ================================================ ================================================ FILE: docs/tsconfig.json ================================================ { // This file is not used in compilation. It is here just for a nice editor experience. "extends": "@docusaurus/tsconfig", "compilerOptions": { "baseUrl": "." }, "exclude": [".docusaurus", "build"] } ================================================ FILE: go.mod ================================================ module github.com/TecharoHQ/anubis go 1.24.2 require ( github.com/TecharoHQ/thoth-proto v0.5.0 github.com/a-h/templ v0.3.960 github.com/aws/aws-sdk-go-v2 v1.41.0 github.com/aws/aws-sdk-go-v2/config v1.32.5 github.com/aws/aws-sdk-go-v2/service/s3 v1.93.2 github.com/cespare/xxhash/v2 v2.3.0 github.com/facebookgo/flagenv v0.0.0-20160425205200-fcd59fca7456 github.com/fahedouch/go-logrotate v0.3.0 github.com/gaissmai/bart v0.26.0 github.com/golang-jwt/jwt/v5 v5.3.0 github.com/google/cel-go v0.26.1 github.com/google/uuid v1.6.0 github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 github.com/joho/godotenv v1.5.1 github.com/lum8rjack/go-ja4h v0.0.0-20250828030157-fa5266d50650 github.com/nicksnyder/go-i18n/v2 v2.6.0 github.com/nikandfor/spintax v0.0.0-20181023094358-fc346b245bb3 github.com/playwright-community/playwright-go v0.5200.1 github.com/prometheus/client_golang v1.23.2 github.com/redis/go-redis/v9 v9.17.2 github.com/sebest/xff v0.0.0-20210106013422-671bd2870b3a github.com/shirou/gopsutil/v4 v4.25.11 github.com/testcontainers/testcontainers-go v0.40.0 go.etcd.io/bbolt v1.4.3 golang.org/x/net v0.48.0 golang.org/x/text v0.32.0 google.golang.org/grpc v1.77.0 gopkg.in/yaml.v3 v3.0.1 k8s.io/apimachinery v0.34.3 sigs.k8s.io/yaml v1.6.0 ) require ( al.essio.dev/pkg/shellescape v1.6.0 // indirect buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20251209175733-2a1774d88802.1 // indirect cel.dev/expr v0.25.1 // indirect dario.cat/mergo v1.0.2 // indirect github.com/AlekSi/pointer v1.2.0 // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/BurntSushi/toml v1.5.0 // indirect github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/semver/v3 v3.4.0 // indirect github.com/Masterminds/sprig/v3 v3.3.0 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/ProtonMail/go-crypto v1.3.0 // indirect github.com/Songmu/gitconfig v0.2.1 // indirect github.com/TecharoHQ/yeet v0.6.3 // indirect github.com/a-h/parse v0.0.0-20250122154542-74294addb73e // indirect github.com/andybalholm/brotli v1.2.0 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect github.com/aws/aws-sdk-go-v2/credentials v1.19.5 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 // indirect github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7 // indirect github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 // indirect github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16 // indirect github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 // indirect github.com/aws/aws-sdk-go-v2/service/sso v1.30.7 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 // indirect github.com/aws/smithy-go v1.24.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb // indirect github.com/cavaliergopher/cpio v1.0.1 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cli/browser v1.3.0 // indirect github.com/cli/go-gh/v2 v2.12.1 // indirect github.com/cli/safeexec v1.0.1 // indirect github.com/cloudflare/circl v1.6.1 // indirect github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/containerd/log v0.1.0 // indirect github.com/containerd/platforms v0.2.1 // indirect github.com/cpuguy83/dockercfg v0.3.2 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect github.com/cyphar/filepath-securejoin v0.4.1 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/deckarep/golang-set/v2 v2.8.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/distribution/reference v0.6.0 // indirect github.com/djherbis/times v1.6.0 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect github.com/docker/docker v28.5.1+incompatible // indirect github.com/docker/go-connections v0.6.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/dop251/goja v0.0.0-20250630131328-58d95d85e994 // indirect github.com/ebitengine/purego v0.9.1 // indirect github.com/emirpasic/gods v1.18.1 // 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.18.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/go-billy/v5 v5.6.2 // indirect github.com/go-git/go-git/v5 v5.16.2 // indirect github.com/go-jose/go-jose/v3 v3.0.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect github.com/go-stack/stack v1.8.1 // indirect github.com/gobwas/glob v0.2.3 // indirect github.com/goccy/go-yaml v1.18.0 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/google/go-github/v70 v70.0.0 // indirect github.com/google/go-querystring v1.1.0 // indirect github.com/google/pprof v0.0.0-20250630185457-6e76a2b096b5 // indirect github.com/google/rpmpack v0.7.1 // indirect github.com/goreleaser/chglog v0.7.3 // indirect github.com/goreleaser/fileglob v1.3.0 // indirect github.com/goreleaser/nfpm/v2 v2.43.0 // indirect github.com/hashicorp/go-version v1.7.0 // indirect github.com/huandu/xstrings v1.5.0 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/kevinburke/ssh_config v1.2.0 // indirect github.com/klauspost/compress v1.18.0 // indirect github.com/klauspost/pgzip v1.2.6 // indirect github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 // indirect github.com/magiconair/properties v1.8.10 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/go-archive v0.1.0 // indirect github.com/moby/patternmatcher v0.6.0 // indirect github.com/moby/sys/sequential v0.6.0 // indirect github.com/moby/sys/user v0.4.0 // indirect github.com/moby/sys/userns v0.1.0 // indirect github.com/moby/term v0.5.2 // indirect github.com/morikuni/aec v1.0.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/natefinch/atomic v1.0.1 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pjbgf/sha1cd v0.4.0 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.67.4 // indirect github.com/prometheus/procfs v0.19.2 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/sergi/go-diff v1.4.0 // indirect github.com/shopspring/decimal v1.4.0 // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/skeema/knownhosts v1.3.1 // indirect github.com/spf13/afero v1.14.0 // indirect github.com/spf13/cast v1.9.2 // indirect github.com/stoewer/go-strcase v1.3.1 // indirect github.com/stretchr/testify v1.11.1 // indirect github.com/suzuki-shunsuke/logrus-error v0.1.4 // indirect github.com/suzuki-shunsuke/pinact v1.6.0 // indirect github.com/suzuki-shunsuke/urfave-cli-help-all v0.0.4 // indirect github.com/tklauser/go-sysconf v0.3.16 // indirect github.com/tklauser/numcpus v0.11.0 // indirect github.com/ulikunitz/xz v0.5.14 // indirect github.com/urfave/cli/v2 v2.27.7 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect gitlab.com/digitalxero/go-conventional-commit v1.0.7 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 // indirect go.opentelemetry.io/otel v1.38.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 // indirect go.opentelemetry.io/otel/metric v1.38.0 // indirect go.opentelemetry.io/otel/trace v1.38.0 // indirect go.opentelemetry.io/proto/otlp v1.7.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.46.0 // indirect golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 // indirect golang.org/x/exp/typeparams v0.0.0-20250718183923-645b1fa84792 // indirect golang.org/x/mod v0.31.0 // indirect golang.org/x/oauth2 v0.32.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.39.0 // indirect golang.org/x/telemetry v0.0.0-20251203150158-8fff8a5912fc // indirect golang.org/x/term v0.38.0 // indirect golang.org/x/tools v0.40.0 // indirect golang.org/x/vuln v1.1.4 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20251213004720-97cd9d5aeac2 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect honnef.co/go/tools v0.6.1 // indirect mvdan.cc/sh/v3 v3.12.0 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect ) tool ( github.com/TecharoHQ/yeet/cmd/yeet github.com/a-h/templ/cmd/templ github.com/nicksnyder/go-i18n/v2/goi18n github.com/suzuki-shunsuke/pinact/cmd/pinact golang.org/x/tools/cmd/deadcode golang.org/x/tools/cmd/goimports golang.org/x/tools/cmd/stringer golang.org/x/vuln/cmd/govulncheck honnef.co/go/tools/cmd/staticcheck ) ================================================ FILE: go.sum ================================================ al.essio.dev/pkg/shellescape v1.6.0 h1:NxFcEqzFSEVCGN2yq7Huv/9hyCEGVa/TncnOOBBeXHA= al.essio.dev/pkg/shellescape v1.6.0/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890= buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20251209175733-2a1774d88802.1 h1:j9yeqTWEFrtimt8Nng2MIeRrpoCvQzM9/g25XTvqUGg= buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20251209175733-2a1774d88802.1/go.mod h1:tvtbpgaVXZX4g6Pn+AnzFycuRK3MOz5HJfEGeEllXYM= cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= github.com/AlekSi/pointer v1.2.0 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w= github.com/AlekSi/pointer v1.2.0/go.mod h1:gZGfd3dpW4vEc/UlyfKKi1roIqcCgwOIvb0tSNSBle0= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/DataDog/zstd v1.5.5 h1:oWf5W7GtOLgp6bciQYDmhHHjdhYkALu6S/5Ni9ZgSvQ= github.com/DataDog/zstd v1.5.5/go.mod h1:g4AWEaM3yOg3HYfnJ3YIawPnVdXJh9QME85blwSAmyw= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs= github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0= github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw= github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f h1:tCbYj7/299ekTTXpdwKYF8eBlsYsDVoggDAuAjoK66k= github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f/go.mod h1:gcr0kNtGBqin9zDW9GOHcVntrwnjrK+qdJ06mWYBybw= github.com/ProtonMail/gopenpgp/v2 v2.7.1 h1:Awsg7MPc2gD3I7IFac2qE3Gdls0lZW8SzrFZ3k1oz0s= github.com/ProtonMail/gopenpgp/v2 v2.7.1/go.mod h1:/BU5gfAVwqyd8EfC3Eu7zmuhwYQpKs+cGD8M//iiaxs= github.com/ProtonMail/gopenpgp/v3 v3.3.0 h1:N6rHCH5PWwB6zSRMgRj1EbAMQHUAAHxH3Oo4KibsPwY= github.com/ProtonMail/gopenpgp/v3 v3.3.0/go.mod h1:J+iNPt0/5EO9wRt7Eit9dRUlzyu3hiGX3zId6iuaKOk= github.com/Songmu/gitconfig v0.2.1 h1:cZsqELfMtxWVI8ovq17gbvsR4qLfoYLAiXy5GwtJWbk= github.com/Songmu/gitconfig v0.2.1/go.mod h1:XM4O3SoXFnli9Ql2G7qXK2Fg7LJwf7Hs8GLFEOJlzmM= github.com/TecharoHQ/thoth-proto v0.5.0 h1:Fa663s4soYiURSU8MfW9tZ2wF+LsCRSaYmjUSyagfBM= github.com/TecharoHQ/thoth-proto v0.5.0/go.mod h1:C/U7FqTxpVn4V/qebC/GcW32I0h9xzsmWehF27KFOJs= github.com/TecharoHQ/yeet v0.6.3 h1:Iev6TYt/tpFYU73kbkNIYjCObYTvlihtby+htGF4Us8= github.com/TecharoHQ/yeet v0.6.3/go.mod h1:ltt+PWPjnvmQJxEHsdJ5K9u3GoWK83vSLWCCp8XbxqI= 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.960 h1:trshEpGa8clF5cdI39iY4ZrZG8Z/QixyzEyUnA7feTM= github.com/a-h/templ v0.3.960/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/aws/aws-sdk-go-v2 v1.41.0 h1:tNvqh1s+v0vFYdA1xq0aOJH+Y5cRyZ5upu6roPgPKd4= github.com/aws/aws-sdk-go-v2 v1.41.0/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4= github.com/aws/aws-sdk-go-v2/config v1.32.5 h1:pz3duhAfUgnxbtVhIK39PGF/AHYyrzGEyRD9Og0QrE8= github.com/aws/aws-sdk-go-v2/config v1.32.5/go.mod h1:xmDjzSUs/d0BB7ClzYPAZMmgQdrodNjPPhd6bGASwoE= github.com/aws/aws-sdk-go-v2/credentials v1.19.5 h1:xMo63RlqP3ZZydpJDMBsH9uJ10hgHYfQFIk1cHDXrR4= github.com/aws/aws-sdk-go-v2/credentials v1.19.5/go.mod h1:hhbH6oRcou+LpXfA/0vPElh/e0M3aFeOblE1sssAAEk= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 h1:80+uETIWS1BqjnN9uJ0dBUaETh+P1XwFy5vwHwK5r9k= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16/go.mod h1:wOOsYuxYuB/7FlnVtzeBYRcjSRtQpAW0hCP7tIULMwo= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 h1:rgGwPzb82iBYSvHMHXc8h9mRoOUBZIGFgKb9qniaZZc= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16/go.mod h1:L/UxsGeKpGoIj6DxfhOWHWQ/kGKcd4I1VncE4++IyKA= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 h1:1jtGzuV7c82xnqOVfx2F0xmJcOw5374L7N6juGW6x6U= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16/go.mod h1:M2E5OQf+XLe+SZGmmpaI2yy+J326aFf6/+54PoxSANc= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16 h1:CjMzUs78RDDv4ROu3JnJn/Ig1r6ZD7/T2DXLLRpejic= github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16/go.mod h1:uVW4OLBqbJXSHJYA9svT9BluSvvwbzLQ2Crf6UPzR3c= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow= github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7 h1:DIBqIrJ7hv+e4CmIk2z3pyKT+3B6qVMgRsawHiR3qso= github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7/go.mod h1:vLm00xmBke75UmpNvOcZQ/Q30ZFjbczeLFqGx5urmGo= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 h1:oHjJHeUy0ImIV0bsrX0X91GkV5nJAyv1l1CC9lnO0TI= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16/go.mod h1:iRSNGgOYmiYwSCXxXaKb9HfOEj40+oTKn8pTxMlYkRM= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16 h1:NSbvS17MlI2lurYgXnCOLvCFX38sBW4eiVER7+kkgsU= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16/go.mod h1:SwT8Tmqd4sA6G1qaGdzWCJN99bUmPGHfRwwq3G5Qb+A= github.com/aws/aws-sdk-go-v2/service/s3 v1.93.2 h1:U3ygWUhCpiSPYSHOrRhb3gOl9T5Y3kB8k5Vjs//57bE= github.com/aws/aws-sdk-go-v2/service/s3 v1.93.2/go.mod h1:79S2BdqCJpScXZA2y+cpZuocWsjGjJINyXnOsf5DTz8= github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 h1:HpI7aMmJ+mm1wkSHIA2t5EaFFv5EFYXePW30p1EIrbQ= github.com/aws/aws-sdk-go-v2/service/signin v1.0.4/go.mod h1:C5RdGMYGlfM0gYq/tifqgn4EbyX99V15P2V3R+VHbQU= github.com/aws/aws-sdk-go-v2/service/sso v1.30.7 h1:eYnlt6QxnFINKzwxP5/Ucs1vkG7VT3Iezmvfgc2waUw= github.com/aws/aws-sdk-go-v2/service/sso v1.30.7/go.mod h1:+fWt2UHSb4kS7Pu8y+BMBvJF0EWx+4H0hzNwtDNRTrg= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 h1:AHDr0DaHIAo8c9t1emrzAlVDFp+iMMKnPdYy6XO4MCE= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12/go.mod h1:GQ73XawFFiWxyWXMHWfhiomvP3tXtdNar/fi8z18sx0= github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 h1:SciGFVNZ4mHdm7gpD1dgZYnCuVdX1s+lFTg4+4DOy70= github.com/aws/aws-sdk-go-v2/service/sts v1.41.5/go.mod h1:iW40X4QBmUxdP+fZNOpfmkdMZqsovezbAeO+Ubiv2pk= github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk= github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= 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/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb h1:m935MPodAbYS46DG4pJSv7WO+VECIWUQ7OJYSoTrMh4= github.com/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb/go.mod h1:PkYb9DJNAwrSvRx5DYA+gUcOIgTGVMNkfSCbZM8cWpI= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/caarlos0/testfs v0.4.4 h1:3PHvzHi5Lt+g332CiShwS8ogTgS3HjrmzZxCm6JCDr8= github.com/caarlos0/testfs v0.4.4/go.mod h1:bRN55zgG4XCUVVHZCeU+/Tz1Q6AxEJOEJTliBy+1DMk= github.com/cavaliergopher/cpio v1.0.1 h1:KQFSeKmZhv0cr+kawA3a0xTQCU4QxXF1vhU7P7av2KM= github.com/cavaliergopher/cpio v1.0.1/go.mod h1:pBdaqQjnvXxdS/6CvNDwIANIFSP0xRKI16PX4xejRQc= github.com/cavaliergopher/rpm v1.3.0 h1:UHX46sasX8MesUXXQ+UbkFLUX4eUWTlEcX8jcnRBIgI= github.com/cavaliergopher/rpm v1.3.0/go.mod h1:vEumo1vvtrHM1Ov86f6+k8j7zNKOxQfHDCAIcR/36ZI= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cli/browser v1.3.0 h1:LejqCrpWr+1pRqmEPDGnTZOjsMe7sehifLynZJuqJpo= github.com/cli/browser v1.3.0/go.mod h1:HH8s+fOAxjhQoBUAsKuPCbqUuxZDhQ2/aD+SzsEfBTk= github.com/cli/go-gh/v2 v2.12.1 h1:SVt1/afj5FRAythyMV3WJKaUfDNsxXTIe7arZbwTWKA= github.com/cli/go-gh/v2 v2.12.1/go.mod h1:+5aXmEOJsH9fc9mBHfincDwnS02j2AIA/DsTH0Bk5uw= github.com/cli/safeexec v1.0.1 h1:e/C79PbXF4yYTN/wauC4tviMxEV13BwljGj0N9j+N00= github.com/cli/safeexec v1.0.1/go.mod h1:Z/D4tTN8Vs5gXYHDCbaM1S/anmEDnJb1iW0+EJ5zx3Q= github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/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.8.0 h1:swm0rlPCmdWn9mESxKOjWk8hXSqoxOp+ZlfuyaAdFlQ= github.com/deckarep/golang-set/v2 v2.8.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c= github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/docker/docker v28.5.1+incompatible h1:Bm8DchhSD2J6PsFzxC35TZo4TLGR2PdW/E69rU45NhM= github.com/docker/docker v28.5.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/dop251/goja v0.0.0-20250630131328-58d95d85e994 h1:aQYWswi+hRL2zJqGacdCZx32XjKYV8ApXFGntw79XAM= github.com/dop251/goja v0.0.0-20250630131328-58d95d85e994/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4= github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A= github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= 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= github.com/facebookgo/flagenv v0.0.0-20160425205200-fcd59fca7456/go.mod h1:zFhibDvPDWmtk4dAQ05sRobtyoffEHygEt3wSNuAzz8= github.com/facebookgo/stack v0.0.0-20160209184415-751773369052 h1:JWuenKqqX8nojtoVVWjGfOF9635RETekkoH6Cc9SX0A= github.com/facebookgo/stack v0.0.0-20160209184415-751773369052/go.mod h1:UbMTZqLaRiH3MsBH8va0n7s1pQYcu3uTb8G4tygF4Zg= github.com/facebookgo/subset v0.0.0-20150612182917-8dac2c3c4870 h1:E2s37DuLxFhQDg5gKsWoLBOB0n+ZW8s599zru8FJ2/Y= github.com/facebookgo/subset v0.0.0-20150612182917-8dac2c3c4870/go.mod h1:5tD+neXqOorC30/tWg0LCSkrqj/AR6gu8yY8/fpw1q0= github.com/fahedouch/go-logrotate v0.3.0 h1:XP+dHIDgWZ1ckz43mG6gl5ASer3PZDVr755SVMyzaUQ= github.com/fahedouch/go-logrotate v0.3.0/go.mod h1:X49m0bvPLkk71MHNCQ1yEfVEw8W/u+qvHa/hOnhCYf4= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/gaissmai/bart v0.26.0 h1:xOZ57E9hJLBiQaSyeZa9wgWhGuzfGACgqp4BE77OkO0= github.com/gaissmai/bart v0.26.0/go.mod h1:GREWQfTLRWz/c5FTOsIw+KkscuFkIV5t8Rp7Nd1Td5c= github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM= github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= github.com/go-git/go-git/v5 v5.16.2 h1:fT6ZIOjE5iEnkzKyxTHK1W4HGAsPhqEqiSAssSO77hM= github.com/go-git/go-git/v5 v5.16.2/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8= 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-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q= github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg= 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/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/cel-go v0.26.1 h1:iPbVVEdkhTX++hpe3lzSk7D3G3QSYqLGoHOcEio+UXQ= github.com/google/cel-go v0.26.1/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM= github.com/google/go-cmdtest v0.4.1-0.20220921163831-55ab3332a786 h1:rcv+Ippz6RAtvaGgKxc+8FQIpxHgsF+HBzPyYL2cyVU= github.com/google/go-cmdtest v0.4.1-0.20220921163831-55ab3332a786/go.mod h1:apVn/GCasLZUVpAJ6oWAuyP7Ne7CEsQbTnc0plM3m+o= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-github/v70 v70.0.0 h1:/tqCp5KPrcvqCc7vIvYyFYTiCGrYvaWoYMGHSQbo55o= github.com/google/go-github/v70 v70.0.0/go.mod h1:xBUZgo8MI3lUL/hwxl3hlceJW1U8MVnXP3zUyI+rhQY= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/google/pprof v0.0.0-20250630185457-6e76a2b096b5 h1:xhMrHhTJ6zxu3gA4enFM9MLn9AY7613teCdFnlUVbSQ= github.com/google/pprof v0.0.0-20250630185457-6e76a2b096b5/go.mod h1:5hDyRhoBCxViHszMt12TnOpEI4VVi+U8Gm9iphldiMA= github.com/google/renameio v0.1.0 h1:GOZbcHa3HfsPKPlmyPyN2KEohoMXOhdMbHrvbpl2QaA= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/rpmpack v0.7.1 h1:YdWh1IpzOjBz60Wvdw0TU0A5NWP+JTVHA5poDqwMO2o= github.com/google/rpmpack v0.7.1/go.mod h1:h1JL16sUTWCLI/c39ox1rDaTBo3BXUQGjczVJyK4toU= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g= github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k= github.com/goreleaser/chglog v0.7.3 h1:eCKJrvsDgG+F1F2fhwM6qX+S5yMiZgsQ4VNTPFl9qEM= github.com/goreleaser/chglog v0.7.3/go.mod h1:HXPf4avc1kTD00a46LuTEH0i1dZctLq8Xs2BxUfROnY= github.com/goreleaser/fileglob v1.3.0 h1:/X6J7U8lbDpQtBvGcwwPS6OpzkNVlVEsFUVRx9+k+7I= github.com/goreleaser/fileglob v1.3.0/go.mod h1:Jx6BoXv3mbYkEzwm9THo7xbr5egkAraxkGorbJb4RxU= github.com/goreleaser/nfpm/v2 v2.43.0 h1:o5oureuZkhu55RK0M9WSN8JLW7hu6MymtMh7LypInlk= github.com/goreleaser/nfpm/v2 v2.43.0/go.mod h1:f//PE8PjNHjaPCbd7Jkok+aPKdLTrzM+fuIWg3PfVRg= github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 h1:QGLs/O40yoNK9vmy4rhUGBVyMf1lISBGtXRpsu/Qu/o= github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0/go.mod h1:hM2alZsMUni80N33RBe6J0e423LB+odMj7d3EMP9l20= github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 h1:B+8ClL/kCQkRiU82d9xajRPKYMrB7E0MbtzWVi1K4ns= github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3/go.mod h1:NbCUVmiS4foBGBHOYlCT25+YmGpJ32dZPi75pGEUpj4= github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo= github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI= github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY= github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= github.com/kjk/lzma v0.0.0-20161016003348-3fd93898850d h1:RnWZeH8N8KXfbwMTex/KKMYMj0FJRCF6tQubUuQ02GM= github.com/kjk/lzma v0.0.0-20161016003348-3fd93898850d/go.mod h1:phT/jsRPBAEqjAibu1BurrabCBNTYiVI+zbmyCZJY6Q= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU= github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 h1:PpXWgLPs+Fqr325bN2FD2ISlRRztXibcX6e8f5FR5Dc= github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= github.com/lum8rjack/go-ja4h v0.0.0-20250828030157-fa5266d50650 h1:hhx/Mo6+Hk0mAQS5MW311ON1VlSzp0D1cYhY27IcmnI= github.com/lum8rjack/go-ja4h v0.0.0-20250828030157-fa5266d50650/go.mod h1:bMqyXOakqQIdx82d4vcnk5TIZLptZ2gLqju9xmPrWYA= github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE= github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= 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/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= 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/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ= github.com/moby/go-archive v0.1.0/go.mod h1:G9B+YoujNohJmrIYFBpSd54GTUB4lt9S+xVQvsJyFuo= github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= 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/nicksnyder/go-i18n/v2 v2.6.0 h1:C/m2NNWNiTB6SK4Ao8df5EWm3JETSTIGNXBpMJTxzxQ= github.com/nicksnyder/go-i18n/v2 v2.6.0/go.mod h1:88sRqr0C6OPyJn0/KRNaEz1uWorjxIKP7rUUcvycecE= github.com/nikandfor/spintax v0.0.0-20181023094358-fc346b245bb3 h1:foZ9X1bz2KmW7b8Yx5V0LAQKhTazdllv5rnGUe6iGTY= github.com/nikandfor/spintax v0.0.0-20181023094358-fc346b245bb3/go.mod h1:wwDYKfVF3WHdY0rugsAZoIpyQjDA3bn9wEzo/QXPx1Y= github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4= github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/pjbgf/sha1cd v0.4.0 h1:NXzbL1RvjTUi6kgYZCX3fPwwl27Q1LJndxtUDVfJGRY= github.com/pjbgf/sha1cd v0.4.0/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/playwright-community/playwright-go v0.5200.1 h1:Sm2oOuhqt0M5Y4kUi/Qh9w4cyyi3ZIWTBeGKImc2UVo= github.com/playwright-community/playwright-go v0.5200.1/go.mod h1:UnnyQZaqUOO5ywAZu60+N4EiWReUqX1MQBBA3Oofvf8= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc= github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI= github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI= github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sassoftware/go-rpmutils v0.4.0 h1:ojND82NYBxgwrV+mX1CWsd5QJvvEZTKddtCdFLPWhpg= github.com/sassoftware/go-rpmutils v0.4.0/go.mod h1:3goNWi7PGAT3/dlql2lv3+MSN5jNYPjT5mVcQcIsYzI= github.com/sebest/xff v0.0.0-20210106013422-671bd2870b3a h1:iLcLb5Fwwz7g/DLK89F+uQBDeAhHhwdzB5fSlVdhGcM= github.com/sebest/xff v0.0.0-20210106013422-671bd2870b3a/go.mod h1:wozgYq9WEBQBaIJe4YZ0qTSFAMxmcwBhQH0fO0R34Z0= github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/shirou/gopsutil/v4 v4.25.11 h1:X53gB7muL9Gnwwo2evPSE+SfOrltMoR6V3xJAXZILTY= github.com/shirou/gopsutil/v4 v4.25.11/go.mod h1:EivAfP5x2EhLp2ovdpKSozecVXn1TmuG7SMzs/Wh4PU= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= github.com/smarty/assertions v1.15.0 h1:cR//PqUBUiQRakZWqBiFFQ9wb8emQGDb0HeGdqGByCY= github.com/smarty/assertions v1.15.0/go.mod h1:yABtdzeQs6l1brC900WlRNwj6ZR55d7B+E8C6HtKdec= github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sSznIX1xY= github.com/smartystreets/goconvey v1.8.1/go.mod h1:+/u4qLyY6x1jReYOp7GOM2FSt8aP9CzCZL03bI28W60= github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA= github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo= github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE= github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= github.com/stoewer/go-strcase v1.3.1 h1:iS0MdW+kVTxgMoE1LAZyMiYJFKlOzLooE4MxjirtkAs= github.com/stoewer/go-strcase v1.3.1/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 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.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/suzuki-shunsuke/logrus-error v0.1.4 h1:nWo98uba1fANHdZ9Y5pJ2RKs/PpVjrLzRp5m+mRb9KE= github.com/suzuki-shunsuke/logrus-error v0.1.4/go.mod h1:WsVvvw6SKSt08/fB2qbnsKIMJA4K1MYCUprqsBJbMiM= github.com/suzuki-shunsuke/pinact v1.6.0 h1:2QvSzREOquwLwKXhF9Hj0AInE/Rl63SZz9dKkHFC6so= github.com/suzuki-shunsuke/pinact v1.6.0/go.mod h1:FDUMck0mmL0mcnNZ23Vjh/aOR5cIdZhF1IIpGksT4dQ= github.com/suzuki-shunsuke/urfave-cli-help-all v0.0.4 h1:YGHgrVjGTYHY98II6zijXUHP+OyvrzSCvd8m9iUcaK8= github.com/suzuki-shunsuke/urfave-cli-help-all v0.0.4/go.mod h1:sSi6xaUaHfaqu32ECLeyE7NTMv+ZM5dW0JikhllaalY= github.com/testcontainers/testcontainers-go v0.40.0 h1:pSdJYLOVgLE8YdUY2FHQ1Fxu+aMnb6JfVz1mxk7OeMU= github.com/testcontainers/testcontainers-go v0.40.0/go.mod h1:FSXV5KQtX2HAMlm7U3APNyLkkap35zNLxukw9oBi/MY= github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= github.com/ulikunitz/xz v0.5.14 h1:uv/0Bq533iFdnMHZdRBTOlaNMdb1+ZxXIlHDZHIHcvg= github.com/ulikunitz/xz v0.5.14/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU= github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 h1:nIPpBwaJSVYIxUFsDv3M8ofmx9yWTog9BfvIu0q41lo= github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos= github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg= github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= gitlab.com/digitalxero/go-conventional-commit v1.0.7 h1:8/dO6WWG+98PMhlZowt/YjuiKhqhGlOCwlIV8SqqGh8= gitlab.com/digitalxero/go-conventional-commit v1.0.7/go.mod h1:05Xc2BFsSyC5tKhK0y+P3bs0AwUtNuTp+mTpbCU/DZ0= go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo= go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 h1:Hf9xI/XLML9ElpiHVDNwvqI0hIFlzV8dgIr35kV1kRU= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0/go.mod h1:NfchwuyNoMcZ5MLHwPrODwUF1HWCXWrL31s8gSAdIKY= go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 h1:Ahq7pZmv87yiyn3jeFz/LekZmPLLdKejuO3NcK9MssM= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0/go.mod h1:MJTqhM0im3mRLw1i8uGHnCvUEeS7VwRyxlLC78PA18M= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU= go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= go.opentelemetry.io/proto/otlp v1.7.0 h1:jX1VolD6nHuFzOYso2E73H85i92Mv8JQYk0K9vz09os= go.opentelemetry.io/proto/otlp v1.7.0/go.mod h1:fSKjH6YJ7HDlwzltzyMj036AJ3ejJLCgCSHGj4efDDo= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= 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.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 h1:MDfG8Cvcqlt9XXrmEiD4epKn7VJHZO84hejP9Jmp0MM= golang.org/x/exp v0.0.0-20251209150349-8475f28825e9/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU= golang.org/x/exp/typeparams v0.0.0-20250718183923-645b1fa84792 h1:54/e+WfmhvjR2Zuz8Q7dzLGxIBM+s5WZpvo1QfVDGB8= golang.org/x/exp/typeparams v0.0.0-20250718183923-645b1fa84792/go.mod h1:LKZHyeOpPuZcMgxeHjJp4p5yvxrCX1xDvH10zYHhjjQ= 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.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 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.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY= golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= 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.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 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.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/telemetry v0.0.0-20251203150158-8fff8a5912fc h1:bH6xUXay0AIFMElXG2rQ4uiE+7ncwtiOdPfYK1NK2XA= golang.org/x/telemetry v0.0.0-20251203150158-8fff8a5912fc/go.mod h1:hKdjCMrbv9skySur+Nek8Hd0uJ0GuxJIoIX2payrIdQ= 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.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= 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.6/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.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 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.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM= golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM= golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8= golang.org/x/vuln v1.1.4 h1:Ju8QsuyhX3Hk8ma3CesTbO8vfJD9EvUBgHvkxHBzj0I= golang.org/x/vuln v1.1.4/go.mod h1:F+45wmU18ym/ca5PLTPLsSzr2KppzswxPP603ldA67s= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/genproto/googleapis/api v0.0.0-20251213004720-97cd9d5aeac2 h1:7LRqPCEdE4TP4/9psdaB7F2nhZFfBiGJomA5sojLWdU= google.golang.org/genproto/googleapis/api v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA= google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM= google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 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= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= honnef.co/go/tools v0.6.1 h1:R094WgE8K4JirYjBaOpz/AvTyUu/3wbmAoskKN/pxTI= honnef.co/go/tools v0.6.1/go.mod h1:3puzxxljPCe8RGJX7BIy1plGbxEOZni5mR2aXe3/uk4= k8s.io/apimachinery v0.34.3 h1:/TB+SFEiQvN9HPldtlWOTp0hWbJ+fjU+wkxysf/aQnE= k8s.io/apimachinery v0.34.3/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= mvdan.cc/sh/v3 v3.12.0 h1:ejKUR7ONP5bb+UGHGEG/k9V5+pRVIyD+LsZz7o8KHrI= mvdan.cc/sh/v3 v3.12.0/go.mod h1:Se6Cj17eYSn+sNooLZiEUnNNmNxg0imoYlTu4CyaGyg= pault.ag/go/debian v0.18.0 h1:nr0iiyOU5QlG1VPnhZLNhnCcHx58kukvBJp+dvaM6CQ= pault.ag/go/debian v0.18.0/go.mod h1:JFl0XWRCv9hWBrB5MDDZjA5GSEs1X3zcFK/9kCNIUmE= pault.ag/go/topsort v0.1.1 h1:L0QnhUly6LmTv0e3DEzbN2q6/FGgAcQvaEw65S53Bg4= pault.ag/go/topsort v0.1.1/go.mod h1:r1kc/L0/FZ3HhjezBIPaNVhkqv8L0UJ9bxRuHRVZ0q4= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= ================================================ FILE: internal/actorify/actorify.go ================================================ // Package actorify lets you transform a parallel operation into a serialized // operation via the Actor pattern[1]. // // [1]: https://en.wikipedia.org/wiki/Actor_model package actorify import ( "context" "errors" ) func z[Z any]() Z { var z Z return z } var ( // ErrActorDied is returned when the actor inbox or reply channel was closed. ErrActorDied = errors.New("actorify: the actor inbox or reply channel was closed") ) // Handler is a function alias for the underlying logic the Actor should call. type Handler[Input, Output any] func(ctx context.Context, input Input) (Output, error) // Actor is a serializing wrapper that runs a function in a background goroutine. // Whenever the Call method is invoked, a message is sent to the actor's inbox and then // the callee waits for a response. Depending on how busy the actor is, this may take // a moment. type Actor[Input, Output any] struct { handler Handler[Input, Output] inbox chan *message[Input, Output] } type message[Input, Output any] struct { ctx context.Context arg Input reply chan reply[Output] } type reply[Output any] struct { output Output err error } // New constructs a new Actor and starts its background thread. Cancel the context and you cancel // the Actor. func New[Input, Output any](ctx context.Context, handler Handler[Input, Output]) *Actor[Input, Output] { result := &Actor[Input, Output]{ handler: handler, inbox: make(chan *message[Input, Output], 32), } go result.handle(ctx) return result } func (a *Actor[Input, Output]) handle(ctx context.Context) { for { select { case <-ctx.Done(): close(a.inbox) return case msg, ok := <-a.inbox: if !ok { if msg.reply != nil { close(msg.reply) } return } result, err := a.handler(msg.ctx, msg.arg) reply := reply[Output]{ output: result, err: err, } msg.reply <- reply } } } // Call calls the Actor with a given Input and returns the handler's Output. // // This only works with unary functions by design. If you need to have more inputs, define // a struct type to use as a container. func (a *Actor[Input, Output]) Call(ctx context.Context, input Input) (Output, error) { replyCh := make(chan reply[Output]) a.inbox <- &message[Input, Output]{ arg: input, reply: replyCh, } select { case reply, ok := <-replyCh: if !ok { return z[Output](), ErrActorDied } return reply.output, reply.err case <-ctx.Done(): return z[Output](), context.Cause(ctx) } } ================================================ FILE: internal/clampip.go ================================================ package internal import "net/netip" func ClampIP(addr netip.Addr) (netip.Prefix, bool) { switch { case addr.Is4(): result, err := addr.Prefix(24) if err != nil { return netip.Prefix{}, false } return result, true case addr.Is4In6(): // Extract the IPv4 address from IPv4-mapped IPv6 and clamp it ipv4 := addr.Unmap() result, err := ipv4.Prefix(24) if err != nil { return netip.Prefix{}, false } return result, true case addr.Is6(): result, err := addr.Prefix(48) if err != nil { return netip.Prefix{}, false } return result, true default: return netip.Prefix{}, false } } ================================================ FILE: internal/clampip_test.go ================================================ package internal import ( "net/netip" "testing" ) func TestClampIP(t *testing.T) { tests := []struct { name string input string expected string }{ // IPv4 addresses { name: "IPv4 normal address", input: "192.168.1.100", expected: "192.168.1.0/24", }, { name: "IPv4 boundary - network address", input: "192.168.1.0", expected: "192.168.1.0/24", }, { name: "IPv4 boundary - broadcast address", input: "192.168.1.255", expected: "192.168.1.0/24", }, { name: "IPv4 class A address", input: "10.0.0.1", expected: "10.0.0.0/24", }, { name: "IPv4 loopback", input: "127.0.0.1", expected: "127.0.0.0/24", }, { name: "IPv4 link-local", input: "169.254.0.1", expected: "169.254.0.0/24", }, { name: "IPv4 public address", input: "203.0.113.1", expected: "203.0.113.0/24", }, // IPv6 addresses { name: "IPv6 normal address", input: "2001:db8::1", expected: "2001:db8::/48", }, { name: "IPv6 with full expansion", input: "2001:0db8:0000:0000:0000:0000:0000:0001", expected: "2001:db8::/48", }, { name: "IPv6 loopback", input: "::1", expected: "::/48", }, { name: "IPv6 unspecified address", input: "::", expected: "::/48", }, { name: "IPv6 link-local", input: "fe80::1", expected: "fe80::/48", }, { name: "IPv6 unique local", input: "fc00::1", expected: "fc00::/48", }, { name: "IPv6 documentation prefix", input: "2001:db8:abcd:ef01::1234", expected: "2001:db8:abcd::/48", }, { name: "IPv6 global unicast", input: "2606:4700:4700::1111", expected: "2606:4700:4700::/48", }, { name: "IPv6 multicast", input: "ff02::1", expected: "ff02::/48", }, // IPv4-mapped IPv6 addresses { name: "IPv4-mapped IPv6 address", input: "::ffff:192.168.1.100", expected: "192.168.1.0/24", }, { name: "IPv4-mapped IPv6 with different format", input: "::ffff:10.0.0.1", expected: "10.0.0.0/24", }, { name: "IPv4-mapped IPv6 loopback", input: "::ffff:127.0.0.1", expected: "127.0.0.0/24", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { addr := netip.MustParseAddr(tt.input) result, ok := ClampIP(addr) if !ok { t.Fatalf("ClampIP(%s) returned false, want true", tt.input) } if result.String() != tt.expected { t.Errorf("ClampIP(%s) = %s, want %s", tt.input, result.String(), tt.expected) } }) } } func TestClampIPSuccess(t *testing.T) { // Test that valid inputs return success tests := []struct { name string input string }{ { name: "IPv4 address", input: "192.168.1.100", }, { name: "IPv6 address", input: "2001:db8::1", }, { name: "IPv4-mapped IPv6", input: "::ffff:192.168.1.100", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { addr := netip.MustParseAddr(tt.input) result, ok := ClampIP(addr) if !ok { t.Fatalf("ClampIP(%s) returned false, want true", tt.input) } // For valid inputs, we should get the clamped prefix if addr.Is4() || addr.Is4In6() { if result.Bits() != 24 { t.Errorf("Expected 24 bits for IPv4, got %d", result.Bits()) } } else if addr.Is6() { if result.Bits() != 48 { t.Errorf("Expected 48 bits for IPv6, got %d", result.Bits()) } } }) } } func TestClampIPZeroValue(t *testing.T) { // Test that when ClampIP fails, it returns zero value // Note: It's hard to make addr.Prefix() fail with valid inputs, // so this test demonstrates the expected behavior addr := netip.MustParseAddr("192.168.1.100") // Manually create a zero value for comparison zeroPrefix := netip.Prefix{} // Call ClampIP - it should succeed with valid input result, ok := ClampIP(addr) // Verify the function succeeded if !ok { t.Error("ClampIP should succeed with valid input") } // Verify that the result is not a zero value if result == zeroPrefix { t.Error("Result should not be zero value for successful operation") } } func TestClampIPSpecialCases(t *testing.T) { tests := []struct { name string input string expectedPrefix int expectedNetwork string }{ { name: "Minimum IPv4", input: "0.0.0.0", expectedPrefix: 24, expectedNetwork: "0.0.0.0", }, { name: "Maximum IPv4", input: "255.255.255.255", expectedPrefix: 24, expectedNetwork: "255.255.255.0", }, { name: "Minimum IPv6", input: "::", expectedPrefix: 48, expectedNetwork: "::", }, { name: "Maximum IPv6 prefix part", input: "ffff:ffff:ffff::", expectedPrefix: 48, expectedNetwork: "ffff:ffff:ffff::", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { addr := netip.MustParseAddr(tt.input) result, ok := ClampIP(addr) if !ok { t.Fatalf("ClampIP(%s) returned false, want true", tt.input) } if result.Bits() != tt.expectedPrefix { t.Errorf("ClampIP(%s) bits = %d, want %d", tt.input, result.Bits(), tt.expectedPrefix) } if result.Addr().String() != tt.expectedNetwork { t.Errorf("ClampIP(%s) network = %s, want %s", tt.input, result.Addr().String(), tt.expectedNetwork) } }) } } // Benchmark to ensure the function is performant func BenchmarkClampIP(b *testing.B) { ipv4 := netip.MustParseAddr("192.168.1.100") ipv6 := netip.MustParseAddr("2001:db8::1") ipv4mapped := netip.MustParseAddr("::ffff:192.168.1.100") b.Run("IPv4", func(b *testing.B) { for i := 0; i < b.N; i++ { ClampIP(ipv4) } }) b.Run("IPv6", func(b *testing.B) { for i := 0; i < b.N; i++ { ClampIP(ipv6) } }) b.Run("IPv4-mapped", func(b *testing.B) { for i := 0; i < b.N; i++ { ClampIP(ipv4mapped) } }) } ================================================ FILE: internal/dns/cache.go ================================================ package dns import ( "log/slog" "time" "github.com/TecharoHQ/anubis/lib/store" _ "github.com/TecharoHQ/anubis/lib/store/all" ) type DnsCache struct { forward store.JSON[[]string] reverse store.JSON[[]string] forwardTTL time.Duration reverseTTL time.Duration } func NewDNSCache(forwardTTL int, reverseTTL int, backend store.Interface) *DnsCache { return &DnsCache{ forward: store.JSON[[]string]{ Underlying: backend, Prefix: "forwardDNS", }, reverse: store.JSON[[]string]{ Underlying: backend, Prefix: "reverseDNS", }, forwardTTL: time.Duration(forwardTTL) * time.Second, reverseTTL: time.Duration(reverseTTL) * time.Second, } } func (d *Dns) getCachedForward(host string) ([]string, bool) { if d.cache == nil { return nil, false } if cached, err := d.cache.forward.Get(d.ctx, host); err == nil { slog.Debug("DNS: forward cache hit", "name", host, "ips", cached) return cached, true } slog.Debug("DNS: forward cache miss", "name", host) return nil, false } func (d *Dns) getCachedReverse(addr string) ([]string, bool) { if d.cache == nil { return nil, false } if cached, err := d.cache.reverse.Get(d.ctx, addr); err == nil { slog.Debug("DNS: reverse cache hit", "addr", addr, "names", cached) return cached, true } slog.Debug("DNS: reverse cache miss", "addr", addr) return nil, false } func (d *Dns) forwardCachePut(host string, entries []string) { if d.cache == nil { return } d.cache.forward.Set(d.ctx, host, entries, d.cache.forwardTTL) } func (d *Dns) reverseCachePut(addr string, entries []string) { if d.cache == nil { return } d.cache.reverse.Set(d.ctx, addr, entries, d.cache.reverseTTL) } ================================================ FILE: internal/dns/dns.go ================================================ package dns import ( "context" "encoding/hex" "errors" "fmt" "log/slog" "net" "regexp" "slices" "strings" ) var ( DNSLookupAddr = net.LookupAddr DNSLookupHost = net.LookupHost ) type Dns struct { cache *DnsCache ctx context.Context } func New(ctx context.Context, cache *DnsCache) *Dns { return &Dns{ cache: cache, ctx: ctx, } } // ReverseDNS performs a reverse DNS lookup for the given IP address and trims the trailing dot from the results. func (d *Dns) ReverseDNS(addr string) ([]string, error) { slog.Debug("DNS: performing reverse lookup", "addr", addr) if cached, ok := d.getCachedReverse(addr); ok { return cached, nil } names, err := DNSLookupAddr(addr) if err != nil { if dnsErr, ok := err.(*net.DNSError); ok && dnsErr.IsNotFound { slog.Debug("DNS: no PTR record found", "addr", addr) return []string{}, nil } slog.Error("DNS: reverse lookup failed", "addr", addr, "err", err) return nil, err } slog.Debug("DNS: reverse lookup successful", "addr", addr, "names", names) trimmedNames := make([]string, len(names)) for i, name := range names { trimmedNames[i] = strings.TrimSuffix(name, ".") } d.reverseCachePut(addr, trimmedNames) return trimmedNames, nil } // LookupHost performs a forward DNS lookup for the given hostname. func (d *Dns) LookupHost(host string) ([]string, error) { slog.Debug("DNS: performing forward lookup", "host", host) if cached, ok := d.getCachedForward(host); ok { return cached, nil } addrs, err := DNSLookupHost(host) if err != nil { if dnsErr, ok := err.(*net.DNSError); ok && dnsErr.IsNotFound { slog.Debug("DNS: no A/AAAA record found", "host", host) return []string{}, nil } slog.Error("DNS: forward lookup failed", "host", host, "err", err) return nil, err } slog.Debug("DNS: forward lookup successful", "host", host, "addrs", addrs) d.forwardCachePut(host, addrs) return addrs, nil } // verifyFCrDNSInternal performs the second half of the FCrDNS check, using a // pre-fetched list of names to perform the forward lookups. func (d *Dns) verifyFCrDNSInternal(addr string, names []string) bool { for _, name := range names { if cached, err := d.LookupHost(name); err == nil { if slices.Contains(cached, addr) { slog.Info("DNS: forward lookup confirmed original IP", "name", name, "addr", addr) return true } continue } } slog.Info("DNS: could not confirm original IP in forward lookups", "addr", addr) return false } // VerifyFCrDNS performs a forward-confirmed reverse DNS (FCrDNS) lookup for the given IP address, // optionally matching against a provided pattern. func (d *Dns) VerifyFCrDNS(addr string, pattern *string) bool { var patternVal string if pattern != nil { patternVal = *pattern } slog.Debug("DNS: performing FCrDNS lookup", "addr", addr, "pattern", patternVal) names, err := d.ReverseDNS(addr) if err != nil { return false } if len(names) == 0 { return pattern == nil // If no pattern specified, check is passed } // If a pattern is provided, check for a match. if pattern != nil { anyNameMatched := false for _, name := range names { matched, err := regexp.MatchString(*pattern, name) if err != nil { slog.Error("DNS: verifyFCrDNS invalid regex pattern", "err", err) return false // Invalid pattern is a failure. } if matched { anyNameMatched = true break } } if !anyNameMatched { slog.Debug("DNS: FCrDNS no PTR matches the pattern", "addr", addr, "pattern", *pattern) return false } slog.Debug("DNS: FCrDNS PTR matched pattern, proceeding with forward check", "addr", addr, "pattern", *pattern) } // If we're here, either there was no pattern, or the pattern matched. // Proceed with the forward lookup confirmation. return d.verifyFCrDNSInternal(addr, names) } // ArpaReverseIP performs translation from ip v4/v6 to arpa reverse notation func (d *Dns) ArpaReverseIP(addr string) (string, error) { ip := net.ParseIP(addr) if ip == nil { return addr, errors.New("invalid IP address") } if ipv4 := ip.To4(); ipv4 != nil { return fmt.Sprintf("%d.%d.%d.%d", ipv4[3], ipv4[2], ipv4[1], ipv4[0]), nil } ipv6 := ip.To16() if ipv6 == nil { return addr, errors.New("invalid IPv6 address") } hexBytes := make([]byte, hex.EncodedLen(len(ipv6))) hex.Encode(hexBytes, ipv6) var sb strings.Builder sb.Grow(len(hexBytes)*2 - 1) for i := len(hexBytes) - 1; i >= 0; i-- { sb.WriteByte(hexBytes[i]) if i > 0 { sb.WriteByte('.') } } return sb.String(), nil } ================================================ FILE: internal/dns/dns_test.go ================================================ package dns import ( "context" "errors" "net" "reflect" "testing" "time" "github.com/TecharoHQ/anubis/lib/store/memory" ) // newTestDNS is a helper function to create a new Dns object with an in-memory cache for testing. func newTestDNS(forwardTTL int, reverseTTL int) *Dns { ctx := context.Background() memStore := memory.New(ctx) cache := NewDNSCache(forwardTTL, reverseTTL, memStore) return New(ctx, cache) } // mockLookupAddr is a mock implementation of the net.LookupAddr function. func mockLookupAddr(addr string) ([]string, error) { switch addr { case "8.8.8.8": return []string{"dns.google."}, nil case "1.1.1.1": return []string{"one.one.one.one."}, nil case "208.67.222.222": return []string{"resolver1.opendns.com."}, nil case "9.9.9.9": return nil, &net.DNSError{Err: "no such host", Name: "9.9.9.9", IsNotFound: true} case "1.2.3.4": return nil, errors.New("unknown error") default: return nil, &net.DNSError{Err: "no such host", Name: addr, IsNotFound: true} } } // mockLookupHost is a mock implementation of the net.LookupHost function. func mockLookupHost(host string) ([]string, error) { switch host { case "dns.google": return []string{"8.8.8.8", "8.8.4.4"}, nil case "one.one.one.one": return []string{"1.1.1.1", "1.0.0.1"}, nil case "resolver1.opendns.com": return []string{"208.67.222.222"}, nil case "example.com": return nil, &net.DNSError{Err: "no such host", Name: "example.com", IsNotFound: true} default: return nil, &net.DNSError{Err: "no such host", Name: host, IsNotFound: true} } } func TestMain(m *testing.M) { // Before all tests originalLookupAddr := DNSLookupAddr originalLookupHost := DNSLookupHost DNSLookupAddr = mockLookupAddr DNSLookupHost = mockLookupHost // Run tests exitCode := m.Run() // After all tests DNSLookupAddr = originalLookupAddr DNSLookupHost = originalLookupHost // Exit if exitCode != 0 { panic(exitCode) } } func TestDns_ArpaReverseIP(t *testing.T) { d := newTestDNS(0, 0) tests := []struct { name string ip string want string wantErr bool }{ {"ipv4", "192.0.2.1", "1.2.0.192", false}, {"ipv6", "2001:db8::1", "1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2", false}, {"invalid ip", "invalid", "invalid", true}, {"ipv4-mapped ipv6", "::ffff:192.0.2.1", "1.2.0.192", false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := d.ArpaReverseIP(tt.ip) if (err != nil) != tt.wantErr { t.Errorf("ArpaReverseIP() error = %v, wantErr %v", err, tt.wantErr) return } if got != tt.want { t.Errorf("ArpaReverseIP() = %v, want %v", got, tt.want) } }) } } func TestDns_ReverseDNS(t *testing.T) { d := newTestDNS(1, 1) // short TTL for testing cache // First call - cache miss t.Run("cache miss", func(t *testing.T) { got, err := d.ReverseDNS("8.8.8.8") if err != nil { t.Fatalf("ReverseDNS() error = %v", err) } want := []string{"dns.google"} if !reflect.DeepEqual(got, want) { t.Errorf("ReverseDNS() = %v, want %v", got, want) } }) // Second call - cache hit t.Run("cache hit", func(t *testing.T) { // Temporarily replace lookup function to ensure cache is used originalLookupAddr := DNSLookupAddr DNSLookupAddr = func(addr string) ([]string, error) { return nil, errors.New("should not be called") } defer func() { DNSLookupAddr = originalLookupAddr }() got, err := d.ReverseDNS("8.8.8.8") if err != nil { t.Fatalf("ReverseDNS() error = %v", err) } want := []string{"dns.google"} if !reflect.DeepEqual(got, want) { t.Errorf("ReverseDNS() = %v, want %v", got, want) } }) // Test cache expiration t.Run("cache expiration", func(t *testing.T) { time.Sleep(2 * time.Second) // Now the cache should be expired // We expect the mock to be called again // To test this we will change the mock to return something different originalLookupAddr := DNSLookupAddr DNSLookupAddr = func(addr string) ([]string, error) { if addr == "8.8.8.8" { return []string{"expired.google."}, nil } return mockLookupAddr(addr) } defer func() { DNSLookupAddr = originalLookupAddr }() got, err := d.ReverseDNS("8.8.8.8") if err != nil { t.Fatalf("ReverseDNS() error = %v", err) } want := []string{"expired.google"} if !reflect.DeepEqual(got, want) { t.Errorf("ReverseDNS() = %v, want %v", got, want) } }) // Test not found t.Run("not found", func(t *testing.T) { got, err := d.ReverseDNS("9.9.9.9") if err != nil { t.Fatalf("ReverseDNS() error = %v", err) } if len(got) != 0 { t.Errorf("ReverseDNS() = %v, want empty slice", got) } }) } func TestDns_LookupHost(t *testing.T) { d := newTestDNS(1, 1) t.Run("cache miss", func(t *testing.T) { got, err := d.LookupHost("dns.google") if err != nil { t.Fatalf("LookupHost() error = %v", err) } want := []string{"8.8.8.8", "8.8.4.4"} if !reflect.DeepEqual(got, want) { t.Errorf("LookupHost() = %v, want %v", got, want) } }) t.Run("cache hit", func(t *testing.T) { originalLookupHost := DNSLookupHost DNSLookupHost = func(host string) ([]string, error) { return nil, errors.New("should not be called") } defer func() { DNSLookupHost = originalLookupHost }() got, err := d.LookupHost("dns.google") if err != nil { t.Fatalf("LookupHost() error = %v", err) } want := []string{"8.8.8.8", "8.8.4.4"} if !reflect.DeepEqual(got, want) { t.Errorf("LookupHost() = %v, want %v", got, want) } }) t.Run("cache expiration", func(t *testing.T) { time.Sleep(2 * time.Second) originalLookupHost := DNSLookupHost DNSLookupHost = func(host string) ([]string, error) { if host == "dns.google" { return []string{"9.9.9.9"}, nil } return mockLookupHost(host) } defer func() { DNSLookupHost = originalLookupHost }() got, err := d.LookupHost("dns.google") if err != nil { t.Fatalf("LookupHost() error = %v", err) } want := []string{"9.9.9.9"} if !reflect.DeepEqual(got, want) { t.Errorf("LookupHost() = %v, want %v", got, want) } }) t.Run("not found", func(t *testing.T) { got, err := d.LookupHost("example.com") if err != nil { t.Fatalf("LookupHost() error = %v", err) } if len(got) != 0 { t.Errorf("LookupHost() = %v, want empty slice", got) } }) } func TestDns_VerifyFCrDNS(t *testing.T) { d := newTestDNS(1, 1) // Helper to convert string to *string p := func(s string) *string { return &s } tests := []struct { name string ip string pattern *string want bool }{ // Cases without pattern {"valid no pattern", "8.8.8.8", nil, true}, {"valid partial no pattern", "1.1.1.1", nil, true}, {"not found no pattern", "9.9.9.9", nil, true}, {"unknown error no pattern", "1.2.3.4", nil, false}, // Cases with pattern {"valid match", "8.8.8.8", p(`.*\.google$`), true}, {"valid no match", "8.8.8.8", p(`\.com$`), false}, {"not found with pattern", "9.9.9.9", p(".*"), false}, {"unknown error with pattern", "1.2.3.4", p(".*"), false}, {"invalid pattern", "8.8.8.8", p(`[`), false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := d.VerifyFCrDNS(tt.ip, tt.pattern); got != tt.want { t.Errorf("VerifyFCrDNS() = %v, want %v", got, tt.want) } }) } t.Run("reverse cache hit", func(t *testing.T) { // Prime the cache if got := d.VerifyFCrDNS("8.8.8.8", nil); got != true { t.Fatalf("VerifyFCrDNS() priming failed, got %v, want true", got) } // Now test with a failing lookup to ensure cache is used originalLookupAddr := DNSLookupAddr DNSLookupAddr = func(addr string) ([]string, error) { return nil, errors.New("should not be called") } defer func() { DNSLookupAddr = originalLookupAddr }() if got := d.VerifyFCrDNS("8.8.8.8", nil); got != true { t.Errorf("VerifyFCrDNS() = %v, want true", got) } }) t.Run("forward cache hit", func(t *testing.T) { // Prime the cache if got := d.VerifyFCrDNS("8.8.8.8", nil); got != true { t.Fatalf("VerifyFCrDNS() priming failed, got %v, want true", got) } // Now test with a failing lookup to ensure cache is used originalLookupHost := DNSLookupHost DNSLookupHost = func(host string) ([]string, error) { return nil, errors.New("should not be called") } defer func() { DNSLookupHost = originalLookupHost }() if got := d.VerifyFCrDNS("8.8.8.8", nil); got != true { t.Errorf("VerifyFCrDNS() = %v, want true", got) } }) } ================================================ FILE: internal/dnsbl/dnsbl.go ================================================ package dnsbl import ( "errors" "fmt" "net" "strings" ) //go:generate go tool golang.org/x/tools/cmd/stringer -type=DroneBLResponse type DroneBLResponse byte const ( AllGood DroneBLResponse = 0 IRCDrone DroneBLResponse = 3 Bottler DroneBLResponse = 5 UnknownSpambotOrDrone DroneBLResponse = 6 DDOSDrone DroneBLResponse = 7 SOCKSProxy DroneBLResponse = 8 HTTPProxy DroneBLResponse = 9 ProxyChain DroneBLResponse = 10 OpenProxy DroneBLResponse = 11 OpenDNSResolver DroneBLResponse = 12 BruteForceAttackers DroneBLResponse = 13 OpenWingateProxy DroneBLResponse = 14 CompromisedRouter DroneBLResponse = 15 AutoRootingWorms DroneBLResponse = 16 AutoDetectedBotIP DroneBLResponse = 17 Unknown DroneBLResponse = 255 ) func Reverse(ip net.IP) string { if ip.To4() != nil { return reverse4(ip) } return reverse6(ip) } func reverse4(ip net.IP) string { splitAddress := strings.Split(ip.String(), ".") // swap first and last octet splitAddress[0], splitAddress[3] = splitAddress[3], splitAddress[0] // swap middle octets splitAddress[1], splitAddress[2] = splitAddress[2], splitAddress[1] return strings.Join(splitAddress, ".") } func reverse6(ip net.IP) string { ipBytes := []byte(ip) var sb strings.Builder for i := len(ipBytes) - 1; i >= 0; i-- { // Split the byte into two nibbles highNibble := ipBytes[i] >> 4 lowNibble := ipBytes[i] & 0x0F // Append the nibbles in reversed order sb.WriteString(fmt.Sprintf("%x.%x.", lowNibble, highNibble)) } return sb.String()[:len(sb.String())-1] } func Lookup(ipStr string) (DroneBLResponse, error) { ip := net.ParseIP(ipStr) if ip == nil { return Unknown, errors.New("dnsbl: input is not an IP address") } revIP := Reverse(ip) + ".dnsbl.dronebl.org" ips, err := net.LookupIP(revIP) if err != nil { var dnserr *net.DNSError if errors.As(err, &dnserr) { if dnserr.IsNotFound { return AllGood, nil } } return Unknown, err } if len(ips) != 0 { for _, ip := range ips { return DroneBLResponse(ip.To4()[3]), nil } } return UnknownSpambotOrDrone, nil } ================================================ FILE: internal/dnsbl/dnsbl_test.go ================================================ package dnsbl import ( "fmt" "net" "os" "testing" ) func TestReverse4(t *testing.T) { cases := []struct { inp, out string }{ {"1.2.3.4", "4.3.2.1"}, } for _, cs := range cases { t.Run(fmt.Sprintf("%s->%s", cs.inp, cs.out), func(t *testing.T) { out := reverse4(net.ParseIP(cs.inp)) if out != cs.out { t.Errorf("wanted %s\ngot: %s", cs.out, out) } }) } } func TestReverse6(t *testing.T) { cases := []struct { inp, out string }{ { inp: "1234:5678:9ABC:DEF0:1234:5678:9ABC:DEF0", out: "0.f.e.d.c.b.a.9.8.7.6.5.4.3.2.1.0.f.e.d.c.b.a.9.8.7.6.5.4.3.2.1", }, } for _, cs := range cases { t.Run(fmt.Sprintf("%s->%s", cs.inp, cs.out), func(t *testing.T) { out := reverse6(net.ParseIP(cs.inp)) if out != cs.out { t.Errorf("wanted %s, got: %s", cs.out, out) } }) } } func TestLookup(t *testing.T) { if os.Getenv("DONT_USE_NETWORK") != "" { t.Skip("test requires network egress") return } resp, err := Lookup("27.65.243.194") if err != nil { t.Fatalf("it broked: %v", err) } t.Logf("response: %d", resp) } ================================================ FILE: internal/dnsbl/droneblresponse_string.go ================================================ // Code generated by "stringer -type=DroneBLResponse"; DO NOT EDIT. package dnsbl import "strconv" func _() { // An "invalid array index" compiler error signifies that the constant values have changed. // Re-run the stringer command to generate them again. var x [1]struct{} _ = x[AllGood-0] _ = x[IRCDrone-3] _ = x[Bottler-5] _ = x[UnknownSpambotOrDrone-6] _ = x[DDOSDrone-7] _ = x[SOCKSProxy-8] _ = x[HTTPProxy-9] _ = x[ProxyChain-10] _ = x[OpenProxy-11] _ = x[OpenDNSResolver-12] _ = x[BruteForceAttackers-13] _ = x[OpenWingateProxy-14] _ = x[CompromisedRouter-15] _ = x[AutoRootingWorms-16] _ = x[AutoDetectedBotIP-17] _ = x[Unknown-255] } const ( _DroneBLResponse_name_0 = "AllGood" _DroneBLResponse_name_1 = "IRCDrone" _DroneBLResponse_name_2 = "BottlerUnknownSpambotOrDroneDDOSDroneSOCKSProxyHTTPProxyProxyChainOpenProxyOpenDNSResolverBruteForceAttackersOpenWingateProxyCompromisedRouterAutoRootingWormsAutoDetectedBotIP" _DroneBLResponse_name_3 = "Unknown" ) var ( _DroneBLResponse_index_2 = [...]uint8{0, 7, 28, 37, 47, 56, 66, 75, 90, 109, 125, 142, 158, 175} ) func (i DroneBLResponse) String() string { switch { case i == 0: return _DroneBLResponse_name_0 case i == 3: return _DroneBLResponse_name_1 case 5 <= i && i <= 17: i -= 5 return _DroneBLResponse_name_2[_DroneBLResponse_index_2[i]:_DroneBLResponse_index_2[i+1]] case i == 255: return _DroneBLResponse_name_3 default: return "DroneBLResponse(" + strconv.FormatInt(int64(i), 10) + ")" } } ================================================ FILE: internal/glob/glob.go ================================================ package glob import "strings" const GLOB = "*" const maxGlobParts = 5 // Glob will test a string pattern, potentially containing globs, against a // subject string. The result is a simple true/false, determining whether or // not the glob pattern matched the subject text. func Glob(pattern, subj string) bool { // Empty pattern can only match empty subject if pattern == "" { return subj == pattern } // If the pattern _is_ a glob, it matches everything if pattern == GLOB { return true } parts := strings.Split(pattern, GLOB) if len(parts) > maxGlobParts { return false // Pattern is too complex, reject it. } if len(parts) == 1 { // No globs in pattern, so test for equality return subj == pattern } leadingGlob := strings.HasPrefix(pattern, GLOB) trailingGlob := strings.HasSuffix(pattern, GLOB) end := len(parts) - 1 // Go over the leading parts and ensure they match. for i := range end { idx := strings.Index(subj, parts[i]) switch i { case 0: // Check the first section. Requires special handling. if !leadingGlob && idx != 0 { return false } default: // Check that the middle parts match. if idx < 0 { return false } } // Trim evaluated text from subj as we loop over the pattern. subj = subj[idx+len(parts[i]):] } // Reached the last section. Requires special handling. return trailingGlob || strings.HasSuffix(subj, parts[end]) } ================================================ FILE: internal/glob/glob_test.go ================================================ package glob import "testing" func TestGlob_EqualityAndEmpty(t *testing.T) { cases := []struct { name string pattern string subj string want bool }{ {"exact match", "hello", "hello", true}, {"exact mismatch", "hello", "hell", false}, {"empty pattern and subject", "", "", true}, {"empty pattern with non-empty subject", "", "x", false}, {"pattern star matches empty", "*", "", true}, {"pattern star matches anything", "*", "anything at all", true}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { if got := Glob(tc.pattern, tc.subj); got != tc.want { t.Fatalf("Glob(%q,%q) = %v, want %v", tc.pattern, tc.subj, got, tc.want) } }) } } func TestGlob_LeadingAndTrailing(t *testing.T) { cases := []struct { name string pattern string subj string want bool }{ {"prefix match - minimal", "foo*", "foo", true}, {"prefix match - extended", "foo*", "foobar", true}, {"prefix mismatch - not at start", "foo*", "xfoo", false}, {"suffix match - minimal", "*foo", "foo", true}, {"suffix match - extended", "*foo", "xfoo", true}, {"suffix mismatch - not at end", "*foo", "foox", false}, {"contains match", "*foo*", "barfoobaz", true}, {"contains mismatch - missing needle", "*foo*", "f", false}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { if got := Glob(tc.pattern, tc.subj); got != tc.want { t.Fatalf("Glob(%q,%q) = %v, want %v", tc.pattern, tc.subj, got, tc.want) } }) } } func TestGlob_MiddleAndOrder(t *testing.T) { cases := []struct { name string pattern string subj string want bool }{ {"middle wildcard basic", "f*o", "fo", true}, {"middle wildcard gap", "f*o", "fZZZo", true}, {"middle wildcard requires start f", "f*o", "xfyo", false}, {"order enforced across parts", "a*b*c*d", "axxbxxcxxd", true}, {"order mismatch fails", "a*b*c*d", "abdc", false}, {"must end with last part when no trailing *", "*foo*bar", "zzfooqqbar", true}, {"failing when trailing chars remain", "*foo*bar", "zzfooqqbarzz", false}, {"first part must start when no leading *", "foo*bar", "zzfooqqbar", false}, {"works with overlapping content", "ab*ba", "ababa", true}, {"needle not found fails", "foo*bar", "foobaz", false}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { if got := Glob(tc.pattern, tc.subj); got != tc.want { t.Fatalf("Glob(%q,%q) = %v, want %v", tc.pattern, tc.subj, got, tc.want) } }) } } func TestGlob_ConsecutiveStarsAndEmptyParts(t *testing.T) { cases := []struct { name string pattern string subj string want bool }{ {"double star matches anything", "**", "", true}, {"double star matches anything non-empty", "**", "abc", true}, {"consecutive stars behave like single", "a**b", "ab", true}, {"consecutive stars with gaps", "a**b", "axxxb", true}, {"consecutive stars + trailing star", "a**b*", "axxbzzz", true}, {"consecutive stars still enforce anchors", "a**b", "xaBy", false}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { if got := Glob(tc.pattern, tc.subj); got != tc.want { t.Fatalf("Glob(%q,%q) = %v, want %v", tc.pattern, tc.subj, got, tc.want) } }) } } func TestGlob_MaxPartsLimit(t *testing.T) { // Allowed: up to 4 '*' (5 parts) allowed := []struct { pattern string subj string want bool }{ {"a*b*c*d*e", "axxbxxcxxdxxe", true}, // 4 stars -> 5 parts {"*a*b*c*d", "zzzaaaabbbcccddd", true}, {"a*b*c*d*e", "abcde", true}, {"a*b*c*d*e", "abxdxe", false}, // missing 'c' should fail } for _, tc := range allowed { if got := Glob(tc.pattern, tc.subj); got != tc.want { t.Fatalf("allowed pattern Glob(%q,%q) = %v, want %v", tc.pattern, tc.subj, got, tc.want) } } // Disallowed: 5 '*' (6 parts) -> always false by complexity check disallowed := []struct { pattern string subj string }{ {"a*b*c*d*e*f", "aXXbYYcZZdQQeRRf"}, {"*a*b*c*d*e*", "abcdef"}, {"******", "anything"}, // 6 stars -> 7 parts } for _, tc := range disallowed { if got := Glob(tc.pattern, tc.subj); got { t.Fatalf("disallowed pattern should fail Glob(%q,%q) = %v, want false", tc.pattern, tc.subj, got) } } } func TestGlob_CaseSensitivity(t *testing.T) { cases := []struct { pattern string subj string want bool }{ {"FOO*", "foo", false}, {"*Bar", "bar", false}, {"Foo*Bar", "FooZZZBar", true}, } for _, tc := range cases { if got := Glob(tc.pattern, tc.subj); got != tc.want { t.Fatalf("Glob(%q,%q) = %v, want %v", tc.pattern, tc.subj, got, tc.want) } } } func TestGlob_EmptySubjectInteractions(t *testing.T) { cases := []struct { pattern string subj string want bool }{ {"*a", "", false}, {"a*", "", false}, {"**", "", true}, {"*", "", true}, } for _, tc := range cases { if got := Glob(tc.pattern, tc.subj); got != tc.want { t.Fatalf("Glob(%q,%q) = %v, want %v", tc.pattern, tc.subj, got, tc.want) } } } func BenchmarkGlob(b *testing.B) { patterns := []string{ "*", "*foo*", "foo*bar", "a*b*c*d*e", "a**b*", "*needle*end", } subjects := []string{ "", "foo", "barfoo", "foobarbaz", "axxbxxcxxdxxe", "zzfooqqbarzz", "lorem ipsum dolor sit amet, consectetur adipiscing elit", } for _, p := range patterns { for _, s := range subjects { b.Run(p+"::"+s, func(b *testing.B) { for i := 0; i < b.N; i++ { _ = Glob(p, s) } }) } } } ================================================ FILE: internal/gzip.go ================================================ package internal import ( "compress/gzip" "net/http" "strings" ) func GzipMiddleware(level int, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") { next.ServeHTTP(w, r) return } w.Header().Set("Content-Encoding", "gzip") gz, err := gzip.NewWriterLevel(w, level) if err != nil { panic(err) } defer gz.Close() grw := gzipResponseWriter{ResponseWriter: w, sink: gz} next.ServeHTTP(grw, r) }) } type gzipResponseWriter struct { http.ResponseWriter sink *gzip.Writer } func (w gzipResponseWriter) Write(b []byte) (int, error) { return w.sink.Write(b) } ================================================ FILE: internal/hash.go ================================================ package internal import ( "crypto/sha256" "encoding/hex" "strconv" "github.com/cespare/xxhash/v2" ) // SHA256sum computes a cryptographic hash. Still used for proof-of-work challenges // where we need the security properties of a cryptographic hash function. func SHA256sum(text string) string { hash := sha256.New() hash.Write([]byte(text)) return hex.EncodeToString(hash.Sum(nil)) } // FastHash is a high-performance non-cryptographic hash function suitable for // internal caching, policy rule identification, and other performance-critical // use cases where cryptographic security is not required. func FastHash(text string) string { h := xxhash.Sum64String(text) return strconv.FormatUint(h, 16) } ================================================ FILE: internal/hash_bench_test.go ================================================ package internal import ( "fmt" "strings" "testing" ) // XXHash64sum is a test alias for FastHash to benchmark against SHA256 func XXHash64sum(text string) string { return FastHash(text) } // Test data that matches real usage patterns in the codebase var ( // Typical policy checker inputs policyInputs = []string{ "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "User-Agent: bot/1.0", "User-Agent: GoogleBot/2.1", "/robots.txt", "/api/.*", "10.0.0.0/8", "192.168.1.0/24", "172.16.0.0/12", } // Challenge data from challengeFor function challengeInputs = []string{ "Accept-Language=en-US,X-Real-IP=192.168.1.100,User-Agent=Mozilla/5.0,WeekTime=2025-06-16T00:00:00Z,Fingerprint=abc123,Difficulty=5", "Accept-Language=fr-FR,X-Real-IP=10.0.0.50,User-Agent=Chrome/91.0,WeekTime=2025-06-16T00:00:00Z,Fingerprint=def456,Difficulty=3", "Accept-Language=es-ES,X-Real-IP=172.16.1.1,User-Agent=Safari/14.0,WeekTime=2025-06-16T00:00:00Z,Fingerprint=ghi789,Difficulty=7", } // Bot rule patterns botRuleInputs = []string{ "GoogleBot::path:/robots.txt", "BingBot::useragent:Mozilla/5.0 (compatible; bingbot/2.0)", "FacebookBot::headers:Accept-Language,User-Agent", "TwitterBot::cidr:192.168.1.0/24", } // CEL expressions from policy rules celInputs = []string{ `request.headers["User-Agent"].contains("bot")`, `request.path.startsWith("/api/") && request.method == "POST"`, `request.remoteAddress in ["192.168.1.0/24", "10.0.0.0/8"]`, `request.userAgent.matches(".*[Bb]ot.*") || request.userAgent.matches(".*[Cc]rawler.*")`, } // Thoth ASN checker inputs asnInputs = []string{ "ASNChecker\nAS 15169\nAS 8075\nAS 32934", "ASNChecker\nAS 13335\nAS 16509\nAS 14061", "ASNChecker\nAS 36351\nAS 20940\nAS 8100", } ) func BenchmarkSHA256_PolicyInputs(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { input := policyInputs[i%len(policyInputs)] _ = SHA256sum(input) } } func BenchmarkXXHash_PolicyInputs(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { input := policyInputs[i%len(policyInputs)] _ = XXHash64sum(input) } } func BenchmarkSHA256_ChallengeInputs(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { input := challengeInputs[i%len(challengeInputs)] _ = SHA256sum(input) } } func BenchmarkXXHash_ChallengeInputs(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { input := challengeInputs[i%len(challengeInputs)] _ = XXHash64sum(input) } } func BenchmarkSHA256_BotRuleInputs(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { input := botRuleInputs[i%len(botRuleInputs)] _ = SHA256sum(input) } } func BenchmarkXXHash_BotRuleInputs(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { input := botRuleInputs[i%len(botRuleInputs)] _ = XXHash64sum(input) } } func BenchmarkSHA256_CELInputs(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { input := celInputs[i%len(celInputs)] _ = SHA256sum(input) } } func BenchmarkXXHash_CELInputs(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { input := celInputs[i%len(celInputs)] _ = XXHash64sum(input) } } func BenchmarkSHA256_ASNInputs(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { input := asnInputs[i%len(asnInputs)] _ = SHA256sum(input) } } func BenchmarkXXHash_ASNInputs(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { input := asnInputs[i%len(asnInputs)] _ = XXHash64sum(input) } } // Benchmark the policy list hashing used in checker.go func BenchmarkSHA256_PolicyList(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { var sb strings.Builder for _, input := range policyInputs { fmt.Fprintln(&sb, SHA256sum(input)) } _ = SHA256sum(sb.String()) } } func BenchmarkXXHash_PolicyList(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { var sb strings.Builder for _, input := range policyInputs { fmt.Fprintln(&sb, XXHash64sum(input)) } _ = XXHash64sum(sb.String()) } } // Tests that xxhash doesn't have collisions in realistic scenarios func TestHashCollisions(t *testing.T) { allInputs := append(append(append(append(policyInputs, challengeInputs...), botRuleInputs...), celInputs...), asnInputs...) // Start with realistic inputs from actual usage xxhashHashes := make(map[string]string) for _, input := range allInputs { hash := XXHash64sum(input) if existing, exists := xxhashHashes[hash]; exists { t.Errorf("XXHash collision detected: %q and %q both hash to %s", input, existing, hash) } xxhashHashes[hash] = input } t.Logf("Basic test: %d realistic inputs, no collisions", len(allInputs)) // Test similar strings that might cause hash collisions prefixes := []string{"User-Agent: ", "X-Real-IP: ", "Accept-Language: ", "Host: "} suffixes := []string{"bot", "crawler", "spider", "scraper", "Mozilla", "Chrome", "Safari", "Firefox"} variations := []string{"", "/1.0", "/2.0", " (compatible)", " (Windows)", " (Linux)", " (Mac)"} stressCount := 0 for _, prefix := range prefixes { for _, suffix := range suffixes { for _, variation := range variations { for i := range 100 { input := fmt.Sprintf("%s%s%s-%d", prefix, suffix, variation, i) hash := XXHash64sum(input) if existing, exists := xxhashHashes[hash]; exists { t.Errorf("XXHash collision in stress test: %q and %q both hash to %s", input, existing, hash) } xxhashHashes[hash] = input stressCount++ } } } } t.Logf("Stress test 1: %d similar string variations, no collisions", stressCount) // Test sequential patterns that might be problematic patterns := []string{ "192.168.1.%d", "10.0.0.%d", "172.16.%d.1", "challenge-%d", "bot-rule-%d", "policy-%016x", "session-%016x", } seqCount := 0 for _, pattern := range patterns { for i := range 10000 { input := fmt.Sprintf(pattern, i) hash := XXHash64sum(input) if existing, exists := xxhashHashes[hash]; exists { t.Errorf("XXHash collision in sequential test: %q and %q both hash to %s", input, existing, hash) } xxhashHashes[hash] = input seqCount++ } } t.Logf("Stress test 2: %d sequential patterns, no collisions", seqCount) totalInputs := len(allInputs) + stressCount + seqCount t.Logf("TOTAL: Tested %d inputs across realistic scenarios - NO COLLISIONS", totalInputs) } // Verify xxhash output works as cache keys func TestXXHashFormat(t *testing.T) { testCases := []string{ "short", "", "very long string with lots of content that might be used in policy checking and other internal hashing scenarios", "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", } for _, input := range testCases { hash := XXHash64sum(input) // Check it's valid hex if len(hash) == 0 { t.Errorf("Empty hash for input %q", input) } // xxhash is 64-bit so max 16 hex chars if len(hash) > 16 { t.Errorf("Hash too long for input %q: %s (length %d)", input, hash, len(hash)) } // Make sure it's all hex characters for _, char := range hash { if !((char >= '0' && char <= '9') || (char >= 'a' && char <= 'f')) { t.Errorf("Non-hex character %c in hash %s for input %q", char, hash, input) } } t.Logf("Input: %q -> Hash: %s", input, hash) } } ================================================ FILE: internal/headers.go ================================================ package internal import ( "context" "errors" "fmt" "log/slog" "net" "net/http" "net/netip" "strings" "github.com/TecharoHQ/anubis" "github.com/sebest/xff" ) type realIPKey struct{} func RealIP(r *http.Request) (netip.Addr, bool) { result, ok := r.Context().Value(realIPKey{}).(netip.Addr) return result, ok } // TODO: move into config type XFFComputePreferences struct { StripPrivate bool StripLoopback bool StripCGNAT bool StripLLU bool Flatten bool } var CGNat = netip.MustParsePrefix("100.64.0.0/10") // 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 } return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "public, max-age=31536000") next.ServeHTTP(w, r) }) } // CustomXRealIPHeader sets the X-Real-IP header to the value of a // different header. // Used in environments where the upstream proxy sets the request's // origin IP in a custom header. func CustomRealIPHeader(customRealIPHeaderValue string, next http.Handler) http.Handler { if customRealIPHeaderValue == "" { slog.Debug("skipping middleware, customRealIPHeaderValue is empty") return next } return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { r.Header.Set("X-Real-IP", r.Header.Get(customRealIPHeaderValue)) next.ServeHTTP(w, r) }) } // 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) { host, _, err := net.SplitHostPort(r.RemoteAddr) if err != nil { panic(err) // this should never happen } r.Header.Set("X-Real-Ip", host) if addr, err := netip.ParseAddr(host); err == nil { r = r.WithContext(context.WithValue(r.Context(), realIPKey{}, addr)) } next.ServeHTTP(w, r) }) } // XForwardedForToXRealIP sets the X-Real-Ip header based on the contents // of the X-Forwarded-For header. func XForwardedForToXRealIP(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if xffHeader := r.Header.Get("X-Forwarded-For"); r.Header.Get("X-Real-Ip") == "" && xffHeader != "" { ip := xff.Parse(xffHeader) slog.Debug("setting X-Real-Ip from X-Forwarded-For", "to", ip, "x-forwarded-for", xffHeader) r.Header.Set("X-Real-Ip", ip) if addr, err := netip.ParseAddr(ip); err == nil { r = r.WithContext(context.WithValue(r.Context(), realIPKey{}, addr)) } } next.ServeHTTP(w, r) }) } // XForwardedForUpdate sets or updates the X-Forwarded-For header, adding // the known remote address to an existing chain if present func XForwardedForUpdate(stripPrivate bool, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { defer next.ServeHTTP(w, r) pref := XFFComputePreferences{ StripPrivate: stripPrivate, StripLoopback: true, StripCGNAT: true, Flatten: true, StripLLU: true, } remoteAddr := r.RemoteAddr origXFFHeader := r.Header.Get("X-Forwarded-For") if remoteAddr == "@" { // remote is a unix socket // do not touch chain return } xffHeaderString, err := computeXFFHeader(remoteAddr, origXFFHeader, pref) if err != nil { slog.Debug("computing X-Forwarded-For header failed", "err", err) return } if len(xffHeaderString) == 0 { r.Header.Del("X-Forwarded-For") } else { r.Header.Set("X-Forwarded-For", xffHeaderString) } }) } var ( ErrCantSplitHostParse = errors.New("internal: unable to net.SplitHostParse") ErrCantParseRemoteIP = errors.New("internal: unable to parse remote IP") ) func computeXFFHeader(remoteAddr string, origXFFHeader string, pref XFFComputePreferences) (string, error) { remoteIP, _, err := net.SplitHostPort(remoteAddr) if err != nil { return "", fmt.Errorf("%w: %w", ErrCantSplitHostParse, err) } parsedRemoteIP, err := netip.ParseAddr(remoteIP) if err != nil { return "", fmt.Errorf("%w: %w", ErrCantParseRemoteIP, err) } origForwardedList := make([]string, 0, 4) if origXFFHeader != "" { origForwardedList = strings.Split(origXFFHeader, ",") for i := range origForwardedList { origForwardedList[i] = strings.TrimSpace(origForwardedList[i]) } } origForwardedList = append(origForwardedList, parsedRemoteIP.String()) forwardedList := make([]string, 0, len(origForwardedList)) // this behavior is equivalent to // ingress-nginx "compute-full-forwarded-for" // https://kubernetes.github.io/ingress-nginx/user-guide/nginx-configuration/configmap/#compute-full-forwarded-for // // this would be the correct place to strip and/or flatten this list // // strip - iterate backwards and eliminate configured trusted IPs // flatten - only return the last element to avoid spoofing confusion // // many applications handle this in different ways, but // generally they'd be expected to do these two things on // their own end to find the first non-spoofed IP for i := len(origForwardedList) - 1; i >= 0; i-- { segmentIP, err := netip.ParseAddr(strings.TrimSpace(origForwardedList[i])) if err != nil { // can't assess this element, so the remainder of the chain // can't be trusted. not a fatal error, since anyone can // spoof an XFF header slog.Debug("failed to parse XFF segment", "err", err) break } if pref.StripPrivate && segmentIP.IsPrivate() { continue } if pref.StripLoopback && segmentIP.IsLoopback() { continue } if pref.StripLLU && segmentIP.IsLinkLocalUnicast() { continue } if pref.StripCGNAT && CGNat.Contains(segmentIP) { continue } forwardedList = append([]string{segmentIP.String()}, forwardedList...) } var xffHeaderString string if len(forwardedList) == 0 { xffHeaderString = "" return xffHeaderString, nil } if pref.Flatten { xffHeaderString = forwardedList[len(forwardedList)-1] } else { xffHeaderString = strings.Join(forwardedList, ",") } return xffHeaderString, nil } // 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) }) } // NoBrowsing prevents directory browsing by returning a 404 for any request that ends with a "/". 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) }) } ================================================ FILE: internal/health.go ================================================ package internal import ( "context" "google.golang.org/grpc/health" healthv1 "google.golang.org/grpc/health/grpc_health_v1" ) var HealthSrv = health.NewServer() func SetHealth(svc string, status healthv1.HealthCheckResponse_ServingStatus) { HealthSrv.SetServingStatus(svc, status) } func GetHealth(svc string) (healthv1.HealthCheckResponse_ServingStatus, bool) { st, err := HealthSrv.Check(context.Background(), &healthv1.HealthCheckRequest{ Service: svc, }) if err != nil { return healthv1.HealthCheckResponse_UNKNOWN, false } return st.GetStatus(), true } ================================================ FILE: internal/honeypot/honeypot.go ================================================ package honeypot import ( "time" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" ) var Timings = promauto.NewHistogramVec(prometheus.HistogramOpts{ Namespace: "anubis", Subsystem: "honeypot", Name: "pagegen_timings", Help: "The amount of time honeypot page generation takes per method", Buckets: prometheus.ExponentialBuckets(0.5, 2, 32), }, []string{"method"}) type Info struct { CreatedAt time.Time `json:"createdAt"` UserAgent string `json:"userAgent"` IPAddress string `json:"ipAddress"` HitCount int `json:"hitCount"` } ================================================ FILE: internal/honeypot/naive/100bytes.css ================================================ html { max-width: 70ch; padding: 3em 1em; margin: auto; line-height: 1.75; font-size: 1.25em; } ================================================ FILE: internal/honeypot/naive/affirmations.txt ================================================ {Yeah|Yep|Yup|Yes|Absolutely|Definitely|Sure|Sounds|That's|I'm|I am|Totally|Completely|Right|Correct|Exactly|Perfectly|Certainly|Of course|Naturally|Indeed|Awesome|Sweet|Cool|Neat|Great|Excellent|Fantastic|Wonderful|Amazing|Love it|Nice|Right on|You bet|For sure|No doubt|Without a doubt|Undoubtedly|Positively|Surely|Truly|Really|Genuinely|Honestly|Frankly|Literally|Precisely|Spot on|On point|Ideally|Optimally|Superbly|Brilliantly|Marvelously|Splendidly|Magnificently|Phenomenally|Extraordinarily|Remarkably|Exceptionally|Outstandingly|Impressively|Stunningly|Breathtakingly|Astonishingly|Surprisingly|Pleasantly|Delightfully|Charmingly|Appealingly|Attractively|Invitingly|Encouragingly|Motivatingly|Inspiringly|Upliftingly|Positive|Optimistic|Supportive|Approving|Favorable|Enthusiastic|Eager|Willing|Ready|Prepared|Set|Go|Let's|Alright|Okay|Sure thing|No problem|You got it|Consider it done|Will do|Roger that|Copy that|Got it|Understood|Acknowledged|Noted|Confirmed|Agreed|Approved|Accepted|Endorsed|Backed|Championed} {sounds|looks|seems|feels|is|appears|comes across|strikes me|hits me|registers|resonates|clicks|makes sense|fits|works|functions|operates|performs|delivers|succeeds|achieves|accomplishes|excels|shines|stands out|impresses|satisfies|meets expectations|exceeds expectations|delights|pleases|gratifies|fulfills|completes|finishes|concludes|wraps up|finalizes|settles|resolves|solves|fixes|addresses|handles|manages|tackles|conquers|overcomes|defeats|beats|wins|triumphs|prevails|dominates|leads|guides|directs|steers|navigates|paves the way|opens doors|creates opportunities|makes possible|enables|allows|permits|facilitates|drives|pushes|propels|launches|initiates|starts|begins|commences|kicks off|gets going|moves forward|progresses|advances|develops|evolves|grows|expands|improves|enhances|upgrades|optimizes|refines|perfects|polishes} {good|great|perfect|excellent|wonderful|fantastic|amazing|awesome|fine|okay|alright|nice|cool|spot on|reasonable|about right|superb|brilliant|marvelous|splendid|magnificent|phenomenal|extraordinary|remarkable|exceptional|outstanding|impressive|stunning|breathtaking|astonishing|surprising|pleasant|delightful|charming|appealing|attractive|inviting|positive|optimistic|supportive|approving|favorable|enthusiastic|eager|willing|ready|prepared|set|solid|strong|robust|powerful|effective|efficient|productive|successful|fruitful|beneficial|valuable|useful|helpful|advantageous|profitable|rewarding|satisfying|gratifying|fulfilling|complete|whole|total|entire|full|thorough|comprehensive|exhaustive|detailed|precise|accurate|correct|right|true|valid|sound|logical|rational|practical|realistic|feasible|possible|doable|achievable|attainable|obtainable|reachable|accessible|available|present|arranged|organized|structured|planned|scheduled|timed|well positioned|strategically located|ideally situated|well suited|well matched|compatible|harmonious|balanced|proportional|symmetrical|aesthetic|beautiful|gorgeous|lovely|pretty|handsome|striking|dramatic|bold|confident|assertive|decisive|clear|obvious|apparent|evident|manifest|plain|simple|easy|straightforward|uncomplicated|complex|intricate|nuanced|subtle|refined|elegant|sophisticated|advanced|progressive|innovative|creative|original|unique|special|distinctive|memorable|unforgettable|significant|important|major|key|critical|essential|vital|crucial|fundamental|basic|primary|principal|main|chief|leading|top|best|finest|ultimate|supreme|paramount|foremost|world class|professional|expert|master|skilled|talented|gifted|intelligent|smart|clever|wise|knowledgeable|informed|educated|learned|scholarly|theoretical|practical|applied|hands on|experienced|seasoned|veteran|mature|visionary|prophetic|intuitive|perceptive|insightful|sage|profound|deep|meaningful|substantial|considerable|influential|resilient|tough|durable|lasting|permanent|enduring|timeless|classic|traditional|conventional|standard|regular|normal|typical|usual|common|ordinary|average|fair|decent|respectable|acceptable|satisfactory|adequate|sufficient|enough|plentiful|abundant|ample|generous|rich|wealthy|prosperous|thriving|flourishing|blooming|superior|higher|elevated|modern|contemporary|current|fresh|novel|rare|uncommon|legendary|famous|well known|celebrated|accredited|honored|awarded|decorated|distinguished|illustrious|prestigious|reputable|admired|revered|beloved|cherished|treasured|prized|precious|close|intimate|personal|private|individual|priceless|worthwhile} {to me|for me|with me|I agree|I like it|let's do it|count me in|I'm on board|I'm in|I'm up for it|I'm down for that|I'm all for it|I'm good with that|I'm happy with that|I'm cool with that|let's go with that|let's make it happen|that works|that'll work|sounds like a plan|that's a good idea|that's a great choice|I think so too|my thoughts exactly|you read my mind|couldn't agree more|absolutely right|you nailed it|let's go|game on|challenge accepted|say no more|you had me at hello|I'm sold|sign me up|be there|definitely|for sure|sounds good|looks good|seems good|feels good|is good|let's do this|time to rock|let's roll|here we go|off we go|moving forward|full steam ahead|all systems go|green light|clear for takeoff|ready when you are|on your mark|get set|let's begin|commence operation|initiate protocol|execute plan|implement strategy|deploy solution|activate system|engage process|start procedure|begin sequence|launch project|kick off event|open doors|make way|clear path|pave way|create opportunity|make possible|enable success|facilitate growth|support development|encourage progress|inspire change|motivate action|drive results|push boundaries|break barriers|overcome challenges|solve problems|fix issues|address concerns|handle situations|manage difficulties|tackle obstacles|conquer fears|defeat doubts|win battles|triumph over adversity|prevail against odds|rise above|excel beyond|achieve greatness|reach heights|attain goals|accomplish dreams|realize potential|fulfill destiny|complete journey|finish race|cross finish line|arrive at destination|reach summit|climb mountain|sail seas|fly skies|explore worlds|discover truths|find answers|solve mysteries|uncover secrets|reveal wonders|share insights|spread joy|create happiness|build relationships|strengthen bonds|foster community|grow together|learn constantly|improve daily|evolve continuously|adapt quickly|change rapidly|transform completely|renew fully|refresh completely|restart anew|begin again|start fresh|clean slate|new chapter|fresh start|bright future|promising tomorrow|better days|good times|great moments|wonderful experiences|fantastic adventures|amazing journeys|awesome memories|precious moments|valuable lessons|helpful advice|useful tips|practical solutions|effective strategies|successful methods|proven approaches|tested techniques|reliable systems|dependable support|consistent performance|steady progress|continuous improvement|ongoing development|perpetual growth|endless possibilities|unlimited potential|infinite opportunities|boundless horizons|vast expanses|wide ranges|broad spectrums|diverse options|multiple choices|various paths|different routes|alternative ways|other methods|additional approaches|extra techniques|supplementary tools|auxiliary resources|backup plans|contingency options|emergency measures|safety nets|security blankets|comfort zones|safe spaces|peaceful havens|tranquil sanctuaries|serene environments|calm atmospheres|relaxed vibes|easy feelings|comfortable sensations|pleasant experiences|enjoyable moments|delightful times|charming encounters|appealing situations|attractive prospects|inviting opportunities|encouraging signs|motivating factors|inspiring elements|uplifting aspects|positive features|optimistic views|encouraging outlooks|supportive attitudes|approving perspectives|favorable opinions|enthusiastic responses|eager reactions|willing participants|ready volunteers|prepared individuals|set teams|organized groups|structured units|planned initiatives|scheduled events|timed activities|well positioned assets|strategically located resources|ideally situated elements|perfectly suited components|well matched partners|compatible collaborations|harmonious relationships|balanced arrangements|proportional distributions|symmetrical designs|aesthetic presentations|beautiful displays|gorgeous exhibitions|lovely shows|pretty sights|attractive views|striking scenes|dramatic performances|bold statements|confident expressions|decisive actions|clear communications|obvious demonstrations|apparent revelations|evident truths|manifest realities|plain facts|simple solutions|easy implementations|straightforward processes|uncomplicated procedures|complex systems|intricate networks|detailed analyses|nuanced discussions|subtle distinctions|refined approaches|elegant solutions|sophisticated methods|advanced technologies|progressive ideas|innovative concepts|creative designs|original works|unique creations|special projects|distinctive features|memorable experiences|unforgettable moments|legendary achievements|famous accomplishments|well recognized contributions|acknowledged impacts|celebrated successes|acclaimed performances|honored achievements|awarded excellence|decorated heroes|distinguished leaders|illustrious careers|prestigious positions|reputable organizations|respected institutions|admired figures|revered icons|beloved personalities|cherished treasures|valued possessions|prized collections|precious artifacts|dear friends|close companions|intimate partners|personal connections|individual expressions|unique perspectives|special talents|one of a kind gifts|irreplaceable values|invaluable insights|priceless wisdom|worthwhile endeavors|valuable investments|useful tools|beneficial resources|helpful services|advantageous positions|profitable ventures|rewarding careers|satisfying lives|gratifying experiences|fulfilling purposes|complete beings|whole persons|total entities|entire systems|full cycles|perfect circles|ideal forms|ultimate goals|best practices|finest qualities|supreme achievements|excellent results|outstanding performances|superior outcomes|exceptional contributions|remarkable discoveries|extraordinary breakthroughs|special recognitions|unique innovations|distinctive designs|memorable impacts|impressive feats|dramatic transformations|powerful changes|strong foundations|effective actions|efficient operations|successful missions|productive endeavors|fruitful partnerships|beneficial collaborations|valuable connections|helpful networks|worthwhile projects|rewarding adventures|satisfying journeys|gratifying accomplishments|fulfilling destinies}{|!|, let's go!|, amazing!|, fantastic!|, wonderful!|, perfect!|, brilliant!|, excellent!|, outstanding!|, superb!|, great!|, nice!|, cool!|, sweet!|, awesome!|, love it!|, beautiful!|, gorgeous!|, stunning!|, breathtaking!|, phenomenal!|, extraordinary!|, remarkable!|, exceptional!|, impressive!|, striking!|, dramatic!|, powerful!|, magnificent!|, splendid!|, marvelous!|, terrific!|, superb!|, divine!|, heavenly!|, celestial!|, transcendent!|, sublime!|, perfect!|, flawless!|, impeccable!|, ideal!|, ultimate!|, supreme!|, paramount!|, unbeatable!|, unstoppable!|, incredible!|, unbelievable!|, astounding!|, mind-blowing!|, jaw-dropping!|, spectacular!|, epic!|, legendary!|, iconic!|, classic!|, timeless!|, eternal!|, infinite!|, boundless!|, limitless!|, endless!|, forever!|, always!|, never-ending!|, perpetual!|, constant!|, steady!|, solid!|, rock-solid!|, unshakeable!|, unbreakable!|, invincible!|, indestructible!|, immortal!|, everlasting!|, undying!|, living!|, vibrant!|, dynamic!|, energetic!|, lively!|, spirited!|, enthusiastic!|, passionate!|, fervent!|, zealous!|, dedicated!|, committed!|, devoted!|, loyal!|, faithful!|, true!|, real!|, authentic!|, genuine!|, legit!|, certified!|, proven!|, tested!|, verified!|, confirmed!|, validated!|, approved!|, endorsed!|, supported!|, backed!|, guaranteed!|, assured!|, certain!|, sure!|, positive!|, confident!|, secure!|, safe!|, protected!|, covered!|, sheltered!|, guarded!|, watched over!|, cared for!|, nurtured!|, cherished!|, treasured!|, valued!|, respected!|, admired!|, appreciated!|, recognized!|, acknowledged!|, celebrated!|, honored!|, praised!|, applauded!|, cheered!|, supported!|, embraced!|, welcomed!|, accepted!|, included!|, belonging!|, connected!|, united!|, joined!|, together!|, as one!|, in harmony!|, in sync!|, aligned!|, balanced!|, centered!|, grounded!|, rooted!|, established!|, settled!|, calm!|, peaceful!|, serene!|, tranquil!|, quiet!|, still!|, at ease!|, comfortable!|, relaxed!|, content!|, happy!|, joyful!|, delighted!|, thrilled!|, excited!|, elated!|, ecstatic!|, overjoyed!|, euphoric!|, blissful!|, radiant!|, glowing!|, shining!|, sparkling!|, dazzling!|, brilliant!|, bright!|, luminous!|, illuminated!|, enlightened!|, inspired!|, uplifted!|, elevated!|, empowered!|, strengthened!|, fortified!|, revitalized!|, renewed!|, refreshed!|, recharged!|, energized!|, activated!|, awakened!|, alive!|, thriving!|, flourishing!|, blooming!|, growing!|, expanding!|, developing!|, evolving!|, transforming!|, becoming!|, emerging!|, rising!|, ascending!|, climbing!|, reaching!|, achieving!|, succeeding!|, winning!|, triumphing!|, conquering!|, overcoming!|, mastering!|, perfecting!|, completing!|, fulfilling!|, realizing!|, manifesting!|, creating!|, building!|, making!|, doing!|, being!|, living!|, breathing!|, existing!|, present!|, here!|, now!|, always!|, forever!|, eternally!} ================================================ FILE: internal/honeypot/naive/naive.go ================================================ package naive import ( "context" _ "embed" "fmt" "log/slog" "math/rand/v2" "net/http" "net/netip" "time" "github.com/TecharoHQ/anubis/internal" "github.com/TecharoHQ/anubis/internal/honeypot" "github.com/TecharoHQ/anubis/lib/policy/checker" "github.com/TecharoHQ/anubis/lib/store" "github.com/a-h/templ" "github.com/google/uuid" "github.com/nikandfor/spintax" ) //go:generate go tool github.com/a-h/templ/cmd/templ generate // XXX(Xe): All of this was generated by ChatGPT, GLM 4.6, and GPT-OSS 120b. This is pseudoprofound bullshit in spintax[1] format so that the bullshit generator can emit plausibly human-authored text while being very computationally cheap. // // It feels somewhat poetic to use spammer technology in Anubis. // // [1]: https://outboundly.ai/blogs/what-is-spintax-and-how-to-use-it/ // //go:embed spintext.txt var spintext string //go:embed titles.txt var titles string //go:embed affirmations.txt var affirmations string func New(st store.Interface, lg *slog.Logger) (*Impl, error) { affirmation, err := spintax.Parse(affirmations) if err != nil { return nil, fmt.Errorf("can't parse affirmations: %w", err) } body, err := spintax.Parse(spintext) if err != nil { return nil, fmt.Errorf("can't parse bodies: %w", err) } title, err := spintax.Parse(titles) if err != nil { return nil, fmt.Errorf("can't parse titles: %w", err) } lg.Debug("initialized basic bullshit generator", "affirmations", affirmation.Count(), "bodies", body.Count(), "titles", title.Count()) return &Impl{ st: st, infos: store.JSON[honeypot.Info]{Underlying: st, Prefix: "honeypot:info"}, uaWeight: store.JSON[int]{Underlying: st, Prefix: "honeypot:user-agent"}, networkWeight: store.JSON[int]{Underlying: st, Prefix: "honeypot:network"}, affirmation: affirmation, body: body, title: title, lg: lg.With("component", "honeypot/naive"), }, nil } type Impl struct { st store.Interface infos store.JSON[honeypot.Info] uaWeight store.JSON[int] networkWeight store.JSON[int] lg *slog.Logger affirmation, body, title spintax.Spintax } func (i *Impl) incrementUA(ctx context.Context, userAgent string) int { result, _ := i.uaWeight.Get(ctx, internal.SHA256sum(userAgent)) result++ i.uaWeight.Set(ctx, internal.SHA256sum(userAgent), result, time.Hour) return result } func (i *Impl) incrementNetwork(ctx context.Context, network string) int { result, _ := i.networkWeight.Get(ctx, internal.SHA256sum(network)) result++ i.networkWeight.Set(ctx, internal.SHA256sum(network), result, time.Hour) return result } func (i *Impl) CheckUA() checker.Impl { return checker.Func(func(r *http.Request) (bool, error) { result, _ := i.uaWeight.Get(r.Context(), internal.SHA256sum(r.UserAgent())) if result >= 25 { return true, nil } return false, nil }) } func (i *Impl) CheckNetwork() checker.Impl { return checker.Func(func(r *http.Request) (bool, error) { result, _ := i.uaWeight.Get(r.Context(), internal.SHA256sum(r.UserAgent())) if result >= 25 { return true, nil } return false, nil }) } func (i *Impl) Hash() string { return internal.SHA256sum("naive honeypot") } func (i *Impl) makeAffirmations() []string { count := rand.IntN(5) + 1 var result []string for range count { result = append(result, i.affirmation.Spin()) } return result } func (i *Impl) makeSpins() []string { count := rand.IntN(5) + 1 var result []string for range count { result = append(result, i.body.Spin()) } return result } func (i *Impl) makeTitle() string { return i.title.Spin() } func (i *Impl) ServeHTTP(w http.ResponseWriter, r *http.Request) { t0 := time.Now() lg := internal.GetRequestLogger(i.lg, r) id := r.PathValue("id") if id == "" { id = uuid.NewString() } realIP, _ := internal.RealIP(r) if !realIP.IsValid() { realIP = netip.MustParseAddr(r.Header.Get("X-Real-Ip")) } network, ok := internal.ClampIP(realIP) if !ok { lg.Error("clampIP failed", "output", network, "ok", ok) http.Error(w, "The cake is a lie", http.StatusTeapot) return } networkCount := i.incrementNetwork(r.Context(), network.String()) uaCount := i.incrementUA(r.Context(), r.UserAgent()) stage := r.PathValue("stage") if stage == "init" { lg.Debug("found new entrance point", "id", id, "stage", stage, "userAgent", r.UserAgent(), "clampedIP", network) } else { switch { case networkCount%256 == 0, uaCount%256 == 0: lg.Warn("found possible crawler", "id", id, "network", network) } } spins := i.makeSpins() affirmations := i.makeAffirmations() title := i.makeTitle() var links []link for _, affirmation := range affirmations { links = append(links, link{ href: uuid.NewString(), body: affirmation, }) } templ.Handler( base(title, i.maze(spins, links)), templ.WithStreaming(), templ.WithStatus(http.StatusOK), ).ServeHTTP(w, r) t1 := time.Since(t0) honeypot.Timings.WithLabelValues("naive").Observe(float64(t1.Milliseconds())) } type link struct { href string body string } ================================================ FILE: internal/honeypot/naive/page.templ ================================================ package naive import "fmt" templ base(title string, body templ.Component) { <!DOCTYPE html> <html> <head> <style> html { max-width: 70ch; padding: 3em 1em; margin: auto; line-height: 1.75; font-size: 1.25em; } </style> <title>{ title }

{ title }

@body } templ (i Impl) maze(body []string, links []link) { for _, paragraph := range body {

{ paragraph }

} } ================================================ FILE: internal/honeypot/naive/page_templ.go ================================================ // Code generated by templ - DO NOT EDIT. // templ: version: v0.3.960 package naive //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 "fmt" 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, "") 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: `page.templ`, Line: 18, 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, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var3 string templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(title) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `page.templ`, Line: 21, Col: 14} } _, 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, "

") 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, 4, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } return nil }) } func (i Impl) maze(body []string, links []link) 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_Var4 := templ.GetChildren(ctx) if templ_7745c5c3_Var4 == nil { templ_7745c5c3_Var4 = templ.NopComponent } ctx = templ.ClearChildren(ctx) for _, paragraph := range body { templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var5 string templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(paragraph) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `page.templ`, Line: 29, Col: 16} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } return nil }) } var _ = templruntime.GeneratedTemplate ================================================ FILE: internal/honeypot/naive/spintext.txt ================================================ {There's a moment|At some point|In this season|Right now|If we're being honest} when {leaders|builders|creators|change-makers|visionaries} {realize|begin to realize|are being called to realize} that {the work|the mission|the journey|the evolution} {isn't just about|was never just about|can't be reduced to} {execution|scaling|optimization|velocity|results}, but about {presence|intention|alignment|resonance|energy}. {We don't scale|We don't innovate|We don't transform} {systems|products|teams|communities|ideas} by {pushing harder|moving faster|doing more|grinding endlessly}, we do it by {creating space|holding the vision|listening deeply|leading with empathy|operating from clarity}. Because {impact|growth|momentum|trust|meaning} {isn't manufactured|can't be forced|doesn't come from hustle}, it {emerges organically|compounds quietly|unfolds over time|flows naturally} when {values|purpose|strategy|people|culture} are {in integrity|deeply aligned|moving in the same direction|rooted in truth}. {We're witnessing|We're living through|We're being invited into|This moment represents} a {paradigm shift|recalibration|collective awakening|fundamental reimagining} in how {we think about|we relate to|we show up for} {work|leadership|innovation|value creation}. This {isn't a trend|isn't a tactic|isn't a framework}, it's a {felt experience|lived truth|deeper knowing|shared frequency} that {requires|demands|asks of us} {courage|presence|intentionality|emotional fluency}. When we {slow down|get honest|create space|center ourselves}, we {unlock|activate|surface|make room for} {new possibilities|emergent outcomes|nonlinear growth|unseen leverage} that {can't be measured|don't show up in dashboards|defy traditional KPIs}, but {change everything|move the needle where it matters|redefine success anyway}. As {AI accelerates|systems become autonomous|the pace of change compounds}, the real {differentiator|competitive advantage|edge} won't be {speed|scale|automation}, but {discernment|human-centered design|ethical intentionality|values-led decision making}. The future {belongs to|is shaped by|will reward} those who can {hold complexity|navigate ambiguity|lead with nuance} while {staying grounded|remaining adaptable|operating from purpose}. In reflecting on {recent events|the last few weeks|this experience}, it's clear that {we moved fast|we optimized prematurely|we prioritized execution} without fully {honoring the process|listening deeply|bringing everyone along}. Going forward, we're committed to {doing the work|rebuilding trust|showing up differently} by {leading with transparency|centering our values|taking a more holistic approach}. This is {not the end|just the beginning|part of the journey}. {In the sacred space|Within the circle|As the moon waxes|During the rite} {when|as} {initiates|adepts|seekers|practitioners|neophytes} {awaken|begin to awaken|are called to awaken|ascend|initiate} to {the mysteries|the ancient wisdom|the hidden truths|the arcane arts|the sacred teachings}, they {discover|uncover|reveal|realize|attain} that {magick|the craft|ritual work|spiritual practice|the great work} {isn't just about|was never just about|can't be reduced to|transcends} {spells|incantations|ceremonies|tools|rituals}, but {about|through|via} {intention|will|consciousness|spiritual alignment|true will|divine purpose}. {We don't invoke|We don't channel|We don't transform|We cannot manifest} {energy|consciousness|reality|spiritual forces|divine power|cosmic energy} through {rote memorization|empty gestures|meaningless rituals|hollow words|dead forms}, but {achieve|attain|accomplish|realize} it {through|via|by means of} {focused intention|spiritual discipline|inner work|divine connection|sacred silence|meditative focus}. {True power|Real magick|Authentic wisdom|Sacred knowledge|Divine gnosis} {cannot be forced|cannot be bought|cannot be faked|cannot be manufactured|resists coercion}, it {unfolds naturally|emerges through practice|awakens within|manifests organically|blossoms in due season} when {mind|body|spirit|will|soul|heart} are {in harmony|deeply aligned|moving as one|connected to source|in resonance|unified}. {We are witnessing|We are experiencing|This is the dawn of|The age of} a {great awakening|spiritual revolution|paradigm shift|new aeon|cosmic alignment|quantum leap} in how {humanity perceives|we understand|we connect with|consciousness relates to} {the divine|universal consciousness|higher realms|spiritual realities|source energy|the absolute}, {transcending|going beyond|surpassing} {philosophy|religion|metaphysics|dogma|doctrine} into {lived experience|direct gnosis|personal revelation|sacred knowing|intimate understanding|embodied wisdom} that {requires|demands|invites|necessitates} {devotion|discipline|spiritual courage|inner purification|unwavering commitment|radical honesty}. When we {enter trance|quiet the mind|open ourselves|still the thoughts|cross the threshold|journey within}, we {access|connect with|attune to|commune with|perceive} {higher dimensions|spiritual realms|the akashic records|divine wisdom|the celestial planes|the inner worlds} that {transcend ordinary perception|defy rational explanation|exist beyond the veil|surpass linear understanding|operate beyond time}, yet {transform our understanding|reshape our reality|expand our consciousness|illuminate our path|reconfigure our perception|realign our being}. As {the veil thins|consciousness evolves|spiritual acceleration increases|the new age dawns|humanity ascends}, the real {power|gift|ability|mastery|sovereignty} isn't {in the tools|in the books|in the rituals|in the techniques|in the methods}, but in {the intention behind them|the purity of heart|the clarity of purpose|the depth of devotion|the sincerity of soul|the authenticity of spirit}. The future {reveals|unfolds|manifests|emerges|dawns} for those who can {navigate the unseen|walk between worlds|hold paradox|embrace mystery|dance with ambiguity|soar above duality} while {remaining grounded|staying centered|keeping their feet on earth|maintaining balance|honoring form|respecting structure}. {Through meditation|In ritual|By studying the grimoires|During communion|Via sacred practice|Through inner work}, it {becomes clear|is revealed|is understood|dawns upon us|manifests as truth} that {true wisdom|spiritual power|magical ability|authentic knowledge|real attainment} {comes not from|arises not from|originates not in} {external sources|mere study|intellectual knowledge|outer teachings|second-hand wisdom} but {from|through|via} {inner awakening|direct experience|spiritual practice|personal gnosis|embodied realization|soulful communion}. The path forward {requires dedication|demands sacrifice|calls for commitment|necessitates devotion|asks for discipline|invokes perseverance} through {daily practice|spiritual discipline|consistent devotion|ritual purity|sacred routine|holy observance}, {leading to|culminating in|resulting in} {ultimate liberation|final union|complete awakening|full realization|perfect illumination}. {The work continues|The path unfolds|The journey never ends|The spiral ascends|The evolution persists|The transformation deepens}. ================================================ FILE: internal/honeypot/naive/titles.txt ================================================ {{The|A|This} {Future|Next|New|Coming|Emerging} {Paradigm|Reality|Era|Age|World} {of|for|in} {AI|Artificial Intelligence|Machine Learning|Automation|Technology|Innovation} | {Building|Creating|Designing|Developing|Crafting} {Sustainable|Scalable|Robust|Resilient|Future-Proof} {Systems|Platforms|Solutions|Architectures|Frameworks} | {The|Our|Your} {Journey|Path|Road|Quest|Voyage} {to|toward|towards} {Digital|Technological|Business|Organizational} {Transformation|Evolution|Revolution|Mastery} | {Unlocking|Harnessing|Activating|Unleashing|Channeling} {Human|Collective|Team|Organizational} {Potential|Capacity|Capability|Power|Genius} | {Beyond|Past|Moving Beyond|Transcending} {Limits|Boundaries|Constraints|Barriers|Horizons}: {New|Fresh|Innovative|Revolutionary} {Perspectives|Approaches|Solutions|Strategies} | {The|This|Our} {Age|Era|Time|Period} {of|for|in} {Conscious|Aware|Mindful|Intentional} {Leadership|Business|Strategy|Innovation} | {Sacred|Ancient|Esoteric|Mystical} {Wisdom|Knowledge|Teachings|Mysteries} {for|in|to} {Modern|Contemporary|Today's} {Life|Business|Leadership|Success} | {Quantum|Cosmic|Universal|Divine} {Shift|Evolution|Transformation|Awakening} {in|of|for} {Consciousness|Awareness|Perception|Reality} | {The|A|This} {Great|Profound|Fundamental|Deep} {Reset|Recalibration|Realignment|Restructuring} {of|for|in} {Everything|All Things|Reality|Systems} | {Embracing|Integrating|Honoring|Welcoming} {Chaos|Uncertainty|Complexity|Ambiguity|Paradox} {as|for} {Growth|Evolution|Transformation|Innovation} | {The|This|Our} {Alchemy|Magic|Art|Science} {of|for|in} {Transformation|Change|Evolution|Metamorphosis} {and|&} {Creation|Manifestation|Innovation} | {Resilient|Adaptive|Flexible|Agile|Dynamic} {Mindsets|Mental Models|Paradigms|Frameworks} {for|in|to} {Uncertain|Complex|Volatile|Rapidly-Changing} {Times|Environments|Worlds} | {The|Our|Your} {Collective|Shared|Unified|Common} {Vision|Dream|Future|Destiny}: {Co-creating|Building|Designing|Manifesting} {Tomorrow|The Future|What's Next} | {Sovereign|Authentic|True|Real} {Self|Identity|Being|Expression} {in|during|through} {Times|Ages|Eras} {of|for|in} {Change|Transition|Transformation|Awakening} | {The|This|Our} {Return|Homecoming|Journey Back|Restoration} {to|towards|for} {Wholeness|Unity|Integration|Balance} {and|&} {Harmony|Peace|Alignment|Flow} | {Infinite|Limitless|Boundless|Unlimited} {Potential|Possibility|Capacity|Power} {Within|Inside|of} {You|Us|Every Being|Consciousness} | {Riding|Navigating|Mastering|Surfing} {Waves|Currents|Tides|Flows} {of|for|in} {Change|Evolution|Transformation|Progress} | {The|This|Our} {Sacred|Holy|Divine} {Dance|Play|Game|Journey} {of|for|in} {Creation|Manifestation|Evolution|Existence} | {Awakening|Remembering|Rediscovering|Uncovering} {Ancient|Primordial|Original|True} {Wisdom|Knowledge|Truth|Teachings} {for|in|to} {Modern Life|Today|Now} | {The|This|Our} {Bridge|Portal|Gateway|Threshold} {Between|Betwixt|Connecting} {Worlds|Realities|Dimensions|Eras} {and|&} {Possibilities|Potentials|Futures} | {Cosmic|Universal|Galactic|Celestial} {Alignment|Convergence|Synchronization|Harmony} {for|in|to|during|through} {Planetary|Global|Universal|Collective} {Awakening|Evolution|Transformation} | {The|This|Our} {Emergence|Arising|Birthing|Becoming} {into|as|through|for} {New|Next|Higher|Evolved} {States|Levels|Dimensions|Realms} {of|for|in} {Consciousness|Awareness|Being|Existence} | {The|This|Our} {Quantum|Paradigm|Reality|Fundamental} {Shift|Change|Leap|Transition}: {Reimagining|Rethinking|Reinventing|Transforming} {Everything|All Things|Reality|Possibility} | {Harmonizing|Balancing|Integrating|Unifying} {Masculine|Feminine|Yin|Yang|Dual} {and|&} {Feminine|Masculine|Yang|Yin|Non-Dual}: {The|This|Our} {Sacred|Divine|Holy|Mystical} {Union|Marriage|Integration|Wholeness} | {The|This|Our} {Path|Way|Journey|Quest} {of|for|in} {Heart|Love|Compassion|Service}: {Living|Being|Existing|Creating} {from|through|with} {Soul|Spirit|Essence|Core} | {Revolutionary|Paradigm-Shifting|Game-Changing|Transformative|Evolutionary} {Ideas|Concepts|Frameworks|Models} {for|in|to|during|through} {Tomorrow's|The Future|Next-Generation|Emerging} {World|Reality|Era|Age} | {The|This|Our} {Great|Profound|Fundamental|Momentous} {Work|Task|Mission|Purpose}: {Becoming|Evolving|Transforming|Ascending} {into|as|through|for} {Who|What|How} {We|You|One|Consciousness} {Truly|Really|Authentically|Essentially} {Are|Is|Can Be|Could Be} | {The|This|Our} {Alchemy|Magic|Art|Science} {of|for|in} {Turning|Transforming|Converting|Transmuting} {Lead|Challenges|Limitations|Darkness|Shadow} {into|to|as} {Gold|Wisdom|Strength|Light|Gifts} | {The|This|Our} {Return|Journey|Quest|Path} {to|toward|for|in} {Source|Origin|Beginning|Essence}: {Remembering|Rediscovering|Reclaiming|Awakening to} {Who|What|Why|How} {We|You|All|Consciousness} {Truly|Really|Essentially|Fundamentally} {Are|Is|Exists|Can Be} | {The|This|Our} {Sacred|Holy|Divine|Blessed} {Contract|Agreement|Promise|Covenant}: {Living|Fulfilling|Embodying|Realizing} {Your|Our|The|Universal} {Purpose|Mission|Destiny|Calling|Dharma} | {The|This|Our} {Age|Era|Time|Period} {of|for|in} {Miracles|Wonder|Magic|Mystery|Enchantment}: {Embracing|Welcoming|Celebrating|Honoring} {The|This|Our|All|Every} {Impossible|Unbelievable|Extraordinary|Supernatural} {Becoming|Becomes|Becoming Real|Manifesting} | {The|This|Our} {Revolution|Evolution|Transformation|Awakening} {of|for|in} {Consciousness|Awareness|Perception|Reality}: {Creating|Designing|Building|Manifesting} {New|Fresh|Innovative|Paradigm-Shifting} {Worlds|Realities|Futures|Possibilities} | {The|This|Our} {Journey|Path|Quest|Adventure} {Home|Back|Return|Homecoming} {to|toward|for|in} {Unity|Oneness|Wholeness|Integration|Love} {and|&} {Belonging|Connection|Relationship|Communion}} ================================================ FILE: internal/ja4h.go ================================================ package internal import ( "net/http" "github.com/lum8rjack/go-ja4h" ) func JA4H(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { r.Header.Add("X-Http-Fingerprint-JA4H", ja4h.JA4H(r)) next.ServeHTTP(w, r) }) } ================================================ FILE: internal/listor.go ================================================ package internal import ( "encoding/json" ) // ListOr[T any] is a slice that can contain either a single T or multiple T values. // During JSON unmarshaling, it checks if the first character is '[' to determine // whether to treat the JSON as an array or a single value. type ListOr[T any] []T func (lo *ListOr[T]) UnmarshalJSON(data []byte) error { if len(data) == 0 { return nil } // Check if first non-whitespace character is '[' firstChar := data[0] for i := range data { if data[i] != ' ' && data[i] != '\t' && data[i] != '\n' && data[i] != '\r' { firstChar = data[i] break } } if firstChar == '[' { // It's an array, unmarshal directly return json.Unmarshal(data, (*[]T)(lo)) } else { // It's a single value, unmarshal as a single item in a slice var single T if err := json.Unmarshal(data, &single); err != nil { return err } *lo = ListOr[T]{single} } return nil } ================================================ FILE: internal/listor_test.go ================================================ package internal import ( "encoding/json" "testing" ) func TestListOr_UnmarshalJSON(t *testing.T) { t.Run("single value should be unmarshaled as single item", func(t *testing.T) { var lo ListOr[string] err := json.Unmarshal([]byte(`"hello"`), &lo) if err != nil { t.Fatalf("Failed to unmarshal single string: %v", err) } if len(lo) != 1 { t.Fatalf("Expected 1 item, got %d", len(lo)) } if lo[0] != "hello" { t.Errorf("Expected 'hello', got %q", lo[0]) } }) t.Run("array should be unmarshaled as multiple items", func(t *testing.T) { var lo ListOr[string] err := json.Unmarshal([]byte(`["hello", "world"]`), &lo) if err != nil { t.Fatalf("Failed to unmarshal array: %v", err) } if len(lo) != 2 { t.Fatalf("Expected 2 items, got %d", len(lo)) } if lo[0] != "hello" { t.Errorf("Expected 'hello', got %q", lo[0]) } if lo[1] != "world" { t.Errorf("Expected 'world', got %q", lo[1]) } }) t.Run("single number should be unmarshaled as single item", func(t *testing.T) { var lo ListOr[int] err := json.Unmarshal([]byte(`42`), &lo) if err != nil { t.Fatalf("Failed to unmarshal single number: %v", err) } if len(lo) != 1 { t.Fatalf("Expected 1 item, got %d", len(lo)) } if lo[0] != 42 { t.Errorf("Expected 42, got %d", lo[0]) } }) t.Run("array of numbers should be unmarshaled as multiple items", func(t *testing.T) { var lo ListOr[int] err := json.Unmarshal([]byte(`[1, 2, 3]`), &lo) if err != nil { t.Fatalf("Failed to unmarshal number array: %v", err) } if len(lo) != 3 { t.Fatalf("Expected 3 items, got %d", len(lo)) } if lo[0] != 1 || lo[1] != 2 || lo[2] != 3 { t.Errorf("Expected [1, 2, 3], got %v", lo) } }) } ================================================ FILE: internal/log.go ================================================ package internal import ( "fmt" "io" "log" "log/slog" "net/http" "os" "strings" ) func InitSlog(level string, sink io.Writer) *slog.Logger { var programLevel slog.Level if err := (&programLevel).UnmarshalText([]byte(level)); err != nil { fmt.Fprintf(os.Stderr, "invalid log level %s: %v, using info\n", level, err) programLevel = slog.LevelInfo } leveler := &slog.LevelVar{} leveler.Set(programLevel) h := slog.NewJSONHandler(sink, &slog.HandlerOptions{ AddSource: true, Level: leveler, }) result := slog.New(h) return result } func GetRequestLogger(base *slog.Logger, r *http.Request) *slog.Logger { host := r.Host if host == "" { host = r.Header.Get("X-Forwarded-Host") } return base.With( "host", host, "method", r.Method, "path", r.URL.Path, "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"), ) } // ErrorLogFilter is used to suppress "context canceled" logs from the http server when a request is canceled (e.g., when a client disconnects). type ErrorLogFilter struct { Unwrap *log.Logger } func (elf *ErrorLogFilter) Write(p []byte) (n int, err error) { logMessage := string(p) if strings.Contains(logMessage, "context canceled") { return len(p), nil // Suppress the log by doing nothing } if strings.Contains(logMessage, "Unsolicited response received on idle HTTP channel") { return len(p), nil } if elf.Unwrap != nil { return elf.Unwrap.Writer().Write(p) } return len(p), nil } func GetFilteredHTTPLogger() *log.Logger { stdErrLogger := log.New(os.Stderr, "", log.LstdFlags) // essentially what the default logger is. return log.New(&ErrorLogFilter{Unwrap: stdErrLogger}, "", 0) } ================================================ FILE: internal/log_test.go ================================================ package internal import ( "bytes" "log" "log/slog" "net/http" "strings" "testing" ) func TestErrorLogFilter(t *testing.T) { var buf bytes.Buffer destLogger := log.New(&buf, "", 0) errorFilterWriter := &ErrorLogFilter{Unwrap: destLogger} testErrorLogger := log.New(errorFilterWriter, "", 0) // Test Case 1: Suppressed message suppressedMessage := "http: proxy error: context canceled" testErrorLogger.Println(suppressedMessage) if buf.Len() != 0 { t.Errorf("Suppressed message was written to output. Output: %q", buf.String()) } buf.Reset() // Test Case 2: Allowed message allowedMessage := "http: another error occurred" testErrorLogger.Println(allowedMessage) output := buf.String() if !strings.Contains(output, allowedMessage) { t.Errorf("Allowed message was not written to output. Output: %q", output) } if !strings.HasSuffix(output, "\n") { t.Errorf("Allowed message output is missing newline. Output: %q", output) } buf.Reset() // Test Case 3: Partially matching message (should be suppressed) partiallyMatchingMessage := "Some other log before http: proxy error: context canceled and after" testErrorLogger.Println(partiallyMatchingMessage) if buf.Len() != 0 { t.Errorf("Partially matching message was written to output. Output: %q", buf.String()) } buf.Reset() } func TestGetRequestLogger(t *testing.T) { // Test case 1: Normal request with Host header req1, _ := http.NewRequest("GET", "http://example.com/test", nil) req1.Host = "example.com" logger := slog.Default() reqLogger := GetRequestLogger(logger, req1) // We can't easily test the actual log output without setting up a test handler, // but we can verify the function doesn't panic and returns a logger if reqLogger == nil { t.Error("GetRequestLogger returned nil") } // Test case 2: Subrequest auth mode with X-Forwarded-Host req2, _ := http.NewRequest("GET", "http://test.com/auth", nil) req2.Host = "" req2.Header.Set("X-Forwarded-Host", "original-site.com") reqLogger2 := GetRequestLogger(logger, req2) if reqLogger2 == nil { t.Error("GetRequestLogger returned nil for X-Forwarded-Host case") } // Test case 3: No host information available req3, _ := http.NewRequest("GET", "http://test.com/nohost", nil) req3.Host = "" reqLogger3 := GetRequestLogger(logger, req3) if reqLogger3 == nil { t.Error("GetRequestLogger returned nil for no host case") } } ================================================ FILE: internal/mimetype.go ================================================ package internal import "mime" func init() { mime.AddExtensionType(".mjs", "text/javascript") } ================================================ FILE: internal/ogtags/cache.go ================================================ package ogtags import ( "context" "errors" "log/slog" "net/url" "strings" "syscall" "time" ) // GetOGTags is the main function that retrieves Open Graph tags for a URL func (c *OGTagCache) GetOGTags(ctx context.Context, url *url.URL, originalHost string) (map[string]string, error) { if url == nil { return nil, errors.New("nil URL provided, cannot fetch OG tags") } if len(c.ogOverride) != 0 { return c.ogOverride, nil } target := c.getTarget(url) cacheKey := c.generateCacheKey(target, originalHost) // Check cache first if cachedTags := c.checkCache(ctx, cacheKey); cachedTags != nil { return cachedTags, nil } // Fetch HTML content, passing the original host doc, err := c.fetchHTMLDocumentWithCache(ctx, target, originalHost, cacheKey) if errors.Is(err, syscall.ECONNREFUSED) { slog.Debug("Connection refused, returning empty tags") return nil, nil } else if errors.Is(err, ErrOgHandled) { // Error was handled in fetchHTMLDocument, return empty tags return nil, nil } if err != nil { return nil, err } // Extract OG tags ogTags := c.extractOGTags(doc) // Store in cache c.cache.Set(ctx, cacheKey, ogTags, c.ogTimeToLive) for k, v := range ogTags { switch { case strings.HasSuffix(k, "image"), strings.HasSuffix(k, "audio"), strings.HasSuffix(k, "secure_url"), strings.HasSuffix(k, "video"): v, _ = strings.CutPrefix(v, "http://") v, _ = strings.CutPrefix(v, "https://") slog.Debug("setting ogtags allow for", "url", k) if err := c.cache.Underlying.Set(ctx, "ogtags:allow:"+v, []byte(k), time.Hour); err != nil { slog.Debug("can't set ogtag allow cache", "err", err) } } } return ogTags, nil } func (c *OGTagCache) generateCacheKey(target string, originalHost string) string { var cacheKey string if c.ogCacheConsiderHost { cacheKey = target + "|" + originalHost } else { cacheKey = target } return cacheKey } // checkCache checks if we have the tags cached and returns them if so func (c *OGTagCache) checkCache(ctx context.Context, cacheKey string) map[string]string { if cachedTags, err := c.cache.Get(ctx, cacheKey); err == nil { slog.Debug("cache hit", "tags", cachedTags) return cachedTags } slog.Debug("cache miss", "url", cacheKey) return nil } ================================================ FILE: internal/ogtags/cache_test.go ================================================ package ogtags import ( "errors" "net/http" "net/http/httptest" "net/url" "reflect" "testing" "time" "github.com/TecharoHQ/anubis/lib/config" "github.com/TecharoHQ/anubis/lib/store" "github.com/TecharoHQ/anubis/lib/store/memory" ) func TestCacheReturnsDefault(t *testing.T) { want := map[string]string{ "og:title": "Foo bar", "og:description": "The best website ever made!!!1!", } cache := NewOGTagCache("", config.OpenGraph{ Enabled: true, TimeToLive: time.Minute, ConsiderHost: false, Override: want, }, memory.New(t.Context()), TargetOptions{}) u, err := url.Parse("https://anubis.techaro.lol") if err != nil { t.Fatal(err) } result, err := cache.GetOGTags(t.Context(), u, "anubis.techaro.lol") if err != nil { t.Fatal(err) } for k, v := range want { t.Run(k, func(t *testing.T) { if got := result[k]; got != v { t.Logf("want: tags[%q] = %q", k, v) t.Logf("got: tags[%q] = %q", k, got) t.Error("invalid result from function") } }) } } func TestCheckCache(t *testing.T) { cache := NewOGTagCache("http://example.com", config.OpenGraph{ Enabled: true, TimeToLive: time.Minute, ConsiderHost: false, }, memory.New(t.Context()), TargetOptions{}) // Set up test data urlStr := "http://example.com/page" expectedTags := map[string]string{ "og:title": "Test Title", "og:description": "Test Description", } cacheKey := cache.generateCacheKey(urlStr, "example.com") // Test cache miss tags := cache.checkCache(t.Context(), cacheKey) if tags != nil { t.Errorf("expected nil tags on cache miss, got %v", tags) } // Manually add to cache cache.cache.Set(t.Context(), cacheKey, expectedTags, time.Minute) // Test cache hit tags = cache.checkCache(t.Context(), cacheKey) 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(`

Hello, world!

`)) })) defer ts.Close() // Create an instance of OGTagCache with a short TTL for testing cache := NewOGTagCache(ts.URL, config.OpenGraph{ Enabled: true, TimeToLive: time.Minute, ConsiderHost: false, }, memory.New(t.Context()), TargetOptions{}) // 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 // Pass the host from the parsed test server URL ogTags, err := cache.GetOGTags(t.Context(), parsedURL, parsedURL.Host) 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 // Pass the host from the parsed test server URL ogTags, err = cache.GetOGTags(t.Context(), parsedURL, parsedURL.Host) if err != nil { t.Fatalf("failed to get OG tags from cache: %v", err) } // Test fetching OG tags from the cache (3rd time) // Pass the host from the parsed test server URL newOgTags, err := cache.GetOGTags(t.Context(), parsedURL, parsedURL.Host) 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) } } t.Run("ensure image is cached as allow", func(t *testing.T) { if _, err := cache.cache.Underlying.Get(t.Context(), "ogtags:allow:example.com/image.jpg"); errors.Is(err, store.ErrNotFound) { t.Fatal("ogtags allow caching for example.com/image.jpg did not work") } }) } // TestGetOGTagsWithHostConsideration tests the behavior of the cache with and without host consideration and for multiple hosts in a theoretical setup. func TestGetOGTagsWithHostConsideration(t *testing.T) { var loadCount int // Counter to track how many times the test route is loaded // Create a test server ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { loadCount++ // Increment counter on each request to the server w.Header().Set("Content-Type", "text/html") w.Write([]byte(`

Content

`)) })) defer ts.Close() parsedURL, err := url.Parse(ts.URL) if err != nil { t.Fatalf("failed to parse test server URL: %v", err) } expectedTags := map[string]string{ "og:title": "Test Title", "og:description": "Test Description", } testCases := []struct { name string requests []struct { host string expectedLoadCount int } ogCacheConsiderHost bool // Expected load count *after* this request }{ { name: "Host Not Considered - Same Host", ogCacheConsiderHost: false, requests: []struct { host string expectedLoadCount int }{ {"host1", 1}, // First request, miss {"host1", 1}, // Second request, same host, hit (host ignored) }, }, { name: "Host Not Considered - Different Host", ogCacheConsiderHost: false, requests: []struct { host string expectedLoadCount int }{ {"host1", 1}, // First request, miss {"host2", 1}, // Second request, different host, hit (host ignored) }, }, { name: "Host Considered - Same Host", ogCacheConsiderHost: true, requests: []struct { host string expectedLoadCount int }{ {"host1", 1}, // First request, miss {"host1", 1}, // Second request, same host, hit }, }, { name: "Host Considered - Different Host", ogCacheConsiderHost: true, requests: []struct { host string expectedLoadCount int }{ {"host1", 1}, // First request, miss {"host2", 2}, // Second request, different host, miss {"host2", 2}, // Third request, same as second, hit {"host1", 2}, // Fourth request, same as first, hit }, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { loadCount = 0 // Reset load count for each test case cache := NewOGTagCache(ts.URL, config.OpenGraph{ Enabled: true, TimeToLive: time.Minute, ConsiderHost: tc.ogCacheConsiderHost, }, memory.New(t.Context()), TargetOptions{}) for i, req := range tc.requests { ogTags, err := cache.GetOGTags(t.Context(), parsedURL, req.host) if err != nil { t.Errorf("Request %d (host: %s): unexpected error: %v", i+1, req.host, err) continue // Skip further checks for this request if error occurred } // Verify tags are correct (should always be the same in this setup) if !reflect.DeepEqual(ogTags, expectedTags) { t.Errorf("Request %d (host: %s): expected tags %v, got %v", i+1, req.host, expectedTags, ogTags) } // Verify the load count to check cache hit/miss behavior if loadCount != req.expectedLoadCount { t.Errorf("Request %d (host: %s): expected load count %d, got %d (cache hit/miss mismatch)", i+1, req.host, req.expectedLoadCount, loadCount) } } }) } } ================================================ FILE: internal/ogtags/fetch.go ================================================ package ogtags import ( "context" "errors" "fmt" "io" "log/slog" "mime" "net" "net/http" "golang.org/x/net/html" ) var ( ErrOgHandled = errors.New("og: handled error") // used to indicate that the error was handled and should not be logged emptyMap = map[string]string{} // used to indicate an empty result in the cache. Can't use nil as it would be a cache miss. ) // fetchHTMLDocumentWithCache fetches the HTML document from the given URL string, // preserving the original host header. func (c *OGTagCache) fetchHTMLDocumentWithCache(ctx context.Context, urlStr string, originalHost string, cacheKey string) (*html.Node, error) { req, err := http.NewRequestWithContext(ctx, "GET", urlStr, nil) if err != nil { return nil, fmt.Errorf("failed to create http request: %w", err) } // Set the Host header to the original host var hostForRequest string switch { case c.targetHost != "": hostForRequest = c.targetHost case originalHost != "": hostForRequest = originalHost } if hostForRequest != "" { req.Host = hostForRequest } // Add proxy headers req.Header.Set("X-Forwarded-Proto", "https") req.Header.Set("User-Agent", "Anubis-OGTag-Fetcher/1.0") // For tracking purposes serverName := hostForRequest if serverName == "" { serverName = req.URL.Hostname() } client := c.clientForSNI(serverName) // Send the request resp, err := client.Do(req) 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(ctx, cacheKey, 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) } // Ensure the response body is closed defer func(Body io.ReadCloser) { err := Body.Close() if err != nil { slog.Debug("og: error closing response body", "url", urlStr, "error", err) } }(resp.Body) if resp.StatusCode != http.StatusOK { slog.Debug("og: received non-OK status code", "url", urlStr, "status", resp.StatusCode) c.cache.Set(ctx, cacheKey, emptyMap, c.ogTimeToLive) // Cache empty result for non-successful status codes return nil, fmt.Errorf("%w: page not found", ErrOgHandled) } // Check content type ct := resp.Header.Get("Content-Type") if ct == "" { return nil, fmt.Errorf("missing Content-Type header") } else { mediaType, _, err := mime.ParseMediaType(ct) if err != nil { slog.Debug("og: malformed Content-Type header", "url", urlStr, "contentType", ct) return nil, fmt.Errorf("%w malformed Content-Type header: %w", ErrOgHandled, err) } if mediaType != "text/html" && mediaType != "application/xhtml+xml" { slog.Debug("og: unsupported Content-Type", "url", urlStr, "contentType", mediaType) return nil, fmt.Errorf("%w unsupported Content-Type: %s", ErrOgHandled, mediaType) } } resp.Body = http.MaxBytesReader(nil, resp.Body, 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", maxContentLength) return nil, fmt.Errorf("content too large: exceeded %d bytes", maxContentLength) } return nil, fmt.Errorf("failed to parse HTML: %w", err) } return doc, nil } ================================================ FILE: internal/ogtags/fetch_test.go ================================================ package ogtags import ( "context" "fmt" "io" "net/http" "net/http/httptest" "os" "strings" "testing" "time" "github.com/TecharoHQ/anubis/lib/config" "github.com/TecharoHQ/anubis/lib/store/memory" "golang.org/x/net/html" ) func TestFetchHTMLDocument(t *testing.T) { tests := []struct { name string htmlContent string contentType string statusCode int contentLength int64 expectError bool }{ { name: "Valid HTML", htmlContent: ` Test

Test content

`, 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("", config.OpenGraph{ Enabled: true, TimeToLive: time.Minute, ConsiderHost: false, }, memory.New(t.Context()), TargetOptions{}) doc, err := cache.fetchHTMLDocument(t.Context(), ts.URL, "anything") 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("", config.OpenGraph{ Enabled: true, TimeToLive: time.Minute, ConsiderHost: false, }, memory.New(t.Context()), TargetOptions{}) doc, err := cache.fetchHTMLDocument(t.Context(), "http://invalid.url.that.doesnt.exist.example", "anything") 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") } } // fetchHTMLDocument allows you to call fetchHTMLDocumentWithCache without a duplicate generateCacheKey call func (c *OGTagCache) fetchHTMLDocument(ctx context.Context, urlStr string, originalHost string) (*html.Node, error) { cacheKey := c.generateCacheKey(urlStr, originalHost) return c.fetchHTMLDocumentWithCache(ctx, urlStr, originalHost, cacheKey) } ================================================ FILE: internal/ogtags/integration_test.go ================================================ package ogtags import ( "net/http" "net/http/httptest" "net/url" "testing" "time" "github.com/TecharoHQ/anubis/lib/config" "github.com/TecharoHQ/anubis/lib/store/memory" ) 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(`

Simple page content

`)) case "/complete": w.Write([]byte(`

Complete page content

`)) case "/no-og": w.Write([]byte(` No OG Tags

No OG tags here

`)) default: w.WriteHeader(http.StatusNotFound) } })) defer ts.Close() // Test with different configurations testCases := []struct { expectedTags map[string]string name string path string query 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: "Nonexistent 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, config.OpenGraph{ Enabled: true, TimeToLive: time.Minute, ConsiderHost: false, }, memory.New(t.Context()), TargetOptions{}) // Create URL for test testURL, _ := url.Parse(ts.URL) testURL.Path = tc.path testURL.RawQuery = tc.query // Get OG tags // Pass the host from the test URL ogTags, err := cache.GetOGTags(t.Context(), testURL, testURL.Host) // 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 // Pass the host from the test URL cachedOGTags, err := cache.GetOGTags(t.Context(), testURL, testURL.Host) 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) } } }) } } ================================================ FILE: internal/ogtags/mem_test.go ================================================ package ogtags import ( "net/url" "runtime" "strings" "testing" "github.com/TecharoHQ/anubis/lib/config" "github.com/TecharoHQ/anubis/lib/store/memory" "golang.org/x/net/html" ) func BenchmarkGetTarget(b *testing.B) { tests := []struct { name string target string paths []string }{ { name: "HTTP", target: "http://example.com", paths: []string{"/", "/path", "/path/to/resource", "/path?query=1&foo=bar"}, }, { name: "Unix", target: "unix:///var/run/app.sock", paths: []string{"/", "/api/endpoint", "/api/endpoint?param=value"}, }, } for _, tt := range tests { b.Run(tt.name, func(b *testing.B) { cache := NewOGTagCache(tt.target, config.OpenGraph{}, memory.New(b.Context()), TargetOptions{}) urls := make([]*url.URL, len(tt.paths)) for i, path := range tt.paths { u, _ := url.Parse(path) urls[i] = u } b.ResetTimer() b.ReportAllocs() for i := 0; i < b.N; i++ { _ = cache.getTarget(urls[i%len(urls)]) } }) } } func BenchmarkExtractOGTags(b *testing.B) { htmlSamples := []string{ ` `, `

Content

`, } cache := NewOGTagCache("http://example.com", config.OpenGraph{}, memory.New(b.Context()), TargetOptions{}) docs := make([]*html.Node, len(htmlSamples)) for i, sample := range htmlSamples { doc, _ := html.Parse(strings.NewReader(sample)) docs[i] = doc } b.ResetTimer() b.ReportAllocs() for i := 0; i < b.N; i++ { _ = cache.extractOGTags(docs[i%len(docs)]) } } // Memory usage test func TestMemoryUsage(t *testing.T) { cache := NewOGTagCache("http://example.com", config.OpenGraph{}, memory.New(t.Context()), TargetOptions{}) // Force GC and wait for it to complete runtime.GC() var m1 runtime.MemStats runtime.ReadMemStats(&m1) // Run getTarget many times u, _ := url.Parse("/path/to/resource?query=1&foo=bar&baz=qux") for range 10000 { _ = cache.getTarget(u) } // Force GC after operations runtime.GC() var m2 runtime.MemStats runtime.ReadMemStats(&m2) allocatedBytes := int64(m2.TotalAlloc) - int64(m1.TotalAlloc) allocatedKB := float64(allocatedBytes) / 1024.0 allocatedPerOp := float64(allocatedBytes) / 10000.0 t.Logf("Memory allocated for 10k getTarget calls:") t.Logf(" Total: %.2f KB (%.2f MB)", allocatedKB, allocatedKB/1024.0) t.Logf(" Per operation: %.2f bytes", allocatedPerOp) // Test extractOGTags memory usage htmlDoc := ` ` doc, _ := html.Parse(strings.NewReader(htmlDoc)) runtime.GC() runtime.ReadMemStats(&m1) for range 1000 { _ = cache.extractOGTags(doc) } runtime.GC() runtime.ReadMemStats(&m2) allocatedBytes = int64(m2.TotalAlloc) - int64(m1.TotalAlloc) allocatedKB = float64(allocatedBytes) / 1024.0 allocatedPerOp = float64(allocatedBytes) / 1000.0 t.Logf("Memory allocated for 1k extractOGTags calls:") t.Logf(" Total: %.2f KB (%.2f MB)", allocatedKB, allocatedKB/1024.0) t.Logf(" Per operation: %.2f bytes", allocatedPerOp) // Sanity checks if allocatedPerOp > 10000 { t.Errorf("extractOGTags allocating too much memory per operation: %.2f bytes", allocatedPerOp) } } ================================================ FILE: internal/ogtags/ogtags.go ================================================ package ogtags import ( "context" "crypto/tls" "log/slog" "net" "net/http" "net/url" "strings" "sync" "time" "github.com/TecharoHQ/anubis/lib/config" "github.com/TecharoHQ/anubis/lib/store" ) const ( maxContentLength = 8 << 20 // 8 MiB is enough for anyone httpTimeout = 5 * time.Second /*todo: make this configurable?*/ schemeSeparatorLength = 3 // Length of "://" querySeparatorLength = 1 // Length of "?" for query strings ) type OGTagCache struct { ogOverride map[string]string targetURL *url.URL client *http.Client transport *http.Transport cache store.JSON[map[string]string] // Pre-built strings for optimization unixPrefix string // "http://unix" targetSNI string targetHost string approvedPrefixes []string approvedTags []string ogTimeToLive time.Duration ogPassthrough bool ogCacheConsiderHost bool targetSNIAuto bool insecureSkipVerify bool sniClients map[string]*http.Client transportMu sync.RWMutex } type TargetOptions struct { Host string SNI string InsecureSkipVerify bool } func NewOGTagCache(target string, conf config.OpenGraph, backend store.Interface, targetOpts TargetOptions) *OGTagCache { // Predefined approved tags and prefixes defaultApprovedTags := []string{"description", "keywords", "author"} defaultApprovedPrefixes := []string{"og:", "twitter:", "fediverse:"} var parsedTargetURL *url.URL var err error if target == "" { // Default to localhost if target is empty parsedTargetURL, _ = url.Parse("http://localhost") } else { parsedTargetURL, err = url.Parse(target) if err != nil { slog.Debug("og: failed to parse target URL, treating as non-unix", "target", target, "error", err) // If parsing fails, treat it as a non-unix target for backward compatibility or default behavior // For now, assume it's not a scheme issue but maybe an invalid char, etc. // A simple string target might be intended if it's not a full URL. parsedTargetURL = &url.URL{Scheme: "http", Host: target} // Assume http if scheme missing and host-like if !strings.Contains(target, "://") && !strings.HasPrefix(target, "unix:") { // If it looks like just a host/host:port (and not unix), prepend http:// (todo: is this bad...? Trace path to see if i can yell at user to do it right) parsedTargetURL, _ = url.Parse("http://" + target) // fetch cares about scheme but anubis doesn't } } } transport := http.DefaultTransport.(*http.Transport).Clone() // Configure custom transport for Unix sockets if parsedTargetURL.Scheme == "unix" { socketPath := parsedTargetURL.Path // For unix scheme, path is the socket path transport.DialContext = func(_ context.Context, _, _ string) (net.Conn, error) { return net.Dial("unix", socketPath) } } targetSNIAuto := targetOpts.SNI == "auto" if targetOpts.SNI != "" && !targetSNIAuto { if transport.TLSClientConfig == nil { transport.TLSClientConfig = &tls.Config{} } transport.TLSClientConfig.ServerName = targetOpts.SNI } if targetOpts.InsecureSkipVerify { if transport.TLSClientConfig == nil { transport.TLSClientConfig = &tls.Config{} } transport.TLSClientConfig.InsecureSkipVerify = true } client := &http.Client{ Timeout: httpTimeout, Transport: transport, } return &OGTagCache{ cache: store.JSON[map[string]string]{ Underlying: backend, Prefix: "ogtags:", }, targetURL: parsedTargetURL, ogPassthrough: conf.Enabled, ogTimeToLive: conf.TimeToLive, ogCacheConsiderHost: conf.ConsiderHost, ogOverride: conf.Override, approvedTags: defaultApprovedTags, approvedPrefixes: defaultApprovedPrefixes, client: client, transport: transport, unixPrefix: "http://unix", targetHost: targetOpts.Host, targetSNI: targetOpts.SNI, targetSNIAuto: targetSNIAuto, insecureSkipVerify: targetOpts.InsecureSkipVerify, sniClients: make(map[string]*http.Client), } } // getTarget constructs the target URL string for fetching OG tags. // Optimized to minimize allocations by building strings directly. func (c *OGTagCache) getTarget(u *url.URL) string { var escapedPath = u.EscapedPath() // will cause an allocation if path contains special characters if c.targetURL.Scheme == "unix" { // Build URL string directly without creating intermediate URL object var sb strings.Builder sb.Grow(len(c.unixPrefix) + len(escapedPath) + len(u.RawQuery) + querySeparatorLength) // Pre-allocate sb.WriteString(c.unixPrefix) sb.WriteString(escapedPath) if u.RawQuery != "" { sb.WriteByte('?') sb.WriteString(u.RawQuery) } return sb.String() } // For regular http/https targets, build URL string directly var sb strings.Builder // Pre-calculate size: scheme + "://" + host + path + "?" + query estimatedSize := len(c.targetURL.Scheme) + schemeSeparatorLength + len(c.targetURL.Host) + len(escapedPath) + len(u.RawQuery) + querySeparatorLength sb.Grow(estimatedSize) sb.WriteString(c.targetURL.Scheme) sb.WriteString("://") sb.WriteString(c.targetURL.Host) sb.WriteString(escapedPath) if u.RawQuery != "" { sb.WriteByte('?') sb.WriteString(u.RawQuery) } return sb.String() } ================================================ FILE: internal/ogtags/ogtags_fuzz_test.go ================================================ package ogtags import ( "context" "net/url" "slices" "strings" "testing" "unicode/utf8" "github.com/TecharoHQ/anubis/lib/config" "github.com/TecharoHQ/anubis/lib/store/memory" "golang.org/x/net/html" ) // FuzzGetTarget tests getTarget with various inputs func FuzzGetTarget(f *testing.F) { // Seed corpus with interesting test cases testCases := []struct { target string path string query string }{ {"http://example.com", "/", ""}, {"http://example.com", "/path", "q=1"}, {"unix:///tmp/socket", "/api", "key=value"}, {"https://example.com:8080", "/path/to/resource", "a=1&b=2"}, {"http://example.com", "/path with spaces", "q=hello world"}, {"http://example.com", "/path/❤️/emoji", "emoji=🎉"}, {"http://example.com", "/path/../../../etc/passwd", ""}, {"http://example.com", "/path%2F%2E%2E%2F", "q=%3Cscript%3E"}, {"unix:///var/run/app.sock", "/../../etc/passwd", ""}, {"http://[::1]:8080", "/ipv6", "test=1"}, {"http://example.com", strings.Repeat("/very/long/path", 100), strings.Repeat("param=value&", 100)}, {"http://example.com", "/path%20with%20encoded", "q=%20encoded%20"}, {"http://example.com", "/пример/кириллица", "q=тест"}, {"http://example.com", "/中文/路径", "查询=值"}, {"", "/path", "q=1"}, // Empty target } for _, tc := range testCases { f.Add(tc.target, tc.path, tc.query) } f.Fuzz(func(t *testing.T, target, path, query string) { // Skip invalid UTF-8 to focus on realistic inputs if !utf8.ValidString(target) || !utf8.ValidString(path) || !utf8.ValidString(query) { t.Skip() } // Create cache - should not panic cache := NewOGTagCache(target, config.OpenGraph{}, memory.New(context.Background()), TargetOptions{}) // Create URL u := &url.URL{ Path: path, RawQuery: query, } // Call getTarget - should not panic result := cache.getTarget(u) // Basic validation if result == "" { t.Errorf("getTarget returned empty string for target=%q, path=%q, query=%q", target, path, query) } // Verify result is a valid URL (for non-empty targets) if target != "" { parsedResult, err := url.Parse(result) if err != nil { t.Errorf("getTarget produced invalid URL %q: %v", result, err) } else { // For unix sockets, verify the scheme is http if strings.HasPrefix(target, "unix:") && parsedResult.Scheme != "http" { t.Errorf("Unix socket URL should have http scheme, got %q", parsedResult.Scheme) } } } // Ensure no memory corruption by calling multiple times for range 3 { result2 := cache.getTarget(u) if result != result2 { t.Errorf("getTarget not deterministic: %q != %q", result, result2) } } }) } // FuzzExtractOGTags tests extractOGTags with various HTML inputs func FuzzExtractOGTags(f *testing.F) { // Seed corpus with interesting HTML cases htmlCases := []string{ ``, ``, `` + strings.Repeat(``, 1000) + ``, ``, ``, ``, ``, ``, ``, ``, ``, ``, `` + strings.Repeat(`
`, 1000) + `` + strings.Repeat(`
`, 1000) + ``, ``, ``, ``, ``, // No content ``, // Empty HTML ``, ``, ``, ``, } for _, htmlc := range htmlCases { f.Add(htmlc) } f.Fuzz(func(t *testing.T, htmlContent string) { // Skip invalid UTF-8 if !utf8.ValidString(htmlContent) { t.Skip() } // Parse HTML - may fail on invalid input doc, err := html.Parse(strings.NewReader(htmlContent)) if err != nil { // This is expected for malformed HTML return } cache := NewOGTagCache("http://example.com", config.OpenGraph{}, memory.New(context.Background()), TargetOptions{}) // Should not panic tags := cache.extractOGTags(doc) // Validate results for property, content := range tags { // Ensure property is approved approved := false for _, prefix := range cache.approvedPrefixes { if strings.HasPrefix(property, prefix) { approved = true break } } if !approved { if slices.Contains(cache.approvedTags, property) { approved = true } } if !approved { t.Errorf("Unapproved property %q was extracted", property) } // Ensure content is valid string if !utf8.ValidString(content) { t.Errorf("Invalid UTF-8 in content for property %q", property) } } // Test determinism tags2 := cache.extractOGTags(doc) if len(tags) != len(tags2) { t.Errorf("extractOGTags not deterministic: different lengths %d != %d", len(tags), len(tags2)) } for k, v := range tags { if tags2[k] != v { t.Errorf("extractOGTags not deterministic: %q=%q != %q=%q", k, v, k, tags2[k]) } } }) } // FuzzGetTargetRoundTrip tests that getTarget produces valid URLs that can be parsed back func FuzzGetTargetRoundTrip(f *testing.F) { f.Add("http://example.com", "/path/to/resource", "key=value&foo=bar") f.Add("unix:///tmp/socket", "/api/endpoint", "param=test") f.Fuzz(func(t *testing.T, target, path, query string) { if !utf8.ValidString(target) || !utf8.ValidString(path) || !utf8.ValidString(query) { t.Skip() } cache := NewOGTagCache(target, config.OpenGraph{}, memory.New(context.Background()), TargetOptions{}) u := &url.URL{Path: path, RawQuery: query} result := cache.getTarget(u) if result == "" { return } // Parse the result back parsed, err := url.Parse(result) if err != nil { t.Errorf("getTarget produced unparseable URL: %v", err) return } // For non-unix targets, verify path preservation (accounting for encoding) if !strings.HasPrefix(target, "unix:") && target != "" { // The paths should match after normalization expectedPath := u.EscapedPath() if parsed.EscapedPath() != expectedPath { t.Errorf("Path not preserved: want %q, got %q", expectedPath, parsed.EscapedPath()) } // Query should be preserved exactly if parsed.RawQuery != query { t.Errorf("Query not preserved: want %q, got %q", query, parsed.RawQuery) } } }) } // FuzzExtractMetaTagInfo tests the extractMetaTagInfo function directly func FuzzExtractMetaTagInfo(f *testing.F) { // Seed with various attribute combinations f.Add("og:title", "Test Title", "property") f.Add("keywords", "test,keywords", "name") f.Add("og:description", "A description with \"quotes\"", "property") f.Add("twitter:card", "summary", "property") f.Add("unknown:tag", "Should be filtered", "property") f.Add("", "Content without property", "property") f.Add("og:title", "", "property") // Property without content f.Fuzz(func(t *testing.T, propertyValue, contentValue, propertyKey string) { if !utf8.ValidString(propertyValue) || !utf8.ValidString(contentValue) || !utf8.ValidString(propertyKey) { t.Skip() } // Create a meta node node := &html.Node{ Type: html.ElementNode, Data: "meta", Attr: []html.Attribute{ {Key: propertyKey, Val: propertyValue}, {Key: "content", Val: contentValue}, }, } cache := NewOGTagCache("http://example.com", config.OpenGraph{}, memory.New(context.Background()), TargetOptions{}) // Should not panic property, content := cache.extractMetaTagInfo(node) // If property is returned, it must be approved if property != "" { approved := false for _, prefix := range cache.approvedPrefixes { if strings.HasPrefix(property, prefix) { approved = true break } } if !approved { if slices.Contains(cache.approvedTags, property) { approved = true } } if !approved { t.Errorf("extractMetaTagInfo returned unapproved property: %q", property) } } // Content should match input if property is approved if property != "" && content != contentValue { t.Errorf("Content mismatch: want %q, got %q", contentValue, content) } }) } // Benchmark comparison for the fuzzed scenarios func BenchmarkFuzzedGetTarget(b *testing.B) { // Test with various challenging inputs found during fuzzing inputs := []struct { name string target string path string query string }{ {"Simple", "http://example.com", "/api", "k=v"}, {"LongPath", "http://example.com", strings.Repeat("/segment", 50), ""}, {"LongQuery", "http://example.com", "/", strings.Repeat("param=value&", 50)}, {"Unicode", "http://example.com", "/путь/路径/path", "q=значение"}, {"Encoded", "http://example.com", "/path%20with%20spaces", "q=%3Cscript%3E"}, {"Unix", "unix:///tmp/socket.sock", "/api/v1/resource", "id=123&format=json"}, } for _, input := range inputs { b.Run(input.name, func(b *testing.B) { cache := NewOGTagCache(input.target, config.OpenGraph{}, memory.New(context.Background()), TargetOptions{}) u := &url.URL{Path: input.path, RawQuery: input.query} b.ResetTimer() b.ReportAllocs() for i := 0; i < b.N; i++ { _ = cache.getTarget(u) } }) } } ================================================ FILE: internal/ogtags/ogtags_test.go ================================================ package ogtags import ( "context" "crypto/rand" "crypto/rsa" "crypto/tls" "crypto/x509" "crypto/x509/pkix" "errors" "fmt" "math/big" "net" "net/http" "net/http/httptest" "net/url" "os" "path/filepath" "reflect" "strings" "sync" "testing" "time" "github.com/TecharoHQ/anubis/lib/config" "github.com/TecharoHQ/anubis/lib/store/memory" ) 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, config.OpenGraph{ Enabled: tt.ogPassthrough, TimeToLive: tt.ogTimeToLive, ConsiderHost: false, }, memory.New(t.Context()), TargetOptions{}) if cache == nil { t.Fatal("expected non-nil cache, got nil") } // Check the parsed targetURL, handling the default case for empty target expectedURLStr := tt.target if tt.target == "" { // Default behavior when target is empty is now http://localhost expectedURLStr = "http://localhost" } else if !strings.Contains(tt.target, "://") && !strings.HasPrefix(tt.target, "unix:") { // Handle case where target is just host or host:port (and not unix) expectedURLStr = "http://" + tt.target } if cache.targetURL.String() != expectedURLStr { t.Errorf("expected targetURL %s, got %s", expectedURLStr, cache.targetURL.String()) } 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) } }) } } // TestNewOGTagCache_UnixSocket specifically tests unix socket initialization func TestNewOGTagCache_UnixSocket(t *testing.T) { tempDir := t.TempDir() socketPath := filepath.Join(tempDir, "test.sock") target := "unix://" + socketPath cache := NewOGTagCache(target, config.OpenGraph{ Enabled: true, TimeToLive: 5 * time.Minute, ConsiderHost: false, }, memory.New(t.Context()), TargetOptions{}) if cache == nil { t.Fatal("expected non-nil cache, got nil") } if cache.targetURL.Scheme != "unix" { t.Errorf("expected targetURL scheme 'unix', got '%s'", cache.targetURL.Scheme) } if cache.targetURL.Path != socketPath { t.Errorf("expected targetURL path '%s', got '%s'", socketPath, cache.targetURL.Path) } // Check if the client transport is configured for Unix sockets transport, ok := cache.client.Transport.(*http.Transport) if !ok { t.Fatalf("expected client transport to be *http.Transport, got %T", cache.client.Transport) } if transport.DialContext == nil { t.Fatal("expected client transport DialContext to be non-nil for unix socket") } // Attempt a dummy dial to see if it uses the correct path (optional, more involved check) dummyConn, err := transport.DialContext(context.Background(), "", "") if err == nil { dummyConn.Close() t.Log("DialContext seems functional, but couldn't verify path without a listener") } else if !strings.Contains(err.Error(), "connect: connection refused") && !strings.Contains(err.Error(), "connect: no such file or directory") { // We expect connection refused or not found if nothing is listening t.Errorf("DialContext failed with unexpected error: %v", err) } } 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", // Expect URL encoding and query parameter expected: "http://example.com/pag%28%23%2A%28%28%23@%29%CE%93%CE%93%CE%93%CE%93e/%CE%93?id=123", }, { name: "With query and path", target: "http://example.com", path: "/page", query: "id=123", expected: "http://example.com/page?id=123", }, { name: "Unix socket target", target: "unix:/tmp/anubis.sock", path: "/some/path", query: "key=value&flag=true", expected: "http://unix/some/path?key=value&flag=true", // Scheme becomes http, host is 'unix' }, { name: "Unix socket target with ///", target: "unix:///var/run/anubis.sock", path: "/", query: "", expected: "http://unix/", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cache := NewOGTagCache(tt.target, config.OpenGraph{ Enabled: true, TimeToLive: time.Minute, ConsiderHost: false, }, memory.New(t.Context()), TargetOptions{}) 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) } }) } } // TestIntegrationGetOGTags_UnixSocket tests fetching OG tags via a Unix socket. func TestIntegrationGetOGTags_UnixSocket(t *testing.T) { tempDir := t.TempDir() // XXX(Xe): if this is named longer, macOS fails with `bind: invalid argument` // because the unix socket path is too long. I love computers. socketPath := filepath.Join(tempDir, "t") // Ensure the socket does not exist initially _ = os.Remove(socketPath) // Create a simple HTTP server listening on the Unix socket listener, err := net.Listen("unix", socketPath) if err != nil { t.Fatalf("Failed to listen on unix socket %s: %v", socketPath, err) } defer func(listener net.Listener, socketPath string) { if listener != nil { if err := listener.Close(); err != nil && !errors.Is(err, net.ErrClosed) { t.Logf("Error closing listener: %v", err) } } if _, err := os.Stat(socketPath); err == nil { if err := os.Remove(socketPath); err != nil { t.Logf("Error removing socket file %s: %v", socketPath, err) } } }(listener, socketPath) server := &http.Server{ Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html") fmt.Fprintln(w, `Test`) }), } go func() { if err := server.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) { t.Logf("Unix socket server error: %v", err) } }() defer func(server *http.Server, ctx context.Context) { err := server.Shutdown(ctx) if err != nil { t.Logf("Error shutting down server: %v", err) } }(server, context.Background()) // Ensure server is shut down // Wait a moment for the server to start time.Sleep(100 * time.Millisecond) // Create cache instance pointing to the Unix socket targetURL := "unix://" + socketPath cache := NewOGTagCache(targetURL, config.OpenGraph{ Enabled: true, TimeToLive: time.Minute, ConsiderHost: false, }, memory.New(t.Context()), TargetOptions{}) // Create a dummy URL for the request (path and query matter) testReqURL, _ := url.Parse("/some/page?query=1") // Get OG tags // Pass an empty string for host, as it's irrelevant for unix sockets ogTags, err := cache.GetOGTags(t.Context(), testReqURL, "") if err != nil { t.Fatalf("GetOGTags failed for unix socket: %v", err) } expectedTags := map[string]string{ "og:title": "Unix Socket Test", } if !reflect.DeepEqual(ogTags, expectedTags) { t.Errorf("Expected OG tags %v, got %v", expectedTags, ogTags) } // Test cache retrieval (should hit cache) // Pass an empty string for host cachedTags, err := cache.GetOGTags(t.Context(), testReqURL, "") if err != nil { t.Fatalf("GetOGTags (cache hit) failed for unix socket: %v", err) } if !reflect.DeepEqual(cachedTags, expectedTags) { t.Errorf("Expected cached OG tags %v, got %v", expectedTags, cachedTags) } } func TestGetOGTagsWithTargetHostOverride(t *testing.T) { originalHost := "example.test" overrideHost := "backend.internal" seenHosts := make(chan string, 10) ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { seenHosts <- r.Host w.Header().Set("Content-Type", "text/html") fmt.Fprintln(w, `ok`) })) defer ts.Close() targetURL, err := url.Parse(ts.URL) if err != nil { t.Fatalf("failed to parse server URL: %v", err) } conf := config.OpenGraph{ Enabled: true, TimeToLive: time.Minute, ConsiderHost: false, } t.Run("default host uses original", func(t *testing.T) { cache := NewOGTagCache(ts.URL, conf, memory.New(t.Context()), TargetOptions{}) if _, err := cache.GetOGTags(t.Context(), targetURL, originalHost); err != nil { t.Fatalf("GetOGTags failed: %v", err) } select { case host := <-seenHosts: if host != originalHost { t.Fatalf("expected host %q, got %q", originalHost, host) } case <-time.After(time.Second): t.Fatal("server did not receive request") } }) t.Run("override host respected", func(t *testing.T) { cache := NewOGTagCache(ts.URL, conf, memory.New(t.Context()), TargetOptions{ Host: overrideHost, }) if _, err := cache.GetOGTags(t.Context(), targetURL, originalHost); err != nil { t.Fatalf("GetOGTags failed: %v", err) } select { case host := <-seenHosts: if host != overrideHost { t.Fatalf("expected host %q, got %q", overrideHost, host) } case <-time.After(time.Second): t.Fatal("server did not receive request") } }) } func TestGetOGTagsWithInsecureSkipVerify(t *testing.T) { handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html") fmt.Fprintln(w, `hello`) }) ts := httptest.NewTLSServer(handler) defer ts.Close() parsedURL, err := url.Parse(ts.URL) if err != nil { t.Fatalf("failed to parse server URL: %v", err) } conf := config.OpenGraph{ Enabled: true, TimeToLive: time.Minute, ConsiderHost: false, } // Without skip verify we should get a TLS error cacheStrict := NewOGTagCache(ts.URL, conf, memory.New(t.Context()), TargetOptions{}) if _, err := cacheStrict.GetOGTags(t.Context(), parsedURL, parsedURL.Host); err == nil { t.Fatal("expected TLS verification error without InsecureSkipVerify") } cacheSkip := NewOGTagCache(ts.URL, conf, memory.New(t.Context()), TargetOptions{ InsecureSkipVerify: true, }) tags, err := cacheSkip.GetOGTags(t.Context(), parsedURL, parsedURL.Host) if err != nil { t.Fatalf("expected successful fetch with InsecureSkipVerify, got: %v", err) } if tags["og:title"] != "Self-Signed" { t.Fatalf("expected og:title to be %q, got %q", "Self-Signed", tags["og:title"]) } } func TestGetOGTagsWithTargetSNI(t *testing.T) { originalHost := "hecate.test" conf := config.OpenGraph{ Enabled: true, TimeToLive: time.Minute, ConsiderHost: false, } t.Run("explicit SNI override", func(t *testing.T) { expectedSNI := "backend.internal" ts, recorder := newSNIServer(t, `ok`) defer ts.Close() targetURL, err := url.Parse(ts.URL) if err != nil { t.Fatalf("failed to parse server URL: %v", err) } cacheExplicit := NewOGTagCache(ts.URL, conf, memory.New(t.Context()), TargetOptions{ SNI: expectedSNI, InsecureSkipVerify: true, }) if _, err := cacheExplicit.GetOGTags(t.Context(), targetURL, originalHost); err != nil { t.Fatalf("expected successful fetch with explicit SNI, got: %v", err) } if got := recorder.last(); got != expectedSNI { t.Fatalf("expected server to see SNI %q, got %q", expectedSNI, got) } }) t.Run("auto SNI uses original host", func(t *testing.T) { ts, recorder := newSNIServer(t, `ok`) defer ts.Close() targetURL, err := url.Parse(ts.URL) if err != nil { t.Fatalf("failed to parse server URL: %v", err) } cacheAuto := NewOGTagCache(ts.URL, conf, memory.New(t.Context()), TargetOptions{ SNI: "auto", InsecureSkipVerify: true, }) if _, err := cacheAuto.GetOGTags(t.Context(), targetURL, originalHost); err != nil { t.Fatalf("expected successful fetch with auto SNI, got: %v", err) } if got := recorder.last(); got != originalHost { t.Fatalf("expected server to see SNI %q with auto, got %q", originalHost, got) } }) t.Run("default SNI uses backend host", func(t *testing.T) { ts, recorder := newSNIServer(t, `ok`) defer ts.Close() targetURL, err := url.Parse(ts.URL) if err != nil { t.Fatalf("failed to parse server URL: %v", err) } cacheDefault := NewOGTagCache(ts.URL, conf, memory.New(t.Context()), TargetOptions{ InsecureSkipVerify: true, }) if _, err := cacheDefault.GetOGTags(t.Context(), targetURL, originalHost); err != nil { t.Fatalf("expected successful fetch without explicit SNI, got: %v", err) } wantSNI := "" if net.ParseIP(targetURL.Hostname()) == nil { wantSNI = targetURL.Hostname() } if got := recorder.last(); got != wantSNI { t.Fatalf("expected default SNI %q, got %q", wantSNI, got) } }) } func newSNIServer(t *testing.T, body string) (*httptest.Server, *sniRecorder) { t.Helper() handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html") fmt.Fprint(w, body) }) recorder := &sniRecorder{} ts := httptest.NewUnstartedServer(handler) cert := mustCertificateForHost(t, "sni.test") ts.TLS = &tls.Config{ GetCertificate: func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) { recorder.record(hello.ServerName) return &cert, nil }, } ts.StartTLS() return ts, recorder } func mustCertificateForHost(t *testing.T, host string) tls.Certificate { t.Helper() priv, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { t.Fatalf("failed to generate key: %v", err) } template := &x509.Certificate{ SerialNumber: big.NewInt(1), Subject: pkix.Name{ CommonName: host, }, NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour), ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, BasicConstraintsValid: true, DNSNames: []string{host}, } der, err := x509.CreateCertificate(rand.Reader, template, template, &priv.PublicKey, priv) if err != nil { t.Fatalf("failed to create certificate: %v", err) } return tls.Certificate{ Certificate: [][]byte{der}, PrivateKey: priv, } } type sniRecorder struct { mu sync.Mutex names []string } func (r *sniRecorder) record(name string) { r.mu.Lock() defer r.mu.Unlock() r.names = append(r.names, name) } func (r *sniRecorder) last() string { r.mu.Lock() defer r.mu.Unlock() if len(r.names) == 0 { return "" } return r.names[len(r.names)-1] } ================================================ FILE: internal/ogtags/parse.go ================================================ package ogtags import ( "slices" "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) { 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 func (c *OGTagCache) extractMetaTagInfo(n *html.Node) (property, content string) { var propertyKey string // Single pass through attributes, using range to avoid bounds checking for _, attr := range n.Attr { switch attr.Key { case "property", "name": propertyKey = attr.Val case "content": content = attr.Val } // Early exit if we have both if propertyKey != "" && content != "" { break } } if propertyKey == "" { return "", content } // Check prefixes first (more common case) for _, prefix := range c.approvedPrefixes { if strings.HasPrefix(propertyKey, prefix) { return propertyKey, content } } // Check exact matches if slices.Contains(c.approvedTags, propertyKey) { return propertyKey, content } return "", content } ================================================ FILE: internal/ogtags/parse_test.go ================================================ package ogtags import ( "reflect" "strings" "testing" "time" "github.com/TecharoHQ/anubis/lib/config" "github.com/TecharoHQ/anubis/lib/store/memory" "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("", config.OpenGraph{ Enabled: false, ConsiderHost: false, TimeToLive: time.Minute, }, memory.New(t.Context()), TargetOptions{}) // Manually set approved tags/prefixes based on the user request for clarity testCache.approvedTags = []string{"description"} testCache.approvedPrefixes = []string{"og:"} tests := []struct { expected map[string]string name string htmlStr string }{ { name: "Basic OG tags", // Includes standard 'description' meta tag htmlStr: ` `, expected: map[string]string{ "og:title": "Test Title", "og:description": "Test Description", "description": "Regular Description", }, }, { name: "OG tags with name attribute", htmlStr: ` `, 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: ` `, expected: map[string]string{ "description": "Test Description", }, }, { name: "Empty content", htmlStr: ` `, expected: map[string]string{ "og:title": "", "og:description": "Test Description", }, }, { name: "Explicitly approved tag", htmlStr: ` `, 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: ``, targetNode: "meta", expected: true, }, { name: "Regular meta tag", nodeHTML: ``, targetNode: "meta", expected: true, }, { name: "Not a meta tag", nodeHTML: `
Test
`, 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 := "" + tt.nodeHTML + "" 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("", config.OpenGraph{ Enabled: false, ConsiderHost: false, TimeToLive: time.Minute, }, memory.New(t.Context()), TargetOptions{}) 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: ``, expectedProperty: "og:title", expectedContent: "Test Title", }, { name: "OG description with name (approved by prefix)", nodeHTML: ``, expectedProperty: "og:description", expectedContent: "Test Description", }, { name: "Regular meta tag (name=description, approved by exact match)", // Updated name for clarity nodeHTML: ``, expectedProperty: "description", expectedContent: "Test Description", }, { name: "Regular meta tag (name=keywords, not approved)", nodeHTML: ``, expectedProperty: "", expectedContent: "Test Keywords", }, { name: "Twitter tag (not approved by default)", nodeHTML: ``, expectedProperty: "", expectedContent: "summary", }, { name: "No content (but approved property)", nodeHTML: ``, expectedProperty: "og:title", expectedContent: "", }, { name: "No property/name attribute", nodeHTML: ``, expectedProperty: "", expectedContent: "No property", }, { name: "Explicitly approved tag with property attribute", nodeHTML: ``, 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 := "" + tt.nodeHTML + "" 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) } }) } } ================================================ FILE: internal/ogtags/sni.go ================================================ package ogtags import ( "crypto/tls" "net/http" ) // clientForSNI returns a cached client for the given server name, creating one if needed. func (c *OGTagCache) clientForSNI(serverName string) *http.Client { if !c.targetSNIAuto || serverName == "" { return c.client } c.transportMu.RLock() cli, ok := c.sniClients[serverName] c.transportMu.RUnlock() if ok { return cli } c.transportMu.Lock() defer c.transportMu.Unlock() if cli, ok := c.sniClients[serverName]; ok { return cli } tr := c.transport.Clone() if tr.TLSClientConfig == nil { tr.TLSClientConfig = &tls.Config{} } tr.TLSClientConfig.ServerName = serverName if c.insecureSkipVerify { tr.TLSClientConfig.InsecureSkipVerify = true } cli = &http.Client{ Timeout: httpTimeout, Transport: tr, } c.sniClients[serverName] = cli return cli } ================================================ FILE: internal/test/playwright_test.go ================================================ //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" "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") playwrightRunner = flag.String("playwright-runner", "npx", "how to start Playwright, can be: none,npx,docker,podman") 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: "Amazonbot", action: actionDeny, realIP: placeholderIP, userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_1) AppleWebKit/600.2.5 (KHTML, like Gecko) Version/8.0.2 Safari/600.2.5 (Amazonbot/0.1; +https://developer.amazon.com/support/amazonbot)", }, { name: "Amazonbot", action: actionDeny, realIP: placeholderIP, userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_1) AppleWebKit/600.2.5 (KHTML, like Gecko) Version/8.0.2 Safari/600.2.5 (Amazonbot/0.1; +https://developer.amazon.com/support/amazonbot)", }, { name: "PerplexityAI", action: actionDeny, realIP: placeholderIP, userAgent: "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; PerplexityBot/1.0; +https://perplexity.ai/perplexitybot)", }, { 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.52.0" ) type action string type testCase struct { name string action action realIP string userAgent string isHard bool } func doesCommandExist(t *testing.T, command string) { t.Helper() if _, err := exec.LookPath(command); err != nil { t.Skipf("%s not found in PATH, skipping integration smoke testing: %v", command, err) } } func run(t *testing.T, command string) string { if testing.Short() { t.Skip("skipping integration smoke testing in short mode") } 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 *playwrightRunner == "npx" { doesCommandExist(t, "npx") 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)) } else if *playwrightRunner == "docker" || *playwrightRunner == "podman" { doesCommandExist(t, *playwrightRunner) // docs: https://playwright.dev/docs/docker pwcmd := fmt.Sprintf("npx -y playwright@%s run-server --port %d --host 0.0.0.0", playwrightVersion, *playwrightPort) container := run(t, fmt.Sprintf("%s run -d --ipc=host --user pwuser --workdir /home/pwuser --net=host mcr.microsoft.com/playwright:v%s-noble /bin/sh -c \"%s\"", *playwrightRunner, playwrightVersion, pwcmd)) t.Cleanup(func() { run(t, fmt.Sprintf("%s rm --force %s", *playwrightRunner, container)) }) } else if *playwrightRunner == "none" { t.Log("not starting Playwright, assuming it is already running") } else { t.Skipf("unknown runner: %s, skipping", *playwrightRunner) } 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 } if os.Getenv("SKIP_INTEGRATION") != "" { t.Skip("SKIP_INTEGRATION was set") return } 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(""), }) 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 := range 5 { 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 TestPlaywrightWithBasePrefix(t *testing.T) { if os.Getenv("DONT_USE_NETWORK") != "" { t.Skip("test requires network egress") return } if os.Getenv("SKIP_INTEGRATION") != "" { t.Skip("SKIP_INTEGRATION was set") return } t.Skip("NOTE(Xe)\\ these tests require HTTPS support in #364") startPlaywright(t) pw := setupPlaywright(t) basePrefix := "/myapp" anubisURL := spawnAnubisWithOptions(t, basePrefix) // Reset BasePrefix after test t.Cleanup(func() { anubis.BasePrefix = "" }) browsers := []playwright.BrowserType{pw.Chromium} for _, typ := range browsers { t.Run(typ.Name()+"/basePrefix", func(t *testing.T) { browser, err := typ.Connect(buildBrowserConnect(typ.Name()), playwright.BrowserTypeConnectOptions{ ExposeNetwork: playwright.String(""), }) 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("Mozilla/5.0 (X11; Linux x86_64; rv:136.0) Gecko/20100101 Firefox/136.0"), }) 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() // Test accessing the base URL with prefix _, err = page.Goto(anubisURL+basePrefix, playwright.PageGotoOptions{ Timeout: pwTimeout(testCases[0], time.Now().Add(5*time.Second)), }) if err != nil { pwFail(t, page, "could not navigate to test server with base prefix: %v", err) } // Check if challenge page is displayed image := page.Locator("#image[src*=pensive], #image[src*=happy]") err = image.WaitFor(playwright.LocatorWaitForOptions{ Timeout: pwTimeout(testCases[0], time.Now().Add(5*time.Second)), }) if err != nil { pwFail(t, page, "could not wait for challenge image: %v", err) } isVisible, err := image.IsVisible() if err != nil { pwFail(t, page, "could not check if challenge image is visible: %v", err) } if !isVisible { pwFail(t, page, "challenge image not visible") } // Complete the challenge // Wait for the challenge to be solved anubisTest := page.Locator("#anubis-test") err = anubisTest.WaitFor(playwright.LocatorWaitForOptions{ Timeout: pwTimeout(testCases[0], time.Now().Add(30*time.Second)), }) if err != nil { pwFail(t, page, "could not wait for challenge to be solved: %v", err) } // Verify the challenge was solved content, err := anubisTest.TextContent(playwright.LocatorTextContentOptions{}) if err != nil { pwFail(t, page, "could not get text content: %v", err) } var tm int64 if _, err := fmt.Sscanf(content, "%d", &tm); err != nil { pwFail(t, page, "unexpected output: %s", content) } // Check if the timestamp is reasonable now := time.Now().Unix() if tm < now-60 || tm > now+60 { pwFail(t, page, "unexpected timestamp in output: %d not in range %d±60", tm, now) } // Check if cookie has the correct path cookies, err := ctx.Cookies() if err != nil { pwFail(t, page, "could not get cookies: %v", err) } var found bool for _, cookie := range cookies { if cookie.Name == anubis.CookieName { found = true if cookie.Path != basePrefix+"/" { t.Errorf("cookie path is wrong, wanted %s, got: %s", basePrefix+"/", cookie.Path) } break } } if !found { t.Errorf("Cookie %q not found", anubis.CookieName) } }) } } 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(""), }) 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 { maxTime := *playwrightMaxTime if tc.isHard { maxTime = *playwrightMaxHardTime } d := time.Until(deadline) if d <= 0 || d > maxTime { return playwright.Float(float64(maxTime.Milliseconds())) } return playwright.Float(float64(d.Milliseconds())) } func saveScreenshot(t *testing.T, page playwright.Page) { t.Helper() data, err := page.Screenshot() if err != nil { t.Logf("could not take screenshot: %v", err) return } f, err := os.CreateTemp("", "anubis-test-fail-*.png") if err != nil { t.Logf("could not create temporary file: %v", err) return } defer f.Close() _, err = f.Write(data) if err != nil { t.Logf("could not write screenshot: %v", err) return } t.Logf("screenshot saved to %s", f.Name()) } func setupPlaywright(t *testing.T) *playwright.Playwright { err := playwright.Install(&playwright.RunOptions{ SkipInstallBrowsers: true, }) if err != nil { t.Fatalf("could not install Playwright: %v", err) } pw, err := playwright.Run() if err != nil { t.Fatalf("could not start Playwright: %v", err) } return pw } func spawnAnubis(t *testing.T) string { return spawnAnubisWithOptions(t, "") } func spawnAnubisWithOptions(t *testing.T, basePrefix string) string { t.Helper() h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Add("Content-Type", "text/html") fmt.Fprintf(w, "%d", time.Now().Unix()) }) policy, err := libanubis.LoadPoliciesOrDefault(t.Context(), "", anubis.DefaultDifficulty, "info") 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, BasePrefix: basePrefix, }) 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 } ================================================ FILE: internal/test/var/.gitignore ================================================ *.png *.txt *.html ================================================ FILE: internal/unbreakdocker.go ================================================ package internal import ( "os" "os/exec" ) func UnbreakDocker() { // XXX(Xe): This is bad code. Do not do this. // // I have to do this because I'm running from inside the context of a dev // container. This dev container runs in a different docker network than // the valkey test container runs in. In order to let my dev container // connect to the test container, they need to share a network in common. // The easiest network to use for this is the default "bridge" network. // // This is a horrifying monstrosity, but the part that scares me the most // is the fact that it works. if hostname, err := os.Hostname(); err == nil { exec.Command("docker", "network", "connect", "bridge", hostname).Run() } } ================================================ FILE: internal/xff_test.go ================================================ package internal import ( "errors" "net/http" "net/http/httptest" "testing" ) func TestXForwardedForUpdateIgnoreUnix(t *testing.T) { var remoteAddr = "" var xff = "" h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { remoteAddr = r.RemoteAddr xff = r.Header.Get("X-Forwarded-For") w.WriteHeader(http.StatusOK) }) r := httptest.NewRequest(http.MethodGet, "/", nil) r.RemoteAddr = "@" w := httptest.NewRecorder() XForwardedForUpdate(true, h).ServeHTTP(w, r) if r.RemoteAddr != remoteAddr { t.Errorf("wanted remoteAddr to be %s, got: %s", r.RemoteAddr, remoteAddr) } if xff != "" { t.Error("handler added X-Forwarded-For when it should not have") } } func TestXForwardedForUpdateAddToChain(t *testing.T) { var xff = "" const expected = "1.1.1.1" h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { xff = r.Header.Get("X-Forwarded-For") w.WriteHeader(http.StatusOK) }) srv := httptest.NewServer(XForwardedForUpdate(true, h)) r, err := http.NewRequest(http.MethodGet, srv.URL, nil) if err != nil { t.Fatal(err) } r.Header.Set("X-Forwarded-For", "1.1.1.1,10.20.30.40") if _, err := srv.Client().Do(r); err != nil { t.Fatal(err) } if xff != expected { t.Logf("expected: %s", expected) t.Logf("got: %s", xff) t.Error("X-Forwarded-For header was not what was expected") } } func TestComputeXFFHeader(t *testing.T) { for _, tt := range []struct { err error name string remoteAddr string origXFFHeader string result string pref XFFComputePreferences }{ { name: "StripPrivate", remoteAddr: "127.0.0.1:80", origXFFHeader: "1.1.1.1,10.0.0.1", pref: XFFComputePreferences{ StripPrivate: true, }, result: "1.1.1.1,127.0.0.1", }, { name: "StripPrivate", remoteAddr: "127.0.0.1:80", origXFFHeader: "1.1.1.1,10.0.0.1", pref: XFFComputePreferences{ StripPrivate: false, }, result: "1.1.1.1,10.0.0.1,127.0.0.1", }, { name: "StripLoopback", remoteAddr: "127.0.0.1:80", origXFFHeader: "1.1.1.1,10.0.0.1,127.0.0.1", pref: XFFComputePreferences{ StripLoopback: true, }, result: "1.1.1.1,10.0.0.1", }, { name: "StripCGNAT", remoteAddr: "100.64.0.1:80", origXFFHeader: "1.1.1.1,10.0.0.1,100.64.0.1", pref: XFFComputePreferences{ StripCGNAT: true, }, result: "1.1.1.1,10.0.0.1", }, { name: "StripLinkLocalUnicastIPv4", remoteAddr: "169.254.0.1:80", origXFFHeader: "1.1.1.1,10.0.0.1,169.254.0.1", pref: XFFComputePreferences{ StripLLU: true, }, result: "1.1.1.1,10.0.0.1", }, { name: "StripLinkLocalUnicastIPv6", remoteAddr: "169.254.0.1:80", origXFFHeader: "1.1.1.1,10.0.0.1,fe80::", pref: XFFComputePreferences{ StripLLU: true, }, result: "1.1.1.1,10.0.0.1", }, { name: "Flatten", remoteAddr: "127.0.0.1:80", origXFFHeader: "1.1.1.1,10.0.0.1,fe80::,100.64.0.1,169.254.0.1", pref: XFFComputePreferences{ StripPrivate: true, StripLoopback: true, StripCGNAT: true, StripLLU: true, Flatten: true, }, result: "1.1.1.1", }, { name: "TrimSpaces", remoteAddr: "127.0.0.1:80", origXFFHeader: "1.1.1.1, 10.0.0.1, fe80::, 100.64.0.1, 169.254.0.1", pref: XFFComputePreferences{ StripPrivate: true, StripLoopback: true, StripCGNAT: true, StripLLU: true, Flatten: true, }, result: "1.1.1.1", }, { name: "invalid-ip-port", remoteAddr: "fe80::", err: ErrCantSplitHostParse, }, { name: "invalid-remote-ip", remoteAddr: "anubis:80", err: ErrCantParseRemoteIP, }, { name: "no-xff-dont-panic", remoteAddr: "127.0.0.1:80", pref: XFFComputePreferences{ StripPrivate: true, StripLoopback: true, StripCGNAT: true, StripLLU: true, Flatten: true, }, }, } { t.Run(tt.name, func(t *testing.T) { result, err := computeXFFHeader(tt.remoteAddr, tt.origXFFHeader, tt.pref) if err != nil && !errors.Is(err, tt.err) { t.Errorf("computeXFFHeader got the wrong error, wanted %v but got: %v", tt.err, err) } if result != tt.result { t.Errorf("computeXFFHeader returned the wrong result, wanted %q but got: %q", tt.result, result) } }) } } ================================================ FILE: lib/anubis.go ================================================ package lib import ( "context" "crypto/ed25519" "crypto/rand" "encoding/json" "errors" "fmt" "log/slog" "net" "net/http" "net/url" "strings" "time" "github.com/golang-jwt/jwt/v5" "github.com/google/cel-go/common/types" "github.com/google/uuid" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" "github.com/TecharoHQ/anubis" "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/challenge" "github.com/TecharoHQ/anubis/lib/config" "github.com/TecharoHQ/anubis/lib/localization" "github.com/TecharoHQ/anubis/lib/policy" "github.com/TecharoHQ/anubis/lib/policy/checker" "github.com/TecharoHQ/anubis/lib/store" // challenge implementations _ "github.com/TecharoHQ/anubis/lib/challenge/metarefresh" _ "github.com/TecharoHQ/anubis/lib/challenge/preact" _ "github.com/TecharoHQ/anubis/lib/challenge/proofofwork" ) var ( challengesIssued = promauto.NewCounterVec(prometheus.CounterOpts{ Name: "anubis_challenges_issued", Help: "The total number of challenges issued", }, []string{"method"}) challengesValidated = promauto.NewCounterVec(prometheus.CounterOpts{ Name: "anubis_challenges_validated", Help: "The total number of challenges validated", }, []string{"method"}) droneBLHits = promauto.NewCounterVec(prometheus.CounterOpts{ Name: "anubis_dronebl_hits", Help: "The total number of hits from DroneBL", }, []string{"status"}) failedValidations = promauto.NewCounterVec(prometheus.CounterOpts{ Name: "anubis_failed_validations", Help: "The total number of failed validations", }, []string{"method"}) requestsProxied = promauto.NewCounterVec(prometheus.CounterOpts{ Name: "anubis_proxied_requests_total", Help: "Number of requests proxied through Anubis to upstream targets", }, []string{"host"}) ) type Server struct { next http.Handler store store.Interface mux *http.ServeMux policy *policy.ParsedConfig OGTags *ogtags.OGTagCache logger *slog.Logger opts Options ed25519Priv ed25519.PrivateKey hs512Secret []byte } func (s *Server) getTokenKeyfunc() jwt.Keyfunc { // return ED25519 key if HS512 is not set if len(s.hs512Secret) == 0 { return func(token *jwt.Token) (any, error) { return s.ed25519Priv.Public().(ed25519.PublicKey), nil } } else { return func(token *jwt.Token) (any, error) { return s.hs512Secret, nil } } } func (s *Server) getChallenge(r *http.Request) (*challenge.Challenge, error) { id := r.FormValue("id") j := store.JSON[challenge.Challenge]{Underlying: s.store} chall, err := j.Get(r.Context(), "challenge:"+id) return &chall, err } func (s *Server) issueChallenge(ctx context.Context, r *http.Request, lg *slog.Logger, cr policy.CheckResult, rule *policy.Bot) (*challenge.Challenge, error) { if cr.Rule != config.RuleChallenge { slog.Error("this should be impossible, asked to issue a challenge but the rule is not a challenge rule", "cr", cr, "rule", rule) //return nil, errors.New("[unexpected] this codepath should be impossible, asked to issue a challenge for a non-challenge rule") } if rule.Challenge == nil { rule.Challenge = &config.ChallengeRules{ Difficulty: s.policy.DefaultDifficulty, Algorithm: config.DefaultAlgorithm, } } id, err := uuid.NewV7() if err != nil { return nil, err } var randomData = make([]byte, 64) if _, err := rand.Read(randomData); err != nil { return nil, err } chall := challenge.Challenge{ ID: id.String(), Method: rule.Challenge.Algorithm, RandomData: fmt.Sprintf("%x", randomData), IssuedAt: time.Now(), Difficulty: rule.Challenge.Difficulty, PolicyRuleHash: rule.Hash(), Metadata: map[string]string{ "User-Agent": r.Header.Get("User-Agent"), "X-Real-Ip": r.Header.Get("X-Real-Ip"), }, } j := store.JSON[challenge.Challenge]{Underlying: s.store} if err := j.Set(ctx, "challenge:"+id.String(), chall, 30*time.Minute); err != nil { return nil, err } lg.Info("new challenge issued", "challenge", id.String()) return &chall, err } func (s *Server) hydrateChallengeRule(rule *policy.Bot, chall *challenge.Challenge, lg *slog.Logger) *policy.Bot { if chall == nil { return rule } if rule == nil { rule = &policy.Bot{ Rules: &checker.List{}, } } if chall.Difficulty == 0 { // fall back to whatever the policy currently says or the global default if rule.Challenge != nil && rule.Challenge.Difficulty != 0 { chall.Difficulty = rule.Challenge.Difficulty } else { chall.Difficulty = s.policy.DefaultDifficulty } } if rule.Challenge == nil { lg.Warn("rule missing challenge configuration; using stored challenge metadata", "rule", rule.Name) rule.Challenge = &config.ChallengeRules{} } if rule.Challenge.Difficulty == 0 { rule.Challenge.Difficulty = chall.Difficulty } if rule.Challenge.ReportAs != 0 { s.logger.Warn("[DEPRECATION] the report_as field in this bot rule is deprecated, see https://github.com/TecharoHQ/anubis/issues/1310 for more information", "bot_name", rule.Name, "difficulty", rule.Challenge.Difficulty, "report_as", rule.Challenge.ReportAs) } if rule.Challenge.Algorithm == "" { rule.Challenge.Algorithm = chall.Method } return rule } func (s *Server) maybeReverseProxyHttpStatusOnly(w http.ResponseWriter, r *http.Request) { s.maybeReverseProxy(w, r, true) } func (s *Server) maybeReverseProxyOrPage(w http.ResponseWriter, r *http.Request) { s.maybeReverseProxy(w, r, false) } func (s *Server) maybeReverseProxy(w http.ResponseWriter, r *http.Request, httpStatusOnly bool) { lg := internal.GetRequestLogger(s.logger, r) if val, _ := s.store.Get(r.Context(), fmt.Sprintf("ogtags:allow:%s%s", r.Host, r.URL.String())); val != nil { lg.Debug("serving opengraph tag asset") s.ServeHTTPNext(w, r) return } // Adjust cookie path if base prefix is not empty cookiePath := "/" if anubis.BasePrefix != "" { cookiePath = strings.TrimSuffix(anubis.BasePrefix, "/") + "/" } cr, rule, err := s.check(r, lg) if err != nil { lg.Error("check failed", "err", err) localizer := localization.GetLocalizer(r) s.respondWithError(w, r, fmt.Sprintf("%s \"maybeReverseProxy\"", localizer.T("internal_server_error")), makeCode(err)) 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.Applications.WithLabelValues(cr.Name, string(cr.Rule)).Add(1) ip := r.Header.Get("X-Real-Ip") if s.handleDNSBL(w, r, ip, lg) { return } if s.checkRules(w, r, cr, lg, rule) { return } ckie, err := r.Cookie(anubis.CookieName) if err != nil { lg.Debug("cookie not found", "path", r.URL.Path) s.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host}) s.RenderIndex(w, r, cr, rule, httpStatusOnly) return } if err := ckie.Valid(); err != nil { lg.Debug("cookie is invalid", "err", err) s.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host}) s.RenderIndex(w, r, cr, rule, httpStatusOnly) return } if time.Now().After(ckie.Expires) && !ckie.Expires.IsZero() { lg.Debug("cookie expired", "path", r.URL.Path) s.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host}) s.RenderIndex(w, r, cr, rule, httpStatusOnly) return } token, err := jwt.ParseWithClaims(ckie.Value, jwt.MapClaims{}, s.getTokenKeyfunc(), jwt.WithExpirationRequired(), jwt.WithStrictDecoding()) if err != nil || !token.Valid { lg.Debug("invalid token", "path", r.URL.Path, "err", err) s.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host}) s.RenderIndex(w, r, cr, rule, httpStatusOnly) return } claims, ok := token.Claims.(jwt.MapClaims) if !ok { lg.Debug("invalid token claims type", "path", r.URL.Path) s.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host}) s.RenderIndex(w, r, cr, rule, httpStatusOnly) return } policyRule, ok := claims["policyRule"].(string) if !ok { lg.Debug("policyRule claim is not a string") s.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host}) s.RenderIndex(w, r, cr, rule, httpStatusOnly) return } if policyRule != rule.Hash() { lg.Debug("user originally passed with a different rule, issuing new challenge", "old", policyRule, "new", rule.Name) s.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host}) s.RenderIndex(w, r, cr, rule, httpStatusOnly) return } if s.opts.JWTRestrictionHeader != "" && claims["restriction"] != internal.SHA256sum(r.Header.Get(s.opts.JWTRestrictionHeader)) { lg.Debug("JWT restriction header is invalid") s.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host}) s.RenderIndex(w, r, cr, rule, httpStatusOnly) return } r.Header.Add("X-Anubis-Status", "PASS") s.ServeHTTPNext(w, r) } func (s *Server) checkRules(w http.ResponseWriter, r *http.Request, cr policy.CheckResult, lg *slog.Logger, rule *policy.Bot) bool { // Adjust cookie path if base prefix is not empty cookiePath := "/" if anubis.BasePrefix != "" { cookiePath = strings.TrimSuffix(anubis.BasePrefix, "/") + "/" } localizer := localization.GetLocalizer(r) switch cr.Rule { case config.RuleAllow: lg.Debug("allowing traffic to origin (explicit)") s.ServeHTTPNext(w, r) return true case config.RuleDeny: s.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host}) lg.Info("explicit deny") if rule == nil { lg.Error("rule is nil, cannot calculate checksum") s.respondWithError(w, r, fmt.Sprintf("%s \"maybeReverseProxy.RuleDeny\"", localizer.T("internal_server_error")), makeCode(ErrActualAnubisBug)) return true } hash := rule.Hash() lg.Debug("rule hash", "hash", hash) s.respondWithStatus(w, r, fmt.Sprintf("%s %s", localizer.T("access_denied"), hash), "", s.policy.StatusCodes.Deny) return true case config.RuleChallenge: lg.Debug("challenge requested") case config.RuleBenchmark: lg.Debug("serving benchmark page") s.RenderBench(w, r) return true default: s.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host}) lg.Error("CONFIG ERROR: unknown rule", "rule", cr.Rule) s.respondWithError(w, r, fmt.Sprintf("%s \"maybeReverseProxy.Rules\"", localizer.T("internal_server_error")), makeCode(ErrActualAnubisBug)) return true } return false } func (s *Server) handleDNSBL(w http.ResponseWriter, r *http.Request, ip string, lg *slog.Logger) bool { db := &store.JSON[dnsbl.DroneBLResponse]{Underlying: s.store, Prefix: "dronebl:"} if s.policy.DNSBL && ip != "" { resp, err := db.Get(r.Context(), ip) if err != nil { 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) } db.Set(r.Context(), ip, resp, 24*time.Hour) droneBLHits.WithLabelValues(resp.String()).Inc() } if resp != dnsbl.AllGood { lg.Info("DNSBL hit", "status", resp.String()) localizer := localization.GetLocalizer(r) s.respondWithStatus(w, r, fmt.Sprintf("%s: %s, %s https://dronebl.org/lookup?ip=%s", localizer.T("dronebl_entry"), resp.String(), localizer.T("see_dronebl_lookup"), ip), "", s.policy.StatusCodes.Deny) return true } } return false } func (s *Server) MakeChallenge(w http.ResponseWriter, r *http.Request) { lg := internal.GetRequestLogger(s.logger, r) localizer := localization.GetLocalizer(r) redir := r.FormValue("redir") if redir == "" { w.WriteHeader(http.StatusBadRequest) encoder := json.NewEncoder(w) lg.Error("invalid invocation of MakeChallenge", "redir", redir) encoder.Encode(struct { Error string `json:"error"` }{ Error: localizer.T("invalid_invocation"), }) return } r.URL.Path = redir encoder := json.NewEncoder(w) cr, rule, err := s.check(r, lg) if err != nil { lg.Error("check failed", "err", err) w.WriteHeader(http.StatusInternalServerError) err := encoder.Encode(struct { Error string `json:"error"` }{ Error: fmt.Sprintf("%s \"makeChallenge\"", localizer.T("internal_server_error")), }) if err != nil { lg.Error("failed to encode error response", "err", err) w.WriteHeader(http.StatusInternalServerError) } return } lg = lg.With("check_result", cr) chall, err := s.issueChallenge(r.Context(), r, lg, cr, rule) if err != nil { lg.Error("failed to fetch or issue challenge", "err", err) w.WriteHeader(http.StatusInternalServerError) err := encoder.Encode(struct { Error string `json:"error"` }{ Error: fmt.Sprintf("%s \"makeChallenge\"", localizer.T("internal_server_error")), }) if err != nil { lg.Error("failed to encode error response", "err", err) w.WriteHeader(http.StatusInternalServerError) } return } s.SetCookie(w, CookieOpts{Host: r.Host, Name: anubis.TestCookieName, Value: chall.ID}) err = encoder.Encode(struct { Rules *config.ChallengeRules `json:"rules"` Challenge string `json:"challenge"` ID string `json:"id"` }{ Rules: rule.Challenge, Challenge: chall.RandomData, ID: chall.ID, }) if err != nil { lg.Error("failed to encode challenge", "err", err) w.WriteHeader(http.StatusInternalServerError) return } lg.Debug("made challenge", "challenge", chall, "rules", rule.Challenge, "cr", cr) challengesIssued.WithLabelValues("api").Inc() } func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) { lg := internal.GetRequestLogger(s.logger, r) localizer := localization.GetLocalizer(r) redir := r.FormValue("redir") redirURL, err := url.ParseRequestURI(redir) if err != nil { lg.Error("invalid redirect", "err", err) s.respondWithStatus(w, r, localizer.T("invalid_redirect"), makeCode(err), http.StatusBadRequest) return } switch redirURL.Scheme { case "", "http", "https": // allowed default: lg.Error("XSS attempt blocked, invalid redirect scheme", "scheme", redirURL.Scheme) s.respondWithStatus(w, r, localizer.T("invalid_redirect"), "", http.StatusBadRequest) return } // Adjust cookie path if base prefix is not empty cookiePath := "/" if anubis.BasePrefix != "" { cookiePath = strings.TrimSuffix(anubis.BasePrefix, "/") + "/" } if _, err := r.Cookie(anubis.TestCookieName); errors.Is(err, http.ErrNoCookie) { s.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host}) s.ClearCookie(w, CookieOpts{Name: anubis.TestCookieName, Host: r.Host}) lg.Warn("user has cookies disabled, this is not an anubis bug") s.respondWithError(w, r, localizer.T("cookies_disabled"), "") return } // used by the path checker rule r.URL = redirURL urlParsed, err := r.URL.Parse(redir) if err != nil { s.respondWithError(w, r, localizer.T("redirect_not_parseable"), makeCode(err)) return } if (len(urlParsed.Host) > 0 && len(s.opts.RedirectDomains) != 0 && !matchRedirectDomain(s.opts.RedirectDomains, urlParsed.Host)) || urlParsed.Host != r.URL.Host { lg.Debug("domain not allowed", "domain", urlParsed.Host) s.respondWithError(w, r, localizer.T("redirect_domain_not_allowed"), "") return } cr, rule, err := s.check(r, lg) if err != nil { lg.Error("check failed", "err", err) s.respondWithError(w, r, fmt.Sprintf("%s \"passChallenge\"", localizer.T("internal_server_error")), makeCode(err)) return } lg = lg.With("check_result", cr) chall, err := s.getChallenge(r) if err != nil { lg.Error("getChallenge failed", "err", err) algorithm := "unknown" if rule.Challenge != nil { algorithm = rule.Challenge.Algorithm } s.respondWithError(w, r, fmt.Sprintf("%s: %s", localizer.T("internal_server_error"), algorithm), makeCode(err)) return } if chall.Spent { lg.Error("double spend prevented", "reason", "double_spend") s.respondWithError(w, r, fmt.Sprintf("%s: %s", localizer.T("internal_server_error"), "double_spend"), "") return } rule = s.hydrateChallengeRule(rule, chall, lg) impl, ok := challenge.Get(chall.Method) if !ok { lg.Error("check failed", "err", err) s.respondWithError(w, r, fmt.Sprintf("%s: %s", localizer.T("internal_server_error"), rule.Challenge.Algorithm), makeCode(ErrActualAnubisBug)) return } lg = lg.With("challenge", chall.ID) in := &challenge.ValidateInput{ Challenge: chall, Rule: rule, Store: s.store, } if err := impl.Validate(r, lg, in); err != nil { failedValidations.WithLabelValues(rule.Challenge.Algorithm).Inc() var cerr *challenge.Error s.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host}) lg.Debug("challenge validate call failed", "err", err) switch { case errors.As(err, &cerr): switch { case errors.Is(err, challenge.ErrFailed): lg.Error("challenge failed", "err", err) s.respondWithStatus(w, r, cerr.PublicReason, makeCode(err), cerr.StatusCode) return case errors.Is(err, challenge.ErrInvalidFormat), errors.Is(err, challenge.ErrMissingField): lg.Error("invalid challenge format", "err", err) s.respondWithError(w, r, cerr.PublicReason, makeCode(err)) return } } } // generate JWT cookie var tokenString string // check if JWTRestrictionHeader is set and header is in request claims := jwt.MapClaims{ "challenge": chall.ID, "method": rule.Challenge.Algorithm, "policyRule": rule.Hash(), "action": string(cr.Rule), } if s.opts.JWTRestrictionHeader != "" { if r.Header.Get(s.opts.JWTRestrictionHeader) == "" { lg.Error("JWTRestrictionHeader is set in config but not found in request, please check your reverse proxy config.") s.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host}) s.respondWithError(w, r, "failed to sign JWT", makeCode(err)) return } else { claims["restriction"] = internal.SHA256sum(r.Header.Get(s.opts.JWTRestrictionHeader)) } } if s.opts.DifficultyInJWT { claims["difficulty"] = rule.Challenge.Difficulty } tokenString, err = s.signJWT(claims) if err != nil { lg.Error("failed to sign JWT", "err", err) s.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host}) s.respondWithError(w, r, localizer.T("failed_to_sign_jwt"), makeCode(err)) return } s.SetCookie(w, CookieOpts{Path: cookiePath, Host: r.Host, Value: tokenString}) chall.Spent = true j := store.JSON[challenge.Challenge]{Underlying: s.store} if err := j.Set(r.Context(), "challenge:"+chall.ID, *chall, 30*time.Minute); err != nil { lg.Debug("can't update information about challenge", "err", err) } challengesValidated.WithLabelValues(rule.Challenge.Algorithm).Inc() lg.Debug("challenge passed, redirecting to app") http.Redirect(w, r, redir, http.StatusFound) } func cr(name string, rule config.Rule, weight int) policy.CheckResult { return policy.CheckResult{ Name: name, Rule: rule, Weight: weight, } } // Check evaluates the list of rules, and returns the result func (s *Server) check(r *http.Request, lg *slog.Logger) (policy.CheckResult, *policy.Bot, error) { host := r.Header.Get("X-Real-Ip") if host == "" { return decaymap.Zilch[policy.CheckResult](), nil, fmt.Errorf("[misconfiguration] X-Real-Ip header is not set") } addr := net.ParseIP(host) if addr == nil { return decaymap.Zilch[policy.CheckResult](), nil, fmt.Errorf("[misconfiguration] %q is not an IP address", host) } weight := 0 for _, b := range s.policy.Bots { match, err := b.Rules.Check(r) if err != nil { return decaymap.Zilch[policy.CheckResult](), nil, fmt.Errorf("can't run check %s: %w", b.Name, err) } if match { switch b.Action { case config.RuleDeny, config.RuleAllow, config.RuleBenchmark, config.RuleChallenge: return cr("bot/"+b.Name, b.Action, weight), &b, nil case config.RuleWeigh: lg.Debug("adjusting weight", "name", b.Name, "delta", b.Weight.Adjust) policy.Applications.WithLabelValues("bot/"+b.Name, "WEIGH").Add(1) weight += b.Weight.Adjust } } } for _, t := range s.policy.Thresholds { result, _, err := t.Program.ContextEval(r.Context(), &policy.ThresholdRequest{Weight: weight}) if err != nil { lg.Error("error when evaluating threshold expression", "expression", t.Expression.String(), "err", err) continue } var matches bool if val, ok := result.(types.Bool); ok { matches = bool(val) } if matches { challRules := t.Challenge if challRules == nil { // Non-CHALLENGE thresholds (ALLOW/DENY) don't have challenge config. // Use an empty struct so hydrateChallengeRule can fill from stored // challenge data during validation, rather than baking in defaults // that could mismatch the difficulty the client actually solved for. challRules = &config.ChallengeRules{} } return cr("threshold/"+t.Name, t.Action, weight), &policy.Bot{ Challenge: challRules, Rules: &checker.List{}, }, nil } } return cr("default/allow", config.RuleAllow, weight), &policy.Bot{ Challenge: &config.ChallengeRules{ Difficulty: s.policy.DefaultDifficulty, Algorithm: config.DefaultAlgorithm, }, Rules: &checker.List{}, }, nil } ================================================ FILE: lib/anubis_test.go ================================================ package lib import ( "bytes" "context" "encoding/json" "fmt" "io" "log/slog" "net/http" "net/http/httptest" "net/url" "os" "strings" "sync" "testing" "time" "github.com/TecharoHQ/anubis" "github.com/TecharoHQ/anubis/data" "github.com/TecharoHQ/anubis/internal" "github.com/TecharoHQ/anubis/lib/challenge" "github.com/TecharoHQ/anubis/lib/config" "github.com/TecharoHQ/anubis/lib/policy" "github.com/TecharoHQ/anubis/lib/store" "github.com/TecharoHQ/anubis/lib/thoth/thothmock" ) // TLogWriter implements io.Writer by logging each line to t.Log. type TLogWriter struct { t *testing.T } // NewTLogWriter returns an io.Writer that sends output to t.Log. func NewTLogWriter(t *testing.T) io.Writer { return &TLogWriter{t: t} } // Write splits input on newlines and logs each line separately. func (w *TLogWriter) Write(p []byte) (n int, err error) { lines := strings.SplitSeq(string(p), "\n") for line := range lines { if line != "" { w.t.Log(line) } } return len(p), nil } func loadPolicies(t *testing.T, fname string, difficulty int) *policy.ParsedConfig { t.Helper() ctx := thothmock.WithMockThoth(t) if fname == "" { fname = "./testdata/test_config.yaml" } t.Logf("loading policy file: %s", fname) anubisPolicy, err := LoadPoliciesOrDefault(ctx, fname, difficulty, "info") if err != nil { t.Fatal(err) } return anubisPolicy } func spawnAnubis(t *testing.T, opts Options) *Server { t.Helper() if opts.Policy == nil { opts.Policy = loadPolicies(t, "", 4) } s, err := New(opts) if err != nil { t.Fatalf("can't construct libanubis.Server: %v", err) } s.logger = slog.New(slog.NewJSONHandler(&TLogWriter{t: t}, &slog.HandlerOptions{ AddSource: true, Level: slog.LevelDebug, })) return s } type challengeResp struct { ID string `json:"id"` Challenge string `json:"challenge"` } func makeChallenge(t *testing.T, ts *httptest.Server, cli *http.Client) challengeResp { t.Helper() req, err := http.NewRequest(http.MethodPost, ts.URL+"/.within.website/x/cmd/anubis/api/make-challenge", nil) if err != nil { t.Fatalf("can't make request: %v", err) } q := req.URL.Query() q.Set("redir", "/") req.URL.RawQuery = q.Encode() resp, err := cli.Do(req) if err != nil { t.Fatalf("can't request challenge: %v", err) } defer resp.Body.Close() var chall challengeResp if err := json.NewDecoder(resp.Body).Decode(&chall); err != nil { t.Fatalf("can't read challenge response body: %v", err) } return chall } func handleChallengeZeroDifficulty(t *testing.T, ts *httptest.Server, cli *http.Client, chall challengeResp) *http.Response { t.Helper() t.Logf("%#v", chall) 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)) q.Set("id", chall.ID) req.URL.RawQuery = q.Encode() t.Log(q.Encode()) resp, err := cli.Do(req) if err != nil { t.Fatalf("can't do request: %v", err) } return resp } func handleChallengeInvalidProof(t *testing.T, ts *httptest.Server, cli *http.Client, chall challengeResp) *http.Response { t.Helper() 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", strings.Repeat("f", 64)) // "hash" that never starts with the nonce q.Set("nonce", "0") q.Set("redir", "/") q.Set("elapsedTime", "0") q.Set("id", chall.ID) req.URL.RawQuery = q.Encode() resp, err := cli.Do(req) if err != nil { t.Fatalf("can't do request: %v", err) } return resp } type loggingCookieJar struct { t *testing.T cookies map[string][]*http.Cookie lock sync.Mutex } func (lcj *loggingCookieJar) Cookies(u *url.URL) []*http.Cookie { lcj.lock.Lock() defer lcj.lock.Unlock() // XXX(Xe): This is not RFC compliant in the slightest. result, ok := lcj.cookies[u.Host] if !ok { return nil } lcj.t.Logf("requested cookies for %s", u) for _, ckie := range result { lcj.t.Logf("get cookie: <- %s", ckie) } return result } func (lcj *loggingCookieJar) SetCookies(u *url.URL, cookies []*http.Cookie) { lcj.lock.Lock() defer lcj.lock.Unlock() for _, ckie := range cookies { lcj.t.Logf("set cookie: %s -> %s", u, ckie) } // XXX(Xe): This is not RFC compliant in the slightest. lcj.cookies[u.Host] = append(lcj.cookies[u.Host], cookies...) } type userAgentRoundTripper struct { rt http.RoundTripper } func (u *userAgentRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { // Only set if not already present req = req.Clone(req.Context()) // avoid mutating original request req.Header.Set("User-Agent", "Mozilla/5.0") req.Header.Set("Accept-Encoding", "gzip") return u.rt.RoundTrip(req) } func httpClient(t *testing.T) *http.Client { t.Helper() cli := &http.Client{ Jar: &loggingCookieJar{t: t, cookies: map[string][]*http.Cookie{}}, CheckRedirect: func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse }, Transport: &userAgentRoundTripper{ rt: http.DefaultTransport, }, } return cli } func TestLoadPolicies(t *testing.T) { for _, fname := range []string{"botPolicies.yaml"} { t.Run(fname, func(t *testing.T) { fin, err := data.BotPolicies.Open(fname) if err != nil { t.Fatal(err) } defer fin.Close() if _, err := policy.ParseConfig(t.Context(), fin, fname, 4, "info"); err != nil { t.Fatal(err) } }) } } // Regression test for CVE-2025-24369 func TestCVE2025_24369(t *testing.T) { pol := loadPolicies(t, "", anubis.DefaultDifficulty) srv := spawnAnubis(t, Options{ Next: http.NewServeMux(), Policy: pol, }) ts := httptest.NewServer(internal.RemoteXRealIP(true, "tcp", srv)) defer ts.Close() cli := httpClient(t) chall := makeChallenge(t, ts, cli) resp := handleChallengeInvalidProof(t, ts, cli, chall) 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 TestCookieCustomExpiration(t *testing.T) { pol := loadPolicies(t, "testdata/zero_difficulty.yaml", 0) ckieExpiration := 10 * time.Minute srv := spawnAnubis(t, Options{ Next: http.NewServeMux(), Policy: pol, CookieExpiration: ckieExpiration, }) ts := httptest.NewServer(internal.RemoteXRealIP(true, "tcp", srv)) defer ts.Close() cli := httpClient(t) chall := makeChallenge(t, ts, cli) resp := handleChallengeZeroDifficulty(t, ts, cli, chall) if resp.StatusCode != http.StatusFound { resp.Write(os.Stderr) 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 } } func TestCookieSettings(t *testing.T) { pol := loadPolicies(t, "testdata/zero_difficulty.yaml", 0) srv := spawnAnubis(t, Options{ Next: http.NewServeMux(), Policy: pol, CookieDomain: "127.0.0.1", CookiePartitioned: true, CookieSecure: true, CookieSameSite: http.SameSiteNoneMode, CookieExpiration: anubis.CookieDefaultExpirationTime, }) ts := httptest.NewServer(internal.RemoteXRealIP(true, "tcp", srv)) defer ts.Close() cli := httpClient(t) chall := makeChallenge(t, ts, cli) resp := handleChallengeZeroDifficulty(t, ts, cli, chall) if resp.StatusCode != http.StatusFound { resp.Write(os.Stderr) 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 != "127.0.0.1" { t.Errorf("cookie domain is wrong, wanted 127.0.0.1, got: %s", ckie.Domain) } if ckie.Partitioned != srv.opts.CookiePartitioned { t.Errorf("wanted partitioned flag %v, got: %v", srv.opts.CookiePartitioned, ckie.Partitioned) } if ckie.Secure != srv.opts.CookieSecure { t.Errorf("wanted secure flag %v, got: %v", srv.opts.CookieSecure, ckie.Secure) } if ckie.SameSite != srv.opts.CookieSameSite { t.Errorf("wanted same site option %v, got: %v", srv.opts.CookieSameSite, ckie.SameSite) } } func TestCookieSettingsSameSiteNoneModeDowngradedToLaxWhenUnsecure(t *testing.T) { pol := loadPolicies(t, "testdata/zero_difficulty.yaml", 0) srv := spawnAnubis(t, Options{ Next: http.NewServeMux(), Policy: pol, CookieDomain: "127.0.0.1", CookiePartitioned: true, CookieSecure: false, CookieSameSite: http.SameSiteNoneMode, CookieExpiration: anubis.CookieDefaultExpirationTime, }) ts := httptest.NewServer(internal.RemoteXRealIP(true, "tcp", srv)) defer ts.Close() cli := httpClient(t) chall := makeChallenge(t, ts, cli) resp := handleChallengeZeroDifficulty(t, ts, cli, chall) if resp.StatusCode != http.StatusFound { resp.Write(os.Stderr) 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 != "127.0.0.1" { t.Errorf("cookie domain is wrong, wanted 127.0.0.1, got: %s", ckie.Domain) } if ckie.Partitioned != srv.opts.CookiePartitioned { t.Errorf("wanted partitioned flag %v, got: %v", srv.opts.CookiePartitioned, ckie.Partitioned) } if ckie.Secure != srv.opts.CookieSecure { t.Errorf("wanted secure flag %v, got: %v", srv.opts.CookieSecure, ckie.Secure) } if ckie.SameSite != http.SameSiteLaxMode { t.Errorf("wanted same site Lax option %v, got: %v", http.SameSiteLaxMode, ckie.SameSite) } } func TestCheckDefaultDifficultyMatchesPolicy(t *testing.T) { h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "OK") }) for i := 1; i < 10; i++ { t.Run(fmt.Sprint(i), func(t *testing.T) { anubisPolicy := loadPolicies(t, "testdata/test_config_no_thresholds.yaml", i) 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") cr, bot, err := s.check(req, s.logger) if err != nil { t.Fatal(err) } t.Log(cr.Name) if bot.Challenge.Difficulty != i { t.Errorf("Challenge.Difficulty is wrong, wanted %d, got: %d", i, bot.Challenge.Difficulty) } }) } } func TestBasePrefix(t *testing.T) { h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "OK") }) testCases := []struct { name string basePrefix string path string expected string }{ { name: "no prefix", basePrefix: "", path: "/.within.website/x/cmd/anubis/api/make-challenge", expected: "/.within.website/x/cmd/anubis/api/make-challenge", }, { name: "with prefix", basePrefix: "/myapp", path: "/myapp/.within.website/x/cmd/anubis/api/make-challenge", expected: "/myapp/.within.website/x/cmd/anubis/api/make-challenge", }, { name: "with prefix and trailing slash", basePrefix: "/myapp/", path: "/myapp/.within.website/x/cmd/anubis/api/make-challenge", expected: "/myapp/.within.website/x/cmd/anubis/api/make-challenge", }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { // Reset the global BasePrefix before each test anubis.BasePrefix = "" pol := loadPolicies(t, "", 4) srv := spawnAnubis(t, Options{ Next: h, Policy: pol, BasePrefix: tc.basePrefix, }) ts := httptest.NewServer(internal.RemoteXRealIP(true, "tcp", srv)) defer ts.Close() cli := httpClient(t) req, err := http.NewRequest(http.MethodPost, ts.URL+tc.path, nil) if err != nil { t.Fatal(err) } q := req.URL.Query() redir := tc.basePrefix if tc.basePrefix == "" { redir = "/" } q.Set("redir", redir) req.URL.RawQuery = q.Encode() t.Log(req.URL.String()) // Test API endpoint with prefix resp, err := cli.Do(req) if err != nil { t.Fatalf("can't request challenge: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Errorf("expected status code %d, got: %d", http.StatusOK, resp.StatusCode) } data, err := io.ReadAll(resp.Body) if err != nil { t.Fatalf("can't read body: %v", err) } t.Log(string(data)) var chall challengeResp if err := json.NewDecoder(bytes.NewBuffer(data)).Decode(&chall); err != nil { t.Fatalf("can't read challenge response body: %v", err) } if chall.Challenge == "" { t.Errorf("expected non-empty challenge") } // Test cookie path when passing challenge // Find a nonce that produces a hash with the required number of leading zeros nonce := 0 var calculated string for { calcString := fmt.Sprintf("%s%d", chall.Challenge, nonce) calculated = internal.SHA256sum(calcString) if strings.HasPrefix(calculated, strings.Repeat("0", pol.DefaultDifficulty)) { break } nonce++ } elapsedTime := 420 redir = "/" cli.CheckRedirect = func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse } // Construct the correct path for pass-challenge passChallengePath := tc.path passChallengePath = passChallengePath[:strings.LastIndex(passChallengePath, "/")+1] + "pass-challenge" req, err = http.NewRequest(http.MethodGet, ts.URL+passChallengePath, nil) if err != nil { t.Fatalf("can't make request: %v", err) } for _, ckie := range resp.Cookies() { req.AddCookie(ckie) } q = req.URL.Query() q.Set("response", calculated) q.Set("nonce", fmt.Sprint(nonce)) q.Set("redir", redir) q.Set("elapsedTime", fmt.Sprint(elapsedTime)) q.Set("id", chall.ID) req.URL.RawQuery = q.Encode() t.Log(req.URL.String()) resp, err = cli.Do(req) if err != nil { t.Fatalf("can't do challenge passing: %v", err) } if resp.StatusCode != http.StatusFound { t.Errorf("wanted %d, got: %d", http.StatusFound, resp.StatusCode) } // Check cookie path var ckie *http.Cookie for _, cookie := range resp.Cookies() { if cookie.Name == anubis.CookieName { ckie = cookie break } } if ckie == nil { t.Errorf("Cookie %q not found", anubis.CookieName) return } expectedPath := "/" if tc.basePrefix != "" { expectedPath = strings.TrimSuffix(tc.basePrefix, "/") + "/" } if ckie.Path != expectedPath { t.Errorf("cookie path is wrong, wanted %s, got: %s", expectedPath, ckie.Path) } }) } } func TestCustomStatusCodes(t *testing.T) { h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Log(r.UserAgent()) w.WriteHeader(http.StatusOK) fmt.Fprintln(w, "OK") }) statusMap := map[string]int{ "ALLOW": 200, "CHALLENGE": 401, "DENY": 403, } pol := loadPolicies(t, "./testdata/aggressive_403.yaml", 4) srv := spawnAnubis(t, Options{ Next: h, Policy: pol, }) ts := httptest.NewServer(internal.RemoteXRealIP(true, "tcp", srv)) defer ts.Close() for userAgent, statusCode := range statusMap { t.Run(userAgent, func(t *testing.T) { req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, ts.URL, nil) if err != nil { t.Fatal(err) } req.Header.Set("User-Agent", userAgent) resp, err := ts.Client().Do(req) if err != nil { t.Fatal(err) } if resp.StatusCode != statusCode { t.Errorf("wanted status code %d but got: %d", statusCode, resp.StatusCode) } }) } } func TestCloudflareWorkersRule(t *testing.T) { for _, variant := range []string{"cel", "header"} { t.Run(variant, func(t *testing.T) { pol := loadPolicies(t, "./testdata/cloudflare-workers-"+variant+".yaml", 0) h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "OK") }) s, err := New(Options{ Next: h, Policy: pol, ServeRobotsTXT: true, }) if err != nil { t.Fatalf("can't construct libanubis.Server: %v", err) } t.Run("with-cf-worker-header", func(t *testing.T) { req, err := http.NewRequest(http.MethodGet, "/", nil) if err != nil { t.Fatal(err) } req.Header.Add("X-Real-Ip", "127.0.0.1") req.Header.Add("Cf-Worker", "true") cr, _, err := s.check(req, s.logger) if err != nil { t.Fatal(err) } if cr.Rule != config.RuleDeny { t.Errorf("rule is wrong, wanted %s, got: %s", config.RuleDeny, cr.Rule) } }) t.Run("no-cf-worker-header", func(t *testing.T) { req, err := http.NewRequest(http.MethodGet, "/", nil) if err != nil { t.Fatal(err) } req.Header.Add("X-Real-Ip", "127.0.0.1") cr, _, err := s.check(req, s.logger) if err != nil { t.Fatal(err) } if cr.Rule != config.RuleAllow { t.Errorf("rule is wrong, wanted %s, got: %s", config.RuleAllow, cr.Rule) } }) }) } } func TestRuleChange(t *testing.T) { pol := loadPolicies(t, "testdata/rule_change.yaml", 0) ckieExpiration := 10 * time.Minute srv := spawnAnubis(t, Options{ Next: http.NewServeMux(), Policy: pol, CookieDomain: "127.0.0.1", CookieExpiration: ckieExpiration, }) ts := httptest.NewServer(internal.RemoteXRealIP(true, "tcp", srv)) defer ts.Close() cli := httpClient(t) chall := makeChallenge(t, ts, cli) resp := handleChallengeZeroDifficulty(t, ts, cli, chall) if resp.StatusCode != http.StatusFound { resp.Write(os.Stderr) t.Errorf("wanted %d, got: %d", http.StatusFound, resp.StatusCode) } } func TestStripBasePrefixFromRequest(t *testing.T) { testCases := []struct { name string basePrefix string requestPath string expectedPath string stripBasePrefix bool }{ { name: "strip disabled - no change", basePrefix: "/foo", stripBasePrefix: false, requestPath: "/foo/bar", expectedPath: "/foo/bar", }, { name: "strip enabled - removes prefix", basePrefix: "/foo", stripBasePrefix: true, requestPath: "/foo/bar", expectedPath: "/bar", }, { name: "strip enabled - root becomes slash", basePrefix: "/foo", stripBasePrefix: true, requestPath: "/foo", expectedPath: "/", }, { name: "strip enabled - trailing slash on base prefix", basePrefix: "/foo/", stripBasePrefix: true, requestPath: "/foo/bar", expectedPath: "/bar", }, { name: "strip enabled - no prefix match", basePrefix: "/foo", stripBasePrefix: true, requestPath: "/other/bar", expectedPath: "/other/bar", }, { name: "strip enabled - empty base prefix", basePrefix: "", stripBasePrefix: true, requestPath: "/foo/bar", expectedPath: "/foo/bar", }, { name: "strip enabled - nested path", basePrefix: "/app", stripBasePrefix: true, requestPath: "/app/api/v1/users", expectedPath: "/api/v1/users", }, { name: "strip enabled - exact match becomes root", basePrefix: "/myapp", stripBasePrefix: true, requestPath: "/myapp/", expectedPath: "/", }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { srv := &Server{ opts: Options{ BasePrefix: tc.basePrefix, StripBasePrefix: tc.stripBasePrefix, }, } req := httptest.NewRequest(http.MethodGet, tc.requestPath, nil) originalPath := req.URL.Path result := srv.stripBasePrefixFromRequest(req) if result.URL.Path != tc.expectedPath { t.Errorf("expected path %q, got %q", tc.expectedPath, result.URL.Path) } // Ensure original request is not modified when no stripping should occur if !tc.stripBasePrefix || tc.basePrefix == "" || !strings.HasPrefix(tc.requestPath, strings.TrimSuffix(tc.basePrefix, "/")) { if result != req { t.Error("expected same request object when no modification needed") } } else { // Ensure original request is not modified when stripping occurs if req.URL.Path != originalPath { t.Error("original request was modified") } } }) } } // TestChallengeFor_ErrNotFound makes sure that users with invalid challenge IDs // in the test cookie don't get rejected by the database lookup failing. func TestChallengeFor_ErrNotFound(t *testing.T) { pol := loadPolicies(t, "testdata/aggressive_403.yaml", 0) ckieExpiration := 10 * time.Minute const wrongCookie = "wrong cookie" srv := spawnAnubis(t, Options{ Next: http.NewServeMux(), Policy: pol, CookieDomain: "127.0.0.1", CookieExpiration: ckieExpiration, }) req := httptest.NewRequest("GET", "http://example.com/", nil) req.Header.Set("X-Real-IP", "127.0.0.1") req.Header.Set("User-Agent", "CHALLENGE") req.AddCookie(&http.Cookie{Name: anubis.TestCookieName, Value: wrongCookie}) w := httptest.NewRecorder() srv.maybeReverseProxyOrPage(w, req) resp := w.Result() defer resp.Body.Close() body := new(strings.Builder) _, err := io.Copy(body, resp.Body) if err != nil { t.Fatalf("reading body should not fail: %v", err) } t.Run("make sure challenge page is issued", func(t *testing.T) { if !strings.Contains(body.String(), "anubis_challenge") { t.Error("should get a challenge page") } if resp.StatusCode != http.StatusUnauthorized { t.Errorf("should get a 401 Unauthorized, got: %d", resp.StatusCode) } }) t.Run("make sure that the body is not an error page", func(t *testing.T) { if strings.Contains(body.String(), "reject.webp") { t.Error("should not get an internal server error") } }) t.Run("make sure new test cookie is issued", func(t *testing.T) { found := false for _, cookie := range resp.Cookies() { if cookie.Name == anubis.TestCookieName { if cookie.Value == wrongCookie { t.Error("a new challenge cookie should be issued") } found = true } } if !found { t.Error("a new test cookie should be set") } }) } func TestPassChallengeXSS(t *testing.T) { pol := loadPolicies(t, "", anubis.DefaultDifficulty) srv := spawnAnubis(t, Options{ Next: http.NewServeMux(), Policy: pol, }) ts := httptest.NewServer(internal.RemoteXRealIP(true, "tcp", srv)) defer ts.Close() cli := httpClient(t) chall := makeChallenge(t, ts, cli) testCases := []struct { name string redir string }{ { name: "javascript alert", redir: "javascript:alert('xss')", }, { name: "vbscript", redir: "vbscript:msgbox(\"XSS\")", }, { name: "data url", redir: "data:text/html;base64,PHNjcmlwdD5hbGVydCgneHNzJyk8L3NjcmlwdD4=", }, } t.Run("with test cookie", func(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { nonce := 0 elapsedTime := 420 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", tc.redir) q.Set("elapsedTime", fmt.Sprint(elapsedTime)) req.URL.RawQuery = q.Encode() u, err := url.Parse(ts.URL) if err != nil { t.Fatal(err) } for _, ckie := range cli.Jar.Cookies(u) { if ckie.Name == anubis.TestCookieName { req.AddCookie(ckie) } } resp, err := cli.Do(req) if err != nil { t.Fatalf("can't do request: %v", err) } body, _ := io.ReadAll(resp.Body) if bytes.Contains(body, []byte(tc.redir)) { t.Log(string(body)) t.Error("found XSS in HTML body") } if resp.StatusCode != http.StatusBadRequest { t.Errorf("wanted status %d, got %d. body: %s", http.StatusBadRequest, resp.StatusCode, body) } }) } }) t.Run("no test cookie", func(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { nonce := 0 elapsedTime := 420 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", tc.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 request: %v", err) } body, _ := io.ReadAll(resp.Body) if bytes.Contains(body, []byte(tc.redir)) { t.Log(string(body)) t.Error("found XSS in HTML body") } if resp.StatusCode != http.StatusBadRequest { t.Errorf("wanted status %d, got %d. body: %s", http.StatusBadRequest, resp.StatusCode, body) } }) } }) } func TestPassChallengeNilRuleChallengeFallback(t *testing.T) { pol := loadPolicies(t, "testdata/zero_difficulty.yaml", 0) srv := spawnAnubis(t, Options{ Next: http.NewServeMux(), Policy: pol, }) allowThreshold, err := policy.ParsedThresholdFromConfig(config.Threshold{ Name: "allow-all", Expression: &config.ExpressionOrList{ Expression: "true", }, Action: config.RuleAllow, }) if err != nil { t.Fatalf("can't compile test threshold: %v", err) } srv.policy.Thresholds = []*policy.Threshold{allowThreshold} srv.policy.Bots = nil chall := challenge.Challenge{ ID: "test-challenge", Method: "metarefresh", RandomData: "apple cider", IssuedAt: time.Now().Add(-5 * time.Second), Difficulty: 1, } j := store.JSON[challenge.Challenge]{Underlying: srv.store} if err := j.Set(context.Background(), "challenge:"+chall.ID, chall, time.Minute); err != nil { t.Fatalf("can't insert challenge into store: %v", err) } req := httptest.NewRequest(http.MethodGet, "https://example.com"+anubis.APIPrefix+"pass-challenge", nil) q := req.URL.Query() q.Set("redir", "/") q.Set("id", chall.ID) q.Set("challenge", chall.RandomData) req.URL.RawQuery = q.Encode() req.Header.Set("X-Real-Ip", "203.0.113.4") req.Header.Set("User-Agent", "NilChallengeTester/1.0") req.AddCookie(&http.Cookie{Name: anubis.TestCookieName, Value: chall.ID}) rr := httptest.NewRecorder() srv.PassChallenge(rr, req) if rr.Code != http.StatusFound { t.Fatalf("expected redirect when validating challenge, got %d", rr.Code) } } func TestXForwardedForNoDoubleComma(t *testing.T) { var h http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Forwarded-For", r.Header.Get("X-Forwarded-For")) fmt.Fprintln(w, "OK") }) h = internal.XForwardedForToXRealIP(h) h = internal.XForwardedForUpdate(false, h) pol := loadPolicies(t, "testdata/permissive.yaml", 4) srv := spawnAnubis(t, Options{ Next: h, Policy: pol, }) ts := httptest.NewServer(srv) t.Cleanup(ts.Close) req, err := http.NewRequest(http.MethodGet, ts.URL, nil) if err != nil { t.Fatal(err) } req.Header.Set("X-Real-Ip", "10.0.0.1") resp, err := ts.Client().Do(req) if err != nil { t.Fatal(err) } if resp.StatusCode != http.StatusOK { t.Errorf("response status is wrong, wanted %d but got: %s", http.StatusOK, resp.Status) } if xff := resp.Header.Get("X-Forwarded-For"); strings.HasPrefix(xff, ",,") { t.Errorf("X-Forwarded-For has two leading commas: %q", xff) } } ================================================ FILE: lib/challenge/challenge.go ================================================ package challenge import "time" // Challenge is the metadata about a single challenge issuance. type Challenge struct { IssuedAt time.Time `json:"issuedAt"` // When the challenge was issued Metadata map[string]string `json:"metadata"` // Challenge metadata such as IP address and user agent ID string `json:"id"` // UUID identifying the challenge Method string `json:"method"` // Challenge method RandomData string `json:"randomData"` // The random data the client processes PolicyRuleHash string `json:"policyRuleHash,omitempty"` // Hash of the policy rule that issued this challenge Difficulty int `json:"difficulty,omitempty"` // Difficulty that was in effect when issued Spent bool `json:"spent"` // Has the challenge already been solved? } ================================================ FILE: lib/challenge/challengetest/challengetest.go ================================================ package challengetest import ( "testing" "time" "github.com/TecharoHQ/anubis" "github.com/TecharoHQ/anubis/internal" "github.com/TecharoHQ/anubis/lib/challenge" "github.com/google/uuid" ) func New(t *testing.T) *challenge.Challenge { t.Helper() id := uuid.Must(uuid.NewV7()) randomData := internal.SHA256sum(time.Now().String()) return &challenge.Challenge{ ID: id.String(), RandomData: randomData, IssuedAt: time.Now(), Difficulty: anubis.DefaultDifficulty, } } ================================================ FILE: lib/challenge/challengetest/challengetest_test.go ================================================ package challengetest import "testing" func TestNew(t *testing.T) { _ = New(t) } ================================================ FILE: lib/challenge/error.go ================================================ package challenge import ( "errors" "fmt" "net/http" ) var ( ErrFailed = errors.New("challenge: user failed challenge") ErrMissingField = errors.New("challenge: missing field") ErrInvalidFormat = errors.New("challenge: field has invalid format") ErrInvalidInput = errors.New("challenge: input is nil or missing required fields") ) func NewError(verb, publicReason string, privateReason error) *Error { return &Error{ Verb: verb, PublicReason: publicReason, PrivateReason: privateReason, StatusCode: http.StatusForbidden, } } type Error struct { PrivateReason error Verb string PublicReason string StatusCode int } func (e *Error) Error() string { return fmt.Sprintf("challenge: error when processing challenge: %s: %v", e.Verb, e.PrivateReason) } func (e *Error) Unwrap() error { return e.PrivateReason } ================================================ FILE: lib/challenge/interface.go ================================================ package challenge import ( "fmt" "log/slog" "net/http" "sort" "sync" "github.com/TecharoHQ/anubis/lib/config" "github.com/TecharoHQ/anubis/lib/policy" "github.com/TecharoHQ/anubis/lib/store" "github.com/a-h/templ" ) var ( registry map[string]Impl = map[string]Impl{} regLock sync.RWMutex ) func Register(name string, impl Impl) { regLock.Lock() defer regLock.Unlock() registry[name] = impl } func Get(name string) (Impl, bool) { regLock.RLock() defer regLock.RUnlock() result, ok := registry[name] return result, ok } func Methods() []string { regLock.RLock() defer regLock.RUnlock() var result []string for method := range registry { result = append(result, method) } sort.Strings(result) return result } type IssueInput struct { Impressum *config.Impressum Rule *policy.Bot Challenge *Challenge OGTags map[string]string Store store.Interface } func (in *IssueInput) Valid() error { if in == nil { return fmt.Errorf("%w: IssueInput is nil", ErrInvalidInput) } if in.Rule == nil { return fmt.Errorf("%w: Rule is nil", ErrInvalidInput) } if in.Rule.Challenge == nil { return fmt.Errorf("%w: Rule.Challenge is nil", ErrInvalidInput) } if in.Challenge == nil { return fmt.Errorf("%w: Challenge is nil", ErrInvalidInput) } return nil } type ValidateInput struct { Rule *policy.Bot Challenge *Challenge Store store.Interface } func (in *ValidateInput) Valid() error { if in == nil { return fmt.Errorf("%w: ValidateInput is nil", ErrInvalidInput) } if in.Rule == nil { return fmt.Errorf("%w: Rule is nil", ErrInvalidInput) } if in.Rule.Challenge == nil { return fmt.Errorf("%w: Rule.Challenge is nil", ErrInvalidInput) } if in.Challenge == nil { return fmt.Errorf("%w: Challenge is nil", ErrInvalidInput) } return nil } type Impl interface { // Setup registers any additional routes with the Impl for assets or API routes. Setup(mux *http.ServeMux) // Issue a new challenge to the user, called by the Anubis. Issue(w http.ResponseWriter, r *http.Request, lg *slog.Logger, in *IssueInput) (templ.Component, error) // Validate a challenge, making sure that it passes muster. Validate(r *http.Request, lg *slog.Logger, in *ValidateInput) error } ================================================ FILE: lib/challenge/metarefresh/metarefresh.go ================================================ package metarefresh import ( "crypto/subtle" "fmt" "log/slog" "net/http" "time" "github.com/TecharoHQ/anubis" "github.com/TecharoHQ/anubis/lib/challenge" "github.com/TecharoHQ/anubis/lib/localization" "github.com/a-h/templ" ) //go:generate go tool github.com/a-h/templ/cmd/templ generate func init() { challenge.Register("metarefresh", &Impl{}) } type Impl struct{} func (i *Impl) Setup(mux *http.ServeMux) {} func (i *Impl) Issue(w http.ResponseWriter, r *http.Request, lg *slog.Logger, in *challenge.IssueInput) (templ.Component, error) { if err := in.Valid(); err != nil { return nil, err } u, err := r.URL.Parse(anubis.BasePrefix + "/.within.website/x/cmd/anubis/api/pass-challenge") if err != nil { return nil, fmt.Errorf("can't render page: %w", err) } q := u.Query() q.Set("redir", r.URL.String()) q.Set("challenge", in.Challenge.RandomData) q.Set("id", in.Challenge.ID) u.RawQuery = q.Encode() showMeta := in.Challenge.RandomData[0]%2 == 0 if !showMeta { w.Header().Add("Refresh", fmt.Sprintf("%d; url=%s", in.Rule.Challenge.Difficulty+1, u.String())) } loc := localization.GetLocalizer(r) result := page(u.String(), in.Rule.Challenge.Difficulty, showMeta, loc) return result, nil } func (i *Impl) Validate(r *http.Request, lg *slog.Logger, in *challenge.ValidateInput) error { if err := in.Valid(); err != nil { return challenge.NewError("validate", "invalid input", err) } wantTime := in.Challenge.IssuedAt.Add(time.Duration(in.Rule.Challenge.Difficulty) * 800 * time.Millisecond) if time.Now().Before(wantTime) { return challenge.NewError("validate", "insufficient time", fmt.Errorf("%w: wanted user to wait until at least %s", challenge.ErrFailed, wantTime.Format(time.RFC3339))) } gotChallenge := r.FormValue("challenge") if subtle.ConstantTimeCompare([]byte(in.Challenge.RandomData), []byte(gotChallenge)) != 1 { return challenge.NewError("validate", "invalid response", fmt.Errorf("%w: wanted response %s but got %s", challenge.ErrFailed, in.Challenge.RandomData, gotChallenge)) } return nil } ================================================ FILE: lib/challenge/metarefresh/metarefresh.templ ================================================ package metarefresh import ( "fmt" "github.com/TecharoHQ/anubis" "github.com/TecharoHQ/anubis/lib/localization" ) templ page(redir string, difficulty int, showMeta bool, loc *localization.SimpleLocalizer) {
{ loc.T("loading") }

{ loc.T("connection_security") }

if showMeta { }
} ================================================ FILE: lib/challenge/metarefresh/metarefresh_templ.go ================================================ // Code generated by templ - DO NOT EDIT. // templ: version: v0.3.960 package metarefresh //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 ( "fmt" "github.com/TecharoHQ/anubis" "github.com/TecharoHQ/anubis/lib/localization" ) func page(redir string, difficulty int, showMeta bool, loc *localization.SimpleLocalizer) 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, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var4 string templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(loc.T("loading")) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `metarefresh.templ`, Line: 14, Col: 35} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var5 string templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(loc.T("connection_security")) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `metarefresh.templ`, Line: 15, Col: 35} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if showMeta { templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } return nil }) } var _ = templruntime.GeneratedTemplate ================================================ FILE: lib/challenge/metrics.go ================================================ package challenge import ( "math" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" ) var TimeTaken = promauto.NewHistogramVec(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, 20), 20), }, []string{"method"}) ================================================ FILE: lib/challenge/preact/build.sh ================================================ #!/usr/bin/env bash set -euo pipefail cd "$(dirname "$0")" LICENSE='/* @licstart The following is the entire license notice for the JavaScript code in this page. Copyright (c) 2025 Xe Iaso Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. Includes code from https://www.npmjs.com/package/preact which is used under the terms of the MIT license. Includes code from https://github.com/aws/aws-sdk-js-crypto-helpers which is used under the terms of the Apache 2 license. @licend The above is the entire license notice for the JavaScript code in this page. */' mkdir -p static/js for file in js/*.tsx; do filename="${file##*/}" # Extracts "app.jsx" from "./js/app.jsx" output="${filename%.tsx}.js" # Changes "app.jsx" to "app.js" echo $output esbuild "${file}" --minify --bundle --outfile=static/"${output}" --banner:js="${LICENSE}" done ================================================ FILE: lib/challenge/preact/js/app.tsx ================================================ import { render, h, Fragment } from "preact"; import { useState, useEffect } from "preact/hooks"; import { g, j, r, u, x } from "./xeact.js"; import { Sha256 } from "@aws-crypto/sha256-js"; /** @jsx h */ /** @jsxFrag Fragment */ function toHexString(arr: Uint8Array) { return Array.from(arr) .map((c) => c.toString(16).padStart(2, "0")) .join(""); } interface PreactInfo { redir: string; challenge: string; difficulty: number; connection_security_message: string; loading_message: string; pensive_url: string; } const App = () => { const [state, setState] = useState(); const [imageURL, setImageURL] = useState(null); const [passed, setPassed] = useState(false); const [challenge, setChallenge] = useState(null); useEffect(() => { setState(j("preact_info")); }); useEffect(() => { if (state === undefined) { return; } setImageURL(state?.pensive_url); const hash = new Sha256(""); hash.update(state.challenge); setChallenge(toHexString(hash.digestSync())); }, [state]); useEffect(() => { if (state === undefined) { return; } const timer = setTimeout(() => { setPassed(true); }, state?.difficulty * 125); return () => clearTimeout(timer); }, [challenge]); useEffect(() => { if (state === undefined) { return; } if (challenge === null) { return; } window.location.href = u(state.redir, { result: challenge, }); }, [passed]); return ( <> {imageURL !== null && ( )} {state !== undefined && ( <>

{state.loading_message}

{state.connection_security_message}

)} ); }; x(g("app")); render(, g("app")); ================================================ FILE: lib/challenge/preact/js/xeact.js ================================================ /** * Creates a DOM element, assigns the properties of `data` to it, and appends all `children`. * * @type{function(string|Function, Object=, Node|Array.=)} */ const h = (name, data = {}, children = []) => { const result = typeof name == "function" ? name(data) : Object.assign(document.createElement(name), data); if (!Array.isArray(children)) { children = [children]; } result.append(...children); return result; }; /** * Create a text node. * * Equivalent to `document.createTextNode(text)` * * @type{function(string): Text} */ const t = (text) => document.createTextNode(text); /** * Remove all child nodes from a DOM element. * * @type{function(Node)} */ const x = (elem) => { while (elem.lastChild) { elem.removeChild(elem.lastChild); } }; /** * Get all elements with the given ID. * * Equivalent to `document.getElementById(name)` * * @type{function(string): HTMLElement} */ const g = (name) => document.getElementById(name); /** * Get all elements with the given class name. * * Equivalent to `document.getElementsByClassName(name)` * * @type{function(string): HTMLCollectionOf.} */ const c = (name) => document.getElementsByClassName(name); /** @type{function(string): HTMLCollectionOf.} */ const n = (name) => document.getElementsByName(name); /** * Get all elements matching the given HTML selector. * * Matches selectors with `document.querySelectorAll(selector)` * * @type{function(string): Array.} */ const s = (selector) => Array.from(document.querySelectorAll(selector)); /** * Generate a relative URL from `url`, appending all key-value pairs from `params` as URL-encoded parameters. * * @type{function(string=, Object=): string} */ 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(); }; /** * Takes a callback to run when all DOM content is loaded. * * Equivalent to `window.addEventListener('DOMContentLoaded', callback)` * * @type{function(function())} */ const r = (callback) => window.addEventListener("DOMContentLoaded", callback); /** * Allows a stateful value to be tracked by consumers. * * This is the Xeact version of the React useState hook. * * @type{function(any): [function(): any, function(any): void]} */ const useState = (value = undefined) => { return [ () => value, (x) => { value = x; }, ]; }; /** * Debounce an action for up to ms milliseconds. * * @type{function(number): function(function(any): void)} */ const d = (ms) => { let debounceTimer = null; return (f) => { clearTimeout(debounceTimer); debounceTimer = setTimeout(f, ms); }; }; /** * Parse the contents of a given HTML page element as JSON and * return the results. * * This is useful when using templ to pass complicated data from * the server to the client via HTML[1]. * * [1]: https://templ.guide/syntax-and-usage/script-templates/#pass-server-side-data-to-the-client-in-a-html-attribute */ const j = (id) => JSON.parse(g(id).textContent); export { h, t, x, g, j, c, n, u, s, r, useState, d }; ================================================ FILE: lib/challenge/preact/preact.go ================================================ package preact import ( "context" "crypto/subtle" _ "embed" "fmt" "io" "log/slog" "net/http" "time" "github.com/TecharoHQ/anubis" "github.com/TecharoHQ/anubis/internal" "github.com/TecharoHQ/anubis/lib/challenge" "github.com/TecharoHQ/anubis/lib/localization" "github.com/a-h/templ" ) //go:generate ./build.sh //go:generate go tool github.com/a-h/templ/cmd/templ generate //go:embed static/app.js var appJS []byte func renderAppJS(ctx context.Context, out io.Writer) error { fmt.Fprint(out, `") return nil } func init() { challenge.Register("preact", &impl{}) } type impl struct{} func (i *impl) Setup(mux *http.ServeMux) {} func (i *impl) Issue(w http.ResponseWriter, r *http.Request, lg *slog.Logger, in *challenge.IssueInput) (templ.Component, error) { if err := in.Valid(); err != nil { return nil, err } u, err := r.URL.Parse(anubis.BasePrefix + "/.within.website/x/cmd/anubis/api/pass-challenge") if err != nil { return nil, fmt.Errorf("can't render page: %w", err) } q := u.Query() q.Set("redir", r.URL.String()) q.Set("id", in.Challenge.ID) u.RawQuery = q.Encode() loc := localization.GetLocalizer(r) result := page(u.String(), in.Challenge.RandomData, in.Rule.Challenge.Difficulty, loc) return result, nil } func (i *impl) Validate(r *http.Request, lg *slog.Logger, in *challenge.ValidateInput) error { if err := in.Valid(); err != nil { return challenge.NewError("validate", "invalid input", err) } wantTime := in.Challenge.IssuedAt.Add(time.Duration(in.Rule.Challenge.Difficulty) * 80 * time.Millisecond) if time.Now().Before(wantTime) { return challenge.NewError("validate", "insufficient time", fmt.Errorf("%w: wanted user to wait until at least %s", challenge.ErrFailed, wantTime.Format(time.RFC3339))) } got := r.FormValue("result") want := internal.SHA256sum(in.Challenge.RandomData) if subtle.ConstantTimeCompare([]byte(want), []byte(got)) != 1 { return challenge.NewError("validate", "invalid response", fmt.Errorf("%w: wanted response %s but got %s", challenge.ErrFailed, want, got)) } return nil } ================================================ FILE: lib/challenge/preact/preact.templ ================================================ package preact import ( "github.com/TecharoHQ/anubis" "github.com/TecharoHQ/anubis/lib/localization" ) templ page(redir, challenge string, difficulty int, loc *localization.SimpleLocalizer) {
{ loc.T("loading") }

{ loc.T("connection_security") }

@templ.JSONScript("preact_info", map[string]any{ "redir": redir, "challenge": challenge, "difficulty": difficulty, "connection_security_message": loc.T("connection_security"), "loading_message": loc.T("loading"), "pensive_url": anubis.BasePrefix + "/.within.website/x/cmd/anubis/static/img/pensive.webp?cacheBuster=" + anubis.Version, }) @templ.ComponentFunc(renderAppJS)
} ================================================ FILE: lib/challenge/preact/preact_templ.go ================================================ // Code generated by templ - DO NOT EDIT. // templ: version: v0.3.960 package preact //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/lib/localization" ) func page(redir, challenge string, difficulty int, loc *localization.SimpleLocalizer) 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, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var3 string templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(loc.T("loading")) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `preact.templ`, Line: 12, Col: 36} } _, 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, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var4 string templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(loc.T("connection_security")) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `preact.templ`, Line: 13, Col: 36} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } templ_7745c5c3_Err = templ.JSONScript("preact_info", map[string]any{ "redir": redir, "challenge": challenge, "difficulty": difficulty, "connection_security_message": loc.T("connection_security"), "loading_message": loc.T("loading"), "pensive_url": anubis.BasePrefix + "/.within.website/x/cmd/anubis/static/img/pensive.webp?cacheBuster=" + anubis.Version, }).Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } templ_7745c5c3_Err = templ.ComponentFunc(renderAppJS).Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } return nil }) } var _ = templruntime.GeneratedTemplate ================================================ FILE: lib/challenge/preact/static/.gitignore ================================================ app.js ================================================ FILE: lib/challenge/proofofwork/proofofwork.go ================================================ package proofofwork import ( "crypto/subtle" "fmt" "log/slog" "net/http" "strconv" "strings" "github.com/TecharoHQ/anubis/internal" chall "github.com/TecharoHQ/anubis/lib/challenge" "github.com/TecharoHQ/anubis/lib/localization" "github.com/a-h/templ" ) //go:generate go tool github.com/a-h/templ/cmd/templ generate func init() { chall.Register("fast", &Impl{Algorithm: "fast"}) chall.Register("slow", &Impl{Algorithm: "slow"}) } type Impl struct { Algorithm string } func (i *Impl) Setup(mux *http.ServeMux) {} func (i *Impl) Issue(w http.ResponseWriter, r *http.Request, lg *slog.Logger, in *chall.IssueInput) (templ.Component, error) { loc := localization.GetLocalizer(r) return page(loc), nil } func (i *Impl) Validate(r *http.Request, lg *slog.Logger, in *chall.ValidateInput) error { if err := in.Valid(); err != nil { return chall.NewError("validate", "invalid input", err) } rule := in.Rule challenge := in.Challenge.RandomData nonceStr := r.FormValue("nonce") if nonceStr == "" { return chall.NewError("validate", "invalid response", fmt.Errorf("%w nonce", chall.ErrMissingField)) } nonce, err := strconv.Atoi(nonceStr) if err != nil { return chall.NewError("validate", "invalid response", fmt.Errorf("%w: nonce: %w", chall.ErrInvalidFormat, err)) } elapsedTimeStr := r.FormValue("elapsedTime") if elapsedTimeStr == "" { return chall.NewError("validate", "invalid response", fmt.Errorf("%w elapsedTime", chall.ErrMissingField)) } elapsedTime, err := strconv.ParseFloat(elapsedTimeStr, 64) if err != nil { return chall.NewError("validate", "invalid response", fmt.Errorf("%w: elapsedTime: %w", chall.ErrInvalidFormat, err)) } response := r.FormValue("response") if response == "" { return chall.NewError("validate", "invalid response", fmt.Errorf("%w response", chall.ErrMissingField)) } calcString := fmt.Sprintf("%s%d", challenge, nonce) calculated := internal.SHA256sum(calcString) if subtle.ConstantTimeCompare([]byte(response), []byte(calculated)) != 1 { return chall.NewError("validate", "invalid response", fmt.Errorf("%w: wanted response %s but got %s", chall.ErrFailed, calculated, response)) } // compare the leading zeroes if !strings.HasPrefix(response, strings.Repeat("0", rule.Challenge.Difficulty)) { return chall.NewError("validate", "invalid response", fmt.Errorf("%w: wanted %d leading zeros but got %s", chall.ErrFailed, rule.Challenge.Difficulty, response)) } lg.Debug("challenge took", "elapsedTime", elapsedTime) chall.TimeTaken.WithLabelValues(i.Algorithm).Observe(elapsedTime) return nil } ================================================ FILE: lib/challenge/proofofwork/proofofwork.templ ================================================ package proofofwork import ( "github.com/TecharoHQ/anubis" "github.com/TecharoHQ/anubis/lib/localization" ) templ page(localizer *localization.SimpleLocalizer) {
{ localizer.T("loading") }

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if anubis.UseSimplifiedExplanation { templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var6 string templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T("simplified_explanation")) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 20, Col: 44} } _, 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, 7, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var7 string templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T("ai_companies_explanation")) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 24, Col: 46} } _, 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, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var8 string templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T("anubis_compromise")) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 27, Col: 39} } _, 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, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var9 string templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T("hack_purpose")) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 30, Col: 34} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var10 string templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T("jshelter_note")) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 33, Col: 35} } _, 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, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } return nil }) } var _ = templruntime.GeneratedTemplate ================================================ FILE: lib/challenge/proofofwork/proofofwork_test.go ================================================ package proofofwork import ( "errors" "log/slog" "net/http" "net/http/httptest" "testing" "github.com/TecharoHQ/anubis/lib/challenge" "github.com/TecharoHQ/anubis/lib/config" "github.com/TecharoHQ/anubis/lib/policy" ) func mkRequest(t *testing.T, values map[string]string) *http.Request { t.Helper() req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil) if err != nil { t.Fatal(err) } q := req.URL.Query() for k, v := range values { q.Set(k, v) } req.URL.RawQuery = q.Encode() return req } // TestValidateNilRuleChallenge reproduces the panic from // https://github.com/TecharoHQ/anubis/issues/1463 // // When a threshold rule matches during PassChallenge, check() can return // a policy.Bot with Challenge == nil. After hydrateChallengeRule fails to // run (or the error path hits before it), Validate dereferences // rule.Challenge.Difficulty and panics. func TestValidateNilRuleChallenge(t *testing.T) { i := &Impl{Algorithm: "fast"} lg := slog.With() // This is the exact response for SHA256("hunter" + "0") with 0 leading zeros required. const challengeStr = "hunter" const response = "2652bdba8fb4d2ab39ef28d8534d7694c557a4ae146c1e9237bd8d950280500e" req := mkRequest(t, map[string]string{ "nonce": "0", "elapsedTime": "69", "response": response, }) for _, tc := range []struct { name string input *challenge.ValidateInput }{ { name: "nil-rule-challenge", input: &challenge.ValidateInput{ Rule: &policy.Bot{}, Challenge: &challenge.Challenge{RandomData: challengeStr}, }, }, { name: "nil-rule", input: &challenge.ValidateInput{ Challenge: &challenge.Challenge{RandomData: challengeStr}, }, }, { name: "nil-challenge", input: &challenge.ValidateInput{Rule: &policy.Bot{Challenge: &config.ChallengeRules{Algorithm: "fast"}}}, }, { name: "nil-input", input: nil, }, } { t.Run(tc.name, func(t *testing.T) { err := i.Validate(req, lg, tc.input) if !errors.Is(err, challenge.ErrInvalidInput) { t.Fatalf("expected ErrInvalidInput, got: %v", err) } }) } } func TestBasic(t *testing.T) { i := &Impl{Algorithm: "fast"} bot := &policy.Bot{ Challenge: &config.ChallengeRules{ Algorithm: "fast", Difficulty: 0, }, } const challengeStr = "hunter" const response = "2652bdba8fb4d2ab39ef28d8534d7694c557a4ae146c1e9237bd8d950280500e" for _, cs := range []struct { name string req *http.Request err error challengeStr string }{ { name: "allgood", req: mkRequest(t, map[string]string{ "nonce": "0", "elapsedTime": "69", "response": response, }), err: nil, challengeStr: challengeStr, }, { name: "no-params", req: mkRequest(t, map[string]string{}), err: challenge.ErrMissingField, challengeStr: challengeStr, }, { name: "missing-nonce", req: mkRequest(t, map[string]string{ "elapsedTime": "69", "response": response, }), err: challenge.ErrMissingField, challengeStr: challengeStr, }, { name: "missing-elapsedTime", req: mkRequest(t, map[string]string{ "nonce": "0", "response": response, }), err: challenge.ErrMissingField, challengeStr: challengeStr, }, { name: "missing-response", req: mkRequest(t, map[string]string{ "nonce": "0", "elapsedTime": "69", }), err: challenge.ErrMissingField, challengeStr: challengeStr, }, { name: "wrong-nonce-format", req: mkRequest(t, map[string]string{ "nonce": "taco", "elapsedTime": "69", "response": response, }), err: challenge.ErrInvalidFormat, challengeStr: challengeStr, }, { name: "wrong-elapsedTime-format", req: mkRequest(t, map[string]string{ "nonce": "0", "elapsedTime": "taco", "response": response, }), err: challenge.ErrInvalidFormat, challengeStr: challengeStr, }, { name: "invalid-response", req: mkRequest(t, map[string]string{ "nonce": "0", "elapsedTime": "69", "response": response, }), err: challenge.ErrFailed, challengeStr: "Tacos are tasty", }, } { t.Run(cs.name, func(t *testing.T) { lg := slog.With() i.Setup(http.NewServeMux()) inp := &challenge.IssueInput{ Rule: bot, Challenge: &challenge.Challenge{ RandomData: cs.challengeStr, }, } if _, err := i.Issue(httptest.NewRecorder(), cs.req, lg, inp); err != nil { t.Errorf("can't issue challenge: %v", err) } if err := i.Validate(cs.req, lg, &challenge.ValidateInput{ Rule: bot, Challenge: &challenge.Challenge{ RandomData: cs.challengeStr, }, }); !errors.Is(err, cs.err) { t.Errorf("got wrong error from Validate, got %v but wanted %v", err, cs.err) } }) } } ================================================ FILE: lib/config/asn.go ================================================ package config import ( "errors" "fmt" ) var ( ErrPrivateASN = errors.New("bot.ASNs: you have specified a private use ASN") ) type ASNs struct { Match []uint32 `json:"match"` } func (a *ASNs) Valid() error { var errs []error for _, asn := range a.Match { if isPrivateASN(asn) { errs = append(errs, fmt.Errorf("%w: %d is private (see RFC 6996)", ErrPrivateASN, asn)) } } if len(errs) != 0 { return fmt.Errorf("bot.ASNs: invalid ASN settings: %w", errors.Join(errs...)) } return nil } // isPrivateASN checks if an ASN is in the private use area. // // Based on RFC 6996 and IANA allocations. func isPrivateASN(asn uint32) bool { switch { case asn >= 64512 && asn <= 65534: return true case asn >= 4200000000 && asn <= 4294967294: return true default: return false } } ================================================ FILE: lib/config/asn_test.go ================================================ package config import ( "errors" "fmt" "testing" ) func TestASNsValid(t *testing.T) { for _, tt := range []struct { err error input *ASNs name string }{ { name: "basic valid", input: &ASNs{ Match: []uint32{13335}, // Cloudflare }, }, { name: "private ASN", input: &ASNs{ Match: []uint32{64513, 4206942069}, // 16 and 32 bit private ASN }, err: ErrPrivateASN, }, } { t.Run(tt.name, func(t *testing.T) { if err := tt.input.Valid(); !errors.Is(err, tt.err) { t.Logf("want: %v", tt.err) t.Logf("got: %v", err) t.Error("got wrong validation error") } }) } } func TestIsPrivateASN(t *testing.T) { for _, tt := range []struct { input uint32 output bool }{ {13335, false}, // Cloudflare {64513, true}, // 16 bit private ASN {4206942069, true}, // 32 bit private ASN } { t.Run(fmt.Sprint(tt.input, "->", tt.output), func(t *testing.T) { result := isPrivateASN(tt.input) if result != tt.output { t.Errorf("wanted isPrivateASN(%d) == %v, got: %v", tt.input, tt.output, result) } }) } } ================================================ FILE: lib/config/check.go ================================================ //go:build ignore package config import ( "context" "encoding/json" "errors" "fmt" "github.com/TecharoHQ/anubis/lib/checker" ) var ( ErrUnknownCheckType = errors.New("config.Bot.Check: unknown check type") ) type AllChecks struct { All []Check `json:"all"` } type AnyChecks struct { All []Check `json:"any"` } type Check struct { Type string `json:"type"` Args json.RawMessage `json:"args"` } func (c *Check) Valid(ctx context.Context) error { var errs []error if len(c.Type) == 0 { errs = append(errs, ErrNoStoreBackend) } fac, ok := checker.Get(c.Type) switch ok { case true: if err := fac.Valid(ctx, c.Args); err != nil { errs = append(errs, err) } case false: errs = append(errs, fmt.Errorf("%w: %q", ErrUnknownCheckType, c.Type)) } if len(errs) != 0 { return errors.Join(errs...) } return nil } ================================================ FILE: lib/config/config.go ================================================ package config import ( "errors" "fmt" "io" "io/fs" "net" "net/http" "os" "regexp" "strings" "time" "github.com/TecharoHQ/anubis/data" "k8s.io/apimachinery/pkg/util/yaml" ) var ( ErrNoBotRulesDefined = errors.New("config: must define at least one (1) bot rule") ErrBotMustHaveName = errors.New("config.Bot: must set name") ErrBotMustHaveUserAgentOrPath = errors.New("config.Bot: must set either user_agent_regex, path_regex, headers_regex, or remote_addresses") ErrBotMustHaveUserAgentOrPathNotBoth = errors.New("config.Bot: must set either user_agent_regex, path_regex, and not both") ErrUnknownAction = errors.New("config.Bot: unknown action") ErrInvalidUserAgentRegex = errors.New("config.Bot: invalid user agent regex") ErrInvalidPathRegex = errors.New("config.Bot: invalid path regex") ErrInvalidHeadersRegex = errors.New("config.Bot: invalid headers regex") ErrInvalidCIDR = errors.New("config.Bot: invalid CIDR") ErrRegexEndsWithNewline = errors.New("config.Bot: regular expression ends with newline (try >- instead of > in yaml)") ErrInvalidImportStatement = errors.New("config.ImportStatement: invalid source file") ErrCantSetBotAndImportValuesAtOnce = errors.New("config.BotOrImport: can't set bot rules and import values at the same time") ErrMustSetBotOrImportRules = errors.New("config.BotOrImport: rule definition is invalid, you must set either bot rules or an import statement, not both") ErrStatusCodeNotValid = errors.New("config.StatusCode: status code not valid, must be between 100 and 599") ) type Rule string const ( RuleUnknown Rule = "" RuleAllow Rule = "ALLOW" RuleDeny Rule = "DENY" RuleChallenge Rule = "CHALLENGE" RuleWeigh Rule = "WEIGH" RuleBenchmark Rule = "DEBUG_BENCHMARK" ) func (r Rule) Valid() error { switch r { case RuleAllow, RuleDeny, RuleChallenge, RuleWeigh, RuleBenchmark: return nil default: return ErrUnknownAction } } const DefaultAlgorithm = "fast" type BotConfig struct { UserAgentRegex *string `json:"user_agent_regex,omitempty" yaml:"user_agent_regex,omitempty"` PathRegex *string `json:"path_regex,omitempty" yaml:"path_regex,omitempty"` HeadersRegex map[string]string `json:"headers_regex,omitempty" yaml:"headers_regex,omitempty"` Expression *ExpressionOrList `json:"expression,omitempty" yaml:"expression,omitempty"` Challenge *ChallengeRules `json:"challenge,omitempty" yaml:"challenge,omitempty"` Weight *Weight `json:"weight,omitempty" yaml:"weight,omitempty"` // Thoth features GeoIP *GeoIP `json:"geoip,omitempty"` ASNs *ASNs `json:"asns,omitempty"` Name string `json:"name" yaml:"name"` Action Rule `json:"action" yaml:"action"` RemoteAddr []string `json:"remote_addresses,omitempty" yaml:"remote_addresses,omitempty"` } func (b BotConfig) Zero() bool { for _, cond := range []bool{ b.Name != "", b.UserAgentRegex != nil, b.PathRegex != nil, len(b.HeadersRegex) != 0, b.Action != "", len(b.RemoteAddr) != 0, b.Challenge != nil, b.GeoIP != nil, b.ASNs != nil, } { if cond { return false } } return true } func (b *BotConfig) Valid() error { var errs []error if b.Name == "" { errs = append(errs, ErrBotMustHaveName) } allFieldsEmpty := b.UserAgentRegex == nil && b.PathRegex == nil && len(b.RemoteAddr) == 0 && len(b.HeadersRegex) == 0 && b.ASNs == nil && b.GeoIP == nil if allFieldsEmpty && b.Expression == nil { errs = append(errs, ErrBotMustHaveUserAgentOrPath) } if b.UserAgentRegex != nil && b.PathRegex != nil { errs = append(errs, ErrBotMustHaveUserAgentOrPathNotBoth) } if b.UserAgentRegex != nil { if strings.HasSuffix(*b.UserAgentRegex, "\n") { errs = append(errs, fmt.Errorf("%w: user agent regex: %q", ErrRegexEndsWithNewline, *b.UserAgentRegex)) } if _, err := regexp.Compile(*b.UserAgentRegex); err != nil { errs = append(errs, ErrInvalidUserAgentRegex, err) } } if b.PathRegex != nil { if strings.HasSuffix(*b.PathRegex, "\n") { errs = append(errs, fmt.Errorf("%w: path regex: %q", ErrRegexEndsWithNewline, *b.PathRegex)) } if _, err := regexp.Compile(*b.PathRegex); err != nil { errs = append(errs, ErrInvalidPathRegex, err) } } if len(b.HeadersRegex) > 0 { for name, expr := range b.HeadersRegex { if name == "" { continue } if strings.HasSuffix(expr, "\n") { errs = append(errs, fmt.Errorf("%w: header %s regex: %q", ErrRegexEndsWithNewline, name, expr)) } if _, err := regexp.Compile(expr); err != nil { errs = append(errs, ErrInvalidHeadersRegex, err) } } } if len(b.RemoteAddr) > 0 { for _, cidr := range b.RemoteAddr { if _, _, err := net.ParseCIDR(cidr); err != nil { errs = append(errs, ErrInvalidCIDR, err) } } } if b.Expression != nil { if err := b.Expression.Valid(); err != nil { errs = append(errs, err) } } switch b.Action { case RuleAllow, RuleBenchmark, RuleChallenge, RuleDeny, RuleWeigh: // okay default: errs = append(errs, fmt.Errorf("%w: %q", ErrUnknownAction, b.Action)) } if b.Action == RuleChallenge && b.Challenge != nil { if err := b.Challenge.Valid(); err != nil { errs = append(errs, err) } } if b.Action == RuleWeigh && b.Weight == nil { b.Weight = &Weight{Adjust: 5} } if len(errs) != 0 { return fmt.Errorf("config: bot entry for %q is not valid:\n%w", b.Name, errors.Join(errs...)) } return nil } type ChallengeRules struct { Algorithm string `json:"algorithm,omitempty" yaml:"algorithm,omitempty"` Difficulty int `json:"difficulty,omitempty" yaml:"difficulty,omitempty"` ReportAs int `json:"report_as,omitempty" yaml:"report_as,omitempty"` } var ( ErrChallengeDifficultyTooLow = errors.New("config.ChallengeRules: difficulty is too low (must be >= 0)") ErrChallengeDifficultyTooHigh = errors.New("config.ChallengeRules: difficulty is too high (must be <= 64)") ErrChallengeMustHaveAlgorithm = errors.New("config.ChallengeRules: must have algorithm name set") ) func (cr ChallengeRules) Valid() error { var errs []error if cr.Algorithm == "" { errs = append(errs, ErrChallengeMustHaveAlgorithm) } if cr.Difficulty < 0 { errs = append(errs, fmt.Errorf("%w, got: %d", ErrChallengeDifficultyTooLow, cr.Difficulty)) } if cr.Difficulty > 64 { errs = append(errs, fmt.Errorf("%w, got: %d", ErrChallengeDifficultyTooHigh, cr.Difficulty)) } if len(errs) != 0 { return fmt.Errorf("config: challenge rules entry is not valid:\n%w", errors.Join(errs...)) } return nil } type ImportStatement struct { Import string `json:"import"` Bots []BotConfig } func (is *ImportStatement) open() (fs.File, error) { if after, ok := strings.CutPrefix(is.Import, "(data)/"); ok { fname := after fin, err := data.BotPolicies.Open(fname) return fin, err } return os.Open(is.Import) } func (is *ImportStatement) load() error { fin, err := is.open() if err != nil { return fmt.Errorf("%w: %s: %w", ErrInvalidImportStatement, is.Import, err) } defer fin.Close() var imported []BotOrImport var result []BotConfig if err := yaml.NewYAMLToJSONDecoder(fin).Decode(&imported); err != nil { return fmt.Errorf("can't parse %s: %w", is.Import, err) } var errs []error for _, b := range imported { if err := b.Valid(); err != nil { errs = append(errs, err) } if b.ImportStatement != nil { result = append(result, b.ImportStatement.Bots...) } if b.BotConfig != nil { result = append(result, *b.BotConfig) } } if len(errs) != 0 { return fmt.Errorf("config %s is not valid:\n%w", is.Import, errors.Join(errs...)) } is.Bots = result return nil } func (is *ImportStatement) Valid() error { return is.load() } type BotOrImport struct { *BotConfig `json:",inline"` *ImportStatement `json:",inline"` } func (boi *BotOrImport) Valid() error { if boi.BotConfig != nil && boi.ImportStatement != nil { return ErrCantSetBotAndImportValuesAtOnce } if boi.BotConfig != nil { return boi.BotConfig.Valid() } if boi.ImportStatement != nil { return boi.ImportStatement.Valid() } return ErrMustSetBotOrImportRules } type StatusCodes struct { Challenge int `json:"CHALLENGE"` Deny int `json:"DENY"` } func (sc StatusCodes) Valid() error { var errs []error if sc.Challenge == 0 || (sc.Challenge < 100 && sc.Challenge >= 599) { errs = append(errs, fmt.Errorf("%w: challenge is %d", ErrStatusCodeNotValid, sc.Challenge)) } if sc.Deny == 0 || (sc.Deny < 100 && sc.Deny >= 599) { errs = append(errs, fmt.Errorf("%w: deny is %d", ErrStatusCodeNotValid, sc.Deny)) } if len(errs) != 0 { return fmt.Errorf("status codes not valid:\n%w", errors.Join(errs...)) } return nil } type fileConfig struct { OpenGraph openGraphFileConfig `json:"openGraph"` Impressum *Impressum `json:"impressum,omitempty"` Store *Store `json:"store"` Bots []BotOrImport `json:"bots"` Thresholds []Threshold `json:"thresholds"` StatusCodes StatusCodes `json:"status_codes"` DNSBL bool `json:"dnsbl"` DNSTTL DnsTTL `json:"dns_ttl"` Logging *Logging `json:"logging"` } func (c *fileConfig) Valid() error { var errs []error if len(c.Bots) == 0 { errs = append(errs, ErrNoBotRulesDefined) } for i, b := range c.Bots { if err := b.Valid(); err != nil { errs = append(errs, fmt.Errorf("bot %d: %w", i, err)) } } if c.OpenGraph.Enabled { if err := c.OpenGraph.Valid(); err != nil { errs = append(errs, err) } } if err := c.StatusCodes.Valid(); err != nil { errs = append(errs, err) } for i, t := range c.Thresholds { if err := t.Valid(); err != nil { errs = append(errs, fmt.Errorf("threshold %d: %w", i, err)) } } if err := c.Logging.Valid(); err != nil { errs = append(errs, err) } if c.Store != nil { if err := c.Store.Valid(); err != nil { errs = append(errs, err) } } if len(errs) != 0 { return fmt.Errorf("config is not valid:\n%w", errors.Join(errs...)) } return nil } func Load(fin io.Reader, fname string) (*Config, error) { c := &fileConfig{ StatusCodes: StatusCodes{ Challenge: http.StatusOK, Deny: http.StatusOK, }, DNSTTL: DnsTTL{ Forward: 300, Reverse: 300, }, Store: &Store{ Backend: "memory", }, Logging: (Logging{}).Default(), } if err := yaml.NewYAMLToJSONDecoder(fin).Decode(&c); err != nil { return nil, fmt.Errorf("can't parse policy config YAML %s: %w", fname, err) } if err := c.Valid(); err != nil { return nil, err } result := &Config{ DNSBL: c.DNSBL, DNSTTL: c.DNSTTL, OpenGraph: OpenGraph{ Enabled: c.OpenGraph.Enabled, ConsiderHost: c.OpenGraph.ConsiderHost, Override: c.OpenGraph.Override, }, StatusCodes: c.StatusCodes, Store: c.Store, Logging: c.Logging, } if c.OpenGraph.TimeToLive != "" { // XXX(Xe): already validated in Valid() ogTTL, _ := time.ParseDuration(c.OpenGraph.TimeToLive) result.OpenGraph.TimeToLive = ogTTL } var validationErrs []error for _, boi := range c.Bots { if boi.ImportStatement != nil { if err := boi.load(); err != nil { validationErrs = append(validationErrs, err) continue } result.Bots = append(result.Bots, boi.ImportStatement.Bots...) } if boi.BotConfig != nil { if err := boi.BotConfig.Valid(); err != nil { validationErrs = append(validationErrs, err) continue } result.Bots = append(result.Bots, *boi.BotConfig) } } if c.Impressum != nil { if err := c.Impressum.Valid(); err != nil { validationErrs = append(validationErrs, err) } result.Impressum = c.Impressum } if len(c.Thresholds) == 0 { c.Thresholds = DefaultThresholds } for _, t := range c.Thresholds { if err := t.Valid(); err != nil { validationErrs = append(validationErrs, err) continue } result.Thresholds = append(result.Thresholds, t) } if len(validationErrs) > 0 { return nil, fmt.Errorf("errors validating policy config %s: %w", fname, errors.Join(validationErrs...)) } return result, nil } type DnsTTL struct { Forward int `json:"forward"` Reverse int `json:"reverse"` } func (sc DnsTTL) Valid() error { var errs []error if sc.Forward < 0 { errs = append(errs, fmt.Errorf("%w: forward TTL is %d", ErrStatusCodeNotValid, sc.Forward)) } if sc.Reverse < 0 { errs = append(errs, fmt.Errorf("%w: reverse TTL is %d", ErrStatusCodeNotValid, sc.Reverse)) } if len(errs) != 0 { return fmt.Errorf("dns TTL values not valid:\n%w", errors.Join(errs...)) } return nil } type Config struct { Impressum *Impressum Store *Store OpenGraph OpenGraph Bots []BotConfig Thresholds []Threshold StatusCodes StatusCodes Logging *Logging DNSBL bool DNSTTL DnsTTL } func (c Config) Valid() error { var errs []error if len(c.Bots) == 0 { errs = append(errs, ErrNoBotRulesDefined) } for _, b := range c.Bots { if err := b.Valid(); err != nil { errs = append(errs, err) } } if len(errs) != 0 { return fmt.Errorf("config is not valid:\n%w", errors.Join(errs...)) } return nil } ================================================ FILE: lib/config/config_test.go ================================================ package config_test import ( "errors" "io/fs" "os" "path/filepath" "testing" "github.com/TecharoHQ/anubis/data" . "github.com/TecharoHQ/anubis/lib/config" ) func p[V any](v V) *V { return &v } func TestBotValid(t *testing.T) { var tests = []struct { bot BotConfig err error name string }{ { name: "simple user agent", bot: BotConfig{ Name: "mozilla-ua", Action: RuleChallenge, UserAgentRegex: p("Mozilla"), }, err: nil, }, { name: "simple path", bot: BotConfig{ Name: "well-known-path", Action: RuleAllow, PathRegex: p("^/.well-known/.*$"), }, err: nil, }, { name: "no rule name", bot: BotConfig{ Action: RuleChallenge, UserAgentRegex: p("Mozilla"), }, err: ErrBotMustHaveName, }, { name: "no rule matcher", bot: BotConfig{ Name: "broken-rule", Action: RuleAllow, }, err: ErrBotMustHaveUserAgentOrPath, }, { name: "both user-agent and path", bot: BotConfig{ Name: "path-and-user-agent", Action: RuleDeny, UserAgentRegex: p("Mozilla"), PathRegex: p("^/.secret-place/.*$"), }, err: ErrBotMustHaveUserAgentOrPathNotBoth, }, { name: "unknown action", bot: BotConfig{ Name: "Unknown action", Action: RuleUnknown, UserAgentRegex: p("Mozilla"), }, err: ErrUnknownAction, }, { name: "invalid user agent regex", bot: BotConfig{ Name: "mozilla-ua", Action: RuleChallenge, UserAgentRegex: p("a(b"), }, err: ErrInvalidUserAgentRegex, }, { name: "invalid path regex", bot: BotConfig{ Name: "mozilla-ua", Action: RuleChallenge, PathRegex: p("a(b"), }, err: ErrInvalidPathRegex, }, { name: "invalid headers regex", bot: BotConfig{ Name: "mozilla-ua", Action: RuleChallenge, HeadersRegex: map[string]string{ "Content-Type": "a(b", }, PathRegex: p("a(b"), }, err: ErrInvalidHeadersRegex, }, { name: "challenge difficulty too low", bot: BotConfig{ Name: "mozilla-ua", Action: RuleChallenge, PathRegex: p("Mozilla"), Challenge: &ChallengeRules{ Difficulty: -1, Algorithm: "fast", }, }, err: ErrChallengeDifficultyTooLow, }, { name: "challenge difficulty too high", bot: BotConfig{ Name: "mozilla-ua", Action: RuleChallenge, PathRegex: p("Mozilla"), Challenge: &ChallengeRules{ Difficulty: 420, Algorithm: "fast", }, }, err: ErrChallengeDifficultyTooHigh, }, { name: "invalid cidr range", bot: BotConfig{ Name: "mozilla-ua", Action: RuleAllow, RemoteAddr: []string{"0.0.0.0/33"}, }, err: ErrInvalidCIDR, }, { name: "only filter by IP range", bot: BotConfig{ Name: "mozilla-ua", Action: RuleAllow, RemoteAddr: []string{"0.0.0.0/0"}, }, err: nil, }, { name: "filter by user agent and IP range", bot: BotConfig{ Name: "mozilla-ua", Action: RuleAllow, UserAgentRegex: p("Mozilla"), RemoteAddr: []string{"0.0.0.0/0"}, }, err: nil, }, { name: "filter by path and IP range", bot: BotConfig{ Name: "mozilla-ua", Action: RuleAllow, PathRegex: p("^.*$"), RemoteAddr: []string{"0.0.0.0/0"}, }, err: nil, }, { name: "weight rule without weight", bot: BotConfig{ Name: "weight-adjust-if-mozilla", Action: RuleWeigh, UserAgentRegex: p("Mozilla"), }, }, { name: "weight rule with weight adjust", bot: BotConfig{ Name: "weight-adjust-if-mozilla", Action: RuleWeigh, UserAgentRegex: p("Mozilla"), Weight: &Weight{ Adjust: 5, }, }, }, } for _, cs := range tests { t.Run(cs.name, func(t *testing.T) { err := cs.bot.Valid() if err == nil && cs.err == nil { return } if err == nil && cs.err != nil { t.Errorf("didn't get an error, but wanted: %v", cs.err) } if !errors.Is(err, cs.err) { t.Logf("got wrong error from Valid()") t.Logf("wanted: %v", cs.err) t.Logf("got: %v", err) t.Errorf("got invalid error from check") } }) } } func TestConfigValidKnownGood(t *testing.T) { finfos, err := os.ReadDir("testdata/good") if err != nil { t.Fatal(err) } for _, st := range finfos { t.Run(st.Name(), func(t *testing.T) { fin, err := os.Open(filepath.Join("testdata", "good", st.Name())) if err != nil { t.Fatal(err) } defer fin.Close() c, err := Load(fin, st.Name()) if err != nil { t.Fatal(err) } if err := c.Valid(); err != nil { t.Error(err) } if len(c.Bots) == 0 { t.Error("wanted more than 0 bots, got zero") } }) } } func TestImportStatement(t *testing.T) { type testCase struct { err error name string importPath string } var tests []testCase for _, folderName := range []string{ "apps", "bots", "common", "crawlers", "meta", } { if err := fs.WalkDir(data.BotPolicies, folderName, func(path string, d fs.DirEntry, err error) error { if err != nil { return err } if d.IsDir() { return nil } if d.Name() == "README.md" { return nil } tests = append(tests, testCase{ name: "(data)/" + path, importPath: "(data)/" + path, err: nil, }) return nil }); err != nil { t.Fatal(err) } } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { is := &ImportStatement{ Import: tt.importPath, } if err := is.Valid(); err != nil { t.Errorf("validation error: %v", err) } if len(is.Bots) == 0 { t.Error("wanted bot definitions, but got none") } }) } } func TestConfigValidBad(t *testing.T) { finfos, err := os.ReadDir("testdata/bad") if err != nil { t.Fatal(err) } for _, st := range finfos { t.Run(st.Name(), func(t *testing.T) { fin, err := os.Open(filepath.Join("testdata", "bad", st.Name())) if err != nil { t.Fatal(err) } defer fin.Close() _, err = Load(fin, filepath.Join("testdata", "bad", st.Name())) if err == nil { t.Fatal("validation should have failed but didn't somehow") } else { t.Log(err) } }) } } func TestBotConfigZero(t *testing.T) { var b BotConfig if !b.Zero() { t.Error("zero value config.BotConfig is not zero value") } b.Name = "hi" if b.Zero() { t.Error("config.BotConfig with name is zero value") } b.UserAgentRegex = p(".*") if b.Zero() { t.Error("config.BotConfig with user agent regex is zero value") } b.PathRegex = p(".*") if b.Zero() { t.Error("config.BotConfig with path regex is zero value") } b.HeadersRegex = map[string]string{"hi": "there"} if b.Zero() { t.Error("config.BotConfig with headers regex is zero value") } b.Action = RuleAllow if b.Zero() { t.Error("config.BotConfig with action is zero value") } b.RemoteAddr = []string{"::/0"} if b.Zero() { t.Error("config.BotConfig with remote addresses is zero value") } b.Challenge = &ChallengeRules{ Difficulty: 4, Algorithm: DefaultAlgorithm, } if b.Zero() { t.Error("config.BotConfig with challenge rules is zero value") } } ================================================ FILE: lib/config/expressionorlist.go ================================================ package config import ( "encoding/json" "errors" "fmt" "slices" "strings" ) var ( ErrExpressionOrListMustBeStringOrObject = errors.New("config: this must be a string or an object") ErrExpressionEmpty = errors.New("config: this expression is empty") ErrExpressionCantHaveBoth = errors.New("config: expression block can't contain multiple expression types") ) type ExpressionOrList struct { Expression string `json:"-" yaml:"-"` All []string `json:"all,omitempty" yaml:"all,omitempty"` Any []string `json:"any,omitempty" yaml:"any,omitempty"` } func (eol ExpressionOrList) String() string { switch { case len(eol.Expression) != 0: return eol.Expression case len(eol.All) != 0: var sb strings.Builder for i, pred := range eol.All { if i != 0 { fmt.Fprintf(&sb, " && ") } fmt.Fprintf(&sb, "( %s )", pred) } return sb.String() case len(eol.Any) != 0: var sb strings.Builder for i, pred := range eol.Any { if i != 0 { fmt.Fprintf(&sb, " || ") } fmt.Fprintf(&sb, "( %s )", pred) } return sb.String() } panic("this should not happen") } func (eol ExpressionOrList) Equal(rhs *ExpressionOrList) bool { if eol.Expression != rhs.Expression { return false } if !slices.Equal(eol.All, rhs.All) { return false } if !slices.Equal(eol.Any, rhs.Any) { return false } return true } func (eol *ExpressionOrList) MarshalYAML() (any, error) { switch { case len(eol.All) == 1 && len(eol.Any) == 0: eol.Expression = eol.All[0] eol.All = nil case len(eol.Any) == 1 && len(eol.All) == 0: eol.Expression = eol.Any[0] eol.Any = nil } if eol.Expression != "" { return eol.Expression, nil } type RawExpressionOrList ExpressionOrList return RawExpressionOrList(*eol), nil } func (eol *ExpressionOrList) MarshalJSON() ([]byte, error) { switch { case len(eol.All) == 1 && len(eol.Any) == 0: eol.Expression = eol.All[0] eol.All = nil case len(eol.Any) == 1 && len(eol.All) == 0: eol.Expression = eol.Any[0] eol.Any = nil } if eol.Expression != "" { return json.Marshal(string(eol.Expression)) } type RawExpressionOrList ExpressionOrList val := RawExpressionOrList(*eol) return json.Marshal(val) } func (eol *ExpressionOrList) UnmarshalJSON(data []byte) error { switch string(data[0]) { case `"`: // string return json.Unmarshal(data, &eol.Expression) case "{": // object type RawExpressionOrList ExpressionOrList var val RawExpressionOrList if err := json.Unmarshal(data, &val); err != nil { return err } eol.All = val.All eol.Any = val.Any return nil } return ErrExpressionOrListMustBeStringOrObject } func (eol *ExpressionOrList) Valid() error { if eol.Expression == "" && len(eol.All) == 0 && len(eol.Any) == 0 { return ErrExpressionEmpty } if len(eol.All) != 0 && len(eol.Any) != 0 { return ErrExpressionCantHaveBoth } return nil } ================================================ FILE: lib/config/expressionorlist_test.go ================================================ package config import ( "bytes" "encoding/json" "errors" "testing" yaml "sigs.k8s.io/yaml/goyaml.v3" ) func TestExpressionOrListMarshalJSON(t *testing.T) { for _, tt := range []struct { err error input *ExpressionOrList name string output []byte }{ { name: "single expression", input: &ExpressionOrList{ Expression: "true", }, output: []byte(`"true"`), err: nil, }, { name: "all", input: &ExpressionOrList{ All: []string{"true", "true"}, }, output: []byte(`{"all":["true","true"]}`), err: nil, }, { name: "all one", input: &ExpressionOrList{ All: []string{"true"}, }, output: []byte(`"true"`), err: nil, }, { name: "any", input: &ExpressionOrList{ Any: []string{"true", "false"}, }, output: []byte(`{"any":["true","false"]}`), err: nil, }, { name: "any one", input: &ExpressionOrList{ Any: []string{"true"}, }, output: []byte(`"true"`), err: nil, }, } { t.Run(tt.name, func(t *testing.T) { result, err := json.Marshal(tt.input) if !errors.Is(err, tt.err) { t.Errorf("wanted marshal error: %v but got: %v", tt.err, err) } if !bytes.Equal(result, tt.output) { t.Logf("wanted: %s", string(tt.output)) t.Logf("got: %s", string(result)) t.Error("mismatched output") } }) } } func TestExpressionOrListMarshalYAML(t *testing.T) { for _, tt := range []struct { err error input *ExpressionOrList name string output []byte }{ { name: "single expression", input: &ExpressionOrList{ Expression: "true", }, output: []byte(`"true"`), err: nil, }, { name: "all", input: &ExpressionOrList{ All: []string{"true", "true"}, }, output: []byte(`all: - "true" - "true"`), err: nil, }, { name: "all one", input: &ExpressionOrList{ All: []string{"true"}, }, output: []byte(`"true"`), err: nil, }, { name: "any", input: &ExpressionOrList{ Any: []string{"true", "false"}, }, output: []byte(`any: - "true" - "false"`), err: nil, }, { name: "any one", input: &ExpressionOrList{ Any: []string{"true"}, }, output: []byte(`"true"`), err: nil, }, } { t.Run(tt.name, func(t *testing.T) { result, err := yaml.Marshal(tt.input) if !errors.Is(err, tt.err) { t.Errorf("wanted marshal error: %v but got: %v", tt.err, err) } result = bytes.TrimSpace(result) if !bytes.Equal(result, tt.output) { t.Logf("wanted: %q", string(tt.output)) t.Logf("got: %q", string(result)) t.Error("mismatched output") } }) } } func TestExpressionOrListUnmarshalJSON(t *testing.T) { for _, tt := range []struct { err error validErr error result *ExpressionOrList name string inp string }{ { name: "simple", inp: `"\"User-Agent\" in headers"`, result: &ExpressionOrList{ Expression: `"User-Agent" in headers`, }, }, { name: "object-and", inp: `{ "all": ["\"User-Agent\" in headers"] }`, result: &ExpressionOrList{ All: []string{ `"User-Agent" in headers`, }, }, }, { name: "object-or", inp: `{ "any": ["\"User-Agent\" in headers"] }`, result: &ExpressionOrList{ Any: []string{ `"User-Agent" in headers`, }, }, }, { name: "both-or-and", inp: `{ "all": ["\"User-Agent\" in headers"], "any": ["\"User-Agent\" in headers"] }`, validErr: ErrExpressionCantHaveBoth, }, { name: "expression-empty", inp: `{ "any": [] }`, validErr: ErrExpressionEmpty, }, } { t.Run(tt.name, func(t *testing.T) { var eol ExpressionOrList if err := json.Unmarshal([]byte(tt.inp), &eol); !errors.Is(err, tt.err) { t.Errorf("wanted unmarshal error: %v but got: %v", tt.err, err) } if tt.result != nil && !eol.Equal(tt.result) { t.Logf("wanted: %#v", tt.result) t.Logf("got: %#v", &eol) t.Fatal("parsed expression is not what was expected") } if err := eol.Valid(); !errors.Is(err, tt.validErr) { t.Errorf("wanted validation error: %v but got: %v", tt.err, err) } }) } } func TestExpressionOrListString(t *testing.T) { for _, tt := range []struct { name string out string in ExpressionOrList }{ { name: "single expression", in: ExpressionOrList{ Expression: "true", }, out: "true", }, { name: "all", in: ExpressionOrList{ All: []string{"true"}, }, out: "( true )", }, { name: "all with &&", in: ExpressionOrList{ All: []string{"true", "true"}, }, out: "( true ) && ( true )", }, { name: "any", in: ExpressionOrList{ All: []string{"true"}, }, out: "( true )", }, { name: "any with ||", in: ExpressionOrList{ Any: []string{"true", "true"}, }, out: "( true ) || ( true )", }, } { t.Run(tt.name, func(t *testing.T) { result := tt.in.String() if result != tt.out { t.Errorf("wanted %q, got: %q", tt.out, result) } }) } } ================================================ FILE: lib/config/geoip.go ================================================ package config import ( "errors" "fmt" "regexp" "strings" ) var ( countryCodeRegexp = regexp.MustCompile(`^[a-zA-Z]{2}$`) ErrNotCountryCode = errors.New("config.Bot: invalid country code") ) type GeoIP struct { Countries []string `json:"countries"` } func (g *GeoIP) Valid() error { var errs []error for i, cc := range g.Countries { if !countryCodeRegexp.MatchString(cc) { errs = append(errs, fmt.Errorf("%w: %s", ErrNotCountryCode, cc)) } g.Countries[i] = strings.ToLower(cc) } if len(errs) != 0 { return fmt.Errorf("bot.GeoIP: invalid GeoIP settings: %w", errors.Join(errs...)) } return nil } ================================================ FILE: lib/config/geoip_test.go ================================================ package config import ( "errors" "testing" ) func TestGeoIPValid(t *testing.T) { for _, tt := range []struct { err error input *GeoIP name string }{ { name: "basic valid", input: &GeoIP{ Countries: []string{"CA"}, }, }, { name: "invalid country", input: &GeoIP{ Countries: []string{"XOB"}, }, err: ErrNotCountryCode, }, } { t.Run(tt.name, func(t *testing.T) { if err := tt.input.Valid(); !errors.Is(err, tt.err) { t.Logf("want: %v", tt.err) t.Logf("got: %v", err) t.Error("got wrong validation error") } }) } } ================================================ FILE: lib/config/impressum.go ================================================ package config import ( "context" "errors" "fmt" "io" ) var ErrMissingValue = errors.New("config: missing value") type Impressum struct { Footer string `json:"footer" yaml:"footer"` Page ImpressumPage `json:"page" yaml:"page"` } func (i Impressum) Render(_ context.Context, w io.Writer) error { if _, err := fmt.Fprint(w, i.Footer); err != nil { return err } return nil } func (i Impressum) Valid() error { var errs []error if len(i.Footer) == 0 { errs = append(errs, fmt.Errorf("%w: impressum footer must be defined", ErrMissingValue)) } if err := i.Page.Valid(); err != nil { errs = append(errs, err) } if len(errs) != 0 { return errors.Join(errs...) } return nil } type ImpressumPage struct { Title string `json:"title" yaml:"title"` Body string `json:"body" yaml:"body"` } func (ip ImpressumPage) Render(_ context.Context, w io.Writer) error { if _, err := fmt.Fprint(w, ip.Body); err != nil { return err } return nil } func (ip ImpressumPage) Valid() error { var errs []error if len(ip.Title) == 0 { errs = append(errs, fmt.Errorf("%w: impressum page title must be defined", ErrMissingValue)) } if len(ip.Body) == 0 { errs = append(errs, fmt.Errorf("%w: impressum body title must be defined", ErrMissingValue)) } if len(errs) != 0 { return errors.Join(errs...) } return nil } ================================================ FILE: lib/config/impressum_test.go ================================================ package config import ( "bytes" "errors" "testing" ) func TestImpressumValid(t *testing.T) { for _, cs := range []struct { err error inp Impressum name string }{ { name: "basic happy path", inp: Impressum{ Footer: "

Website hosted by Techaro.

", Page: ImpressumPage{ Title: "Techaro Imprint", Body: "

This is an imprint page.

", }, }, err: nil, }, { name: "no footer", inp: Impressum{ Footer: "", Page: ImpressumPage{ Title: "Techaro Imprint", Body: "

This is an imprint page.

", }, }, err: ErrMissingValue, }, { name: "page not valid", inp: Impressum{ Footer: "test page please ignore", }, err: ErrMissingValue, }, } { t.Run(cs.name, func(t *testing.T) { if err := cs.inp.Valid(); !errors.Is(err, cs.err) { t.Logf("want: %v", cs.err) t.Logf("got: %v", err) t.Error("validation failed") } var buf bytes.Buffer if err := cs.inp.Render(t.Context(), &buf); err != nil { t.Errorf("can't render footer: %v", err) } if err := cs.inp.Page.Render(t.Context(), &buf); err != nil { t.Errorf("can't render page: %v", err) } }) } } ================================================ FILE: lib/config/logging.go ================================================ package config import ( "errors" "fmt" "log/slog" ) var ( ErrMissingLoggingFileConfig = errors.New("config.Logging: missing value parameters in logging block") ErrInvalidLoggingSink = errors.New("config.Logging: invalid sink") ErrInvalidLoggingFileConfig = errors.New("config.LoggingFileConfig: invalid parameters") ErrOutOfRange = errors.New("config: error out of range") ) type Logging struct { Sink string `json:"sink"` // Logging sink, either "stdio" or "file" Level *slog.Level `json:"level"` // Log level, if set supersedes the level in flags Parameters *LoggingFileConfig `json:"parameters"` // Logging parameters, to be dynamic in the future } const ( LogSinkStdio = "stdio" LogSinkFile = "file" ) func (l *Logging) Valid() error { var errs []error switch l.Sink { case LogSinkStdio: // no validation needed case LogSinkFile: if l.Parameters == nil { errs = append(errs, ErrMissingLoggingFileConfig) } if err := l.Parameters.Valid(); err != nil { errs = append(errs, err) } default: errs = append(errs, fmt.Errorf("%w: sink %s is unknown to me", ErrInvalidLoggingSink, l.Sink)) } if len(errs) != 0 { return errors.Join(errs...) } return nil } func (Logging) Default() *Logging { return &Logging{ Sink: "stdio", } } type LoggingFileConfig struct { Filename string `json:"file"` MaxBackups int `json:"maxBackups"` MaxBytes int64 `json:"maxBytes"` MaxAge int `json:"maxAge"` Compress bool `json:"compress"` UseLocalTime bool `json:"useLocalTime"` } func (lfc *LoggingFileConfig) Valid() error { if lfc == nil { return fmt.Errorf("logging file config is nil, why are you calling this?") } var errs []error if lfc.Zero() { errs = append(errs, ErrMissingValue) } if lfc.Filename == "" { errs = append(errs, fmt.Errorf("%w: filename", ErrMissingValue)) } if lfc.MaxBackups < 0 { errs = append(errs, fmt.Errorf("%w: max backup count %d is not greater than or equal to zero", ErrOutOfRange, lfc.MaxBackups)) } if lfc.MaxAge < 0 { errs = append(errs, fmt.Errorf("%w: max backup count %d is not greater than or equal to zero", ErrOutOfRange, lfc.MaxAge)) } if len(errs) != 0 { errs = append([]error{ErrInvalidLoggingFileConfig}, errs...) return errors.Join(errs...) } return nil } func (lfc LoggingFileConfig) Zero() bool { for _, cond := range []bool{ lfc.Filename != "", lfc.MaxBackups != 0, lfc.MaxBytes != 0, lfc.MaxAge != 0, lfc.Compress, lfc.UseLocalTime, } { if cond { return false } } return true } func (LoggingFileConfig) Default() *LoggingFileConfig { return &LoggingFileConfig{ Filename: "./var/anubis.log", MaxBackups: 3, MaxBytes: 104857600, // 100 Mi MaxAge: 7, // 7 days Compress: true, UseLocalTime: false, } } ================================================ FILE: lib/config/logging_test.go ================================================ package config import ( "errors" "testing" ) func TestLoggingValid(t *testing.T) { for _, tt := range []struct { name string input *Logging want error }{ { name: "simple happy", input: (Logging{}).Default(), }, { name: "default file config", input: &Logging{ Sink: LogSinkFile, Parameters: (&LoggingFileConfig{}).Default(), }, }, { name: "invalid sink", input: &Logging{ Sink: "taco invalid", }, want: ErrInvalidLoggingSink, }, { name: "missing parameters", input: &Logging{ Sink: LogSinkFile, }, want: ErrMissingLoggingFileConfig, }, { name: "invalid parameters", input: &Logging{ Sink: LogSinkFile, Parameters: &LoggingFileConfig{}, }, want: ErrInvalidLoggingFileConfig, }, { name: "file sink with no filename", input: &Logging{ Sink: LogSinkFile, Parameters: &LoggingFileConfig{ Filename: "", MaxBackups: 3, MaxBytes: 104857600, // 100 Mi MaxAge: 7, // 7 days Compress: true, UseLocalTime: false, }, }, want: ErrMissingValue, }, { name: "file sink with negative max backups", input: &Logging{ Sink: LogSinkFile, Parameters: &LoggingFileConfig{ Filename: "./var/anubis.log", MaxBackups: -3, MaxBytes: 104857600, // 100 Mi MaxAge: 7, // 7 days Compress: true, UseLocalTime: false, }, }, want: ErrOutOfRange, }, { name: "file sink with negative max age", input: &Logging{ Sink: LogSinkFile, Parameters: &LoggingFileConfig{ Filename: "./var/anubis.log", MaxBackups: 3, MaxBytes: 104857600, // 100 Mi MaxAge: -7, // 7 days Compress: true, UseLocalTime: false, }, }, want: ErrOutOfRange, }, } { t.Run(tt.name, func(t *testing.T) { err := tt.input.Valid() if !errors.Is(err, tt.want) { t.Logf("wanted error: %v", tt.want) t.Logf(" got error: %v", err) t.Fatal("got wrong error") } }) } } ================================================ FILE: lib/config/opengraph.go ================================================ package config import ( "errors" "fmt" "time" ) var ( ErrInvalidOpenGraphConfig = errors.New("config.OpenGraph: invalid OpenGraph configuration") ErrOpenGraphTTLDoesNotParse = errors.New("config.OpenGraph: ttl does not parse as a Duration, see https://pkg.go.dev/time#ParseDuration (formatted like 5m -> 5 minutes, 2h -> 2 hours, etc)") ErrOpenGraphMissingProperty = errors.New("config.OpenGraph: default opengraph tags missing a property") ) type openGraphFileConfig struct { Override map[string]string `json:"override,omitempty" yaml:"override,omitempty"` TimeToLive string `json:"ttl" yaml:"ttl"` Enabled bool `json:"enabled" yaml:"enabled"` ConsiderHost bool `json:"considerHost" yaml:"enabled"` } type OpenGraph struct { Override map[string]string `json:"override,omitempty" yaml:"override,omitempty"` TimeToLive time.Duration `json:"ttl" yaml:"ttl"` Enabled bool `json:"enabled" yaml:"enabled"` ConsiderHost bool `json:"considerHost" yaml:"enabled"` } func (og *openGraphFileConfig) Valid() error { var errs []error if _, err := time.ParseDuration(og.TimeToLive); err != nil { errs = append(errs, fmt.Errorf("%w: ParseDuration(%q) returned: %w", ErrOpenGraphTTLDoesNotParse, og.TimeToLive, err)) } if len(og.Override) != 0 { for _, tag := range []string{ "og:title", } { if _, ok := og.Override[tag]; !ok { errs = append(errs, fmt.Errorf("%w: %s", ErrOpenGraphMissingProperty, tag)) } } } if len(errs) != 0 { return errors.Join(ErrInvalidOpenGraphConfig, errors.Join(errs...)) } return nil } ================================================ FILE: lib/config/opengraph_test.go ================================================ package config import ( "errors" "testing" ) func TestOpenGraphFileConfigValid(t *testing.T) { for _, tt := range []struct { err error input *openGraphFileConfig name string }{ { name: "basic happy path", input: &openGraphFileConfig{ Enabled: true, ConsiderHost: false, TimeToLive: "1h", Override: map[string]string{}, }, err: nil, }, { name: "basic happy path with default", input: &openGraphFileConfig{ Enabled: true, ConsiderHost: false, TimeToLive: "1h", Override: map[string]string{ "og:title": "foobar", }, }, err: nil, }, { name: "invalid time duration", input: &openGraphFileConfig{ Enabled: true, ConsiderHost: false, TimeToLive: "taco", Override: map[string]string{}, }, err: ErrOpenGraphTTLDoesNotParse, }, { name: "missing og:title in defaults", input: &openGraphFileConfig{ Enabled: true, ConsiderHost: false, TimeToLive: "1h", Override: map[string]string{ "description": "foobar", }, }, err: ErrOpenGraphMissingProperty, }, } { t.Run(tt.name, func(t *testing.T) { if err := tt.input.Valid(); !errors.Is(err, tt.err) { t.Logf("wanted error: %v", tt.err) t.Logf("got error: %v", err) t.Error("validation failed") } }) } } ================================================ FILE: lib/config/store.go ================================================ package config import ( "encoding/json" "errors" "fmt" "github.com/TecharoHQ/anubis/lib/store" _ "github.com/TecharoHQ/anubis/lib/store/all" ) var ( ErrNoStoreBackend = errors.New("config.Store: no backend defined") ErrUnknownStoreBackend = errors.New("config.Store: unknown backend") ) type Store struct { Backend string `json:"backend"` Parameters json.RawMessage `json:"parameters"` } func (s *Store) Valid() error { var errs []error if len(s.Backend) == 0 { errs = append(errs, ErrNoStoreBackend) } fac, ok := store.Get(s.Backend) switch ok { case true: if err := fac.Valid(s.Parameters); err != nil { errs = append(errs, err) } case false: errs = append(errs, fmt.Errorf("%w: %q", ErrUnknownStoreBackend, s.Backend)) } if len(errs) != 0 { return errors.Join(errs...) } return nil } ================================================ FILE: lib/config/store_test.go ================================================ package config_test import ( "encoding/json" "errors" "testing" "github.com/TecharoHQ/anubis/lib/config" "github.com/TecharoHQ/anubis/lib/store/bbolt" "github.com/TecharoHQ/anubis/lib/store/valkey" ) func TestStoreValid(t *testing.T) { for _, tt := range []struct { err error name string input config.Store }{ { name: "no backend", input: config.Store{}, err: config.ErrNoStoreBackend, }, { name: "in-memory backend", input: config.Store{ Backend: "memory", }, }, { name: "bbolt backend", input: config.Store{ Backend: "bbolt", Parameters: json.RawMessage(`{"path": "/tmp/foo", "bucket": "bar"}`), }, }, { name: "valkey backend", input: config.Store{ Backend: "valkey", Parameters: json.RawMessage(`{"url": "redis://valkey:6379/0"}`), }, }, { name: "valkey backend no URL", input: config.Store{ Backend: "valkey", Parameters: json.RawMessage(`{}`), }, err: valkey.ErrNoURL, }, { name: "valkey backend bad URL", input: config.Store{ Backend: "valkey", Parameters: json.RawMessage(`{"url": "http://anubis.techaro.lol"}`), }, err: valkey.ErrBadURL, }, { name: "bbolt backend no path", input: config.Store{ Backend: "bbolt", Parameters: json.RawMessage(`{"path": "", "bucket": "bar"}`), }, err: bbolt.ErrMissingPath, }, { name: "unknown backend", input: config.Store{ Backend: "taco salad", }, err: config.ErrUnknownStoreBackend, }, } { t.Run(tt.name, func(t *testing.T) { if err := tt.input.Valid(); !errors.Is(err, tt.err) { t.Logf("want: %v", tt.err) t.Logf("got: %v", err) t.Error("invalid error returned") } }) } } ================================================ FILE: lib/config/testdata/bad/badregexes.json ================================================ { "bots": [ { "name": "path-bad", "path_regex": "a(b", "action": "DENY" }, { "name": "user-agent-bad", "user_agent_regex": "a(b", "action": "DENY" }, { "name": "headers-bad", "headers": { "Accept-Encoding": "a(b" }, "action": "DENY" } ] } ================================================ FILE: lib/config/testdata/bad/badregexes.yaml ================================================ bots: - name: path-bad path_regex: "a(b" action: DENY - name: user-agent-bad user_agent_regex: "a(b" action: DENY ================================================ FILE: lib/config/testdata/bad/dns-ttl-custom.yaml ================================================ dns_ttl: forward: 60.0 reverse: "600" bots: - name: "test" user_agent_regex: ".*" action: "DENY" ================================================ FILE: lib/config/testdata/bad/import_and_bot.json ================================================ { "bots": [ { "import": "(data)/bots/ai-catchall.yaml", "name": "generic-browser", "user_agent_regex": "Mozilla|Opera\n", "action": "CHALLENGE" } ] } ================================================ FILE: lib/config/testdata/bad/import_and_bot.yaml ================================================ bots: - import: (data)/bots/ai-catchall.yaml name: generic-browser user_agent_regex: > Mozilla|Opera action: CHALLENGE ================================================ FILE: lib/config/testdata/bad/import_invalid_file.json ================================================ { "bots": [ { "import": "(data)/does-not-exist-fake-file.yaml" } ] } ================================================ FILE: lib/config/testdata/bad/import_invalid_file.yaml ================================================ bots: - import: (data)/does-not-exist-fake-file.yaml ================================================ FILE: lib/config/testdata/bad/impressum-no-footer.yaml ================================================ bots: - name: simple-weight-adjust action: WEIGH user_agent_regex: Mozilla weight: adjust: 5 impressum: page: title: Test body:

This is a test

================================================ FILE: lib/config/testdata/bad/impressum-no-page-contents.yaml ================================================ bots: - name: simple-weight-adjust action: WEIGH user_agent_regex: Mozilla weight: adjust: 5 impressum: footer: "Hi there these are WORDS on the INTERNET." page: {} ================================================ FILE: lib/config/testdata/bad/invalid.json ================================================ { "bots": [{}] } ================================================ FILE: lib/config/testdata/bad/invalid.yaml ================================================ bots: [] ================================================ FILE: lib/config/testdata/bad/logging-invalid-sink.yaml ================================================ logging: sink: "nope" ================================================ FILE: lib/config/testdata/bad/logging-no-parameters.yaml ================================================ logging: sink: "file" ================================================ FILE: lib/config/testdata/bad/multiple_expression_types.json ================================================ { "bots": [ { "name": "multiple-expression-types", "action": "ALLOW", "expression": { "all": [ "userAgent.startsWith(\"git/\") || userAgent.contains(\"libgit\")", "\"Git-Protocol\" in headers && headers[\"Git-Protocol\"] == \"version=2\"\n" ], "any": ["userAgent.startsWith(\"evilbot/\")"] } } ] } ================================================ FILE: lib/config/testdata/bad/multiple_expression_types.yaml ================================================ bots: - name: multiple-expression-types action: ALLOW expression: all: - userAgent.startsWith("git/") || userAgent.contains("libgit") - > "Git-Protocol" in headers && headers["Git-Protocol"] == "version=2" any: - userAgent.startsWith("evilbot/") ================================================ FILE: lib/config/testdata/bad/nobots.json ================================================ {} ================================================ FILE: lib/config/testdata/bad/nobots.yaml ================================================ {} ================================================ FILE: lib/config/testdata/bad/opengraph_bad_ttl.yaml ================================================ bots: - name: everything user_agent_regex: .* action: DENY openGraph: enabled: true considerHost: false ttl: taco default: "og:title": "Xe's magic land of fun" "og:description": "We're no strangers to love, you know the rules and so do I" ================================================ FILE: lib/config/testdata/bad/regex_ends_newline.json ================================================ { "bots": [ { "name": "user-agent-ends-newline", "user_agent_regex": "Mozilla\n", "action": "CHALLENGE" }, { "name": "path-ends-newline", "path_regex": "^/evil/.*$\n", "action": "CHALLENGE" }, { "name": "headers-ends-newline", "headers_regex": { "CF-Worker": ".*\n" }, "action": "CHALLENGE" } ] } ================================================ FILE: lib/config/testdata/bad/regex_ends_newline.yaml ================================================ bots: - name: user-agent-ends-newline # Subtle bug: this ends with a newline user_agent_regex: > Mozilla action: CHALLENGE - name: path-ends-newline # Subtle bug: this ends with a newline path_regex: > ^/evil/.*$ action: CHALLENGE - name: headers-ends-newline # Subtle bug: this ends with a newline headers_regex: CF-Worker: > .* action: CHALLENGE ================================================ FILE: lib/config/testdata/bad/status-codes-0.json ================================================ { "bots": [ { "name": "everything", "user_agent_regex": ".*", "action": "DENY" } ], "status_codes": { "CHALLENGE": 0, "DENY": 0 } } ================================================ FILE: lib/config/testdata/bad/status-codes-0.yaml ================================================ bots: - name: everything user_agent_regex: .* action: DENY status_codes: CHALLENGE: 0 DENY: 0 ================================================ FILE: lib/config/testdata/bad/threshold-challenge-without-challenge.yaml ================================================ bots: - name: simple-weight-adjust action: WEIGH user_agent_regex: Mozilla weight: adjust: 5 thresholds: - name: extreme-suspicion expression: "true" action: WEIGH ================================================ FILE: lib/config/testdata/bad/thresholds.yaml ================================================ bots: - name: simple-weight-adjust action: WEIGH user_agent_regex: Mozilla weight: adjust: 5 thresholds: - name: extreme-suspicion expression: "true" action: WEIGH challenge: algorithm: fast difficulty: 4 report_as: 4 ================================================ FILE: lib/config/testdata/bad/unparseable.json ================================================ } ================================================ FILE: lib/config/testdata/bad/unparseable.yaml ================================================ } ================================================ FILE: lib/config/testdata/good/allow_everyone.json ================================================ { "bots": [ { "name": "everyones-invited", "remote_addresses": ["0.0.0.0/0", "::/0"], "action": "ALLOW" } ] } ================================================ FILE: lib/config/testdata/good/allow_everyone.yaml ================================================ bots: - name: everyones-invited remote_addresses: - "0.0.0.0/0" - "::/0" action: ALLOW ================================================ FILE: lib/config/testdata/good/block_cf_workers.json ================================================ { "bots": [ { "name": "Cloudflare Workers", "headers_regex": { "CF-Worker": ".*" }, "action": "DENY" } ], "dnsbl": false } ================================================ FILE: lib/config/testdata/good/block_cf_workers.yaml ================================================ bots: - name: cloudflare-workers headers_regex: CF-Worker: .* action: DENY ================================================ FILE: lib/config/testdata/good/challenge_cloudflare.yaml ================================================ bots: - name: challenge-cloudflare action: CHALLENGE asns: match: - 13335 # Cloudflare ================================================ FILE: lib/config/testdata/good/challengemozilla.json ================================================ { "bots": [ { "name": "generic-browser", "user_agent_regex": "Mozilla", "action": "CHALLENGE" } ] } ================================================ FILE: lib/config/testdata/good/challengemozilla.yaml ================================================ bots: - name: generic-browser user_agent_regex: Mozilla action: CHALLENGE ================================================ FILE: lib/config/testdata/good/dns-ttl-custom.yaml ================================================ dns_ttl: forward: 600 reverse: 600 bots: - name: "test" user_agent_regex: ".*" action: "DENY" ================================================ FILE: lib/config/testdata/good/entropy.yaml ================================================ bots: - name: total-randomness action: ALLOW expression: all: - '"Accept" in headers' - headers["Accept"].contains("text/html") - randInt(1) == 0 ================================================ FILE: lib/config/testdata/good/everything_blocked.json ================================================ { "bots": [ { "name": "everything", "user_agent_regex": ".*", "action": "DENY" } ], "dnsbl": false } ================================================ FILE: lib/config/testdata/good/everything_blocked.yaml ================================================ bots: - name: everything user_agent_regex: .* action: DENY ================================================ FILE: lib/config/testdata/good/geoip_us.yaml ================================================ bots: - name: compute-tarrif-us action: CHALLENGE geoip: countries: - US ================================================ FILE: lib/config/testdata/good/git_client.json ================================================ { "bots": [ { "name": "allow-git-clients", "action": "ALLOW", "expression": { "all": [ "userAgent.startsWith(\"git/\") || userAgent.contains(\"libgit\")", "\"Git-Protocol\" in headers && headers[\"Git-Protocol\"] == \"version=2\"" ] } } ] } ================================================ FILE: lib/config/testdata/good/git_client.yaml ================================================ bots: - name: allow-git-clients action: ALLOW expression: all: - userAgent.startsWith("git/") || userAgent.contains("libgit") - > "Git-Protocol" in headers && headers["Git-Protocol"] == "version=2" ================================================ FILE: lib/config/testdata/good/import_filesystem.json ================================================ { "bots": [ { "import": "./testdata/hack-test.json" } ] } ================================================ FILE: lib/config/testdata/good/import_filesystem.yaml ================================================ bots: - import: ./testdata/hack-test.yaml ================================================ FILE: lib/config/testdata/good/import_keep_internet_working.json ================================================ { "bots": [ { "import": "(data)/common/keep-internet-working.yaml" } ] } ================================================ FILE: lib/config/testdata/good/import_keep_internet_working.yaml ================================================ bots: - import: (data)/common/keep-internet-working.yaml ================================================ FILE: lib/config/testdata/good/impressum.yaml ================================================ bots: - name: simple action: CHALLENGE user_agent_regex: Mozilla impressum: footer: "Hi these are WORDS on the INTERNET." page: title: Test body:

This is a test

================================================ FILE: lib/config/testdata/good/logging-file.yaml ================================================ bots: - name: simple action: CHALLENGE user_agent_regex: Mozilla logs: sink: "file" parameters: file: "/var/log/botstopper/default.log" maxBackups: 3 # keep at least 3 old copies maxBytes: 67108864 # each file can have up to 64 MB of logs maxAge: 7 # rotate files out every n days oldFileTimeFormat: 2006-01-02T15-04-05 # RFC 3339-ish compress: true useLocalTime: false # timezone for rotated files is UTC ================================================ FILE: lib/config/testdata/good/logging-stdio.yaml ================================================ bots: - name: simple action: CHALLENGE user_agent_regex: Mozilla logging: sink: "stdio" ================================================ FILE: lib/config/testdata/good/no-thresholds.yaml ================================================ bots: - name: simple-weight-adjust action: WEIGH user_agent_regex: Mozilla weight: adjust: 5 thresholds: [] ================================================ FILE: lib/config/testdata/good/old_xesite.json ================================================ { "bots": [ { "name": "amazonbot", "user_agent_regex": "Amazonbot", "action": "DENY" }, { "name": "googlebot", "user_agent_regex": "\\+http\\:\\/\\/www\\.google\\.com/bot\\.html", "action": "ALLOW" }, { "name": "bingbot", "user_agent_regex": "\\+http\\:\\/\\/www\\.bing\\.com/bingbot\\.htm", "action": "ALLOW" }, { "name": "qwantbot", "user_agent_regex": "\\+https\\:\\/\\/help\\.qwant\\.com/bot/", "action": "ALLOW" }, { "name": "discordbot", "user_agent_regex": "Discordbot/2\\.0; \\+https\\:\\/\\/discordapp\\.com", "action": "ALLOW" }, { "name": "blueskybot", "user_agent_regex": "Bluesky Cardyb", "action": "ALLOW" }, { "name": "us-artificial-intelligence-scraper", "user_agent_regex": "\\+https\\:\\/\\/github\\.com\\/US-Artificial-Intelligence\\/scraper", "action": "DENY" }, { "name": "well-known", "path_regex": "^/.well-known/.*$", "action": "ALLOW" }, { "name": "favicon", "path_regex": "^/favicon.ico$", "action": "ALLOW" }, { "name": "robots-txt", "path_regex": "^/robots.txt$", "action": "ALLOW" }, { "name": "rss-readers", "path_regex": ".*\\.(rss|xml|atom|json)$", "action": "ALLOW" }, { "name": "lightpanda", "user_agent_regex": "^Lightpanda/.*$", "action": "DENY" }, { "name": "headless-chrome", "user_agent_regex": "HeadlessChrome", "action": "DENY" }, { "name": "headless-chromium", "user_agent_regex": "HeadlessChromium", "action": "DENY" }, { "name": "generic-browser", "user_agent_regex": "Mozilla", "action": "CHALLENGE" } ] } ================================================ FILE: lib/config/testdata/good/opengraph_all_good.yaml ================================================ bots: - name: everything user_agent_regex: .* action: DENY openGraph: enabled: true considerHost: false ttl: 1h default: "og:title": "Xe's magic land of fun" "og:description": "We're no strangers to love, you know the rules and so do I" ================================================ FILE: lib/config/testdata/good/simple-weight.yaml ================================================ bots: - name: simple-weight-adjust action: WEIGH user_agent_regex: Mozilla weight: adjust: 5 ================================================ FILE: lib/config/testdata/good/status-codes-paranoid.json ================================================ { "bots": [ { "name": "everything", "user_agent_regex": ".*", "action": "DENY" } ], "status_codes": { "CHALLENGE": 200, "DENY": 200 } } ================================================ FILE: lib/config/testdata/good/status-codes-paranoid.yaml ================================================ bots: - name: everything user_agent_regex: .* action: DENY status_codes: CHALLENGE: 200 DENY: 200 ================================================ FILE: lib/config/testdata/good/status-codes-rfc.json ================================================ { "bots": [ { "name": "everything", "user_agent_regex": ".*", "action": "DENY" } ], "status_codes": { "CHALLENGE": 403, "DENY": 403 } } ================================================ FILE: lib/config/testdata/good/status-codes-rfc.yaml ================================================ bots: - name: everything user_agent_regex: .* action: DENY status_codes: CHALLENGE: 403 DENY: 403 ================================================ FILE: lib/config/testdata/good/thresholds.yaml ================================================ bots: - name: simple-weight-adjust action: WEIGH user_agent_regex: Mozilla weight: adjust: 5 thresholds: - name: minimal-suspicion expression: weight < 0 action: ALLOW - name: mild-suspicion expression: all: - weight >= 0 - weight < 10 action: CHALLENGE challenge: algorithm: metarefresh difficulty: 1 - name: moderate-suspicion expression: all: - weight >= 10 - weight < 20 action: CHALLENGE challenge: algorithm: fast difficulty: 2 - name: extreme-suspicion expression: weight >= 20 action: CHALLENGE challenge: algorithm: fast difficulty: 4 ================================================ FILE: lib/config/testdata/good/weight-no-weight.yaml ================================================ bots: - name: weight action: WEIGH user_agent_regex: Mozilla ================================================ FILE: lib/config/testdata/hack-test.json ================================================ [ { "name": "ipv6-ula", "action": "ALLOW", "remote_addresses": ["fc00::/7"] } ] ================================================ FILE: lib/config/testdata/hack-test.yaml ================================================ - name: well-known path_regex: ^/.well-known/.*$ action: ALLOW ================================================ FILE: lib/config/threshold.go ================================================ package config import ( "errors" "fmt" "github.com/TecharoHQ/anubis" ) var ( ErrNoThresholdRulesDefined = errors.New("config: no thresholds defined") ErrThresholdMustHaveName = errors.New("config.Threshold: must set name") ErrThresholdMustHaveExpression = errors.New("config.Threshold: must set expression") ErrThresholdChallengeMustHaveChallenge = errors.New("config.Threshold: a threshold with the CHALLENGE action must have challenge set") ErrThresholdCannotHaveWeighAction = errors.New("config.Threshold: a threshold cannot have the WEIGH action") DefaultThresholds = []Threshold{ { Name: "legacy-anubis-behaviour", Expression: &ExpressionOrList{ Expression: "weight > 0", }, Action: RuleChallenge, Challenge: &ChallengeRules{ Algorithm: "fast", Difficulty: anubis.DefaultDifficulty, }, }, } ) type Threshold struct { Expression *ExpressionOrList `json:"expression" yaml:"expression"` Challenge *ChallengeRules `json:"challenge" yaml:"challenge"` Name string `json:"name" yaml:"name"` Action Rule `json:"action" yaml:"action"` } func (t Threshold) Valid() error { var errs []error if len(t.Name) == 0 { errs = append(errs, ErrThresholdMustHaveName) } if t.Expression == nil { errs = append(errs, ErrThresholdMustHaveExpression) } if t.Expression != nil { if err := t.Expression.Valid(); err != nil { errs = append(errs, err) } } if err := t.Action.Valid(); err != nil { errs = append(errs, err) } if t.Action == RuleWeigh { errs = append(errs, ErrThresholdCannotHaveWeighAction) } if t.Action == RuleChallenge && t.Challenge == nil { errs = append(errs, ErrThresholdChallengeMustHaveChallenge) } if t.Challenge != nil { if err := t.Challenge.Valid(); err != nil { errs = append(errs, err) } } if len(errs) != 0 { return fmt.Errorf("config: threshold entry for %q is not valid:\n%w", t.Name, errors.Join(errs...)) } return nil } ================================================ FILE: lib/config/threshold_test.go ================================================ package config import ( "errors" "fmt" "os" "path/filepath" "testing" ) func TestThresholdValid(t *testing.T) { for _, tt := range []struct { err error input *Threshold name string }{ { name: "basic allow", input: &Threshold{ Name: "basic-allow", Expression: &ExpressionOrList{Expression: "true"}, Action: RuleAllow, }, err: nil, }, { name: "basic challenge", input: &Threshold{ Name: "basic-challenge", Expression: &ExpressionOrList{Expression: "true"}, Action: RuleChallenge, Challenge: &ChallengeRules{ Algorithm: "fast", Difficulty: 1, }, }, err: nil, }, { name: "no name", input: &Threshold{}, err: ErrThresholdMustHaveName, }, { name: "no expression", input: &Threshold{}, err: ErrThresholdMustHaveName, }, { name: "invalid expression", input: &Threshold{ Expression: &ExpressionOrList{}, }, err: ErrExpressionEmpty, }, { name: "invalid action", input: &Threshold{}, err: ErrUnknownAction, }, { name: "challenge action but no challenge", input: &Threshold{ Action: RuleChallenge, }, err: ErrThresholdChallengeMustHaveChallenge, }, { name: "challenge invalid", input: &Threshold{ Action: RuleChallenge, Challenge: &ChallengeRules{Difficulty: -1, ReportAs: -1}, }, err: ErrChallengeDifficultyTooLow, }, } { t.Run(tt.name, func(t *testing.T) { if err := tt.input.Valid(); !errors.Is(err, tt.err) { t.Errorf("threshold is invalid: %v", err) } }) } } func TestDefaultThresholdsValid(t *testing.T) { for i, th := range DefaultThresholds { t.Run(fmt.Sprintf("%d %s", i, th.Name), func(t *testing.T) { if err := th.Valid(); err != nil { t.Errorf("threshold invalid: %v", err) } }) } } func TestLoadActuallyLoadsThresholds(t *testing.T) { fin, err := os.Open(filepath.Join(".", "testdata", "good", "thresholds.yaml")) if err != nil { t.Fatal(err) } defer fin.Close() c, err := Load(fin, fin.Name()) if err != nil { t.Fatal(err) } if len(c.Thresholds) != 4 { t.Errorf("wanted 4 thresholds, got %d thresholds", len(c.Thresholds)) } } ================================================ FILE: lib/config/weight.go ================================================ package config type Weight struct { Adjust int `json:"adjust" yaml:"adjust"` } ================================================ FILE: lib/config.go ================================================ package lib import ( "context" "crypto/ed25519" "crypto/rand" "errors" "fmt" "io" "log/slog" "net/http" "os" "strings" "time" "github.com/TecharoHQ/anubis" "github.com/TecharoHQ/anubis/data" "github.com/TecharoHQ/anubis/internal" "github.com/TecharoHQ/anubis/internal/honeypot/naive" "github.com/TecharoHQ/anubis/internal/ogtags" "github.com/TecharoHQ/anubis/lib/challenge" "github.com/TecharoHQ/anubis/lib/config" "github.com/TecharoHQ/anubis/lib/localization" "github.com/TecharoHQ/anubis/lib/policy" "github.com/TecharoHQ/anubis/web" "github.com/TecharoHQ/anubis/xess" "github.com/a-h/templ" ) type Options struct { Next http.Handler Policy *policy.ParsedConfig Target string TargetHost string TargetSNI string TargetInsecureSkipVerify bool CookieDynamicDomain bool CookieDomain string CookieExpiration time.Duration CookiePartitioned bool BasePrefix string WebmasterEmail string RedirectDomains []string ED25519PrivateKey ed25519.PrivateKey HS512Secret []byte StripBasePrefix bool OpenGraph config.OpenGraph ServeRobotsTXT bool CookieSecure bool CookieSameSite http.SameSite Logger *slog.Logger LogLevel string PublicUrl string JWTRestrictionHeader string DifficultyInJWT bool } func LoadPoliciesOrDefault(ctx context.Context, fname string, defaultDifficulty int, logLevel string) (*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.yaml" fin, err = data.BotPolicies.Open("botPolicies.yaml") if err != nil { return nil, fmt.Errorf("[unexpected] can't parse builtin policy file %s: %w", fname, err) } } defer func(fin io.ReadCloser) { err := fin.Close() if err != nil { slog.Error("failed to close policy file", "file", fname, "err", err) } }(fin) anubisPolicy, err := policy.ParseConfig(ctx, fin, fname, defaultDifficulty, logLevel) if err != nil { return nil, fmt.Errorf("can't parse policy file %s: %w", fname, err) } var validationErrs []error for _, b := range anubisPolicy.Bots { if _, ok := challenge.Get(b.Challenge.Algorithm); !ok { validationErrs = append(validationErrs, fmt.Errorf("%w %s", policy.ErrChallengeRuleHasWrongAlgorithm, b.Challenge.Algorithm)) } } if len(validationErrs) != 0 { return nil, fmt.Errorf("can't do final validation of Anubis config: %w", errors.Join(validationErrs...)) } return anubisPolicy, err } func New(opts Options) (*Server, error) { if opts.Logger == nil { opts.Logger = slog.With("subsystem", "anubis") } if opts.ED25519PrivateKey == nil && opts.HS512Secret == nil { opts.Logger.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.ED25519PrivateKey = priv } anubis.BasePrefix = strings.TrimRight(opts.BasePrefix, "/") anubis.PublicUrl = opts.PublicUrl result := &Server{ next: opts.Next, ed25519Priv: opts.ED25519PrivateKey, hs512Secret: opts.HS512Secret, policy: opts.Policy, opts: opts, OGTags: ogtags.NewOGTagCache(opts.Target, opts.Policy.OpenGraph, opts.Policy.Store, ogtags.TargetOptions{ Host: opts.TargetHost, SNI: opts.TargetSNI, InsecureSkipVerify: opts.TargetInsecureSkipVerify, }), store: opts.Policy.Store, logger: opts.Logger, } mux := http.NewServeMux() xess.Mount(mux) // Helper to add global prefix registerWithPrefix := func(pattern string, handler http.Handler, method string) { if method != "" { method = method + " " // methods must end with a space to register with them } // Ensure there's no double slash when concatenating BasePrefix and pattern basePrefix := strings.TrimSuffix(anubis.BasePrefix, "/") prefix := method + basePrefix // If pattern doesn't start with a slash, add one if !strings.HasPrefix(pattern, "/") { pattern = "/" + pattern } mux.Handle(prefix+pattern, handler) } // Ensure there's no double slash when concatenating BasePrefix and StaticPath stripPrefix := strings.TrimSuffix(anubis.BasePrefix, "/") + anubis.StaticPath registerWithPrefix(anubis.StaticPath, internal.UnchangingCache(internal.NoBrowsing(http.StripPrefix(stripPrefix, http.FileServerFS(web.Static)))), "") if opts.ServeRobotsTXT { registerWithPrefix("/robots.txt", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.ServeFileFS(w, r, web.Static, "static/robots.txt") }), "GET") registerWithPrefix("/.well-known/robots.txt", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.ServeFileFS(w, r, web.Static, "static/robots.txt") }), "GET") } if opts.Policy.Impressum != nil { registerWithPrefix(anubis.APIPrefix+"imprint", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { templ.Handler( web.Base(opts.Policy.Impressum.Page.Title, opts.Policy.Impressum.Page, opts.Policy.Impressum, localization.GetLocalizer(r)), ).ServeHTTP(w, r) }), "GET") } registerWithPrefix(anubis.APIPrefix+"pass-challenge", http.HandlerFunc(result.PassChallenge), "GET") registerWithPrefix(anubis.APIPrefix+"check", http.HandlerFunc(result.maybeReverseProxyHttpStatusOnly), "") registerWithPrefix("/", http.HandlerFunc(result.maybeReverseProxyOrPage), "") mazeGen, err := naive.New(result.store, result.logger) if err == nil { registerWithPrefix(anubis.APIPrefix+"honeypot/{id}/{stage}", mazeGen, http.MethodGet) opts.Policy.Bots = append( opts.Policy.Bots, policy.Bot{ Rules: mazeGen.CheckNetwork(), Action: config.RuleWeigh, Weight: &config.Weight{ Adjust: 30, }, Name: "honeypot/network", }, policy.Bot{ Rules: mazeGen.CheckUA(), Action: config.RuleWeigh, Weight: &config.Weight{ Adjust: 30, }, Name: "honeypot/user-agent", }, ) } else { result.logger.Error("can't init honeypot subsystem", "err", err) } //goland:noinspection GoBoolExpressions if anubis.Version == "devel" { // make-challenge is only used in tests. Only enable while version is devel registerWithPrefix(anubis.APIPrefix+"make-challenge", http.HandlerFunc(result.MakeChallenge), "POST") } for _, implKind := range challenge.Methods() { impl, _ := challenge.Get(implKind) impl.Setup(mux) } result.mux = mux return result, nil } ================================================ FILE: lib/config_test.go ================================================ package lib import ( "errors" "os" "path/filepath" "testing" "github.com/TecharoHQ/anubis" "github.com/TecharoHQ/anubis/lib/policy" "github.com/TecharoHQ/anubis/lib/thoth/thothmock" ) func TestInvalidChallengeMethod(t *testing.T) { if _, err := LoadPoliciesOrDefault(t.Context(), "testdata/invalid-challenge-method.yaml", 4, "info"); !errors.Is(err, policy.ErrChallengeRuleHasWrongAlgorithm) { t.Fatalf("wanted error %v but got %v", policy.ErrChallengeRuleHasWrongAlgorithm, err) } } func TestBadConfigs(t *testing.T) { finfos, err := os.ReadDir("config/testdata/bad") if err != nil { t.Fatal(err) } for _, st := range finfos { t.Run(st.Name(), func(t *testing.T) { if _, err := LoadPoliciesOrDefault(t.Context(), filepath.Join("config", "testdata", "bad", st.Name()), anubis.DefaultDifficulty, "info"); err == nil { t.Fatal(err) } else { t.Log(err) } }) } } func TestGoodConfigs(t *testing.T) { finfos, err := os.ReadDir("config/testdata/good") if err != nil { t.Fatal(err) } for _, st := range finfos { t.Run(st.Name(), func(t *testing.T) { t.Run("with-thoth", func(t *testing.T) { ctx := thothmock.WithMockThoth(t) if _, err := LoadPoliciesOrDefault(ctx, filepath.Join("config", "testdata", "good", st.Name()), anubis.DefaultDifficulty, "info"); err != nil { t.Fatal(err) } }) t.Run("without-thoth", func(t *testing.T) { if _, err := LoadPoliciesOrDefault(t.Context(), filepath.Join("config", "testdata", "good", st.Name()), anubis.DefaultDifficulty, "info"); err != nil { t.Fatal(err) } }) }) } } ================================================ FILE: lib/http.go ================================================ package lib import ( "bytes" "compress/gzip" "encoding/base64" "errors" "fmt" "math/rand" "net/http" "net/url" "regexp" "strings" "time" "github.com/TecharoHQ/anubis" "github.com/TecharoHQ/anubis/internal" "github.com/TecharoHQ/anubis/internal/glob" "github.com/TecharoHQ/anubis/lib/challenge" "github.com/TecharoHQ/anubis/lib/localization" "github.com/TecharoHQ/anubis/lib/policy" "github.com/TecharoHQ/anubis/web" "github.com/TecharoHQ/anubis/xess" "github.com/a-h/templ" "github.com/golang-jwt/jwt/v5" "golang.org/x/net/publicsuffix" ) var domainMatchRegexp = regexp.MustCompile(`^((xn--)?[a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$`) var ( ErrActualAnubisBug = errors.New("this is an actual bug in Anubis, please file an issue with the magic string 'taco bell'") ) // matchRedirectDomain returns true if host matches any of the allowed redirect // domain patterns. Patterns may contain '*' which are matched using the // internal glob matcher. Matching is case-insensitive on hostnames. func matchRedirectDomain(allowed []string, host string) bool { h := strings.ToLower(strings.TrimSpace(host)) for _, pat := range allowed { p := strings.ToLower(strings.TrimSpace(pat)) if strings.Contains(p, glob.GLOB) { if glob.Glob(p, h) { return true } continue } if p == h { return true } } return false } type CookieOpts struct { Value string Host string Path string Name string Expiry time.Duration } func (s *Server) SetCookie(w http.ResponseWriter, cookieOpts CookieOpts) { var domain = s.opts.CookieDomain var name = anubis.CookieName var path = "/" var sameSite = s.opts.CookieSameSite if cookieOpts.Name != "" { name = cookieOpts.Name } if cookieOpts.Path != "" { path = cookieOpts.Path } if s.opts.CookieDynamicDomain && domainMatchRegexp.MatchString(cookieOpts.Host) { if etld, err := publicsuffix.EffectiveTLDPlusOne(cookieOpts.Host); err == nil { domain = etld } } if cookieOpts.Expiry == 0 { cookieOpts.Expiry = s.opts.CookieExpiration } if s.opts.CookieSameSite == http.SameSiteNoneMode && !s.opts.CookieSecure { sameSite = http.SameSiteLaxMode } http.SetCookie(w, &http.Cookie{ Name: name, Value: cookieOpts.Value, Expires: time.Now().Add(cookieOpts.Expiry), SameSite: sameSite, Domain: domain, Secure: s.opts.CookieSecure, Partitioned: s.opts.CookiePartitioned, Path: path, }) } func (s *Server) ClearCookie(w http.ResponseWriter, cookieOpts CookieOpts) { var domain = s.opts.CookieDomain var name = anubis.CookieName var path = "/" var sameSite = s.opts.CookieSameSite if cookieOpts.Name != "" { name = cookieOpts.Name } if cookieOpts.Path != "" { path = cookieOpts.Path } if s.opts.CookieDynamicDomain && domainMatchRegexp.MatchString(cookieOpts.Host) { if etld, err := publicsuffix.EffectiveTLDPlusOne(cookieOpts.Host); err == nil { domain = etld } } if s.opts.CookieSameSite == http.SameSiteNoneMode && !s.opts.CookieSecure { sameSite = http.SameSiteLaxMode } http.SetCookie(w, &http.Cookie{ Name: name, Value: "", MaxAge: -1, Expires: time.Now().Add(-1 * time.Minute), SameSite: sameSite, Partitioned: s.opts.CookiePartitioned, Domain: domain, Secure: s.opts.CookieSecure, Path: path, }) } // 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) } func randomChance(n int) bool { return rand.Intn(n) == 0 } // XXX(Xe): generated by ChatGPT func rot13(s string) string { rotated := make([]rune, len(s)) for i, c := range s { switch { case c >= 'A' && c <= 'Z': rotated[i] = 'A' + ((c - 'A' + 13) % 26) case c >= 'a' && c <= 'z': rotated[i] = 'a' + ((c - 'a' + 13) % 26) default: rotated[i] = c } } return string(rotated) } func makeCode(err error) string { var buf bytes.Buffer gzw := gzip.NewWriter(&buf) errStr := fmt.Sprintf("internal error: %v", err) fmt.Fprintln(gzw, rot13(errStr)) if err := gzw.Close(); err != nil { panic("can't write to gzip in ram buffer") } const width = 16 enc := base64.StdEncoding.EncodeToString(buf.Bytes()) var builder strings.Builder for i := 0; i < len(enc); i += width { end := min(i+width, len(enc)) builder.WriteString(enc[i:end]) builder.WriteByte('\n') } return builder.String() } func (s *Server) RenderIndex(w http.ResponseWriter, r *http.Request, cr policy.CheckResult, rule *policy.Bot, returnHTTPStatusOnly bool) { localizer := localization.GetLocalizer(r) if returnHTTPStatusOnly { if s.opts.PublicUrl == "" { w.WriteHeader(http.StatusUnauthorized) w.Write([]byte(localizer.T("authorization_required"))) } else { redirectURL, err := s.constructRedirectURL(r) if err != nil { s.respondWithStatus(w, r, err.Error(), "", http.StatusBadRequest) return } http.Redirect(w, r, redirectURL, http.StatusTemporaryRedirect) } return } lg := internal.GetRequestLogger(s.logger, r) if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") && randomChance(64) { lg.Error("client was given a challenge but does not in fact support gzip compression") s.respondWithError(w, r, localizer.T("client_error_browser"), "") return } challengesIssued.WithLabelValues("embedded").Add(1) chall, err := s.issueChallenge(r.Context(), r, lg, cr, rule) if err != nil { lg.Error("can't get challenge", "err", err) algorithm := "unknown" if rule.Challenge != nil { algorithm = rule.Challenge.Algorithm } s.ClearCookie(w, CookieOpts{Name: anubis.TestCookieName, Host: r.Host}) s.respondWithError(w, r, fmt.Sprintf("%s: %s", localizer.T("internal_server_error"), algorithm), makeCode(err)) return } lg = lg.With("challenge", chall.ID) var ogTags map[string]string = nil if s.opts.OpenGraph.Enabled { var err error ogTags, err = s.OGTags.GetOGTags(r.Context(), r.URL, r.Host) if err != nil { lg.Error("failed to get OG tags", "err", err) } } s.SetCookie(w, CookieOpts{ Value: chall.ID, Host: r.Host, Path: "/", Name: anubis.TestCookieName, Expiry: 30 * time.Minute, }) impl, ok := challenge.Get(chall.Method) if !ok { algorithm := "unknown" if rule.Challenge != nil { algorithm = rule.Challenge.Algorithm } lg.Error("check failed", "err", "can't get algorithm", "algorithm", algorithm) s.ClearCookie(w, CookieOpts{Name: anubis.TestCookieName, Host: r.Host}) s.respondWithError(w, r, fmt.Sprintf("%s: %s", localizer.T("internal_server_error"), algorithm), makeCode(err)) return } in := &challenge.IssueInput{ Impressum: s.policy.Impressum, Rule: rule, Challenge: chall, OGTags: ogTags, Store: s.store, } component, err := impl.Issue(w, r, lg, in) if err != nil { lg.Error("[unexpected] challenge component render failed, please open an issue", "err", err) // This is likely a bug in the template. Should never be triggered as CI tests for this. s.respondWithError(w, r, fmt.Sprintf("%s \"RenderIndex\"", localizer.T("internal_server_error")), makeCode(err)) return } page := web.BaseWithChallengeAndOGTags( localizer.T("making_sure_not_bot"), component, s.policy.Impressum, chall, in.Rule.Challenge, in.OGTags, localizer, ) handler := internal.GzipMiddleware(1, internal.NoStoreCache(templ.Handler( page, templ.WithStatus(s.opts.Policy.StatusCodes.Challenge), ))) handler.ServeHTTP(w, r) } func (s *Server) constructRedirectURL(r *http.Request) (string, error) { proto := r.Header.Get("X-Forwarded-Proto") host := r.Header.Get("X-Forwarded-Host") uri := r.Header.Get("X-Forwarded-Uri") localizer := localization.GetLocalizer(r) if proto == "" || host == "" || uri == "" { return "", errors.New(localizer.T("missing_required_forwarded_headers")) } switch proto { case "http", "https": // allowed default: lg := internal.GetRequestLogger(s.logger, r) lg.Warn("invalid protocol in X-Forwarded-Proto", "proto", proto) return "", errors.New(localizer.T("invalid_redirect")) } // Check if host is allowed in RedirectDomains (supports '*' via glob) if len(s.opts.RedirectDomains) > 0 && !matchRedirectDomain(s.opts.RedirectDomains, host) { lg := internal.GetRequestLogger(s.logger, r) lg.Debug("domain not allowed", "domain", host) return "", errors.New(localizer.T("redirect_domain_not_allowed")) } redir := proto + "://" + host + uri escapedURL := url.QueryEscape(redir) return fmt.Sprintf("%s/.within.website/?redir=%s", s.opts.PublicUrl, escapedURL), nil } func (s *Server) RenderBench(w http.ResponseWriter, r *http.Request) { localizer := localization.GetLocalizer(r) templ.Handler( web.Base(localizer.T("benchmarking_anubis"), web.Bench(localizer), s.policy.Impressum, localizer), ).ServeHTTP(w, r) } func (s *Server) respondWithError(w http.ResponseWriter, r *http.Request, message, code string) { s.respondWithStatus(w, r, message, code, http.StatusInternalServerError) } func (s *Server) respondWithStatus(w http.ResponseWriter, r *http.Request, msg, code string, status int) { localizer := localization.GetLocalizer(r) component := web.Base( localizer.T("oh_noes"), web.ErrorPage(msg, s.opts.WebmasterEmail, code, localizer), s.policy.Impressum, localizer, ) handler := internal.NoStoreCache(templ.Handler(component, templ.WithStatus(status))) handler.ServeHTTP(w, r) } func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { if strings.HasPrefix(r.URL.Path, anubis.BasePrefix+anubis.StaticPath) { s.mux.ServeHTTP(w, r) return } else if strings.HasPrefix(r.URL.Path, anubis.BasePrefix+xess.BasePrefix) { s.mux.ServeHTTP(w, r) return } // Forward robots.txt requests to mux when ServeRobotsTXT is enabled if s.opts.ServeRobotsTXT { path := strings.TrimPrefix(r.URL.Path, anubis.BasePrefix) if path == "/robots.txt" || path == "/.well-known/robots.txt" { s.mux.ServeHTTP(w, r) return } } s.maybeReverseProxyOrPage(w, r) } func (s *Server) stripBasePrefixFromRequest(r *http.Request) *http.Request { if !s.opts.StripBasePrefix || s.opts.BasePrefix == "" { return r } basePrefix := strings.TrimSuffix(s.opts.BasePrefix, "/") path := r.URL.Path if !strings.HasPrefix(path, basePrefix) { return r } trimmedPath := strings.TrimPrefix(path, basePrefix) if trimmedPath == "" { trimmedPath = "/" } // Clone the request and URL reqCopy := r.Clone(r.Context()) urlCopy := *r.URL urlCopy.Path = trimmedPath reqCopy.URL = &urlCopy return reqCopy } func (s *Server) ServeHTTPNext(w http.ResponseWriter, r *http.Request) { if s.next == nil { localizer := localization.GetLocalizer(r) redir := r.FormValue("redir") urlParsed, err := url.ParseRequestURI(redir) if err != nil { // if ParseRequestURI fails, try as relative URL urlParsed, err = r.URL.Parse(redir) if err != nil { s.respondWithStatus(w, r, localizer.T("redirect_not_parseable"), makeCode(err), http.StatusBadRequest) return } } // validate URL scheme to prevent javascript:, data:, file:, tel:, etc. switch urlParsed.Scheme { case "", "http", "https": // allowed: empty scheme means relative URL default: lg := internal.GetRequestLogger(s.logger, r) lg.Warn("XSS attempt blocked, invalid redirect scheme", "scheme", urlParsed.Scheme, "redir", redir) s.respondWithStatus(w, r, localizer.T("invalid_redirect"), "", http.StatusBadRequest) return } hostNotAllowed := len(urlParsed.Host) > 0 && len(s.opts.RedirectDomains) != 0 && !matchRedirectDomain(s.opts.RedirectDomains, urlParsed.Host) hostMismatch := r.URL.Host != "" && urlParsed.Host != "" && urlParsed.Host != r.URL.Host if hostNotAllowed || hostMismatch { lg := internal.GetRequestLogger(s.logger, r) lg.Debug("domain not allowed", "domain", urlParsed.Host) s.respondWithStatus(w, r, localizer.T("redirect_domain_not_allowed"), makeCode(err), http.StatusBadRequest) return } if redir != "" { http.Redirect(w, r, redir, http.StatusFound) return } templ.Handler( web.Base(localizer.T("you_are_not_a_bot"), web.StaticHappy(localizer), s.policy.Impressum, localizer), ).ServeHTTP(w, r) } else { requestsProxied.WithLabelValues(r.Host).Inc() r = s.stripBasePrefixFromRequest(r) s.next.ServeHTTP(w, r) } } func (s *Server) signJWT(claims jwt.MapClaims) (string, error) { claims["iat"] = time.Now().Unix() claims["nbf"] = time.Now().Add(-1 * time.Minute).Unix() claims["exp"] = time.Now().Add(s.opts.CookieExpiration).Unix() if len(s.hs512Secret) == 0 { return jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims).SignedString(s.ed25519Priv) } else { return jwt.NewWithClaims(jwt.SigningMethodHS512, claims).SignedString(s.hs512Secret) } } ================================================ FILE: lib/http_test.go ================================================ package lib import ( "net/http" "net/http/httptest" "net/url" "testing" "github.com/TecharoHQ/anubis" "github.com/TecharoHQ/anubis/internal" "github.com/TecharoHQ/anubis/lib/policy" ) func TestSetCookie(t *testing.T) { for _, tt := range []struct { name string host string cookieName string options Options }{ { name: "basic", options: Options{}, host: "", cookieName: anubis.CookieName, }, { name: "domain techaro.lol", options: Options{CookieDomain: "techaro.lol"}, host: "", cookieName: anubis.CookieName, }, { name: "dynamic cookie domain", options: Options{CookieDynamicDomain: true}, host: "techaro.lol", cookieName: anubis.CookieName, }, } { t.Run(tt.name, func(t *testing.T) { srv := spawnAnubis(t, tt.options) rw := httptest.NewRecorder() srv.SetCookie(rw, CookieOpts{Value: "test", Host: tt.host}) resp := rw.Result() cookies := resp.Cookies() ckie := cookies[0] if ckie.Name != tt.cookieName { t.Errorf("wanted cookie named %q, got cookie named %q", tt.cookieName, ckie.Name) } }) } } func TestClearCookie(t *testing.T) { srv := spawnAnubis(t, Options{}) rw := httptest.NewRecorder() srv.ClearCookie(rw, CookieOpts{Host: "localhost"}) resp := rw.Result() cookies := resp.Cookies() if len(cookies) != 1 { t.Errorf("wanted 1 cookie, got %d cookies", len(cookies)) } ckie := cookies[0] if ckie.Name != anubis.CookieName { t.Errorf("wanted cookie named %q, got cookie named %q", anubis.CookieName, ckie.Name) } if ckie.MaxAge != -1 { t.Errorf("wanted cookie max age of -1, got: %d", ckie.MaxAge) } } func TestClearCookieWithDomain(t *testing.T) { srv := spawnAnubis(t, Options{CookieDomain: "techaro.lol"}) rw := httptest.NewRecorder() srv.ClearCookie(rw, CookieOpts{Host: "localhost"}) resp := rw.Result() cookies := resp.Cookies() if len(cookies) != 1 { t.Errorf("wanted 1 cookie, got %d cookies", len(cookies)) } ckie := cookies[0] if ckie.Name != anubis.CookieName { t.Errorf("wanted cookie named %q, got cookie named %q", anubis.CookieName, ckie.Name) } if ckie.MaxAge != -1 { t.Errorf("wanted cookie max age of -1, got: %d", ckie.MaxAge) } } func TestClearCookieWithDynamicDomain(t *testing.T) { srv := spawnAnubis(t, Options{CookieDynamicDomain: true}) rw := httptest.NewRecorder() srv.ClearCookie(rw, CookieOpts{Host: "subdomain.xeiaso.net"}) resp := rw.Result() cookies := resp.Cookies() if len(cookies) != 1 { t.Errorf("wanted 1 cookie, got %d cookies", len(cookies)) } ckie := cookies[0] if ckie.Name != anubis.CookieName { t.Errorf("wanted cookie named %q, got cookie named %q", anubis.CookieName, ckie.Name) } if ckie.Domain != "xeiaso.net" { t.Errorf("wanted cookie domain %q, got cookie domain %q", "xeiaso.net", ckie.Domain) } if ckie.MaxAge != -1 { t.Errorf("wanted cookie max age of -1, got: %d", ckie.MaxAge) } } func TestRenderIndexRedirect(t *testing.T) { s := &Server{ opts: Options{ PublicUrl: "https://anubis.example.com", }, } req := httptest.NewRequest("GET", "/", nil) req.Header.Set("X-Forwarded-Proto", "https") req.Header.Set("X-Forwarded-Host", "example.com") req.Header.Set("X-Forwarded-Uri", "/foo") rr := httptest.NewRecorder() s.RenderIndex(rr, req, policy.CheckResult{}, nil, true) if rr.Code != http.StatusTemporaryRedirect { t.Errorf("expected status %d, got %d", http.StatusTemporaryRedirect, rr.Code) } location := rr.Header().Get("Location") parsedURL, err := url.Parse(location) if err != nil { t.Fatalf("failed to parse location URL %q: %v", location, err) } scheme := "https" if parsedURL.Scheme != scheme { t.Errorf("expected scheme to be %q, got %q", scheme, parsedURL.Scheme) } host := "anubis.example.com" if parsedURL.Host != host { t.Errorf("expected url to be %q, got %q", host, parsedURL.Host) } redir := parsedURL.Query().Get("redir") expectedRedir := "https://example.com/foo" if redir != expectedRedir { t.Errorf("expected redir param to be %q, got %q", expectedRedir, redir) } } func TestRenderIndexUnauthorized(t *testing.T) { s := &Server{ opts: Options{ PublicUrl: "", }, } req := httptest.NewRequest("GET", "/", nil) rr := httptest.NewRecorder() s.RenderIndex(rr, req, policy.CheckResult{}, nil, true) if rr.Code != http.StatusUnauthorized { t.Errorf("expected status %d, got %d", http.StatusUnauthorized, rr.Code) } if body := rr.Body.String(); body != "Authorization required" { t.Errorf("expected body %q, got %q", "Authorization required", body) } } func TestNoCacheOnError(t *testing.T) { pol := loadPolicies(t, "testdata/useragent.yaml", 0) srv := spawnAnubis(t, Options{Policy: pol}) ts := httptest.NewServer(internal.RemoteXRealIP(true, "tcp", srv)) defer ts.Close() for userAgent, expectedCacheControl := range map[string]string{ "DENY": "no-store", "CHALLENGE": "no-store", "ALLOW": "", } { t.Run(userAgent, func(t *testing.T) { req, err := http.NewRequest(http.MethodGet, ts.URL, nil) if err != nil { t.Fatal(err) } req.Header.Set("User-Agent", userAgent) resp, err := ts.Client().Do(req) if err != nil { t.Fatal(err) } if resp.Header.Get("Cache-Control") != expectedCacheControl { t.Errorf("wanted Cache-Control header %q, got %q", expectedCacheControl, resp.Header.Get("Cache-Control")) } }) } } ================================================ FILE: lib/localization/locales/cs.json ================================================ { "loading": "Načítám...", "why_am_i_seeing": "Proč to vidím?", "protected_by": "Chráněno pomocí", "protected_from": "Od", "made_with": "Vytvořeno s ❤️ v 🇨🇦", "mascot_design": "Design maskota od", "ai_companies_explanation": "Vidíte to proto, že správce této webové stránky nastavil Anubis na ochranu serveru před pohromou AI společností, které agresivně stahují webové stránky. To může a skutečně způsobuje výpadky webových stránek, čímž se jejich zdroje stávají pro všechny nedostupnými.", "anubis_compromise": "Anubis je kompromis. Anubis používá schéma Proof-of-Work v duchu Hashcash, návrhu schématu proof-of-work pro snížení e-mailového spamu. Myšlenka je, že na individuálních úrovních je dodatečná zátěž zanedbatelná, ale na úrovni masového použití se sčítá a činí stahování mnohem dražším.", "hack_purpose": "V konečném důsledku se jedná o zástupné řešení, aby bylo možné věnovat více času otiskům prstů a identifikaci bezhlavých prohlížečů (např. podle toho, jak vykreslují písma), aby se stránka s důkazem práce nemusela zobrazovat uživatelům, kteří jsou mnohem pravděpodobněji legitimní.", "simplified_explanation": "Jedná se o opatření proti botům a škodlivým požadavkům podobné CAPTCHA. Místo toho, abyste museli pracovat sami, váš prohlížeč dostane výpočetní úkol, který musí vyřešit, aby se zajistilo, že je platným klientem. Tento koncept se nazývá Proof of Work. Úkol je vypočítán během několika sekund a získáte přístup na webovou stránku. Děkujeme za pochopení a trpělivost.", "jshelter_note": "Upozorňujeme, že Anubis vyžaduje použití moderních funkcí JavaScriptu, které rozšíření jako JShelter omezují. Prosím zakažte JShelter nebo jiná podobná rozšíření pro tuto doménu.", "version_info": "Tato webová stránka běží na Anubis verzi", "try_again": "Zkusit znovu", "go_home": "Přejít na úvodní stránku", "contact_webmaster": "nebo pokud si myslíte, že byste neměli být blokováni, kontaktujte správce na", "connection_security": "Prosím počkejte chvilku, zatímco zajišťujeme bezpečnost vašeho připojení.", "javascript_required": "Bohužel musíte povolit JavaScript, abyste prošli touto výzvou. To je vyžadováno proto, že AI společnosti změnily společenskou smlouvu ohledně toho, jak funguje hosting webových stránek. Řešení bez JavaScriptu je ve vývoji.", "benchmark_requires_js": "Spuštění testovacího nástroje vyžaduje povolení JavaScriptu.", "difficulty": "Obtížnost:", "algorithm": "Algoritmus:", "compare": "Porovnat:", "time": "Čas", "iters": "Iterace", "time_a": "Čas A", "iters_a": "Iterace A", "time_b": "Čas B", "iters_b": "Iterace B", "static_check_endpoint": "Toto je pouze kontrolní koncový bod pro přístup na tuto stránku.", "authorization_required": "Vyžadována autorizace", "cookies_disabled": "Váš prohlížeč je nakonfigurován tak, aby zakázal cookies. Anubis vyžaduje cookies pro zajištění, že jste platný klient. Prosím povolte cookies pro tuto doménu", "access_denied": "Přístup zamítnut: kód chyby", "dronebl_entry": "DroneBL nahlásil záznam", "see_dronebl_lookup": "viz", "internal_server_error": "Interní chyba serveru: správce špatně nakonfiguroval Anubis. Kontaktujte správce a požádejte ho, aby zkontroloval systémové záznamy.", "invalid_redirect": "Neplatné přesměrování", "redirect_not_parseable": "URL přesměrování nelze analyzovat", "redirect_domain_not_allowed": "Doména přesměrování není povolena", "failed_to_sign_jwt": "nepodařilo se podepsat JWT", "invalid_invocation": "Neplatné vyvolání MakeChallenge", "client_error_browser": "Chyba prohlížeče: Ujistěte se, že váš prohlížeč je aktuální a zkuste to později.", "oh_noes": "Jejda!", "benchmarking_anubis": "Testování Anubise!", "you_are_not_a_bot": "Nejste robot!", "making_sure_not_bot": "Ujišťujeme se, že nejste robot!", "celphase": "CELPHASE", "js_web_crypto_error": "Váš prohlížeč nepodporuje funkci web.crypto. Používáte zabezpečené připojení?", "js_web_workers_error": "Váš prohlížeč nepodporuje web workers (Anubis je používá, aby zabránil zamrznutí vašeho prohlížeče). Máte nainstalováno rozšíření JShelter nebo podobné?", "js_cookies_error": "Váš prohlížeč neukládá cookies. Anubis používá cookies k určení, kteří klienti prošli výzvami uložením podepsaného tokenu v cookie. Prosím povolte ukládání cookies pro tuto doménu. Názvy cookies, které Anubis ukládá, se mohou měnit bez upozornění. Názvy a hodnoty cookies nejsou součástí veřejného API.", "js_context_not_secure": "Vaše připojení není bezpečné!", "js_context_not_secure_msg": "Zkuste se připojit přes HTTPS nebo informujte správce o nastavení HTTPS. Pro více informací viz MDN.", "js_calculating": "Počítám...", "js_missing_feature": "Chybějící funkce", "js_challenge_error": "Chyba výzvy!", "js_challenge_error_msg": "Nepodařilo se vyřešit kontrolní algoritmus. Možná budete chtít obnovit stránku.", "js_calculating_difficulty": "Počítám...
Obtížnost:", "js_speed": "Rychlost:", "js_verification_longer": "Ověřování trvá déle, než se očekávalo. Prosím neobnovujte stránku.", "js_success": "Úspěch!", "js_done_took": "Hotovo! Trvalo to", "js_iterations": "iterací", "js_finished_reading": "Čtení dokončeno, pokračovat →", "js_calculation_error": "Chyba výpočtu!", "js_calculation_error_msg": "Nepodařilo se vypočítat výzvu:", "missing_required_forwarded_headers": "Chybějící požadované hlavičky X-Forwarded-*" } ================================================ FILE: lib/localization/locales/de.json ================================================ { "loading": "Ladevorgang...", "why_am_i_seeing": "Warum sehe ich diese Seite?", "protected_by": "Geschützt durch", "protected_from": "Von", "made_with": "Mit ❤️ entwickelt in 🇨🇦", "mascot_design": "Maskottchen erstellt von", "ai_companies_explanation": "Diese Seite wird angezeigt, da der Betreiber der Website Anubis eingerichtet hat, um sie vor aggressiven Webcrawlern von KI-Unternehmen zu schützen. Diese können Ausfälle verursachen, wodurch die Website für niemanden erreichbar ist.", "anubis_compromise": "Anubis stellt einen Kompromiss dar. Es verwendet eine Proof-of-Work-Methode nach dem Hashcash-Prinzip, das ursprünglich zur Bekämpfung von E-Mail-Spam entwickelt wurde. Die Idee dahinter: Für einen einzelnen Besucher ist die Verzögerung vernachlässigbar, aber massenhaftes Scraping wird dadurch aufwändig und teuer.", "hack_purpose": "Letztendlich ist dies eine Übergangslösung, um mehr Zeit für Browser-Fingerprinting und die Identifizierung von Headless-Browsern (z. B. anhand ihrer Schriftwiedergabe) zu gewinnen. So muss die Proof-of-Work-Seite nicht Nutzern angezeigt werden, die sehr wahrscheinlich legitim sind.", "simplified_explanation": "Dies ist eine Maßnahme gegen Bots und bösartige Anfragen, ähnlich einem CAPTCHA. Anstatt jedoch selbst arbeiten zu müssen, erhält dein Browser eine Rechenaufgabe, um sicherzustellen, dass es sich um einen gültigen Client handelt. Dieses Konzept nennt sich Proof of Work. Die Aufgabe wird in wenigen Sekunden berechnet und du erhältst Zugriff auf die Website. Danke für deine Geduld.", "jshelter_note": "Anubis benötigt moderne JavaScript-Features, die von Plugins wie JShelter deaktiviert werden. Bitte deaktiviere JShelter oder ähnliche Plugins für diese Domain.", "version_info": "Diese Website läuft mit Anubis-Version", "try_again": "Erneut versuchen", "go_home": "Zur Startseite", "contact_webmaster": "Falls du glaubst, dass es sich um einen Fehler handelt, kontaktiere bitte den Administrator unter", "connection_security": "Bitte warte einen Moment, während wir die Sicherheit deiner Verbindung prüfen.", "javascript_required": "Du musst JavaScript aktivieren, um diese Prüfung durchführen zu können. Dies ist notwendig, da KI-Unternehmen die bisherigen Regeln für das Hosting von Websites nicht mehr respektieren. Eine Lösung ohne JavaScript ist in Entwicklung.", "benchmark_requires_js": "Für die Nutzung des Benchmark-Tools muss JavaScript aktiviert sein.", "difficulty": "Schwierigkeit:", "algorithm": "Algorithmus:", "compare": "Vergleich:", "time": "Zeit", "iters": "Iterationen", "time_a": "Zeit A", "iters_a": "Iterationen A", "time_b": "Zeit B", "iters_b": "Iterationen B", "static_check_endpoint": "Dies ist ein Endpunkt zur Prüfung durch einen Reverse-Proxy.", "authorization_required": "Autorisierung erforderlich", "cookies_disabled": "Cookies sind in deinem Browser deaktiviert. Anubis benötigt Cookies, um sicherzustellen, dass es sich um einen legitimen Zugriff handelt. Bitte aktiviere Cookies für diese Domain.", "access_denied": "Zugriff verweigert – Fehlercode", "dronebl_entry": "Eintrag in DroneBL", "see_dronebl_lookup": "anzeigen", "internal_server_error": "Interner Serverfehler: Der Administrator hat Anubis fehlerhaft konfiguriert. Bitte kontaktiere den Administrator und bitte ihn, die Logs zu prüfen.", "invalid_redirect": "Ungültige Weiterleitung", "redirect_not_parseable": "Weiterleitungs-URL kann nicht verarbeitet werden", "redirect_domain_not_allowed": "Weiterleitungs-Domain nicht erlaubt", "missing_required_forwarded_headers": "Erforderliche X-Forwarded-*-Header fehlen", "failed_to_sign_jwt": "JWT konnte nicht signiert werden", "invalid_invocation": "Ungültiger Aufruf von MakeChallenge", "client_error_browser": "Client-Fehler: Bitte stelle sicher, dass dein Browser aktuell ist, und versuche es später erneut.", "oh_noes": "Oh nein!", "benchmarking_anubis": "Benchmark wird durchgeführt!", "you_are_not_a_bot": "Du bist kein Bot!", "making_sure_not_bot": "Dein Browser wird geprüft!", "celphase": "CELPHASE", "js_web_crypto_error": "Dein Browser verfügt nicht über ein funktionierendes web.crypto-Element. Wird eine sichere Verbindung verwendet?", "js_web_workers_error": "Dein Browser unterstützt keine Web-Worker (Anubis verwendet diese, damit der Browser nicht einfriert). Ist ein Plugin wie JShelter installiert?", "js_cookies_error": "Dein Browser speichert keine Cookies. Anubis verwendet Cookies, um nach bestandener Prüfung ein signiertes Token abzulegen. Bitte aktiviere Cookies für diese Domain. Die Cookie-Namen von Anubis können sich jederzeit ändern. Cookie-Namen und gespeicherte Werte sind nicht Teil der öffentlichen API.", "js_context_not_secure": "Diese Verbindung ist nicht sicher!", "js_context_not_secure_msg": "Bitte versuche, dich über HTTPS zu verbinden, oder weise den Administrator darauf hin, HTTPS einzurichten. Mehr Informationen: MDN.", "js_calculating": "Berechnung läuft...", "js_missing_feature": "Fehlendes Feature", "js_challenge_error": "Prüfung fehlgeschlagen!", "js_challenge_error_msg": "Der Prüf-Algorithmus konnte nicht geladen werden. Bitte lade die Seite neu.", "js_calculating_difficulty": "Berechnung läuft...
Schwierigkeit:", "js_speed": "Geschwindigkeit:", "js_verification_longer": "Die Prüfung dauert länger als erwartet. Bitte warte und lade die Seite nicht neu.", "js_success": "Erfolgreich!", "js_done_took": "Fertig! Dauer:", "js_iterations": "Iterationen", "js_finished_reading": "Fertig gelesen – weiter zur Seite →", "js_calculation_error": "Berechnungsfehler!", "js_calculation_error_msg": "Fehler bei der Berechnung der Prüfung:" } ================================================ FILE: lib/localization/locales/en.json ================================================ { "loading": "Loading...", "why_am_i_seeing": "Why am I seeing this?", "protected_by": "Protected by", "protected_from": "From", "made_with": "Made with ❤️ in 🇨🇦", "mascot_design": "Mascot design by", "ai_companies_explanation": "You are seeing this because the administrator of this website has set up Anubis to protect the server against the scourge of AI companies aggressively scraping websites. This can and does cause downtime for the websites, which makes their resources inaccessible for everyone.", "anubis_compromise": "Anubis is a compromise. Anubis uses a Proof-of-Work scheme in the vein of Hashcash, a proposed proof-of-work scheme for reducing email spam. The idea is that at individual scales the additional load is ignorable, but at mass scraper levels it adds up and makes scraping much more expensive.", "hack_purpose": "Ultimately, this is a 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.", "simplified_explanation": "This is a measure against bots and malicious requests similar to a CAPTCHA. However, instead of having to do work yourself, your browser is given a calculation task that it has to solve to ensure that it is a valid client. This concept is called Proof of Work. The task is calculated in a few seconds and you are granted access to the website. Thank you for your understanding and patience.", "jshelter_note": "Please note that Anubis requires the use of modern JavaScript features that plugins like JShelter will disable. Please disable JShelter or other such plugins for this domain.", "version_info": "This website is running Anubis version", "try_again": "Try again", "go_home": "Go home", "contact_webmaster": "or if you believe you should not be blocked, please contact the webmaster at", "connection_security": "Please wait a moment while we ensure the security of your connection.", "javascript_required": "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.", "benchmark_requires_js": "Running the benchmark tool requires JavaScript to be enabled.", "difficulty": "Difficulty:", "algorithm": "Algorithm:", "compare": "Compare:", "time": "Time", "iters": "Iters", "time_a": "Time A", "iters_a": "Iters A", "time_b": "Time B", "iters_b": "Iters B", "static_check_endpoint": "This is just a check endpoint for your reverse proxy to use.", "authorization_required": "Authorization required", "cookies_disabled": "Your browser is configured to disable cookies. Anubis requires cookies for the legitimate interest of making sure you are a valid client. Please enable cookies for this domain", "access_denied": "Access Denied: error code", "dronebl_entry": "DroneBL reported an entry", "see_dronebl_lookup": "see", "internal_server_error": "Internal Server Error: administrator has misconfigured Anubis. Please contact the administrator and ask them to look for the logs around", "invalid_redirect": "Invalid redirect", "redirect_not_parseable": "Redirect URL not parseable", "redirect_domain_not_allowed": "Redirect domain not allowed", "missing_required_forwarded_headers": "Missing required X-Forwarded-* headers", "failed_to_sign_jwt": "failed to sign JWT", "invalid_invocation": "Invalid invocation of MakeChallenge", "client_error_browser": "Client Error: Please ensure your browser is up to date and try again later.", "oh_noes": "Oh noes!", "benchmarking_anubis": "Benchmarking Anubis!", "you_are_not_a_bot": "You are not a bot!", "making_sure_not_bot": "Making sure you're not a bot!", "celphase": "CELPHASE", "js_web_crypto_error": "Your browser doesn't have a functioning web.crypto element. Are you viewing this over a secure context?", "js_web_workers_error": "Your browser doesn't support web workers (Anubis uses this to avoid freezing your browser). Do you have a plugin like JShelter installed?", "js_cookies_error": "Your browser doesn't store cookies. Anubis uses cookies to determine which clients have passed challenges by storing a signed token in a cookie. Please enable storing cookies for this domain. The names of the cookies Anubis stores may vary without notice. Cookie names and values are not part of the public API.", "js_context_not_secure": "Your context is not secure!", "js_context_not_secure_msg": "Try connecting over HTTPS or let the admin know to set up HTTPS. For more information, see MDN.", "js_calculating": "Calculating...", "js_missing_feature": "Missing feature", "js_challenge_error": "Challenge error!", "js_challenge_error_msg": "Failed to resolve check algorithm. You may want to reload the page.", "js_calculating_difficulty": "Calculating...
Difficulty:", "js_speed": "Speed:", "js_verification_longer": "Verification is taking longer than expected. Please do not refresh the page.", "js_success": "Success!", "js_done_took": "Done! Took", "js_iterations": "iterations", "js_finished_reading": "I've finished reading, continue →", "js_calculation_error": "Calculation error!", "js_calculation_error_msg": "Failed to calculate challenge:" } ================================================ FILE: lib/localization/locales/es.json ================================================ { "loading": "Cargando...", "why_am_i_seeing": "¿Por qué veo esto?", "protected_by": "Protegido por", "protected_from": "From", "made_with": "Hecho con ❤️ en 🇨🇦", "mascot_design": "Diseño de la mascota por", "ai_companies_explanation": "Ves esto porque el administrador de este sitio web ha configurado Anubis para proteger el servidor contra la plaga de empresas de IA que rastrean agresivamente los sitios web. Esto puede y causa tiempo de inactividad para los sitios web, haciendo que sus recursos sean inaccesibles para todos.", "anubis_compromise": "Anubis es un compromiso. Anubis utiliza un esquema de Prueba de Trabajo en la línea de Hashcash, un esquema de prueba de trabajo propuesto para reducir el spam por correo electrónico. La idea es que a escala individual, la carga adicional es insignificante, pero a escala de raspadores masivos, se acumula y hace que el raspado sea mucho más costoso.", "hack_purpose": "En última instancia, esta es una solución provisional para que se pueda dedicar más tiempo a la identificación y el reconocimiento de navegadores sin cabeza (por ejemplo, a través de cómo renderizan las fuentes) de modo que la página de prueba de trabajo del desafío no tenga que presentarse a usuarios que son mucho más propensos a ser legítimos.", "jshelter_note": "Ten en cuenta que Anubis requiere el uso de características modernas de JavaScript que plugins como JShelter deshabilitarán. Por favor, deshabilita JShelter u otros plugins similares para este dominio.", "version_info": "Este sitio web utiliza Anubis versión", "try_again": "Intentar de nuevo", "go_home": "Inicio", "contact_webmaster": "o si crees que no deberías estar bloqueado, por favor contacta al webmaster en", "connection_security": "Espere un momento mientras garantizamos la seguridad de su conexión.", "javascript_required": "Desafortunadamente, necesitas habilitar JavaScript para pasar este desafío. Esto es requerido porque las empresas de IA han cambiado el contrato social sobre cómo funciona el alojamiento de sitios web. Una solución sin JS está en desarrollo.", "benchmark_requires_js": "Ejecutar la herramienta de benchmark requiere que JavaScript esté habilitado.", "difficulty": "Dificultad:", "algorithm": "Algoritmo:", "compare": "Comparar:", "time": "Tiempo", "iters": "Iteraciones", "time_a": "Tiempo A", "iters_a": "Iter. A", "time_b": "Tiempo B", "iters_b": "Iter. B", "static_check_endpoint": "Este es solo un endpoint de verificación para que tu proxy inverso lo use.", "authorization_required": "Autorización requerida", "cookies_disabled": "Tu navegador está configurado para deshabilitar las cookies. Anubis requiere cookies para el interés legítimo de asegurar que eres un cliente válido. Por favor habilita las cookies para este dominio", "access_denied": "Acceso denegado: código de error", "dronebl_entry": "DroneBL reportó una entrada", "see_dronebl_lookup": "ver", "internal_server_error": "Error interno del servidor: el administrador ha configurado mal Anubis. Por favor contacta al administrador y pídele que revise los logs alrededor de", "invalid_redirect": "Redirección inválida", "redirect_not_parseable": "URL de redirección no analizable", "redirect_domain_not_allowed": "Dominio de redirección no permitido", "failed_to_sign_jwt": "falló al firmar JWT", "invalid_invocation": "Invocación inválida de MakeChallenge", "client_error_browser": "Error del cliente: Por favor asegúrate de que tu navegador esté actualizado e inténtalo de nuevo más tarde.", "oh_noes": "¡Oh no!", "benchmarking_anubis": "¡Benchmarking de Anubis!", "you_are_not_a_bot": "¡No eres un robot!", "making_sure_not_bot": "¡Asegurándonos de que no eres un robot!", "celphase": "CELPHASE", "js_web_crypto_error": "Tu navegador no tiene un elemento web.crypto funcional. ¿Estás viendo esta página en un contexto seguro?", "js_web_workers_error": "Tu navegador no soporta web workers (Anubis los usa para evitar bloquear tu navegador). ¿Tienes un plugin como JShelter instalado?", "js_cookies_error": "Tu navegador no almacena cookies. Anubis usa cookies para determinar qué clientes han pasado los desafíos almacenando un token firmado en una cookie. Por favor habilita el almacenamiento de cookies para este dominio. Los nombres de las cookies que Anubis almacena pueden variar sin previo aviso. Los nombres y valores de las cookies no son parte de la API pública.", "js_context_not_secure": "¡Tu contexto no es seguro!", "js_context_not_secure_msg": "Intenta conectarte a través de HTTPS o informa al administrador para configurar HTTPS. Para más información, consulta MDN.", "js_calculating": "Calculando...", "js_missing_feature": "Característica faltante", "js_challenge_error": "¡Error de desafío!", "js_challenge_error_msg": "Falló al resolver el algoritmo de verificación. Puedes intentar recargar la página.", "js_calculating_difficulty": "Calculando...
Dificultad:", "js_speed": "Velocidad:", "js_verification_longer": "La verificación está tomando más tiempo del esperado. Por favor no actualices la página.", "js_success": "¡Éxito!", "js_done_took": "¡Terminado! Tomó", "js_iterations": "iteraciones", "js_finished_reading": "He terminado de leer, continuar →", "js_calculation_error": "¡Error de cálculo!", "js_calculation_error_msg": "Falló al calcular el desafío:", "missing_required_forwarded_headers": "Faltan los encabezados X-Forwarded-* requeridos", "simplified_explanation": "Esta es una medida contra bots y solicitudes maliciosas similar a un CAPTCHA. Sin embargo, en lugar de tener que hacer el trabajo usted mismo, a su navegador se le asigna una tarea de cálculo que debe resolver para garantizar que es un cliente válido. Este concepto se llama Prueba de trabajo. La tarea se calcula en unos segundos y se le concede acceso al sitio web. Gracias por su comprensión y paciencia." } ================================================ FILE: lib/localization/locales/et.json ================================================ { "loading": "Laadin...", "why_am_i_seeing": "Miks ma pean seda nägema?", "protected_by": "Kaitseb", "protected_from": "From", "made_with": "Tehtud ❤️ga 🇨🇦s", "mascot_design": "Maskoti disainis", "ai_companies_explanation": "Seda näidatakse selle pärast, et selle lehe administraator on paigaldanud Anubise, et kaitsta serverit selle nuhtluse eest, mida kujutab endast AI firmade agressiivne veebikraapimine. Selle tagajärjeks võib olla ja tihti ongi see, et veebilehed lakkavad töötamast ja keegi ei saa nendele ligi.", "anubis_compromise": "Anubis on kompromisslahendus. Anubis kasutab nö. töötõendi skeemi, mille sarnane oli Hashcash, mis oli mõeldud spämmikaitseks. Põhimõte on selles, et üksiku kasutaja tasemel on lisakoormus tajumatu, aga massiivse kraapimise tasemel see koormus läheb kõik arvesse ja muudab andmete töötluse palju kallimaks.", "hack_purpose": "Lõppkokkuvõttes on see ajutine lahendus, et saaks rohkem aega kulutada peata brauserite (nt nende fondi renderdamise viisi kaudu) sõrmejälgede võtmisele ja tuvastamisele, nii et töö tõendamise lehte ei peaks esitama kasutajatele, kes on palju tõenäolisemalt legitiimsed.", "jshelter_note": "NB! Anubis vajab töötamiseks kaasaegseid JavaScripti võimalusi, mida teatud pluginad nagu JShelter ära keelavad. Palun lülita JShelter või teised sellised veebilehitseja laiendused välja.", "version_info": "Sellel lehel jookseb Anubis, versioon", "try_again": "Proovi uuesti", "go_home": "Mine koju", "contact_webmaster": "või kui sa arvad, et sa ei peaks olema blokeeritud, võta ühendust veebimeistriga aadressil", "connection_security": "Oota korraks, me kontrollime ühenduse turvalisust.", "javascript_required": "Kahjuks tuleb JavaScript sisse lülitada, et sellest kontrollist mööda pääseda. See on kohustuslik, sest AI ettevõtted on muutnud ühiskondlikke norme veebimajutuse suhtes. Ilma JavaScriptita töötav versioon on alles arendamisel.", "benchmark_requires_js": "Kiirustesti jaoks on vajalik JavaScript sisse lülitada.", "difficulty": "Raskus:", "algorithm": "Algoritm:", "compare": "Võrdle:", "time": "Aega", "iters": "Korda", "time_a": "A aega", "iters_a": "A korda", "time_b": "B aega", "iters_b": "B korda", "static_check_endpoint": "Seda lehte vaatab ainult sinu vaheserver.", "authorization_required": "Ligipääs puudub", "cookies_disabled": "Sinu brauseris on küpsised keelatud. Anubis vajab küpsiseid töötamiseks, et aru saada, kas sa oled päris kasutaja või mitte. Palun luba küpsised sellel domeenil", "access_denied": "Ligipääs keelatud: veakood", "dronebl_entry": "DroneBL tagastas sissekande", "see_dronebl_lookup": "vaata", "internal_server_error": "Programmi sisemine viga: administraator on Anubise valesti seadistanud. Võta temaga ühendust ja palu tal otsida logidest märksõna", "invalid_redirect": "Vigane ümbersuunamine", "redirect_not_parseable": "Ümbersuunamise URL on vigane", "redirect_domain_not_allowed": "Ümbersuunamise domeen pole lubatud", "failed_to_sign_jwt": "JWT allkirjastamine ebaõnnestus", "invalid_invocation": "MakeChallenge väljakutsumine on vigane", "client_error_browser": "Kliendipoolne viga: palun kontrolli, et su brauser oleks uuendatud ja proovi uuesti.", "oh_noes": "Oi ei!", "benchmarking_anubis": "Anubise kiirustest!", "you_are_not_a_bot": "Sina ei ole bott!", "making_sure_not_bot": "Kontrollime, et sa ei ole bott!", "celphase": "CELPHASE", "js_web_crypto_error": "Sinu brauseris ei ole töötavat web.crypto elementi. Kas sa avasid selle turvakontekstis?", "js_web_workers_error": "Sinu brauser ei toeta veebi taustaprotsesse (Anubis kasutab neid, et su veebilehitseja ei hanguks). Kas sul on installitud mingi laiendus nagu JShelter?", "js_cookies_error": "Sinu brauser ei salvesta küpsiseid. Anubis kirjutab küpsise, milles on allkirjastatud sedel, et vahet teha, millised kliendid on kontrolli läbinud ja millised mitte. Palun luba küpsiste salvestamine sellel domeenil. Küpsiste nimed, mida Anubis kasutab, võivad muutuda ette teatamata. Küpsiste nimed ja väärtused ei ole avaliku liidese osa.", "js_context_not_secure": "Sinu brauserikontekst ei ole turvaline!", "js_context_not_secure_msg": "Proovi ühendada HTTPS aadressiga või anna administraatorile teada, et HTTPS on vajalik seadistada. Lisainfot vaata MDNist.", "js_calculating": "Arvutan...", "js_missing_feature": "Puuduv brauseri omadus", "js_challenge_error": "Kontrolli viga!", "js_challenge_error_msg": "Ei suutnud tuvastada kontrollalgoritmi. Võiksid proovida lehe uuesti laadida.", "js_calculating_difficulty": "Arvutan...
Raskus:", "js_speed": "Kiirus:", "js_verification_longer": "Kontrollimine võtab kauem kui tavaliselt. Palun ära lae lehte uuesti.", "js_success": "Õnnestus!", "js_done_took": "Tehtud! Võttis", "js_iterations": "kordust", "js_finished_reading": "Lugesin ära, edasi →", "js_calculation_error": "Arvutamise viga!", "js_calculation_error_msg": "Ei suutnud kontrolli arvutada:", "missing_required_forwarded_headers": "Puuduvad nõutud X-Forwarded-* päised", "simplified_explanation": "See on meede robotite ja pahatahtlike päringute vastu, mis sarnaneb CAPTCHA-le. Kuid selle asemel, et peaksite ise tööd tegema, antakse teie brauserile arvutusülesanne, mille see peab lahendama, et tagada selle kehtivus kliendina. Seda kontseptsiooni nimetatakse Töötõendiks. Ülesanne arvutatakse mõne sekundiga ja teile antakse juurdepääs veebisaidile. Täname teid mõistva suhtumise ja kannatlikkuse eest." } ================================================ FILE: lib/localization/locales/fi.json ================================================ { "loading": "Ladataan...", "why_am_i_seeing": "Miksi näen tämän?", "protected_by": "Suojan tarjoaa", "protected_from": "tekijänä", "made_with": "❤️ tehty 🇨🇦:ssa", "mascot_design": "Maskotin suunnitellut", "ai_companies_explanation": "Sivustolla on käytössä Anubis. Anubis estää robotteja lataamasta sivustoa ylettömästi. Tämä voi aiheuttaa palvelimen ylikuormituksen, joka estää ketään pääsemästä sivustolle.", "anubis_compromise": "Anubis on kompromissi. Anubis käyttää roskapostin vähentämiseen ehdotettua, Hashcash-järjestelmän mukaista työnnäytettä. Yksittäiselle käyttäjälle kuormitus on mitätön, mutta kasvattaa sivuston ylettömän lataamisen kuluja huomattavasti.", "hack_purpose": "Viime kädessä tämä on paikkamerkkiratkaisu, jotta enemmän aikaa voidaan käyttää päättömien selainten sormenjälkien ottamiseen ja tunnistamiseen (esim. niiden fonttien renderöintitavan perusteella), jotta työnäytesivua ei tarvitse esittää käyttäjille, jotka ovat paljon todennäköisemmin laillisia.", "jshelter_note": "Anubis tarvitsee toimiakseen JavaScript-ominaisuuksia, jotka liitännäiset kuten jShelter estää. Otathan tällaiset liitännäiset pois käytöstä tälle verkkotunnukselle.", "version_info": "Sivusto käyttää Anubis versiota", "try_again": "Yritä uudelleen", "go_home": "Poistu", "contact_webmaster": "tai jos uskot ettei sinua tulisi estää, ota yhteyttä ylläpitäjään", "connection_security": "Odota hetki. Varmistamme yhteytesi tietoturvan.", "javascript_required": "Valitettavasti JavaScript on oltava käytössä tämän haasteen suorittamiseksi. Vaihtoehtoinen ratkaisu on työn alla.", "benchmark_requires_js": "JavaScript on oltava käytössä suorituskykytestin ajamiseksi.", "difficulty": "Vaikeus:", "algorithm": "Kaava:", "compare": "Vertailu:", "time": "Aika", "iters": "Toisto", "time_a": "Aika A", "iters_a": "Toisto A", "time_b": "Aika B", "iters_b": "Toisto B", "static_check_endpoint": "Tämä päätepiste on käyttämääsi käänteistä välityspalvelinta varten.", "authorization_required": "Valtuutus vaadittu", "cookies_disabled": "Selaimesi estää evästeet. Anubis tarvitsee evästeitä varmistaakseen, että olet todellinen käyttäjä. Otathan evästeet käyttöön tälle verkkotunnukselle", "access_denied": "Pääsy estetty: virhekoodi", "dronebl_entry": "DroneBL ilmoitti merkinnän", "see_dronebl_lookup": "katso", "internal_server_error": "Palvelinvirhe: Anubis on väärin määritetty. Pyydä ylläpitäjää tarkistamaan lokit", "invalid_redirect": "Virheellinen pyyntö", "redirect_not_parseable": "Uudellenohjauksen URL ei voitu jäsentää", "redirect_domain_not_allowed": "Uudelleenohjauksen verkkotunnus ei ole sallittu", "failed_to_sign_jwt": "JWT ei voitu allekirjoittaa", "invalid_invocation": "Virheellinen MakeChallenge-kaava", "client_error_browser": "Käyttäjävirhe: Varmista ettei selaimesi ole vanhentunut ja yritä uudelleen.", "oh_noes": "Voi ei!", "benchmarking_anubis": "Testataan Anubis!", "you_are_not_a_bot": "Et ole robotti!", "making_sure_not_bot": "Varmistetaan ettet ole robotti!", "celphase": "CELPHASE", "js_web_crypto_error": "Selaimesi web.crypto elementti ei toimi. Onko yhteytesi suojattu?", "js_web_workers_error": "Selaimesi ei tue Web Workers ominaisuutta. Anubis käyttää tätä estääkseen selaimesi lukkiutumisen. Onko sinulla liitännäinen, kuten jShelter käytössä?", "js_cookies_error": "Selaimesi ei tallenna evästeitä. Anubis tallentaa allekirjoitetun merkinnän evästeeseen, tunnistaakseen haasteen läpäisseet käyttäjät. Sallithan evästeiden tallentamisen tälle verkkotunnukselle. Tallennettujen evästeiden nimet voivat vaihdella. Evästeiden nimet ja arvot eivät ole osa julkista rajapintaa.", "js_context_not_secure": "Yhteytesi ei ole suojattu!", "js_context_not_secure_msg": "Yhdistä käyttäen HTTPS tai pyydä ylläpitäjää määrittämään HTTPS. Saadaksesi lisätietoja, katso MDN.", "js_calculating": "Lasketaan...", "js_missing_feature": "Puuttuva ominaisuus", "js_challenge_error": "Haastevirhe!", "js_challenge_error_msg": "Tarkistuskaavaa ei voitu ratkaista. Voit yrittää ladata sivua uudelleen.", "js_calculating_difficulty": "Lasketaan...
Vaikeus:", "js_speed": "Nopeus:", "js_verification_longer": "Vahvistus kestää odotettua pitempään. Ethän lataa sivua uudelleen.", "js_success": "Onnistui!", "js_done_took": "Valmis! Kesti", "js_iterations": "toistot", "js_finished_reading": "Luettu, jatka →", "js_calculation_error": "Laskentavirhe!", "js_calculation_error_msg": "Haasteen laskenta ei onnistunut:", "missing_required_forwarded_headers": "Puuttuvat vaaditut X-Forwarded-* otsikot", "simplified_explanation": "Tämä on toimenpide botteja ja haitallisia pyyntöjä vastaan, joka on samanlainen kuin CAPTCHA. Sen sijaan, että joutuisit tekemään työtä itse, selaimesi saa laskentatehtävän, joka sen on ratkaistava varmistaakseen, että se on kelvollinen asiakas. Tätä käsitettä kutsutaan nimellä Työtodistus. Tehtävä lasketaan muutamassa sekunnissa ja saat pääsyn verkkosivustolle. Kiitos ymmärryksestäsi ja kärsivällisyydestäsi." } ================================================ FILE: lib/localization/locales/fil.json ================================================ { "loading": "Naglo-load...", "why_am_i_seeing": "Bakit nakikita ko ito?", "protected_by": "Pinoprotekta ng", "protected_from": "mula sa", "made_with": "Ginawa na may ❤️ sa 🇨🇦", "mascot_design": "Disenyo ng Maskot ni/ng", "ai_companies_explanation": "Nakikita mo ito dahil ang tagapangasiwa ng website na ito ay nag-set up ng Anubis upang protektahan ang server laban sa salot ng mga kumpanya ng AI na aggresibong nagse-scrape ng mga website. Maaari nitong magdulot ng downtime para sa mga website, na gagawing hindi naa-access ang kanilang mga resource para sa lahat.", "anubis_compromise": "Isang kompromiso ang Anubis. Gumagamit ang Anubis ng isang Proof-of-Work na scheme sa ugat ng Hashcash, isang iminungkahing proof-of-work scheme upang mabawasan ang email spam. Ang ideya ay sa indibidwal na scale hindi napapansin ang karagdagang load, ngunit sa malaking antas ng pag-scrape nagkararagdag ito at ginagawang mas mahal ang pag-scrape.", "hack_purpose": "Sa huli, ito ay isang placeholder na solusyon upang mas maraming oras ang magugol sa pag-fingerprint at pagtukoy ng mga headless browser (hal: sa pamamagitan ng kung paano nila ginagawa ang pag-render ng font) upang hindi na kailangang iharap ang pahina ng patunay ng trabaho sa mga user na mas malamang na lehitimo.", "jshelter_note": "Pakitandaan na kinakailangan ng Anubis ang paggamit ng modernong JavaScript na feature na idi-disable ng mga plugin tulad ng JShelter. Mangyaring i-disable ang JShelter o ibang mga plugin para sa domain na ito.", "version_info": "Ang website na ito ay tumatakbo ng Anubis bersyon", "try_again": "Subukan muli", "go_home": "Bumalik sa panimula", "contact_webmaster": "o kung naniniwala ka na hindi ka dapat na-block, mangyaring makipag-ugnayan sa mga webmaster sa", "connection_security": "Mangyaring maghintay nang ilang sandali habang sinisigurado namin ang seguridad ng iyong koneksyon.", "javascript_required": "Nakalulungkot, ngunit kailangan mong paganahin ang JavaScript upang malampasan ang hamong ito. Ito ay kinakailangan dahil binago ng mga kumpanya ng AI ang social contract tungkol sa kung paano gumagana ang pagho-host ng website. Ang isang walang-JS na solusyon ay isang work-in-progress.", "benchmark_requires_js": "Kinakailangang naka-enable ang JavaScript upang patakbuhin ang benchmark tool.", "difficulty": "Kahirapan:", "algorithm": "Algoritmo:", "compare": "Kumpara:", "time": "Oras", "iters": "Mga Iterasyon", "time_a": "Time A", "iters_a": "Iters A", "time_b": "Time B", "iters_b": "Iters B", "static_check_endpoint": "Isa lang itong check endpoint para magamit ng iyong reverse proxy.", "authorization_required": "Kinakailangan ang pagpapatunay", "cookies_disabled": "Ang iyong browser ay na-configure upang hindi paganahin ang cookies. Kinakailangan ng Anubis ang cookies para sa lehitimong interes ng pagtiyak na ikaw ay isang wastong kliyente. Mangyaring paganahin ang cookies para sa domain na ito", "access_denied": "Tinanggihan ang Access: error code", "dronebl_entry": "Nag-ulat ang DroneBL ng entry", "see_dronebl_lookup": "tignan ang", "internal_server_error": "Internal Server Error: hindi na-configure nang mabuti ng tagapangasiwa ang Anubis. Makipag-ugnayan sa tagapangasiwa at sabihin sa kanila na tumingin sa mga log sa paligid ng", "invalid_redirect": "Hindi wastong redirect", "redirect_not_parseable": "Hindi ma-parse ang redirect URL", "redirect_domain_not_allowed": "Hindi pinapayagan ang redirect domain", "failed_to_sign_jwt": "nabigong ilagda ang JWT", "invalid_invocation": "Hindi wastong panawagan para sa MakeChallenge", "client_error_browser": "Error sa Kliyente: Pakitiyak na napapanahon ang iyong browser at subukang muli sa ibang pagkakataon.", "oh_noes": "Ay, naku!", "benchmarking_anubis": "Binebenchmark ang Anubis!", "you_are_not_a_bot": "Hindi ka isang bot!", "making_sure_not_bot": "Sinisigurado na hindi ka isang bot!", "celphase": "CELPHASE", "js_web_crypto_error": "Ang iyong browser ay walang gumaganang web.crypto element. Tinitingnan mo ba ito sa isang secure na konteksto?", "js_web_workers_error": "Hindi sinusuportahan ng iyong browser ang mga web worker (ginagamit ito ng Anubis upang maiwasan ang pag-freeze ng iyong browser). Mayroon ka bang naka-install na plugin tulad ng JShelter?", "js_cookies_error": "Ang iyong browser ay hindi nag-iimbak ng cookies. Gumagamit ang Anubis ng cookies upang matukoy kung aling mga kliyente ang nakapasa sa mga hamon sa pamamagitan ng pag-iimbak ng isang nilagdaang token sa isang cookie. Mangyaring paganahin ang pag-iimbak ng cookies para sa domain na ito. Ang mga pangalan ng cookies na iniimbak ng Anubis ay maaaring mag-iba nang walang abiso. Ang mga pangalan at value ng cookie ay hindi bahagi ng pampublikong API.", "js_context_not_secure": "Hindi secure ang iyong konteksto!", "js_context_not_secure_msg": "Subukang kumonekta sa pamamagitan ng HTTPS o sabihin sa admin na i-set up ang HTTPS. Para sa karagdagang impormasyon, tignan ang MDN.", "js_calculating": "Kinakalkula...", "js_missing_feature": "Nawawalang feature", "js_challenge_error": "Error sa hamon!", "js_challenge_error_msg": "Nabigong iresolba ang algoritmo ng pagsusuri. Baka gusto mong i-reload ang pahina.", "js_calculating_difficulty": "Kinakalkula...
Kahirapan:", "js_speed": "Bilis:", "js_verification_longer": "Mas tumatagal ang pag-verify kaysa sa inaasahan. Mangyaring huwag i-refresh ang pahina.", "js_success": "Matagumpay!", "js_done_took": "Tapos na! Nagtagal nang", "js_iterations": "mga iterasyon", "js_finished_reading": "Tapos na akong magbasa, magpatuloy →", "js_calculation_error": "Error sa pagkalkula!", "js_calculation_error_msg": "Nabigong ikalkula ang hamon:", "missing_required_forwarded_headers": "Nawawala ang kinakailangang X-Forwarded-* na mga header", "simplified_explanation": "Ito ay isang panukala laban sa mga bot at malisyosong mga kahilingan na katulad ng isang CAPTCHA. Gayunpaman, sa halip na ikaw mismo ang gumawa ng trabaho, binibigyan ang iyong browser ng isang gawain sa pagkalkula na kailangan nitong lutasin upang matiyak na ito ay isang wastong kliyente. Ang konseptong ito ay tinatawag na Proof of Work. Ang gawain ay kinakalkula sa loob ng ilang segundo at binibigyan ka ng access sa website. Salamat sa iyong pag-unawa at pasensya." } ================================================ FILE: lib/localization/locales/fr.json ================================================ { "loading": "Chargement...", "why_am_i_seeing": "Comment suis-je arrivé·e ici ?", "protected_by": "Protégé par", "protected_from": "de", "made_with": "Fait avec ❤️ au 🇨🇦", "mascot_design": "Design de la mascotte par", "ai_companies_explanation": "Vous voyez cette page car l'administrateur·rice de ce site Web a configuré Anubis pour protéger le serveur contre le fléau des entreprises d'IA qui récupèrent agressivement le contenu des sites Web. Cela perturbe leur fonctionnement et rend leurs ressources inaccessibles pour tout le monde.", "anubis_compromise": "Anubis est un compromis. Anubis utilise un procédé de preuve de travail similaire à Hashcash, un procédé de preuve de travail proposé pour réduire le spam par e-mail. L'idée est qu'à l'échelle individuelle, la charge supplémentaire est négligeable, mais à l'échelle des scrapers de masse, la charge s'accumule et le scraping devient beaucoup plus coûteux.", "hack_purpose": "En fin de compte, il s'agit d'une solution de substitution permettant de consacrer plus de temps à l'identification et à la prise d'empreintes des navigateurs headless (par exemple, en reconnaissant leur rendu des polices), pour que, à terme, la page de défi utilisant la preuve de travail n'ait plus besoin d'être présentée aux utilisateur·rices qui sont beaucoup plus susceptibles d'être légitimes.", "jshelter_note": "Veuillez noter qu'Anubis nécessite l'utilisation de fonctionnalités JavaScript modernes qui peuvent être désactivées par des plugins comme JShelter. Veuillez désactiver JShelter ou tout autre plugin similaire pour ce domaine.", "version_info": "Ce site Web utilise Anubis version", "try_again": "Réessayer", "go_home": "Accueil", "contact_webmaster": "ou si vous pensez que vous ne devriez pas être bloqué, veuillez contacter le webmaster à l'adresse", "connection_security": "Veuillez patienter un instant pendant que nous assurons la sécurité de votre connexion.", "javascript_required": "Malheureusement, vous devez activer JavaScript pour passer cette page de défi. Cette obligation est imposée par les entreprises d'IA, qui ont décidé de modifier unilatéralement les termes du contrat social régissant l'hébergement de sites Web. Une solution sans JavaScript est en cours de développement.", "benchmark_requires_js": "L'exécution de l'outil de benchmark nécessite l'activation de JavaScript.", "difficulty": "Difficulté :", "algorithm": "Algorithme :", "compare": "Comparer :", "time": "Temps", "iters": "Itérations", "time_a": "Temps A", "iters_a": "Itér. A", "time_b": "Temps B", "iters_b": "Itér. B", "static_check_endpoint": "Ceci est juste un point de terminaison de vérification à utiliser par votre proxy inverse.", "authorization_required": "Autorisation requise", "cookies_disabled": "Les cookies sont désactivés dans votre navigateur. Anubis a recours aux cookies pour l'intérêt légitime de s'assurer que vous êtes un client valide. Veuillez activer les cookies pour ce domaine.", "access_denied": "Accès refusé : code d'erreur", "dronebl_entry": "DroneBL a rapporté une entrée", "see_dronebl_lookup": "voir", "internal_server_error": "Erreur interne du serveur : l'administrateur·rice a mal configuré Anubis. Veuillez contacter l'administrateur·rice et lui demander de consulter les logs autour de", "invalid_redirect": "Redirection invalide", "redirect_not_parseable": "URL de redirection non analysable", "redirect_domain_not_allowed": "Domaine de redirection non autorisé", "failed_to_sign_jwt": "échec de la signature du JWT", "invalid_invocation": "Invocation invalide de MakeChallenge", "client_error_browser": "Erreur client : Veuillez vous assurer que votre navigateur est à jour et réessayez plus tard.", "oh_noes": "Oh non !", "benchmarking_anubis": "Je vérifie les performances d'Anubis !", "you_are_not_a_bot": "Vous n'êtes pas un robot !", "making_sure_not_bot": "Je m'assure que vous n'êtes pas un robot !", "celphase": "CELPHASE", "js_web_crypto_error": "L'élément web.crypto de votre navigateur n'est pas fonctionnel. Consultez-vous bien cette page dans un contexte sécurisé ?", "js_web_workers_error": "Votre navigateur ne prend pas en charge les web workers (Anubis les utilise pour éviter de bloquer votre navigateur). Avez-vous installé un plugin comme JShelter ?", "js_cookies_error": "Votre navigateur ne stocke pas les cookies. Anubis a recours aux cookies pour déterminer quels clients ont réussi les défis en stockant un jeton signé dans un cookie. Veuillez activer le stockage des cookies pour ce domaine. Le nom des cookies stockés par Anubis peut varier à tout moment. Le nom et la valeur des cookies ne font pas partie de l'API publique.", "js_context_not_secure": "Votre contexte n'est pas sécurisé !", "js_context_not_secure_msg": "Essayez de vous connecter via HTTPS ou demandez à l'administrateur·rice de configurer HTTPS. Pour plus d'informations, consultez MDN.", "js_calculating": "Calcul en cours...", "js_missing_feature": "Fonctionnalité manquante", "js_challenge_error": "Erreur de défi !", "js_challenge_error_msg": "Échec de la résolution de l'algorithme de vérification. Vous pouvez essayer de recharger la page.", "js_calculating_difficulty": "Calcul en cours...
Difficulté :", "js_speed": "Vitesse :", "js_verification_longer": "La vérification prend plus de temps que prévu. Veuillez ne pas actualiser la page.", "js_success": "Vérification réussie !", "js_done_took": "Terminé ! Cela aura nécessité", "js_iterations": "itérations", "js_finished_reading": "J'ai fini de lire, continuer →", "js_calculation_error": "Erreur de calcul !", "js_calculation_error_msg": "Échec du calcul du défi :", "missing_required_forwarded_headers": "En-têtes X-Forwarded-* manquants", "simplified_explanation": "Ceci est une mesure contre les robots et les requêtes malveillantes, similaire à un CAPTCHA. Cependant, au lieu d'avoir à faire le travail vous-même, votre navigateur se voit confier une tâche de calcul qu'il doit résoudre pour confirmer qu'il est un client valide. Ce concept est nommé Preuve de travail. La tâche s'effectue en quelques secondes, puis vous avez accès au site Web. Merci pour votre compréhension et votre patience." } ================================================ FILE: lib/localization/locales/is.json ================================================ { "loading": "Hleður...", "why_am_i_seeing": "Af hverju er ég að sjá þetta?", "protected_by": "Verndað með", "protected_from": "Frá", "made_with": "Gert í 🇨🇦 með ❤️", "mascot_design": "Lukkudýrið hannað af", "ai_companies_explanation": "Þú ert að sjá þetta vegna þess að kerfisstjóri þessa vefsvæðis hefur sett upp Anubis til að vernda vefþjóninn fyrir holskeflu beiðna frá svokölluðum gervigreindarfyrirtækjum sem samviskulaust eru að skrapa upplýsingar af vefsvæðum annarra. Þetta getur valdið og veldur töfum og truflunum á þessum vefsvæðum, sem aftur veldur því að efni þeirra verður öllum óaðgengilegt.", "anubis_compromise": "Anubis er millivegur. Anubis notar sönnun-á-vinnu (Proof-of-Work) skema í líkingu við Hashcash, sem er viðlíka skema til að minnka ruslpóst. Hugmyndin er að fyrir almennar heimsóknir verði viðbótarálagið vegna þessa ásættanlegt og valdi litlum truflunum, en við massaskröpun verði samlegðaráhrifin veruleg og geri slíka skröpun upplýsinga of dýra hvað varðar afköst og reiknigetu.", "hack_purpose": "Að lokum er þetta staðgengilslausn svo hægt sé að eyða meiri tíma í fingraför og auðkenningu höfuðlausra vafra (t.d. með því hvernig þeir birta leturgerðir) svo að áskorunarprófunarsíðan þurfi ekki að birtast notendum sem eru mun líklegri til að vera lögmætir.", "jshelter_note": "Athugaðu að Anubis krefst notkunar á ýmsum nútímalegum eiginleikum JavaScript sem viðbætur á borð við JShelter munu gera óvirka. Endilega gerðu JShelter eða álíka viðbætur óvirkar fyrir þetta lén.", "version_info": "Þetta vefsvæði er að keyra Anubis útgáfu", "try_again": "Prófaðu aftur", "go_home": "Farðu aftur heim til þín", "contact_webmaster": "eða ef þú heldur að ekki ætti að loka á þig, þá ættirðu að hafa samband við vefstjórann á", "connection_security": "Hinkraðu augnablik á meðan við tryggjum öryggi tengingarinnar þinnar.", "javascript_required": "Það er leiðinlegt, en þú verður að virkja JavaScript til að komast í gegnum þessa áskorun. Þetta er nauðsynlegt vegna þess að AI-fyrirtækin neita að fara eftir þeim samfélagslegu viðmiðum sem hafa mótað það hvernig vefhýsing virkar. Lausn sem ekki reiðir sig á JS er í vinnslu.", "benchmark_requires_js": "JavaScript þarf að vera virkt til að keyra afkastaprófunarkerfið.", "difficulty": "Erfiðleikastig:", "algorithm": "Reiknirit:", "compare": "Bera saman:", "time": "Tími", "iters": "Umferðir", "time_a": "Tími A", "iters_a": "Umferðir A", "time_b": "Tími B", "iters_b": "Umferðir B", "static_check_endpoint": "Þetta er bara endapunktur prófunar til notkunar fyrir öfuga milliþjóninn (reverse proxy) þinn.", "authorization_required": "Auðkenning nauðsynleg", "cookies_disabled": "Vafrinn þinn er stilltur á að gera vefkökur óvirkar. Anubis þarf að nota vefkökur í þeim tilgangi að tryggja að þú sért með leyfilegt forrit. Vinsamlega virkjaðu vefkökur fyrir þetta lén", "access_denied": "Aðgangi hafnað: villukóði", "dronebl_entry": "DroneBL tilkynnti færslu", "see_dronebl_lookup": "skoðaðu", "internal_server_error": "Innri villa á netþjóni: Kerfisstjóri hefur stillt Anubis rangt. Hafðu samband við kerfisstjóra og biddu þá um að skoða atvikaskrár sem tengjast þessu", "invalid_redirect": "Ógild endurbeining", "redirect_not_parseable": "Slóð endurbeiningar er ekki túlkanleg", "redirect_domain_not_allowed": "Lén endurbeiningar er ekki leyft", "failed_to_sign_jwt": "mistókst að undirrita JWT", "invalid_invocation": "Ógild kvaðning á MakeChallenge", "client_error_browser": "Villa í forriti: Gakktu úr skugga um að vafrinn þinn sé uppfærður í nýjustu útgáfu og prófaðu aftur síðar.", "oh_noes": "Æi nei!", "benchmarking_anubis": "Afkastaprófun Anubis!", "you_are_not_a_bot": "Þú ert ekki botti!", "making_sure_not_bot": "Geng úr skugga um að þú sért ekki botti!", "celphase": "CELPHASE", "js_web_crypto_error": "Vafrinn þinn er ekki með web.crypto einindi sem virkar. Ertu að skoða þetta í gegnum öruggt umhverfi?", "js_web_workers_error": "Vafrinn þinn styður ekki vefvaktara (web workers - Anubis notar þetta til að koma í veg fyrir að vafrinn frjósi). Ertu með viðbót á borð við JShelter uppsetta?", "js_cookies_error": "Vafrinn þinn geymir ekki vefkökur. Anubis notar vefkökur til að ákvarða hvaða biðlaraforrit hafi leyst áskoranir og geymir þá undirritað teikn í vefköku. Vinsamlega virkjaðu geymslu á vefkökum fyrir þetta lén. Nöfnin á þeim vefkökum sem Anubis geymir geta breyst fyrirvaralaust. Heiti vefkakna og gildi þeirra eru ekki hluti opinbera API-kerfisviðmótsins.", "js_context_not_secure": "Umhverfið þitt er ekki öruggt!", "js_context_not_secure_msg": "Prófaðu að tengjast í gegnum HTTPS eða láttu kerfisstjórann vita að hann þurfi að setja upp HTTPS. Fyrir nánari upplýsingar er hægt að skoða MDN.", "js_calculating": "Reikna...", "js_missing_feature": "Eiginleika vantar", "js_challenge_error": "Villa í áskorun!", "js_challenge_error_msg": "Mistókst að leysa reiknirit á prófunar. Þú gætir viljað endurlesa síðuna.", "js_calculating_difficulty": "Reikna...
Erfiðleikastig:", "js_speed": "Hraði:", "js_verification_longer": "Sannvottun tók lengri tíma en búast má við. Ekki endurlesa síðuna.", "js_success": "Tókst!", "js_done_took": "Klárt! Tók", "js_iterations": "umferðir", "js_finished_reading": "Ég hef lokið lestrinum, höldum áfram →", "js_calculation_error": "Reiknivilla!", "js_calculation_error_msg": "Mistókst að reikna áskorun:", "missing_required_forwarded_headers": "Vantar nauðsynleg X-Forwarded-* hausar", "simplified_explanation": "Þetta er ráðstöfun gegn vélmennum og illa meinandi beiðnum, sem virkar svipað og CAPTCHA-mennskupróf. Hins vegar; í stað þess að þurfa að vinna sjálfur, fær vafrinn þinn útreikningsverkefni sem hann þarf að leysa til að tryggja að hann sé gildur biðlari. Þetta hugtak er kallað Sönnun-á-vinnu. Verkefnið er reiknað á nokkrum sekúndum og þú færð aðgang að vefsíðunni. Takk fyrir skilninginn og þolinmæðina." } ================================================ FILE: lib/localization/locales/it.json ================================================ { "loading": "Caricamento...", "why_am_i_seeing": "Perché vedo questa schermata?", "protected_by": "Protetto da", "protected_from": "From", "made_with": "Realizzato con ❤️ in 🇨🇦", "mascot_design": "Mascotte disegnata da", "ai_companies_explanation": "Vedi questa schermata perché l'amministratore di questo sito web ha installato Anubis per proteggere il server dalla piaga delle aziende di AI generativa che estraggono, senza freno, dati dai siti web. Questo comportamento causa disservizi per i siti web, rendendoli inaccessibili a tutti.", "anubis_compromise": "Anubis è un compromesso. Anubis utilizza un meccanismo di proof-of-work in stile Hashcash, un meccanismo per ridurre le email di spam. L'idea è che, a livello individuale, il lavoro aggiuntivo necessario per la proof-of-work sia trascurabile, ma, a livello di grandi reti di bot, il lavoro si somma e diventa molto più costoso.", "hack_purpose": "In definitiva, questa è una soluzione provvisoria in modo che si possa dedicare più tempo all'identificazione e al rilevamento dei browser headless (ad esempio, tramite il modo in cui rendono i caratteri) in modo che la pagina di prova del lavoro non debba essere presentata agli utenti che sono molto più propensi a essere legittimi.", "jshelter_note": "Si noti che Anubis richiede l'utilizzo di caratteristiche moderne di JavaScript che alcuni plugin, come JShelter, disabilitano. Per accedere, disabilita JShelter (o altri plugin simili) per questo dominio.", "version_info": "Questo sito sta usando Anubis versione", "try_again": "Riprova", "go_home": "Vai alla home", "contact_webmaster": "o, se pensi di non dover essere bloccato, contatta l'amministratore a", "connection_security": "Un momento: stiamo controllando la sicurezza della tua connessione.", "javascript_required": "Purtroppo, devi abilitare Javascript per riuscire a superare questa pagina. Questa misura è necessaria perché alcune compagnie di AI hanno unilateralmente deciso di violare il contratto sociale sulla fornitura di siti web. Stiamo lavorando ad una soluzione che non richieda Javascript.", "benchmark_requires_js": "Per eseguire lo strumento di test, è necessario abilitare Javascript.", "difficulty": "Difficoltà:", "algorithm": "Algoritmo:", "compare": "Test:", "time": "Tempo", "iters": "Iterazioni", "time_a": "Tempo A", "iters_a": "Iterazioni A", "time_b": "Tempo B", "iters_b": "Iterazioni B", "static_check_endpoint": "Questo è solo un endpoint di test da utilizzare col reverse proxy.", "authorization_required": "Autorizzazione necessaria", "cookies_disabled": "Il tuo browser è configurato per disabilitare i cookies. Anubis richiede i cookie per accertarsi che tu sia un visitatore umano, ed è un legittimo interesse. Per favore, abilita i cookie per questo dominio.", "access_denied": "Accesso negato: errore", "dronebl_entry": "DroneBL ha riportato un record", "see_dronebl_lookup": "vedi", "internal_server_error": "Internal Server Error: Anubis non è configurato correttamente. Contattare l'amministratore e chiedergli di controllare i log attorno a", "invalid_redirect": "Reindirizzamento non valido", "redirect_not_parseable": "Errore di sintassi nel reindirizzamento", "redirect_domain_not_allowed": "Dominio non permesso per il reindirizzamento", "failed_to_sign_jwt": "Impossibile firmare JWT", "invalid_invocation": "Chiamata non valida a MakeChallenge", "client_error_browser": "Client Error: assicurati che il tuo browser sia aggiornato e riprova.", "oh_noes": "Oh no!", "benchmarking_anubis": "Testando Anubis!", "you_are_not_a_bot": "Non sei un robot!", "making_sure_not_bot": "Controllo se sei un robot...", "celphase": "CELPHASE", "js_web_crypto_error": "Il tuo browser non ha un elemento web.crypto funzionante. Stai utilizzando una connessione sicura?", "js_web_workers_error": "Il tuo browser non supporta web workers (Anubis li utilizza per evitare di rallentare il tuo browser). Hai installato un plugin come JShelter?", "js_cookies_error": "Il tuo browser non salva i cookie. Anubis utilizza i cookie per determinare quali client hanno superato la prova, salvando un identificativo firmato digitalmente in un cookie. Abilita il salvataggio dei cookie per questo dominio. Il nome del cookie salvato da Anubis potrebbe cambiare senza preavviso. I nomi dei cookie e il loro contenuto non fanno parte dell'API pubblica.", "js_context_not_secure": "La tua connessione non è sicura!", "js_context_not_secure_msg": "Prova a connetterti tramite HTTPS, o fallo abilitare dall'amministratore del sito. Per maggiori informazioni, vedi MDN.", "js_calculating": "Calcolo in corso...", "js_missing_feature": "Funzionalità mancante", "js_challenge_error": "Errore nel test!", "js_challenge_error_msg": "Impossibile trovare l'algoritmo di controllo. Ricarica la pagina.", "js_calculating_difficulty": "Calcolo in corso...
Difficoltà:", "js_speed": "Velocità:", "js_verification_longer": "La verifica sta richiedendo più tempo del previsto. Non aggiornare la pagina: attendere.", "js_success": "Successo!", "js_done_took": "Fatto! Sono state necessarie", "js_iterations": "iterazioni.", "js_finished_reading": "Ho finito di leggere, continua →", "js_calculation_error": "Errore nel calcolo!", "js_calculation_error_msg": "Impossibile superare il test:", "missing_required_forwarded_headers": "Mancano gli header X-Forwarded-* richiesti", "simplified_explanation": "Questa è una misura contro bot e richieste dannose simile a un CAPTCHA. Tuttavia, invece di dover lavorare tu stesso, al tuo browser viene assegnato un compito di calcolo che deve risolvere per garantire che sia un client valido. Questo concetto è chiamato Proof of Work. Il compito viene calcolato in pochi secondi e ti viene concesso l'accesso al sito web. Grazie per la tua comprensione e pazienza." } ================================================ FILE: lib/localization/locales/ja.json ================================================ { "loading": "ロード中...", "why_am_i_seeing": "なぜこれが表示されるのですか?", "protected_by": "Protected by", "protected_from": "From", "made_with": "Made with ❤️ 🇨🇦", "mascot_design": "Mascot design by", "ai_companies_explanation": "このメッセージが表示されているのは、このウェブサイトの管理者が、AI企業による過剰なウェブスクレイピングからサーバーを守るためにAnubisを導入しているためです。これにより、ウェブサイトがダウンし、すべての利用者がリソースにアクセスできなくなる事態が発生することがあります。", "anubis_compromise": "Anubisは妥協策です。AnubisはHashcashのようなProof-of-Work方式を採用しており、これは元々メールスパムを減らすために提案された仕組みです。個人レベルでは追加の負荷は無視できる程度ですが、大規模なスクレイピングでは負荷が積み重なり、スクレイピングのコストが大幅に増加します。", "hack_purpose": "最終的に、これはヘッドレスブラウザのフィンガープリントと識別に時間を費やすためのプレースホルダーソリューションです(例:フォントレンダリングの方法による)。これにより、正当なユーザーにはチャレンジのプルーフオブワークページを提示する必要がなくなります。", "jshelter_note": "Anubisは、JShelterのようなプラグインが無効化する最新のJavaScript機能を必要とします。このドメインではJShelterや同様のプラグインを無効にしてください。", "version_info": "このウェブサイトはAnubisバージョンで動作しています", "try_again": "再試行", "go_home": "ホームに戻る", "contact_webmaster": "もしブロックされるべきでないと思われる場合は、ウェブマスターにご連絡ください:", "connection_security": "接続の安全性を確認しています。しばらくお待ちください。", "javascript_required": "申し訳ありませんが、このチャレンジを通過するにはJavaScriptを有効にする必要があります。これはAI企業がウェブホスティングの社会的契約を変えてしまったためです。JavaScriptなしの解決策は現在開発中です。", "benchmark_requires_js": "ベンチマークツールを実行するにはJavaScriptを有効にする必要があります。", "difficulty": "難易度:", "algorithm": "アルゴリズム:", "compare": "比較:", "time": "時間", "iters": "イテレーション数", "time_a": "時間A", "iters_a": "イテレーションA", "time_b": "時間B", "iters_b": "イテレーションB", "static_check_endpoint": "これはリバースプロキシ用のチェックエンドポイントです。", "authorization_required": "認証が必要です", "cookies_disabled": "お使いのブラウザはCookieを無効にしています。Anubisは、あなたが正当なクライアントであることを確認するためにCookieを必要とします。このドメインでCookieを有効にしてください。", "access_denied": "アクセス拒否: エラーコード", "dronebl_entry": "DroneBLにエントリーが報告されました", "see_dronebl_lookup": "参照", "internal_server_error": "内部サーバーエラー: 管理者がAnubisの設定を誤っています。管理者に連絡し、次のログを確認するよう依頼してください:", "invalid_redirect": "無効なリダイレクト", "redirect_not_parseable": "リダイレクトURLを解析できません", "redirect_domain_not_allowed": "リダイレクトドメインは許可されていません", "failed_to_sign_jwt": "JWTの署名に失敗しました", "invalid_invocation": "MakeChallengeの無効な呼び出し", "client_error_browser": "クライアントエラー: ブラウザが最新であることを確認し、後でもう一度お試しください。", "oh_noes": "Oh noes!", "benchmarking_anubis": "Anubisのベンチマーク中!", "you_are_not_a_bot": "あなたはボットではありません!", "making_sure_not_bot": "あなたがボットでないことを確認しています!", "celphase": "CELPHASE", "js_web_crypto_error": "お使いのブラウザには正常に動作するweb.crypto要素がありません。安全なコンテキストで閲覧していますか?", "js_web_workers_error": "お使いのブラウザはWebワーカーをサポートしていません(Anubisはこれでブラウザのフリーズを防ぎます)。JShelterのようなプラグインを使用していませんか?", "js_cookies_error": "お使いのブラウザはCookieを保存しません。Anubisは、チャレンジを通過したクライアントを判別するために署名付きトークンをCookieに保存します。このドメインでCookieの保存を有効にしてください。Anubisが保存するCookie名は予告なく変更される場合があります。Cookie名や値は公開APIの一部ではありません。", "js_context_not_secure": "お使いのコンテキストは安全ではありません!", "js_context_not_secure_msg": "HTTPSで接続するか、管理者にHTTPSの設定を依頼してください。詳細はMDNをご覧ください。", "js_calculating": "計算中...", "js_missing_feature": "機能がありません", "js_challenge_error": "チャレンジエラー!", "js_challenge_error_msg": "チェックアルゴリズムの解決に失敗しました。ページを再読み込みしてください。", "js_calculating_difficulty": "計算中...
難易度:", "js_speed": "速度:", "js_verification_longer": "検証に予想以上の時間がかかっています。ページをリフレッシュしないでください。", "js_success": "成功!", "js_done_took": "完了!所要時間", "js_iterations": "イテレーション数", "js_finished_reading": "読み終わりました。続行 →", "js_calculation_error": "計算エラー!", "js_calculation_error_msg": "チャレンジの計算に失敗しました:", "missing_required_forwarded_headers": "必要な X-Forwarded-* ヘッダーがありません", "simplified_explanation": "これは、CAPTCHAと同様の、ボットや悪意のあるリクエストに対する対策です。ただし、自分で作業する代わりに、ブラウザに計算タスクが与えられ、それを解決して有効なクライアントであることを確認する必要があります。この概念はProof of Workと呼ばれます。タスクは数秒で計算され、ウェブサイトへのアクセスが許可されます。ご理解とご協力をお願いいたします。" } ================================================ FILE: lib/localization/locales/lt.json ================================================ { "loading": "Įkeliama...", "why_am_i_seeing": "Kodėl tai matau?", "protected_by": "Saugo", "protected_from": "iš", "made_with": "Sukurta 🇨🇦 su ❤️", "mascot_design": "Talismao dizainą sukūrė", "ai_companies_explanation": "Šią užsklandą matote, nes šią svetainę administruojantis asmuo įdiegė ir sukonfigūravo „Anubis“, siekdamas apsaugoti svetainę nuo DI kompanijų robotų, agresyviai siurbiančių visą svetainių turinį. Neretai toks elgesys sukelia svetainių veikimo trikdžius, todėl jos tampa nepasiekiamos niekam.", "anubis_compromise": "„Anubis“ – tai kompromisas. „Anubis“ naudoja „darbo įrodymo“ (angl. „Proof-of-Work“) metodą, panašų į „Hashcash“ – anksčiau siūlytą „darbo įrodymo“ principu pagrįstą apsaugą el. paštui. Šio sumanymo pagrindinė idėja paprasta: paprastiems lankytojams toks papildomas krūvis yra nežymus, tuo tarpu masiškai duomenis siurbiantiems robotams jis greitai pasijunta ir stipriai pabrangina siurbimą.", "hack_purpose": "Vis dėlto, šis metodas laikytinas tik laikinu tarpiniu sprendimu, suteikiančiu galimybę skirti daugiau laiko atrasti robotizuotų naršyklių ypatybėms (pavyzdžiui, šriftų atvaizdavimo savitumams), siekiant iššūkio „darbo įrodymu“ tinklalapį tiems naudotojams, kurie atrodo tikri, rodyti kuo rečiau.", "simplified_explanation": "Tai – priemonė prieš robotus ir piktybines užklausas, panaši į „CAPTCHA“. Tačiau šiuo atveju užduotį turite atlikti ne jūs, o jūsų naršyklė, kuriai išspręsti pateikiama matematinė užduotis. Šis metodas vadinamas „darbo įrodymu“ (angl. „Proof-of-work“). Naršyklė atsakymą paprastai apskaičiuoja per kelias sekundes, o tuomet jums suteikiama prieiga prie svetainės. Dėkojame jums už supratingumą ir kantrybę.", "jshelter_note": "Turėkite omenyje, jog „Anubis“ reikalauja šiuolaikinių „JavaScript“ funkcijų, kurias tam tikri naršyklių įskiepiai, pavyzdžiui, „JShelter“, gali atjungti. Norint naršyti šią svetainę, teks joje „JShelter“ ar kitus analogiškus įskiepius atjungti.", "version_info": "Šioje svetainėje veikia „Anubis“ versija", "try_again": "Bandyti dar kartą", "go_home": "Grįžkite į pradžią", "contact_webmaster": "arba, jei manote, jog esate blokuojami per klaidą, kreipkitės į svetainės administratorių adresu", "connection_security": "Prašom luktelėti, kol patikrinsime jūsų ryšio saugumą.", "javascript_required": "Deja, kad galėtumėte praeiti pro šią užsklandą, naršyklėje turėsite įjungti „JavaScript“. Tai reikalinga, nes DI produktus kuriančios įmonės visiškai nepaiso saityne nusistovėjusios naudojimosi svetainėmis tvarkos (etiketo). Sprendimas, kuriam nebūtinas įjungtas „JavaScript“, šiuo metu kuriamas.", "benchmark_requires_js": "Įvertinimo įrankiui būtina, kad naršyklėje būtų įjungtas „JavaScript“ palaikymas.", "difficulty": "Sudėtingumas:", "algorithm": "Algoritmas:", "compare": "Palyginti:", "time": "Laikas", "iters": "Iteracijos", "time_a": "Laikas A", "iters_a": "Iteracijos A", "time_b": "Laikas B", "iters_b": "Iteracijos B", "static_check_endpoint": "Tai – tik būsenos patikrinimo adresas, kurį gali naudoti jūsų atvirkštinis įgaliotasis serveris.", "authorization_required": "Būtinas leidimas", "cookies_disabled": "Jūsų naršyklė sukonfigūruota nepriimti slapukų. „Anubis“ veikimui – siekiant užtikrinti, jog jūs esate tikras asmuo, būtini funkciniai (teisėto intereso) slapukai. Prašom leisti slapukus šioje svetainėje", "access_denied": "Prieiga uždrausta: klaidos kodas", "dronebl_entry": "„DroneBL“ pranešė apie įrašą", "see_dronebl_lookup": "parodyti", "internal_server_error": "Saityno serverio klaida: administratorius netinkamai sukonfigūravo „Anubis“ užsklandą. Susisiekite su svetainės administratoriumi ir paprašykite, kad paskaitytų žurnalų įrašus", "invalid_redirect": "Netinkamas nukreipimas", "redirect_not_parseable": "Nukreipimo adreso nepavyko išanalizuoti", "redirect_domain_not_allowed": "Nukreipimo domenas neleistinas", "missing_required_forwarded_headers": "Trūksta būtinų „X-Forwarded-*“ antraščių", "failed_to_sign_jwt": "nepavyko pasirašyti JWT", "invalid_invocation": "Netinkamas kreipinys į „MakeChallenge“", "client_error_browser": "Problema klientinėje dalyje: įsitikinkite, jog jūsų naršyklė nepasenusi ir bandykite dar kartą.", "oh_noes": "O, ne!", "benchmarking_anubis": "Vertinama „Anubis“ sparta!", "you_are_not_a_bot": "Jūs nesate robotas!", "making_sure_not_bot": "Stengiamasi užtikrinti, jog jūs nesate robotas!", "celphase": "CELPHASE", "js_web_crypto_error": "Jūsų naršyklėje nėra funkcionalaus „web.crypto“ elemento. Ar jūs šį tinklalapį žiūrite iš saugaus konteksto?", "js_web_workers_error": "Jūsų naršyklė nepalaiko aptarnavimo scenarijų, kuriuos „Anubis“ naudoja išvengti naršyklės strigčių. Gal turite įdiegtą „JShelter“ ar panašų įskiepį?", "js_cookies_error": "Jūsų naršyklė nepriima slapukų. „Anubis“ iššūkį jau praėjusius lankytojus atskiria pagal prieigos raktą, kurį įrašo slapuke. Prašom įjungti slapukus šiai svetainei. „Anubis“ slapuko pavadinimas gali kisti be įspėjimo. Slapuko pavadinimas ir reikšmė nėra viešosios programavimo sąsajos dalis.", "js_context_not_secure": "Jūsų kontekstas nėra saugus!", "js_context_not_secure_msg": "Pabandykite prisijungti per HTTPS arba praneškite svetainės administratoriui, kad sukonfigūruotų HTTPS. Išsamesnės informacijos rasite MDN (anglų k.).", "js_calculating": "Skaičiuojama...", "js_missing_feature": "Trūksta funkcionalumo", "js_challenge_error": "Iššūkio klaida!", "js_challenge_error_msg": "Nepavyko nustatyti patikros algoritmo. Pamėginkite įkelti tinklalapį iš naujo.", "js_calculating_difficulty": "Skaičiuojama...
Sudėtingumas:", "js_speed": "Sparta:", "js_verification_longer": "Patikra užtrunka ilgiau nei įprasta. Neskubėkite įkelti šio tinklalapio iš naujo.", "js_success": "Sėkmė!", "js_done_took": "Baigta! Prireikė", "js_iterations": "iteracijų", "js_finished_reading": "Viską perskaičiau, tęskime →", "js_calculation_error": "Skaičiavimo klaida!", "js_calculation_error_msg": "Nepavyko įveikti iššūkio:", "missing_required_forwarded_headers": "Trūksta privalomų X-Forwarded-* antraščių" } ================================================ FILE: lib/localization/locales/manifest.json ================================================ { "supportedLanguages": [ "cs", "de", "en", "es", "et", "fi", "fil", "fr", "is", "it", "ja", "lt", "nb", "nl", "nn", "pl", "pt-BR", "ru", "tr", "uk", "vi", "zh-CN", "zh-TW", "sv" ] } ================================================ FILE: lib/localization/locales/nb.json ================================================ { "loading": "Laster inn...", "why_am_i_seeing": "Hvorfor ser jeg dette?", "protected_by": "Beskyttet av", "protected_from": "fra", "made_with": "Laget med ❤️ i 🇨🇦", "mascot_design": "Maskotdesign av", "ai_companies_explanation": "Du ser dette fordi administratoren av dette nettstedet har satt opp Anubis for å beskytte sørveren mot plagen av KI-selskaper som aggressivt skraper nettsteder. Dette kan, og fortsetter med å, forårsake driftstans for nettstedene, som gjør ressursene deres utilgjengelige for alle.", "anubis_compromise": "Anubis er et kompromiss. Anubis bruker et «Proof-of-Work»-skjema som ligner på Hashcash, et lignende skjema for å redusere søppel-e-post. Idéen er at ved småstilte tilfeller er den ytterligere belastningen ignorerbar, men ved storstilt skraping samler den på seg fart og gjør det å skrape mye mer dyrt.", "hack_purpose": "Til syvende og sist er dette en plassholderløsning slik at mer tid kan brukes på fingeravtrykk og identifisering av hodeløse nettlesere (f.eks. via hvordan de gjengir skrifttyper) slik at utfordringssiden for arbeidsprosessen ikke trenger å presenteres for brukere som er mye mer sannsynlig å være legitime.", "jshelter_note": "NB: Anubis krever bruk av moderne JavaScript-funksjoner som tillegg som JShelter slår av. Vennligst slå av JShelter eller lignende tillegg for dette domenet.", "version_info": "Dette nettstedet kjører Anubis-utgave", "try_again": "Prøv igjen", "go_home": "Gå hjem", "contact_webmaster": "eller om du synes at du ikke burde være blokkert, vennligst ta kontakt med administratoren på", "connection_security": "Vennligst vent mens vi bekrefter tryggheten av tilkoblingen din.", "javascript_required": "Du må dessverre slå på JavaScript for å komme deg forbi denne utfordringen. Dette kreves fordi KI-selskaper har endret sosialkontrakten om hvordan nettstedsverting fungerer. En ikke-JS-løsning er i gang med å skapes.", "benchmark_requires_js": "JavaScript må være påslått for å kjøre sammenligningsverktøyet.", "difficulty": "Vanskelighetsnivå:", "algorithm": "Algoritme:", "compare": "Jevnfør:", "time": "Tid", "iters": "Gjentakelser", "time_a": "Tid A", "iters_a": "Gjentakelser A", "time_b": "Tid B", "iters_b": "Gjentakelser B", "static_check_endpoint": "Dette er bare et sjekkeendepunkt for din omvendte proxy å bruke.", "authorization_required": "Legitimasjon kreves", "cookies_disabled": "Nettleseren din er konfigurert for å avslå informasjonskapsler. Anubis krever informasjonskapsler for å bekrefte at du er en ekte bruker. Vennligst slå på informasjonskapsler på dette domenet.", "access_denied": "Adgang nektet: feilkode", "dronebl_entry": "DroneBL rapporterte em oppføring.", "see_dronebl_lookup": "se", "internal_server_error": "Intern serverfeil: administratoren har feilkonfigurert Anubis. Vennligst ta kontakt med hen og spør hen om å se gjennom loggene om", "invalid_redirect": "Ugyldig omdirigering", "redirect_not_parseable": "Omdirigerings-URL-en kunne ikkj tolkes", "redirect_domain_not_allowed": "Omdirigeringsdomenet er ikke tillatt", "failed_to_sign_jwt": "mislyktes i å signere JWT", "invalid_invocation": "Ugyldig fremkalling av MakeChallenge", "client_error_browser": "Klientfeil: Vennligst sørg for at at nettleseren din er oppdatert og prøv igjen senere.", "oh_noes": "Å nei!", "benchmarking_anubis": "Sammenligner Anubis!", "you_are_not_a_bot": "Du er ikke en bot!", "making_sure_not_bot": "Bekrefter at du ikke er en bot!", "celphase": "CELPHASE", "js_web_crypto_error": "Nettleseren din har ikke et fungerande web.crypto-element. Ser du dette med ei sikker tilkopling?", "js_web_workers_error": "Nettleseren din støtter ikke nettarbeidere (Anubis bruker dette for å unngå å fryse nettleseren din). Har du et tillegg som JShelter installert?", "js_cookies_error": "Nettleseren lagrer ikke informasjonskapsler. Anubis bruker informasjonskapsler for å avgjøre hvilke klienter har lyktes i utfordringen ved å lagre en signert token i en informasjonskapsel. Vennligst slå på informasjonskapsler på dette domenet. Navnene på informasjonskapslene Anubis lagrer, kan variere uten varsel. Informasjonskapselnavn og -verdier er ikke en del av det offentlege API-et.", "js_context_not_secure": "Du bruker ikke en sikker tilkobling!", "js_context_not_secure_msg": "Prøv å koble til over HTTPS eller fortell administratoren å opprette HTTPS. Se MDN for mer informasjon.", "js_calculating": "Beregner…", "js_missing_feature": "Mangler funksjon", "js_challenge_error": "Utfordringsfeil!", "js_challenge_error_msg": "Mislyktes i å tolke sjekkalgoritmen. Du burde laste inn denne siden på nytt.", "js_calculating_difficulty": "Beregner…
Vanskelighetsnivå:", "js_speed": "hastighet:", "js_verification_longer": "Verifisering tar lengre enn forventet. Vennligst ikke last inn denne siden på nytt.", "js_success": "Vellykket!", "js_done_took": "Ferdig! Tok", "js_iterations": "gjentakelser", "js_finished_reading": "Jeg har sluttet å lese, fortsett →", "js_calculation_error": "Beregningsfeil!", "js_calculation_error_msg": "Mislyktes i å beregne utfordring:", "missing_required_forwarded_headers": "Mangler nødvendige X-Forwarded-* header", "simplified_explanation": "Dette er et tiltak mot roboter og ondsinnede forespørsler som ligner på en CAPTCHA. Men i stedet for å måtte gjøre arbeidet selv, får nettleseren din en beregningsoppgave som den må løse for å sikre at den er en gyldig klient. Dette konseptet kalles Proof of Work. Oppgaven beregnes på noen få sekunder, og du får tilgang til nettstedet. Takk for din forståelse og tålmodighet." } ================================================ FILE: lib/localization/locales/nl.json ================================================ { "loading": "Laden...", "why_am_i_seeing": "Waarom zie ik dit?", "protected_by": "Beschermd door", "protected_from": "Van", "made_with": "Gemaakt met ❤️ in 🇨🇦", "mascot_design": "Mascotte-ontwerp door", "ai_companies_explanation": "Je ziet dit omdat de beheerder van deze website Anubis heeft ingesteld om de server te beschermen tegen de plaag van AI-bedrijven die agressief websites scrapen. Dit kan downtime veroorzaken voor de websites, waardoor de website voor iedereen ontoegankelijk wordt.", "anubis_compromise": "Anubis is een compromis. Anubis gebruikt een proof-of-work-algoritme in de geest van Hashcash, een proof-of-work-algoritme voor het verminderen van e-mailspam. Het idee is dat voor individuen minimaal is, maar het voor scrapers veel duurder wordt.", "hack_purpose": "Uiteindelijk is dit een tijdelijke oplossing, zodat er meer tijd kan worden besteed aan het identificeren en herkennen van headless browsers (bijv. via de manier waarop ze lettertypen renderen), zodat de proof-of-work-pagina niet hoeft te worden gepresenteerd aan gebruikers die veel waarschijnlijker legitiem zijn.", "jshelter_note": "Anubis vereist het gebruik van moderne JavaScript-functies die worden uitgeschakeld door plugins zoals JShelter. Schakel JShelter of soortgelijke plugins uit voor dit domein.", "version_info": "Deze website draait op de Anubis-versie", "try_again": "Probeer opnieuw", "go_home": "Naar de hoofdpagine", "contact_webmaster": "of als je denkt dat je niet geblokkeerd had moeten worden, neem contact op met de webmaster op", "connection_security": "Wacht even terwijl we de veiligheid van je verbinding waarborgen.", "javascript_required": "Helaas moet je JavaScript inschakelen om voorbij deze uitdaging te komen. Dit is nodig omdat AI-bedrijven het sociale contract rond de werking van websitehosting hebben veranderd. Een oplossing zonder JavaScript is nog in ontwikkeling.", "benchmark_requires_js": "Voor het uitvoeren van de check moet JavaScript zijn ingeschakeld.", "difficulty": "Moeilijkheidsgraad:", "algorithm": "Algoritme:", "compare": "Vergelijken:", "time": "Tijd", "iters": "Iters", "time_a": "Tijd A", "iters_a": "Iters A", "time_b": "Tijd B", "iters_b": "Iters B", "static_check_endpoint": "Dit is gewoon een controle-eindpunt voor je reverse proxy om te gebruiken.", "authorization_required": "Autorisatie vereist", "cookies_disabled": "Cookies zijn uitgeschakeld in je browser. Anubis heeft cookies nodig om er zeker van te zijn dat je een echt persoon bent. Schakel cookies in voor dit domein", "access_denied": "Toegang geweigerd: foutcode", "dronebl_entry": "DroneBL meldde een item", "see_dronebl_lookup": "zie", "internal_server_error": "Interne Serverfout: beheerder heeft Anubis verkeerd geconfigureerd. Vraag de beheerder om de logs te bekijken.", "invalid_redirect": "Ongeldige omleiding", "redirect_not_parseable": "Redirect-URL kan niet verwerkt worden", "redirect_domain_not_allowed": "Redirect-domein niet toegestaan", "failed_to_sign_jwt": "JWT niet ondertekend", "invalid_invocation": "Ongeldige aanroep van MakeChallenge", "client_error_browser": "Fout bij client: Controleer of je browser bijgewerkt is en probeer het later opnieuw.", "oh_noes": "Oh nee-tjes!", "benchmarking_anubis": "Anubis benchmarken!", "you_are_not_a_bot": "Je bent geen bot!", "making_sure_not_bot": "Even checken of je een bot bent!", "celphase": "CELPHASE", "js_web_crypto_error": "Je browser heeft geen werkend web.crypto-element. Bekijkt u dit via een beveiligde context?", "js_web_workers_error": "Je browser ondersteunt geen web-takers (Anubis gebruikt dit om te voorkomen dat je browser bevriest). Heb je een plugin zoals JShelter geïnstalleerd?", "js_cookies_error": "Je browser slaat geen cookies op. Anubis gebruikt cookies om te bepalen welke bezoekers echte personen zijn. Schakel het opslaan van cookies voor dit domein in. De namen van de cookies die Anubis opslaat, kunnen in de toekomst veranderen. De namen en waarden van cookies maken geen deel uit van de openbare API.", "js_context_not_secure": "Je context is niet veilig!", "js_context_not_secure_msg": "Probeer verbinding te maken via HTTPS of laat de beheerder weten dat HTTPS moet worden ingesteld. Zie MDN voor meer informatie.", "js_calculating": "Berekenen...", "js_missing_feature": "Ontbrekende functie", "js_challenge_error": "Uitdagingsfout!", "js_challenge_error_msg": "De check is gefaald. Misschien wil je de pagina opnieuw laden.", "js_calculating_difficulty": "Rekenen...
Moeilijkheidsgraad:", "js_speed": "Snelheid:", "js_verification_longer": "Verificatie duurt langer dan verwacht. Ververs de pagina niet.", "js_success": "Gelukt!", "js_done_took": "Klaar! Nam", "js_iterations": "iteraties", "js_finished_reading": "Ik ben klaar met lezen, ga verder →", "js_calculation_error": "Rekenfout!", "js_calculation_error_msg": "Uitdaging niet berekend:", "missing_required_forwarded_headers": "Ontbrekende vereiste X-Forwarded-* headers", "simplified_explanation": "Dit is een maatregel tegen bots en kwaadwillende verzoeken, vergelijkbaar met een CAPTCHA. In plaats van dat je zelf werk moet verrichten, krijgt je browser een rekentaak die moet worden opgelost om ervoor te zorgen dat het een geldige client is. Dit concept wordt Proof of Work genoemd. De taak wordt in een paar seconden berekend en u krijgt toegang tot de website. Bedankt voor je begrip en geduld." } ================================================ FILE: lib/localization/locales/nn.json ================================================ { "loading": "Lastar inn...", "why_am_i_seeing": "Kvifor ser eg dette?", "protected_by": "Verna av", "protected_from": "frå", "made_with": "Laga med ❤️ i 🇨🇦", "mascot_design": "Maskotdesign av", "ai_companies_explanation": "Du ser dette av di administratoren av denne netstaden har sett opp Anubis for å verna tenaren mot plaga av KI-selskap som aggressivt skrapar netstader. Dette kan, og held fram med å, forårsaka driftstans for netstadene, som gjer ressursane deira utilgjengelege for alle.", "anubis_compromise": "Anubis er eit kompromiss. Anubis nøyter eit «Proof-of-Work»-skjema som liknar på Hashcash, eit liknande skjema for å filtrera bort søppel-e-post. Idéen er at i små meng kan den ytterlegare lastinga lett ignorerast, men ved storslegen skraping vert byrda større og større og gjer det å skrapa mykje meir dyrt.", "hack_purpose": "Til sjuande og sist er dette ei plasshaldarløysing slik at meir tid kan verta nøytt på å fingeravtrykkja og identifisera hovudlause netlesarar (t.d. via korleis dei attgjev skrifttypar) slik at utfordringssida for arbeidsprosessen ikkje treng å synast for brukarar som er nok legitime.", "jshelter_note": "NB: Anubis krev bruk av moderne JavaScript-funksjonar som tillegg som JShelter slår av. Venlegast slå av JShelter eller liknande tillegg for dette domenet.", "version_info": "Denne netstaden køyrer Anubis-utgåve", "try_again": "Prøv att", "go_home": "Far heim", "contact_webmaster": "eller om du tykkjer at du ikkje burde vera blokkert, venlegast tak kontakt med administratoren på", "connection_security": "Venlegast venta medan vi stadfester tryggleiken av tilkoplinga di.", "javascript_required": "Du lyt diverre slå på JavaScript for å koma deg forbi denne utfordringa. Dette krevst fordi KI-selskap har endra sosialkontrakten om korleis netstadsverting fungerer. Ei ikkje-JS-løysing er i gang med å verta skapt.", "benchmark_requires_js": "JavaScript må vera slegen på for å køyra samanlikningsverktøyet.", "difficulty": "Vanskenivå:", "algorithm": "Algoritme:", "compare": "Jamfør:", "time": "Tid", "iters": "Oppattakingar", "time_a": "Tid A", "iters_a": "Oppattakingar A", "time_b": "Tid B", "iters_b": "Oppattakingar B", "static_check_endpoint": "Dette er berre eit sjekkeendepunkt for din omvende proxy å nøyta.", "authorization_required": "Legitimering krevst", "cookies_disabled": "Netlesaren din er konfigurert for å avslå informasjonskapslar. Anubis krev informasjonskapslar for å stadfesta at du er ein ekte brukar. Venlegast slå på informasjonskapslar på dette domenet.", "access_denied": "Tilgang nekta: feilkode", "dronebl_entry": "DroneBL rapporterte ei oppføring.", "see_dronebl_lookup": "sjå", "internal_server_error": "Intern serverfeil: administratoren har feilkonfigurert Anubis. Venlegast tak kontakt med hen og spør hen om å sjå gjennom loggane om", "invalid_redirect": "Ugyldig omdirigering", "redirect_not_parseable": "Omdirigerings-URL-en kunne ikkje tolkast", "redirect_domain_not_allowed": "Omdirigeringsdomenet er ikkje tillate", "failed_to_sign_jwt": "mislukkast i å signera JWT", "invalid_invocation": "Ugyldig framkalling av MakeChallenge", "client_error_browser": "Klientfeil: Venlegast stadfest at netlesaren din er oppdatert og prøv att seinare.", "oh_noes": "Å nei!", "benchmarking_anubis": "Samanliknar Anubis!", "you_are_not_a_bot": "Du er ikkje ein bot!", "making_sure_not_bot": "Stadfester at du ikkje er ein bot!", "celphase": "CELPHASE", "js_web_crypto_error": "Netlesaren din har ikkje eit fungerande web.crypto-element. Ser du dette med ei sikker tilkopling?", "js_web_workers_error": "Netlesaren din stør ikkje netarbeidarar (Anubis nøyter dei for å undangå å frysa netlesaren din). Har du eit tillegg som JShelter installert?", "js_cookies_error": "Netlesaren lagrar ikkje informasjonskapslar. Anubis nøyter informasjonskapslar for å avgjera kva klientar har lukkast i utfordringa ved å lagra ein signert lykel i ein informasjonskapsel. Venlegast slå på informasjonskapslar på dette domenet. Namna på informasjonskapslane Anubis lagrar, kan ymsa utan varsel. Informasjonskapselnamn og -verdiar er ikkje ein del av det offentlege API-et.", "js_context_not_secure": "Du nøyter ikkje ei sikker tilkopling!", "js_context_not_secure_msg": "Prøv å kopla til over HTTPS eller fortel administratoren å oppretta HTTPS. Sjå MDN for fleire opplysingar.", "js_calculating": "Reknar…", "js_missing_feature": "Manglar funksjon", "js_challenge_error": "Utfordringsfeil!", "js_challenge_error_msg": "Mislukkast i å tolka sjekkalgoritmen. Du burde lasta inn denne sida på nytt.", "js_calculating_difficulty": "Reknar…
Vanskenivå:", "js_speed": "fart:", "js_verification_longer": "Verifisering tek lenger enn venta. Venlegast ikkje last inn denne sida på nytt.", "js_success": "Vellukka!", "js_done_took": "Ferdig! Tok", "js_iterations": "oppattakingar", "js_finished_reading": "Eg har slutta å lesa, hald fram →", "js_calculation_error": "Rekningsfeil!", "js_calculation_error_msg": "Mislukkast i å rekna utfordring:", "missing_required_forwarded_headers": "Vantande naudsynte «X-Forwarded-*»-overskrifter", "simplified_explanation": "Dette er eit tiltak mot robotar og ondsinna førespurnader som liknar på ein CAPTCHA. Men i staden for å måtte gjera arbeidet sjølv, får netlesaren din ei utrekningsoppgåve som han må løysa for å stadfesta at han er ein gyldig klient. Dette konseptet vert kalla arbeidsstadfesting. Oppgåva vert rekna ut på nokre få sekund, og du får tilgang til nettstaden. Takk for forståinga di og tolmodet ditt." } ================================================ FILE: lib/localization/locales/pl.json ================================================ { "loading": "Ładowanie...", "why_am_i_seeing": "Dlaczego to widzę?", "protected_by": "Chronione przez", "protected_from": "Przed", "made_with": "Stworzone z ❤️ w 🇨🇦", "mascot_design": "Projekt maskotki:", "ai_companies_explanation": "Widzisz to, ponieważ administrator tej strony skonfigurował Anubisa, aby chronić serwer przed masowym skanowaniem treści przez firmy tworzące AI. Powoduje to obciążenie i przestoje, przez co zasoby strony stają się niedostępne dla wszystkich.", "anubis_compromise": "Anubis jest kompromisem. Używa mechanizmu Proof-of-Work w stylu Hashcash — proponowanego systemu ograniczania spamu e-mail. Pomysł polega na tym, że dla indywidualnych użytkowników dodatkowe obciążenie jest niezauważalne, ale w skali masowego skanowania koszt szybko rośnie.", "hack_purpose": "Docelowo jest to rozwiązanie tymczasowe, aby zyskać czas na ulepszenie metod identyfikacji przeglądarek bez interfejsu graficznego (np. poprzez analizę renderowania czcionek), by w przyszłości nie musieć wyświetlać strony z zadaniem Proof-of-Work użytkownikom, którzy najprawdopodobniej są prawidłowi.", "simplified_explanation": "To zabezpieczenie przed botami i złośliwymi żądaniami, podobne do CAPTCHA. Jednak zamiast wykonywać zadanie samodzielnie, przeglądarka otrzymuje obliczenie do wykonania, aby potwierdzić, że jest prawidłowym klientem. Ten mechanizm to Proof of Work. Zadanie trwa kilka sekund i uzyskujesz dostęp do strony. Dziękujemy za cierpliwość.", "jshelter_note": "Uwaga: Anubis wymaga nowoczesnych funkcji JavaScript, które wtyczki typu JShelter mogą blokować. Wyłącz JShelter lub podobne dodatki dla tej domeny.", "version_info": "Ta strona działa na Anubis w wersji", "try_again": "Spróbuj ponownie", "go_home": "Wróć na stronę główną", "contact_webmaster": "lub jeśli uważasz, że nie powinieneś być blokowany, skontaktuj się z administratorem pod adresem", "connection_security": "Poczekaj chwilę, sprawdzamy bezpieczeństwo Twojego połączenia.", "javascript_required": "Niestety, aby przejść tę próbę, musisz włączyć obsługę JavaScript. Jest to konieczne, ponieważ firmy zajmujące się sztuczną inteligencją zmieniły umowę społeczną dotyczącą funkcjonowania hostingu stron internetowych. Rozwiązanie bez obsługi JavaScript jest w trakcie opracowywania.", "benchmark_requires_js": "Uruchomienie narzędzia testowego wymaga włączonego JavaScript.", "difficulty": "Trudność:", "algorithm": "Algorytm:", "compare": "Porównaj:", "time": "Czas", "iters": "Iteracje", "time_a": "Czas A", "iters_a": "Iteracje A", "time_b": "Czas B", "iters_b": "Iteracje B", "static_check_endpoint": "To jedynie punkt kontrolny do użytku przez Twój reverse proxy.", "authorization_required": "Wymagane uwierzytelnienie", "cookies_disabled": "Twoja przeglądarka blokuje ciasteczka. Anubis wymaga ich, aby potwierdzić, że jesteś prawidłowym klientem. Włącz ciasteczka dla tej domeny.", "access_denied": "Brak dostępu: kod błędu", "dronebl_entry": "DroneBL zgłosił wpis", "see_dronebl_lookup": "zobacz", "internal_server_error": "Błąd wewnętrzny serwera: administrator błędnie skonfigurował Anubis. Skontaktuj się z administratorem i poproś o sprawdzenie logów", "invalid_redirect": "Nieprawidłowe przekierowanie", "redirect_not_parseable": "Nie można odczytać adresu przekierowania", "redirect_domain_not_allowed": "Domena przekierowania niedozwolona", "missing_required_forwarded_headers": "Brak wymaganych nagłówków X-Forwarded-*", "failed_to_sign_jwt": "Nie udało się podpisać JWT", "invalid_invocation": "Nieprawidłowe wywołanie MakeChallenge", "client_error_browser": "Błąd klienta: upewnij się, że Twoja przeglądarka jest aktualna i spróbuj ponownie później.", "oh_noes": "O nie!", "benchmarking_anubis": "Testowanie wydajności Anubis!", "you_are_not_a_bot": "Nie jesteś botem!", "making_sure_not_bot": "Sprawdzamy, czy nie jesteś botem!", "celphase": "CELPHASE", "js_web_crypto_error": "Twoja przeglądarka nie obsługuje web.crypto. Czy korzystasz z bezpiecznego połączenia?", "js_web_workers_error": "Twoja przeglądarka nie obsługuje web workers (Anubis ich używa, by nie zawieszać przeglądarki). Czy masz zainstalowaną wtyczkę typu JShelter?", "js_cookies_error": "Twoja przeglądarka nie zapisuje ciasteczek. Anubis używa ich do przechowywania podpisanego tokenu potwierdzającego przejście zabezpieczenia. Włącz zapis ciasteczek dla tej domeny. Nazwy ciasteczek mogą zmieniać się bez zapowiedzi. Nazwy oraz zawartość ciasteczek nie są cześcią publicznego API.", "js_context_not_secure": "Kontekst nie jest bezpieczny!", "js_context_not_secure_msg": "Spróbuj połączyć się przez HTTPS lub poinformuj administratora, by skonfigurował HTTPS. Więcej informacji na MDN.", "js_calculating": "Obliczanie...", "js_missing_feature": "Brakująca funkcja", "js_challenge_error": "Błąd wyzwania!", "js_challenge_error_msg": "Nie udało się ustalić algorytmu sprawdzającego. Możesz spróbować odświeżyć stronę.", "js_calculating_difficulty": "Obliczanie...
Trudność:", "js_speed": "Prędkość:", "js_verification_longer": "Weryfikacja trwa dłużej niż zwykle. Proszę nie odświeżać strony.", "js_success": "Sukces!", "js_done_took": "Gotowe! Zajęło to", "js_iterations": "iteracji", "js_finished_reading": "Skończyłem czytać, kontynuuj →", "js_calculation_error": "Błąd obliczeń!", "js_calculation_error_msg": "Nie udało się obliczyć zadania:" } ================================================ FILE: lib/localization/locales/pt-BR.json ================================================ { "loading": "Carregando...", "why_am_i_seeing": "Por que estou vendo isso?", "protected_by": "Protegido por", "protected_from": "de", "made_with": "Feito com ❤️ no Canadá", "mascot_design": "Design do mascote por", "ai_companies_explanation": "Você está vendo isso porque o administrador deste site configurou Anubis para proteger o servidor contra a praga de empresas de IA que realizam captura agressiva dos dados de páginas da Web. Isso pode causar, e de fato causa, indisponibilidade nos sites, o que os torna inacessíveis para todos.", "anubis_compromise": "O Anubis é um meio-termo. Ele utiliza um mecanismo de prova de validação semelhante ao Hashcash, proposto para reduzir spam de e-mail. A ideia é que, para acessos individuais, a carga adicional seja insignificante, mas acessos para captura em massa, ela se acumula e torna esse processo muito mais oneroso.", "hack_purpose": "Em última análise, esta é uma solução provisória para que mais tempo possa ser gasto na identificação e reconhecimento de navegadores sem interface (por exemplo, através de como eles renderizam fontes), para que a página de prova de trabalho do desafio não precise ser apresentada a usuários que são muito mais propensos a serem legítimos.", "jshelter_note": "Lembrando que o Anubis requer o uso de recursos JavaScript modernos, que plugins como o JShelter desabilitarão. Desabilite o JShelter ou similares para este domínio.", "version_info": "Este site está usando o Anubis versão", "try_again": "Tente novamente", "go_home": "Início", "contact_webmaster": "ou se você acredita que não deveria estar bloqueado, contate o administrador em", "connection_security": "Por favor, aguarde um momento enquanto nós garantimos a segurança de sua conexão.", "javascript_required": "Infelizmente, você deve habilitar JavaScript para passar por esta validação. Isso é necessário porque empresas de IA alteraram o contrato social sobre como a hospedagem de sites funciona. Uma solução não dependente de JavaScript ainda está sendo desenvolvida.", "benchmark_requires_js": "Para executar a ferramenta de benchmark, é necessário que o JavaScript esteja habilitado.", "difficulty": "Dificuldade:", "algorithm": "Algoritmo:", "compare": "Comparar:", "time": "Tempo", "iters": "Iteração", "time_a": "Tempo A", "iters_a": "Iteração A", "time_b": "Tempo B", "iters_b": "Iteração B", "static_check_endpoint": "Este é apenas um ponto de verificação para seu proxy reverso usar.", "authorization_required": "Autorização necessária", "cookies_disabled": "Seu navegador está configurado para desabilitar cookies. O Anubis requer cookies somente com o interesse de garantir que você seja um cliente válido. Por favor, habilite os cookies para este domínio.", "access_denied": "Acesso negado: código de erro", "dronebl_entry": "DroneBL relatou uma entrada", "see_dronebl_lookup": "consulte", "internal_server_error": "Erro interno do servidor: o administrador configurou incorretamente o Anubis. Entre em contato com o administrador e peça para analisar os logs relacionados.", "invalid_redirect": "Redirecionamento inválido", "redirect_not_parseable": "URL de redirecionamento não analisável", "redirect_domain_not_allowed": "Domínio de redirecionamento não permitido", "failed_to_sign_jwt": "falha ao assinar JWT", "invalid_invocation": "Invocação de MakeChallenge inválida", "client_error_browser": "Erro do cliente: verifique se seu navegador está atualizado e tente novamente mais tarde.", "oh_noes": "Ah, não!", "benchmarking_anubis": "Fazendo benchmark do Anubis!", "you_are_not_a_bot": "Você não é um bot!", "making_sure_not_bot": "Certificando de que você não é um bot!", "celphase": "CELPHASE", "js_web_crypto_error": "Seu navegador não possui um elemento web.crypto funcional. Você está visualizando isso em um contexto seguro?", "js_web_workers_error": "Seu navegador não suporta web workers (o Anubis os usa para evitar que seu navegador trave). Você tem um plugin como o JShelter instalado?", "js_cookies_error": "Seu navegador não armazena cookies. O Anubis usa cookies para determinar quais clientes passaram pelas validações, armazenando um token assinado nesse cookie. Habilite o armazenamento de cookies para este domínio. Os nomes dos cookies armazenados pelo Anubis podem variar sem aviso prévio. Os nomes e valores dos cookies não fazem parte da API pública.", "js_context_not_secure": "Seu contexto não é seguro!", "js_context_not_secure_msg": "Tente conectar-se via HTTPS ou avise o administrador para configurar a segurança de site via HTTPS. Para mais informações, consulte o MDN.", "js_calculating": "Calculando...", "js_missing_feature": "Recurso não disponível", "js_challenge_error": "Erro na validação!", "js_challenge_error_msg": "Falha ao resolver o algoritmo de verificação. Talvez seja necessário recarregar a página.", "js_calculating_difficulty": "Calculando...
Dificuldade:", "js_speed": "Velocidade:", "js_verification_longer": "A verificação está demorando mais do que o esperado. Não atualize a página.", "js_success": "Sucesso!", "js_done_took": "Feito! Levou", "js_iterations": "iterações", "js_finished_reading": "Terminei de ler, continue →", "js_calculation_error": "Erro de cálculo!", "js_calculation_error_msg": "Falha ao calcular a validação:", "missing_required_forwarded_headers": "Faltam os cabeçalhos X-Forwarded-* obrigatórios", "simplified_explanation": "Esta é uma medida contra bots e solicitações maliciosas, semelhante a um CAPTCHA. No entanto, em vez de você mesmo ter que fazer o trabalho, seu navegador recebe uma tarefa de cálculo que ele deve resolver para garantir que seja um cliente válido. Esse conceito é chamado de Prova de Trabalho. A tarefa é calculada em poucos segundos e você tem acesso ao site. Obrigado pela sua compreensão e paciência." } ================================================ FILE: lib/localization/locales/ru.json ================================================ { "loading": "Загрузка...", "why_am_i_seeing": "Почему я вижу это?", "protected_by": "Защищено", "protected_from": "От", "made_with": "Сделано с ❤️ из 🇨🇦", "mascot_design": "Дизайн маскота от", "ai_companies_explanation": "Вы это видите, потому что администратор этого сайта настроил Anubis для защиты сервера от атак, использующих ИИ, которые агрессивно копируют данные с сайтов. Это может привести к зависанию сайтов и делает их ресурсы недоступными для всех.", "anubis_compromise": "Anubis - это компромисс. Anubis использует Proof-of-Work, похожую на Hashcash, для борьбы со спамом в электронной почте. Идея в том, что на отдельных уровнях дополнительная нагрузка не влиятельна, но на уровне массового парсинга она накапливается и значительно удорожает сбор данных.", "hack_purpose": "В конечном итоге, это временное решение, чтобы можно было уделить больше времени снятию отпечатков и идентификации безголовых браузеров (например, по тому, как они отрисовывают шрифты), чтобы страница с доказательством работы не требовалась для пользователей, которые с гораздо большей вероятностью являются легитимными.", "jshelter_note": "Anubis требует использования современных функций JavaScript, которые плагины, по типу JShelter, отключают. Пожалуйста, отключите JShelter и другие подобные плагины для этого домена.", "version_info": "На сайте запущен Anubis версии", "try_again": "Попробуйте снова", "go_home": "Перейти на домашнюю", "contact_webmaster": "если вы уверены, что это ошибка, свяжитесь с владельцем сайта через", "connection_security": "Пожалуйста, подождите, пока мы проверим безопасность вашего соединения.", "javascript_required": "К сожалению, для решения этой проверки необходимо включить JavaScript. Это необходимо, поскольку компании, занимающиеся разработкой ИИ, изменили моральные правила, касающийся хостинга веб-сайтов. Решение без использования JavaScript находится в стадии разработки.", "benchmark_requires_js": "Для работы тестирования необходимо включить JavaScript.", "difficulty": "Сложность:", "algorithm": "Алгоритм:", "compare": "Сравнить:", "time": "Время", "iters": "Итерации", "time_a": "Время A", "iters_a": "Итерации A", "time_b": "Время B", "iters_b": "Итерации B", "static_check_endpoint": "Это всего лишь точка проверки, которую может использовать ваш обратный прокси-сервер.", "authorization_required": "Требуется авторизация.", "cookies_disabled": "В вашем браузере отключены cookie файлы. Anubis требует их для подтверждения того, что вы являетесь настоящим человеком. Пожалуйста, включите файлы cookie для этого домена", "access_denied": "Доступ запрещён: код ошибки", "dronebl_entry": "DroneBL сообщил о записи", "see_dronebl_lookup": "см.", "internal_server_error": "Внутренняя ошибка сервера: администратор неправильно настроил Anubis. Обратитесь к администратору и попросите его просмотреть логи", "invalid_redirect": "Неверное перенаправление", "redirect_not_parseable": "URL-адрес перенаправления не может быть анализирован", "redirect_domain_not_allowed": "Перенаправление домена запрещено", "failed_to_sign_jwt": "не смог подписать JWT", "invalid_invocation": "Неверный вызов MakeChallenge", "client_error_browser": "Ошибка клиента: убедитесь, что у вас браузер новейшей версии, и повторите попытку позже.", "oh_noes": "О нет!", "benchmarking_anubis": "Анализ Анубиса!", "you_are_not_a_bot": "Вы не бот!", "making_sure_not_bot": "Проверяем, что вы не бот!", "celphase": "CELPHASE", "js_web_crypto_error": "В вашем браузере отсутствует функция web.crypto. Вы просматриваете страницу через защищённый контекст?", "js_web_workers_error": "Ваш браузер не поддерживает web worker (Anubis использует его, чтобы избежать зависания браузера). У вас установлен плагин типа JShelter?", "js_cookies_error": "Ваш браузер не сохраняет cookie файлы. Anubis использует их для определения клиентов, прошедших проверку, сохраняя подписанный токен в файле cookie. Включите сохранение файлов cookie для этого домена. Имена файлов cookie, хранимых Anubis, могут изменяться без предварительного уведомления. Имена и значения cookie файлов не являются частью общедоступного API.", "js_context_not_secure": "Ваш контекст небезопасен!", "js_context_not_secure_msg": "Попробуйте подключиться по HTTPS или попросите администратора, чтобы он настроил HTTPS. Подробнее см. MDN.", "js_calculating": "Расчёт...", "js_missing_feature": "Отсутствует функция", "js_challenge_error": "Ошибка проверки!", "js_challenge_error_msg": "Не удалось определить алгоритм проверки. Возможно, нужно перезагрузить страницу..", "js_calculating_difficulty": "Расчёт...
Сложность:", "js_speed": "Скорость:", "js_verification_longer": "Проверка занимает больше времени, чем ожидалось. Пожалуйста, не обновляйте страницу.", "js_success": "Успех!", "js_done_took": "Получилось! Заняло", "js_iterations": "итераций", "js_finished_reading": "Я дочитал, продолжить →", "js_calculation_error": "Ошибка расчёта!", "js_calculation_error_msg": "Не удалось рассчитать задачу:", "missing_required_forwarded_headers": "Отсутствуют требуемые заголовки X-Forwarded-*", "simplified_explanation": "Это мера против ботов и вредоносных запросов, аналогичная CAPTCHA. Однако вместо того, чтобы вам приходилось работать самостоятельно, вашему браузеру дается задача вычисления, которую он должен решить, чтобы убедиться, что он является действительным клиентом. Эта концепция называется Доказательство выполнения работы. Задача рассчитывается за несколько секунд, и вам предоставляется доступ к веб-сайту. Спасибо за понимание и терпение." } ================================================ FILE: lib/localization/locales/sv.json ================================================ { "loading": "Laddar...", "why_am_i_seeing": "Varför ser jag detta?", "protected_by": "Skyddat av", "protected_from": "Från", "made_with": "Gjort med ❤️ i 🇨🇦", "mascot_design": "Maskotdesign av", "ai_companies_explanation": "Du ser detta eftersom att administratören av denna webbsida har upprättat Anubis-systemet för att skydda servern mot plågan av att AI-företag aggressivt skrapar webbsidor. Detta kan orsaka driftstopp för webbsidor, vilket gör deras resurser otillgängliga för alla.", "anubis_compromise": "Anubis är en kompromiss. Anubis använder sig av ett arbetsbevissystem på samma sätt som Hashcash, ett förslag om arbetsbevissystem för att minska epostspam. Idén är att den extra belastningen är obetydlig på en individuell skala, men att den på massskrapningsnivåer adderas upp och gör processen mycket dyrare.", "hack_purpose": "I slutändan är detta en platshållarlösning så att mer tid kan ägnas åt fingeravtryck och identifiering av huvudlösa webbläsare (t.ex. via hur de renderar teckensnitt) så att utmaningens bevis på arbete-sida inte behöver presenteras för användare som är mycket mer sannolikt att vara legitima.", "jshelter_note": "Notera att Anubis kräver användningen av moderna JavaScript-funktioner som tillägg såsom JShelter kommer att avaktivera. Var vänlig och avaktivera JShelter eller andra liknande tillägg för denna domän.", "version_info": "Den här webbsidan kör Anubis version", "try_again": "Försök igen", "go_home": "Gå hem", "contact_webmaster": "eller om du tycker att du inte borde bli blockerad, kontakta den webbansvarige på", "connection_security": "Var vänlig och vänta en stund medan vi säkerställer din anslutnings säkerhet.", "javascript_required": "Tyvärr måste du slå igång JavaScript för att komma förbi denna utmaning. Detta eftersom AI-företag har ändrat samhällskontraktet gällande webbhosting. En lösning som icke kräver JavaScript ett pågående arbete.", "benchmark_requires_js": "För att köra prestandamätningsverktyget krävs det att JavaScript är igång.", "difficulty": "Svårighetsgrad:", "algorithm": "Algoritm:", "compare": "Jämför:", "time": "Tid", "iters": "Iterationer", "time_a": "Tid A", "iters_a": "Iterationer A", "time_b": "Tid B", "iters_b": "Iterationer B", "static_check_endpoint": "Detta är bara en kontrollendpunkt för användning av din reverse-proxy.", "authorization_required": "Tillstånd krävs", "cookies_disabled": "Din webbläsare är konfigurerad för att inaktivera cookies. Anubis kräver cookies för att säkerställa att du är en giltig klient. Var vänlig och aktivera cookies för den här domänen", "access_denied": "Tillstånd nekat: felkod", "dronebl_entry": "DroneBL rapporterade en post", "see_dronebl_lookup": "visa", "internal_server_error": "Internt serverfel: administratören har felkonfigurerat Anubis. Kontakta administratören och be dem att leta efter loggarna.", "invalid_redirect": "Ogiltig omdirigering", "redirect_not_parseable": "Omdirigeringsurl icke tolkbar", "redirect_domain_not_allowed": "Omdirigeringsdomän icke tillåten", "failed_to_sign_jwt": "misslyckades att signera JWT", "invalid_invocation": "Ogiltigt anrop av MakeChallenge", "client_error_browser": "Klientfel: Dubbelkolla att din webbläsare är uppdaterad och försök igen senare.", "oh_noes": "Aj då!", "benchmarking_anubis": "Prestandamäter Anubis!", "you_are_not_a_bot": "Du är inte en bot!", "making_sure_not_bot": "Kollar så att du inte är en bot!", "celphase": "CELPHASE", "js_web_crypto_error": "Din webbläsare har inte ett fungerande web.crypto-element. Ser du denna sida över en säker webbläsarkontext?", "js_web_workers_error": "Din webbläsare stödjer inte webbworkers-teknik (Anubis använder sig av detta för att undvika att din webbläsare fryser). Har du ett tillägg såsom JShelter installerat?", "js_cookies_error": "Din webbläsare lagrar inte cookies. Anubis använder sig av cookies för att avgöra vilka klienter som har klarat utmaningar genom att lagra en signerad token i en cookie. Vänligen aktivera lagring av cookies för den här domänen. Namnen på de cookies som Anubis lagrar kan variera utan varsel då cookienamn och värden inte ingår i det publika API:et.", "js_context_not_secure": "Din webbläsarkontext är ej säker!", "js_context_not_secure_msg": "Försök att ansluta via HTTPS eller kontakta administratören och be dem att konfigurera HTTPS. För mer information, se MDN.", "js_calculating": "Beräknar...", "js_missing_feature": "Funktion saknas", "js_challenge_error": "Utmaningsfel!", "js_challenge_error_msg": "Misslyckades att lösa kontrollalgoritm. Du bör ladda om sidan.", "js_calculating_difficulty": "Beräknar...
Svårighetsgrad:", "js_speed": "Hastighet:", "js_verification_longer": "Verifikation tar längre än förväntat. Ladda ej om sidan.", "js_success": "Lyckades!", "js_done_took": "Klart! tog", "js_iterations": "iterationer", "js_finished_reading": "Jag har läst klart, fortsätt →", "js_calculation_error": "Beräkningsfel!", "js_calculation_error_msg": "Misslyckades att kalkylera utmaning:", "missing_required_forwarded_headers": "Saknar nödvändiga X-Forwarded-* headers", "simplified_explanation": "Detta är en åtgärd mot botar och skadliga förfrågningar som liknar en CAPTCHA. Men i stället för att du själv måste göra jobbet får din webbläsare en beräkningsuppgift som den måste lösa för att säkerställa att den är en giltig klient. Detta koncept kallas Arbetsbevis. Uppgiften beräknas på några sekunder och du beviljas tillgång till webbplatsen. Tack för din förståelse och ditt tålamod." } ================================================ FILE: lib/localization/locales/th.json ================================================ { "loading": "กำลังโหลด...", "why_am_i_seeing": "ทำไมถึงเห็นสิ่งนี้?", "protected_by": "ปกป้องโดย", "protected_from": "จาก", "made_with": "สร้างด้วย ❤️ ใน 🇨🇦", "mascot_design": "ออกแบบมาสค็อตโดย", "ai_companies_explanation": "คุณเห็นสิ่งนี้เพราะผู้ดูแลเว็บไซต์ได้ตั้งค่า Anubis เพื่อป้องกันเซิร์ฟเวอร์จากบริษัท AI ที่ทำการขูดข้อมูลเว็บไซต์อย่างก้าวร้าว ซึ่งสามารถทำให้เว็บไซต์ล่ม และทำให้ทรัพยากรของเว็บไซต์ไม่สามารถเข้าถึงได้สำหรับทุกคน", "anubis_compromise": "Anubis คือการประนีประนอม โดยใช้ระบบ Proof-of-Work คล้ายกับ Hashcash ซึ่งเป็นแนวคิดสำหรับลดสแปมอีเมล แนวคิดคือ การโหลดเพิ่มเติมในระดับผู้ใช้รายบุคคลสามารถละเลยได้ แต่ในระดับการขูดข้อมูลจำนวนมาก มันจะสะสมและทำให้การขูดแพงขึ้น", "hack_purpose": "ท้ายที่สุดแล้ว นี่คือการแฮ็กที่มีวัตถุประสงค์หลักเพื่อเป็นโซลูชันชั่วคราวที่ 'เพียงพอ' เพื่อให้มีเวลาในการสร้างการตรวจจับตัวตนของเบราว์เซอร์แบบไม่มีกล่องข้อความ (เช่น ผ่านการเรนเดอร์ฟอนต์) เพื่อไม่ต้องแสดงหน้า Proof-of-Work แก่ผู้ใช้ที่มีแนวโน้มว่าจะเป็นผู้ใช้จริง", "jshelter_note": "โปรดทราบว่า Anubis ต้องการใช้คุณสมบัติ JavaScript สมัยใหม่ที่ปลั๊กอินอย่าง JShelter จะปิดใช้งาน กรุณาปิด JShelter หรือปลั๊กอินลักษณะคล้ายกันสำหรับโดเมนนี้", "version_info": "เว็บไซต์นี้กำลังใช้ Anubis เวอร์ชัน", "try_again": "ลองอีกครั้ง", "go_home": "กลับหน้าหลัก", "contact_webmaster": "หากคุณเชื่อว่าไม่ควรถูกบล็อก กรุณาติดต่อผู้ดูแลเว็บไซต์ที่", "connection_security": "กรุณารอสักครู่ในขณะที่เราตรวจสอบความปลอดภัยของการเชื่อมต่อของคุณ", "javascript_required": "น่าเสียดายที่คุณต้องเปิดใช้ JavaScript เพื่อผ่านการทดสอบนี้ เนื่องจากบริษัท AI ได้เปลี่ยนข้อตกลงทางสังคมเกี่ยวกับการโฮสต์เว็บไซต์ ทางเลือกแบบ 'ไม่มี JS' กำลังอยู่ระหว่างการพัฒนา", "benchmark_requires_js": "เครื่องมือวัดประสิทธิภาพต้องใช้ JavaScript", "difficulty": "ความยาก:", "algorithm": "อัลกอริธึม:", "compare": "เปรียบเทียบ:", "time": "เวลา", "iters": "จำนวนรอบ", "time_a": "เวลา A", "iters_a": "รอบ A", "time_b": "เวลา B", "iters_b": "รอบ B", "static_check_endpoint": "นี่เป็นเพียง endpoint ตรวจสอบสำหรับ reverse proxy ของคุณ", "authorization_required": "ต้องมีการยืนยันตัวตน", "cookies_disabled": "เบราว์เซอร์ของคุณปิดการใช้งานคุกกี้ Anubis ต้องใช้คุกกี้เพื่อตรวจสอบว่าเป็นผู้ใช้ที่แท้จริง กรุณาเปิดใช้งานคุกกี้สำหรับโดเมนนี้", "access_denied": "การเข้าถึงถูกปฏิเสธ: รหัสข้อผิดพลาด", "dronebl_entry": "DroneBL รายงานรายการนี้", "see_dronebl_lookup": "ดู", "internal_server_error": "เกิดข้อผิดพลาดในเซิร์ฟเวอร์: ผู้ดูแลระบบได้กำหนดค่า Anubis อย่างไม่ถูกต้อง กรุณาติดต่อผู้ดูแลระบบและให้เขาตรวจสอบบันทึกใกล้กับ", "invalid_redirect": "การเปลี่ยนเส้นทางไม่ถูกต้อง", "redirect_not_parseable": "ไม่สามารถแยกวิเคราะห์ URL สำหรับเปลี่ยนเส้นทาง", "redirect_domain_not_allowed": "ไม่อนุญาตให้เปลี่ยนเส้นทางไปยังโดเมนนี้", "failed_to_sign_jwt": "ไม่สามารถเซ็น JWT ได้", "invalid_invocation": "เรียกใช้ MakeChallenge อย่างไม่ถูกต้อง", "client_error_browser": "ข้อผิดพลาดของไคลเอนต์: กรุณาตรวจสอบว่าเบราว์เซอร์ของคุณเป็นเวอร์ชันล่าสุด และลองใหม่ในภายหลัง", "oh_noes": "โอ้ ไม่!", "benchmarking_anubis": "กำลังวัดประสิทธิภาพ Anubis!", "you_are_not_a_bot": "คุณไม่ใช่บอท!", "making_sure_not_bot": "ตรวจสอบให้แน่ใจว่าคุณไม่ใช่บอท!", "celphase": "CELPHASE", "js_web_crypto_error": "เบราว์เซอร์ของคุณไม่มีฟีเจอร์ web.crypto ที่ใช้งานได้ คุณกำลังดูผ่านบริบทที่ปลอดภัยหรือไม่?", "js_web_workers_error": "เบราว์เซอร์ของคุณไม่รองรับ web workers (Anubis ใช้เพื่อลดการค้างของเบราว์เซอร์) คุณใช้ปลั๊กอินเช่น JShelter หรือไม่?", "js_cookies_error": "เบราว์เซอร์ของคุณไม่เก็บคุกกี้ Anubis ใช้คุกกี้เพื่อเก็บโทเค็นที่เซ็นแล้วสำหรับไคลเอนต์ที่ผ่านการท้าทาย กรุณาเปิดใช้งานการเก็บคุกกี้สำหรับโดเมนนี้ ชื่อคุกกี้อาจเปลี่ยนแปลงได้โดยไม่แจ้งล่วงหน้า", "js_context_not_secure": "บริบทของคุณไม่ปลอดภัย!", "js_context_not_secure_msg": "ลองเชื่อมต่อผ่าน HTTPS หรือแจ้งผู้ดูแลระบบให้ตั้งค่า HTTPS สำหรับข้อมูลเพิ่มเติมดูที่ MDN", "js_calculating": "กำลังคำนวณ...", "js_missing_feature": "ไม่มีคุณลักษณะนี้", "js_challenge_error": "เกิดข้อผิดพลาดในการท้าทาย!", "js_challenge_error_msg": "ไม่สามารถแก้ไขอัลกอริธึมการตรวจสอบ อาจต้องโหลดหน้าใหม่", "js_calculating_difficulty": "กำลังคำนวณ...
ความยาก:", "js_speed": "ความเร็ว:", "js_verification_longer": "การตรวจสอบใช้เวลานานกว่าที่คาดไว้ กรุณาอย่ารีเฟรชหน้านี้", "js_success": "สำเร็จ!", "js_done_took": "เสร็จแล้ว! ใช้เวลา", "js_iterations": "รอบ", "js_finished_reading": "อ่านจบแล้ว ดำเนินการต่อ →", "js_calculation_error": "เกิดข้อผิดพลาดในการคำนวณ!", "js_calculation_error_msg": "ไม่สามารถคำนวณการท้าทายได้:" } ================================================ FILE: lib/localization/locales/tr.json ================================================ { "loading": "Yükleniyor...", "why_am_i_seeing": "Bunu neden görüyorum?", "protected_by": "Koruma sağlayan:", "protected_from": "Yapan:", "made_with": "🇨🇦’da ❤️ ile yapıldı", "mascot_design": "Maskot tasarımı:", "ai_companies_explanation": "Bunu görüyorsunuz; çünkü bu web sitesinin yöneticisi, yapay zekâ şirketlerinin web sitelerini agresif şekilde kazımasına karşı sunucuyu korumak için Anubis’i kurdu. Bu tarz kazımalar sitelerin erişilemez olmasına ve kesintilere neden olabiliyor.", "anubis_compromise": "Anubis bir uzlaşmadır. Anubis, istenmeyen e-postaları azaltmak için önerilen bir iş kanıtı sistemi olan Hashcash benzeri bir sistemi kullanır. Bireysel kullanımda bu ek yük göz ardı edilebilir olsa da, büyük ölçekli kazıyıcılarda birikerek kazımayı oldukça maliyetli hale getirir.", "hack_purpose": "Nihayetinde, bu bir yer tutucu çözümdür, böylece başsız tarayıcıların parmak izi alma ve tanımlama (örneğin, yazı tipi oluşturma şekilleri aracılığıyla) için daha fazla zaman harcanabilir, böylece iş kanıtı sayfası meşru olma olasılığı çok daha yüksek olan kullanıcılara sunulmak zorunda kalmaz.", "jshelter_note": "Lütfen dikkat: Anubis, JShelter gibi eklentilerin devre dışı bıraktığı modern JavaScript özelliklerini gerektirir. Lütfen bu alan adı için JShelter veya benzeri eklentileri devre dışı bırakın.", "version_info": "Bu web sitesi şu Anubis sürümünü çalıştırıyor:", "try_again": "Tekrar dene", "go_home": "Ana sayfaya dön", "contact_webmaster": "veya engellenmemeniz gerektiğini düşünüyorsanız lütfen şu adrese e-posta gönderin:", "connection_security": "Bağlantınızın güvenliği sağlanırken lütfen bekleyin.", "javascript_required": "Ne yazık ki bu aşamayı geçebilmek için JavaScript’i etkinleştirmeniz gerekiyor. Bunun nedeni, yapay zekâ şirketlerinin web barındırma konusundaki sosyal sözleşmeyi değiştirmiş olmasıdır. JavaScript’siz bir çözüm geliştirilmektedir.", "benchmark_requires_js": "Kıyaslama aracının çalıştırılması için JavaScript’in etkin olması gereklidir.", "difficulty": "Zorluk:", "algorithm": "Algoritma:", "compare": "Karşılaştır:", "time": "Süre", "iters": "Tekrar", "time_a": "Süre A", "iters_a": "Tekrar A", "time_b": "Süre B", "iters_b": "Tekrar B", "static_check_endpoint": "Bu sadece, ters vekil sunucunuzun kullanması için bir kontrol adresidir.", "authorization_required": "Yetkilendirme gerekli", "cookies_disabled": "Tarayıcınız çerezleri devre dışı bırakacak şekilde yapılandırılmış. Anubis, gerçek bir kullanıcı olduğunuzu doğrulamak için çerezlere ihtiyaç duyar. Lütfen bu alan adı için çerezleri etkinleştirin.", "access_denied": "Erişim reddedildi: Hata kodu", "dronebl_entry": "DroneBL bir giriş bildirdi", "see_dronebl_lookup": "bakınız", "internal_server_error": "Sunucu Hatası: Yönetici Anubis’i yanlış yapılandırmış. Lütfen yöneticinizle iletişime geçin ve şu civardaki kayıtlara bakmasını isteyin:", "invalid_redirect": "Geçersiz yönlendirme", "redirect_not_parseable": "Yönlendirme URL’si çözümlenemiyor", "redirect_domain_not_allowed": "Yönlendirme alan adına izin verilmiyor", "failed_to_sign_jwt": "JWT imzalanamadı", "invalid_invocation": "Geçersiz MakeChallenge çağrısı", "client_error_browser": "İstemci hatası: Lütfen tarayıcınızın güncel olduğundan emin olun ve daha sonra tekrar deneyin.", "oh_noes": "Ah hayır!", "benchmarking_anubis": "Anubis kıyaslanıyor!", "you_are_not_a_bot": "Bot değilsiniz!", "making_sure_not_bot": "Bot olmadığınızdan emin oluyoruz!", "celphase": "CELPHASE", "js_web_crypto_error": "Tarayıcınızda çalışan bir web.crypto öğesi yok. Bu sayfayı güvenli bir bağlantı üzerinden mi görüntülüyorsunuz?", "js_web_workers_error": "Tarayıcınız web işçilerini desteklemiyor (Anubis, tarayıcınızın donmaması için bunları kullanır). JShelter gibi bir eklenti mi kurulu?", "js_cookies_error": "Tarayıcınız çerezleri kaydetmiyor. Anubis, kullanıcıların zorlukları geçtiğini belirlemek için imzalı bir belirteci çerezde saklar. Lütfen bu alan adı için çerezleri etkinleştirin. Anubis’in kullandığı çerez adları önceden bildirilmeksizin değişebilir. Çerez adları ve değerleri resmi API’nin bir parçası değildir.", "js_context_not_secure": "Bağlantınız güvenli değil!", "js_context_not_secure_msg": "HTTPS üzerinden bağlanmayı deneyin veya yöneticiden HTTPS kurulumu yapmasını isteyin. Daha fazla bilgi için bkz. MDN.", "js_calculating": "Hesaplanıyor...", "js_missing_feature": "Eksik özellik", "js_challenge_error": "Hesaplama hatası!", "js_challenge_error_msg": "Algoritma çözümlemesi başarısız oldu. Sayfayı yeniden yüklemeyi deneyebilirsiniz.", "js_calculating_difficulty": "Hesaplanıyor...
Zorluk:", "js_speed": "Hız:", "js_verification_longer": "Doğrulama beklenenden uzun sürüyor. Lütfen sayfayı yenilemeyin.", "js_success": "Başarılı!", "js_done_took": "Tamamlandı! Süre:", "js_iterations": "tekrar", "js_finished_reading": "Okumayı bitirdim, devam et →", "js_calculation_error": "Hesaplama hatası!", "js_calculation_error_msg": "Zorluk hesaplaması başarısız oldu:", "missing_required_forwarded_headers": "Gerekli X-Forwarded-* başlıkları eksik", "simplified_explanation": "Bu, botlara ve kötü niyetli isteklere karşı CAPTCHA'ya benzer bir önlemdir. Ancak, kendiniz çalışmak yerine, tarayıcınıza geçerli bir istemci olduğundan emin olmak için çözmesi gereken bir hesaplama görevi verilir. Bu kavrama İş Kanıtı denir. Görev birkaç saniye içinde hesaplanır ve web sitesine erişim hakkı kazanırsınız. Anlayışınız ve sabrınız için teşekkür ederiz." } ================================================ FILE: lib/localization/locales/uk.json ================================================ { "loading": "Завантаження...", "why_am_i_seeing": "Чому я це бачу?", "protected_by": "Захищено засобами", "protected_from": "за авторством", "made_with": "Зроблено з ❤️ у 🇨🇦", "mascot_design": "Дизайн персонажа від", "ai_companies_explanation": "Ви це бачите, оскільки адміністрація сайту налаштувала Anubis, щоб захистити сервер від тиску ШІ-компаній, які агресивно сканують вебсайти. Їхня діяльність спричиняє перебої в роботі вебсайтів, що робить матеріали недоступними для всіх.", "anubis_compromise": "Anubis — це компроміс. Anubis втілює схему доказу виконаної роботи подібно до Hashcash — засобу боротьби зі спамом. По ідеї, додаткове навантаження не обтяжує справжню людину, котра робить небагато запитів, а от масове сканування таким чином стає суттєво дорожчим.", "hack_purpose": "Це тимчасове рішення, котре дозволяє приділити більше часу розпізнанню й виокремленню автоматизованих браузерів (наприклад, за тим, як вони промальовують шрифти), щоб сторінку перевірки доказу виконаної роботи не доводилося показувати ймовірно справжнім користувачам.", "simplified_explanation": "Це засіб боротьби з ботами й зловмисними запитами, подібний до капчі. Проте замість того, щоб просити вас щось зробити, він пропонує вашому браузеру розв'язати обчислювальне завдання. Ця концепція називається доказом виконаної роботи. Завдання обчислюється кілька секунд, після чого вам надається доступ до сайту. Дякуємо за розуміння й терплячість.", "jshelter_note": "Зауважте, Anubis потребує сучасного JavaScript-функціоналу, котрий може бути недоступним при використанні розширень на зразок JShelter. Будь ласка, вимкніть JShelter чи інші подібні розширення для цього домену.", "version_info": "Цей вебсайт застосовує Anubis версії", "try_again": "Повторіть спробу", "go_home": "Перейдіть на головну сторінку", "contact_webmaster": "або, якщо ви певні в помилковості блокування, сконтактуйте з адміністрацією за адресою", "connection_security": "Зачекайте хвилинку, поки ми перевіримо безпеку вашого з'єднання.", "javascript_required": "На жаль, вам потрібно ввімкнути JavaScript, щоб пройти цю перевірку. Це необхідно, оскільки ШІ-компанії нехтують суспільним договором, завдяки якому можливо утримувати вебсайти. Робота над рішенням без використання JS триває.", "benchmark_requires_js": "Щоб запустити тестування продуктивності, ввімкніть JavaScript.", "difficulty": "Складність:", "algorithm": "Алгоритм:", "compare": "Порівняти:", "time": "Час", "iters": "Ітерації", "time_a": "Час A", "iters_a": "Ітерації A", "time_b": "Час B", "iters_b": "Ітерації B", "static_check_endpoint": "Це просто сторінка перевірки для вашого зворотного проксі.", "authorization_required": "Необхідно авторизуватися", "cookies_disabled": "У вашому браузері вимкнено кукі. Anubis використовує кукі, щоб упевнитись, що ви дійсно людина. Це законний інтерес. Будь ласка, ввімкніть кукі для цього домену", "access_denied": "Доступ заборонено: код помилки", "dronebl_entry": "DroneBL містить пункт", "see_dronebl_lookup": "див.", "internal_server_error": "Внутрішня помилка сервера: адміністрація хибно налаштувала Anubis. Будь ласка, сконтактуйте з адміністрацією й попросіть глянути логи довкола", "invalid_redirect": "Хибне переспрямування", "redirect_not_parseable": "Не вдається розпізнати URL-адресу переспрямування", "redirect_domain_not_allowed": "Заборонений домен переспрямування", "missing_required_forwarded_headers": "Бракує обов'язкових заголовків X-Forwarded-*", "failed_to_sign_jwt": "не вдається підписати JWT", "invalid_invocation": "Хибний виклик MakeChallenge", "client_error_browser": "Помилка клієнта: переконайтесь, що використовуєте браузер актуальної версії, й повторіть спробу.", "oh_noes": "Йой!", "benchmarking_anubis": "Тестування продуктивності Anubis!", "you_are_not_a_bot": "Ви не бот!", "making_sure_not_bot": "Перевірка, чи ви не бот!", "celphase": "CELPHASE", "js_web_crypto_error": "Ваш браузер не надає web.crypto. Ви певні, що дивитесь це через захищений контекст?", "js_web_workers_error": "Ваш браузер не підтримує Web Workers (Anubis використовує їх, щоб ваш браузер не зависав на час перевірки). Можливо, у вас встановлено розширення на зразок JShelter?", "js_cookies_error": "Ваш браузер не зберігає кукі. Anubis записує підписаний токен до кукі, щоб занотувати, що клієнт пройшов перевірку. Будь ласка, ввімкніть збереження кукі для цього домену. Назви кукі, які записує Anubis, можуть змінюватися без попередження. Назви й значення кукі не є частиною публічного API.", "js_context_not_secure": "Ваш контекст незахищений!", "js_context_not_secure_msg": "Спробуйте з'єднатися через HTTPS або попросіть адміністрацію налаштувати HTTPS. Докладніше — в MDN.", "js_calculating": "Обчислення...", "js_missing_feature": "Бракує функціоналу", "js_challenge_error": "Помилка перевірки!", "js_challenge_error_msg": "Не вдалося визначити алгоритм перевірки. Спробуйте оновити сторінку.", "js_calculating_difficulty": "Обчислення...
Складність:", "js_speed": "Швидкість:", "js_verification_longer": "Перевірка триває довше, ніж очікувалося. Будь ласка, не оновлюйте сторінку.", "js_success": "Успіх!", "js_done_took": "Готово! Знадобилося", "js_iterations": "ітерацій", "js_finished_reading": "Читання завершено, продовжити →", "js_calculation_error": "Помилка обчислення!", "js_calculation_error_msg": "Не вдалося обчислити перевірку:" } ================================================ FILE: lib/localization/locales/vi.json ================================================ { "loading": "Đang nạp...", "why_am_i_seeing": "Tại sao tôi đang thấy trang này?", "protected_by": "Bảo vệ bởi", "protected_from": "từ", "made_with": "Tạo ra bằng ❤️ tại 🇨🇦", "mascot_design": "Thiết kế mascot bởi", "ai_companies_explanation": "Bạn đang thấy trang này do quản trị viên của trang web này đã thiết lập Anubis để bảo vệ máy chủ của họ khỏi quấy rầy từ những công ty AI hung hãn cóp nhặt nội dung khắp Internet. Điều này có thể và đã dẫn tới tình trạng gián đoạn hoạt động trên nhiều trang web, khiến tài nguyên tại đó nằm ngoài tầm với của mọi người.", "anubis_compromise": "Anubis là giải pháp thỏa hiệp. Anubis sử dụng cơ chế Proof-of-Work dựa trên Hashcash, được thiết kế ban đầu để giảm bớt email spam. Ý tưởng đằng sau đó là với người dùng cá nhân phần nạp thêm sẽ không đáng kể, nhưng ở tầm mức quy mô lớn sẽ cộng dồn và dẫn tới chi phí tiêu hao hơn rất nhiều.", "hack_purpose": "Chốt lại, đây cũng chỉ là giải pháp \"tạm ổn\" với mục đích thực sự là để giành thêm thời gian nhận diện và fingerprint những trình duyệt headless (VD: cách dựng font ra sao), sao cho hạn chế tối đa các yêu cầu tính toán trang thử thách Proof-of-Work tới nhóm người dùng có khả năng cao là con người hơn.", "simplified_explanation": "Đây là một biện pháp chống lại bot và các yêu cầu độc hại tương tự như CAPTCHA. Tuy nhiên, thay vì bạn phải tự mình thực hiện, trình duyệt của bạn sẽ được giao một nhiệm vụ tính toán mà nó phải giải quyết để đảm bảo rằng nó là một máy khách hợp lệ. Khái niệm này được gọi là Bằng chứng Công việc. Nhiệm vụ được tính toán trong vài giây và bạn được cấp quyền truy cập vào trang web. Cảm ơn sự thông cảm và kiên nhẫn của bạn.", "jshelter_note": "Vui lòng lưu ý Anubis cần sử dụng những tính năng JavaScript hiện đại mà một số phần mở rộng như JShelter sẽ tắt. Vui lòng vô hiệu hóa JShelter hoặc những phần mở rộng tương tự cho tên miền này.", "version_info": "Trang web này đang chạy Anubis phiên bản", "try_again": "Thử lại", "go_home": "Về trang chủ", "contact_webmaster": "hoặc nếu bạn tin rằng mình không nên bị chặn, vui lòng liên hệ chủ trang web tại", "connection_security": "Vui lòng chờ một chút trong khi chúng tôi kiểm tra an ninh kết nối của bạn.", "javascript_required": "Rất tiếc, bạn phải bật JavaScript để vượt qua thử thách này. Điều này bắt buộc do những công ty AI đã thay đổi luật ngầm quanh việc hoạt động máy chủ web ra sao. Giải pháp không có JavaScript đang được phát triển.", "benchmark_requires_js": "Bắt buộc phải bật JavaScript để chạy công cụ benchmark.", "difficulty": "Độ khó:", "algorithm": "Thuật toán:", "compare": "So sánh:", "time": "Thời gian", "iters": "Lặp lại", "time_a": "Thời gian A", "iters_a": "Lặp lại kiểu A", "time_b": "Thời gian B", "iters_b": "Lặp lại kiểu B", "static_check_endpoint": "Đây là điểm cuối cho reverse proxy của bạn sử dụng.", "authorization_required": "Bắt buộc xác thực", "cookies_disabled": "Trình duyệt của bạn được thiết lập để vô hiệu hóa cookie. Anubis cần cookie cho mục đích chính đáng để kiểm tra chắc chắn bạn là người dùng hợp lệ. Vui lòng bật cookie cho tên miền này", "access_denied": "Truy cập bị từ chối: mã lỗi", "dronebl_entry": "DroneBL báo cáo truy cập", "see_dronebl_lookup": "xem", "internal_server_error": "Lỗi máy chủ nội bộ: quản trị viên đã thiết lập sai Anubis. Vui lòng liên hệ quản trị viên và yêu cầu họ kiểm tra log", "invalid_redirect": "Điều hướng không hợp lệ", "redirect_not_parseable": "Liên kết điều hướng không thể xử lý", "redirect_domain_not_allowed": "Tên miền điều hướng không được phép", "missing_required_forwarded_headers": "Thiếu các tiêu đề X-Forwarded-* bắt buộc", "failed_to_sign_jwt": "không thể ký JWT", "invalid_invocation": "Gọi hàm MakeChallenge không hợp lệ", "client_error_browser": "Lỗi client: Vui lòng kiểm tra trình duyệt của bạn đã cập nhật và thử lại sau.", "oh_noes": "Ôi không!", "benchmarking_anubis": "Đang benchmark Anubis!", "you_are_not_a_bot": "Bạn không phải là bot!", "making_sure_not_bot": "Đang kiểm tra bạn không phải là bot!", "celphase": "CELPHASE", "js_web_crypto_error": "Trình duyệt của bạn không có web.crypto hoạt động. Liệu bạn có đang xem trang này với kết nối bảo mật không?", "js_web_workers_error": "Trình duyệt của bạn không hỗ trợ tính năng web worker (Anubis sử dụng để trình duyệt của bạn bị đơ). Bạn có cài đặt và sử dụng phần mở rộng như JShelter hay không?", "js_cookies_error": "Trình duyệt của bạn không lưu cookie. Anubis sử dụng cookie để xác định client nào đã đạt thành công thử thách bằng cách chứa một token đã được xác thực trong cookie. Vui lòng bật lưu trữ cookie cho tền miền này. Tên và cookie Anubis chứa vào có thể thay đổi khác nhau mà không báo trước. Tên và giá trị cookie không phải là một phần của API công khai.", "js_context_not_secure": "Kết nối này không bảo mật!", "js_context_not_secure_msg": "Thử kết nối lại qua HTTPS hoặc báo quản trị viên biết cách thiết lập HTTPS. Để biết thêm thông tin, vui lòng đọc MDN.", "js_calculating": "Đang tính toán...", "js_missing_feature": "Thiếu tính năng", "js_challenge_error": "Lỗi thử thách!", "js_challenge_error_msg": "Không thể xử lý thuật toán kiểm tra. Bạn nên nạp lại trang này.", "js_calculating_difficulty": "Đang tính...
Độ khó:", "js_speed": "Tốc độ:", "js_verification_longer": "Quá trình kiểm tra đang kéo dài lâu hơn dự kiến. Vui lòng không nạp lại trang này.", "js_success": "Thành công!", "js_done_took": "Hoàn tất! Mất", "js_iterations": "lần lặp lại", "js_finished_reading": "Tôi đã đọc xong, tiếp tục →", "js_calculation_error": "Lỗi tính toán!", "js_calculation_error_msg": "Không thể tính toán thử thách:" } ================================================ FILE: lib/localization/locales/zh-CN.json ================================================ { "loading": "加载中...", "why_am_i_seeing": "为什么我会看到这个?", "protected_by": "本网站由", "protected_from": "保护,来自", "made_with": "在 🇨🇦 用 ❤️ 制作", "mascot_design": "吉祥物由", "ai_companies_explanation": "您会看到这个画面,是因为网站管理员启用了 Anubis 来保护服务器,避免 AI 公司大量爬取网站内容。这类行为会导致网站崩溃,让所有用户都无法正常访问资源。", "anubis_compromise": "Anubis 是一种折中做法。它采用了类似 Hashcash 的工作量证明机制(Proof-of-Work),该机制最初是为了减少垃圾邮件而提出。其核心概念是:对个别用户而言,额外的计算负担可以忽略,但对大规模爬虫来说,累积起来的成本将大幅增加,从而让爬取行为变得更困难。", "hack_purpose": "最终,这是一个占位符解决方案,以便将更多时间用于指纹识别和识别无头浏览器(例如:通过它们如何进行字体渲染),从而无需向更可能是合法用户的用户呈现挑战工作量证明页面。", "jshelter_note": "请注意,Anubis 需要使用现代 JavaScript 功能,而像 JShelter 这类插件可能会阻挡这些功能。请为此域名停用 JShelter 或类似的插件。", "version_info": "这个网站正在运行的 Anubis 版本为", "try_again": "再试一次", "go_home": "返回首页", "contact_webmaster": "或者您觉得您不应该被封锁,请联系网站管理员于", "connection_security": "请稍等,我们需要在继续之前检查您的连接安全性。", "javascript_required": "很遗憾,您必须启用 JavaScript 才能通过这项验证。这是因为 AI 公司已经改变了网站托管的社会契约,因此我们必须采取这样的保护机制。无需 JavaScript 的解决方案仍在开发中。", "benchmark_requires_js": "运行基准测试工具需要启用 JavaScript。", "difficulty": "难度:", "algorithm": "算法:", "compare": "比较:", "time": "时间", "iters": "迭代", "time_a": "时间 A", "iters_a": "迭代 A", "time_b": "时间 B", "iters_b": "迭代 B", "static_check_endpoint": "这是提供给您的反向代理服务器使用的检查端点。", "authorization_required": "需要认证", "cookies_disabled": "您的浏览器目前已禁用 Cookie,为了确认您是合法用户,Anubis 需要启用 Cookie。 请您为此域名启用 Cookie", "access_denied": "拒绝访问:错误代码", "dronebl_entry": "DroneBL 报告了一条记录", "see_dronebl_lookup": "见", "internal_server_error": "内部服务器错误:管理员错误地配置了 Anubis。 请联系管理员要求他们检查日志", "invalid_redirect": "无效的重定向", "redirect_not_parseable": "重定向 URL 无法解析", "redirect_domain_not_allowed": "重定向的域名并不允许", "failed_to_sign_jwt": "签署 JWT 失败", "invalid_invocation": "无效的 MakeChallenge 调用", "client_error_browser": "客户端错误:请确保您的浏览器是最新版本并稍候再试。", "oh_noes": "哎呀糟糕了!", "benchmarking_anubis": "正在进行 Anubis 性能测试!", "you_are_not_a_bot": "你不是机器人!", "making_sure_not_bot": "正在确认你是不是机器人!", "celphase": "CELPHASE 设计", "js_web_crypto_error": "您的浏览器无法正常使用 web.crypto 组件。您是否通过安全连接(HTTPS)查看此网站?", "js_web_workers_error": "您的浏览器并不支持 Web workers (Anubis 使用这个来避免冻结您的浏览器 )您有安装像是 JShelter 之类的插件吗?", "js_cookies_error": "您的浏览器无法存储 Cookie。 Anubis 会使用 Cookie 存储签署的凭证,以判断用户是否已通过验证。请为此域名启用 Cookie 存储功能。 请注意,Anubis 存储的 Cookie 名称可能会变动,且其名称与内容不属于公开 API 的一部分。", "js_context_not_secure": "您的内容并不安全", "js_context_not_secure_msg": "请尝试使用 HTTPS 连接,或联系网站管理员设置 HTTPS。更多信息请参见 MDN。", "js_calculating": "计算中...", "js_missing_feature": "缺少功能", "js_challenge_error": "挑战错误!", "js_challenge_error_msg": "解决检查算法失败。 您可能会想要刷新页面。", "js_calculating_difficulty": "计算中...
难度:", "js_speed": "速度:", "js_verification_longer": "验证所花的时间高于预期。 请不要刷新页面。", "js_success": "成功!", "js_done_took": "完成! 花费", "js_iterations": "迭代", "js_finished_reading": "我读完了,继续 →", "js_calculation_error": "计算错误!", "js_calculation_error_msg": "计算挑战失败:", "missing_required_forwarded_headers": "缺少必要的 X-Forwarded-* 头", "simplified_explanation": "这是一种类似于验证码的措施,用于防止机器人和恶意请求。但是,您无需自己动手,您的浏览器会收到一个计算任务,必须解决该任务以确保它是有效的客户端。这个概念称为工作量证明。该任务在几秒钟内计算完毕,您将被授予访问网站的权限。感谢您的理解和耐心。" } ================================================ FILE: lib/localization/locales/zh-TW.json ================================================ { "loading": "載入中...", "why_am_i_seeing": "為什麼我看到這個?", "protected_by": "本網站由", "protected_from": "保護,來自", "made_with": "在 🇨🇦 用 ❤️ 製作", "mascot_design": "吉祥物由", "ai_companies_explanation": "您會看到這個畫面,是因為網站管理員啟用了 Anubis 來保護伺服器,避免 AI 公司大量爬取網站內容。這類行為會導致網站當機,讓所有使用者都無法正常存取資源。", "anubis_compromise": "Anubis 是一種折衷做法。它採用了類似 Hashcash 的工作量證明機制(Proof-of-Work),該機制最初是為了減少垃圾郵件而提出。其核心概念是:對個別使用者而言,額外的運算負擔可以忽略,但對大規模爬蟲來說,累積起來的成本將大幅增加,從而讓爬取行為變得更困難。", "hack_purpose": "最終,這是一個佔位符解決方案,以便將更多時間用於指紋識別和識別無頭瀏覽器(例如:透過它們如何進行字體渲染),從而無需向更可能是合法用戶的用戶呈現挑戰工作量證明頁面。", "jshelter_note": "請注意,Anubis 需要使用現代 JavaScript 功能,而像 JShelter 這類外掛可能會阻擋這些功能。請為此網域停用 JShelter 或類似的插件。", "version_info": "這個網站正在運行 Anubis 版本", "try_again": "再試一次", "go_home": "回首頁", "contact_webmaster": "或者您覺得您不應該被封鎖,請聯絡站點管理員於", "connection_security": "請稍等,我們需要在繼續之前檢閱您的連線安全性。", "javascript_required": "很遺憾,您必須啟用 JavaScript 才能通過這項驗證。這是因為 AI 公司已經改變了網站託管的社會契約,因此我們必須採取這樣的保護機制。無需 JavaScript 的解法仍在開發中。", "benchmark_requires_js": "執行基準測試工具需要啟用 JavaScript。", "difficulty": "難度:", "algorithm": "演算法:", "compare": "比較:", "time": "時間", "iters": "迭代", "time_a": "時間 A", "iters_a": "迭代 A", "time_b": "時間 B", "iters_b": "迭代 B", "static_check_endpoint": "這是提供給您的反向代理伺服器使用的檢查端點。", "authorization_required": "需要認證", "cookies_disabled": "您的瀏覽器目前已停用 Cookie,為了確認您是合法使用者,Anubis 需要啟用 Cookie。 請為此網域啟用 Cookie", "access_denied": "拒絕存取:錯誤代碼", "dronebl_entry": "DroneBL 回報了一筆紀錄", "see_dronebl_lookup": "見", "internal_server_error": "內部伺服器錯誤:管理員錯誤地配置了 Anubis。 請聯絡管理員要求他們檢閱日誌", "invalid_redirect": "無效的重新導向", "redirect_not_parseable": "重新導向 URL 無法解析", "redirect_domain_not_allowed": "重新導向的網域並不允許", "failed_to_sign_jwt": "簽署 JWT 失敗", "invalid_invocation": "無效的 MakeChallenge 呼叫", "client_error_browser": "客戶端錯誤:請確保您的瀏覽器是最新版本並稍候再試。", "oh_noes": "哎呀糟糕了!", "benchmarking_anubis": "正在進行 Anubis 效能測試!", "you_are_not_a_bot": "你不是機器人!", "making_sure_not_bot": "正在確認你是不是機器人!", "celphase": "CELPHASE 設計", "js_web_crypto_error": "您的瀏覽器無法正常使用 web.crypto 元件。您是否透過安全連線(HTTPS)檢視此網站?", "js_web_workers_error": "您的瀏覽器並不支援 Web workers (Anubis 使用這個來避免凍結您的瀏覽器 )您有安裝像是 JShelter 之類的插件嗎?", "js_cookies_error": "您的瀏覽器無法儲存 Cookie。 Anubis 會使用 Cookie 儲存簽署的憑證,以判斷使用者是否已通過驗證。請為此網域啟用 Cookie 儲存功能。 請注意,Anubis 儲存的 Cookie 名稱可能會變動,且其名稱與內容不屬於公開 API 的一部分。", "js_context_not_secure": "您的內容並不安全", "js_context_not_secure_msg": "請嘗試使用 HTTPS 連線,或聯繫網站管理員設定 HTTPS。更多資訊請參見 MDN。", "js_calculating": "計算中...", "js_missing_feature": "缺少功能", "js_challenge_error": "挑戰錯誤!", "js_challenge_error_msg": "解決檢查演算法失敗。 您可能會想要重整頁面。", "js_calculating_difficulty": "計算中...
難度:", "js_speed": "速度:", "js_verification_longer": "驗證所花的時間高於預期。 請不要重整頁面。", "js_success": "成功!", "js_done_took": "完成! 花費", "js_iterations": "迭代", "js_finished_reading": "我讀完了,繼續 →", "js_calculation_error": "計算錯誤!", "js_calculation_error_msg": "計算挑戰失敗:", "missing_required_forwarded_headers": "缺少必要的 X-Forwarded-* 標頭", "simplified_explanation": "這是一種類似於驗證碼的措施,用於防止機器人和惡意請求。但是,您無需自己動手,您的瀏覽器會收到一個計算任務,必須解決該任務以確保它是有效的客戶端。這個概念稱為工作量證明。該任務在幾秒鐘內計算完畢,您將被授予訪問網站的權限。感謝您的理解和耐心。" } ================================================ FILE: lib/localization/localization.go ================================================ package localization import ( "embed" "encoding/json" "net/http" "strings" "sync" "github.com/TecharoHQ/anubis" "github.com/nicksnyder/go-i18n/v2/i18n" "golang.org/x/text/language" ) //go:embed locales/*.json var localeFS embed.FS type LocalizationService struct { bundle *i18n.Bundle } var ( globalService *LocalizationService once sync.Once ) func NewLocalizationService() *LocalizationService { once.Do(func() { bundle := i18n.NewBundle(language.English) bundle.RegisterUnmarshalFunc("json", json.Unmarshal) // Read all JSON files from the locales directory entries, err := localeFS.ReadDir("locales") if err != nil { // Try fallback - create a minimal service with default messages globalService = &LocalizationService{bundle: bundle} return } loadedAny := false for _, entry := range entries { if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".json") { filePath := "locales/" + entry.Name() _, err := bundle.LoadMessageFileFS(localeFS, filePath) if err != nil { // Log error but continue with other files continue } loadedAny = true } } if !loadedAny { // If no files were loaded successfully, create minimal service globalService = &LocalizationService{bundle: bundle} return } globalService = &LocalizationService{bundle: bundle} }) // Safety check - if globalService is still nil, create a minimal one if globalService == nil { bundle := i18n.NewBundle(language.English) bundle.RegisterUnmarshalFunc("json", json.Unmarshal) globalService = &LocalizationService{bundle: bundle} } return globalService } func (ls *LocalizationService) GetLocalizer(lang string) *i18n.Localizer { return i18n.NewLocalizer(ls.bundle, lang) } func (ls *LocalizationService) GetLocalizerFromRequest(r *http.Request) *i18n.Localizer { if ls == nil || ls.bundle == nil { // Fallback to a basic bundle if service is not properly initialized bundle := i18n.NewBundle(language.English) bundle.RegisterUnmarshalFunc("json", json.Unmarshal) return i18n.NewLocalizer(bundle, "en") } acceptLanguage := r.Header.Get("Accept-Language") // Parse Accept-Language header to properly handle quality factors // The language.ParseAcceptLanguage function returns tags sorted by quality tags, _, err := language.ParseAcceptLanguage(acceptLanguage) if err != nil || len(tags) == 0 { return i18n.NewLocalizer(ls.bundle, "en") } // Convert parsed tags to strings for the localizer // We include both the full tag and base language to ensure proper matching langs := make([]string, 0, len(tags)*2+1) for _, tag := range tags { langs = append(langs, tag.String()) // Also add base language (e.g., "en" for "en-GB") to help matching base, _ := tag.Base() if base.String() != tag.String() { langs = append(langs, base.String()) } } langs = append(langs, "en") // Always include English as fallback return i18n.NewLocalizer(ls.bundle, langs...) } // SimpleLocalizer wraps i18n.Localizer with a more convenient API type SimpleLocalizer struct { Localizer *i18n.Localizer } // T provides a concise way to localize messages func (sl *SimpleLocalizer) T(messageID string) string { return sl.Localizer.MustLocalize(&i18n.LocalizeConfig{MessageID: messageID}) } // Get the language that is used by the localizer by retrieving a well-known string that is required to be present func (sl *SimpleLocalizer) GetLang() string { _, tag, err := sl.Localizer.LocalizeWithTag(&i18n.LocalizeConfig{MessageID: "loading"}) if err != nil { return "en" } return tag.String() } // GetLocalizer creates a localizer based on the request's Accept-Language header or forcedLanguage option func GetLocalizer(r *http.Request) *SimpleLocalizer { var localizer *i18n.Localizer if anubis.ForcedLanguage == "" { localizer = NewLocalizationService().GetLocalizerFromRequest(r) } else { localizer = NewLocalizationService().GetLocalizer(anubis.ForcedLanguage) } return &SimpleLocalizer{Localizer: localizer} } ================================================ FILE: lib/localization/localization_test.go ================================================ package localization import ( "encoding/json" "fmt" "net/http/httptest" "sort" "testing" "github.com/nicksnyder/go-i18n/v2/i18n" ) func TestLocalizationService(t *testing.T) { service := NewLocalizationService() loadingStrMap := map[string]string{ "de": "Ladevorgang...", "en": "Loading...", "es": "Cargando...", "et": "Laadin...", "fil": "Naglo-load...", "fr": "Chargement...", "ja": "ロード中...", "is": "Hleður...", "nb": "Laster inn...", "nl": "Laden...", "nn": "Lastar inn...", "pl": "Ładowanie...", "pt-BR": "Carregando...", "tr": "Yükleniyor...", "ru": "Загрузка...", "uk": "Завантаження...", "vi": "Đang nạp...", "zh-CN": "加载中...", "zh-TW": "載入中...", "sv": "Laddar...", } var keys []string for lang := range loadingStrMap { keys = append(keys, lang) } sort.Strings(keys) for _, lang := range keys { expected := loadingStrMap[lang] t.Run(fmt.Sprintf("%s localization", lang), func(t *testing.T) { localizer := service.GetLocalizer(lang) result := localizer.MustLocalize(&i18n.LocalizeConfig{MessageID: "loading"}) if result != expected { t.Errorf("Expected '%s', got '%s'", expected, result) } }) } // Test for requiredKeys localization requiredKeys := []string{ "loading", "why_am_i_seeing", "protected_by", "protected_from", "made_with", "mascot_design", "try_again", "go_home", "javascript_required", } for _, lang := range keys { t.Run(fmt.Sprintf("All required keys exist in %s", lang), func(t *testing.T) { loc := service.GetLocalizer(lang) for _, key := range requiredKeys { result := loc.MustLocalize(&i18n.LocalizeConfig{MessageID: key}) if result == "" { t.Errorf("Key '%s' returned empty string", key) } } }) } } type manifest struct { SupportedLanguages []string `json:"supportedLanguages"` } func loadManifest(t *testing.T) manifest { t.Helper() fin, err := localeFS.Open("locales/manifest.json") if err != nil { t.Fatal(err) } defer fin.Close() var result manifest if err := json.NewDecoder(fin).Decode(&result); err != nil { t.Fatal(err) } return result } func TestComprehensiveTranslations(t *testing.T) { service := NewLocalizationService() var translations = map[string]any{} fin, err := localeFS.Open("locales/en.json") if err != nil { t.Fatal(err) } defer fin.Close() if err := json.NewDecoder(fin).Decode(&translations); err != nil { t.Fatal(err) } var keys []string for k := range translations { keys = append(keys, k) } sort.Strings(keys) manifest := loadManifest(t) if len(manifest.SupportedLanguages) == 0 { t.Fatal("no languages loaded") } for _, lang := range loadManifest(t).SupportedLanguages { t.Run(lang, func(t *testing.T) { loc := service.GetLocalizer(lang) sl := SimpleLocalizer{Localizer: loc} service_lang := sl.GetLang() if service_lang != lang { t.Error("Localizer language not same as specified") } for _, key := range keys { t.Run(key, func(t *testing.T) { if result := sl.T(key); result == "" { t.Error("key not defined") } }) } }) } } func TestAcceptLanguageQualityFactors(t *testing.T) { service := NewLocalizationService() testCases := []struct { name string acceptLanguage string expectedLang string }{ {"simple_en", "en", "en"}, {"simple_de", "de", "de"}, {"en_GB_with_lower_priority_de", "en-GB,de-DE;q=0.5", "en"}, {"en_GB_only", "en-GB", "en"}, {"de_with_lower_priority_en", "de,en;q=0.5", "de"}, {"de_DE_with_lower_priority_en", "de-DE,en;q=0.5", "de"}, {"fr_with_lower_priority_de", "fr,de;q=0.5", "fr"}, {"zh_CN_regional", "zh-CN", "zh-CN"}, {"zh_TW_regional", "zh-TW", "zh-TW"}, {"pt_BR_regional", "pt-BR", "pt-BR"}, {"complex_header", "fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7,de;q=0.5", "fr"}, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { req := httptest.NewRequest("GET", "/", nil) req.Header.Set("Accept-Language", tc.acceptLanguage) localizer := service.GetLocalizerFromRequest(req) sl := &SimpleLocalizer{Localizer: localizer} gotLang := sl.GetLang() if gotLang != tc.expectedLang { t.Errorf("Accept-Language %q: expected %s, got %s", tc.acceptLanguage, tc.expectedLang, gotLang) } }) } } ================================================ FILE: lib/policy/bot.go ================================================ package policy import ( "fmt" "github.com/TecharoHQ/anubis/internal" "github.com/TecharoHQ/anubis/lib/config" "github.com/TecharoHQ/anubis/lib/policy/checker" ) type Bot struct { Rules checker.Impl Challenge *config.ChallengeRules Weight *config.Weight Name string Action config.Rule } func (b Bot) Hash() string { return internal.FastHash(fmt.Sprintf("%s::%s", b.Name, b.Rules.Hash())) } ================================================ FILE: lib/policy/celchecker.go ================================================ package policy import ( "fmt" "net/http" "github.com/TecharoHQ/anubis/internal" "github.com/TecharoHQ/anubis/internal/dns" "github.com/TecharoHQ/anubis/lib/config" "github.com/TecharoHQ/anubis/lib/policy/expressions" "github.com/google/cel-go/cel" "github.com/google/cel-go/common/types" ) type CELChecker struct { program cel.Program src string } func NewCELChecker(cfg *config.ExpressionOrList, dnsObj *dns.Dns) (*CELChecker, error) { env, err := expressions.BotEnvironment(dnsObj) if err != nil { return nil, err } program, err := expressions.Compile(env, cfg.String()) if err != nil { return nil, fmt.Errorf("can't compile CEL program: %w", err) } return &CELChecker{ src: cfg.String(), program: program, }, nil } func (cc *CELChecker) Hash() string { return internal.FastHash(cc.src) } func (cc *CELChecker) Check(r *http.Request) (bool, error) { result, _, err := cc.program.ContextEval(r.Context(), &CELRequest{r}) if err != nil { return false, err } if val, ok := result.(types.Bool); ok { return bool(val), nil } return false, nil } type CELRequest struct { *http.Request } func (cr *CELRequest) Parent() cel.Activation { return nil } func (cr *CELRequest) ResolveName(name string) (any, bool) { switch name { case "remoteAddress": return cr.Header.Get("X-Real-Ip"), true case "contentLength": return cr.ContentLength, true case "host": return cr.Host, true case "method": return cr.Method, true case "userAgent": return cr.UserAgent(), true case "path": return cr.URL.Path, true case "query": return expressions.URLValues{Values: cr.URL.Query()}, true case "headers": return expressions.HTTPHeaders{Header: cr.Header}, true case "load_1m": return expressions.Load1(), true case "load_5m": return expressions.Load5(), true case "load_15m": return expressions.Load15(), true default: return nil, false } } ================================================ FILE: lib/policy/checker/checker.go ================================================ // Package checker defines the Checker interface and a helper utility to avoid import cycles. package checker import ( "fmt" "net/http" "strings" "github.com/TecharoHQ/anubis/internal" ) type Impl interface { Check(*http.Request) (bool, error) Hash() string } type Func func(*http.Request) (bool, error) func (f Func) Check(r *http.Request) (bool, error) { return f(r) } func (f Func) Hash() string { return internal.FastHash(fmt.Sprintf("%#v", f)) } type List []Impl // Check runs each checker in the list against the request. // It returns true only if *all* checkers return true (AND semantics). // If any checker returns an error, the function returns false and the error. func (l List) Check(r *http.Request) (bool, error) { for _, c := range l { ok, err := c.Check(r) if err != nil { // Propagate the error; overall result is false. return false, err } if !ok { // One false means the combined result is false. Short-circuit // so we don't waste time. return false, err } } // Assume success until a checker says otherwise. return true, nil } func (l List) Hash() string { var sb strings.Builder for _, c := range l { fmt.Fprintln(&sb, c.Hash()) } return internal.FastHash(sb.String()) } ================================================ FILE: lib/policy/checker/checker_test.go ================================================ package checker import ( "errors" "net/http" "testing" ) // Mock implements the Impl interface for testing. type Mock struct { result bool err error hash string } func (m Mock) Check(r *http.Request) (bool, error) { return m.result, m.err } func (m Mock) Hash() string { return m.hash } func TestListCheck_AndSemantics(t *testing.T) { req, _ := http.NewRequest(http.MethodGet, "http://example.com", nil) tests := []struct { name string list List want bool wantErr bool }{ { name: "all true", list: List{Mock{true, nil, "a"}, Mock{true, nil, "b"}}, want: true, }, { name: "one false", list: List{Mock{true, nil, "a"}, Mock{false, nil, "b"}}, want: false, }, { name: "error propagates", list: List{Mock{true, nil, "a"}, Mock{true, errors.New("boom"), "b"}}, want: false, wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := tt.list.Check(req) if (err != nil) != tt.wantErr { t.Fatalf("unexpected error state: %v", err) } if got != tt.want { t.Fatalf("expected %v, got %v", tt.want, got) } }) } } ================================================ FILE: lib/policy/checker.go ================================================ package policy import ( "errors" "fmt" "net/http" "net/netip" "regexp" "strings" "github.com/TecharoHQ/anubis/internal" "github.com/TecharoHQ/anubis/lib/policy/checker" "github.com/gaissmai/bart" ) var ( ErrMisconfiguration = errors.New("[unexpected] policy: administrator misconfiguration") ) type RemoteAddrChecker struct { prefixTable *bart.Lite hash string } func NewRemoteAddrChecker(cidrs []string) (checker.Impl, error) { table := new(bart.Lite) for _, cidr := range cidrs { prefix, err := netip.ParsePrefix(cidr) if err != nil { return nil, fmt.Errorf("%w: range %s not parsing: %w", ErrMisconfiguration, cidr, err) } table.Insert(prefix) } return &RemoteAddrChecker{ prefixTable: table, hash: internal.FastHash(strings.Join(cidrs, ",")), }, nil } func (rac *RemoteAddrChecker) Check(r *http.Request) (bool, error) { host := r.Header.Get("X-Real-Ip") if host == "" { return false, fmt.Errorf("%w: header X-Real-Ip is not set", ErrMisconfiguration) } addr, err := netip.ParseAddr(host) if err != nil { return false, fmt.Errorf("%w: %s is not an IP address: %w", ErrMisconfiguration, host, err) } // Convert IPv4-mapped IPv6 addresses to IPv4 if addr.Is6() && addr.Is4In6() { addr = addr.Unmap() } return rac.prefixTable.Contains(addr), nil } func (rac *RemoteAddrChecker) Hash() string { return rac.hash } type HeaderMatchesChecker struct { header string regexp *regexp.Regexp hash string } func NewUserAgentChecker(rexStr string) (checker.Impl, error) { return NewHeaderMatchesChecker("User-Agent", rexStr) } func NewHeaderMatchesChecker(header, rexStr string) (checker.Impl, error) { rex, err := regexp.Compile(strings.TrimSpace(rexStr)) if err != nil { return nil, fmt.Errorf("%w: regex %s failed parse: %w", ErrMisconfiguration, rexStr, err) } return &HeaderMatchesChecker{strings.TrimSpace(header), rex, internal.FastHash(header + ": " + rexStr)}, nil } func (hmc *HeaderMatchesChecker) Check(r *http.Request) (bool, error) { if hmc.regexp.MatchString(r.Header.Get(hmc.header)) { return true, nil } return false, nil } func (hmc *HeaderMatchesChecker) Hash() string { return hmc.hash } type PathChecker struct { regexp *regexp.Regexp hash string } func NewPathChecker(rexStr string) (checker.Impl, error) { rex, err := regexp.Compile(strings.TrimSpace(rexStr)) if err != nil { return nil, fmt.Errorf("%w: regex %s failed parse: %w", ErrMisconfiguration, rexStr, err) } return &PathChecker{rex, internal.FastHash(rexStr)}, nil } func (pc *PathChecker) Check(r *http.Request) (bool, error) { originalUrl := r.Header.Get("X-Original-URI") if originalUrl != "" { if pc.regexp.MatchString(originalUrl) { return true, nil } } if pc.regexp.MatchString(r.URL.Path) { return true, nil } return false, nil } func (pc *PathChecker) Hash() string { return pc.hash } func NewHeaderExistsChecker(key string) checker.Impl { return headerExistsChecker{strings.TrimSpace(key)} } type headerExistsChecker struct { header string } func (hec headerExistsChecker) Check(r *http.Request) (bool, error) { if r.Header.Get(hec.header) != "" { return true, nil } return false, nil } func (hec headerExistsChecker) Hash() string { return internal.FastHash(hec.header) } func NewHeadersChecker(headermap map[string]string) (checker.Impl, error) { var result checker.List var errs []error for key, rexStr := range headermap { if rexStr == ".*" { result = append(result, headerExistsChecker{strings.TrimSpace(key)}) continue } rex, err := regexp.Compile(strings.TrimSpace(rexStr)) if err != nil { errs = append(errs, fmt.Errorf("while compiling header %s regex %s: %w", key, rexStr, err)) continue } result = append(result, &HeaderMatchesChecker{key, rex, internal.FastHash(key + ": " + rexStr)}) } if len(errs) != 0 { return nil, errors.Join(errs...) } return result, nil } ================================================ FILE: lib/policy/checker_test.go ================================================ package policy import ( "errors" "net/http" "testing" ) func TestRemoteAddrChecker(t *testing.T) { for _, tt := range []struct { err error name string ip string cidrs []string ok bool }{ { name: "match_ipv4", cidrs: []string{"0.0.0.0/0"}, ip: "1.1.1.1", ok: true, err: nil, }, { name: "match_ipv4_in_ipv6", cidrs: []string{"0.0.0.0/0"}, ip: "::ffff:1.1.1.1", ok: true, err: nil, }, { name: "match_ipv4_in_ipv6_hex", cidrs: []string{"0.0.0.0/0"}, ip: "::ffff:101:101", ok: true, err: nil, }, { name: "match_ipv6", cidrs: []string{"::/0"}, ip: "cafe:babe::", ok: true, err: nil, }, { name: "not_match_ipv4", cidrs: []string{"1.1.1.1/32"}, ip: "1.1.1.2", ok: false, err: nil, }, { name: "not_match_ipv6", cidrs: []string{"cafe:babe::/128"}, ip: "cafe:babe:4::/128", ok: false, err: nil, }, { name: "no_ip_set", cidrs: []string{"::/0"}, ok: false, err: ErrMisconfiguration, }, { name: "invalid_ip", cidrs: []string{"::/0"}, ip: "According to all natural laws of aviation", ok: false, err: ErrMisconfiguration, }, } { t.Run(tt.name, func(t *testing.T) { rac, err := NewRemoteAddrChecker(tt.cidrs) if err != nil && !errors.Is(err, tt.err) { t.Fatalf("creating RemoteAddrChecker failed: %v", err) } r, err := http.NewRequest(http.MethodGet, "/", nil) if err != nil { t.Fatalf("can't make request: %v", err) } if tt.ip != "" { r.Header.Add("X-Real-Ip", tt.ip) } ok, err := rac.Check(r) if tt.ok != ok { t.Errorf("ok: %v, wanted: %v", ok, tt.ok) } if err != nil && tt.err != nil && !errors.Is(err, tt.err) { t.Errorf("err: %v, wanted: %v", err, tt.err) } }) } } func TestHeaderMatchesChecker(t *testing.T) { for _, tt := range []struct { err error name string header string rexStr string reqHeaderKey string reqHeaderValue string ok bool }{ { name: "match", header: "Cf-Worker", rexStr: ".*", reqHeaderKey: "Cf-Worker", reqHeaderValue: "true", ok: true, err: nil, }, { name: "not_match", header: "Cf-Worker", rexStr: "false", reqHeaderKey: "Cf-Worker", reqHeaderValue: "true", ok: false, err: nil, }, { name: "not_present", header: "Cf-Worker", rexStr: "foobar", reqHeaderKey: "Something-Else", reqHeaderValue: "true", ok: false, err: nil, }, { name: "invalid_regex", rexStr: "a(b", err: ErrMisconfiguration, }, } { t.Run(tt.name, func(t *testing.T) { hmc, err := NewHeaderMatchesChecker(tt.header, tt.rexStr) if err != nil && !errors.Is(err, tt.err) { t.Fatalf("creating HeaderMatchesChecker failed") } if tt.err != nil && hmc == nil { return } r, err := http.NewRequest(http.MethodGet, "/", nil) if err != nil { t.Fatalf("can't make request: %v", err) } r.Header.Set(tt.reqHeaderKey, tt.reqHeaderValue) ok, err := hmc.Check(r) if tt.ok != ok { t.Errorf("ok: %v, wanted: %v", ok, tt.ok) } if err != nil && tt.err != nil && !errors.Is(err, tt.err) { t.Errorf("err: %v, wanted: %v", err, tt.err) } }) } } func TestHeaderExistsChecker(t *testing.T) { for _, tt := range []struct { name string header string reqHeader string ok bool }{ { name: "match", header: "Authorization", reqHeader: "Authorization", ok: true, }, { name: "not_match", header: "Authorization", reqHeader: "Authentication", }, } { t.Run(tt.name, func(t *testing.T) { hec := headerExistsChecker{tt.header} r, err := http.NewRequest(http.MethodGet, "/", nil) if err != nil { t.Fatalf("can't make request: %v", err) } r.Header.Set(tt.reqHeader, "hunter2") ok, err := hec.Check(r) if tt.ok != ok { t.Errorf("ok: %v, wanted: %v", ok, tt.ok) } if err != nil { t.Errorf("err: %v", err) } }) } } func TestPathChecker_XOriginalURI(t *testing.T) { tests := []struct { name string regex string xOriginalURI string urlPath string headerKey string expectedMatch bool expectError bool }{ { name: "X-Original-URI matches regex (with trailing space - current typo)", regex: "^/api/.*", xOriginalURI: "/api/users", urlPath: "/different/path", headerKey: "X-Original-URI", expectedMatch: true, expectError: false, }, { name: "X-Original-URI doesn't match, falls back to URL.Path", regex: "^/admin/.*", xOriginalURI: "/api/users", urlPath: "/admin/dashboard", headerKey: "X-Original-URI", expectedMatch: true, expectError: false, }, { name: "Neither X-Original-URI nor URL.Path match", regex: "^/admin/.*", xOriginalURI: "/api/users", urlPath: "/public/info", headerKey: "X-Original-URI ", expectedMatch: false, expectError: false, }, { name: "Empty X-Original-URI, URL.Path matches", regex: "^/static/.*", xOriginalURI: "", urlPath: "/static/css/style.css", headerKey: "X-Original-URI", expectedMatch: true, expectError: false, }, { name: "Complex regex matching X-Original-URI", regex: `^/api/v[0-9]+/(users|posts)/[0-9]+$`, xOriginalURI: "/api/v1/users/123", urlPath: "/different", headerKey: "X-Original-URI", expectedMatch: true, expectError: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Create the PathChecker pc, err := NewPathChecker(tt.regex) if err != nil { if !tt.expectError { t.Fatalf("NewPathChecker() unexpected error: %v", err) } return } if tt.expectError { t.Fatal("NewPathChecker() expected error but got none") } req, err := http.NewRequest("GET", "http://example.com"+tt.urlPath, nil) if err != nil { t.Fatalf("Failed to create request: %v", err) } if tt.xOriginalURI != "" { req.Header.Set(tt.headerKey, tt.xOriginalURI) } match, err := pc.Check(req) if err != nil { t.Fatalf("Check() unexpected error: %v", err) } if match != tt.expectedMatch { t.Errorf("Check() = %v, want %v", match, tt.expectedMatch) } }) } } ================================================ FILE: lib/policy/checkresult.go ================================================ package policy import ( "log/slog" "github.com/TecharoHQ/anubis/lib/config" ) type CheckResult struct { Name string Rule config.Rule Weight int } func (cr CheckResult) LogValue() slog.Value { return slog.GroupValue( slog.String("name", cr.Name), slog.String("rule", string(cr.Rule)), slog.Int("weight", cr.Weight), ) } ================================================ FILE: lib/policy/expressions/README.md ================================================ # Expressions support The expressions support is based on ideas from [go-away](https://git.gammaspectra.live/git/go-away) but with different opinions about how things should be done. ================================================ FILE: lib/policy/expressions/environment.go ================================================ package expressions import ( "math/rand/v2" "strings" "github.com/TecharoHQ/anubis/internal/dns" "github.com/google/cel-go/cel" "github.com/google/cel-go/common/types" "github.com/google/cel-go/common/types/ref" "github.com/google/cel-go/common/types/traits" "github.com/google/cel-go/ext" ) // BotEnvironment creates a new CEL environment, this is the set of // variables and functions that are passed into the CEL scope so that // Anubis can fail loudly and early when something is invalid instead // of blowing up at runtime. func BotEnvironment(dnsObj *dns.Dns) (*cel.Env, error) { return New( // Variables exposed to CEL programs: cel.Variable("remoteAddress", cel.StringType), cel.Variable("contentLength", cel.IntType), cel.Variable("host", cel.StringType), cel.Variable("method", cel.StringType), cel.Variable("userAgent", cel.StringType), cel.Variable("path", cel.StringType), cel.Variable("query", cel.MapType(cel.StringType, cel.StringType)), cel.Variable("headers", cel.MapType(cel.StringType, cel.StringType)), cel.Variable("load_1m", cel.DoubleType), cel.Variable("load_5m", cel.DoubleType), cel.Variable("load_15m", cel.DoubleType), // Bot-specific functions: cel.Function("missingHeader", cel.Overload("missingHeader_map_string_string_string", []*cel.Type{cel.MapType(cel.StringType, cel.StringType), cel.StringType}, cel.BoolType, cel.BinaryBinding(func(headers, key ref.Val) ref.Val { // Convert headers to a trait that supports Find headersMap, ok := headers.(traits.Indexer) if !ok { return types.ValOrErr(headers, "headers is not a map, but is %T", headers) } keyStr, ok := key.(types.String) if !ok { return types.ValOrErr(key, "key is not a string, but is %T", key) } val := headersMap.Get(keyStr) // Check if the key is missing by testing for an error if types.IsError(val) { return types.Bool(true) // header is missing } return types.Bool(false) // header is present }), ), ), cel.Function("reverseDNS", cel.Overload("reverseDNS_string_list_string", []*cel.Type{cel.StringType}, cel.ListType(cel.StringType), cel.UnaryBinding(func(addr ref.Val) ref.Val { addrStr, ok := addr.(types.String) if !ok { return types.ValOrErr(addr, "addr is not a string, but is %T", addr) } names, err := dnsObj.ReverseDNS(string(addrStr)) if err != nil { return types.NewStringList(types.DefaultTypeAdapter, []string{}) } return types.NewStringList(types.DefaultTypeAdapter, names) }), ), ), cel.Function("lookupHost", cel.Overload("lookupHost_string_list_string", []*cel.Type{cel.StringType}, cel.ListType(cel.StringType), cel.UnaryBinding(func(host ref.Val) ref.Val { hostStr, ok := host.(types.String) if !ok { return types.ValOrErr(host, "host is not a string, but is %T", host) } addrs, err := dnsObj.LookupHost(string(hostStr)) if err != nil { return types.NewStringList(types.DefaultTypeAdapter, []string{}) } return types.NewStringList(types.DefaultTypeAdapter, addrs) }), ), ), cel.Function("verifyFCrDNS", cel.Overload("verifyFCrDNS_string_bool", []*cel.Type{cel.StringType}, cel.BoolType, cel.UnaryBinding(func(addr ref.Val) ref.Val { addrStr, ok := addr.(types.String) if !ok { return types.ValOrErr(addr, "addr is not a string") } return types.Bool(dnsObj.VerifyFCrDNS(string(addrStr), nil)) }), ), cel.Overload("verifyFCrDNS_string_string_bool", []*cel.Type{cel.StringType, cel.StringType}, cel.BoolType, cel.BinaryBinding(func(addr, pattern ref.Val) ref.Val { addrStr, ok := addr.(types.String) if !ok { return types.ValOrErr(addr, "addr is not a string") } patternStr, ok := pattern.(types.String) if !ok { return types.ValOrErr(pattern, "pattern is not a string") } p := string(patternStr) return types.Bool(dnsObj.VerifyFCrDNS(string(addrStr), &p)) }), ), ), // arpaReverseIP transforms ip into arpa reverse notation like this // 1.2.3.4 -> 4.3.2.1 // 2001:db8::1 -> 1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2 cel.Function("arpaReverseIP", cel.Overload("arpaReverseIP_string_string", []*cel.Type{cel.StringType}, cel.StringType, cel.UnaryBinding(func(addr ref.Val) ref.Val { s, ok := addr.(types.String) if !ok { return types.ValOrErr(addr, "addr is not a string") } reversedIp, err := dnsObj.ArpaReverseIP(string(s)) if err != nil { return types.ValOrErr(addr, "%s", err.Error()) } return types.String(reversedIp) }), ), ), // regexSafe escapes a string for insertion into a regular expression cel.Function("regexSafe", cel.Overload("regexSafe_string_string", []*cel.Type{cel.StringType}, cel.StringType, cel.UnaryBinding(func(str ref.Val) ref.Val { s, ok := str.(types.String) if !ok { return types.ValOrErr(str, "addr is not a string") } escapes := []string{"\\", ".", ":", "*", "?", "-", "[", "]", "(", ")", "+", "{", "}", "|", "^", "$"} r := string(s) for _, escape := range escapes { r = strings.ReplaceAll(r, escape, "\\"+escape) } return types.String(r) }), ), ), cel.Function("segments", cel.Overload("segments_string_list_string", []*cel.Type{cel.StringType}, cel.ListType(cel.StringType), cel.UnaryBinding(func(path ref.Val) ref.Val { pathStrType, ok := path.(types.String) if !ok { return types.ValOrErr(path, "path is not a string, but is %T", path) } pathStr := string(pathStrType) if !strings.HasPrefix(pathStr, "/") { return types.ValOrErr(path, "path does not start with /") } pathList := strings.Split(string(pathStr), "/")[1:] return types.NewStringList(types.DefaultTypeAdapter, pathList) }), ), ), ) } // NewThreshold creates a new CEL environment for threshold checking. func ThresholdEnvironment() (*cel.Env, error) { return New( cel.Variable("weight", cel.IntType), ) } func New(opts ...cel.EnvOption) (*cel.Env, error) { args := []cel.EnvOption{ ext.Strings( ext.StringsLocale("en_US"), ext.StringsValidateFormatCalls(true), ), // default all timestamps to UTC cel.DefaultUTCTimeZone(true), // Functions exposed to all CEL programs: cel.Function("randInt", cel.Overload("randInt_int", []*cel.Type{cel.IntType}, cel.IntType, cel.UnaryBinding(func(val ref.Val) ref.Val { n, ok := val.(types.Int) if !ok { return types.ValOrErr(val, "value is not an integer, but is %T", val) } return types.Int(rand.IntN(int(n))) }), ), ), } args = append(args, opts...) return cel.NewEnv(args...) } // Compile takes CEL environment and syntax tree then emits an optimized // Program for execution. func Compile(env *cel.Env, src string) (cel.Program, error) { intermediate, iss := env.Compile(src) if iss != nil { return nil, iss.Err() } ast, iss := env.Check(intermediate) if iss != nil { return nil, iss.Err() } return env.Program( ast, cel.EvalOptions( // optimize regular expressions right now instead of on the fly cel.OptOptimize, ), ) } ================================================ FILE: lib/policy/expressions/environment_test.go ================================================ package expressions import ( "context" "errors" "net" "strings" "testing" "github.com/TecharoHQ/anubis/internal/dns" "github.com/TecharoHQ/anubis/lib/store/memory" "github.com/google/cel-go/common/types" "github.com/google/cel-go/common/types/ref" ) // newTestDNS is a helper function to create a new Dns object with an in-memory cache for testing. func newTestDNS(forwardTTL int, reverseTTL int) *dns.Dns { ctx := context.Background() memStore := memory.New(ctx) cache := dns.NewDNSCache(forwardTTL, reverseTTL, memStore) return dns.New(ctx, cache) } func TestBotEnvironment(t *testing.T) { dnsObj := newTestDNS(300, 300) env, err := BotEnvironment(dnsObj) if err != nil { t.Fatalf("failed to create bot environment: %v", err) } t.Run("missingHeader", func(t *testing.T) { tests := []struct { headers map[string]string name string expression string description string expected types.Bool }{ { name: "missing-header", expression: `missingHeader(headers, "Missing-Header")`, headers: map[string]string{ "User-Agent": "test-agent", "Content-Type": "application/json", }, expected: types.Bool(true), description: "should return true when header is missing", }, { name: "existing-header", expression: `missingHeader(headers, "User-Agent")`, headers: map[string]string{ "User-Agent": "test-agent", "Content-Type": "application/json", }, expected: types.Bool(false), description: "should return false when header exists", }, { name: "case-sensitive", expression: `missingHeader(headers, "user-agent")`, headers: map[string]string{ "User-Agent": "test-agent", }, expected: types.Bool(true), description: "should be case-sensitive (user-agent != User-Agent)", }, { name: "empty-headers", expression: `missingHeader(headers, "Any-Header")`, headers: map[string]string{}, expected: types.Bool(true), description: "should return true for any header when map is empty", }, { name: "real-world-sec-ch-ua", expression: `missingHeader(headers, "Sec-Ch-Ua")`, headers: map[string]string{ "User-Agent": "curl/7.68.0", "Accept": "*/*", "Host": "example.com", }, expected: types.Bool(true), description: "should detect missing browser-specific headers from bots", }, { name: "browser-with-sec-ch-ua", expression: `missingHeader(headers, "Sec-Ch-Ua")`, headers: map[string]string{ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Sec-Ch-Ua": `"Chrome"; v="91", "Not A Brand"; v="99"`, "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", }, expected: types.Bool(false), description: "should return false when browser sends Sec-Ch-Ua header", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { prog, err := Compile(env, tt.expression) if err != nil { t.Fatalf("failed to compile expression %q: %v", tt.expression, err) } result, _, err := prog.Eval(map[string]any{ "headers": tt.headers, }) if err != nil { t.Fatalf("failed to evaluate expression %q: %v", tt.expression, err) } if result != tt.expected { t.Errorf("%s: expected %v, got %v", tt.description, tt.expected, result) } }) } t.Run("function-compilation", func(t *testing.T) { src := `missingHeader(headers, "Test-Header")` _, err := Compile(env, src) if err != nil { t.Fatalf("failed to compile missingHeader expression: %v", err) } }) }) t.Run("segments", func(t *testing.T) { for _, tt := range []struct { name string description string expression string path string expected types.Bool }{ { name: "simple", description: "/ should have one path segment", expression: `size(segments(path)) == 1`, path: "/", expected: types.Bool(true), }, { name: "two segments without trailing slash", description: "/user/foo should have two segments", expression: `size(segments(path)) == 2`, path: "/user/foo", expected: types.Bool(true), }, { name: "at least two segments", description: "/foo/bar/ should have at least two path segments", expression: `size(segments(path)) >= 2`, path: "/foo/bar/", expected: types.Bool(true), }, { name: "at most two segments", description: "/foo/bar/ does not have less than two path segments", expression: `size(segments(path)) < 2`, path: "/foo/bar/", expected: types.Bool(false), }, } { t.Run(tt.name, func(t *testing.T) { prog, err := Compile(env, tt.expression) if err != nil { t.Fatalf("failed to compile expression %q: %v", tt.expression, err) } result, _, err := prog.Eval(map[string]any{ "path": tt.path, }) if err != nil { t.Fatalf("failed to evaluate expression %q: %v", tt.expression, err) } if result != tt.expected { t.Errorf("%s: expected %v, got %v", tt.description, tt.expected, result) } }) } t.Run("invalid", func(t *testing.T) { for _, tt := range []struct { env any name string description string expression string wantFailCompile bool wantFailEval bool }{ { name: "segments of headers", description: "headers are not a path list", expression: `segments(headers)`, env: map[string]any{ "headers": map[string]string{ "foo": "bar", }, }, wantFailCompile: true, }, { name: "invalid path type", description: "a path should be a sting", expression: `size(segments(path)) != 0`, env: map[string]any{ "path": 4, }, wantFailEval: true, }, { name: "invalid path", description: "a path should start with a leading slash", expression: `size(segments(path)) != 0`, env: map[string]any{ "path": "foo", }, wantFailEval: true, }, } { t.Run(tt.name, func(t *testing.T) { prog, err := Compile(env, tt.expression) if err != nil { if !tt.wantFailCompile { t.Log(tt.description) t.Fatalf("failed to compile expression %q: %v", tt.expression, err) } else { return } } _, _, err = prog.Eval(tt.env) if err == nil { t.Log(tt.description) t.Fatal("wanted an error but got none") } t.Log(err) }) } }) t.Run("function-compilation", func(t *testing.T) { src := `size(segments(path)) <= 2` _, err := Compile(env, src) if err != nil { t.Fatalf("failed to compile missingHeader expression: %v", err) } }) }) t.Run("regexSafe", func(t *testing.T) { tests := []struct { name string expression string expected types.String description string }{ { name: "complex-test", expression: `regexSafe("^(test1|test2|)[a-z]+$")`, expected: types.String("\\^\\(test1\\|test2\\|\\)\\[a\\-z\\]\\+\\$"), description: "should escape all reserved regex characters", }, { name: "backslash-test", expression: `regexSafe("use \\\\ for special characters escaping\t, one/\"\\\"/for/cel and one/for/regex")`, expected: types.String("use \\\\\\\\ for special characters escaping\t, one/\"\\\\\"/for/cel and one/for/regex"), description: "should escape double-backslashes as double-double-backslashes and ignore cel escaping and forward slashes", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { prog, err := Compile(env, tt.expression) if err != nil { t.Fatalf("failed to compile expression %q: %v", tt.expression, err) } result, _, err := prog.Eval(map[string]any{}) if err != nil { t.Fatalf("failed to evaluate expression %q: %v", tt.expression, err) } if result != tt.expected { t.Errorf("%s: expected %v, got %v", tt.description, tt.expected, result) } }) } t.Run("function-compilation", func(t *testing.T) { src := `regexSafe(".*")` _, err := Compile(env, src) if err != nil { t.Fatalf("failed to compile regexSafe expression: %v", err) } }) }) t.Run("dnsFunctions", func(t *testing.T) { originalDNSLookupAddr := dns.DNSLookupAddr originalDNSLookupHost := dns.DNSLookupHost defer func() { dns.DNSLookupAddr = originalDNSLookupAddr dns.DNSLookupHost = originalDNSLookupHost }() t.Run("reverseDNS", func(t *testing.T) { tests := []struct { name string addr string mockReturn []string mockError error expression string expected ref.Val description string }{ { name: "success", addr: "8.8.8.8", mockReturn: []string{"dns.google."}, expression: `reverseDNS("8.8.8.8")`, expected: types.NewStringList(types.DefaultTypeAdapter, []string{"dns.google"}), description: "should return domain names for an IP", }, { name: "not-found", addr: "127.0.0.1", mockReturn: []string{}, mockError: &net.DNSError{IsNotFound: true}, expression: `reverseDNS("127.0.0.1")`, expected: types.NewStringList(types.DefaultTypeAdapter, []string{}), description: "should return an empty list when not found", }, { name: "error", addr: "error-addr", mockError: errors.New("some dns error"), expression: `reverseDNS("error-addr")`, expected: types.NewStringList(types.DefaultTypeAdapter, []string{}), description: "should return empty list on error", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { dns.DNSLookupAddr = func(addr string) ([]string, error) { if addr == tt.addr { return tt.mockReturn, tt.mockError } return nil, errors.New("unexpected address for reverse lookup") } prog, err := Compile(env, tt.expression) if err != nil { t.Fatalf("failed to compile expression %q: %v", tt.expression, err) } result, _, err := prog.Eval(map[string]any{}) if err != nil { t.Fatalf("failed to evaluate expression %q: %v", tt.expression, err) } if result.Equal(tt.expected) != types.True { t.Errorf("%s: expected %v, got %v", tt.description, tt.expected, result) } }) } }) t.Run("lookupHost", func(t *testing.T) { tests := []struct { name string host string mockReturn []string mockError error expression string expected ref.Val description string }{ { name: "success", host: "dns.google", mockReturn: []string{"8.8.8.8", "8.8.4.4"}, expression: `lookupHost("dns.google")`, expected: types.NewStringList(types.DefaultTypeAdapter, []string{"8.8.8.8", "8.8.4.4"}), description: "should return IPs for a domain name", }, { name: "not-found", host: "nonexistent.domain.example.com", mockReturn: []string{}, mockError: &net.DNSError{IsNotFound: true}, expression: `lookupHost("nonexistent.domain.example.com")`, expected: types.NewStringList(types.DefaultTypeAdapter, []string{}), description: "should return an empty list when not found", }, { name: "error", host: "error-host", mockError: errors.New("some dns error"), expression: `lookupHost("error-host")`, expected: types.NewStringList(types.DefaultTypeAdapter, []string{}), description: "should return empty list on error", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { dns.DNSLookupHost = func(host string) ([]string, error) { if host == tt.host { return tt.mockReturn, tt.mockError } return nil, errors.New("unexpected host for forward lookup") } prog, err := Compile(env, tt.expression) if err != nil { t.Fatalf("failed to compile expression %q: %v", tt.expression, err) } result, _, err := prog.Eval(map[string]any{}) if err != nil { t.Fatalf("failed to evaluate expression %q: %v", tt.expression, err) } if result.Equal(tt.expected) != types.True { t.Errorf("%s: expected %v, got %v", tt.description, tt.expected, result) } }) } }) t.Run("verifyFCrDNS", func(t *testing.T) { tests := []struct { name string addr string reverseMockReturn []string reverseMockError error forwardMockReturn map[string][]string // name -> ips forwardMockError map[string]error expression string expected types.Bool description string }{ { name: "success", addr: "8.8.8.8", reverseMockReturn: []string{"dns.google."}, forwardMockReturn: map[string][]string{"dns.google": {"8.8.8.8", "8.8.4.4"}}, expression: `verifyFCrDNS("8.8.8.8")`, expected: types.Bool(true), description: "should return true for valid FCrDNS", }, { name: "failure", addr: "1.2.3.4", reverseMockReturn: []string{"spoofed.example.com."}, forwardMockReturn: map[string][]string{"spoofed.example.com": {"5.6.7.8"}}, expression: `verifyFCrDNS("1.2.3.4")`, expected: types.Bool(false), description: "should return false for invalid FCrDNS", }, { name: "reverse-lookup-fails", addr: "1.1.1.1", reverseMockError: errors.New("reverse lookup failed"), expression: `verifyFCrDNS("1.1.1.1")`, expected: types.Bool(false), description: "should return false if reverse lookup fails", }, { name: "success-with-pattern", addr: "8.8.8.8", reverseMockReturn: []string{"dns.google."}, forwardMockReturn: map[string][]string{"dns.google": {"8.8.8.8"}}, expression: `verifyFCrDNS("8.8.8.8", "dns.google")`, expected: types.Bool(true), description: "should return true for valid FCrDNS with matching pattern", }, { name: "failure-with-pattern", addr: "8.8.8.8", reverseMockReturn: []string{"dns.google."}, forwardMockReturn: map[string][]string{"dns.google": {"8.8.8.8"}}, expression: `verifyFCrDNS("8.8.8.8", "wrong.pattern")`, expected: types.Bool(false), description: "should return false for FCrDNS with non-matching pattern", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { dns.DNSLookupAddr = func(addr string) ([]string, error) { if addr == tt.addr { return tt.reverseMockReturn, tt.reverseMockError } return nil, errors.New("unexpected address for reverse lookup") } dns.DNSLookupHost = func(host string) ([]string, error) { host = strings.TrimSuffix(host, ".") if ips, ok := tt.forwardMockReturn[host]; ok { return ips, nil } if err, ok := tt.forwardMockError[host]; ok { return nil, err } return nil, &net.DNSError{IsNotFound: true} } prog, err := Compile(env, tt.expression) if err != nil { t.Fatalf("failed to compile expression %q: %v", tt.expression, err) } result, _, err := prog.Eval(map[string]any{}) if err != nil { t.Fatalf("failed to evaluate expression %q: %v", tt.expression, err) } if result.Equal(tt.expected) != types.True { t.Errorf("%s: expected %v, got %v", tt.description, tt.expected, result) } }) } }) t.Run("arpaReverseIP", func(t *testing.T) { tests := []struct { name string expression string expected types.String description string evalError bool }{ { name: "ipv4", expression: `arpaReverseIP("1.2.3.4")`, expected: types.String("4.3.2.1"), description: "should correctly reverse an IPv4 address", }, { name: "ipv6", expression: `arpaReverseIP("2001:db8::1")`, expected: types.String("1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2"), description: "should correctly reverse an IPv6 address", }, { name: "ipv6-full", expression: `arpaReverseIP("2001:0db8:85a3:0000:0000:8a2e:0370:7334")`, expected: types.String("4.3.3.7.0.7.3.0.e.2.a.8.0.0.0.0.0.0.0.0.3.a.5.8.8.b.d.0.1.0.0.2"), description: "should correctly reverse a fully expanded IPv6 address", }, { name: "ipv6-loopback", expression: `arpaReverseIP("::1")`, expected: types.String("1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0"), description: "should correctly reverse the IPv6 loopback address", }, { name: "invalid-ip", expression: `arpaReverseIP("not-an-ip")`, evalError: true, description: "should error on an invalid IP", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { prog, err := Compile(env, tt.expression) if err != nil { t.Fatalf("failed to compile expression %q: %v", tt.expression, err) } result, _, err := prog.Eval(map[string]any{}) if tt.evalError { if err == nil { t.Errorf("%s: expected an evaluation error, but got none", tt.description) } return } if err != nil { t.Fatalf("failed to evaluate expression %q: %v", tt.expression, err) } if result.Equal(tt.expected) != types.True { t.Errorf("%s: expected %v, got %v", tt.description, tt.expected, result) } }) } }) }) } func TestThresholdEnvironment(t *testing.T) { env, err := ThresholdEnvironment() if err != nil { t.Fatalf("failed to create threshold environment: %v", err) } tests := []struct { variables map[string]any name string expression string description string expected types.Bool shouldCompile bool }{ { name: "weight-variable-available", expression: `weight > 100`, variables: map[string]any{"weight": 150}, expected: types.Bool(true), description: "should support weight variable in expressions", shouldCompile: true, }, { name: "weight-variable-false-case", expression: `weight > 100`, variables: map[string]any{"weight": 50}, expected: types.Bool(false), description: "should correctly evaluate weight comparisons", shouldCompile: true, }, { name: "missingHeader-not-available", expression: `missingHeader(headers, "Test")`, variables: map[string]any{}, expected: types.Bool(false), // not used description: "should not have missingHeader function available", shouldCompile: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { prog, err := Compile(env, tt.expression) if !tt.shouldCompile { if err == nil { t.Fatalf("%s: expected compilation to fail but it succeeded", tt.description) } return // Test passed - compilation failed as expected } if err != nil { t.Fatalf("failed to compile expression %q: %v", tt.expression, err) } result, _, err := prog.Eval(tt.variables) if err != nil { t.Fatalf("failed to evaluate expression %q: %v", tt.expression, err) } if result != tt.expected { t.Errorf("%s: expected %v, got %v", tt.description, tt.expected, result) } }) } } func TestNewEnvironment(t *testing.T) { env, err := New() if err != nil { t.Fatalf("failed to create new environment: %v", err) } tests := []struct { name string expression string variables map[string]any expectBool *bool // nil if we just want to test compilation or non-bool result description string shouldCompile bool }{ { name: "randInt-function-compilation", expression: `randInt(10)`, variables: map[string]any{}, expectBool: nil, // Don't check result, just compilation description: "should compile randInt function", shouldCompile: true, }, { name: "randInt-range-validation", expression: `randInt(10) >= 0 && randInt(10) < 10`, variables: map[string]any{}, expectBool: boolPtr(true), description: "should return values in correct range", shouldCompile: true, }, { name: "strings-extension-size", expression: `"hello".size() == 5`, variables: map[string]any{}, expectBool: boolPtr(true), description: "should support string extension functions", shouldCompile: true, }, { name: "strings-extension-contains", expression: `"hello world".contains("world")`, variables: map[string]any{}, expectBool: boolPtr(true), description: "should support string contains function", shouldCompile: true, }, { name: "strings-extension-startsWith", expression: `"hello world".startsWith("hello")`, variables: map[string]any{}, expectBool: boolPtr(true), description: "should support string startsWith function", shouldCompile: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { prog, err := Compile(env, tt.expression) if !tt.shouldCompile { if err == nil { t.Fatalf("%s: expected compilation to fail but it succeeded", tt.description) } return // Test passed - compilation failed as expected } if err != nil { t.Fatalf("failed to compile expression %q: %v", tt.expression, err) } // If we only want to test compilation, skip evaluation if tt.expectBool == nil { return } result, _, err := prog.Eval(tt.variables) if err != nil { t.Fatalf("failed to evaluate expression %q: %v", tt.expression, err) } if result != types.Bool(*tt.expectBool) { t.Errorf("%s: expected %v, got %v", tt.description, *tt.expectBool, result) } }) } } // Helper function to create bool pointers func boolPtr(b bool) *bool { return &b } ================================================ FILE: lib/policy/expressions/http_headers.go ================================================ package expressions import ( "net/http" "reflect" "strings" "github.com/google/cel-go/common/types" "github.com/google/cel-go/common/types/ref" "github.com/google/cel-go/common/types/traits" ) // HTTPHeaders is a type wrapper to expose HTTP headers into CEL programs. type HTTPHeaders struct { http.Header } func (h HTTPHeaders) ConvertToNative(typeDesc reflect.Type) (any, error) { return nil, ErrNotImplemented } func (h HTTPHeaders) ConvertToType(typeVal ref.Type) ref.Val { switch typeVal { case types.MapType: return h case types.TypeType: return types.MapType } return types.NewErr("can't convert from %q to %q", types.MapType, typeVal) } func (h HTTPHeaders) Equal(other ref.Val) ref.Val { return types.Bool(false) // We don't want to compare header maps } func (h HTTPHeaders) Type() ref.Type { return types.MapType } func (h HTTPHeaders) Value() any { return h } func (h HTTPHeaders) Find(key ref.Val) (ref.Val, bool) { k, ok := key.(types.String) if !ok { return nil, false } if _, ok := h.Header[string(k)]; !ok { return nil, false } return types.String(strings.Join(h.Header.Values(string(k)), ",")), true } func (h HTTPHeaders) Contains(key ref.Val) ref.Val { _, ok := h.Find(key) return types.Bool(ok) } func (h HTTPHeaders) Get(key ref.Val) ref.Val { result, ok := h.Find(key) if !ok { return types.ValOrErr(result, "no such key: %v", key) } return result } func (h HTTPHeaders) Iterator() traits.Iterator { panic("TODO(Xe): implement me") } func (h HTTPHeaders) IsZeroValue() bool { return len(h.Header) == 0 } func (h HTTPHeaders) Size() ref.Val { return types.Int(len(h.Header)) } ================================================ FILE: lib/policy/expressions/http_headers_test.go ================================================ package expressions import ( "net/http" "testing" "github.com/google/cel-go/common/types" ) func TestHTTPHeaders(t *testing.T) { headers := HTTPHeaders{ Header: http.Header{ "Content-Type": {"application/json"}, "Cf-Worker": {"true"}, "User-Agent": {"Go-http-client/2"}, }, } t.Run("contains-existing-header", func(t *testing.T) { resp := headers.Contains(types.String("User-Agent")) if !resp.(types.Bool) { t.Fatal("headers does not contain User-Agent") } }) t.Run("not-contains-missing-header", func(t *testing.T) { resp := headers.Contains(types.String("Xxx-Random-Header")) if resp.(types.Bool) { t.Fatal("headers does not contain User-Agent") } }) t.Run("get-existing-header", func(t *testing.T) { val := headers.Get(types.String("User-Agent")) switch val.(type) { case types.String: // ok default: t.Fatalf("result was wrong type %T", val) } }) t.Run("not-get-missing-header", func(t *testing.T) { val := headers.Get(types.String("Xxx-Random-Header")) switch val.(type) { case *types.Err: // ok default: t.Fatalf("result was wrong type %T", val) } }) } ================================================ FILE: lib/policy/expressions/loadavg.go ================================================ package expressions import ( "context" "log/slog" "sync" "time" "github.com/shirou/gopsutil/v4/load" ) type loadAvg struct { data *load.AvgStat lock sync.RWMutex } func (l *loadAvg) updateThread(ctx context.Context) { ticker := time.NewTicker(15 * time.Second) defer ticker.Stop() l.update() for { select { case <-ticker.C: l.update() case <-ctx.Done(): return } } } func (l *loadAvg) update() { l.lock.Lock() defer l.lock.Unlock() var err error l.data, err = load.Avg() if err != nil { slog.Debug("can't get load average", "err", err) } } var ( globalLoadAvg *loadAvg ) func init() { globalLoadAvg = &loadAvg{} go globalLoadAvg.updateThread(context.Background()) } func Load1() float64 { globalLoadAvg.lock.RLock() defer globalLoadAvg.lock.RUnlock() return globalLoadAvg.data.Load1 } func Load5() float64 { globalLoadAvg.lock.RLock() defer globalLoadAvg.lock.RUnlock() return globalLoadAvg.data.Load5 } func Load15() float64 { globalLoadAvg.lock.RLock() defer globalLoadAvg.lock.RUnlock() return globalLoadAvg.data.Load15 } ================================================ FILE: lib/policy/expressions/url_values.go ================================================ package expressions import ( "errors" "net/url" "reflect" "strings" "github.com/google/cel-go/common/types" "github.com/google/cel-go/common/types/ref" "github.com/google/cel-go/common/types/traits" ) var ErrNotImplemented = errors.New("expressions: not implemented") // URLValues is a type wrapper to expose url.Values into CEL programs. type URLValues struct { url.Values } func (u URLValues) ConvertToNative(typeDesc reflect.Type) (any, error) { return nil, ErrNotImplemented } func (u URLValues) ConvertToType(typeVal ref.Type) ref.Val { switch typeVal { case types.MapType: return u case types.TypeType: return types.MapType } return types.NewErr("can't convert from %q to %q", types.MapType, typeVal) } func (u URLValues) Equal(other ref.Val) ref.Val { return types.Bool(false) // We don't want to compare header maps } func (u URLValues) Type() ref.Type { return types.MapType } func (u URLValues) Value() any { return u } func (u URLValues) Find(key ref.Val) (ref.Val, bool) { k, ok := key.(types.String) if !ok { return nil, false } if _, ok := u.Values[string(k)]; !ok { return nil, false } return types.String(strings.Join(u.Values[string(k)], ",")), true } func (u URLValues) Contains(key ref.Val) ref.Val { _, ok := u.Find(key) return types.Bool(ok) } func (u URLValues) Get(key ref.Val) ref.Val { result, ok := u.Find(key) if !ok { return types.ValOrErr(result, "no such key: %v", key) } return result } func (u URLValues) Iterator() traits.Iterator { panic("TODO(Xe): implement me") } func (u URLValues) IsZeroValue() bool { return len(u.Values) == 0 } func (u URLValues) Size() ref.Val { return types.Int(len(u.Values)) } ================================================ FILE: lib/policy/expressions/url_values_test.go ================================================ package expressions import ( "net/url" "testing" "github.com/google/cel-go/common/types" ) func TestURLValues(t *testing.T) { headers := URLValues{ Values: url.Values{ "format": {"json"}, }, } t.Run("contains-existing-key", func(t *testing.T) { resp := headers.Contains(types.String("format")) if !resp.(types.Bool) { t.Fatal("headers does not contain User-Agent") } }) t.Run("not-contains-missing-key", func(t *testing.T) { resp := headers.Contains(types.String("not-there")) if resp.(types.Bool) { t.Fatal("headers does not contain User-Agent") } }) t.Run("get-existing-key", func(t *testing.T) { val := headers.Get(types.String("format")) switch val.(type) { case types.String: // ok default: t.Fatalf("result was wrong type %T", val) } }) t.Run("not-get-missing-key", func(t *testing.T) { val := headers.Get(types.String("not-there")) switch val.(type) { case *types.Err: // ok default: t.Fatalf("result was wrong type %T", val) } }) } ================================================ FILE: lib/policy/policy.go ================================================ package policy import ( "context" "errors" "fmt" "io" "log/slog" "os" "sync/atomic" "time" "github.com/TecharoHQ/anubis/internal" "github.com/TecharoHQ/anubis/internal/dns" "github.com/TecharoHQ/anubis/lib/config" "github.com/TecharoHQ/anubis/lib/policy/checker" "github.com/TecharoHQ/anubis/lib/store" "github.com/TecharoHQ/anubis/lib/thoth" "github.com/fahedouch/go-logrotate" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" _ "github.com/TecharoHQ/anubis/lib/store/all" ) var ( Applications = promauto.NewCounterVec(prometheus.CounterOpts{ Name: "anubis_policy_results", Help: "The results of each policy rule", }, []string{"rule", "action"}) ErrChallengeRuleHasWrongAlgorithm = errors.New("config.Bot.ChallengeRules: algorithm is invalid") warnedAboutThresholds = &atomic.Bool{} ) type ParsedConfig struct { Store store.Interface orig *config.Config Impressum *config.Impressum OpenGraph config.OpenGraph Bots []Bot Thresholds []*Threshold StatusCodes config.StatusCodes DefaultDifficulty int DNSBL bool DnsCache *dns.DnsCache Dns *dns.Dns Logger *slog.Logger } func newParsedConfig(orig *config.Config) *ParsedConfig { return &ParsedConfig{ orig: orig, OpenGraph: orig.OpenGraph, StatusCodes: orig.StatusCodes, } } func ParseConfig(ctx context.Context, fin io.Reader, fname string, defaultDifficulty int, logLevel string) (*ParsedConfig, error) { c, err := config.Load(fin, fname) if err != nil { return nil, err } var validationErrs []error tc, hasThothClient := thoth.FromContext(ctx) result := newParsedConfig(c) result.DefaultDifficulty = defaultDifficulty if c.Logging.Level != nil { logLevel = c.Logging.Level.String() } switch c.Logging.Sink { case config.LogSinkStdio: result.Logger = internal.InitSlog(logLevel, os.Stderr) case config.LogSinkFile: out := &logrotate.Logger{ Filename: c.Logging.Parameters.Filename, FilenameTimeFormat: time.RFC3339, MaxBytes: c.Logging.Parameters.MaxBytes, MaxAge: c.Logging.Parameters.MaxAge, MaxBackups: c.Logging.Parameters.MaxBackups, LocalTime: c.Logging.Parameters.UseLocalTime, Compress: c.Logging.Parameters.Compress, } result.Logger = internal.InitSlog(logLevel, out) } lg := result.Logger.With("at", "config-validate") stFac, ok := store.Get(c.Store.Backend) switch ok { case true: store, err := stFac.Build(ctx, c.Store.Parameters) if err != nil { validationErrs = append(validationErrs, err) } else { result.Store = store } case false: validationErrs = append(validationErrs, config.ErrUnknownStoreBackend) } result.DnsCache = dns.NewDNSCache(result.orig.DNSTTL.Forward, result.orig.DNSTTL.Reverse, result.Store) result.Dns = dns.New(ctx, result.DnsCache) for _, b := range c.Bots { if berr := b.Valid(); berr != nil { validationErrs = append(validationErrs, berr) continue } parsedBot := Bot{ Name: b.Name, Action: b.Action, } cl := checker.List{} if len(b.RemoteAddr) > 0 { c, err := NewRemoteAddrChecker(b.RemoteAddr) if err != nil { validationErrs = append(validationErrs, fmt.Errorf("while processing rule %s remote addr set: %w", b.Name, err)) } else { cl = append(cl, c) } } if b.UserAgentRegex != nil { c, err := NewUserAgentChecker(*b.UserAgentRegex) if err != nil { validationErrs = append(validationErrs, fmt.Errorf("while processing rule %s user agent regex: %w", b.Name, err)) } else { cl = append(cl, c) } } if b.PathRegex != nil { c, err := NewPathChecker(*b.PathRegex) if err != nil { validationErrs = append(validationErrs, fmt.Errorf("while processing rule %s path regex: %w", b.Name, err)) } else { cl = append(cl, c) } } if len(b.HeadersRegex) > 0 { c, err := NewHeadersChecker(b.HeadersRegex) if err != nil { validationErrs = append(validationErrs, fmt.Errorf("while processing rule %s headers regex map: %w", b.Name, err)) } else { cl = append(cl, c) } } if b.Expression != nil { c, err := NewCELChecker(b.Expression, result.Dns) if err != nil { validationErrs = append(validationErrs, fmt.Errorf("while processing rule %s expressions: %w", b.Name, err)) } else { cl = append(cl, c) } } if b.ASNs != nil { if !hasThothClient { lg.Warn("You have specified a Thoth specific check but you have no Thoth client configured. Please read https://anubis.techaro.lol/docs/admin/thoth for more information", "check", "asn", "settings", b.ASNs) continue } cl = append(cl, tc.ASNCheckerFor(b.ASNs.Match)) } if b.GeoIP != nil { if !hasThothClient { lg.Warn("You have specified a Thoth specific check but you have no Thoth client configured. Please read https://anubis.techaro.lol/docs/admin/thoth for more information", "check", "geoip", "settings", b.GeoIP) continue } cl = append(cl, tc.GeoIPCheckerFor(b.GeoIP.Countries)) } if b.Challenge == nil { parsedBot.Challenge = &config.ChallengeRules{ Difficulty: defaultDifficulty, Algorithm: "fast", } } else { parsedBot.Challenge = b.Challenge if parsedBot.Challenge.Algorithm == "" { parsedBot.Challenge.Algorithm = config.DefaultAlgorithm } if parsedBot.Challenge.Algorithm == "slow" { lg.Warn("use of deprecated algorithm \"slow\" detected, please update this to \"fast\" when possible", "name", parsedBot.Name) } } if b.Weight != nil { parsedBot.Weight = b.Weight } result.Impressum = c.Impressum parsedBot.Rules = cl result.Bots = append(result.Bots, parsedBot) } for _, t := range c.Thresholds { if t.Challenge != nil && t.Challenge.Algorithm == "slow" { lg.Warn("use of deprecated algorithm \"slow\" detected, please update this to \"fast\" when possible", "name", t.Name) } if t.Challenge != nil && t.Challenge.ReportAs != 0 { lg.Warn("use of deprecated report_as setting detected, please remove this from your policy file when possible", "name", t.Name) } if t.Name == "legacy-anubis-behaviour" && t.Expression.String() == "true" { if !warnedAboutThresholds.Load() { lg.Warn("configuration file does not contain thresholds, see docs for details on how to upgrade", "fname", fname, "docs_url", "https://anubis.techaro.lol/docs/admin/configuration/thresholds/") warnedAboutThresholds.Store(true) } t.Challenge.Difficulty = defaultDifficulty } threshold, err := ParsedThresholdFromConfig(t) if err != nil { validationErrs = append(validationErrs, fmt.Errorf("can't compile threshold config for %s: %w", t.Name, err)) continue } result.Thresholds = append(result.Thresholds, threshold) } 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 } ================================================ FILE: lib/policy/policy_test.go ================================================ package policy import ( "os" "path/filepath" "testing" "github.com/TecharoHQ/anubis" "github.com/TecharoHQ/anubis/data" "github.com/TecharoHQ/anubis/lib/thoth/thothmock" ) func TestDefaultPolicyMustParse(t *testing.T) { ctx := thothmock.WithMockThoth(t) fin, err := data.BotPolicies.Open("botPolicies.yaml") if err != nil { t.Fatal(err) } defer fin.Close() if _, err := ParseConfig(ctx, fin, "botPolicies.yaml", anubis.DefaultDifficulty, "info"); err != nil { t.Fatalf("can't parse config: %v", err) } } func TestGoodConfigs(t *testing.T) { finfos, err := os.ReadDir("../config/testdata/good") if err != nil { t.Fatal(err) } for _, st := range finfos { t.Run(st.Name(), func(t *testing.T) { t.Run("with-thoth", func(t *testing.T) { fin, err := os.Open(filepath.Join("..", "config", "testdata", "good", st.Name())) if err != nil { t.Fatal(err) } defer fin.Close() ctx := thothmock.WithMockThoth(t) if _, err := ParseConfig(ctx, fin, fin.Name(), anubis.DefaultDifficulty, "info"); err != nil { t.Fatal(err) } }) t.Run("without-thoth", func(t *testing.T) { fin, err := os.Open(filepath.Join("..", "config", "testdata", "good", st.Name())) if err != nil { t.Fatal(err) } defer fin.Close() if _, err := ParseConfig(t.Context(), fin, fin.Name(), anubis.DefaultDifficulty, "info"); err != nil { t.Fatal(err) } }) }) } } func TestBadConfigs(t *testing.T) { ctx := thothmock.WithMockThoth(t) finfos, err := os.ReadDir("../config/testdata/bad") if err != nil { t.Fatal(err) } for _, st := range finfos { t.Run(st.Name(), func(t *testing.T) { fin, err := os.Open(filepath.Join("..", "config", "testdata", "bad", st.Name())) if err != nil { t.Fatal(err) } defer fin.Close() if _, err := ParseConfig(ctx, fin, fin.Name(), anubis.DefaultDifficulty, "info"); err == nil { t.Fatal(err) } else { t.Log(err) } }) } } ================================================ FILE: lib/policy/testdata/hack-test.json ================================================ [ { "name": "ipv6-ula", "action": "ALLOW", "remote_addresses": ["fc00::/7"] } ] ================================================ FILE: lib/policy/testdata/hack-test.yaml ================================================ - name: well-known path_regex: ^/.well-known/.*$ action: ALLOW ================================================ FILE: lib/policy/thresholds.go ================================================ package policy import ( "github.com/TecharoHQ/anubis/lib/config" "github.com/TecharoHQ/anubis/lib/policy/expressions" "github.com/google/cel-go/cel" ) type Threshold struct { config.Threshold Program cel.Program } func ParsedThresholdFromConfig(t config.Threshold) (*Threshold, error) { result := &Threshold{ Threshold: t, } env, err := expressions.ThresholdEnvironment() if err != nil { return nil, err } program, err := expressions.Compile(env, t.Expression.String()) if err != nil { return nil, err } result.Program = program return result, nil } type ThresholdRequest struct { Weight int } func (tr *ThresholdRequest) Parent() cel.Activation { return nil } func (tr *ThresholdRequest) ResolveName(name string) (any, bool) { switch name { case "weight": return tr.Weight, true default: return nil, false } } ================================================ FILE: lib/redirect_security_test.go ================================================ package lib import ( "log/slog" "net/http" "net/http/httptest" "net/url" "strings" "testing" "github.com/TecharoHQ/anubis/lib/policy" ) func TestRedirectSecurity(t *testing.T) { tests := []struct { reqHost string testType string // "constructRedirectURL", "serveHTTPNext", "renderIndex" // For constructRedirectURL tests xForwardedProto string xForwardedHost string xForwardedUri string // For serveHTTPNext tests redirParam string name string errorContains string expectedStatus int // For renderIndex tests returnHTTPStatusOnly bool shouldError bool shouldNotRedirect bool shouldBlock bool }{ // constructRedirectURL tests - X-Forwarded-Proto validation { name: "constructRedirectURL: javascript protocol should be rejected", testType: "constructRedirectURL", xForwardedProto: "javascript", xForwardedHost: "example.com", xForwardedUri: "alert(1)", shouldError: true, errorContains: "invalid", }, { name: "constructRedirectURL: data protocol should be rejected", testType: "constructRedirectURL", xForwardedProto: "data", xForwardedHost: "text/html", xForwardedUri: ",", shouldError: true, errorContains: "invalid", }, { name: "constructRedirectURL: file protocol should be rejected", testType: "constructRedirectURL", xForwardedProto: "file", xForwardedHost: "", xForwardedUri: "/etc/passwd", shouldError: true, errorContains: "invalid", }, { name: "constructRedirectURL: ftp protocol should be rejected", testType: "constructRedirectURL", xForwardedProto: "ftp", xForwardedHost: "example.com", xForwardedUri: "/file.txt", shouldError: true, errorContains: "invalid", }, { name: "constructRedirectURL: https protocol should be allowed", testType: "constructRedirectURL", xForwardedProto: "https", xForwardedHost: "example.com", xForwardedUri: "/foo", shouldError: false, }, { name: "constructRedirectURL: http protocol should be allowed", testType: "constructRedirectURL", xForwardedProto: "http", xForwardedHost: "example.com", xForwardedUri: "/bar", shouldError: false, }, // serveHTTPNext tests - redir parameter validation { name: "serveHTTPNext: javascript: URL should be rejected", testType: "serveHTTPNext", redirParam: "javascript:alert(1)", reqHost: "example.com", expectedStatus: http.StatusBadRequest, shouldNotRedirect: true, }, { name: "serveHTTPNext: data: URL should be rejected", testType: "serveHTTPNext", redirParam: "data:text/html,", reqHost: "example.com", expectedStatus: http.StatusBadRequest, shouldNotRedirect: true, }, { name: "serveHTTPNext: file: URL should be rejected", testType: "serveHTTPNext", redirParam: "file:///etc/passwd", reqHost: "example.com", expectedStatus: http.StatusBadRequest, shouldNotRedirect: true, }, { name: "serveHTTPNext: vbscript: URL should be rejected", testType: "serveHTTPNext", redirParam: "vbscript:msgbox(1)", reqHost: "example.com", expectedStatus: http.StatusBadRequest, shouldNotRedirect: true, }, { name: "serveHTTPNext: valid https URL should work", testType: "serveHTTPNext", redirParam: "https://example.com/foo", reqHost: "example.com", expectedStatus: http.StatusFound, }, { name: "serveHTTPNext: valid relative URL should work", testType: "serveHTTPNext", redirParam: "/foo/bar", reqHost: "example.com", expectedStatus: http.StatusFound, }, { name: "serveHTTPNext: external domain should be blocked", testType: "serveHTTPNext", redirParam: "https://evil.com/phishing", reqHost: "example.com", expectedStatus: http.StatusBadRequest, shouldBlock: true, }, { name: "serveHTTPNext: relative path should work", testType: "serveHTTPNext", redirParam: "/safe/path", reqHost: "example.com", expectedStatus: http.StatusFound, }, { name: "serveHTTPNext: empty redir should show success page", testType: "serveHTTPNext", redirParam: "", reqHost: "example.com", expectedStatus: http.StatusOK, }, // renderIndex tests - full subrequest auth flow { name: "renderIndex: javascript protocol in X-Forwarded-Proto", testType: "renderIndex", xForwardedProto: "javascript", xForwardedHost: "example.com", xForwardedUri: "alert(1)", returnHTTPStatusOnly: true, expectedStatus: http.StatusBadRequest, }, { name: "renderIndex: data protocol in X-Forwarded-Proto", testType: "renderIndex", xForwardedProto: "data", xForwardedHost: "example.com", xForwardedUri: "text/html,", returnHTTPStatusOnly: true, expectedStatus: http.StatusBadRequest, }, { name: "renderIndex: valid https redirect", testType: "renderIndex", xForwardedProto: "https", xForwardedHost: "example.com", xForwardedUri: "/protected/page", returnHTTPStatusOnly: true, expectedStatus: http.StatusTemporaryRedirect, }, } s := &Server{ opts: Options{ PublicUrl: "https://anubis.example.com", RedirectDomains: []string{}, }, logger: slog.Default(), policy: &policy.ParsedConfig{}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { switch tt.testType { case "constructRedirectURL": req := httptest.NewRequest("GET", "/", nil) req.Header.Set("X-Forwarded-Proto", tt.xForwardedProto) req.Header.Set("X-Forwarded-Host", tt.xForwardedHost) req.Header.Set("X-Forwarded-Uri", tt.xForwardedUri) redirectURL, err := s.constructRedirectURL(req) if tt.shouldError { if err == nil { t.Errorf("expected error containing %q, got nil", tt.errorContains) t.Logf("got redirect URL: %s", redirectURL) } else if tt.errorContains != "" && !strings.Contains(err.Error(), tt.errorContains) { t.Logf("expected error containing %q, got: %v", tt.errorContains, err) } } else { if err != nil { t.Errorf("expected no error, got: %v", err) } // Verify the redirect URL is safe if redirectURL != "" { parsed, err := url.Parse(redirectURL) if err != nil { t.Errorf("failed to parse redirect URL: %v", err) } redirParam := parsed.Query().Get("redir") if redirParam != "" { redirParsed, err := url.Parse(redirParam) if err != nil { t.Errorf("failed to parse redir parameter: %v", err) } if redirParsed.Scheme != "http" && redirParsed.Scheme != "https" { t.Errorf("redir parameter has unsafe scheme: %s", redirParsed.Scheme) } } } } case "serveHTTPNext": req := httptest.NewRequest("GET", "/.within.website/?redir="+url.QueryEscape(tt.redirParam), nil) req.Host = tt.reqHost req.URL.Host = tt.reqHost rr := httptest.NewRecorder() s.ServeHTTPNext(rr, req) if rr.Code != tt.expectedStatus { t.Errorf("expected status %d, got %d", tt.expectedStatus, rr.Code) t.Logf("body: %s", rr.Body.String()) } if tt.shouldNotRedirect { location := rr.Header().Get("Location") if location != "" { t.Errorf("expected no redirect, but got Location header: %s", location) } } if tt.shouldBlock { location := rr.Header().Get("Location") if location != "" && strings.Contains(location, "evil.com") { t.Errorf("redirect to evil.com was not blocked: %s", location) } } case "renderIndex": req := httptest.NewRequest("GET", "/", nil) req.Header.Set("X-Forwarded-Proto", tt.xForwardedProto) req.Header.Set("X-Forwarded-Host", tt.xForwardedHost) req.Header.Set("X-Forwarded-Uri", tt.xForwardedUri) rr := httptest.NewRecorder() s.RenderIndex(rr, req, policy.CheckResult{}, nil, tt.returnHTTPStatusOnly) if rr.Code != tt.expectedStatus { t.Errorf("expected status %d, got %d", tt.expectedStatus, rr.Code) } if tt.expectedStatus == http.StatusTemporaryRedirect { location := rr.Header().Get("Location") if location == "" { t.Error("expected Location header, got none") } else { // Verify the location doesn't contain javascript: if strings.Contains(location, "javascript") { t.Errorf("Location header contains 'javascript': %s", location) } } } } }) } } ================================================ FILE: lib/store/actorifiedstore.go ================================================ package store import ( "context" "time" "github.com/TecharoHQ/anubis/internal/actorify" ) type unit struct{} type ActorifiedStore struct { Interface deleteActor *actorify.Actor[string, unit] getActor *actorify.Actor[string, []byte] setActor *actorify.Actor[*actorSetReq, unit] cancel context.CancelFunc } type actorSetReq struct { key string value []byte expiry time.Duration } func NewActorifiedStore(backend Interface) *ActorifiedStore { ctx, cancel := context.WithCancel(context.Background()) result := &ActorifiedStore{ Interface: backend, cancel: cancel, } result.deleteActor = actorify.New(ctx, result.actorDelete) result.getActor = actorify.New(ctx, backend.Get) result.setActor = actorify.New(ctx, result.actorSet) return result } func (a *ActorifiedStore) Close() { a.cancel() } func (a *ActorifiedStore) Delete(ctx context.Context, key string) error { if _, err := a.deleteActor.Call(ctx, key); err != nil { return err } return nil } func (a *ActorifiedStore) Get(ctx context.Context, key string) ([]byte, error) { return a.getActor.Call(ctx, key) } func (a *ActorifiedStore) Set(ctx context.Context, key string, value []byte, expiry time.Duration) error { if _, err := a.setActor.Call(ctx, &actorSetReq{ key: key, value: value, expiry: expiry, }); err != nil { return err } return nil } func (a *ActorifiedStore) actorDelete(ctx context.Context, key string) (unit, error) { if err := a.Interface.Delete(ctx, key); err != nil { return unit{}, err } return unit{}, nil } func (a *ActorifiedStore) actorSet(ctx context.Context, req *actorSetReq) (unit, error) { if err := a.Interface.Set(ctx, req.key, req.value, req.expiry); err != nil { return unit{}, err } return unit{}, nil } ================================================ FILE: lib/store/all/all.go ================================================ // Package all is a meta-package that imports all store implementations. // // This is a HACK to make tests work consistently. package all import ( _ "github.com/TecharoHQ/anubis/lib/store/bbolt" _ "github.com/TecharoHQ/anubis/lib/store/memory" _ "github.com/TecharoHQ/anubis/lib/store/s3api" _ "github.com/TecharoHQ/anubis/lib/store/valkey" ) ================================================ FILE: lib/store/bbolt/bbolt.go ================================================ package bbolt import ( "context" "errors" "fmt" "log/slog" "time" "github.com/TecharoHQ/anubis/lib/store" "go.etcd.io/bbolt" ) // Sentinel error value used for testing and in admin-visible error messages. var ( ErrNotExists = errors.New("bbolt: value does not exist in store") ) // Store implements store.Interface backed by bbolt[1]. // // In essence, bbolt is a hierarchical key/value store with a twist: every value // needs to belong to a bucket. Buckets can contain an infinite number of // buckets. As such, Anubis nests values in buckets. Each value in the store // is given its own bucket with two keys: // // 1. data - The raw data, usually in JSON // 2. expiry - The expiry time formatted as a time.RFC3339Nano timestamp string // // When Anubis stores a new bit of data, it creates a new bucket for that value. // This allows the cleanup phase to iterate over every bucket in the database and // only scan the expiry times without having to decode the entire record. // // bbolt is not suitable for environments where multiple instance of Anubis need // to read from and write to the same backend store. For that, use the valkey // storage backend. // // [1]: https://github.com/etcd-io/bbolt type Store struct { bdb *bbolt.DB } // Delete a key from the datastore. If the key does not exist, return an error. func (s *Store) Delete(ctx context.Context, key string) error { return s.bdb.Update(func(tx *bbolt.Tx) error { if tx.Bucket([]byte(key)) == nil { return fmt.Errorf("%w: %q", ErrNotExists, key) } return tx.DeleteBucket([]byte(key)) }) } // Get a value from the datastore. // // Because each value is stored in its own bucket with data and expiry keys, // two get operations are required: // // 1. Get the expiry key, parse as time.RFC3339Nano. If the key has expired, run deletion in the background and return a "key not found" error. // 2. Get the data key, copy into the result byteslice, return it. func (s *Store) Get(ctx context.Context, key string) ([]byte, error) { var result []byte if err := s.bdb.View(func(tx *bbolt.Tx) error { itemBucket := tx.Bucket([]byte(key)) if itemBucket == nil { return fmt.Errorf("%w: %q", store.ErrNotFound, key) } expiryStr := itemBucket.Get([]byte("expiry")) if expiryStr == nil { return fmt.Errorf("[unexpected] %w: %q (expiry is nil)", store.ErrNotFound, key) } expiry, err := time.Parse(time.RFC3339Nano, string(expiryStr)) if err != nil { return fmt.Errorf("[unexpected] %w: %w", store.ErrCantDecode, err) } if time.Now().After(expiry) { go s.Delete(context.Background(), key) return fmt.Errorf("%w: %q", store.ErrNotFound, key) } dataStr := itemBucket.Get([]byte("data")) if dataStr == nil { return fmt.Errorf("[unexpected] %w: %q (data is nil)", store.ErrNotFound, key) } result = make([]byte, len(dataStr)) if n := copy(result, dataStr); n != len(dataStr) { return fmt.Errorf("[unexpected] %w: %d bytes copied of %d", store.ErrCantDecode, n, len(dataStr)) } return nil }); err != nil { return nil, err } return result, nil } // Set a value into the store with a given expiry. func (s *Store) Set(ctx context.Context, key string, value []byte, expiry time.Duration) error { expires := time.Now().Add(expiry) return s.bdb.Update(func(tx *bbolt.Tx) error { valueBkt, err := tx.CreateBucketIfNotExists([]byte(key)) if err != nil { return fmt.Errorf("%w: %w: %q (create bucket)", store.ErrCantEncode, err, key) } if err := valueBkt.Put([]byte("expiry"), []byte(expires.Format(time.RFC3339Nano))); err != nil { return fmt.Errorf("%w: %q (expiry)", store.ErrCantEncode, key) } if err := valueBkt.Put([]byte("data"), value); err != nil { return fmt.Errorf("%w: %q (data)", store.ErrCantEncode, key) } return nil }) } func (s *Store) cleanup(ctx context.Context) error { now := time.Now() return s.bdb.Update(func(tx *bbolt.Tx) error { return tx.ForEach(func(key []byte, valueBkt *bbolt.Bucket) error { var expiry time.Time var err error expiryStr := valueBkt.Get([]byte("expiry")) if expiryStr == nil { slog.Warn("while running cleanup, expiry is not set somehow, file a bug?", "key", string(key)) return nil } expiry, err = time.Parse(time.RFC3339Nano, string(expiryStr)) if err != nil { return fmt.Errorf("[unexpected] %w in bucket %q: %w", store.ErrCantDecode, string(key), err) } if now.After(expiry) { return tx.DeleteBucket(key) } return nil }) }) } func (s *Store) IsPersistent() bool { return true } func (s *Store) cleanupThread(ctx context.Context) { t := time.NewTicker(time.Hour) defer t.Stop() for { select { case <-ctx.Done(): return case <-t.C: if err := s.cleanup(ctx); err != nil { slog.Error("error during bbolt cleanup", "err", err) } } } } ================================================ FILE: lib/store/bbolt/bbolt_test.go ================================================ package bbolt import ( "encoding/json" "path/filepath" "testing" "github.com/TecharoHQ/anubis/lib/store/storetest" ) func TestImpl(t *testing.T) { path := filepath.Join(t.TempDir(), "db") t.Log(path) data, err := json.Marshal(Config{ Path: path, }) if err != nil { t.Fatal(err) } storetest.Common(t, Factory{}, json.RawMessage(data)) } ================================================ FILE: lib/store/bbolt/factory.go ================================================ package bbolt import ( "context" "encoding/json" "errors" "fmt" "os" "path/filepath" "github.com/TecharoHQ/anubis/lib/store" "go.etcd.io/bbolt" ) var ( ErrMissingPath = errors.New("bbolt: path is missing from config") ErrCantWriteToPath = errors.New("bbolt: can't write to path") ) func init() { store.Register("bbolt", Factory{}) } // Factory builds new instances of the bbolt storage backend according to // configuration passed via a json.RawMessage. type Factory struct{} // Build parses and validates the bbolt storage backend Config and creates // a new instance of it. func (Factory) Build(ctx context.Context, data json.RawMessage) (store.Interface, error) { var config Config if err := json.Unmarshal([]byte(data), &config); err != nil { return nil, fmt.Errorf("%w: %w", store.ErrBadConfig, err) } if err := config.Valid(); err != nil { return nil, fmt.Errorf("%w: %w", store.ErrBadConfig, err) } bdb, err := bbolt.Open(config.Path, 0600, nil) if err != nil { return nil, fmt.Errorf("can't open bbolt database %s: %w", config.Path, err) } result := &Store{ bdb: bdb, } go result.cleanupThread(ctx) return result, nil } // Valid parses and validates the bbolt store Config or returns // an error. func (Factory) Valid(data json.RawMessage) error { var config Config if err := json.Unmarshal([]byte(data), &config); err != nil { return fmt.Errorf("%w: %w", store.ErrBadConfig, err) } if err := config.Valid(); err != nil { return fmt.Errorf("%w: %w", store.ErrBadConfig, err) } return nil } // Config is the bbolt storage backend configuration. type Config struct { // Path is the filesystem path of the database. The folder must be writable to Anubis. Path string `json:"path"` } // Valid validates the configuration including checking if its containing folder is writable. func (c Config) Valid() error { var errs []error if c.Path == "" { errs = append(errs, ErrMissingPath) } else { dir := filepath.Dir(c.Path) if err := os.WriteFile(filepath.Join(dir, ".test-file"), []byte(""), 0600); err != nil { errs = append(errs, ErrCantWriteToPath) } os.Remove(filepath.Join(dir, ".test-file")) } if len(errs) != 0 { return errors.Join(errs...) } return nil } ================================================ FILE: lib/store/bbolt/factory_test.go ================================================ package bbolt import ( "encoding/json" "errors" "testing" ) func TestFactoryValid(t *testing.T) { f := Factory{} t.Run("bad config", func(t *testing.T) { if err := f.Valid(json.RawMessage(`}`)); err == nil { t.Error("wanted parsing failure but got a successful result") } }) t.Run("invalid config", func(t *testing.T) { for _, tt := range []struct { err error name string cfg Config }{ { name: "missing path", cfg: Config{}, err: ErrMissingPath, }, } { t.Run(tt.name, func(t *testing.T) { data, err := json.Marshal(tt.cfg) if err != nil { t.Fatal(err) } if err := f.Valid(json.RawMessage(data)); !errors.Is(err, tt.err) { t.Error(err) } }) } }) } ================================================ FILE: lib/store/interface.go ================================================ package store import ( "context" "encoding/json" "errors" "fmt" "time" ) var ( // ErrNotFound is returned when the store implementation cannot find the value // for a given key. ErrNotFound = errors.New("store: key not found") // ErrCantDecode is returned when a store adaptor cannot decode the store format // to a value used by the code. ErrCantDecode = errors.New("store: can't decode value") // ErrCantEncode is returned when a store adaptor cannot encode the value into // the format that the store uses. ErrCantEncode = errors.New("store: can't encode value") // ErrBadConfig is returned when a store adaptor's configuration is invalid. ErrBadConfig = errors.New("store: configuration is invalid") ) // Interface defines the calls that Anubis uses for storage in a local or remote // datastore. This can be implemented with an in-memory, on-disk, or in-database // storage backend. type Interface interface { // Delete removes a value from the store by key. Delete(ctx context.Context, key string) error // Get returns the value of a key assuming that value exists and has not expired. Get(ctx context.Context, key string) ([]byte, error) // Set puts a value into the store that expires according to its expiry. Set(ctx context.Context, key string, value []byte, expiry time.Duration) error // IsPersistent returns true if this storage backend persists data across // service restarts (e.g., bbolt, valkey). Returns false for volatile storage // like in-memory backends. IsPersistent() bool } func z[T any]() T { return *new(T) } type JSON[T any] struct { Underlying Interface Prefix string } func (j *JSON[T]) Delete(ctx context.Context, key string) error { if j.Prefix != "" { key = j.Prefix + key } return j.Underlying.Delete(ctx, key) } func (j *JSON[T]) Get(ctx context.Context, key string) (T, error) { if j.Prefix != "" { key = j.Prefix + key } data, err := j.Underlying.Get(ctx, key) if err != nil { return z[T](), err } var result T if err := json.Unmarshal(data, &result); err != nil { return z[T](), fmt.Errorf("%w: %w", ErrCantDecode, err) } return result, nil } func (j *JSON[T]) Set(ctx context.Context, key string, value T, expiry time.Duration) error { if j.Prefix != "" { key = j.Prefix + key } data, err := json.Marshal(value) if err != nil { return fmt.Errorf("%w: %w", ErrCantEncode, err) } if err := j.Underlying.Set(ctx, key, data, expiry); err != nil { return err } return nil } func (j *JSON[T]) IsPersistent() bool { return j.Underlying.IsPersistent() } ================================================ FILE: lib/store/json_test.go ================================================ package store_test import ( "testing" "time" "github.com/TecharoHQ/anubis/lib/store" "github.com/TecharoHQ/anubis/lib/store/memory" ) func TestJSON(t *testing.T) { type data struct { ID string `json:"id"` } st := memory.New(t.Context()) db := store.JSON[data]{ Underlying: st, Prefix: "foo:", } if err := db.Set(t.Context(), "test", data{ID: t.Name()}, time.Minute); err != nil { t.Fatal(err) } got, err := db.Get(t.Context(), "test") if err != nil { t.Fatal(err) } if got.ID != t.Name() { t.Fatalf("got wrong data for key \"test\", wanted %q but got: %q", t.Name(), got.ID) } if err := db.Delete(t.Context(), "test"); err != nil { t.Fatal(err) } if _, err := db.Get(t.Context(), "test"); err == nil { t.Fatal("wanted invalid get to fail, it did not") } if err := st.Set(t.Context(), "foo:test", []byte("}"), time.Minute); err != nil { t.Fatal(err) } if _, err := db.Get(t.Context(), "test"); err == nil { t.Fatal("wanted invalid get to fail, it did not") } } ================================================ FILE: lib/store/memory/memory.go ================================================ package memory import ( "context" "encoding/json" "fmt" "time" "github.com/TecharoHQ/anubis/decaymap" "github.com/TecharoHQ/anubis/lib/store" ) type factory struct{} func (factory) Build(ctx context.Context, _ json.RawMessage) (store.Interface, error) { return New(ctx), nil } func (factory) Valid(json.RawMessage) error { return nil } func init() { store.Register("memory", factory{}) } type impl struct { store *decaymap.Impl[string, []byte] } func (i *impl) Delete(_ context.Context, key string) error { if !i.store.Delete(key) { return fmt.Errorf("%w: %q", store.ErrNotFound, key) } return nil } func (i *impl) Get(_ context.Context, key string) ([]byte, error) { result, ok := i.store.Get(key) if !ok { return nil, fmt.Errorf("%w: %q", store.ErrNotFound, key) } return result, nil } func (i *impl) Set(_ context.Context, key string, value []byte, expiry time.Duration) error { i.store.Set(key, value, expiry) return nil } func (i *impl) IsPersistent() bool { return false } func (i *impl) cleanupThread(ctx context.Context) { t := time.NewTicker(5 * time.Minute) defer t.Stop() for { select { case <-ctx.Done(): return case <-t.C: i.store.Cleanup() } } } // New creates a simple in-memory store. This will not scale to multiple Anubis instances. func New(ctx context.Context) store.Interface { result := &impl{ store: decaymap.New[string, []byte](), } go result.cleanupThread(ctx) return result } ================================================ FILE: lib/store/memory/memory_test.go ================================================ package memory import ( "testing" "github.com/TecharoHQ/anubis/lib/store/storetest" ) func TestImpl(t *testing.T) { storetest.Common(t, factory{}, nil) } ================================================ FILE: lib/store/registry.go ================================================ package store import ( "context" "encoding/json" "sort" "sync" ) var ( registry map[string]Factory = map[string]Factory{} regLock sync.RWMutex ) type Factory interface { Build(ctx context.Context, config json.RawMessage) (Interface, error) Valid(config json.RawMessage) error } func Register(name string, impl Factory) { regLock.Lock() defer regLock.Unlock() registry[name] = impl } func Get(name string) (Factory, bool) { regLock.RLock() defer regLock.RUnlock() result, ok := registry[name] return result, ok } func Methods() []string { regLock.RLock() defer regLock.RUnlock() var result []string for method := range registry { result = append(result, method) } sort.Strings(result) return result } ================================================ FILE: lib/store/s3api/factory.go ================================================ package s3api import ( "context" "encoding/json" "errors" "fmt" "github.com/TecharoHQ/anubis/lib/store" awsConfig "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/service/s3" ) var ( ErrNoRegion = errors.New("s3api.Config: no region env var name defined") ErrNoAccessKeyID = errors.New("s3api.Config: no access key id env var name defined") ErrNoSecretAccessKey = errors.New("s3api.Config: no secret access key env var name defined") ErrNoBucketName = errors.New("s3api.Config: no bucket name env var name defined") ) func init() { store.Register("s3api", Factory{}) } // S3API is the subset of the AWS S3 client used by this store. It enables mocking in tests. type S3API interface { PutObject(ctx context.Context, params *s3.PutObjectInput, optFns ...func(*s3.Options)) (*s3.PutObjectOutput, error) GetObject(ctx context.Context, params *s3.GetObjectInput, optFns ...func(*s3.Options)) (*s3.GetObjectOutput, error) DeleteObject(ctx context.Context, params *s3.DeleteObjectInput, optFns ...func(*s3.Options)) (*s3.DeleteObjectOutput, error) HeadObject(ctx context.Context, params *s3.HeadObjectInput, optFns ...func(*s3.Options)) (*s3.HeadObjectOutput, error) } // Factory builds an S3-backed store. Tests can inject a Mock via Client. // Factory can optionally carry a preconstructed S3 client (e.g., a mock in tests). type Factory struct { Client S3API } func (f Factory) Build(ctx context.Context, data json.RawMessage) (store.Interface, error) { var config Config if err := json.Unmarshal([]byte(data), &config); err != nil { return nil, fmt.Errorf("%w: %w", store.ErrBadConfig, err) } if err := config.Valid(); err != nil { return nil, fmt.Errorf("%w: %w", store.ErrBadConfig, err) } if config.BucketName == "" { return nil, fmt.Errorf("%w: %s", store.ErrBadConfig, ErrNoBucketName) } // If a client was injected (e.g., tests), use it directly. if f.Client != nil { return &Store{ s3: f.Client, bucket: config.BucketName, }, nil } cfg, err := awsConfig.LoadDefaultConfig(ctx) if err != nil { return nil, fmt.Errorf("can't load AWS config from environment: %w", err) } client := s3.NewFromConfig(cfg, func(o *s3.Options) { o.UsePathStyle = config.PathStyle }) return &Store{ s3: client, bucket: config.BucketName, }, nil } func (Factory) Valid(data json.RawMessage) error { var config Config if err := json.Unmarshal([]byte(data), &config); err != nil { return fmt.Errorf("%w: %w", store.ErrBadConfig, err) } if err := config.Valid(); err != nil { return fmt.Errorf("%w: %w", store.ErrBadConfig, err) } return nil } type Config struct { BucketName string `json:"bucketName"` PathStyle bool `json:"pathStyle"` } func (c Config) Valid() error { var errs []error if c.BucketName == "" { errs = append(errs, ErrNoBucketName) } if len(errs) != 0 { return fmt.Errorf("s3api.Config: invalid config: %w", errors.Join(errs...)) } return nil } ================================================ FILE: lib/store/s3api/s3api.go ================================================ package s3api import ( "bytes" "context" "fmt" "io" "strconv" "strings" "time" "github.com/TecharoHQ/anubis/lib/store" "github.com/aws/aws-sdk-go-v2/service/s3" ) type Store struct { s3 S3API bucket string } func (s *Store) Delete(ctx context.Context, key string) error { normKey := strings.ReplaceAll(key, ":", "/") // Emulate not found by probing first. if _, err := s.s3.HeadObject(ctx, &s3.HeadObjectInput{Bucket: &s.bucket, Key: &normKey}); err != nil { return fmt.Errorf("%w: %w", store.ErrNotFound, err) } if _, err := s.s3.DeleteObject(ctx, &s3.DeleteObjectInput{Bucket: &s.bucket, Key: &normKey}); err != nil { return fmt.Errorf("can't delete from s3: %w", err) } return nil } func (s *Store) Get(ctx context.Context, key string) ([]byte, error) { normKey := strings.ReplaceAll(key, ":", "/") out, err := s.s3.GetObject(ctx, &s3.GetObjectInput{ Bucket: &s.bucket, Key: &normKey, }) if err != nil { return nil, fmt.Errorf("%w: %w", store.ErrNotFound, err) } defer out.Body.Close() if msStr, ok := out.Metadata["x-anubis-expiry-ms"]; ok && msStr != "" { if ms, err := strconv.ParseInt(msStr, 10, 64); err == nil { if time.Now().UnixMilli() >= ms { _, _ = s.s3.DeleteObject(ctx, &s3.DeleteObjectInput{Bucket: &s.bucket, Key: &normKey}) return nil, store.ErrNotFound } } } b, err := io.ReadAll(out.Body) if err != nil { return nil, fmt.Errorf("can't read s3 object: %w", err) } return b, nil } func (s *Store) Set(ctx context.Context, key string, value []byte, expiry time.Duration) error { normKey := strings.ReplaceAll(key, ":", "/") // S3 has no native TTL; we store object with metadata X-Anubis-Expiry as epoch seconds. var meta map[string]string if expiry > 0 { exp := time.Now().Add(expiry).UnixMilli() meta = map[string]string{"x-anubis-expiry-ms": fmt.Sprintf("%d", exp)} } _, err := s.s3.PutObject(ctx, &s3.PutObjectInput{ Bucket: &s.bucket, Key: &normKey, Body: bytes.NewReader(value), Metadata: meta, }) if err != nil { return fmt.Errorf("can't put s3 object: %w", err) } return nil } func (Store) IsPersistent() bool { return true } ================================================ FILE: lib/store/s3api/s3api_test.go ================================================ package s3api import ( "bytes" "context" "encoding/json" "fmt" "io" "maps" "sync" "testing" "time" "github.com/TecharoHQ/anubis/lib/store/storetest" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/s3" ) // mockS3 is an in-memory mock of the methods we use. type mockS3 struct { data map[string][]byte meta map[string]map[string]string bucket string mu sync.RWMutex } func (m *mockS3) PutObject(ctx context.Context, in *s3.PutObjectInput, _ ...func(*s3.Options)) (*s3.PutObjectOutput, error) { m.mu.Lock() defer m.mu.Unlock() if m.data == nil { m.data = map[string][]byte{} } if m.meta == nil { m.meta = map[string]map[string]string{} } b, _ := io.ReadAll(in.Body) m.data[aws.ToString(in.Key)] = bytes.Clone(b) if in.Metadata != nil { m.meta[aws.ToString(in.Key)] = map[string]string{} maps.Copy(m.meta[aws.ToString(in.Key)], in.Metadata) } m.bucket = aws.ToString(in.Bucket) return &s3.PutObjectOutput{}, nil } func (m *mockS3) GetObject(ctx context.Context, in *s3.GetObjectInput, _ ...func(*s3.Options)) (*s3.GetObjectOutput, error) { m.mu.RLock() defer m.mu.RUnlock() b, ok := m.data[aws.ToString(in.Key)] if !ok { return nil, fmt.Errorf("not found") } out := &s3.GetObjectOutput{Body: io.NopCloser(bytes.NewReader(b))} if md, ok := m.meta[aws.ToString(in.Key)]; ok { out.Metadata = md } return out, nil } func (m *mockS3) DeleteObject(ctx context.Context, in *s3.DeleteObjectInput, _ ...func(*s3.Options)) (*s3.DeleteObjectOutput, error) { m.mu.Lock() defer m.mu.Unlock() delete(m.data, aws.ToString(in.Key)) delete(m.meta, aws.ToString(in.Key)) return &s3.DeleteObjectOutput{}, nil } func (m *mockS3) HeadObject(ctx context.Context, in *s3.HeadObjectInput, _ ...func(*s3.Options)) (*s3.HeadObjectOutput, error) { m.mu.RLock() defer m.mu.RUnlock() if _, ok := m.data[aws.ToString(in.Key)]; !ok { return nil, fmt.Errorf("not found") } return &s3.HeadObjectOutput{}, nil } func TestImpl(t *testing.T) { mock := &mockS3{} f := Factory{Client: mock} data, _ := json.Marshal(Config{ BucketName: "bucket", }) storetest.Common(t, f, json.RawMessage(data)) } func TestKeyNormalization(t *testing.T) { mock := &mockS3{} f := Factory{Client: mock} data, _ := json.Marshal(Config{ BucketName: "anubis", }) s, err := f.Build(t.Context(), json.RawMessage(data)) if err != nil { t.Fatal(err) } key := "a:b:c" val := []byte("value") if err := s.Set(t.Context(), key, val, 0); err != nil { t.Fatalf("Set failed: %v", err) } // Ensure mock saw normalized key mock.mu.RLock() _, hasRaw := mock.data["a:b:c"] got, hasNorm := mock.data["a/b/c"] mock.mu.RUnlock() if hasRaw { t.Fatalf("mock contains raw key with colon; normalization failed") } if !hasNorm || !bytes.Equal(got, val) { t.Fatalf("normalized key missing or wrong value: got=%q", string(got)) } // Get using colon key should work out, err := s.Get(t.Context(), key) if err != nil { t.Fatalf("Get failed: %v", err) } if !bytes.Equal(out, val) { t.Fatalf("Get returned wrong value: got=%q", string(out)) } // Delete using colon key should delete normalized object if err := s.Delete(t.Context(), key); err != nil { t.Fatalf("Delete failed: %v", err) } // Give any async cleanup in tests a tick (not needed for mock, but harmless) time.Sleep(1 * time.Millisecond) mock.mu.RLock() _, exists := mock.data["a/b/c"] mock.mu.RUnlock() if exists { t.Fatalf("normalized key still exists after Delete") } } ================================================ FILE: lib/store/storetest/storetest.go ================================================ package storetest import ( "bytes" "encoding/json" "errors" "testing" "time" "github.com/TecharoHQ/anubis/lib/store" ) func Common(t *testing.T, f store.Factory, config json.RawMessage) { if err := f.Valid(config); err != nil { t.Fatal(err) } s, err := f.Build(t.Context(), config) if err != nil { t.Fatal(err) } for _, tt := range []struct { err error doer func(t *testing.T, s store.Interface) error name string }{ { name: "basic get set delete", doer: func(t *testing.T, s store.Interface) error { if _, err := s.Get(t.Context(), t.Name()); !errors.Is(err, store.ErrNotFound) { t.Errorf("wanted %s to not exist in store but it exists anyways", t.Name()) } if err := s.Set(t.Context(), t.Name(), []byte(t.Name()), 5*time.Minute); err != nil { return err } val, err := s.Get(t.Context(), t.Name()) if errors.Is(err, store.ErrNotFound) { t.Errorf("wanted %s to exist in store but it does not: %v", t.Name(), err) } else if err != nil { t.Error(err) } if !bytes.Equal(val, []byte(t.Name())) { t.Logf("want: %q", t.Name()) t.Logf("got: %q", string(val)) t.Error("wrong value returned") } if err := s.Delete(t.Context(), t.Name()); err != nil { return err } if _, err := s.Get(t.Context(), t.Name()); !errors.Is(err, store.ErrNotFound) { t.Error("wanted test to not exist in store but it exists anyways") } if err := s.Delete(t.Context(), t.Name()); err == nil { t.Errorf("key %q does not exist and Delete did not return non-nil", t.Name()) } return nil }, }, { name: "expires", doer: func(t *testing.T, s store.Interface) error { if err := s.Set(t.Context(), t.Name(), []byte(t.Name()), 150*time.Millisecond); err != nil { return err } //nosleep:bypass XXX(Xe): use Go's time faking thing in Go 1.25 when that is released. time.Sleep(155 * time.Millisecond) if _, err := s.Get(t.Context(), t.Name()); !errors.Is(err, store.ErrNotFound) { t.Errorf("wanted %s to not exist in store but it exists anyways", t.Name()) } return nil }, }, } { t.Run(tt.name, func(t *testing.T) { t.Parallel() if err := tt.doer(t, s); !errors.Is(err, tt.err) { t.Logf("want: %v", tt.err) t.Logf("got: %v", err) t.Error("wrong error") } }) } } ================================================ FILE: lib/store/valkey/factory.go ================================================ package valkey import ( "context" "encoding/json" "errors" "fmt" "time" "github.com/TecharoHQ/anubis/internal" "github.com/TecharoHQ/anubis/lib/store" valkey "github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9/maintnotifications" ) func init() { store.Register("valkey", Factory{}) } var ( ErrNoURL = errors.New("valkey.Config: no URL defined") ErrBadURL = errors.New("valkey.Config: URL is invalid") // Sentinel validation errors ErrSentinelMasterNameRequired = errors.New("valkey.Sentinel: masterName is required") ErrSentinelAddrRequired = errors.New("valkey.Sentinel: addr is required") ErrSentinelAddrEmpty = errors.New("valkey.Sentinel: addr cannot be empty") ) // Config is what Anubis unmarshals from the "parameters" JSON. type Config struct { URL string `json:"url"` Cluster bool `json:"cluster,omitempty"` Sentinel *Sentinel `json:"sentinel,omitempty"` } func (c Config) Valid() error { var errs []error if c.URL == "" && c.Sentinel == nil { errs = append(errs, ErrNoURL) } // Validate URL only if provided if c.URL != "" { if _, err := valkey.ParseURL(c.URL); err != nil { errs = append(errs, fmt.Errorf("%w: %v", ErrBadURL, err)) } } if c.Sentinel != nil { if err := c.Sentinel.Valid(); err != nil { errs = append(errs, err) } } if len(errs) > 0 { return errors.Join(errs...) } return nil } type Sentinel struct { MasterName string `json:"masterName"` Addr internal.ListOr[string] `json:"addr"` ClientName string `json:"clientName,omitempty"` Username string `json:"username,omitempty"` Password string `json:"password,omitempty"` } func (s Sentinel) Valid() error { var errs []error if s.MasterName == "" { errs = append(errs, ErrSentinelMasterNameRequired) } if len(s.Addr) == 0 { errs = append(errs, ErrSentinelAddrRequired) } else { // Check if all addresses in the list are empty allEmpty := true for _, addr := range s.Addr { if addr != "" { allEmpty = false break } } if allEmpty { errs = append(errs, ErrSentinelAddrEmpty) } } if len(errs) > 0 { return errors.Join(errs...) } return nil } // redisClient is satisfied by *valkey.Client and *valkey.ClusterClient. type redisClient interface { Get(ctx context.Context, key string) *valkey.StringCmd Set(ctx context.Context, key string, value any, expiration time.Duration) *valkey.StatusCmd Del(ctx context.Context, keys ...string) *valkey.IntCmd Ping(ctx context.Context) *valkey.StatusCmd } type Factory struct{} func (Factory) Valid(data json.RawMessage) error { var cfg Config if err := json.Unmarshal(data, &cfg); err != nil { return err } return cfg.Valid() } func (Factory) Build(ctx context.Context, data json.RawMessage) (store.Interface, error) { var cfg Config if err := json.Unmarshal(data, &cfg); err != nil { return nil, err } if err := cfg.Valid(); err != nil { return nil, err } var client redisClient switch { case cfg.Cluster: opts, err := valkey.ParseURL(cfg.URL) if err != nil { return nil, fmt.Errorf("valkey.Factory: %w", err) } // Cluster mode: use the parsed Addr as the seed node. clusterOpts := &valkey.ClusterOptions{ Addrs: []string{opts.Addr}, // Explicitly disable maintenance notifications // This prevents the client from sending CLIENT MAINT_NOTIFICATIONS ON MaintNotificationsConfig: &maintnotifications.Config{ Mode: maintnotifications.ModeDisabled, }, } client = valkey.NewClusterClient(clusterOpts) case cfg.Sentinel != nil: opts := &valkey.FailoverOptions{ MasterName: cfg.Sentinel.MasterName, SentinelAddrs: cfg.Sentinel.Addr, SentinelUsername: cfg.Sentinel.Username, SentinelPassword: cfg.Sentinel.Password, Username: cfg.Sentinel.Username, Password: cfg.Sentinel.Password, ClientName: cfg.Sentinel.ClientName, } client = valkey.NewFailoverClusterClient(opts) default: opts, err := valkey.ParseURL(cfg.URL) if err != nil { return nil, fmt.Errorf("valkey.Factory: %w", err) } opts.MaintNotificationsConfig = &maintnotifications.Config{ Mode: maintnotifications.ModeDisabled, } client = valkey.NewClient(opts) } // Optional but nice: fail fast if the cluster/single node is unreachable. if err := client.Ping(ctx).Err(); err != nil { return nil, fmt.Errorf("valkey.Factory: ping failed: %w", err) } return &Store{client: client}, nil } ================================================ FILE: lib/store/valkey/valkey.go ================================================ package valkey import ( "context" "time" "github.com/TecharoHQ/anubis/lib/store" valkey "github.com/redis/go-redis/v9" ) // Store implements store.Interface on top of Redis/Valkey. type Store struct { client redisClient } var _ store.Interface = (*Store)(nil) func (s *Store) Get(ctx context.Context, key string) ([]byte, error) { cmd := s.client.Get(ctx, key) if err := cmd.Err(); err != nil { if err == valkey.Nil { return nil, store.ErrNotFound } return nil, err } return cmd.Bytes() } func (s *Store) Set(ctx context.Context, key string, value []byte, expiry time.Duration) error { return s.client.Set(ctx, key, value, expiry).Err() } func (s *Store) Delete(ctx context.Context, key string) error { res := s.client.Del(ctx, key) if err := res.Err(); err != nil { return err } if n, _ := res.Result(); n == 0 { return store.ErrNotFound } return nil } // IsPersistent tells Anubis this backend is “real” storage, not in-memory. func (s *Store) IsPersistent() bool { return true } ================================================ FILE: lib/store/valkey/valkey_test.go ================================================ package valkey import ( "encoding/json" "errors" "os" "testing" "github.com/TecharoHQ/anubis/lib/store/storetest" "github.com/testcontainers/testcontainers-go" "github.com/testcontainers/testcontainers-go/wait" ) func TestImpl(t *testing.T) { if os.Getenv("DONT_USE_NETWORK") != "" { t.Skip("test requires network egress") return } testcontainers.SkipIfProviderIsNotHealthy(t) valkeyC, err := testcontainers.Run( t.Context(), "valkey/valkey:8", testcontainers.WithExposedPorts("6379/tcp"), testcontainers.WithWaitStrategy( wait.ForListeningPort("6379/tcp"), wait.ForLog("Ready to accept connections"), ), ) testcontainers.CleanupContainer(t, valkeyC) if err != nil { t.Fatal(err) } endpoint, err := valkeyC.PortEndpoint(t.Context(), "6379/tcp", "redis") if err != nil { t.Fatal(err) } data, err := json.Marshal(Config{ URL: endpoint, }) if err != nil { t.Fatal(err) } storetest.Common(t, Factory{}, json.RawMessage(data)) } func TestFactoryValid(t *testing.T) { tests := []struct { name string jsonData string expectError error }{ { name: "empty config", jsonData: `{}`, expectError: ErrNoURL, }, { name: "valid URL only", jsonData: `{"url": "redis://localhost:6379"}`, expectError: nil, }, { name: "invalid URL", jsonData: `{"url": "invalid-url"}`, expectError: ErrBadURL, }, { name: "valid sentinel config", jsonData: `{"sentinel": {"masterName": "mymaster", "addr": ["localhost:26379"], "password": "mypass"}}`, expectError: nil, }, { name: "sentinel missing masterName", jsonData: `{"sentinel": {"addr": ["localhost:26379"], "password": "mypass"}}`, expectError: ErrSentinelMasterNameRequired, }, { name: "sentinel missing addr", jsonData: `{"sentinel": {"masterName": "mymaster", "password": "mypass"}}`, expectError: ErrSentinelAddrRequired, }, { name: "sentinel empty addr", jsonData: `{"sentinel": {"masterName": "mymaster", "addr": [""], "password": "mypass"}}`, expectError: ErrSentinelAddrEmpty, }, { name: "sentinel missing password", jsonData: `{"sentinel": {"masterName": "mymaster", "addr": ["localhost:26379"]}}`, expectError: nil, }, { name: "sentinel with optional fields", jsonData: `{"sentinel": {"masterName": "mymaster", "addr": ["localhost:26379"], "password": "mypass", "clientName": "myclient", "username": "myuser"}}`, expectError: nil, }, { name: "sentinel single address (not array)", jsonData: `{"sentinel": {"masterName": "mymaster", "addr": "localhost:26379", "password": "mypass"}}`, expectError: nil, }, { name: "sentinel mixed empty and valid addresses", jsonData: `{"sentinel": {"masterName": "mymaster", "addr": ["", "localhost:26379", ""], "password": "mypass"}}`, expectError: nil, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { factory := Factory{} err := factory.Valid(json.RawMessage(tt.jsonData)) if tt.expectError == nil { if err != nil { t.Errorf("expected no error, got: %v", err) } } else { if err == nil { t.Errorf("expected error %v, got nil", tt.expectError) } else if !errors.Is(err, tt.expectError) { t.Errorf("expected error %v, got: %v", tt.expectError, err) } } }) } } ================================================ FILE: lib/testdata/aggressive_403.yaml ================================================ bots: - name: deny user_agent_regex: DENY action: DENY - name: challenge user_agent_regex: CHALLENGE action: CHALLENGE status_codes: CHALLENGE: 401 DENY: 403 ================================================ FILE: lib/testdata/cloudflare-workers-cel.yaml ================================================ bots: - name: cloudflare-workers expression: '"Cf-Worker" in headers' action: DENY status_codes: CHALLENGE: 401 DENY: 403 ================================================ FILE: lib/testdata/cloudflare-workers-header.yaml ================================================ bots: - name: cloudflare-workers headers_regex: CF-Worker: .* action: DENY status_codes: CHALLENGE: 401 DENY: 403 ================================================ FILE: lib/testdata/hack-test.json ================================================ [ { "name": "ipv6-ula", "action": "ALLOW", "remote_addresses": ["fc00::/7"] } ] ================================================ FILE: lib/testdata/hack-test.yaml ================================================ - name: well-known path_regex: ^/.well-known/.*$ action: ALLOW ================================================ FILE: lib/testdata/invalid-challenge-method.yaml ================================================ bots: - name: generic-bot-catchall user_agent_regex: (?i:bot|crawler) action: CHALLENGE challenge: difficulty: 16 algorithm: hunter2 # invalid algorithm ================================================ FILE: lib/testdata/permissive.yaml ================================================ bots: - import: (data)/common/allow-private-addresses.yaml dnsbl: false ================================================ FILE: lib/testdata/rule_change.yaml ================================================ bots: - name: old-rule path_regex: ^/old$ action: CHALLENGE - name: new-rule path_regex: ^/new$ action: CHALLENGE status_codes: CHALLENGE: 401 DENY: 403 ================================================ FILE: lib/testdata/test_config.yaml ================================================ bots: - import: (data)/bots/_deny-pathological.yaml - import: (data)/bots/aggressive-brazilian-scrapers.yaml - import: (data)/meta/ai-block-aggressive.yaml - import: (data)/crawlers/_allow-good.yaml - import: (data)/clients/x-firefox-ai.yaml - import: (data)/common/keep-internet-working.yaml - name: countries-with-aggressive-scrapers action: WEIGH geoip: countries: - BR - CN weight: adjust: 10 - name: aggressive-asns-without-functional-abuse-contact action: WEIGH asns: match: - 13335 # Cloudflare - 136907 # Huawei Cloud - 45102 # Alibaba Cloud weight: adjust: 10 - name: generic-browser user_agent_regex: >- Mozilla|Opera action: WEIGH weight: adjust: 10 dnsbl: false status_codes: CHALLENGE: 200 DENY: 200 thresholds: - name: minimal-suspicion expression: "true" action: CHALLENGE challenge: algorithm: fast difficulty: 1 ================================================ FILE: lib/testdata/test_config_no_thresholds.yaml ================================================ bots: - import: (data)/bots/_deny-pathological.yaml - import: (data)/bots/aggressive-brazilian-scrapers.yaml - import: (data)/meta/ai-block-aggressive.yaml - import: (data)/crawlers/_allow-good.yaml - import: (data)/clients/x-firefox-ai.yaml - import: (data)/common/keep-internet-working.yaml - name: countries-with-aggressive-scrapers action: WEIGH geoip: countries: - BR - CN weight: adjust: 10 - name: aggressive-asns-without-functional-abuse-contact action: WEIGH asns: match: - 13335 # Cloudflare - 136907 # Huawei Cloud - 45102 # Alibaba Cloud weight: adjust: 10 - name: generic-browser user_agent_regex: >- Mozilla|Opera action: WEIGH weight: adjust: 10 dnsbl: false status_codes: CHALLENGE: 200 DENY: 200 thresholds: [] ================================================ FILE: lib/testdata/useragent.yaml ================================================ bots: - name: deny user_agent_regex: DENY action: DENY - name: challenge user_agent_regex: CHALLENGE action: CHALLENGE - name: allow user_agent_regex: ALLOW action: ALLOW ================================================ FILE: lib/testdata/zero_difficulty.yaml ================================================ bots: - import: (data)/bots/_deny-pathological.yaml - import: (data)/bots/aggressive-brazilian-scrapers.yaml - import: (data)/meta/ai-block-aggressive.yaml - import: (data)/crawlers/_allow-good.yaml - import: (data)/clients/x-firefox-ai.yaml - import: (data)/common/keep-internet-working.yaml - name: countries-with-aggressive-scrapers action: WEIGH geoip: countries: - BR - CN weight: adjust: 10 - name: aggressive-asns-without-functional-abuse-contact action: WEIGH asns: match: - 13335 # Cloudflare - 136907 # Huawei Cloud - 45102 # Alibaba Cloud weight: adjust: 10 - name: generic-browser user_agent_regex: >- Mozilla|Opera action: WEIGH weight: adjust: 10 dnsbl: false status_codes: CHALLENGE: 200 DENY: 200 thresholds: - name: minimal-suspicion expression: "true" action: CHALLENGE challenge: algorithm: fast difficulty: 0 ================================================ FILE: lib/thoth/asnchecker.go ================================================ package thoth import ( "context" "errors" "fmt" "log/slog" "net/http" "strings" "time" "github.com/TecharoHQ/anubis/internal" "github.com/TecharoHQ/anubis/lib/policy/checker" iptoasnv1 "github.com/TecharoHQ/thoth-proto/gen/techaro/thoth/iptoasn/v1" ) func (c *Client) ASNCheckerFor(asns []uint32) checker.Impl { asnMap := map[uint32]struct{}{} var sb strings.Builder fmt.Fprintln(&sb, "ASNChecker") for _, asn := range asns { asnMap[asn] = struct{}{} fmt.Fprintln(&sb, "AS", asn) } return &ASNChecker{ iptoasn: c.IPToASN, asns: asnMap, hash: internal.FastHash(sb.String()), } } type ASNChecker struct { iptoasn iptoasnv1.IpToASNServiceClient asns map[uint32]struct{} hash string } func (asnc *ASNChecker) Check(r *http.Request) (bool, error) { ctx, cancel := context.WithTimeout(r.Context(), 500*time.Millisecond) defer cancel() ipInfo, err := asnc.iptoasn.Lookup(ctx, &iptoasnv1.LookupRequest{ IpAddress: r.Header.Get("X-Real-Ip"), }) if err != nil { switch { case errors.Is(err, context.DeadlineExceeded): slog.Debug("error contacting thoth", "err", err, "actionable", false) return false, nil default: slog.Error("error contacting thoth, please contact support", "err", err, "actionable", true) return false, nil } } // If IP is not publicly announced, return false if !ipInfo.GetAnnounced() { return false, nil } _, ok := asnc.asns[uint32(ipInfo.GetAsNumber())] return ok, nil } func (asnc *ASNChecker) Hash() string { return asnc.hash } ================================================ FILE: lib/thoth/asnchecker_test.go ================================================ package thoth_test import ( "fmt" "net/http/httptest" "testing" "github.com/TecharoHQ/anubis/lib/policy/checker" "github.com/TecharoHQ/anubis/lib/thoth" iptoasnv1 "github.com/TecharoHQ/thoth-proto/gen/techaro/thoth/iptoasn/v1" ) var _ checker.Impl = &thoth.ASNChecker{} func TestASNChecker(t *testing.T) { cli := loadSecrets(t) asnc := cli.ASNCheckerFor([]uint32{13335}) for _, cs := range []struct { ipAddress string wantMatch bool wantError bool }{ { ipAddress: "1.1.1.1", wantMatch: true, wantError: false, }, { ipAddress: "2.2.2.2", wantMatch: false, wantError: false, }, { ipAddress: "taco", wantMatch: false, wantError: false, }, { ipAddress: "127.0.0.1", wantMatch: false, wantError: false, }, } { t.Run(fmt.Sprintf("%v", cs), func(t *testing.T) { req := httptest.NewRequest("GET", "/", nil) req.Header.Set("X-Real-Ip", cs.ipAddress) match, err := asnc.Check(req) if match != cs.wantMatch { t.Errorf("Wanted match: %v, got: %v", cs.wantMatch, match) } switch { case err != nil && !cs.wantError: t.Errorf("Did not want error but got: %v", err) case err == nil && cs.wantError: t.Error("Wanted error but got none") } }) } } func BenchmarkWithCache(b *testing.B) { cli := loadSecrets(b) req := &iptoasnv1.LookupRequest{IpAddress: "1.1.1.1"} _, err := cli.IPToASN.Lookup(b.Context(), req) if err != nil { b.Error(err) } for b.Loop() { _, err := cli.IPToASN.Lookup(b.Context(), req) if err != nil { b.Error(err) } } } ================================================ FILE: lib/thoth/auth.go ================================================ package thoth import ( "context" "google.golang.org/grpc" "google.golang.org/grpc/metadata" ) func authUnaryClientInterceptor(token string) grpc.UnaryClientInterceptor { return func( ctx context.Context, method string, req any, reply any, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption, ) error { md := metadata.Pairs("authorization", "Bearer "+token) ctx = metadata.NewOutgoingContext(ctx, md) return invoker(ctx, method, req, reply, cc, opts...) } } func authStreamClientInterceptor(token string) grpc.StreamClientInterceptor { return func( ctx context.Context, desc *grpc.StreamDesc, cc *grpc.ClientConn, method string, streamer grpc.Streamer, opts ...grpc.CallOption, ) (grpc.ClientStream, error) { md := metadata.Pairs("authorization", "Bearer "+token) ctx = metadata.NewOutgoingContext(ctx, md) return streamer(ctx, desc, cc, method, opts...) } } ================================================ FILE: lib/thoth/cachediptoasn.go ================================================ package thoth import ( "context" "errors" "fmt" "log/slog" "net/netip" iptoasnv1 "github.com/TecharoHQ/thoth-proto/gen/techaro/thoth/iptoasn/v1" "github.com/gaissmai/bart" "google.golang.org/grpc" ) type IPToASNWithCache struct { next iptoasnv1.IpToASNServiceClient table *bart.Table[*iptoasnv1.LookupResponse] } func NewIpToASNWithCache(next iptoasnv1.IpToASNServiceClient) *IPToASNWithCache { result := &IPToASNWithCache{ next: next, table: &bart.Table[*iptoasnv1.LookupResponse]{}, } for _, pfx := range []netip.Prefix{ netip.MustParsePrefix("10.0.0.0/8"), // RFC 1918 netip.MustParsePrefix("172.16.0.0/12"), // RFC 1918 netip.MustParsePrefix("192.168.0.0/16"), // RFC 1918 netip.MustParsePrefix("127.0.0.0/8"), // Loopback netip.MustParsePrefix("169.254.0.0/16"), // Link-local netip.MustParsePrefix("100.64.0.0/10"), // CGNAT netip.MustParsePrefix("192.0.0.0/24"), // Protocol assignments netip.MustParsePrefix("192.0.2.0/24"), // TEST-NET-1 netip.MustParsePrefix("198.18.0.0/15"), // Benchmarking netip.MustParsePrefix("198.51.100.0/24"), // TEST-NET-2 netip.MustParsePrefix("203.0.113.0/24"), // TEST-NET-3 netip.MustParsePrefix("240.0.0.0/4"), // Reserved netip.MustParsePrefix("255.255.255.255/32"), // Broadcast netip.MustParsePrefix("fc00::/7"), // Unique local address netip.MustParsePrefix("fe80::/10"), // Link-local netip.MustParsePrefix("::1/128"), // Loopback netip.MustParsePrefix("::/128"), // Unspecified netip.MustParsePrefix("100::/64"), // Discard-only netip.MustParsePrefix("2001:db8::/32"), // Documentation } { result.table.Insert(pfx, &iptoasnv1.LookupResponse{Announced: false}) } return result } func (ip2asn *IPToASNWithCache) Lookup(ctx context.Context, lr *iptoasnv1.LookupRequest, opts ...grpc.CallOption) (*iptoasnv1.LookupResponse, error) { addr, err := netip.ParseAddr(lr.GetIpAddress()) if err != nil { return nil, fmt.Errorf("input is not an IP address: %w", err) } cachedResponse, ok := ip2asn.table.Lookup(addr) if ok { return cachedResponse, nil } resp, err := ip2asn.next.Lookup(ctx, lr, opts...) if err != nil { return nil, err } var errs []error for _, cidr := range resp.GetCidr() { pfx, err := netip.ParsePrefix(cidr) if err != nil { errs = append(errs, err) continue } ip2asn.table.Insert(pfx, resp) } if len(errs) != 0 { slog.Error("errors parsing IP prefixes", "err", errors.Join(errs...)) } return resp, nil } ================================================ FILE: lib/thoth/context.go ================================================ package thoth import "context" type ctxKey struct{} func With(ctx context.Context, cli *Client) context.Context { return context.WithValue(ctx, ctxKey{}, cli) } func FromContext(ctx context.Context) (*Client, bool) { cli, ok := ctx.Value(ctxKey{}).(*Client) return cli, ok } ================================================ FILE: lib/thoth/geoipchecker.go ================================================ package thoth import ( "context" "errors" "fmt" "log/slog" "net/http" "strings" "time" "github.com/TecharoHQ/anubis/lib/policy/checker" iptoasnv1 "github.com/TecharoHQ/thoth-proto/gen/techaro/thoth/iptoasn/v1" ) func (c *Client) GeoIPCheckerFor(countries []string) checker.Impl { countryMap := map[string]struct{}{} var sb strings.Builder fmt.Fprintln(&sb, "GeoIPChecker") for _, cc := range countries { countryMap[cc] = struct{}{} fmt.Fprintln(&sb, cc) } return &GeoIPChecker{ IPToASN: c.IPToASN, Countries: countryMap, hash: sb.String(), } } type GeoIPChecker struct { IPToASN iptoasnv1.IpToASNServiceClient Countries map[string]struct{} hash string } func (gipc *GeoIPChecker) Check(r *http.Request) (bool, error) { ctx, cancel := context.WithTimeout(r.Context(), 500*time.Millisecond) defer cancel() ipInfo, err := gipc.IPToASN.Lookup(ctx, &iptoasnv1.LookupRequest{ IpAddress: r.Header.Get("X-Real-Ip"), }) if err != nil { switch { case errors.Is(err, context.DeadlineExceeded): slog.Debug("error contacting thoth", "err", err, "actionable", false) return false, nil default: slog.Error("error contacting thoth, please contact support", "err", err, "actionable", true) return false, nil } } // If IP is not publicly announced, return false if !ipInfo.GetAnnounced() { return false, nil } _, ok := gipc.Countries[strings.ToLower(ipInfo.GetCountryCode())] return ok, nil } func (gipc *GeoIPChecker) Hash() string { return gipc.hash } ================================================ FILE: lib/thoth/geoipchecker_test.go ================================================ package thoth_test import ( "fmt" "net/http/httptest" "testing" "github.com/TecharoHQ/anubis/lib/policy/checker" "github.com/TecharoHQ/anubis/lib/thoth" ) var _ checker.Impl = &thoth.GeoIPChecker{} func TestGeoIPChecker(t *testing.T) { cli := loadSecrets(t) asnc := cli.GeoIPCheckerFor([]string{"us"}) for _, cs := range []struct { ipAddress string wantMatch bool wantError bool }{ { ipAddress: "1.1.1.1", wantMatch: true, wantError: false, }, { ipAddress: "2.2.2.2", wantMatch: false, wantError: false, }, { ipAddress: "taco", wantMatch: false, wantError: false, }, { ipAddress: "127.0.0.1", wantMatch: false, wantError: false, }, } { t.Run(fmt.Sprintf("%v", cs), func(t *testing.T) { req := httptest.NewRequest("GET", "/", nil) req.Header.Set("X-Real-Ip", cs.ipAddress) match, err := asnc.Check(req) if match != cs.wantMatch { t.Errorf("Wanted match: %v, got: %v", cs.wantMatch, match) } switch { case err != nil && !cs.wantError: t.Errorf("Did not want error but got: %v", err) case err == nil && cs.wantError: t.Error("Wanted error but got none") } }) } } ================================================ FILE: lib/thoth/thoth.go ================================================ package thoth import ( "context" "crypto/tls" "fmt" "time" "github.com/TecharoHQ/anubis" iptoasnv1 "github.com/TecharoHQ/thoth-proto/gen/techaro/thoth/iptoasn/v1" grpcprom "github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus" "github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/timeout" "github.com/prometheus/client_golang/prometheus" "google.golang.org/grpc" "google.golang.org/grpc/credentials" "google.golang.org/grpc/credentials/insecure" healthv1 "google.golang.org/grpc/health/grpc_health_v1" ) type Client struct { conn *grpc.ClientConn health healthv1.HealthClient IPToASN iptoasnv1.IpToASNServiceClient } func New(ctx context.Context, thothURL, apiToken string, plaintext bool) (*Client, error) { clMetrics := grpcprom.NewClientMetrics( grpcprom.WithClientHandlingTimeHistogram( grpcprom.WithHistogramBuckets([]float64{0.001, 0.01, 0.1, 0.3, 0.6, 1, 3, 6, 9, 20, 30, 60, 90, 120}), ), ) prometheus.DefaultRegisterer.Register(clMetrics) do := []grpc.DialOption{ grpc.WithChainUnaryInterceptor( timeout.UnaryClientInterceptor(500*time.Millisecond), clMetrics.UnaryClientInterceptor(), authUnaryClientInterceptor(apiToken), ), grpc.WithChainStreamInterceptor( clMetrics.StreamClientInterceptor(), authStreamClientInterceptor(apiToken), ), grpc.WithUserAgent(fmt.Sprint("Techaro/anubis:", anubis.Version)), } if plaintext { do = append(do, grpc.WithTransportCredentials(insecure.NewCredentials())) } else { do = append(do, grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{}))) } conn, err := grpc.NewClient( thothURL, do..., ) if err != nil { return nil, fmt.Errorf("can't dial thoth at %s: %w", thothURL, err) } hc := healthv1.NewHealthClient(conn) return &Client{ conn: conn, health: hc, IPToASN: NewIpToASNWithCache(iptoasnv1.NewIpToASNServiceClient(conn)), }, nil } func (c *Client) Close() error { if c.conn != nil { return c.conn.Close() } return nil } func (c *Client) WithIPToASNService(impl iptoasnv1.IpToASNServiceClient) { c.IPToASN = impl } ================================================ FILE: lib/thoth/thoth_test.go ================================================ package thoth_test import ( "os" "testing" "github.com/TecharoHQ/anubis/lib/thoth" "github.com/TecharoHQ/anubis/lib/thoth/thothmock" "github.com/joho/godotenv" ) func loadSecrets(t testing.TB) *thoth.Client { t.Helper() if err := godotenv.Load(); err != nil { t.Log("using mock thoth") result := &thoth.Client{} result.WithIPToASNService(thothmock.MockIpToASNService()) return result } cli, err := thoth.New(t.Context(), os.Getenv("THOTH_URL"), os.Getenv("THOTH_API_KEY"), false) if err != nil { t.Fatal(err) } return cli } func TestNew(t *testing.T) { cli := loadSecrets(t) if err := cli.Close(); err != nil { t.Fatal(err) } } ================================================ FILE: lib/thoth/thothmock/iptoasn.go ================================================ package thothmock import ( "context" "net/netip" iptoasnv1 "github.com/TecharoHQ/thoth-proto/gen/techaro/thoth/iptoasn/v1" "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" ) func MockIpToASNService() *IpToASNService { responses := map[string]*iptoasnv1.LookupResponse{ "127.0.0.1": {Announced: false}, "::1": {Announced: false}, "10.10.10.10": { Announced: true, AsNumber: 13335, Cidr: []string{"1.1.1.0/24"}, CountryCode: "US", Description: "Cloudflare", }, "2.2.2.2": { Announced: true, AsNumber: 420, Cidr: []string{"2.2.2.0/24"}, CountryCode: "CA", Description: "test canada", }, "1.1.1.1": { Announced: true, AsNumber: 13335, Cidr: []string{"1.1.1.0/24"}, CountryCode: "US", Description: "Cloudflare", }, } return &IpToASNService{Responses: responses} } type IpToASNService struct { iptoasnv1.UnimplementedIpToASNServiceServer Responses map[string]*iptoasnv1.LookupResponse } func (ip2asn *IpToASNService) Lookup(ctx context.Context, lr *iptoasnv1.LookupRequest, opts ...grpc.CallOption) (*iptoasnv1.LookupResponse, error) { if _, err := netip.ParseAddr(lr.GetIpAddress()); err != nil { return nil, err } resp, ok := ip2asn.Responses[lr.GetIpAddress()] if !ok { return nil, status.Error(codes.NotFound, "IP address not found in mock") } return resp, nil } ================================================ FILE: lib/thoth/thothmock/withthothmock.go ================================================ package thothmock import ( "context" "testing" "github.com/TecharoHQ/anubis/lib/thoth" ) func WithMockThoth(t *testing.T) context.Context { t.Helper() thothCli := &thoth.Client{} thothCli.WithIPToASNService(MockIpToASNService()) ctx := thoth.With(t.Context(), thothCli) return ctx } ================================================ FILE: package.json ================================================ { "name": "@techaro/anubis", "version": "1.25.0", "description": "", "main": "index.js", "scripts": { "test": "npm run assets && SKIP_INTEGRATION=1 go test ./...", "test:integration": "npm run assets && go test -v ./internal/test", "test:integration:podman": "npm run assets && go test -v ./internal/test --playwright-runner=podman", "test:integration:docker": "npm run assets && go test -v ./internal/test --playwright-runner=docker", "assets": "go generate ./... && ./web/build.sh && ./xess/build.sh", "build": "npm run assets && go build -o ./var/anubis ./cmd/anubis", "dev": "npm run assets && go run ./cmd/anubis --use-remote-address --target http://localhost:3000", "container": "npm run assets && go run ./cmd/containerbuild", "package": "go tool yeet", "lint": "make lint", "prepare": "husky && go mod download", "format": "prettier -w . 2>&1 >/dev/null && go run goimports -w ." }, "author": "", "license": "ISC", "devDependencies": { "@commitlint/cli": "^20.4.3", "@commitlint/config-conventional": "^20.4.3", "baseline-browser-mapping": "^2.10.0", "cssnano": "^7.1.3", "cssnano-preset-advanced": "^7.0.11", "esbuild": "^0.27.3", "husky": "^9.1.7", "playwright": "^1.52.0", "postcss-cli": "^11.0.1", "postcss-import": "^16.1.1", "postcss-import-url": "^7.2.0", "postcss-url": "^10.1.3", "prettier": "^3.8.1" }, "dependencies": { "@aws-crypto/sha256-js": "^5.2.0", "preact": "^10.28.4" }, "commitlint": { "extends": [ "@commitlint/config-conventional" ], "rules": { "body-max-line-length": [ 2, "always", 99999 ], "footer-max-line-length": [ 2, "always", 99999 ], "signed-off-by": [ 2, "always" ] } }, "prettier": { "singleQuote": false, "tabWidth": 2, "semi": true, "trailingComma": "all", "printWidth": 80 } } ================================================ FILE: run/anubis.freebsd ================================================ #!/bin/sh # PROVIDE: anubis # REQUIRE: DAEMON NETWORKING # KEYWORD: shutdown # Add the following lines to /etc/rc.conf.local or /etc/rc.conf to enable anubis: # anubis_enable (bool): Set to "NO" by default. # Set it to "YES" to enable anubis. # anubis_user (user): Set to "www" by default. # User to run anubis as. # anubis_group (group): Set to "www" by default. # Group to run anubis as. # anubis_bin (str): Set to "/usr/local/bin/anubis" by default. # Location of the anubis binary # anubis_args (str): Set to "" by default. # Extra flags passed to anubis. # anubis_env (str): Set to "" by default. # List of environment variables to be set before starting.. # anubis_env_file (str): Set to "/etc/anubis.env" by default. # Location of a file containing environment variables. # # Closely follows the init script from https://cgit.freebsd.org/ports/tree/www/go-anubis/files/anubis.in # with a couple of adjustments for more flexible environment variable handling . /etc/rc.subr name=anubis rcvar=anubis_enable load_rc_config ${name} : ${anubis_enable="NO"} : ${anubis_user="www"} : ${anubis_group="www"} : ${anubis_bin="/usr/local/bin/anubis"} : ${anubis_args=""} : ${anubis_env=""} : ${anubis_env_file="/etc/anubis.env"} pidfile=/var/run/${name}.pid daemon_pidfile=/var/run/${name}-daemon.pid command=/usr/sbin/daemon procname=${anubis_bin} logfile=/var/log/${name}.log command_args="-c -f -R 5 -r -T ${name} -p ${pidfile} -P ${daemon_pidfile} -o ${logfile} ${procname} ${anubis_args}" start_precmd=anubis_startprecmd stop_postcmd=anubis_stoppostcmd anubis_startprecmd () { if [ ! -e ${logfile} ]; then install -o ${anubis_user} -g ${anubis_group} /dev/null ${logfile} fi if [ ! -e ${daemon_pidfile} ]; then install -o ${anubis_user} -g ${anubis_group} /dev/null ${daemon_pidfile} fi if [ ! -e ${pidfile} ]; then install -o ${anubis_user} -g ${anubis_group} /dev/null ${pidfile} fi } anubis_stoppostcmd() { if [ -f "${daemon_pidfile}" ]; then pids=$( pgrep -F ${daemon_pidfile} 2>&1 ) _err=$? [ ${_err} -eq 0 ] && kill -9 ${pids} fi } run_rc_command "$1" ================================================ FILE: run/anubis@.service ================================================ [Unit] Description="Anubis HTTP defense proxy (instance %i)" [Service] ExecStart=/usr/bin/anubis Restart=always RestartSec=30s EnvironmentFile=/etc/anubis/%i.env LimitNOFILE=infinity DynamicUser=yes CacheDirectory=anubis/%i CacheDirectoryMode=0755 StateDirectory=anubis/%i StateDirectoryMode=0755 RuntimeDirectory=anubis/%i RuntimeDirectoryMode=0755 ReadWritePaths=/run [Install] WantedBy=multi-user.target ================================================ FILE: run/default.env ================================================ BIND=:8923 DIFFICULTY=4 METRICS_BIND=:9090 SERVE_ROBOTS_TXT=0 TARGET=http://localhost:3000 ================================================ FILE: run/openrc/anubis.confd ================================================ # The URL of the service that Anubis should forward valid requests to. Supports # Unix domain sockets. #ANUBIS_TARGET="http://localhost:3923" #ANUBIS_TARGET="unix:///path/to/socket" # The network address that Anubis listens on. # # If unset, listen on /run/anubis_${instance}/anubis.sock Unix socket instead. #ANUBIS_BIND_PORT=":8923" # The network address that Anubis serves Prometheus metrics on. # # If unset, listen on /run/anubis_${instance}/metrix.sock Unix socket instead. #ANUBIS_METRICS_BIND_PORT=":9090" # The difficulty of the challenge, or the number of leading zeroes that must be # in successful responses. #ANUBIS_DIFFICULTY=4 # Additional command-line options for Anubis. #ANUBIS_OPTS="" # Configure the user[:group] Anubis will run as. #command_user="anubis:anubis" ================================================ FILE: run/openrc/anubis.initd ================================================ #!/sbin/openrc-run # shellcheck shell=sh instance=${RC_SVCNAME#*.} description="Anubis HTTP defense proxy (instance ${instance})" supervisor="supervise-daemon" command="/usr/bin/anubis" command_args="\ -bind ${ANUBIS_BIND_PORT:-/run/anubis_${instance?}/anubis.sock -bind-network unix} \ -metrics-bind ${ANUBIS_METRICS_BIND_PORT:-/run/anubis_${instance?}/metrics.sock -metrics-bind-network unix} \ -target ${ANUBIS_TARGET:-http://localhost:3923} \ -difficulty ${ANUBIS_DIFFICULTY:-4} \ ${ANUBIS_OPTS} " command_background=1 pidfile="/run/anubis_${instance?}/anubis.pid" : "${command_user:=anubis:anubis}" depend() { use net firewall } start_pre() { if [ "${instance?}" = "${RC_SVCNAME?}" ]; then eerror "${RC_SVCNAME?} cannot be started directly. You must create" eerror "symbolic links to it for the services you want to start" eerror "and add those to the appropriate runlevels." return 1 fi rm -rf "/run/anubis_${instance?}" checkpath -D -o "${command_user?}" "/run/anubis_${instance?}" } ================================================ FILE: test/.gitignore ================================================ *.sock *.pem ================================================ FILE: test/anubis_configs/aggressive_403.yaml ================================================ bots: - name: deny user_agent_regex: DENY action: DENY - name: challenge user_agent_regex: CHALLENGE action: CHALLENGE status_codes: CHALLENGE: 401 DENY: 403 ================================================ FILE: test/caddy/Caddyfile ================================================ :80 { reverse_proxy http://anubis:3000 { header_up X-Real-Ip {remote_host} header_up X-Http-Version {http.request.proto} } } :443 { tls /etc/techaro/pki/caddy.local.cetacean.club/cert.pem /etc/techaro/pki/caddy.local.cetacean.club/key.pem reverse_proxy http://anubis:3000 { header_up X-Real-Ip {remote_host} header_up X-Http-Version {http.request.proto} header_up X-Tls-Version {http.request.tls.version} } } ================================================ FILE: test/caddy/Dockerfile ================================================ # FROM caddy:2.10.0-builder AS builder # RUN xcaddy build \ # --with github.com/lolPants/caddy-requestid FROM caddy:2.10.0 AS run # COPY --from=builder /usr/bin/caddy /usr/bin/caddy COPY Caddyfile /etc/caddy/Caddyfile ================================================ FILE: test/caddy/docker-compose.yaml ================================================ services: caddy: image: xxxtest/caddy build: . ports: - 8080:80 - 8443:443 volumes: - "../pki/caddy.local.cetacean.club:/etc/techaro/pki/caddy.local.cetacean.club/" anubis: image: ghcr.io/techarohq/anubis:main environment: BIND: ":3000" TARGET: http://httpdebug:3000 POLICY_FNAME: /etc/techaro/anubis/less_paranoid.yaml volumes: - ../anubis_configs:/etc/techaro/anubis httpdebug: image: ghcr.io/xe/x/httpdebug pull_policy: always ================================================ FILE: test/caddy/start.sh ================================================ #!/usr/bin/env bash # If the transient local TLS certificate doesn't exist, mint a new one if [ ! -f ../pki/caddy.local.cetacean.club/cert.pem ]; then # Subshell to contain the directory change ( cd ../pki \ && mkdir -p caddy.local.cetacean.club \ && \ # Try using https://github.com/FiloSottile/mkcert for better DevEx, # but fall back to using https://github.com/jsha/minica in case # you don't have that installed. ( mkcert \ --cert-file ./caddy.local.cetacean.club/cert.pem \ --key-file ./caddy.local.cetacean.club/key.pem caddy.local.cetacean.club \ || go tool minica -domains caddy.local.cetacean.club ) ) fi docker compose up --build ================================================ FILE: test/cmd/cipra/internal/containerip.go ================================================ package internal import ( "context" "fmt" "github.com/docker/docker/client" ) // GetContainerIPAddress returns the first non-empty IP address of the container with the given name. // It returns the IP address as a string or an error. func GetContainerIPAddress(containerName string) (string, error) { ctx := context.Background() cli, err := client.NewClientWithOpts(client.FromEnv) if err != nil { return "", err } // Get container details containerJSON, err := cli.ContainerInspect(ctx, containerName) if err != nil { return "", err } // Loop through all networks and return the first IP address found for _, net := range containerJSON.NetworkSettings.Networks { if net.IPAddress != "" { return net.IPAddress, nil } } return "", fmt.Errorf("no IP address found for container %q", containerName) } ================================================ FILE: test/cmd/cipra/internal/getlanip.go ================================================ package internal import ( "fmt" "net" ) // GetLANIP returns the first non-loopback IPv4 LAN IP address. func GetLANIP() (net.IP, error) { ifaces, err := net.Interfaces() if err != nil { return nil, err } for _, iface := range ifaces { // Skip down or loopback interfaces if iface.Flags&(net.FlagUp|net.FlagLoopback) != net.FlagUp { continue } addrs, err := iface.Addrs() if err != nil { continue // skip interfaces we can't query } for _, addr := range addrs { var ip net.IP switch v := addr.(type) { case *net.IPNet: ip = v.IP case *net.IPAddr: ip = v.IP } if ip == nil || ip.IsLoopback() { continue } ip = ip.To4() if ip == nil { continue // not an IPv4 address } return ip, nil } } return nil, fmt.Errorf("no connected LAN IPv4 address found") } ================================================ FILE: test/cmd/cipra/internal/unbreakdocker.go ================================================ package internal import ( "context" "log" "os" "github.com/docker/docker/api/types/network" "github.com/docker/docker/client" ) // UnbreakDocker connects the container named after the current hostname // to the specified Docker network. func UnbreakDocker(networkName string) error { ctx := context.Background() cli, err := client.NewClientWithOpts(client.FromEnv) if err != nil { return err } hostname, err := os.Hostname() if err != nil { return err } err = cli.NetworkConnect(ctx, networkName, hostname, &network.EndpointSettings{}) if err != nil { return err } log.Printf("Connected container %q to network %q\n", hostname, networkName) return nil } ================================================ FILE: test/cmd/cipra/main.go ================================================ package main import ( "context" "errors" "flag" "fmt" "log" "net/http" "os" "os/exec" "strings" "time" "github.com/TecharoHQ/anubis/test/cmd/cipra/internal" "github.com/facebookgo/flagenv" ) var ( bind = flag.String("bind", ":9090", "TCP host:port to bind HTTP on") browserBin = flag.String("browser-bin", "palemoon", "browser binary name") browserContainerName = flag.String("browser-container-name", "palemoon", "browser container name") composeName = flag.String("compose-name", "", "docker compose base name for resources") vncServerContainer = flag.String("vnc-container-name", "display", "VNC host:port (NOT a display number)") ) func main() { flagenv.Parse() flag.Parse() ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() lanip, err := internal.GetLANIP() if err != nil { log.Panic(err) } os.Setenv("TARGET", fmt.Sprintf("%s%s", lanip.String(), *bind)) http.HandleFunc("/{$}", func(w http.ResponseWriter, r *http.Request) { http.Error(w, "OK", http.StatusOK) log.Println("got termination signal", r.RequestURI) go func() { time.Sleep(2 * time.Second) cancel() }() }) srv := &http.Server{ Handler: http.DefaultServeMux, Addr: *bind, } go func() { if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) { log.Panic(err) } }() if err := RunScript(ctx, "docker", "compose", "up", "-d"); err != nil { log.Fatalf("can't start project: %v", err) } defer RunScript(ctx, "docker", "compose", "down", "-t", "1") defer RunScript(ctx, "docker", "compose", "rm", "-f") internal.UnbreakDocker(*composeName + "_default") if err := RunScript(ctx, "docker", "exec", fmt.Sprintf("%s-%s-1", *composeName, *browserContainerName), "bash", "/hack/scripts/install-cert.sh"); err != nil { log.Panic(err) } if err := RunScript(ctx, "docker", "exec", fmt.Sprintf("%s-%s-1", *composeName, *browserContainerName), *browserBin, "https://relayd"); err != nil { log.Panic(err) } <-ctx.Done() srv.Close() time.Sleep(2 * time.Second) } func RunScript(ctx context.Context, args ...string) error { var err error backoff := 250 * time.Millisecond for attempt := 0; attempt < 5; attempt++ { select { case <-ctx.Done(): return nil default: } log.Printf("Running command: %s", strings.Join(args, " ")) cmd := exec.CommandContext(ctx, args[0], args[1:]...) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr err = cmd.Run() if exitErr, ok := err.(*exec.ExitError); ok { log.Printf("attempt=%d code=%d", attempt, exitErr.ExitCode()) } if err == nil { return nil } log.Printf("Attempt %d failed: %v %T", attempt+1, err, err) log.Printf("Retrying in %v...", backoff) time.Sleep(backoff) backoff *= 2 } return fmt.Errorf("script failed after 5 attempts: %w", err) } ================================================ FILE: test/cmd/httpdebug/main.go ================================================ package main import ( "flag" "fmt" "log" "log/slog" "net/http" ) var ( bind = flag.String("bind", ":3923", "TCP port to bind to") ) func main() { flag.Parse() slog.Info("listening", "url", "http://localhost"+*bind) log.Fatal(http.ListenAndServe(*bind, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { slog.Info("got request", "method", r.Method, "path", r.RequestURI) fmt.Fprintln(w, r.Method, r.RequestURI) r.Header.Write(w) }))) } ================================================ FILE: test/cmd/relayd/main.go ================================================ package main import ( "context" "flag" "log" "log/slog" "net" "net/http" "net/http/httputil" "net/url" "os" "path/filepath" "strings" "time" "github.com/TecharoHQ/anubis/internal" "github.com/facebookgo/flagenv" "github.com/google/uuid" ) var ( bind = flag.String("bind", ":3004", "port to listen on") certDir = flag.String("cert-dir", "/xe/pki", "where to read mounted certificates from") certFname = flag.String("cert-fname", "cert.pem", "certificate filename") keyFname = flag.String("key-fname", "key.pem", "key filename") proxyTo = flag.String("proxy-to", "http://localhost:5000", "where to reverse proxy to") slogLevel = flag.String("slog-level", "info", "logging level") ) func main() { flagenv.Parse() flag.Parse() internal.InitSlog(*slogLevel) slog.Info("starting", "bind", *bind, "cert-dir", *certDir, "cert-fname", *certFname, "key-fname", *keyFname, "proxy-to", *proxyTo, ) cert := filepath.Join(*certDir, *certFname) key := filepath.Join(*certDir, *keyFname) st, err := os.Stat(cert) if err != nil { slog.Error("can't stat cert file", "certFname", cert) os.Exit(1) } lastModified := st.ModTime() go func(lm time.Time) { t := time.NewTicker(time.Hour) defer t.Stop() for range t.C { st, err := os.Stat(cert) if err != nil { slog.Error("can't stat file", "fname", cert, "err", err) continue } if st.ModTime().After(lm) { slog.Info("new cert detected", "oldTime", lm.Format(time.RFC3339), "newTime", st.ModTime().Format(time.RFC3339)) os.Exit(0) } } }(lastModified) u, err := url.Parse(*proxyTo) if err != nil { log.Fatal(err) } h := httputil.NewSingleHostReverseProxy(u) if u.Scheme == "unix" { slog.Info("using unix socket proxy") h = &httputil.ReverseProxy{ Director: func(r *http.Request) { r.URL.Scheme = "http" r.URL.Host = r.Host r.Header.Set("X-Forwarded-Proto", "https") r.Header.Set("X-Forwarded-Scheme", "https") r.Header.Set("X-Request-Id", uuid.NewString()) r.Header.Set("X-Scheme", "https") remoteHost, remotePort, err := net.SplitHostPort(r.Host) if err == nil { r.Header.Set("X-Forwarded-Host", remoteHost) r.Header.Set("X-Forwarded-Port", remotePort) } else { r.Header.Set("X-Forwarded-Host", r.Host) } host, _, err := net.SplitHostPort(r.RemoteAddr) if err == nil { r.Header.Set("X-Real-Ip", host) } }, Transport: &http.Transport{ DialContext: func(_ context.Context, _, _ string) (net.Conn, error) { return net.Dial("unix", strings.TrimPrefix(*proxyTo, "unix://")) }, }, } } log.Fatal( http.ListenAndServeTLS( *bind, cert, key, h, ), ) } ================================================ FILE: test/cmd/unixhttpd/main.go ================================================ package main import ( "flag" "fmt" "log" "log/slog" "net" "net/http" "os" "path/filepath" "strings" "github.com/TecharoHQ/anubis/internal" "github.com/facebookgo/flagenv" ) var ( dir = flag.String("dir", ".", "directory to serve") slogLevel = flag.String("slog-level", "info", "logging level") socketPath = flag.String("socket-path", "./unixhttpd.sock", "unix socket path to use") ) func init() { flag.Usage = func() { fmt.Fprintf(os.Stderr, "Usage of %s:\n", filepath.Base(os.Args[0])) fmt.Fprintf(os.Stderr, " %s [--dir=.] [--socket-path=./unixhttpd.sock]\n\n", filepath.Base(os.Args[0])) flag.PrintDefaults() os.Exit(2) } } func main() { flagenv.Parse() flag.Parse() internal.InitSlog(*slogLevel) if *dir == "" && *socketPath == "" { flag.Usage() } slog.Info("starting up", "dir", *dir, "socketPath", *socketPath) os.Remove(*socketPath) mux := http.NewServeMux() mux.HandleFunc("/reqmeta", func(w http.ResponseWriter, r *http.Request) { contains := strings.Contains(r.Header.Get("Accept"), "text/html") if contains { w.Header().Add("Content-Type", "text/html") fmt.Fprint(w, "
")
		}

		r.Write(w)

		if contains {
			fmt.Fprintln(w, "
") } }) mux.Handle("/", http.FileServer(http.Dir(*dir))) server := http.Server{ Handler: mux, } unixListener, err := net.Listen("unix", *socketPath) if err != nil { panic(err) } log.Fatal(server.Serve(unixListener)) } ================================================ FILE: test/default-config-macro/compare_bots.py ================================================ #!/usr/bin/env python3 """ Script to verify that the 'bots' field in data/botPolicies.yaml has the same semantic contents as data/meta/default-config.yaml. CW: generated by AI """ import yaml import sys import os import subprocess import difflib def load_yaml(file_path): """Load YAML file and return the data.""" try: with open(file_path, 'r') as f: return yaml.safe_load(f) except Exception as e: print(f"Error loading {file_path}: {e}") sys.exit(1) def normalize_yaml(data): """Normalize YAML data by removing comments and standardizing structure.""" # For lists, just return as is, since YAML comments are stripped by safe_load return data def get_repo_root(): """Get the root directory of the git repository.""" try: result = subprocess.run(['git', 'rev-parse', '--show-toplevel'], capture_output=True, text=True, check=True) return result.stdout.strip() except subprocess.CalledProcessError: print("Error: Not in a git repository") sys.exit(1) def main(): # Get the git repository root repo_root = get_repo_root() # Paths relative to the repo root bot_policies_path = os.path.join(repo_root, 'data', 'botPolicies.yaml') default_config_path = os.path.join(repo_root, 'data', 'meta', 'default-config.yaml') # Load the files bot_policies = load_yaml(bot_policies_path) default_config = load_yaml(default_config_path) # Extract the 'bots' field from botPolicies.yaml if 'bots' not in bot_policies: print("Error: 'bots' field not found in botPolicies.yaml") sys.exit(1) bots_field = bot_policies['bots'] # The default-config.yaml is a list directly default_bots = default_config # Normalize both normalized_bots = normalize_yaml(bots_field) normalized_default = normalize_yaml(default_bots) # Compare if normalized_bots == normalized_default: print("SUCCESS: The 'bots' field in botPolicies.yaml matches the contents of default-config.yaml") sys.exit(0) else: print("FAILURE: The 'bots' field in botPolicies.yaml does not match the contents of default-config.yaml") print("\nDiff:") bots_yaml = yaml.dump(normalized_bots, default_flow_style=False) default_yaml = yaml.dump(normalized_default, default_flow_style=False) diff = difflib.unified_diff( bots_yaml.splitlines(keepends=True), default_yaml.splitlines(keepends=True), fromfile='bots field in botPolicies.yaml', tofile='default-config.yaml' ) print(''.join(diff)) sys.exit(1) if __name__ == "__main__": main() ================================================ FILE: test/default-config-macro/test.sh ================================================ #!/usr/bin/env bash set -euo pipefail cd "$(dirname "$0")" python3 -c 'import yaml' python3 ./compare_bots.py ================================================ FILE: test/docker-registry/anubis.yaml ================================================ bots: - import: (data)/meta/default-config.yaml - import: (data)/clients/docker-client.yaml status_codes: CHALLENGE: 200 DENY: 403 ================================================ FILE: test/docker-registry/docker-compose.yaml ================================================ services: registry: image: distribution/distribution:edge restart: always relayd: image: ghcr.io/xe/x/relayd pull_policy: always environment: CERT_DIR: /etc/techaro/pki/registry.local.cetacean.club CERT_FNAME: cert.pem KEY_FNAME: key.pem PROXY_TO: http://anubis:3000 ports: - 3004:3004 volumes: - ./pki/registry.local.cetacean.club:/etc/techaro/pki/registry.local.cetacean.club anubis: image: ko.local/anubis restart: always environment: BIND: ":3000" TARGET: http://registry:5000 POLICY_FNAME: /etc/techaro/anubis.yaml USE_REMOTE_ADDRESS: "true" ports: - 3000 volumes: - ./anubis.yaml:/etc/techaro/anubis.yaml ================================================ FILE: test/docker-registry/test.sh ================================================ #!/usr/bin/env bash set -eo pipefail export VERSION=${GITHUB_SHA}-test export KO_DOCKER_REPO=ko.local set -u source ../lib/lib.sh build_anubis_ko function cleanup() { docker compose down } trap cleanup EXIT SIGINT mint_cert registry.local.cetacean.club docker compose up -d backoff-retry skopeo \ --insecure-policy \ copy \ --dest-tls-verify=false \ docker://hello-world \ docker://registry.local.cetacean.club:3004/hello-world ================================================ FILE: test/docker-registry/var/.gitignore ================================================ * !.gitignore ================================================ FILE: test/double_slash/anubis.yaml ================================================ bots: - name: challenge user_agent_regex: CHALLENGE action: CHALLENGE status_codes: CHALLENGE: 200 DENY: 403 ================================================ FILE: test/double_slash/input.txt ================================================ /wiki//bin /wiki//boot /wiki//dev /wiki//dev/de /wiki//dev/en /wiki//dev/en-ca /wiki//dev/es /wiki//dev/fr /wiki//dev/hr /wiki//dev/hu /wiki//dev/it /wiki//dev/ja /wiki//dev/ko /wiki//dev/pl /wiki//dev/pt-br /wiki//dev/ro /wiki//dev/ru /wiki//dev/sv /wiki//dev/uk /wiki//dev/zh-cn /wiki//etc /wiki//etc/conf.d /wiki//etc/env.d /wiki//etc/fstab /wiki//etc/fstab/de /wiki//etc/fstab/en /wiki//etc/fstab/es /wiki//etc/fstab/fr /wiki//etc/fstab/hu /wiki//etc/fstab/it /wiki//etc/fstab/ja /wiki//etc/fstab/ko /wiki//etc/fstab/ru /wiki//etc/fstab/sv /wiki//etc/fstab/uk /wiki//etc/fstab/zh-cn /wiki//etc/hosts /wiki//etc/local.d /wiki//etc/make.conf /wiki//etc/portage /wiki//etc/portage/bashrc /wiki//etc/portage/Bashrc /wiki//etc/portage/binrepos.conf /wiki//etc/portage/binrepos.conf/en /wiki//etc/portage/binrepos.conf/hu /wiki//etc/portage/binrepos.conf/ja /wiki//etc/portage/binrepos.conf/ru /wiki//etc/portage/categories /wiki//etc/portage/color.map /wiki//etc/portage/env /wiki//etc/portage/img/ico.png /wiki//etc/portage/license_groups /wiki//etc/portage/make.conf /wiki//etc/portage/make.conf/de /wiki//etc/portage/make.conf/de/etc/portage/make.conf /wiki//etc/portage/make.conf/en /wiki//etc/portage/make.conf/es /wiki//etc/portage/make.conf/fr /wiki//etc/portage/make.conf/hu /wiki//etc/portage/make.conf/it /wiki//etc/portage/make.conf/it/var/db/repos/gentoo/licenses /wiki//etc/portage/make.conf/ja /wiki//etc/portage/make.conf/pl /wiki//etc/portage/make.conf/ru /wiki//etc/portage/make.conf/uk /wiki//etc/portage/make.conf/zh-cn /wiki//etc/portage/make.profile /wiki//etc/portage/mirrors /wiki//etc/portage/modules /wiki//etc/portage/package.accept_keywords /wiki//etc/portage/package.env /wiki//etc/portage/package.license /wiki//etc/portage/package.license/en /wiki//etc/portage/package.license/es /wiki//etc/portage/package.license/hu /wiki//etc/portage/package.license/ja /wiki//etc/portage/package.mask /wiki//etc/portage/package.mask/en /wiki//etc/portage/package.mask/hu /wiki//etc/portage/package.mask/ja /wiki//etc/portage/package.properties /wiki//etc/portage/package.unmask /wiki//etc/portage/package.use /wiki//etc/portage/package.use/de /wiki//etc/portage/package.use/en /wiki//etc/portage/package.use/es /wiki//etc/portage/package.use/fr /wiki//etc/portage/package.use/hu /wiki//etc/portage/package.use/it /wiki//etc/portage/package.use/ja /wiki//etc/portage/package.use/ru /wiki//etc/portage/package.use/uk /wiki//etc/portage/package.use/zh-cn /wiki//etc/portage/patches /wiki//etc/portage/profile/make.defaults /wiki//etc/portage/profile/package.provided /wiki//etc/portage/profile/package.provided/etc/portage/profile/package.provided /wiki//etc/portage/profile/package.provided/etc/portage/profiles/package.provided /wiki//etc/portage/profile/package.use.mask /wiki//etc/portage/profiles/package.provided /wiki//etc/portage/profiles/package.use.mask /wiki//etc/portage/profiles/package.use.mask/etc/portage/profile/package.use.mask /wiki//etc/portage/profiles/package.use.mask/etc/portage/profiles/package.use.mask /wiki//etc/portage/profiles/use.mask /wiki//etc/portage/profile/use.mask /wiki//etc/portage/repos.conf /wiki//etc/portage/repos.conf/brother-overlay.conf /wiki//etc/portage/repos.conf/de /wiki//etc/portage/repos.conf/en /wiki//etc/portage/repos.conf/es /wiki//etc/portage/repos.conf/etc/portage/repos.conf/gentoo.conf /wiki//etc/portage/repos.conf/fr /wiki//etc/portage/repos.conf/fr/etc/portage/repos.conf/gentoo.conf /wiki//etc/portage/repos.conf/gentoo.conf /wiki//etc/portage/repos.conf/gentoo.conf/etc/portage/repos.conf/gentoo.conf /wiki//etc/portage/repos.conf/hr /wiki//etc/portage/repos.conf/hu /wiki//etc/portage/repos.conf/it /wiki//etc/portage/repos.conf/ja /wiki//etc/portage/repos.conf/ko /wiki//etc/portage/repos.conf/pl /wiki//etc/portage/repos.conf/pt-br /wiki//etc/portage/repos.conf/ru /wiki//etc/portage/repos.conf/uk /wiki//etc/portage/repos.conf/zh-cn /wiki//etc/portage/savedconfig /wiki//etc/portage/sets /wiki//etc/profile /wiki//etc/profile.env /wiki//etc/sandbox.conf /wiki//home /wiki//lib /wiki//lib64 /wiki//media /wiki//mnt /wiki//opt /wiki//proc /wiki//proc/config.gz /wiki//run /wiki//sbin /wiki//srv /wiki//sys /wiki//tmp /wiki//usr /wiki//usr/bin /wiki//usr_move /wiki//usr/portage /wiki//usr/portage/distfiles /wiki//usr/portage/licenses /wiki//usr/portage/metadata /wiki//usr/portage/metadata/md5-cache /wiki//usr/portage/metadata/md5-cache/usr/portage/metadata/md5-cache /wiki//usr/portage/metadata/md5-cache/var/db/repos/gentoo//metadata/md5-cache /wiki//usr/portage/packages /wiki//usr/portage/profiles /wiki//usr/portage/profiles/license_groups /wiki//usr/portage/profiles/license_groups/usr/portage/profiles/license_groups /wiki//usr/portage/profiles/license_groups/var/db/repos/gentoo//profiles/license_groups /wiki//usr/share/doc/ /wiki//var/cache/binpkgs /wiki//var/cache/distfiles /wiki//var/db/pkg /wiki//var/db/pkg%22 /wiki//var/db/repos/gentoo /wiki//var/db/repos/gentoo/licenses /wiki//var/db/repos/gentoo/licenses/var/db/repos/gentoo//licenses /wiki//var/db/repos/gentoo/licenses/var/db/repos/gentoo/licenses /wiki//var/db/repos/gentoo/metadata /wiki//var/db/repos/gentoo/metadata/md5-cache /wiki//var/db/repos/gentoo/metadata/var/db/repos/gentoo//metadata /wiki//var/db/repos/gentoo/metadata/var/db/repos/gentoo/metadata /wiki//var/db/repos/gentoo/profiles /wiki//var/db/repos/gentoo/profiles/license_groups /wiki//var/db/repos/gentoo/profiles/package.mask /wiki//var/lib/portage /wiki//var/lib/portage/world /wiki//var/run /gcc-bugs/bug-122002-4@http.gcc.gnu.org%2Fbugzilla%2F/T/ ================================================ FILE: test/double_slash/test.mjs ================================================ import { createReadStream } from "fs"; import { createInterface } from "readline"; async function getPage(path) { return fetch(`http://localhost:8923${path}`) .then((resp) => { if (resp.status !== 200) { throw new Error(`wanted status 200, got status: ${resp.status}`); } return resp; }) .then((resp) => resp.text()); } (async () => { const fin = createReadStream("input.txt"); const rl = createInterface({ input: fin, crlfDelay: Infinity, }); const resultSheet = {}; let failed = false; for await (const line of rl) { console.log(line); const resp = await getPage(line); resultSheet[line] = { match: resp.includes(`GET ${line}`), line: resp.split("\n")[0], }; } for (let [k, v] of Object.entries(resultSheet)) { if (!v.match) { failed = true; } console.debug({ path: k, results: v }); } process.exit(failed ? 1 : 0); })(); ================================================ FILE: test/double_slash/test.sh ================================================ #!/usr/bin/env bash set -euo pipefail function cleanup() { pkill -P $$ } trap cleanup EXIT SIGINT # Build static assets (cd ../.. && npm ci && npm run assets) go tool anubis --help 2>/dev/null || : go run ../cmd/httpdebug & go tool anubis \ --policy-fname ./anubis.yaml \ --use-remote-address \ --target=http://localhost:3923 & backoff-retry node ./test.mjs ================================================ FILE: test/double_slash/var/.gitignore ================================================ * !.gitignore ================================================ FILE: test/forced-language/anubis.yaml ================================================ bots: - name: challenge user_agent_regex: CHALLENGE action: CHALLENGE status_codes: CHALLENGE: 200 DENY: 403 ================================================ FILE: test/forced-language/test.mjs ================================================ async function getChallengePage() { return fetch("http://localhost:8923/reqmeta", { headers: { "Accept-Language": "en", "User-Agent": "CHALLENGE", }, }) .then((resp) => { if (resp.status !== 200) { throw new Error(`wanted status 200, got status: ${resp.status}`); } return resp; }) .then((resp) => resp.text()); } (async () => { const page = await getChallengePage(); if (!page.includes(``)) { console.log(page); throw new Error("force language smoke test failed"); } console.log("FORCED_LANGUAGE=de caused a page to be rendered in german"); process.exit(0); })(); ================================================ FILE: test/forced-language/test.sh ================================================ #!/usr/bin/env bash set -euo pipefail function cleanup() { pkill -P $$ } trap cleanup EXIT SIGINT # Build static assets (cd ../.. && npm ci && npm run assets) go tool anubis --help 2>/dev/null ||: go run ../cmd/unixhttpd & FORCED_LANGUAGE=de go tool anubis \ --policy-fname ./anubis.yaml \ --use-remote-address \ --target=unix://$(pwd)/unixhttpd.sock & backoff-retry node ./test.mjs ================================================ FILE: test/forced-language/var/.gitignore ================================================ * !.gitignore ================================================ FILE: test/git-clone/docker-compose.yaml ================================================ services: cgit: image: joseluisq/alpine-cgit pull_policy: always restart: always environment: CGIT_TITLE: Test git server CGIT_DESC: Test server, please ignore volumes: - ./var/repos:/srv/git anubis: image: ko.local/anubis environment: BIND: ":8005" TARGET: http://cgit:80 USE_REMOTE_ADDRESS: "true" ports: - 8005:8005 volumes: cgit-data: ================================================ FILE: test/git-clone/test.sh ================================================ #!/usr/bin/env bash set -eo pipefail export VERSION=$GITHUB_COMMIT-test export KO_DOCKER_REPO=ko.local set -u source ../lib/lib.sh build_anubis_ko rm -rf ./var/repos ./var/clones mkdir -p ./var/repos ./var/clones (cd ./var/repos && git clone --bare https://github.com/TecharoHQ/status.git) docker compose up -d sleep 2 (cd ./var/clones && git clone http://localhost:8005/status.git) exit 0 ================================================ FILE: test/git-clone/var/.gitignore ================================================ * !.gitignore ================================================ FILE: test/git-push/docker-compose.yaml ================================================ services: git: image: ghcr.io/kutespaces/simple-git-http-server pull_policy: always restart: always volumes: - ./var/repos:/git anubis: image: ko.local/anubis environment: BIND: ":3000" TARGET: http://git:80 USE_REMOTE_ADDRESS: "true" ports: - 3000:3000 ================================================ FILE: test/git-push/test.sh ================================================ #!/usr/bin/env bash set -eo pipefail export VERSION=${GITHUB_SHA}-test export KO_DOCKER_REPO=ko.local set -u source ../lib/lib.sh build_anubis_ko rm -rf ./var/repos ./var/foo mkdir -p ./var/repos (cd ./var/repos && git init --bare foo.git && cd foo.git && git config http.receivepack true) docker compose up -d sleep 2 ( cd var && mkdir foo && cd foo && git init && touch README && git add . && git config user.name "Anubis CI" && git config user.email "social+anubis-ci@techaro.lol" && git commit -sm "initial commit" && git push -u http://localhost:3000/git/foo.git master ) exit 0 ================================================ FILE: test/git-push/var/.gitignore ================================================ * !.gitignore ================================================ FILE: test/go.mod ================================================ module github.com/TecharoHQ/anubis/test go 1.24.5 replace github.com/TecharoHQ/anubis => .. require ( github.com/TecharoHQ/anubis v1.23.1 github.com/docker/docker v28.5.2+incompatible github.com/facebookgo/flagenv v0.0.0-20160425205200-fcd59fca7456 github.com/google/uuid v1.6.0 ) require ( buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20251209175733-2a1774d88802.1 // indirect cel.dev/expr v0.25.1 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/TecharoHQ/thoth-proto v0.5.0 // indirect github.com/a-h/templ v0.3.960 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/aws/aws-sdk-go-v2 v1.41.0 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect github.com/aws/aws-sdk-go-v2/config v1.32.5 // indirect github.com/aws/aws-sdk-go-v2/credentials v1.19.5 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 // indirect github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7 // indirect github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 // indirect github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16 // indirect github.com/aws/aws-sdk-go-v2/service/s3 v1.93.2 // indirect github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 // indirect github.com/aws/aws-sdk-go-v2/service/sso v1.30.7 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 // indirect github.com/aws/smithy-go v1.24.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/distribution/reference v0.6.0 // indirect github.com/djherbis/times v1.6.0 // indirect github.com/docker/go-connections v0.6.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/ebitengine/purego v0.9.1 // indirect github.com/facebookgo/ensure v0.0.0-20200202191622-63f1cf65ac4c // indirect github.com/facebookgo/subset v0.0.0-20200203212716-c811ad88dec4 // indirect github.com/fahedouch/go-logrotate v0.3.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/gaissmai/bart v0.26.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.3.0 // indirect github.com/golang-jwt/jwt/v5 v5.3.0 // indirect github.com/google/cel-go v0.26.1 // indirect github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 // indirect github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 // indirect github.com/joho/godotenv v1.5.1 // indirect github.com/jsha/minica v1.1.0 // indirect github.com/lum8rjack/go-ja4h v0.0.0-20250828030157-fa5266d50650 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/sys/atomicwriter v0.1.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/nicksnyder/go-i18n/v2 v2.6.0 // indirect github.com/nikandfor/spintax v0.0.0-20181023094358-fc346b245bb3 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.67.4 // indirect github.com/prometheus/procfs v0.19.2 // indirect github.com/redis/go-redis/v9 v9.17.2 // indirect github.com/sebest/xff v0.0.0-20210106013422-671bd2870b3a // indirect github.com/shirou/gopsutil/v4 v4.25.11 // indirect github.com/stoewer/go-strcase v1.3.1 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect go.etcd.io/bbolt v1.4.3 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 // indirect go.opentelemetry.io/otel v1.38.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.37.0 // indirect go.opentelemetry.io/otel/metric v1.38.0 // indirect go.opentelemetry.io/otel/trace v1.38.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 // indirect golang.org/x/net v0.48.0 // indirect golang.org/x/sys v0.39.0 // indirect golang.org/x/text v0.32.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20251213004720-97cd9d5aeac2 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 // indirect google.golang.org/grpc v1.77.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gotest.tools/v3 v3.5.2 // indirect k8s.io/apimachinery v0.34.3 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/yaml v1.6.0 // indirect ) tool ( github.com/TecharoHQ/anubis/cmd/anubis github.com/jsha/minica ) ================================================ FILE: test/go.sum ================================================ buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20251209175733-2a1774d88802.1 h1:j9yeqTWEFrtimt8Nng2MIeRrpoCvQzM9/g25XTvqUGg= buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20251209175733-2a1774d88802.1/go.mod h1:tvtbpgaVXZX4g6Pn+AnzFycuRK3MOz5HJfEGeEllXYM= cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/TecharoHQ/thoth-proto v0.5.0 h1:Fa663s4soYiURSU8MfW9tZ2wF+LsCRSaYmjUSyagfBM= github.com/TecharoHQ/thoth-proto v0.5.0/go.mod h1:C/U7FqTxpVn4V/qebC/GcW32I0h9xzsmWehF27KFOJs= github.com/a-h/templ v0.3.960 h1:trshEpGa8clF5cdI39iY4ZrZG8Z/QixyzEyUnA7feTM= github.com/a-h/templ v0.3.960/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo= github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= github.com/aws/aws-sdk-go-v2 v1.41.0 h1:tNvqh1s+v0vFYdA1xq0aOJH+Y5cRyZ5upu6roPgPKd4= github.com/aws/aws-sdk-go-v2 v1.41.0/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4= github.com/aws/aws-sdk-go-v2/config v1.32.5 h1:pz3duhAfUgnxbtVhIK39PGF/AHYyrzGEyRD9Og0QrE8= github.com/aws/aws-sdk-go-v2/config v1.32.5/go.mod h1:xmDjzSUs/d0BB7ClzYPAZMmgQdrodNjPPhd6bGASwoE= github.com/aws/aws-sdk-go-v2/credentials v1.19.5 h1:xMo63RlqP3ZZydpJDMBsH9uJ10hgHYfQFIk1cHDXrR4= github.com/aws/aws-sdk-go-v2/credentials v1.19.5/go.mod h1:hhbH6oRcou+LpXfA/0vPElh/e0M3aFeOblE1sssAAEk= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 h1:80+uETIWS1BqjnN9uJ0dBUaETh+P1XwFy5vwHwK5r9k= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16/go.mod h1:wOOsYuxYuB/7FlnVtzeBYRcjSRtQpAW0hCP7tIULMwo= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 h1:rgGwPzb82iBYSvHMHXc8h9mRoOUBZIGFgKb9qniaZZc= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16/go.mod h1:L/UxsGeKpGoIj6DxfhOWHWQ/kGKcd4I1VncE4++IyKA= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 h1:1jtGzuV7c82xnqOVfx2F0xmJcOw5374L7N6juGW6x6U= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16/go.mod h1:M2E5OQf+XLe+SZGmmpaI2yy+J326aFf6/+54PoxSANc= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16 h1:CjMzUs78RDDv4ROu3JnJn/Ig1r6ZD7/T2DXLLRpejic= github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16/go.mod h1:uVW4OLBqbJXSHJYA9svT9BluSvvwbzLQ2Crf6UPzR3c= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow= github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7 h1:DIBqIrJ7hv+e4CmIk2z3pyKT+3B6qVMgRsawHiR3qso= github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7/go.mod h1:vLm00xmBke75UmpNvOcZQ/Q30ZFjbczeLFqGx5urmGo= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 h1:oHjJHeUy0ImIV0bsrX0X91GkV5nJAyv1l1CC9lnO0TI= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16/go.mod h1:iRSNGgOYmiYwSCXxXaKb9HfOEj40+oTKn8pTxMlYkRM= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16 h1:NSbvS17MlI2lurYgXnCOLvCFX38sBW4eiVER7+kkgsU= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16/go.mod h1:SwT8Tmqd4sA6G1qaGdzWCJN99bUmPGHfRwwq3G5Qb+A= github.com/aws/aws-sdk-go-v2/service/s3 v1.93.2 h1:U3ygWUhCpiSPYSHOrRhb3gOl9T5Y3kB8k5Vjs//57bE= github.com/aws/aws-sdk-go-v2/service/s3 v1.93.2/go.mod h1:79S2BdqCJpScXZA2y+cpZuocWsjGjJINyXnOsf5DTz8= github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 h1:HpI7aMmJ+mm1wkSHIA2t5EaFFv5EFYXePW30p1EIrbQ= github.com/aws/aws-sdk-go-v2/service/signin v1.0.4/go.mod h1:C5RdGMYGlfM0gYq/tifqgn4EbyX99V15P2V3R+VHbQU= github.com/aws/aws-sdk-go-v2/service/sso v1.30.7 h1:eYnlt6QxnFINKzwxP5/Ucs1vkG7VT3Iezmvfgc2waUw= github.com/aws/aws-sdk-go-v2/service/sso v1.30.7/go.mod h1:+fWt2UHSb4kS7Pu8y+BMBvJF0EWx+4H0hzNwtDNRTrg= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 h1:AHDr0DaHIAo8c9t1emrzAlVDFp+iMMKnPdYy6XO4MCE= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12/go.mod h1:GQ73XawFFiWxyWXMHWfhiomvP3tXtdNar/fi8z18sx0= github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 h1:SciGFVNZ4mHdm7gpD1dgZYnCuVdX1s+lFTg4+4DOy70= github.com/aws/aws-sdk-go-v2/service/sts v1.41.5/go.mod h1:iW40X4QBmUxdP+fZNOpfmkdMZqsovezbAeO+Ubiv2pk= github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk= github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= 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/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cenkalti/backoff/v5 v5.0.2 h1:rIfFVxEf1QsI7E1ZHfp/B4DF/6QBAUhmgkxc0H7Zss8= github.com/cenkalti/backoff/v5 v5.0.2/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/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/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c= github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0= github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A= github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/facebookgo/ensure v0.0.0-20200202191622-63f1cf65ac4c h1:8ISkoahWXwZR41ois5lSJBSVw4D0OV19Ht/JSTzvSv0= github.com/facebookgo/ensure v0.0.0-20200202191622-63f1cf65ac4c/go.mod h1:Yg+htXGokKKdzcwhuNDwVvN+uBxDGXJ7G/VN1d8fa64= github.com/facebookgo/flagenv v0.0.0-20160425205200-fcd59fca7456 h1:CkmB2l68uhvRlwOTPrwnuitSxi/S3Cg4L5QYOcL9MBc= github.com/facebookgo/flagenv v0.0.0-20160425205200-fcd59fca7456/go.mod h1:zFhibDvPDWmtk4dAQ05sRobtyoffEHygEt3wSNuAzz8= github.com/facebookgo/stack v0.0.0-20160209184415-751773369052 h1:JWuenKqqX8nojtoVVWjGfOF9635RETekkoH6Cc9SX0A= github.com/facebookgo/stack v0.0.0-20160209184415-751773369052/go.mod h1:UbMTZqLaRiH3MsBH8va0n7s1pQYcu3uTb8G4tygF4Zg= github.com/facebookgo/subset v0.0.0-20200203212716-c811ad88dec4 h1:7HZCaLC5+BZpmbhCOZJ293Lz68O7PYrF2EzeiFMwCLk= github.com/facebookgo/subset v0.0.0-20200203212716-c811ad88dec4/go.mod h1:5tD+neXqOorC30/tWg0LCSkrqj/AR6gu8yY8/fpw1q0= github.com/fahedouch/go-logrotate v0.3.0 h1:XP+dHIDgWZ1ckz43mG6gl5ASer3PZDVr755SVMyzaUQ= github.com/fahedouch/go-logrotate v0.3.0/go.mod h1:X49m0bvPLkk71MHNCQ1yEfVEw8W/u+qvHa/hOnhCYf4= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/gaissmai/bart v0.26.0 h1:xOZ57E9hJLBiQaSyeZa9wgWhGuzfGACgqp4BE77OkO0= github.com/gaissmai/bart v0.26.0/go.mod h1:GREWQfTLRWz/c5FTOsIw+KkscuFkIV5t8Rp7Nd1Td5c= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/cel-go v0.26.1 h1:iPbVVEdkhTX++hpe3lzSk7D3G3QSYqLGoHOcEio+UXQ= github.com/google/cel-go v0.26.1/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 h1:QGLs/O40yoNK9vmy4rhUGBVyMf1lISBGtXRpsu/Qu/o= github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0/go.mod h1:hM2alZsMUni80N33RBe6J0e423LB+odMj7d3EMP9l20= github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 h1:B+8ClL/kCQkRiU82d9xajRPKYMrB7E0MbtzWVi1K4ns= github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3/go.mod h1:NbCUVmiS4foBGBHOYlCT25+YmGpJ32dZPi75pGEUpj4= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 h1:X5VWvz21y3gzm9Nw/kaUeku/1+uBhcekkmy4IkffJww= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1/go.mod h1:Zanoh4+gvIgluNqcfMVTJueD4wSS5hT7zTt4Mrutd90= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/jsha/minica v1.1.0 h1:O2ZbzAN75w4RTB+5+HfjIEvY5nxRqDlwj3ZlLVG5JD8= github.com/jsha/minica v1.1.0/go.mod h1:dxC3wNmD+gU1ewXo/R8jB2ihB6wNpyXrG8aUk5Iuf/k= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 h1:PpXWgLPs+Fqr325bN2FD2ISlRRztXibcX6e8f5FR5Dc= github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= github.com/lum8rjack/go-ja4h v0.0.0-20250828030157-fa5266d50650 h1:hhx/Mo6+Hk0mAQS5MW311ON1VlSzp0D1cYhY27IcmnI= github.com/lum8rjack/go-ja4h v0.0.0-20250828030157-fa5266d50650/go.mod h1:bMqyXOakqQIdx82d4vcnk5TIZLptZ2gLqju9xmPrWYA= github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ= github.com/moby/go-archive v0.1.0/go.mod h1:G9B+YoujNohJmrIYFBpSd54GTUB4lt9S+xVQvsJyFuo= github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= 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/nicksnyder/go-i18n/v2 v2.6.0 h1:C/m2NNWNiTB6SK4Ao8df5EWm3JETSTIGNXBpMJTxzxQ= github.com/nicksnyder/go-i18n/v2 v2.6.0/go.mod h1:88sRqr0C6OPyJn0/KRNaEz1uWorjxIKP7rUUcvycecE= github.com/nikandfor/spintax v0.0.0-20181023094358-fc346b245bb3 h1:foZ9X1bz2KmW7b8Yx5V0LAQKhTazdllv5rnGUe6iGTY= github.com/nikandfor/spintax v0.0.0-20181023094358-fc346b245bb3/go.mod h1:wwDYKfVF3WHdY0rugsAZoIpyQjDA3bn9wEzo/QXPx1Y= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc= github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI= github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI= github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/sebest/xff v0.0.0-20210106013422-671bd2870b3a h1:iLcLb5Fwwz7g/DLK89F+uQBDeAhHhwdzB5fSlVdhGcM= github.com/sebest/xff v0.0.0-20210106013422-671bd2870b3a/go.mod h1:wozgYq9WEBQBaIJe4YZ0qTSFAMxmcwBhQH0fO0R34Z0= github.com/shirou/gopsutil/v4 v4.25.11 h1:X53gB7muL9Gnwwo2evPSE+SfOrltMoR6V3xJAXZILTY= github.com/shirou/gopsutil/v4 v4.25.11/go.mod h1:EivAfP5x2EhLp2ovdpKSozecVXn1TmuG7SMzs/Wh4PU= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/stoewer/go-strcase v1.3.1 h1:iS0MdW+kVTxgMoE1LAZyMiYJFKlOzLooE4MxjirtkAs= github.com/stoewer/go-strcase v1.3.1/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/testcontainers/testcontainers-go v0.40.0 h1:pSdJYLOVgLE8YdUY2FHQ1Fxu+aMnb6JfVz1mxk7OeMU= github.com/testcontainers/testcontainers-go v0.40.0/go.mod h1:FSXV5KQtX2HAMlm7U3APNyLkkap35zNLxukw9oBi/MY= github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo= go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 h1:Hf9xI/XLML9ElpiHVDNwvqI0hIFlzV8dgIr35kV1kRU= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0/go.mod h1:NfchwuyNoMcZ5MLHwPrODwUF1HWCXWrL31s8gSAdIKY= go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 h1:Ahq7pZmv87yiyn3jeFz/LekZmPLLdKejuO3NcK9MssM= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0/go.mod h1:MJTqhM0im3mRLw1i8uGHnCvUEeS7VwRyxlLC78PA18M= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.37.0 h1:bDMKF3RUSxshZ5OjOTi8rsHGaPKsAt76FaqgvIUySLc= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.37.0/go.mod h1:dDT67G/IkA46Mr2l9Uj7HsQVwsjASyV9SjGofsiUZDA= go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= go.opentelemetry.io/proto/otlp v1.7.0 h1:jX1VolD6nHuFzOYso2E73H85i92Mv8JQYk0K9vz09os= go.opentelemetry.io/proto/otlp v1.7.0/go.mod h1:fSKjH6YJ7HDlwzltzyMj036AJ3ejJLCgCSHGj4efDDo= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 h1:MDfG8Cvcqlt9XXrmEiD4epKn7VJHZO84hejP9Jmp0MM= golang.org/x/exp v0.0.0-20251209150349-8475f28825e9/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU= golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/genproto/googleapis/api v0.0.0-20251213004720-97cd9d5aeac2 h1:7LRqPCEdE4TP4/9psdaB7F2nhZFfBiGJomA5sojLWdU= google.golang.org/genproto/googleapis/api v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA= google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM= google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 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= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= k8s.io/apimachinery v0.34.3 h1:/TB+SFEiQvN9HPldtlWOTp0hWbJ+fjU+wkxysf/aQnE= k8s.io/apimachinery v0.34.3/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= ================================================ FILE: test/healthcheck/docker-compose.yaml ================================================ services: web: image: ghcr.io/xe/nginx-micro:v1.29.0 anubis: image: ko.local/anubis environment: TARGET: http://web:80 USE_REMOTE_ADDRESS: "true" healthcheck: test: ["CMD", "anubis", "--healthcheck"] interval: 5s timeout: 30s retries: 5 start_period: 500ms ================================================ FILE: test/healthcheck/test.sh ================================================ #!/usr/bin/env bash set -eo pipefail export VERSION=$GITHUB_COMMIT-test export KO_DOCKER_REPO=ko.local set -u source ../lib/lib.sh build_anubis_ko docker compose up -d attempt=1 max_attempts=5 delay=2 while ! docker compose ps | grep healthy; do if (( attempt >= max_attempts )); then echo "Service did not become healthy after $max_attempts attempts." exit 1 fi echo "Waiting for healthy service... attempt $attempt" sleep $delay delay=$(( delay * 2 )) attempt=$(( attempt + 1 )) done exit 0 ================================================ FILE: test/healthcheck/var/.gitignore ================================================ * !.gitignore ================================================ FILE: test/i18n/anubis.yaml ================================================ bots: - name: challenge user_agent_regex: CHALLENGE action: CHALLENGE status_codes: CHALLENGE: 200 DENY: 403 ================================================ FILE: test/i18n/test.mjs ================================================ async function fetchLanguages() { return fetch( "http://localhost:8923/.within.website/x/cmd/anubis/static/locales/manifest.json", ) .then((resp) => { if (resp.status !== 200) { throw new Error(`wanted status 200, got status: ${resp.status}`); } return resp; }) .then((resp) => resp.json()); } async function getChallengePage(lang) { return fetch("http://localhost:8923/reqmeta", { headers: { "Accept-Language": lang, "User-Agent": "CHALLENGE", }, }) .then((resp) => { if (resp.status !== 200) { throw new Error(`wanted status 200, got status: ${resp.status}`); } return resp; }) .then((resp) => resp.text()); } (async () => { const languages = await fetchLanguages(); console.log(languages); const { supportedLanguages } = languages; if (supportedLanguages.length === 0) { throw new Error(`no languages defined`); } const resultSheet = {}; let failed = false; for (const lang of supportedLanguages) { console.log(`getting for ${lang}`); const page = await getChallengePage(lang); resultSheet[lang] = page.includes(``); } for (const [lang, result] of Object.entries(resultSheet)) { if (!result) { failed = true; console.log(`${lang} did not show up in challenge page`); } } console.log(resultSheet); if (failed) { throw new Error("i18n smoke test failed"); } process.exit(0); })(); ================================================ FILE: test/i18n/test.sh ================================================ #!/usr/bin/env bash set -euo pipefail function cleanup() { pkill -P $$ } trap cleanup EXIT SIGINT # Build static assets (cd ../.. && npm ci && npm run assets) go tool anubis --help 2>/dev/null ||: go run ../cmd/unixhttpd & go tool anubis \ --policy-fname ./anubis.yaml \ --use-remote-address \ --target=unix://$(pwd)/unixhttpd.sock & backoff-retry node ./test.mjs ================================================ FILE: test/i18n/var/.gitignore ================================================ * !.gitignore ================================================ FILE: test/k8s/cert-manager/selfsigned-issuer.yaml ================================================ apiVersion: cert-manager.io/v1 kind: ClusterIssuer metadata: name: selfsigned spec: selfSigned: {} ================================================ FILE: test/k8s/deps/cert-manager.yaml ================================================ apiVersion: helm.cattle.io/v1 kind: HelmChart metadata: name: cert-manager namespace: kube-system spec: repo: https://charts.jetstack.io chart: cert-manager targetNamespace: cert-manager createNamespace: true set: installCRDs: "true" "prometheus.enabled": "false" ================================================ FILE: test/lib/lib.sh ================================================ REPO_ROOT=$(git rev-parse --show-toplevel) (cd $REPO_ROOT && go install ./utils/cmd/...) mkdir -p pki echo '*' >>./pki/.gitignore function cleanup() { set +e pkill -P $$ if [ -f "docker-compose.yaml" ]; then docker compose down -t 1 || : docker compose rm -f || : fi } trap cleanup EXIT SIGINT function build_anubis_ko() { ( cd $REPO_ROOT && npm ci && npm run assets ) ( cd $REPO_ROOT && VERSION=devel ko build \ --platform=all \ --base-import-paths \ --tags="latest" \ --image-user=1000 \ --image-annotation="" \ --image-label="" \ ./cmd/anubis \ --local ) } function mint_cert() { if [ "$#" -ne 1 ]; then echo "Usage: mint_cert " fi domainName="$1" # If the transient local TLS certificate doesn't exist, mint a new one if [ ! -f "./pki/${domainName}/cert.pem" ]; then # Subshell to contain the directory change ( cd ./pki && mkdir -p "${domainName}" && go tool minica -domains "${domainName}" && cd "${domainName}" && chmod 666 * ) fi } ================================================ FILE: test/log-file/anubis.yaml ================================================ bots: - name: challenge user_agent_regex: CHALLENGE action: CHALLENGE status_codes: CHALLENGE: 200 DENY: 403 logging: sink: file parameters: file: "./var/anubis.log" maxBackups: 3 # keep at least 3 old copies maxBytes: 67108864 # each file can have up to 64 Mi of logs maxAge: 7 # rotate files out every n days compress: true useLocalTime: false # timezone for rotated files is UTC ================================================ FILE: test/log-file/input.txt ================================================ /wiki//bin /wiki//boot /wiki//dev /wiki//dev/de /wiki//dev/en /wiki//dev/en-ca /wiki//dev/es /wiki//dev/fr /wiki//dev/hr /wiki//dev/hu /wiki//dev/it /wiki//dev/ja /wiki//dev/ko /wiki//dev/pl /wiki//dev/pt-br /wiki//dev/ro /wiki//dev/ru /wiki//dev/sv /wiki//dev/uk /wiki//dev/zh-cn /wiki//etc /wiki//etc/conf.d /wiki//etc/env.d /wiki//etc/fstab /wiki//etc/fstab/de /wiki//etc/fstab/en /wiki//etc/fstab/es /wiki//etc/fstab/fr /wiki//etc/fstab/hu /wiki//etc/fstab/it /wiki//etc/fstab/ja /wiki//etc/fstab/ko /wiki//etc/fstab/ru /wiki//etc/fstab/sv /wiki//etc/fstab/uk /wiki//etc/fstab/zh-cn /wiki//etc/hosts /wiki//etc/local.d /wiki//etc/make.conf /wiki//etc/portage /wiki//etc/portage/bashrc /wiki//etc/portage/Bashrc /wiki//etc/portage/binrepos.conf /wiki//etc/portage/binrepos.conf/en /wiki//etc/portage/binrepos.conf/hu /wiki//etc/portage/binrepos.conf/ja /wiki//etc/portage/binrepos.conf/ru /wiki//etc/portage/categories /wiki//etc/portage/color.map /wiki//etc/portage/env /wiki//etc/portage/img/ico.png /wiki//etc/portage/license_groups /wiki//etc/portage/make.conf /wiki//etc/portage/make.conf/de /wiki//etc/portage/make.conf/de/etc/portage/make.conf /wiki//etc/portage/make.conf/en /wiki//etc/portage/make.conf/es /wiki//etc/portage/make.conf/fr /wiki//etc/portage/make.conf/hu /wiki//etc/portage/make.conf/it /wiki//etc/portage/make.conf/it/var/db/repos/gentoo/licenses /wiki//etc/portage/make.conf/ja /wiki//etc/portage/make.conf/pl /wiki//etc/portage/make.conf/ru /wiki//etc/portage/make.conf/uk /wiki//etc/portage/make.conf/zh-cn /wiki//etc/portage/make.profile /wiki//etc/portage/mirrors /wiki//etc/portage/modules /wiki//etc/portage/package.accept_keywords /wiki//etc/portage/package.env /wiki//etc/portage/package.license /wiki//etc/portage/package.license/en /wiki//etc/portage/package.license/es /wiki//etc/portage/package.license/hu /wiki//etc/portage/package.license/ja /wiki//etc/portage/package.mask /wiki//etc/portage/package.mask/en /wiki//etc/portage/package.mask/hu /wiki//etc/portage/package.mask/ja /wiki//etc/portage/package.properties /wiki//etc/portage/package.unmask /wiki//etc/portage/package.use /wiki//etc/portage/package.use/de /wiki//etc/portage/package.use/en /wiki//etc/portage/package.use/es /wiki//etc/portage/package.use/fr /wiki//etc/portage/package.use/hu /wiki//etc/portage/package.use/it /wiki//etc/portage/package.use/ja /wiki//etc/portage/package.use/ru /wiki//etc/portage/package.use/uk /wiki//etc/portage/package.use/zh-cn /wiki//etc/portage/patches /wiki//etc/portage/profile/make.defaults /wiki//etc/portage/profile/package.provided /wiki//etc/portage/profile/package.provided/etc/portage/profile/package.provided /wiki//etc/portage/profile/package.provided/etc/portage/profiles/package.provided /wiki//etc/portage/profile/package.use.mask /wiki//etc/portage/profiles/package.provided /wiki//etc/portage/profiles/package.use.mask /wiki//etc/portage/profiles/package.use.mask/etc/portage/profile/package.use.mask /wiki//etc/portage/profiles/package.use.mask/etc/portage/profiles/package.use.mask /wiki//etc/portage/profiles/use.mask /wiki//etc/portage/profile/use.mask /wiki//etc/portage/repos.conf /wiki//etc/portage/repos.conf/brother-overlay.conf /wiki//etc/portage/repos.conf/de /wiki//etc/portage/repos.conf/en /wiki//etc/portage/repos.conf/es /wiki//etc/portage/repos.conf/etc/portage/repos.conf/gentoo.conf /wiki//etc/portage/repos.conf/fr /wiki//etc/portage/repos.conf/fr/etc/portage/repos.conf/gentoo.conf /wiki//etc/portage/repos.conf/gentoo.conf /wiki//etc/portage/repos.conf/gentoo.conf/etc/portage/repos.conf/gentoo.conf /wiki//etc/portage/repos.conf/hr /wiki//etc/portage/repos.conf/hu /wiki//etc/portage/repos.conf/it /wiki//etc/portage/repos.conf/ja /wiki//etc/portage/repos.conf/ko /wiki//etc/portage/repos.conf/pl /wiki//etc/portage/repos.conf/pt-br /wiki//etc/portage/repos.conf/ru /wiki//etc/portage/repos.conf/uk /wiki//etc/portage/repos.conf/zh-cn /wiki//etc/portage/savedconfig /wiki//etc/portage/sets /wiki//etc/profile /wiki//etc/profile.env /wiki//etc/sandbox.conf /wiki//home /wiki//lib /wiki//lib64 /wiki//media /wiki//mnt /wiki//opt /wiki//proc /wiki//proc/config.gz /wiki//run /wiki//sbin /wiki//srv /wiki//sys /wiki//tmp /wiki//usr /wiki//usr/bin /wiki//usr_move /wiki//usr/portage /wiki//usr/portage/distfiles /wiki//usr/portage/licenses /wiki//usr/portage/metadata /wiki//usr/portage/metadata/md5-cache /wiki//usr/portage/metadata/md5-cache/usr/portage/metadata/md5-cache /wiki//usr/portage/metadata/md5-cache/var/db/repos/gentoo//metadata/md5-cache /wiki//usr/portage/packages /wiki//usr/portage/profiles /wiki//usr/portage/profiles/license_groups /wiki//usr/portage/profiles/license_groups/usr/portage/profiles/license_groups /wiki//usr/portage/profiles/license_groups/var/db/repos/gentoo//profiles/license_groups /wiki//usr/share/doc/ /wiki//var/cache/binpkgs /wiki//var/cache/distfiles /wiki//var/db/pkg /wiki//var/db/pkg%22 /wiki//var/db/repos/gentoo /wiki//var/db/repos/gentoo/licenses /wiki//var/db/repos/gentoo/licenses/var/db/repos/gentoo//licenses /wiki//var/db/repos/gentoo/licenses/var/db/repos/gentoo/licenses /wiki//var/db/repos/gentoo/metadata /wiki//var/db/repos/gentoo/metadata/md5-cache /wiki//var/db/repos/gentoo/metadata/var/db/repos/gentoo//metadata /wiki//var/db/repos/gentoo/metadata/var/db/repos/gentoo/metadata /wiki//var/db/repos/gentoo/profiles /wiki//var/db/repos/gentoo/profiles/license_groups /wiki//var/db/repos/gentoo/profiles/package.mask /wiki//var/lib/portage /wiki//var/lib/portage/world /wiki//var/run /gcc-bugs/bug-122002-4@http.gcc.gnu.org%2Fbugzilla%2F/T/ ================================================ FILE: test/log-file/test.mjs ================================================ import { statSync } from "fs"; async function getPage(path) { return fetch(`http://localhost:8923${path}`, { headers: { "User-Agent": "CHALLENGE", }, }) .then((resp) => { if (resp.status !== 200) { throw new Error(`wanted status 200, got status: ${resp.status}`); } return resp; }) .then((resp) => resp.text()); } async function getFileSize(filePath) { try { return statSync(filePath).size; } catch (error) { return 0; } } (async () => { const logFilePath = "./var/anubis.log"; // Get initial log file size const initialSize = await getFileSize(logFilePath); console.log(`Initial log file size: ${initialSize} bytes`); // Make 35 requests with different paths const requests = []; for (let i = 0; i < 35; i++) { requests.push(`/test${i}`); } const resultSheet = {}; let failed = false; for (const path of requests) { try { const resp = await getPage(path); resultSheet[path] = { success: true, line: resp.split("\n")[0], }; } catch (error) { resultSheet[path] = { success: false, error: error.message, }; console.log(`✗ Request to ${path} failed: ${error.message}`); failed = true; } } // Check final log file size const finalSize = await getFileSize(logFilePath); console.log(`Final log file size: ${finalSize} bytes`); console.log(`Size increase: ${finalSize - initialSize} bytes`); // Verify that log file size increased if (finalSize <= initialSize) { console.error( "ERROR: Log file size did not increase after making requests!", ); failed = true; } let successCount = 0; for (let [k, v] of Object.entries(resultSheet)) { if (!v.success) { console.error({ path: k, error: v.error }); } else { successCount++; } } console.log(`Successful requests: ${successCount}/${requests.length}`); if (failed) { console.error( "Test failed: Some requests failed or log file size did not increase", ); process.exit(1); } else { console.log( "Test passed: All requests succeeded and log file size increased", ); process.exit(0); } })(); ================================================ FILE: test/log-file/test.sh ================================================ #!/usr/bin/env bash set -euo pipefail function cleanup() { pkill -P $$ } trap cleanup EXIT SIGINT # Build static assets (cd ../.. && npm ci && npm run assets) go tool anubis --help 2>/dev/null || : go run ../cmd/httpdebug & go tool anubis \ --policy-fname ./anubis.yaml \ --use-remote-address \ --target=http://localhost:3923 & sleep 2 backoff-retry node ./test.mjs ================================================ FILE: test/log-file/var/.gitignore ================================================ * !.gitignore ================================================ FILE: test/nginx/conf/nginx/conf-anubis.inc ================================================ # /etc/nginx/conf-anubis.inc # Forward to anubis location / { proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_pass http://anubis; } ================================================ FILE: test/nginx/conf/nginx/conf.d/server-mimi-techaro-lol.conf ================================================ # /etc/nginx/conf.d/server-mimi-techaro-lol.conf server { # Listen on 443 with SSL listen 443 ssl; listen [::]:443 ssl; http2 on; # Slipstream via Anubis include "conf-anubis.inc"; server_name mimi.techaro.lol; ssl_certificate /techaro/pki/mimi.techaro.lol/cert.pem; ssl_certificate_key /techaro/pki/mimi.techaro.lol/key.pem; } server { listen unix:/tmp/nginx.sock; server_name mimi.techaro.lol; port_in_redirect off; root "/srv/http/mimi.techaro.lol"; index index.html; # Your normal configuration can go here # location .php { fastcgi...} etc. } ================================================ FILE: test/nginx/conf/nginx/conf.d/upstream-anubis.conf ================================================ # /etc/nginx/conf.d/upstream-anubis.conf upstream anubis { zone anubis_zone 64k; # Make sure this matches the values you set for `BIND` and `BIND_NETWORK`. # If this does not match, your services will not be protected by Anubis. # Try anubis first over a UNIX socket #server unix:/run/anubis/nginx.sock; server anubis:3000 resolve; # Optional: fall back to serving the websites directly. This allows your # websites to be resilient against Anubis failing, at the risk of exposing # them to the raw internet without protection. This is a tradeoff and can # be worth it in some edge cases. #server unix:/run/nginx.sock backup; } ================================================ FILE: test/nginx/conf/nginx/mime.types ================================================ types { text/html html htm shtml; text/css css; text/xml xml; image/gif gif; image/jpeg jpeg jpg; application/javascript js; application/atom+xml atom; application/rss+xml rss; text/mathml mml; text/plain txt; text/vnd.sun.j2me.app-descriptor jad; text/vnd.wap.wml wml; text/x-component htc; image/avif avif; image/png png; image/svg+xml svg svgz; image/tiff tif tiff; image/vnd.wap.wbmp wbmp; image/webp webp; image/x-icon ico; image/x-jng jng; image/x-ms-bmp bmp; font/woff woff; font/woff2 woff2; application/java-archive jar war ear; application/json json; application/mac-binhex40 hqx; application/msword doc; application/pdf pdf; application/postscript ps eps ai; application/rtf rtf; application/vnd.apple.mpegurl m3u8; application/vnd.google-earth.kml+xml kml; application/vnd.google-earth.kmz kmz; application/vnd.ms-excel xls; application/vnd.ms-fontobject eot; application/vnd.ms-powerpoint ppt; application/vnd.oasis.opendocument.graphics odg; application/vnd.oasis.opendocument.presentation odp; application/vnd.oasis.opendocument.spreadsheet ods; application/vnd.oasis.opendocument.text odt; application/vnd.openxmlformats-officedocument.presentationml.presentation pptx; application/vnd.openxmlformats-officedocument.spreadsheetml.sheet xlsx; application/vnd.openxmlformats-officedocument.wordprocessingml.document docx; application/vnd.wap.wmlc wmlc; application/wasm wasm; application/x-7z-compressed 7z; application/x-cocoa cco; application/x-java-archive-diff jardiff; application/x-java-jnlp-file jnlp; application/x-makeself run; application/x-perl pl pm; application/x-pilot prc pdb; application/x-rar-compressed rar; application/x-redhat-package-manager rpm; application/x-sea sea; application/x-shockwave-flash swf; application/x-stuffit sit; application/x-tcl tcl tk; application/x-x509-ca-cert der pem crt; application/x-xpinstall xpi; application/xhtml+xml xhtml; application/xspf+xml xspf; application/zip zip; application/octet-stream bin exe dll; application/octet-stream deb; application/octet-stream dmg; application/octet-stream iso img; application/octet-stream msi msp msm; audio/midi mid midi kar; audio/mpeg mp3; audio/ogg ogg; audio/x-m4a m4a; audio/x-realaudio ra; video/3gpp 3gpp 3gp; video/mp2t ts; video/mp4 mp4; video/mpeg mpeg mpg; video/quicktime mov; video/webm webm; video/x-flv flv; video/x-m4v m4v; video/x-mng mng; video/x-ms-asf asx asf; video/x-ms-wmv wmv; video/x-msvideo avi; } ================================================ FILE: test/nginx/conf/nginx/nginx.conf ================================================ worker_processes auto; error_log /var/log/nginx/error.log notice; pid /run/nginx.pid; events { worker_connections 1024; } http { resolver 169.254.42.1 valid=300s ipv6=on; resolver_timeout 10s; include /etc/nginx/mime.types; default_type application/octet-stream; log_format main '$remote_addr - $remote_user [$time_local] "$request" ' '$status $body_bytes_sent "$http_referer" ' '"$http_user_agent" "$http_x_forwarded_for"'; access_log /var/log/nginx/access.log main; sendfile on; #tcp_nopush on; keepalive_timeout 65; #gzip on; include /etc/nginx/conf.d/*.conf; } ================================================ FILE: test/nginx/test.sh ================================================ #!/usr/bin/env bash source ../lib/lib.sh export KO_DOCKER_REPO=ko.local set -euo pipefail mint_cert mimi.techaro.lol docker run --rm \ -v $PWD/conf/nginx:/etc/nginx:ro \ -v $PWD/pki:/techaro/pki:ro \ nginx \ nginx -t exit 0 ================================================ FILE: test/nginx-external-auth/conf.d/default.conf ================================================ server { listen 80; listen [::]:80; server_name nginx.local.cetacean.club; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; location /.within.website/ { proxy_pass http://localhost:8923; auth_request off; } location @redirectToAnubis { return 307 /.within.website/?redir=$scheme://$host$request_uri; auth_request off; } location / { auth_request /.within.website/x/cmd/anubis/api/check; error_page 401 = @redirectToAnubis; root /usr/share/nginx/html; index index.html index.htm; } } ================================================ FILE: test/nginx-external-auth/deployment.yaml ================================================ apiVersion: apps/v1 kind: Deployment metadata: name: nginx-external-auth spec: selector: matchLabels: app: nginx-external-auth template: metadata: labels: app: nginx-external-auth spec: volumes: - name: config configMap: name: nginx-cfg containers: - name: www image: nginx:alpine resources: limits: memory: "128Mi" cpu: "500m" requests: memory: "128Mi" cpu: "500m" ports: - containerPort: 80 volumeMounts: - name: config mountPath: /etc/nginx/conf.d readOnly: true - name: anubis image: ttl.sh/techaro/anubis:latest imagePullPolicy: Always resources: limits: cpu: 500m memory: 128Mi requests: cpu: 250m memory: 128Mi env: - name: TARGET value: " " - name: REDIRECT_DOMAINS value: nginx.local.cetacean.club ================================================ FILE: test/nginx-external-auth/ingress.yaml ================================================ apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: nginx-external-auth labels: name: nginx-external-auth annotations: cert-manager.io/cluster-issuer: "selfsigned" spec: ingressClassName: traefik tls: - hosts: - nginx.local.cetacean.club secretName: nginx-local-cetacean-club-public-tls rules: - host: nginx.local.cetacean.club http: paths: - pathType: Prefix path: "/" backend: service: name: nginx-external-auth port: name: http ================================================ FILE: test/nginx-external-auth/kustomization.yaml ================================================ resources: - deployment.yaml - service.yaml - ingress.yaml configMapGenerator: - name: nginx-cfg behavior: create files: - ./conf.d/default.conf ================================================ FILE: test/nginx-external-auth/service.yaml ================================================ apiVersion: v1 kind: Service metadata: name: nginx-external-auth spec: selector: app: nginx-external-auth ports: - name: http protocol: TCP port: 80 targetPort: 80 type: ClusterIP ================================================ FILE: test/nginx-external-auth/start.sh ================================================ #!/usr/bin/env bash set -euo pipefail # Build container image ( cd ../.. && npm ci && npm run container -- \ --docker-repo ttl.sh/techaro/anubis \ --docker-tags ttl.sh/techaro/anubis:latest ) kubectl apply -k . echo "open https://nginx.local.cetacean.club, press control c when done" control_c() { kubectl delete -k . exit } trap control_c SIGINT sleep infinity ================================================ FILE: test/palemoon/README.md ================================================ # Pale Moon CI tests Pale Moon has exposed [some pretty bad bugs](https://anubis.techaro.lol/blog/release/v1.21.1#fix-event-loop-thrashing-when-solving-a-proof-of-work-challenge) in Anubis. As such, we're running Pale Moon against Anubis in CI to ensure that it keeps working. This test is a fork of [dtinth/xtigervnc-docker](https://github.com/dtinth/xtigervnc-docker) but focused on Pale Moon. ================================================ FILE: test/palemoon/amd64/docker-compose.yml ================================================ services: display: image: ghcr.io/techarohq/ci-images/xserver:latest pull_policy: always ports: - 5900:5900 anubis: image: ko.local/anubis environment: BIND: ":3000" TARGET: http://$TARGET POLICY_FNAME: /cfg/anubis.yaml SLOG_LEVEL: DEBUG volumes: - ../anubis:/cfg depends_on: - relayd relayd: image: ghcr.io/xe/x/relayd environment: BIND: :443 CERT_DIR: /techaro/pki CERT_FNAME: cert.pem KEY_FNAME: key.pem PROXY_TO: http://anubis:3000 volumes: - ./pki/relayd:/techaro/pki:ro # novnc: # image: geek1011/easy-novnc # command: -a :5800 -h display --no-url-password # ports: # - 5800:5800 palemoon: platform: linux/amd64 init: true image: ghcr.io/techarohq/ci-images/palemoon:latest command: sleep inf environment: DISPLAY: display:0 volumes: - ./pki:/usr/local/share/ca-certificates/minica:ro - ../scripts:/hack/scripts:ro depends_on: - anubis - relayd - display ================================================ FILE: test/palemoon/amd64/test.sh ================================================ #!/usr/bin/env bash export VERSION=$GITHUB_COMMIT-test export KO_DOCKER_REPO=ko.local function capture_vnc_snapshots() { sudo apt-get update && sudo apt-get install -y gvncviewer mkdir -p ./var while true; do timestamp=$(date +"%Y%m%d%H%M%S") gvnccapture localhost:0 ./var/snapshot_$timestamp.png 2>/dev/null sleep 1 done } function timeout() { sleep 180 exit 1 } source ../../lib/lib.sh if [ "$GITHUB_ACTIONS" = "true" ]; then capture_vnc_snapshots & fi set -euo pipefail build_anubis_ko mint_cert relayd timeout & go run ../../cmd/cipra/ --compose-name $(basename $(pwd)) docker compose down -t 1 || : docker compose rm -f || : exit 0 ================================================ FILE: test/palemoon/amd64/var/.gitignore ================================================ * !.gitignore ================================================ FILE: test/palemoon/anubis/anubis.yaml ================================================ bots: - name: palemoon user_agent_regex: PaleMoon action: CHALLENGE challenge: difficulty: 2 algorithm: fast status_codes: CHALLENGE: 401 DENY: 403 ================================================ FILE: test/palemoon/i386/docker-compose.yml ================================================ services: display: image: ghcr.io/techarohq/ci-images/xserver:latest pull_policy: always ports: - 5900:5900 anubis: image: ko.local/anubis environment: BIND: ":3000" TARGET: http://$TARGET POLICY_FNAME: /cfg/anubis.yaml SLOG_LEVEL: DEBUG volumes: - ../anubis:/cfg relayd: image: ghcr.io/xe/x/relayd environment: BIND: :443 CERT_DIR: /techaro/pki CERT_FNAME: cert.pem KEY_FNAME: key.pem PROXY_TO: http://anubis:3000 volumes: - ./pki/relayd:/techaro/pki:ro # novnc: # image: geek1011/easy-novnc # command: -a :5800 -h display --no-url-password # ports: # - 5800:5800 palemoon: platform: linux/386 init: true image: ghcr.io/techarohq/ci-images/palemoon:latest command: sleep inf environment: DISPLAY: display:0 volumes: - ./pki:/usr/local/share/ca-certificates/minica:ro - ../scripts:/hack/scripts:ro ================================================ FILE: test/palemoon/i386/test.sh ================================================ #!/usr/bin/env bash export VERSION=$GITHUB_COMMIT-test export KO_DOCKER_REPO=ko.local function capture_vnc_snapshots() { sudo apt-get update && sudo apt-get install -y gvncviewer mkdir -p ./var while true; do timestamp=$(date +"%Y%m%d%H%M%S") gvnccapture localhost:0 ./var/snapshot_$timestamp.png 2>/dev/null sleep 1 done } source ../../lib/lib.sh if [ "$GITHUB_ACTIONS" = "true" ]; then capture_vnc_snapshots & fi set -euo pipefail build_anubis_ko mint_cert relayd go run ../../cmd/cipra/ --compose-name $(basename $(pwd)) docker compose down -t 1 || : docker compose rm -f || : ================================================ FILE: test/palemoon/i386/var/.gitignore ================================================ * !.gitignore ================================================ FILE: test/palemoon/scripts/install-cert.sh ================================================ #!/usr/bin/env bash set -euo pipefail CERT_PATH="/usr/local/share/ca-certificates/minica/minica.pem" CERT_NAME="minica" TRUST_FLAGS="C,," FIREFOX_DIR="$HOME/.mozilla/firefox" PALEMOON_DIR="$HOME/.moonchild productions/pale moon" echo "🔄 Updating system CA certificates..." update-ca-certificates # 🌀 Trigger Pale Moon to create its profile if needed if command -v palemoon &>/dev/null; then echo "🚀 Launching Pale Moon to initialize profile..." palemoon &>/dev/null & PALEMOON_PID=$! # Wait up to 20 seconds for prefs.js to be created for i in {1..20}; do set +e PROFILE_DIR=$(grep Path ~/.moonchild\ productions/pale\ moon/profiles.ini | cut -d= -f2) PREFS_FILE="$HOME/.moonchild productions/pale moon/$PROFILE_DIR/prefs.js" if [[ -f "$PREFS_FILE" ]]; then set -e echo "✅ prefs.js found at: $PREFS_FILE" break fi sleep 5 done kill $PALEMOON_PID 2>/dev/null || true wait $PALEMOON_PID 2>/dev/null || true if [[ ! -f "$PREFS_FILE" ]]; then echo "❌ prefs.js not found. Pale Moon did not fully initialize." exit 1 fi else echo "⚠️ Pale Moon is not installed or not in PATH. Skipping profile bootstrap." fi echo 'user_pref("security.cert_pinning.enforcement_level", 0);' >>"$PREFS_FILE" echo "✅ TLS cert validation disabled in Pale Moon profile: $PROFILE_DIR" # 🔧 Ensure certutil is installed if ! command -v certutil &>/dev/null; then if [ -f /etc/debian_version ]; then echo "🔧 'certutil' not found. Installing via apt..." apt-get update apt-get install -y libnss3-tools else echo "❌ 'certutil' not found and install is only supported on Debian-based systems." exit 1 fi fi import_cert_to_profiles() { local base_dir="$1" local browser_name="$2" local profile_glob="$3" if [ ! -d "$base_dir" ]; then echo "⚠️ $browser_name profile directory not found: $base_dir" return fi echo "📌 Searching for $browser_name profiles in: $base_dir" local found=0 for profile in "$base_dir"/$profile_glob; do if [ ! -d "$profile" ]; then continue fi found=1 local db_path="sql:$profile" echo "🔍 Processing $browser_name profile: $profile" if certutil -L -d "$db_path" | grep -q "^$CERT_NAME"; then echo " ✅ Certificate '$CERT_NAME' already exists in profile." continue fi certutil -A -n "$CERT_NAME" -t "$TRUST_FLAGS" -i "$CERT_PATH" -d "$db_path" echo " ➕ Added certificate '$CERT_NAME' to $browser_name profile." done if [ "$found" -eq 0 ]; then echo "⚠️ No $browser_name profiles found in: $base_dir" fi } import_cert_to_profiles "$FIREFOX_DIR" "Firefox" "*.default*" import_cert_to_profiles "$PALEMOON_DIR" "Pale Moon" "*.*" echo "✅ Done. Firefox and Pale Moon profiles updated with '$CERT_NAME' certificate." ================================================ FILE: test/pki/.gitignore ================================================ * !.gitignore ================================================ FILE: test/robots_txt/anubis.yaml ================================================ bots: - name: challenge user_agent_regex: CHALLENGE action: CHALLENGE status_codes: CHALLENGE: 200 DENY: 403 ================================================ FILE: test/robots_txt/test.mjs ================================================ async function getRobotsTxt() { return fetch("http://localhost:8923/robots.txt", { headers: { "Accept-Language": "en", "User-Agent": "Mozilla/5.0", }, }) .then((resp) => { if (resp.status !== 200) { throw new Error(`wanted status 200, got status: ${resp.status}`); } return resp; }) .then((resp) => resp.text()); } (async () => { const page = await getRobotsTxt(); if (page.includes(``)) { console.log(page); throw new Error("serve robots.txt smoke test failed"); } console.log("serve-robots-txt serves robots.txt"); process.exit(0); })(); ================================================ FILE: test/robots_txt/test.sh ================================================ #!/usr/bin/env bash set -euo pipefail function cleanup() { pkill -P $$ } trap cleanup EXIT SIGINT # Build static assets (cd ../.. && npm ci && npm run assets) go tool anubis --help 2>/dev/null || : go run ../cmd/unixhttpd & go tool anubis \ --policy-fname ./anubis.yaml \ --use-remote-address \ --serve-robots-txt \ --target=unix://$(pwd)/unixhttpd.sock & backoff-retry node ./test.mjs ================================================ FILE: test/robots_txt/var/.gitignore ================================================ * !.gitignore ================================================ FILE: test/shared/www/index.html ================================================ Anubis works!

Anubis works!

If you see this, everything has gone according to keikaku.

================================================ FILE: test/ssh-ci/Dockerfile ================================================ ARG ALPINE_VERSION=3.22 FROM alpine:${ALPINE_VERSION} RUN apk add -U go nodejs git build-base git npm bash zstd brotli gzip LABEL org.opencontainers.image.source="https://github.com/TecharoHQ/anubis" ================================================ FILE: test/ssh-ci/docker-bake.hcl ================================================ variable "ALPINE_VERSION" { default = "3.22" } group "default" { targets = [ "ci-runner", ] } target "ci-runner" { args = { ALPINE_VERSION = "3.22" } context = "." dockerfile = "./Dockerfile" platforms = [ "linux/amd64", "linux/arm64", "linux/arm/v7", "linux/ppc64le", "linux/riscv64", ] pull = true tags = [ "ghcr.io/techarohq/anubis/ci-runner:latest" ] } ================================================ FILE: test/ssh-ci/in-container.sh ================================================ #!/usr/bin/env sh set -euo pipefail set -x npm ci npm run build SKIP_INTEGRATION=1 go test ./... ================================================ FILE: test/ssh-ci/rigging.sh ================================================ #!/usr/bin/env bash set -euo pipefail [ ! -z "${DEBUG:-}" ] && set -x if [ "$#" -ne 1 ]; then echo "Usage: rigging.sh " fi declare -A Hosts Hosts["riscv64"]="ubuntu@riscv64.techaro.lol" # GOARCH=riscv64 GOOS=linux Hosts["ppc64le"]="ci@ppc64le.techaro.lol" # GOARCH=ppc64le GOOS=linux Hosts["aarch64-4k"]="rocky@192.168.2.52" # GOARCH=arm64 GOOS=linux 4k page size Hosts["aarch64-16k"]="ci@192.168.2.28" # GOARCH=arm64 GOOS=linux 16k page size CIRunnerImage="ghcr.io/techarohq/anubis/ci-runner:latest" RunID=${GITHUB_RUN_ID:-$(uuidgen)} RunFolder="anubis/runs/${RunID}" Target="${Hosts["$1"]}" ssh "${Target}" uname -av >/dev/null ssh "${Target}" mkdir -p "${RunFolder}" git archive HEAD | ssh "${Target}" tar xC "${RunFolder}" ssh "${Target}" < resp.status); return statusCode; } const codes = { allow: await testWithUserAgent("ALLOW"), challenge: await testWithUserAgent("CHALLENGE"), deny: await testWithUserAgent("DENY"), }; const expected = { allow: 200, challenge: 401, deny: 403, }; console.log("ALLOW: ", codes.allow); console.log("CHALLENGE:", codes.challenge); console.log("DENY: ", codes.deny); if (JSON.stringify(codes) !== JSON.stringify(expected)) { throw new Error( `wanted ${JSON.stringify(expected)}, got: ${JSON.stringify(codes)}`, ); } ================================================ FILE: utils/cmd/backoff-retry/main.go ================================================ package main import ( "flag" "fmt" "log/slog" "os" "os/exec" "strings" "time" ) var ( startWait = flag.Duration("start-wait", 250*time.Millisecond, "amount of time to start with exponential backoff") tryCount = flag.Int("try-count", 5, "number of retries") ) func main() { flag.Parse() cmdStr := strings.Join(flag.Args(), " ") wait := *startWait for i := range make([]struct{}, *tryCount) { slog.Info("executing", "try", i+1, "wait", wait, "cmd", cmdStr) cmd := exec.Command("sh", "-c", cmdStr) cmd.Stdin = nil cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr if err := cmd.Run(); err != nil { time.Sleep(wait) wait = wait * 2 } else { os.Exit(0) } } fmt.Printf("giving up after %d tries\n", *tryCount) os.Exit(1) } ================================================ FILE: utils/cmd/iplist2rule/blocklist.go ================================================ package main import ( "bufio" "fmt" "io" "net/http" "net/netip" "strings" ) // FetchBlocklist reads the blocklist over HTTP and returns every non-commented // line parsed as an IP address in CIDR notation. IPv4 addresses are returned as // /32, IPv6 addresses as /128. // // This function was generated with GLM 4.7. func FetchBlocklist(url string) ([]string, error) { resp, err := http.Get(url) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("HTTP request failed with status: %s", resp.Status) } var lines []string scanner := bufio.NewScanner(resp.Body) for scanner.Scan() { line := scanner.Text() // Skip empty lines and comments (lines starting with #) if line == "" || strings.HasPrefix(line, "#") { continue } addr, err := netip.ParseAddr(line) if err != nil { // Skip lines that aren't valid IP addresses continue } var cidr string if addr.Is4() { cidr = fmt.Sprintf("%s/32", addr.String()) } else { cidr = fmt.Sprintf("%s/128", addr.String()) } lines = append(lines, cidr) } if err := scanner.Err(); err != nil && err != io.EOF { return nil, err } return lines, nil } ================================================ FILE: utils/cmd/iplist2rule/main.go ================================================ package main import ( "flag" "fmt" "log" "os" "path/filepath" "strings" "time" "github.com/TecharoHQ/anubis/lib/config" "github.com/facebookgo/flagenv" "sigs.k8s.io/yaml" ) type Rule struct { Name string `yaml:"name" json:"name"` Action config.Rule `yaml:"action" json:"action"` RemoteAddr []string `json:"remote_addresses,omitempty" yaml:"remote_addresses,omitempty"` Weight *config.Weight `json:"weight,omitempty" yaml:"weight,omitempty"` } func init() { flag.Usage = func() { fmt.Printf(`Usage of %[1]s: %[1]s [flags] Grabs the contents of the blocklist, converts it to an Anubis ruleset, and writes it to filename. Flags: `, filepath.Base(os.Args[0])) flag.PrintDefaults() } } var ( action = flag.String("action", "DENY", "Anubis action to take (ALLOW / DENY / WEIGH)") manualRuleName = flag.String("rule-name", "", "If set, prefer this name over inferring from filename") weight = flag.Int("weight", 0, "If set to any number, add/subtract this many weight points when --action=WEIGH") ) func main() { flagenv.Parse() flag.Parse() if flag.NArg() != 2 { flag.Usage() os.Exit(2) } blocklistURL := flag.Arg(0) foutName := flag.Arg(1) ruleName := strings.TrimSuffix(foutName, filepath.Ext(foutName)) if *manualRuleName != "" { ruleName = *manualRuleName } ruleAction := config.Rule(*action) if err := ruleAction.Valid(); err != nil { log.Fatalf("--action=%q is invalid: %v", *action, err) } result := &Rule{ Name: ruleName, Action: ruleAction, } if *weight != 0 { if ruleAction != config.RuleWeigh { log.Fatalf("used --weight=%d but --action=%s", *weight, *action) } result.Weight = &config.Weight{ Adjust: *weight, } } ips, err := FetchBlocklist(blocklistURL) if err != nil { log.Fatalf("can't fetch blocklist %s: %v", blocklistURL, err) } result.RemoteAddr = ips fout, err := os.Create(foutName) if err != nil { log.Fatalf("can't create output file %q: %v", foutName, err) } defer fout.Close() fmt.Fprintf(fout, "# Generated by %s on %s from %s\n\n", filepath.Base(os.Args[0]), time.Now().Format(time.RFC3339), blocklistURL) data, err := yaml.Marshal([]*Rule{result}) if err != nil { log.Fatalf("can't marshal yaml") } fout.Write(data) } ================================================ FILE: var/.gitignore ================================================ * !.gitignore ================================================ FILE: web/build.sh ================================================ #!/usr/bin/env bash set -euo pipefail cd "$(dirname "$0")" LICENSE='/* @licstart The following is the entire license notice for the JavaScript code in this page. Copyright (c) 2025 Xe Iaso Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. Includes code from https://github.com/aws/aws-sdk-js-crypto-helpers which is used under the terms of the Apache 2 license. @licend The above is the entire license notice for the JavaScript code in this page. */' # Copy localization files to static directory mkdir -p static/locales cp ../lib/localization/locales/*.json static/locales/ shopt -s nullglob globstar for file in js/**/*.ts js/**/*.mjs; do out="static/${file}" if [[ "$file" == *.ts ]]; then out="static/${file%.ts}.mjs" fi mkdir -p "$(dirname "$out")" esbuild "$file" --sourcemap --bundle --minify --outfile="$out" --banner:js="$LICENSE" gzip -f -k -n "$out" zstd -f -k --ultra -22 "$out" brotli -fZk "$out" done ================================================ FILE: web/embed.go ================================================ package web import "embed" //go:generate go tool github.com/a-h/templ/cmd/templ generate var ( //go:embed static Static embed.FS ) ================================================ FILE: web/index.go ================================================ package web import ( "context" "fmt" "io" "github.com/a-h/templ" "github.com/TecharoHQ/anubis/lib/challenge" "github.com/TecharoHQ/anubis/lib/config" "github.com/TecharoHQ/anubis/lib/localization" ) func Base(title string, body templ.Component, impressum *config.Impressum, localizer *localization.SimpleLocalizer) templ.Component { return base(title, body, impressum, nil, nil, localizer) } func BaseWithChallengeAndOGTags(title string, body templ.Component, impressum *config.Impressum, challenge *challenge.Challenge, rules *config.ChallengeRules, ogTags map[string]string, localizer *localization.SimpleLocalizer) templ.Component { return base(title, body, impressum, struct { Rules *config.ChallengeRules `json:"rules"` Challenge any `json:"challenge"` }{ Challenge: challenge, Rules: rules, }, ogTags, localizer) } func ErrorPage(msg, mail, code string, localizer *localization.SimpleLocalizer) templ.Component { return errorPage(msg, mail, code, localizer) } func Bench(localizer *localization.SimpleLocalizer) templ.Component { return bench(localizer) } func honeypotLink(href string) templ.Component { return templ.ComponentFunc(func(ctx context.Context, w io.Writer) error { fmt.Fprintf(w, ``, href) return nil }) } ================================================ FILE: web/index.templ ================================================ package web import ( "fmt" "github.com/TecharoHQ/anubis" "github.com/TecharoHQ/anubis/lib/config" "github.com/TecharoHQ/anubis/lib/localization" "github.com/TecharoHQ/anubis/xess" "github.com/google/uuid" ) templ base(title string, body templ.Component, impressum *config.Impressum, challenge any, ogTags map[string]string, localizer *localization.SimpleLocalizer) { { title } for key, value := range ogTags { } @templ.JSONScript("anubis_version", anubis.Version) @templ.JSONScript("anubis_challenge", challenge) @templ.JSONScript("anubis_base_prefix", anubis.BasePrefix) @templ.JSONScript("anubis_public_url", anubis.PublicUrl) @honeypotLink(anubis.BasePrefix + fmt.Sprintf("%shoneypot/%s/init", anubis.APIPrefix, uuid.NewString()))

{ title }

@body

{ localizer.T("protected_by") } Anubis { localizer.T("protected_from") } Techaro. { localizer.T("made_with") }.

{ localizer.T("mascot_design") } { localizer.T("celphase") }.

if impressum != nil {

@templ.Raw(impressum.Footer) -- Imprint

}

{ localizer.T("version_info") } { anubis.Version }.

} templ errorPage(message, mail, code string, localizer *localization.SimpleLocalizer) {
Sad Anubis
{ code }
} if mail != "" {

{ localizer.T("go_home") } { localizer.T("contact_webmaster") } { mail }

} else {

{ localizer.T("go_home") }

}
} templ StaticHappy(localizer *localization.SimpleLocalizer) {
} templ bench(localizer *localization.SimpleLocalizer) {
{ localizer.T("time") } { localizer.T("iters") }
{ localizer.T("loading") }

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } return nil }) } var _ = templruntime.GeneratedTemplate ================================================ FILE: web/index_test.go ================================================ package web import ( "context" "net/http/httptest" "strings" "testing" "github.com/TecharoHQ/anubis" "github.com/TecharoHQ/anubis/lib/config" "github.com/TecharoHQ/anubis/lib/localization" "github.com/a-h/templ" ) func TestBasePrefixInLinks(t *testing.T) { tests := []struct { name string basePrefix string wantInLink string }{ { name: "no prefix", basePrefix: "", wantInLink: "/.within.website/x/cmd/anubis/api/", }, { name: "with rififi prefix", basePrefix: "/rififi", wantInLink: "/rififi/.within.website/x/cmd/anubis/api/", }, { name: "with myapp prefix", basePrefix: "/myapp", wantInLink: "/myapp/.within.website/x/cmd/anubis/api/", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Save original BasePrefix and restore after test origPrefix := anubis.BasePrefix defer func() { anubis.BasePrefix = origPrefix }() anubis.BasePrefix = tt.basePrefix // Create test impressum impressum := &config.Impressum{ Footer: "

Test footer

", Page: config.ImpressumPage{ Title: "Test Imprint", Body: "

Test imprint body

", }, } // Create localizer using a dummy request req := httptest.NewRequest("GET", "/", nil) localizer := &localization.SimpleLocalizer{} localizer.Localizer = localization.NewLocalizationService().GetLocalizerFromRequest(req) // Render the base template to a buffer var buf strings.Builder component := base(tt.name, templ.NopComponent, impressum, nil, nil, localizer) err := component.Render(context.Background(), &buf) if err != nil { t.Fatalf("failed to render template: %v", err) } output := buf.String() // Check that honeypot link includes the base prefix if !strings.Contains(output, `href="`+tt.wantInLink+`honeypot/`) { t.Errorf("honeypot link does not contain base prefix %q\noutput: %s", tt.wantInLink, output) } // Check that imprint link includes the base prefix if !strings.Contains(output, `href="`+tt.wantInLink+`imprint`) { t.Errorf("imprint link does not contain base prefix %q\noutput: %s", tt.wantInLink, output) } }) } } ================================================ FILE: web/js/algorithms/fast.ts ================================================ type ProgressCallback = (nonce: number) => void; interface ProcessOptions { basePrefix: string; version: string; } const getHardwareConcurrency = () => navigator.hardwareConcurrency !== undefined ? navigator.hardwareConcurrency : 1; export default function process( options: ProcessOptions, data: string, difficulty: number = 5, signal: AbortSignal | null = null, progressCallback?: ProgressCallback, threads: number = Math.trunc(Math.max(getHardwareConcurrency() / 2, 1)), ): Promise { console.debug("fast algo"); // Choose worker based on secure context. // Use the WebCrypto worker if the page is a secure context; otherwise fall back to pure‑JS. let workerMethod: "webcrypto" | "purejs" = "purejs"; if (window.isSecureContext) { workerMethod = "webcrypto"; } if ( navigator.userAgent.includes("Firefox") || navigator.userAgent.includes("Goanna") ) { console.log("Firefox detected, using pure-JS fallback"); workerMethod = "purejs"; } return new Promise((resolve, reject) => { let webWorkerURL = `${options.basePrefix}/.within.website/x/cmd/anubis/static/js/worker/sha256-${workerMethod}.mjs?cacheBuster=${options.version}`; const workers: Worker[] = []; let settled = false; const onAbort = () => { console.log("PoW aborted"); cleanup(); reject(new DOMException("Aborted", "AbortError")); }; const cleanup = () => { if (settled) { return; } settled = true; workers.forEach((w) => w.terminate()); if (signal != null) { signal.removeEventListener("abort", onAbort); } }; if (signal != null) { if (signal.aborted) { return onAbort(); } signal.addEventListener("abort", onAbort, { once: true }); } for (let i = 0; i < threads; i++) { let worker = new Worker(webWorkerURL); worker.onmessage = (event) => { if (typeof event.data === "number") { progressCallback?.(event.data); } else { cleanup(); resolve(event.data); } }; worker.onerror = (event) => { cleanup(); reject(event); }; worker.postMessage({ data, difficulty, nonce: i, threads, }); workers.push(worker); } }); } ================================================ FILE: web/js/algorithms/index.ts ================================================ import fast from "./fast"; export default { fast: fast, slow: fast, // XXX(Xe): slow is deprecated, but keep this around in case anything goes bad }; ================================================ FILE: web/js/bench.ts ================================================ import algorithms from "./algorithms"; const defaultDifficulty = 4; const status: HTMLParagraphElement = document.getElementById( "status", ) as HTMLParagraphElement; const difficultyInput: HTMLInputElement = document.getElementById( "difficulty-input", ) as HTMLInputElement; const algorithmSelect: HTMLSelectElement = document.getElementById( "algorithm-select", ) as HTMLSelectElement; const compareSelect: HTMLSelectElement = document.getElementById( "compare-select", ) as HTMLSelectElement; const header: HTMLTableRowElement = document.getElementById( "table-header", ) as HTMLTableRowElement; const headerCompare: HTMLTableSectionElement = document.getElementById( "table-header-compare", ) as HTMLTableSectionElement; const results: HTMLTableRowElement = document.getElementById( "results", ) as HTMLTableRowElement; const setupControls = () => { if (defaultDifficulty == null) { return; } difficultyInput.value = defaultDifficulty.toString(); for (const alg of Object.keys(algorithms)) { const option1 = document.createElement("option"); algorithmSelect?.append(option1); const option2 = document.createElement("option"); compareSelect.append(option2); option1.value = option1.innerText = option2.value = option2.innerText = alg; } }; const benchmarkTrial = async (stats, difficulty, algorithm, signal) => { if (!(difficulty >= 1)) { throw new Error(`Invalid difficulty: ${difficulty}`); } const process = algorithms[algorithm]; if (process == null) { throw new Error(`Unknown algorithm: ${algorithm}`); } const rawChallenge = new Uint8Array(32); crypto.getRandomValues(rawChallenge); const challenge = Array.from(rawChallenge) .map((c) => c.toString(16).padStart(2, "0")) .join(""); const t0 = performance.now(); const { hash, nonce } = await process( { basePrefix: "/", version: "devel" }, challenge, Number(difficulty), signal, ); const t1 = performance.now(); console.log({ hash, nonce }); stats.time += t1 - t0; stats.iters += nonce; return { time: t1 - t0, nonce }; }; const stats = { time: 0, iters: 0 }; const comparison = { time: 0, iters: 0 }; const updateStatus = () => { const mainRate = stats.iters / stats.time; const compareRate = comparison.iters / comparison.time; if (Number.isFinite(mainRate)) { status.innerText = `Average hashrate: ${mainRate.toFixed(3)}kH/s`; if (Number.isFinite(compareRate)) { const change = ((mainRate - compareRate) / mainRate) * 100; status.innerText += ` vs ${compareRate.toFixed(3)}kH/s (${change.toFixed(2)}% change)`; } } else { status.innerText = "Benchmarking..."; } }; const tableCell = (text) => { const td = document.createElement("td"); td.innerText = text; td.style.padding = "0 0.25rem"; return td; }; const benchmarkLoop = async (controller) => { const difficulty = difficultyInput.value; const algorithm = algorithmSelect.value; const compareAlgorithm = compareSelect.value; updateStatus(); try { const { time, nonce } = await benchmarkTrial( stats, difficulty, algorithm, controller.signal, ); const tr = document.createElement("tr"); tr.style.display = "contents"; tr.append(tableCell(`${time}ms`), tableCell(nonce)); // auto-scroll to new rows const atBottom = results.scrollHeight - results.clientHeight <= results.scrollTop; results.append(tr); if (atBottom) { results.scrollTop = results.scrollHeight - results.clientHeight; } updateStatus(); if (compareAlgorithm !== "NONE") { const { time, nonce } = await benchmarkTrial( comparison, difficulty, compareAlgorithm, controller.signal, ); tr.append(tableCell(`${time}ms`), tableCell(nonce)); } } catch (e) { if (e !== false) { status.innerText = e; } return; } await benchmarkLoop(controller); }; let controller: AbortController | null = null; const reset = () => { stats.time = stats.iters = 0; comparison.time = comparison.iters = 0; results.innerHTML = status.innerText = ""; const table = results.parentElement as HTMLElement; if (compareSelect.value !== "NONE") { table.style.gridTemplateColumns = "repeat(4,auto)"; header.style.display = "none"; headerCompare.style.display = "contents"; } else { table.style.gridTemplateColumns = "repeat(2,auto)"; header.style.display = "contents"; headerCompare.style.display = "none"; } if (controller != null) { controller.abort(); } controller = new AbortController(); void benchmarkLoop(controller); }; setupControls(); difficultyInput.addEventListener("change", reset); algorithmSelect.addEventListener("change", reset); compareSelect.addEventListener("change", reset); reset(); ================================================ FILE: web/js/main.ts ================================================ import algorithms from "./algorithms"; // from Xeact const u = (url: string = "", params: Record = {}) => { let result = new URL(url, window.location.href); Object.entries(params).forEach(([k, v]) => result.searchParams.set(k, v)); return result.toString(); }; const j = (id: string): any | null => { const elem = document.getElementById(id); if (elem === null) { return null; } return JSON.parse(elem.textContent); }; const imageURL = (mood, cacheBuster, basePrefix) => u(`${basePrefix}/.within.website/x/cmd/anubis/static/img/${mood}.webp`, { cacheBuster, }); // Detect available languages by loading the manifest const getAvailableLanguages = async () => { const basePrefix = j("anubis_base_prefix"); if (basePrefix === null) { return; } try { const response = await fetch( `${basePrefix}/.within.website/x/cmd/anubis/static/locales/manifest.json`, ); if (response.ok) { const manifest = await response.json(); return manifest.supportedLanguages || ["en"]; } } catch (error) { console.warn( "Failed to load language manifest, falling back to default languages", ); } // Fallback to default languages if manifest loading fails return ["en"]; }; // Use the browser language from the HTML lang attribute which is set by the server settings or request headers const getBrowserLanguage = async () => document.documentElement.lang; // Load translations from JSON files const loadTranslations = async (lang) => { const basePrefix = j("anubis_base_prefix"); if (basePrefix === null) { return; } try { const response = await fetch( `${basePrefix}/.within.website/x/cmd/anubis/static/locales/${lang}.json`, ); return await response.json(); } catch (error) { console.warn( `Failed to load translations for ${lang}, falling back to English`, ); if (lang !== "en") { return await loadTranslations("en"); } throw error; } }; const getRedirectUrl = () => { const publicUrl = j("anubis_public_url"); if (publicUrl === null) { return; } if (publicUrl && window.location.href.startsWith(publicUrl)) { const urlParams = new URLSearchParams(window.location.search); return urlParams.get("redir"); } return window.location.href; }; let translations = {}; let currentLang; // Initialize translations const initTranslations = async () => { currentLang = await getBrowserLanguage(); translations = await loadTranslations(currentLang); }; const t = (key) => translations[`js_${key}`] || translations[key] || key; (async () => { // Initialize translations first await initTranslations(); const dependencies = [ { name: "Web Workers", msg: t("web_workers_error"), value: window.Worker, }, { name: "Cookies", msg: t("cookies_error"), value: navigator.cookieEnabled, }, ]; const status: HTMLParagraphElement = document.getElementById( "status", ) as HTMLParagraphElement; const image: HTMLImageElement = document.getElementById( "image", ) as HTMLImageElement; const title: HTMLHeadingElement = document.getElementById( "title", ) as HTMLHeadingElement; const progress: HTMLDivElement = document.getElementById( "progress", ) as HTMLDivElement; const anubisVersion = j("anubis_version"); const basePrefix = j("anubis_base_prefix"); const details = document.querySelector("details"); let userReadDetails = false; if (details) { details.addEventListener("toggle", () => { if (details.open) { userReadDetails = true; } }); } const ohNoes = ({ titleMsg, statusMsg, imageSrc }) => { title.innerHTML = titleMsg; status.innerHTML = statusMsg; image.src = imageSrc; progress.style.display = "none"; }; status.innerHTML = t("calculating"); for (const { value, name, msg } of dependencies) { if (!value) { ohNoes({ titleMsg: `${t("missing_feature")} ${name}`, statusMsg: msg, imageSrc: imageURL("reject", anubisVersion, basePrefix), }); return; } } const { challenge, rules } = j("anubis_challenge"); const process = algorithms[rules.algorithm]; if (!process) { ohNoes({ titleMsg: t("challenge_error"), statusMsg: t("challenge_error_msg"), imageSrc: imageURL("reject", anubisVersion, basePrefix), }); return; } status.innerHTML = `${t("calculating_difficulty")} ${rules.difficulty}, `; progress.style.display = "inline-block"; // the whole text, including "Speed:", as a single node, because some browsers // (Firefox mobile) present screen readers with each node as a separate piece // of text. const rateText = document.createTextNode(`${t("speed")} 0kH/s`); status.appendChild(rateText); let lastSpeedUpdate = 0; let showingApology = false; const likelihood = Math.pow(16, -rules.difficulty); try { const t0 = Date.now(); const { hash, nonce } = await process( { basePrefix, version: anubisVersion }, challenge.randomData, rules.difficulty, null, (iters) => { const delta = Date.now() - t0; // only update the speed every second so it's less visually distracting if (delta - lastSpeedUpdate > 1000) { lastSpeedUpdate = delta; rateText.data = `${t("speed")} ${(iters / delta).toFixed(3)}kH/s`; } // the probability of still being on the page is (1 - likelihood) ^ iters. // by definition, half of the time the progress bar only gets to half, so // apply a polynomial ease-out function to move faster in the beginning // and then slow down as things get increasingly unlikely. quadratic felt // the best in testing, but this may need adjustment in the future. const probability = Math.pow(1 - likelihood, iters); const distance = (1 - Math.pow(probability, 2)) * 100; progress["aria-valuenow"] = distance; if (progress.firstElementChild !== null) { (progress.firstElementChild as HTMLElement).style.width = `${distance}%`; } if (probability < 0.1 && !showingApology) { status.append( document.createElement("br"), document.createTextNode(t("verification_longer")), ); showingApology = true; } }, ); const t1 = Date.now(); console.log({ hash, nonce }); if (userReadDetails) { const container: HTMLDivElement = document.getElementById( "progress", ) as HTMLDivElement; // Style progress bar as a continue button container.style.display = "flex"; container.style.alignItems = "center"; container.style.justifyContent = "center"; container.style.height = "2rem"; container.style.borderRadius = "1rem"; container.style.cursor = "pointer"; container.style.background = "#b16286"; container.style.color = "white"; container.style.fontWeight = "bold"; container.style.outline = "4px solid #b16286"; container.style.outlineOffset = "2px"; container.style.width = "min(20rem, 90%)"; container.style.margin = "1rem auto 2rem"; container.innerHTML = t("finished_reading"); function onDetailsExpand() { const redir = getRedirectUrl(); window.location.replace( u(`${basePrefix}/.within.website/x/cmd/anubis/api/pass-challenge`, { id: challenge.id, response: hash, nonce, redir, elapsedTime: t1 - t0, }), ); } container.onclick = onDetailsExpand; setTimeout(onDetailsExpand, 30000); } else { const redir = getRedirectUrl(); window.location.replace( u(`${basePrefix}/.within.website/x/cmd/anubis/api/pass-challenge`, { id: challenge.id, response: hash, nonce, redir, elapsedTime: t1 - t0, }), ); } } catch (err) { ohNoes({ titleMsg: t("calculation_error"), statusMsg: `${t("calculation_error_msg")} ${err.message}`, imageSrc: imageURL("reject", anubisVersion, basePrefix), }); } })(); ================================================ FILE: web/js/worker/sha256-purejs.ts ================================================ import { Sha256 } from "@aws-crypto/sha256-js"; const calculateSHA256 = (text) => { const hash = new Sha256(); hash.update(text); return hash.digest(); }; function toHexString(arr: Uint8Array): string { return Array.from(arr) .map((c) => c.toString(16).padStart(2, "0")) .join(""); } addEventListener("message", async ({ data: eventData }) => { const { data, difficulty, threads } = eventData; let nonce = eventData.nonce; const isMainThread = nonce === 0; let iterations = 0; const requiredZeroBytes = Math.floor(difficulty / 2); const isDifficultyOdd = difficulty % 2 !== 0; for (;;) { const hashBuffer = await calculateSHA256(data + nonce); const hashArray = new Uint8Array(hashBuffer); let isValid = true; for (let i = 0; i < requiredZeroBytes; i++) { if (hashArray[i] !== 0) { isValid = false; break; } } if (isValid && isDifficultyOdd) { if (hashArray[requiredZeroBytes] >> 4 !== 0) { isValid = false; } } if (isValid) { const finalHash = toHexString(hashArray); postMessage({ hash: finalHash, data, difficulty, nonce, }); return; // Exit worker } nonce += threads; iterations++; /* Truncate the decimal portion of the nonce. This is a bit of an evil bit * hack, but it works reliably enough. The core of why this works is: * * > 13.4 % 1 !== 0 * true * > 13 % 1 !== 0 * false */ if (nonce % 1 !== 0) { nonce = Math.trunc(nonce); } // Send a progress update from the main thread every 1024 iterations. if (isMainThread && (iterations & 1023) === 0) { postMessage(nonce); } } }); ================================================ FILE: web/js/worker/sha256-webcrypto.ts ================================================ const encoder = new TextEncoder(); const calculateSHA256 = async (input: string) => { const data = encoder.encode(input); return await crypto.subtle.digest("SHA-256", data); }; const toHexString = (byteArray: Uint8Array) => { return byteArray.reduce( (str, byte) => str + byte.toString(16).padStart(2, "0"), "", ); }; addEventListener("message", async ({ data: eventData }) => { const { data, difficulty, threads } = eventData; let nonce = eventData.nonce; const isMainThread = nonce === 0; let iterations = 0; const requiredZeroBytes = Math.floor(difficulty / 2); const isDifficultyOdd = difficulty % 2 !== 0; for (;;) { const hashBuffer = await calculateSHA256(data + nonce); const hashArray = new Uint8Array(hashBuffer); let isValid = true; for (let i = 0; i < requiredZeroBytes; i++) { if (hashArray[i] !== 0) { isValid = false; break; } } if (isValid && isDifficultyOdd) { if (hashArray[requiredZeroBytes] >> 4 !== 0) { isValid = false; } } if (isValid) { const finalHash = toHexString(hashArray); postMessage({ hash: finalHash, data, difficulty, nonce, }); return; // Exit worker } nonce += threads; iterations++; /* Truncate the decimal portion of the nonce. This is a bit of an evil bit * hack, but it works reliably enough. The core of why this works is: * * > 13.4 % 1 !== 0 * true * > 13 % 1 !== 0 * false */ if (nonce % 1 !== 0) { nonce = Math.trunc(nonce); } // Send a progress update from the main thread every 1024 iterations. if (isMainThread && (iterations & 1023) === 0) { postMessage(nonce); } } }); ================================================ FILE: web/static/img/ATTRIBUTIONS.txt ================================================ These mascot images were made by CELPHASE (https://bsky.app/profile/celphase.bsky.social). These images are available under the terms of the MIT license that this repository uses. ================================================ FILE: web/static/js/.gitignore ================================================ * !.gitignore ================================================ FILE: web/static/robots.txt ================================================ User-agent: AddSearchBot User-agent: AI2Bot User-agent: Ai2Bot-Dolma User-agent: aiHitBot User-agent: Amazonbot User-agent: Andibot User-agent: anthropic-ai User-agent: Applebot User-agent: Applebot-Extended User-agent: Awario User-agent: bedrockbot User-agent: bigsur.ai User-agent: Brightbot 1.0 User-agent: Bytespider User-agent: CCBot User-agent: ChatGPT Agent User-agent: ChatGPT-User User-agent: Claude-SearchBot User-agent: Claude-User User-agent: Claude-Web User-agent: ClaudeBot User-agent: CloudVertexBot User-agent: cohere-ai User-agent: cohere-training-data-crawler User-agent: Cotoyogi User-agent: Crawlspace User-agent: Datenbank Crawler User-agent: Devin User-agent: Diffbot User-agent: DuckAssistBot User-agent: Echobot Bot User-agent: EchoboxBot User-agent: FacebookBot User-agent: facebookexternalhit User-agent: Factset_spyderbot User-agent: FirecrawlAgent User-agent: FriendlyCrawler User-agent: Gemini-Deep-Research User-agent: Google-CloudVertexBot User-agent: Google-Extended User-agent: GoogleAgent-Mariner User-agent: GoogleOther User-agent: GoogleOther-Image User-agent: GoogleOther-Video User-agent: GPTBot User-agent: iaskspider/2.0 User-agent: ICC-Crawler User-agent: ImagesiftBot User-agent: img2dataset User-agent: ISSCyberRiskCrawler User-agent: Kangaroo Bot User-agent: LinerBot User-agent: meta-externalagent User-agent: Meta-ExternalAgent User-agent: meta-externalfetcher User-agent: Meta-ExternalFetcher User-agent: MistralAI-User User-agent: MistralAI-User/1.0 User-agent: MyCentralAIScraperBot User-agent: netEstate Imprint Crawler User-agent: NovaAct User-agent: OAI-SearchBot User-agent: omgili User-agent: omgilibot User-agent: OpenAI User-agent: Operator User-agent: PanguBot User-agent: Panscient User-agent: panscient.com User-agent: Perplexity-User User-agent: PerplexityBot User-agent: PetalBot User-agent: PhindBot User-agent: Poseidon Research Crawler User-agent: QualifiedBot User-agent: QuillBot User-agent: quillbot.com User-agent: SBIntuitionsBot User-agent: Scrapy User-agent: SemrushBot-OCOB User-agent: SemrushBot-SWA User-agent: Sidetrade indexer bot User-agent: Thinkbot User-agent: TikTokSpider User-agent: Timpibot User-agent: VelenPublicWebCrawler User-agent: WARDBot User-agent: Webzio-Extended User-agent: wpbot User-agent: YaK User-agent: YandexAdditional User-agent: YandexAdditionalBot User-agent: YouBot Disallow: / User-agent: * Disallow: / ================================================ FILE: xess/.gitignore ================================================ xess.min.css ================================================ FILE: xess/build.sh ================================================ #!/usr/bin/env bash set -euo pipefail cd "$(dirname "$0")" postcss ./xess.css -o xess.min.css ================================================ FILE: xess/postcss.config.js ================================================ module.exports = { plugins: [ require("cssnano")({ preset: "advanced", }), require("postcss-url")({ url: "inline" }), ], }; ================================================ FILE: xess/static/podkova.css ================================================ @font-face { font-family: "Podkova"; font-style: normal; font-weight: 400 800; font-display: swap; src: url("podkova.woff2") format("woff2"); } ================================================ FILE: xess/xess.css ================================================ :root { --body-sans-font: Geist, sans-serif; --body-preformatted-font: Iosevka Curly Iaso, monospace; --body-title-font: Podkova, serif; --background: #1d2021; --text: #f9f5d7; --text-selection: #d3869b; --preformatted-background: #3c3836; --link-foreground: #b16286; --link-background: #282828; --blockquote-border-left: 1px solid #bdae93; --progress-bar-outline: #b16286 solid 4px; --progress-bar-fill: #b16286; } @media (prefers-color-scheme: light) { :root { --background: #f9f5d7; --text: #1d2021; --text-selection: #d3869b; --preformatted-background: #ebdbb2; --link-foreground: #b16286; --link-background: #fbf1c7; --blockquote-border-left: 1px solid #655c54; } } @font-face { font-family: "Geist"; font-style: normal; font-weight: 100 900; font-display: swap; src: url("./static/geist.woff2") format("woff2"); } @font-face { font-family: "Podkova"; font-style: normal; font-weight: 400 800; font-display: swap; src: url("./static/podkova.woff2") format("woff2"); } @font-face { font-family: "Iosevka Curly"; font-style: monospace; font-display: swap; src: url("./static/iosevka-curly.woff2") format("woff2"); } main { font-family: var(--body-sans-font); max-width: 50rem; padding: 2rem; margin: auto; } ::selection { background: var(--text-selection); } body { background: var(--background); color: var(--text); } body, html { height: 100%; display: flex; justify-content: center; align-items: center; margin-left: auto; margin-right: auto; } .centered-div { text-align: center; } #status { font-variant-numeric: tabular-nums; } .centered-div { text-align: center; } #status { font-variant-numeric: tabular-nums; } #progress { display: none; width: min(20rem, 90%); height: 2rem; border-radius: 1rem; overflow: hidden; margin: 1rem 0 2rem; outline-offset: 2px; outline: var(--progress-bar-outline); } .bar-inner { background-color: var(--progress-bar-fill); height: 100%; width: 0; transition: width 0.25s ease-in; } @media (prefers-reduced-motion: no-preference) { .bar-inner { transition: width 0.25s ease-in; } } pre { background-color: var(--preformatted-background); padding: 1em; border: 0; font-family: var(--body-preformatted-font); } a, a:active, a:visited { color: var(--link-foreground); background-color: var(--link-background); } h1, h2, h3, h4, h5 { margin-bottom: 0.1rem; font-family: var(--body-title-font); } blockquote { border-left: var(--blockquote-border-left); margin: 0.5em 10px; padding: 0.5em 10px; } footer { text-align: center; } ================================================ FILE: xess/xess.go ================================================ // Package xess vendors a copy of Xess and makes it available at /.xess/xess.css // // This is intended to be used as a vendored package in other projects. package xess import ( "embed" "net/http" "path/filepath" "github.com/TecharoHQ/anubis" "github.com/TecharoHQ/anubis/internal" ) var ( //go:embed *.css static Static embed.FS BasePrefix = "/.within.website/x/xess/" URL = "/.within.website/x/xess/xess.css" ) func init() { Mount(http.DefaultServeMux) //goland:noinspection GoBoolExpressions if anubis.Version != "devel" { URL = filepath.Join(filepath.Dir(URL), "xess.min.css") } URL = URL + "?cachebuster=" + anubis.Version } // Mount registers the xess static file handlers on the given mux func Mount(mux *http.ServeMux) { prefix := anubis.BasePrefix + "/.within.website/x/xess/" mux.Handle(prefix, internal.UnchangingCache(http.StripPrefix(prefix, http.FileServerFS(Static)))) } ================================================ FILE: yeetfile.js ================================================ $`npm run assets`; ["amd64", "arm64", "ppc64le", "riscv64"].forEach((goarch) => { [deb, rpm, tarball].forEach((method) => method.build({ name: "anubis", description: "Anubis weighs the souls of incoming HTTP requests and uses a sha256 proof-of-work challenge in order to protect upstream resources from scraper bots.", homepage: "https://anubis.techaro.lol", license: "MIT", goarch, documentation: { "./README.md": "README.md", "./LICENSE": "LICENSE", "./data/botPolicies.yaml": "botPolicies.yaml", }, build: ({ bin, etc, systemd, doc }) => { $`go build -o ${bin}/anubis -ldflags '-s -w -extldflags "-static" -X "github.com/TecharoHQ/anubis.Version=${git.tag()}"' ./cmd/anubis`; $`go build -o ${bin}/anubis-robots2policy -ldflags '-s -w -extldflags "-static" -X "github.com/TecharoHQ/anubis.Version=${git.tag()}"' ./cmd/robots2policy`; file.install("./run/anubis@.service", `${systemd}/anubis@.service`); file.install("./run/default.env", `${etc}/default.env`); $`mkdir -p ${doc}/docs`; $`cp -a docs/docs ${doc}`; $`find ${doc} -name _category_.json -delete`; $`mkdir -p ${doc}/data`; $`cp -a data/apps ${doc}/data/apps`; $`cp -a data/bots ${doc}/data/bots`; $`cp -a data/clients ${doc}/data/clients`; $`cp -a data/common ${doc}/data/common`; $`cp -a data/crawlers ${doc}/data/crawlers`; $`cp -a data/meta ${doc}/data/meta`; }, }), ); }); // NOTE(Xe): Fixes #217. This is a "half baked" tarball that includes the harder // parts for deterministic distros already done. Distributions like NixOS, Gentoo // and *BSD ports have a difficult time fitting the square peg of their dependency // model into the bazaar of round holes that various modern languages use. Needless // to say, this makes adoption easier. tarball.build({ name: "anubis-src-vendor", license: "MIT", // XXX(Xe): This is needed otherwise go will be very sad. platform: yeet.goos, goarch: yeet.goarch, build: ({ out }) => { // prepare clean checkout in $out $`git archive --format=tar HEAD | tar xC ${out}`; // vendor Go dependencies $`cd ${out} && go mod vendor`; // write VERSION file $`echo ${git.tag()} > ${out}/VERSION`; }, mkFilename: ({ name, version }) => `${name}-${version}`, }); tarball.build({ name: "anubis-src-vendor-npm", license: "MIT", // XXX(Xe): This is needed otherwise go will be very sad. platform: yeet.goos, goarch: yeet.goarch, build: ({ out }) => { // prepare clean checkout in $out $`git archive --format=tar HEAD | tar xC ${out}`; // vendor Go dependencies $`cd ${out} && go mod vendor`; // build NPM-bound dependencies $`cd ${out} && npm ci && npm run assets && rm -rf node_modules`; // write VERSION file $`echo ${git.tag()} > ${out}/VERSION`; }, mkFilename: ({ name, version }) => `${name}-${version}`, });