[
  {
    "path": ".air.toml",
    "content": "root = \".\"\ntmp_dir = \"var\"\n\n[build]\ncmd = \"go build -o ./var/main ./cmd/anubis\"\nbin = \"./var/main\"\nargs = [\"--use-remote-address\"]\nexclude_dir = [\"var\", \"vendor\", \"docs\", \"node_modules\"]\n\n[logger]\ntime = true\n# to change flags at runtime, prepend with -- e.g. $ air -- --target http://localhost:3000 --difficulty 20 --use-remote-address\n"
  },
  {
    "path": ".devcontainer/Dockerfile",
    "content": "FROM ghcr.io/xe/devcontainer-base/pre/go\n\nWORKDIR /app\n\nCOPY go.mod go.sum package.json package-lock.json ./\nRUN apt-get update \\\n  && apt-get -y install zstd brotli redis \\\n  && mkdir -p /home/vscode/.local/share/fish \\\n  && chown -R vscode:vscode /home/vscode/.local/share/fish \\\n  && chown -R vscode:vscode /go\n\nCMD [\"/usr/bin/sleep\", \"infinity\"]"
  },
  {
    "path": ".devcontainer/README.md",
    "content": "# Anubis Dev Container\n\nAnubis 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:\n\n- [Fish](https://fishshell.com/) as the shell complete with a custom theme\n- [Go](https://go.dev) at the most recent stable version\n- [Node.js](https://nodejs.org/en) at the most recent stable version\n- [Atuin](https://atuin.sh/) to sync shell history between your host OS and the development container\n- [Docker](https://docker.com) to manage and build Anubis container images from inside the development container\n- [Ko](https://ko.build/) to build production-ready Anubis container images\n- [Neovim](https://neovim.io/) for use with Git\n\nThis 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.\n"
  },
  {
    "path": ".devcontainer/devcontainer.json",
    "content": "// For format details, see https://aka.ms/devcontainer.json. For config options, see the\n// README at: https://github.com/devcontainers/templates/tree/main/src/debian\n{\n  \"name\": \"Dev\",\n  \"dockerComposeFile\": [\"./docker-compose.yaml\"],\n  \"service\": \"workspace\",\n  \"workspaceFolder\": \"/workspace/anubis\",\n  \"postStartCommand\": \"bash ./.devcontainer/poststart.sh\",\n  \"features\": {\n    \"ghcr.io/xe/devcontainer-features/ko:1.1.0\": {},\n    \"ghcr.io/devcontainers/features/github-cli:1\": {}\n  },\n  \"initializeCommand\": \"mkdir -p ${localEnv:HOME}${localEnv:USERPROFILE}/.local/share/atuin\",\n  \"customizations\": {\n    \"vscode\": {\n      \"extensions\": [\n        \"esbenp.prettier-vscode\",\n        \"ms-azuretools.vscode-containers\",\n        \"golang.go\",\n        \"unifiedjs.vscode-mdx\",\n        \"a-h.templ\",\n        \"redhat.vscode-yaml\",\n        \"streetsidesoftware.code-spell-checker\"\n      ],\n      \"settings\": {\n        \"chat.instructionsFilesLocations\": {\n          \".github/copilot-instructions.md\": true\n        }\n      }\n    }\n  }\n}\n"
  },
  {
    "path": ".devcontainer/docker-compose.yaml",
    "content": "services:\n  playwright:\n    image: mcr.microsoft.com/playwright:v1.52.0-noble\n    init: true\n    network_mode: service:workspace\n    command:\n      - /bin/sh\n      - -c\n      - npx -y playwright@1.52.0 run-server --port 9001 --host 0.0.0.0\n\n  valkey:\n    image: valkey/valkey:8\n    pull_policy: always\n\n  # VS Code workspace service\n  workspace:\n    image: ghcr.io/techarohq/anubis/devcontainer\n    build:\n      context: ..\n      dockerfile: .devcontainer/Dockerfile\n    volumes:\n      - ../:/workspace/anubis:cached\n    environment:\n      VALKEY_URL: redis://valkey:6379/0\n    #entrypoint: [\"/usr/bin/sleep\", \"infinity\"]\n    user: vscode\n"
  },
  {
    "path": ".devcontainer/poststart.sh",
    "content": "#!/usr/bin/env bash\n\npwd\n\nnpm ci &\ngo mod download &\ngo install ./utils/cmd/... &\n\nwait\n"
  },
  {
    "path": ".gitattributes",
    "content": "**/*_templ.go linguist-generated=true\n"
  },
  {
    "path": ".github/FUNDING.yml",
    "content": "patreon: cadey\ngithub: xe\nliberapay: Xe\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.yaml",
    "content": "name: Bug report\ndescription: Create a report to help us improve\n\nbody:\n  - type: textarea\n    id: description-of-bug\n    attributes:\n      label: Describe the bug\n      description: A clear and concise description of what the bug is.\n      placeholder: I can reliably get an error when...\n    validations:\n      required: true\n\n  - type: textarea\n    id: steps-to-reproduce\n    attributes:\n      label: Steps to reproduce\n      description: |\n        Steps to reproduce the behavior.\n      placeholder: |\n        1. Go to the following url...\n        2. Click on...\n        3. You get the following error: ...\n    validations:\n      required: true\n\n  - type: textarea\n    id: expected-behavior\n    attributes:\n      label: Expected behavior\n      description: |\n        A clear and concise description of what you expected to happen.\n        Ideally also describe *why* you expect it to happen.\n      placeholder: Instead of displaying an error, it would...\n    validations:\n      required: true\n\n  - type: input\n    id: version-os\n    attributes:\n      label: Your operating system and its version.\n      description: Unsure? Visit https://whatsmyos.com/\n      placeholder: Android 13\n    validations:\n      required: true\n\n  - type: input\n    id: version-browser\n    attributes:\n      label: Your browser and its version.\n      description: Unsure? Visit https://www.whatsmybrowser.org/\n      placeholder: Firefox 142\n    validations:\n      required: true\n\n  - type: textarea\n    id: additional-context\n    attributes:\n      label: Additional context\n      description: Add any other context about the problem here.\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/config.yml",
    "content": "blank_issues_enabled: false\ncontact_links:\n  - name: Security\n    url: https://techaro.lol/contact\n    about: Do not file security reports here. Email security@techaro.lol.\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.yaml",
    "content": "name: Feature request\ndescription: Suggest an idea for this project\ntitle: \"[Feature request] \"\n\nbody:\n  - type: textarea\n    id: description-of-bug\n    attributes:\n      label: Is your feature request related to a problem? Please describe.\n      description: A clear and concise description of what the problem is that made you submit this report.\n      placeholder: I am always frustrated, when...\n    validations:\n      required: true\n\n  - type: textarea\n    id: description-of-solution\n    attributes:\n      label: Solution you would like.\n      description: A clear and concise description of what you want to happen.\n      placeholder: Instead of behaving like this, there should be...\n    validations:\n      required: true\n\n  - type: textarea\n    id: alternatives\n    attributes:\n      label: Describe alternatives you have considered.\n      description: A clear and concise description of any alternative solutions or features you have considered.\n      placeholder: Another workaround that would work, is...\n    validations:\n      required: false\n\n  - type: textarea\n    id: additional-context\n    attributes:\n      label: Additional context\n      description: Add any other context (such as mock-ups, proof of concepts or screenshots) about the feature request here.\n    validations:\n      required: false\n"
  },
  {
    "path": ".github/PULL_REQUEST_TEMPLATE.md",
    "content": "<!--\ndelete me and describe your change here, give enough context for a maintainer to understand what and why\n\nSee https://github.com/TecharoHQ/anubis/blob/main/CONTRIBUTING.md for more information\n-->\n\nChecklist:\n\n- [ ] Added a description of the changes to the `[Unreleased]` section of docs/docs/CHANGELOG.md\n- [ ] Added test cases to [the relevant parts of the codebase](https://github.com/TecharoHQ/anubis/blob/main/CONTRIBUTING.md)\n- [ ] Ran integration tests `npm run test:integration` (unsupported on Windows, please use WSL)\n- [ ] All of my commits have [verified signatures](https://anubis.techaro.lol/docs/developer/signed-commits)\n"
  },
  {
    "path": ".github/actions/spelling/README.md",
    "content": "# check-spelling/check-spelling configuration\n\n| File                                               | Purpose                                                                          | Format                                                                                            | Info                                                                                                 |\n| -------------------------------------------------- | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |\n| [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)         |\n| [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)                   |\n| [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)     |\n| [excludes.txt](excludes.txt)                       | Files to ignore entirely                                                         | perl regular expression                                                                           | [excludes](https://github.com/check-spelling/check-spelling/wiki/Configuration-Examples%3A-excludes) |\n| [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)         |\n| [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) |\n| [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)        |\n| [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) |\n| [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)                 |\n| [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)     |\n\nNote: you can replace any of these files with a directory by the same name (minus the suffix)\nand then include multiple files inside that directory (with that suffix) to merge multiple files together.\n"
  },
  {
    "path": ".github/actions/spelling/advice.md",
    "content": "<!-- See https://github.com/check-spelling/check-spelling/wiki/Configuration-Examples%3A-advice --> <!-- markdownlint-disable MD033 MD041 -->\n<details><summary>If the flagged items are :exploding_head: false positives</summary>\n\nIf items relate to a ...\n\n- binary file (or some other file you wouldn't want to check at all).\n\n  Please add a file path to the `excludes.txt` file matching the containing file.\n\n  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.\n\n  `^` 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).\n\n- well-formed pattern.\n\n  If you can write a [pattern](https://github.com/check-spelling/check-spelling/wiki/Configuration-Examples:-patterns) that would match it,\n  try adding it to the `patterns.txt` file.\n\n  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.\n\n  Note that patterns can't match multiline strings.\n\n</details>\n\n<!-- adoption information-->\n\n:steam_locomotive: If you're seeing this message and your PR is from a branch that doesn't have check-spelling,\nplease merge to your PR's base branch to get the version configured for your repository.\n"
  },
  {
    "path": ".github/actions/spelling/allow.txt",
    "content": "github\nhttps\nssh\nubuntu\nworkarounds\nrjack\nmsgbox\nxeact\nABee\ntencent\nmaintnotifications\nazurediamond\ncooldown\nverifyfcrdns\nSpintax\nspintax\nclampip\npseudoprofound\nreimagining\niocaine\nadmins\nfout\niplist\nNArg\nblocklists\nrififi\nprolocation\nProlocation\nNecron\nStargate\nFFXIV\nuvensys\nde\nenvoyproxy\nunipromos\n"
  },
  {
    "path": ".github/actions/spelling/candidate.patterns",
    "content": "# Repeated letters\n#\\b([a-z])\\g{-1}{2,}\\b\n\n# marker to ignore all code on line\n^.*/\\* #no-spell-check-line \\*/.*$\n# marker to ignore all code on line\n^.*\\bno-spell-check(?:-line|)(?:\\s.*|)$\n\n# https://cspell.org/configuration/document-settings/\n# cspell inline\n^.*\\b[Cc][Ss][Pp][Ee][Ll]{2}:\\s*[Dd][Ii][Ss][Aa][Bb][Ll][Ee]-[Ll][Ii][Nn][Ee]\\b\n\n# copyright\nCopyright (?:\\([Cc]\\)|)(?:[-\\d, ]|and)+(?: [A-Z][a-z]+ [A-Z][a-z]+,?)+\n\n# patch hunk comments\n^@@ -\\d+(?:,\\d+|) \\+\\d+(?:,\\d+|) @@ .*\n# git index header\nindex (?:[0-9a-z]{7,40},|)[0-9a-z]{7,40}\\.\\.[0-9a-z]{7,40}\n\n# file permissions\n['\"`\\s][-bcdLlpsw](?:[-r][-w][-Ssx]){2}[-r][-w][-SsTtx]\\+?['\"`\\s]\n\n# css fonts\n\\bfont(?:-family|):[^;}]+\n\n# css url wrappings\n\\burl\\([^)]+\\)\n\n# cid urls\n(['\"])cid:.*?\\g{-1}\n\n# data url in parens\n\\(data:(?:[^) ][^)]*?|)(?:[A-Z]{3,}|[A-Z][a-z]{2,}|[a-z]{3,})[^)]*\\)\n# data url in quotes\n([`'\"])data:(?:[^ `'\"].*?|)(?:[A-Z]{3,}|[A-Z][a-z]{2,}|[a-z]{3,}).*\\g{-1}\n# data url\n\\bdata:[-a-zA-Z=;:/0-9+]*,\\S*\n\n# https/http/file urls\n(?:\\b(?:https?|ftp|file)://)[-A-Za-z0-9+&@#/*%?=~_|!:,.;]+[-A-Za-z0-9+&@#/*%=~_|]\n\n# mailto urls\nmailto:[-a-zA-Z=;:/?%&0-9+@._]{3,}\n\n# magnet urls\nmagnet:[?=:\\w]+\n\n# magnet urls\n\"magnet:[^\"]+\"\n\n# obs:\n\"obs:[^\"]*\"\n\n# The `\\b` here means a break, it's the fancy way to handle urls, but it makes things harder to read\n# In this examples content, I'm using a number of different ways to match things to show various approaches\n# asciinema\n\\basciinema\\.org/a/[0-9a-zA-Z]+\n\n# asciinema v2\n^\\[\\d+\\.\\d+, \"[io]\", \".*\"\\]$\n\n# apple\n\\bdeveloper\\.apple\\.com/[-\\w?=/]+\n# Apple music\n\\bembed\\.music\\.apple\\.com/fr/playlist/usr-share/[-\\w.]+\n\n# appveyor api\n\\bci\\.appveyor\\.com/api/projects/status/[0-9a-z]+\n# appveyor project\n\\bci\\.appveyor\\.com/project/(?:[^/\\s\"]*/){2}builds?/\\d+/job/[0-9a-z]+\n\n# Amazon\n\n# Amazon\n\\bamazon\\.com/[-\\w]+/(?:dp/[0-9A-Z]+|)\n# AWS ARN\narn:aws:[-/:\\w]+\n# AWS S3\n\\b\\w*\\.s3[^.]*\\.amazonaws\\.com/[-\\w/&#%_?:=]*\n# AWS execute-api\n\\b[0-9a-z]{10}\\.execute-api\\.[-0-9a-z]+\\.amazonaws\\.com\\b\n# AWS ELB\n\\b\\w+\\.[-0-9a-z]+\\.elb\\.amazonaws\\.com\\b\n# AWS SNS\n\\bsns\\.[-0-9a-z]+.amazonaws\\.com/[-\\w/&#%_?:=]*\n# AWS VPC\nvpc-\\w+\n\n# While you could try to match `http://` and `https://` by using `s?` in `https?://`, sometimes there\n# YouTube url\n\\b(?:(?:www\\.|)youtube\\.com|youtu.be)/(?:channel/|embed/|user/|playlist\\?list=|watch\\?v=|v/|)[-a-zA-Z0-9?&=_%]*\n# YouTube music\n\\bmusic\\.youtube\\.com/youtubei/v1/browse(?:[?&]\\w+=[-a-zA-Z0-9?&=_]*)\n# YouTube tag\n<\\s*youtube\\s+id=['\"][-a-zA-Z0-9?_]*['\"]\n# YouTube image\n\\bimg\\.youtube\\.com/vi/[-a-zA-Z0-9?&=_]*\n# Google Accounts\n\\baccounts.google.com/[-_/?=.:;+%&0-9a-zA-Z]*\n# Google Analytics\n\\bgoogle-analytics\\.com/collect.[-0-9a-zA-Z?%=&_.~]*\n# Google APIs\n\\bgoogleapis\\.(?:com|dev)/[a-z]+/(?:v\\d+/|)[a-z]+/[-@:./?=\\w+|&]+\n# Google Artifact Registry\n\\.pkg\\.dev(?:/[-\\w]+)+(?::[-\\w]+|)\n# Google Storage\n\\b[-a-zA-Z0-9.]*\\bstorage\\d*\\.googleapis\\.com(?:/\\S*|)\n# Google Calendar\n\\bcalendar\\.google\\.com/calendar(?:/u/\\d+|)/embed\\?src=[@./?=\\w&%]+\n\\w+\\@group\\.calendar\\.google\\.com\\b\n# Google DataStudio\n\\bdatastudio\\.google\\.com/(?:(?:c/|)u/\\d+/|)(?:embed/|)(?:open|reporting|datasources|s)/[-0-9a-zA-Z]+(?:/page/[-0-9a-zA-Z]+|)\n# The leading `/` here is as opposed to the `\\b` above\n# ... a short way to match `https://` or `http://` since most urls have one of those prefixes\n# Google Docs\n/docs\\.google\\.com/[a-z]+/(?:ccc\\?key=\\w+|(?:u/\\d+|d/(?:e/|)[0-9a-zA-Z_-]+/)?(?:edit\\?[-\\w=#.]*|/\\?[\\w=&]*|))\n# Google Drive\n\\bdrive\\.google\\.com/(?:file/d/|open)[-0-9a-zA-Z_?=]*\n# Google Groups\n\\bgroups\\.google\\.com(?:/[a-z]+/(?:#!|)[^/\\s\"]+)*\n# Google Maps\n\\bmaps\\.google\\.com/maps\\?[\\w&;=]*\n# Google themes\nthemes\\.googleusercontent\\.com/static/fonts/[^/\\s\"]+/v\\d+/[^.]+.\n# Google CDN\n\\bclients2\\.google(?:usercontent|)\\.com[-0-9a-zA-Z/.]*\n# Goo.gl\n/goo\\.gl/[a-zA-Z0-9]+\n# Google Chrome Store\n\\bchrome\\.google\\.com/webstore/detail/[-\\w]*(?:/\\w*|)\n# Google Books\n\\bgoogle\\.(?:\\w{2,4})/books(?:/\\w+)*\\?[-\\w\\d=&#.]*\n# Google Fonts\n\\bfonts\\.(?:googleapis|gstatic)\\.com/[-/?=:;+&0-9a-zA-Z]*\n# Google Forms\n\\bforms\\.gle/\\w+\n# Google Scholar\n\\bscholar\\.google\\.com/citations\\?user=[A-Za-z0-9_]+\n# Google Colab Research Drive\n\\bcolab\\.research\\.google\\.com/drive/[-0-9a-zA-Z_?=]*\n# Google Cloud regions\n(?:us|(?:north|south)america|europe|asia|australia|me|africa)-(?:north|south|east|west|central){1,2}\\d+\n\n# GitHub SHAs (api)\n\\bapi.github\\.com/repos(?:/[^/\\s\"]+){3}/[0-9a-f]+\\b\n# GitHub SHAs (markdown)\n(?:\\[`?[0-9a-f]+`?\\]\\(https:/|)/(?:www\\.|)github\\.com(?:/[^/\\s\"]+){2,}(?:/[^/\\s\")]+)(?:[0-9a-f]+(?:[-0-9a-zA-Z/#.]*|)\\b|)\n# GitHub SHAs\n\\bgithub\\.com(?:/[^/\\s\"]+){2}[@#][0-9a-f]+\\b\n# GitHub SHA refs\n\\[([0-9a-f]+)\\]\\(https://(?:www\\.|)github.com/[-\\w]+/[-\\w]+/commit/\\g{-1}[0-9a-f]*\n# GitHub wiki\n\\bgithub\\.com/(?:[^/]+/){2}wiki/(?:(?:[^/]+/|)_history|[^/]+(?:/_compare|)/[0-9a-f.]{40,})\\b\n# githubusercontent\n/[-a-z0-9]+\\.githubusercontent\\.com/[-a-zA-Z0-9?&=_\\/.]*\n# githubassets\n\\bgithubassets.com/[0-9a-f]+(?:[-/\\w.]+)\n# gist github\n\\bgist\\.github\\.com/[^/\\s\"]+/[0-9a-f]+\n# git.io\n\\bgit\\.io/[0-9a-zA-Z]+\n# GitHub JSON\n\"node_id\": \"[-a-zA-Z=;:/0-9+_]*\"\n# Contributor\n\\[[^\\]]+\\]\\(https://github\\.com/[^/\\s\"]+/?\\)\n# GHSA\nGHSA(?:-[0-9a-z]{4}){3}\n\n# GitHub actions\n\\buses:\\s+[-\\w.]+/[-\\w./]+@[-\\w.]+\n\n# GitLab commit\n\\bgitlab\\.[^/\\s\"]*/\\S+/\\S+/commit/[0-9a-f]{7,16}#[0-9a-f]{40}\\b\n# GitLab merge requests\n\\bgitlab\\.[^/\\s\"]*/\\S+/\\S+/-/merge_requests/\\d+/diffs#[0-9a-f]{40}\\b\n# GitLab uploads\n\\bgitlab\\.[^/\\s\"]*/uploads/[-a-zA-Z=;:/0-9+]*\n# GitLab commits\n\\bgitlab\\.[^/\\s\"]*/(?:[^/\\s\"]+/){2}commits?/[0-9a-f]+\\b\n\n# #includes\n^\\s*#include\\s*(?:<.*?>|\".*?\")\n\n# #pragma lib\n^\\s*#pragma comment\\(lib, \".*?\"\\)\n\n# binance\naccounts\\.binance\\.com/[a-z/]*oauth/authorize\\?[-0-9a-zA-Z&%]*\n\n# bitbucket diff\n\\bapi\\.bitbucket\\.org/\\d+\\.\\d+/repositories/(?:[^/\\s\"]+/){2}diff(?:stat|)(?:/[^/\\s\"]+){2}:[0-9a-f]+\n# bitbucket repositories commits\n\\bapi\\.bitbucket\\.org/\\d+\\.\\d+/repositories/(?:[^/\\s\"]+/){2}commits?/[0-9a-f]+\n# bitbucket commits\n\\bbitbucket\\.org/(?:[^/\\s\"]+/){2}commits?/[0-9a-f]+\n\n# bit.ly\n\\bbit\\.ly/\\w+\n\n# bitrise\n\\bapp\\.bitrise\\.io/app/[0-9a-f]*/[\\w.?=&]*\n\n# bootstrapcdn.com\n\\bbootstrapcdn\\.com/[-./\\w]+\n\n# cdn.cloudflare.com\n\\bcdnjs\\.cloudflare\\.com/[./\\w]+\n\n# circleci\n\\bcircleci\\.com/gh(?:/[^/\\s\"]+){1,5}.[a-z]+\\?[-0-9a-zA-Z=&]+\n\n# gitter\n\\bgitter\\.im(?:/[^/\\s\"]+){2}\\?at=[0-9a-f]+\n\n# gravatar\n\\bgravatar\\.com/avatar/[0-9a-f]+\n\n# ibm\n[a-z.]*ibm\\.com/[-_#=:%!?~.\\\\/\\d\\w]*\n\n# imgur\n\\bimgur\\.com/[^.]+\n\n# Internet Archive\n\\barchive\\.org/web/\\d+/(?:[-\\w.?,'/\\\\+&%$#_:]*)\n\n# discord\n/discord(?:app\\.com|\\.gg)/(?:invite/)?[a-zA-Z0-9]{7,}\n\n# Disqus\n\\bdisqus\\.com/[-\\w/%.()!?&=_]*\n\n# medium link\n\\blink\\.medium\\.com/[a-zA-Z0-9]+\n# medium\n\\bmedium\\.com/@?[^/\\s\"]+/[-\\w]+\n\n# microsoft\n\\b(?:https?://|)(?:(?:(?:blogs|download\\.visualstudio|docs|msdn2?|research)\\.|)microsoft|blogs\\.msdn)\\.co(?:m|\\.\\w\\w)/[-_a-zA-Z0-9()=./%]*\n# powerbi\n\\bapp\\.powerbi\\.com/reportEmbed/[^\"' ]*\n# vs devops\n\\bvisualstudio.com(?::443|)/[-\\w/?=%&.]*\n# microsoft store\n\\bmicrosoft\\.com/store/apps/\\w+\n\n# mvnrepository.com\n\\bmvnrepository\\.com/[-0-9a-z./]+\n\n# now.sh\n/[0-9a-z-.]+\\.now\\.sh\\b\n\n# oracle\n\\bdocs\\.oracle\\.com/[-0-9a-zA-Z./_?#&=]*\n\n# chromatic.com\n/\\S+.chromatic.com\\S*[\")]\n\n# codacy\n\\bapi\\.codacy\\.com/project/badge/Grade/[0-9a-f]+\n\n# compai\n\\bcompai\\.pub/v1/png/[0-9a-f]+\n\n# mailgun api\n\\.api\\.mailgun\\.net/v3/domains/[0-9a-z]+\\.mailgun.org/messages/[0-9a-zA-Z=@]*\n# mailgun\n\\b[0-9a-z]+.mailgun.org\n\n# /message-id/\n/message-id/[-\\w@./%]+\n\n# Reddit\n\\breddit\\.com/r/[/\\w_]*\n\n# requestb.in\n\\brequestb\\.in/[0-9a-z]+\n\n# sched\n\\b[a-z0-9]+\\.sched\\.com\\b\n\n# Slack url\nslack://[a-zA-Z0-9?&=]+\n# Slack\n\\bslack\\.com/[-0-9a-zA-Z/_~?&=.]*\n# Slack edge\n\\bslack-edge\\.com/[-a-zA-Z0-9?&=%./]+\n# Slack images\n\\bslack-imgs\\.com/[-a-zA-Z0-9?&=%.]+\n\n# shields.io\n\\bshields\\.io/[-\\w/%?=&.:+;,]*\n\n# stackexchange -- https://stackexchange.com/feeds/sites\n\\b(?:askubuntu|serverfault|stack(?:exchange|overflow)|superuser).com/(?:questions/\\w+/[-\\w]+|a/)\n\n# Sentry\n[0-9a-f]{32}\\@o\\d+\\.ingest\\.sentry\\.io\\b\n\n# Twitter markdown\n\\[@[^[/\\]:]*?\\]\\(https://twitter.com/[^/\\s\"')]*(?:/status/\\d+(?:\\?[-_0-9a-zA-Z&=]*|)|)\\)\n# Twitter hashtag\n\\btwitter\\.com/hashtag/[\\w?_=&]*\n# Twitter status\n\\btwitter\\.com/[^/\\s\"')]*(?:/status/\\d+(?:\\?[-_0-9a-zA-Z&=]*|)|)\n# Twitter profile images\n\\btwimg\\.com/profile_images/[_\\w./]*\n# Twitter media\n\\btwimg\\.com/media/[-_\\w./?=]*\n# Twitter link shortened\n\\bt\\.co/\\w+\n\n# facebook\n\\bfburl\\.com/[0-9a-z_]+\n# facebook CDN\n\\bfbcdn\\.net/[\\w/.,]*\n# facebook watch\n\\bfb\\.watch/[0-9A-Za-z]+\n\n# dropbox\n\\bdropbox\\.com/sh?/[^/\\s\"]+/[-0-9A-Za-z_.%?=&;]+\n\n# ipfs protocol\nipfs://[0-9a-zA-Z]{3,}\n# ipfs url\n/ipfs/[0-9a-zA-Z]{3,}\n\n# w3\n\\bw3\\.org/[-0-9a-zA-Z/#.]+\n\n# loom\n\\bloom\\.com/embed/[0-9a-f]+\n\n# regex101\n\\bregex101\\.com/r/[^/\\s\"]+/\\d+\n\n# figma\n\\bfigma\\.com/file(?:/[0-9a-zA-Z]+/)+\n\n# freecodecamp.org\n\\bfreecodecamp\\.org/[-\\w/.]+\n\n# image.tmdb.org\n\\bimage\\.tmdb\\.org/[/\\w.]+\n\n# mermaid\n\\bmermaid\\.ink/img/[-\\w]+|\\bmermaid-js\\.github\\.io/mermaid-live-editor/#/edit/[-\\w]+\n\n# Wikipedia\n\\ben\\.wikipedia\\.org/wiki/[-\\w%.#]+\n\n# gitweb\n[^\"\\s]+/gitweb/\\S+;h=[0-9a-f]+\n\n# HyperKitty lists\n/archives/list/[^@/]+@[^/\\s\"]*/message/[^/\\s\"]*/\n\n# lists\n/thread\\.html/[^\"\\s]+\n\n# list-management\n\\blist-manage\\.com/subscribe(?:[?&](?:u|id)=[0-9a-f]+)+\n\n# kubectl.kubernetes.io/last-applied-configuration\n\"kubectl.kubernetes.io/last-applied-configuration\": \".*\"\n\n# pgp\n\\bgnupg\\.net/pks/lookup[?&=0-9a-zA-Z]*\n\n# Spotify\n\\bopen\\.spotify\\.com/embed/playlist/\\w+\n\n# Mastodon\n\\bmastodon\\.[-a-z.]*/(?:media/|@)[?&=0-9a-zA-Z_]*\n\n# scastie\n\\bscastie\\.scala-lang\\.org/[^/]+/\\w+\n\n# images.unsplash.com\n\\bimages\\.unsplash\\.com/(?:(?:flagged|reserve)/|)[-\\w./%?=%&.;]+\n\n# pastebin\n\\bpastebin\\.com/[\\w/]+\n\n# heroku\n\\b\\w+\\.heroku\\.com/source/archive/\\w+\n\n# quip\n\\b\\w+\\.quip\\.com/\\w+(?:(?:#|/issues/)\\w+)?\n\n# badgen.net\n\\bbadgen\\.net/badge/[^\")\\]'\\s]+\n\n# statuspage.io\n\\w+\\.statuspage\\.io\\b\n\n# media.giphy.com\n\\bmedia\\.giphy\\.com/media/[^/]+/[\\w.?&=]+\n\n# tinyurl\n\\btinyurl\\.com/\\w+\n\n# codepen\n\\bcodepen\\.io/[\\w/]+\n\n# registry.npmjs.org\n\\bregistry\\.npmjs\\.org/(?:@[^/\"']+/|)[^/\"']+/-/[-\\w@.]+\n\n# getopts\n\\bgetopts\\s+(?:\"[^\"]+\"|'[^']+')\n\n# ANSI color codes\n(?:\\\\(?:u00|x)1[Bb]|\\\\03[1-7]|\\x1b|\\\\u\\{1[Bb]\\})\\[\\d+(?:;\\d+)*m\n\n# URL escaped characters\n%[0-9A-F][A-F](?=[A-Za-z])\n# lower URL escaped characters\n%[0-9a-f][a-f](?=[a-z]{2,})\n# IPv6\n\\b(?:[0-9a-fA-F]{0,4}:){3,7}[0-9a-fA-F]{0,4}\\b\n# c99 hex digits (not the full format, just one I've seen)\n0x[0-9a-fA-F](?:\\.[0-9a-fA-F]*|)[pP]\n# Punycode\n\\bxn--[-0-9a-z]+\n# sha\nsha\\d+:[0-9a-f]*?[a-f]{3,}[0-9a-f]*\n# sha-... -- uses a fancy capture\n(\\\\?['\"]|&quot;)[0-9a-f]{40,}\\g{-1}\n# hex runs\n\\b[0-9a-fA-F]{16,}\\b\n# hex in url queries\n=[0-9a-fA-F]*?(?:[A-F]{3,}|[a-f]{3,})[0-9a-fA-F]*?&\n# ssh\n(?:ssh-\\S+|-nistp256) [-a-zA-Z=;:/0-9+]{12,}\n\n# PGP\n\\b(?:[0-9A-F]{4} ){9}[0-9A-F]{4}\\b\n# GPG keys\n\\b(?:[0-9A-F]{4} ){5}(?: [0-9A-F]{4}){5}\\b\n# Well known gpg keys\n.well-known/openpgpkey/[\\w./]+\n\n# pki\n-----BEGIN.*-----END\n\n# pki (base64)\nLS0tLS1CRUdJT.*\n\n# C# includes\n^\\s*using [^;]+;\n\n# uuid:\n\\b[0-9a-fA-F]{8}-(?:[0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}\\b\n# hex digits including css/html color classes:\n(?:[\\\\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\n\n# integrity\nintegrity=(['\"])(?:\\s*sha\\d+-[-a-zA-Z=;:/0-9+]{40,})+\\g{-1}\n\n# https://www.gnu.org/software/groff/manual/groff.html\n# man troff content\n\\\\f[BCIPR]\n# '/\"\n\\\\\\([ad]q\n\n# .desktop mime types\n^MimeTypes?=.*$\n# .desktop localized entries\n^[A-Z][a-z]+\\[[a-z]+\\]=.*$\n# Localized .desktop content\nName\\[[^\\]]+\\]=.*\n\n# IServiceProvider / isAThing\n(?:(?:\\b|_|(?<=[a-z]))I|(?:\\b|_)(?:nsI|isA))(?=(?:[A-Z][a-z]{2,})+(?:[A-Z\\d]|\\b))\n\n# crypt\n(['\"])\\$2[ayb]\\$.{56}\\g{-1}\n\n# apache/old crypt\n(['\"]|)\\$+(?:apr|)1\\$+.{8}\\$+.{22}\\g{-1}\n\n# sha1 hash\n\\{SHA\\}[-a-zA-Z=;:/0-9+]{3,}\n\n# machine learning (?)\n\\b(?i)ml(?=[a-z]{2,})\n\n# python\n#\\b(?i)py(?!gments|gmy|lon|ramid|ro|th)(?=[a-z]{2,})\n\n# scrypt / argon\n\\$(?:scrypt|argon\\d+[di]*)\\$\\S+\n\n# go.sum\n\\bh1:\\S+\n\n# imports\n^import\\s+(?:(?:static|type)\\s+|)(?:[\\w.]|\\{\\s*\\w*?(?:,\\s*(?:\\w*|\\*))+\\s*\\})+\n\n# scala modules\n(\"[^\"]+\"\\s*%%?\\s*){2,3}\"[^\"]+\"\n\n# container images\nimage: [-\\w./:@]+\n\n# Docker images\n^\\s*(?i)FROM\\s+\\S+:\\S+(?:\\s+AS\\s+\\S+|)\n\n# `docker images` REPOSITORY TAG IMAGE ID CREATED SIZE\n\\s*\\S+/\\S+\\s+\\S+\\s+[0-9a-f]{8,}\\s+\\d+\\s+(?:hour|day|week)s ago\\s+[\\d.]+[KMGT]B\n\n# Intel intrinsics\n_mm_(?!dd)\\w+\n\n# Input to GitHub JSON\ncontent: (['\"])[-a-zA-Z=;:/0-9+]*=\\g{-1}\n\n# This does not cover multiline strings, if your repository has them,\n# you'll want to remove the `(?=.*?\")` suffix.\n# The `(?=.*?\")` suffix should limit the false positives rate\n# printf\n%(?:(?:(?:hh?|ll?|[jzt])?[diuoxn]|l?[cs]|L?[fega]|p)(?=[a-z]{2,})|(?:X|L?[FEGA])(?=[a-zA-Z]{2,}))(?!%)(?=[_a-zA-Z]+(?!%)\\b)(?=.*?['\"])\n\n# Alternative printf\n# %s\n%(?:s(?=[a-z]{2,}))(?!%)(?=[_a-zA-Z]+(?!%[^s])\\b)(?=.*?['\"])\n\n# Python string prefix / binary prefix\n# 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\n(?<!['\"])\\b(?:B|BR|Br|F|FR|Fr|R|RB|RF|Rb|Rf|U|UR|Ur|b|bR|br|f|fR|fr|r|rB|rF|rb|rf|u|uR|ur)['\"](?=[A-Z]{3,}|[A-Z][a-z]{2,}|[a-z]{3,})\n\n# Regular expressions for (P|p)assword\n\\([A-Z]\\|[a-z]\\)[a-z]+\n\n# JavaScript regular expressions\n# javascript test regex\n/.{3,}/[gim]*\\.test\\(\n# javascript match regex\n\\.match\\(/[^/\\s\"]{3,}/[gim]*\\s*\n# javascript match regex\n\\.match\\(/\\\\[b].{3,}?/[gim]*\\s*\\)(?:;|$)\n# javascript regex\n^\\s*/\\\\[b].{3,}?/[gim]*\\s*(?:\\)(?:;|$)|,$)\n# javascript replace regex\n\\.replace\\(/[^/\\s\"]{3,}/[gim]*\\s*,\n# assign regex\n= /[^*].*?(?:[a-z]{3,}|[A-Z]{3,}|[A-Z][a-z]{2,}).*/[gim]*(?=\\W|$)\n# perl regex test\n[!=]~ (?:/.*/|m\\{.*?\\}|m<.*?>|m([|!/@#,;']).*?\\g{-1})\n\n# perl qr regex\n(?<!\\$)\\bqr(?:\\{.*?\\}|<.*?>|\\(.*?\\)|([|!/@#,;']).*?\\g{-1})\n\n# perl run\nperl(?:\\s+-[a-zA-Z]\\w*)+\n\n# C network byte conversions\n(?:\\d|\\bh)to(?!ken)(?=[a-z])|to(?=[adhiklpun]\\()\n\n# Go regular expressions\nregexp?\\.MustCompile\\((?:`[^`]*`|\".*\"|'.*')\\)\n\n# regex choice\n\\(\\?:[^)]+\\|[^)]+\\)\n\n# proto\n^\\s*(\\w+)\\s\\g{-1} =\n\n# sed regular expressions\nsed 's/(?:[^/]*?[a-zA-Z]{3,}[^/]*?/){2}\n\n# node packages\n([\"'])@[^/'\" ]+/[^/'\" ]+\\g{-1}\n\n# go install\ngo install(?:\\s+[a-z]+\\.[-@\\w/.]+)+\n\n# pom.xml\n<(?:group|artifact)Id>.*?<\n\n# jetbrains schema https://youtrack.jetbrains.com/issue/RSRP-489571\nurn:shemas-jetbrains-com\n\n# Debian changelog severity\n[-\\w]+ \\(.*\\) (?:\\w+|baseline|unstable|experimental); urgency=(?:low|medium|high|emergency|critical)\\b\n\n# kubernetes pod status lists\n# https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle/#pod-phase\n\\w+(?:-\\w+)+\\s+\\d+/\\d+\\s+(?:Running|Pending|Succeeded|Failed|Unknown)\\s+\n\n# kubectl - pods in CrashLoopBackOff\n\\w+-[0-9a-f]+-\\w+\\s+\\d+/\\d+\\s+CrashLoopBackOff\\s+\n\n# kubernetes applications\n\\.apps/[-\\w]+\n\n# kubernetes object suffix\n-[0-9a-f]{10}-\\w{5}\\s\n\n# kubernetes crd patterns\n^\\s*pattern: .*$\n\n# posthog secrets\n([`'\"])phc_[^\"',]+\\g{-1}\n\n# xcode\n\n# xcodeproject scenes\n(?:Controller|destination|(?:first|second)Item|ID|id)=\"\\w{3}-\\w{2}-\\w{3}\"\n\n# xcode api botches\ncustomObjectInstantitationMethod\n\n# msvc api botches\nPrependWithABINamepsace\n\n# configure flags\n.* \\| --\\w{2,}.*?(?=\\w+\\s\\w+)\n\n# font awesome classes\n\\.fa-[-a-z0-9]+\n\n# bearer auth\n(['\"])[Bb]ear[e][r] .{3,}?\\g{-1}\n\n# bearer auth\n\\b[Bb]ear[e][r]:? [-a-zA-Z=;:/0-9+.]{3,}\n\n# basic auth\n(['\"])[Bb]asic [-a-zA-Z=;:/0-9+]{3,}\\g{-1}\n\n# basic auth\n: [Bb]asic [-a-zA-Z=;:/0-9+.]{3,}\n\n# base64 encoded content\n([`'\"])[-a-zA-Z=;:/0-9+]{3,}=\\g{-1}\n# base64 encoded content in xml/sgml\n>[-a-zA-Z=;:/0-9+]{3,}=</\n# base64 encoded content, possibly wrapped in mime\n#(?:^|[\\s=;:?])[-a-zA-Z=;:/0-9+]{50,}(?:[\\s=;:?]|$)\n# base64 encoded json\n\\beyJ[-a-zA-Z=;:/0-9+]+\n# base64 encoded pkcs\n\\bMII[-a-zA-Z=;:/0-9+]+\n\n# uuencoded\n#[!\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_]{40,}\n\n# DNS rr data\n(?:\\d+\\s+){3}(?:[-+/=.\\w]{2,}\\s*){1,2}\n\n# encoded-word\n=\\?[-a-zA-Z0-9\"*%]+\\?[BQ]\\?[^?]{0,75}\\?=\n\n# numerator\n\\bnumer\\b(?=.*denom)\n\n# Time Zones\n\\b(?:Africa|Atlantic|America|Antarctica|Arctic|Asia|Australia|Europe|Indian|Pacific)(?:/[-\\w]+)+\n\n# linux kernel info\n^(?:bugs|flags|Features)\\s+:.*\n\n# systemd mode\nsystemd.*?running in system mode \\([-+].*\\)$\n\n# Lorem\n# 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 )\n# 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 -\n# Warning, while `(?i)` is very neat and fancy, if you have some binary files that aren't proper unicode, you might run into:\n# ... Operation \"substitution (s///)\" returns its argument for non-Unicode code point 0x1C19AE (the code point will vary).\n# ... You could manually change `(?i)X...` to use `[Xx]...`\n# ... or you could add the files to your `excludes` file (a version after 0.0.19 should identify the file path)\n(?:(?:\\w|\\s|[,.])*\\b(?i)(?:amet|consectetur|cursus|dolor|eros|ipsum|lacus|libero|ligula|lorem|magna|neque|nulla|suscipit|tempus)\\b(?:\\w|\\s|[,.])*)\n\n# Non-English\n# 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.\n#\n# 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.\n#\n# 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:\n# https://docs.check-spelling.dev/Feature:-Configurable-word-characters.html#unicode\n[a-zA-Z]*[ÀÁÂÃÄÅÆČÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝßàáâãäåæčçèéêëìíîïðñòóôõöøùúûüýÿĀāŁłŃńŅņŒœŚśŠšŜŝŸŽžź][a-zA-Z]{3}[a-zA-ZÀÁÂÃÄÅÆČÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝßàáâãäåæčçèéêëìíîïðñòóôõöøùúûüýÿĀāŁłŃńŅņŒœŚśŠšŜŝŸŽžź]*|[a-zA-Z]{3,}[ÀÁÂÃÄÅÆČÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝßàáâãäåæčçèéêëìíîïðñòóôõöøùúûüýÿĀāŁłŃńŅņŒœŚśŠšŜŝŸŽžź]|[ÀÁÂÃÄÅÆČÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝßàáâãäåæčçèéêëìíîïðñòóôõöøùúûüýÿĀāŁłŃńŅņŒœŚśŠšŜŝŸŽžź][a-zA-Z]{3,}\n\n# highlighted letters\n\\[[A-Z]\\][a-z]+\n\n# French\n# This corpus only had capital letters, but you probably want lowercase ones as well.\n\\b[LN]'+[a-z]{2,}\\b\n\n# latex (check-spelling >= 0.0.22)\n\\\\\\w{2,}\\{\n\n# American Mathematical Society (AMS) / Doxygen\nTeX/AMS\n\n# File extensions\n\\*\\.[+\\w]+,\n\n# eslint\n\"varsIgnorePattern\": \".+\"\n\n# nolint\nnolint:\\s*[\\w,]+\n\n# Windows short paths\n[/\\\\][^/\\\\]{5,6}~\\d{1,2}(?=[/\\\\])\n\n# Windows Resources with accelerators\n\\b[A-Z]&[a-z]+\\b(?!;)\n\n# signed off by\n(?i)Signed-off-by: .*\n\n# cygwin paths\n/cygdrive/[a-zA-Z]/(?:Program Files(?: \\(.*?\\)| ?)(?:/[-+.~\\\\/()\\w ]+)*|[-+.~\\\\/()\\w])+\n\n# in check-spelling@v0.0.22+, printf markers aren't automatically consumed\n# printf markers\n(?<!\\\\)\\\\[nrt](?=[a-z]{2,})\n# alternate printf markers if you run into latex and friends\n(?<!\\\\)\\\\[nrt](?=[a-z]{2,})(?=.*['\"`])\n\n# Markdown anchor links\n\\(#\\S*?[a-zA-Z]\\S*?\\)\n\n# apache\na2(?:en|dis)\n\n# weak e-tag\nW/\"[^\"]+\"\n\n# authors/credits\n^\\*(?: [A-Z](?:\\w+|\\.)){2,} (?=\\[|$)\n\n# the negative lookahead here is to allow catching 'templatesz' as a misspelling\n# but to otherwise recognize a Windows path with \\templates\\foo.template or similar:\n\\\\(?:necessary|r(?:elease|eport|esolve[dr]?|esult)|t(?:arget|emplates?))(?![a-z])\n# ignore long runs of a single character:\n\\b([A-Za-z])\\g{-1}{3,}\\b\n\n# version suffix <word>v#\n(?:(?<=[A-Z]{2})V|(?<=[a-z]{2}|[A-Z]{2})v)\\d+(?:\\b|(?=[a-zA-Z_]))\n\n# Compiler flags (Unix, Java/Scala)\n# Use if you have things like `-Pdocker` and want to treat them as `docker`\n#(?:^|[\\t ,>\"'`=(#])-(?:(?:J-|)[DPWXY]|[Llf])(?=[A-Z]{2,}|[A-Z][a-z]|[a-z]{2,})\n\n# Compiler flags (Windows / PowerShell)\n# This is a subset of the more general compiler flags pattern.\n# It avoids matching `-Path` to prevent it from being treated as `ath`\n#(?:^|[\\t ,\"'`=(#])-(?:[DPL](?=[A-Z]{2,})|[WXYlf](?=[A-Z]{2,}|[A-Z][a-z]|[a-z]{2,}))\n\n# Compiler flags (linker)\n,-B\n\n# libraries\n(?:\\b|_)[Ll]ib(?:re(?=office)|)(?!era[lt]|ero|erty|rar(?:i(?:an|es)|y))(?=[a-z])\n\n# WWNN/WWPN (NAA identifiers)\n\\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\n\n# iSCSI iqn (approximate regex)\n\\biqn\\.[0-9]{4}-[0-9]{2}(?:[\\.-][a-z][a-z0-9]*)*\\b\n\n# curl arguments\n\\b(?:\\\\n|)curl(?:\\.exe|)(?:\\s+-[a-zA-Z]{1,2}\\b)*(?:\\s+-[a-zA-Z]{3,})(?:\\s+-[a-zA-Z]+)*\n# set arguments\n\\b(?:bash|sh|set)(?:\\s+[-+][abefimouxE]{1,2})*\\s+[-+][abefimouxE]{3,}(?:\\s+[-+][abefimouxE]+)*\n# tar arguments\n\\b(?:\\\\n|)g?tar(?:\\.exe|)(?:(?:\\s+--[-a-zA-Z]+|\\s+-[a-zA-Z]+|\\s[ABGJMOPRSUWZacdfh-pr-xz]+\\b)(?:=[^ ]*|))+\n# tput arguments -- https://man7.org/linux/man-pages/man5/terminfo.5.html -- technically they can be more than 5 chars long...\n\\btput\\s+(?:(?:-[SV]|-T\\s*\\w+)\\s+)*\\w{3,5}\\b\n# macOS temp folders\n/var/folders/\\w\\w/[+\\w]+/(?:T|-Caches-)/\n# github runner temp folders\n/home/runner/work/_temp/[-_/a-z0-9]+\n"
  },
  {
    "path": ".github/actions/spelling/excludes.txt",
    "content": "# See https://github.com/check-spelling/check-spelling/wiki/Configuration-Examples:-excludes\n(?:^|/)(?i)COPYRIGHT\n(?:^|/)(?i)LICEN[CS]E\n(?:^|/)(?i)third[-_]?party/\n(?:^|/)3rdparty/\n(?:^|/)generated/\n(?:^|/)go\\.sum$\n(?:^|/)package(?:-lock|)\\.json$\n(?:^|/)Pipfile$\n(?:^|/)pyproject.toml\n(?:^|/)vendor/\n(?:^|/|\\b)requirements(?:-dev|-doc|-test|)\\.txt$\n\\.a$\n\\.ai$\n\\.all-contributorsrc$\n\\.avi$\n\\.bmp$\n\\.bz2$\n\\.cert?$|\\.crt$\n\\.class$\n\\.coveragerc$\n\\.crl$\n\\.csr$\n\\.dll$\n\\.docx?$\n\\.drawio$\n\\.DS_Store$\n\\.eot$\n\\.eps$\n\\.exe$\n\\.gif$\n\\.git-blame-ignore-revs$\n\\.gitattributes$\n\\.gitkeep$\n\\.graffle$\n\\.gz$\n\\.icns$\n\\.ico$\n\\.ipynb$\n\\.jar$\n\\.jks$\n\\.jpe?g$\n\\.key$\n\\.lib$\n\\.lock$\n\\.map$\n\\.min\\..\n\\.mo$\n\\.mod$\n\\.mp[34]$\n\\.o$\n\\.ocf$\n\\.otf$\n\\.p12$\n\\.parquet$\n\\.pdf$\n\\.pem$\n\\.pfx$\n\\.png$\n\\.psd$\n\\.pyc$\n\\.pylintrc$\n\\.qm$\n\\.s$\n\\.sig$\n\\.so$\n\\.svgz?$\n\\.sys$\n\\.tar$\n\\.tgz$\n\\.tiff?$\n\\.ttf$\n\\.wav$\n\\.webm$\n\\.webp$\n\\.woff2?$\n\\.xcf$\n\\.xlsx?$\n\\.xpm$\n\\.xz$\n\\.zip$\n^\\.github/actions/spelling/\n^\\Q.github/FUNDING.yml\\E$\n^\\Q.github/workflows/spelling.yml\\E$\n^data/crawlers/\n^docs/blog/tags\\.yml$\n^docs/docs/user/known-instances.md$\n^docs/manifest/.*$\n^docs/static/\\.nojekyll$\n^internal/glob/glob_test.go$\n^internal/honeypot/naive/affirmations\\.txt$\n^internal/honeypot/naive/spintext\\.txt$\n^internal/honeypot/naive/titles\\.txt$\n^lib/config/testdata/bad/unparseable\\.json$\n^lib/localization/.*_test.go$\n^lib/localization/locales/.*\\.json$\n^lib/policy/config/testdata/bad/unparseable\\.json$\n^test/.*$\nignore$\nrobots.txt\n"
  },
  {
    "path": ".github/actions/spelling/expect.txt",
    "content": "acs\nActorified\nactorifiedstore\nactorify\nagentic\nAibrew\nalibaba\nalrest\namazonbot\nanexia\nanthro\nanubis\nanubistest\napnic\nAPNICRANDNETAU\nApplebot\narchlinux\narpa\nasnc\nasnchecker\nasns\naspirational\natuin\nazuretools\nbadregexes\nbbolt\nbdba\nberr\nbezier\nbingbot\nBitcoin\nbitrate\nBluesky\nblueskybot\nboi\nBokm\nbotnet\nbotstopper\nBPort\nBrightbot\nbroked\nbuildah\nbyteslice\nBytespider\ncachebuster\ncachediptoasn\nCaddyfile\ncaninetools\nCardyb\ncelchecker\ncelphase\ncerr\ncertresolver\ncespare\nCGNAT\ncgr\nchainguard\nchall\nchallengemozilla\nchallengetest\ncheckpath\ncheckresult\nchibi\ncidranger\nckie\nCLAUDE\ncloudflare\ncloudsolutions\nCodespaces\nconfd\ncontainerbuild\ncontainerregistry\ncoreutils\nCotoyogi\nCromite\ncrt\nCscript\ndaemonizing\ndatabento\ndayjob\ndco\nDDOS\nDebian\ndebrpm\ndecaymap\ndevcontainers\nDiffbot\ndiscordapp\ndiscordbot\ndistros\ndnf\ndnsbl\ndnserr\nDNSTTL\ndomainhere\ndracula\ndronebl\ndroneblresponse\ndropin\ndsilence\nduckduckbot\neerror\nellenjoe\nemacs\nenbyware\netld\neveryones\nevilbot\nevilsite\nexpressionorlist\nexternalagent\nexternalfetcher\nextldflags\nfacebookgo\nFactset\nfahedouch\nfastcgi\nFCr\nfcrdns\nfediverse\nffprobe\nFFXIV\nfhdr\nfinancials\nfinfos\nFirecrawl\nflagenv\nFordola\nforgejo\nforwardauth\nfsys\nfullchain\ngaissmai\nGalvus\ngeoip\ngeoipchecker\ngha\nGHSA\nGhz\ngipc\ngitea\nGLM\ngodotenv\ngoimports\ngoland\ngomod\ngoodbot\ngooglebot\ngopsutil\ngovulncheck\ngoyaml\nGPG\nGPT\ngptbot\nGraphene\ngrpcprom\ngrw\ngzw\nHashcash\nhashrate\nhdr\nheadermap\nhealthcheck\nhealthz\nhec\nhelpdesk\nHetzner\nhmc\nhomelab\nhostable\nHSTS\nhtmlc\nhtmx\nhttpdebug\nhuawei\nhypertext\niaskspider\niaso\niat\nifm\nImagesift\nimgproxy\nimpressum\ninbox\ningressed\ninp\ninternets\nIPTo\niptoasn\nisp\niss\nisset\nivh\nJenomis\nJGit\njhjj\njoho\njournalctl\njshelter\nJWTs\nkagi\nkagibot\nKeyfunc\nkeypair\nKHTML\nkinda\nKUBECONFIG\nlcj\nldflags\nletsencrypt\nLexentale\nlfc\nlgbt\nlicend\nlicstart\nlightpanda\nlimsa\nLinting\nlistor\nLLU\nloadbalancer\nlol\nlominsa\nmaintainership\nmalware\nmcr\nmemes\nmetarefresh\nmetrix\nmimi\nMinfilia\nmistralai\nmnt\nMojeek\nmojeekbot\nmozilla\nmyclient\nmymaster\nmypass\nmyuser\nnbf\nNecron\nnepeat\nnetsurf\nnginx\nnicksnyder\nnikandfor\nnobots\nNONINFRINGEMENT\nnosleep\nnullglob\noci\nOCOB\nogtag\noklch\nomgili\nomgilibot\nopenai\nopendns\nopengraph\nopenrc\noswald\npag\npagegen\npalemoon\nPangu\nparseable\npassthrough\nPatreon\nperplexitybot\npgrep\nphrik\npidfile\npids\npipefail\npki\npodkova\npodman\nPostgre\npoststart\nprebaked\nprivkey\npromauto\npromhttp\nproofofwork\npublicsuffix\npurejs\npwcmd\npwuser\nqualys\nqwant\nqwantbot\nrac\nrawler\nrcvar\nredhat\nredir\nredirectscheme\nrefactors\nremoteip\nreputational\nRhul\nrisc\nruleset\nrunlevels\nRUnlock\nruntimedir\nruntimedirectory\nRyzen\nsas\nsasl\nscreenshots\nsearchbot\nsearx\nsebest\nsecretplans\nSemrush\nSeo\nsetsebool\nshellcheck\nshirou\nshoneypot\nshopt\nSidetrade\nsimprint\nsitemap\nsls\nsni\nsnipster\nSpambot\nspammer\nsparkline\nspyderbot\nsrcip\nsrv\nstackoverflow\nStargate\nstartprecmd\nstoppostcmd\nstoretest\nstrcmp\nsubgrid\nsubr\nsubrequest\nSVCNAME\ntagline\ntarballs\ntarrif\ntaviso\ntbn\ntbr\ntecharo\ntecharohq\ntelegrambot\ntempl\ntemplruntime\ntestarea\nThancred\nthoth\nthothmock\nTik\nTimpibot\nTLog\ntraefik\ntrunc\ntxn\nuberspace\nUnbreak\nunbreakdocker\nunifiedjs\nunmarshal\nunparseable\nupdown\nuvx\nUXP\nvalkey\nVaris\nVelen\nvendored\nvhosts\nvkbot\nVKE\nvnd\nVPS\nVultr\nWAIFU\nweblate\nwebmaster\nwebpage\nwebsecure\nwebsites\nWebzio\nwhois\nwildbase\nwiththothmock\nwolfbeast\nwordpress\nworkaround\nworkdir\nwpbot\nXCircle\nxeiaso\nxeserv\nxesite\nxess\nxff\nXForwarded\nXNG\nXOB\nXOriginal\nXReal\nY'shtola\nyae\nYAMLTo\nYda\nyeet\nyeetfile\nyourdomain\nyyz\nZenos\nzizmor\nzombocom\nzos\nzst\n"
  },
  {
    "path": ".github/actions/spelling/line_forbidden.patterns",
    "content": "# reject `m_data` as VxWorks defined it and that breaks things if it's used elsewhere\n# see [fprime](https://github.com/nasa/fprime/commit/d589f0a25c59ea9a800d851ea84c2f5df02fb529)\n# and [Qt](https://github.com/qtproject/qt-solutions/blame/fb7bc42bfcc578ff3fa3b9ca21a41e96eb37c1c7/qtscriptclassic/src/qscriptbuffer_p.h#L46)\n#\\bm_data\\b\n\n# Were you debugging using a framework with `fit()`?\n# If you have a framework that uses `it()` for testing and `fit()` for debugging a specific test,\n# you might not want to check in code where you skip all the other tests.\n#\\bfit\\(\n\n# English does not use a hyphen between adverbs and nouns\n# https://twitter.com/nyttypos/status/1894815686192685239\n(?:^|\\s)[A-Z]?[a-z]+ly-(?=[a-z]{3,})(?:[.,?!]?\\s|$)\n\n# Don't use `requires that` + `to be`\n# https://twitter.com/nyttypos/status/1894816551435641027\n\\brequires that \\w+\\b[^.]+to be\\b\n\n# A fully parenthetical sentence’s period goes inside the parentheses, not outside.\n# https://twitter.com/nyttypos/status/1898844061873639490\n#\\([A-Z][a-z]{2,}(?: [a-z]+){3,}\\)\\.\\s\n\n# Complete sentences in parentheticals should not have a space before the period.\n\\s\\.\\)(?!.*\\}\\})\n\n# Should be `HH:MM:SS`\n\\bHH:SS:MM\\b\n\n# Should be `86400` (seconds in a standard day)\n\\b84600\\b(?:.*\\bday\\b)\n\n# Should probably be `2006-01-02` (yyyy-mm-dd)\n# Assuming that the time is being passed to https://go.dev/src/time/format.go\n\\b2006-02-01\\b\n\n# Should probably be `YYYYMMDD`\n\\b[Yy]{4}[Dd]{2}[Mm]{2}(?!.*[Yy]{4}[Dd]{2}[Mm]{2}).*$\n\n# Should be `a priori` or `and prior`\n(?i)(?<!posteriori)\\sand priori\\s\n\n# Should be `a`\n\\san (?=(?:[b-df-gj-np-rtv-xz]|h(?!our|tml|ttp)|s(?!sh|vg))[a-z])\n\n# Should only be one of `a`, `an`, or `the`\n\\b(?:(?:an?|the)\\s+){2,}\\b\n\n# Should only be `are` or `can`, not both\n\\b(?:(?:are|can)\\s+){2,}\\b\n\n# Should probably be `ABCDEFGHIJKLMNOPQRSTUVWXYZ`\n(?i)(?!ABCDEFGHIJKLMNOPQRSTUVWXYZ)ABC[A-Z]{21}YZ\n\n# Should be `anymore`\n\\bany more[,.]\n\n# Should be `Ask`\n(?:^|[.?]\\s+)As\\s+[A-Z][a-z]{2,}\\s[^.?]*?(?:how|if|wh\\w+)\\b\n\n# Should be `at one fell swoop`\n# and only when talking about killing, not some other completion\n# Act 4 Scene 3, Macbeth\n# https://www.opensourceshakespeare.org/views/plays/play_view.php?WorkID=macbeth&Act=4&Scene=3&Scope=scene\n\\bin one fell s[lw]?oop\\b\n\n# Should be `'`\n(?i)\\b(?:(?:i|s?he|they|what|who|you)[`\"]ll|(?:are|ca|did|do|does|ha[ds]|have|is|should|were|wo|would)n[`\"]t|(?:s?he|let|that|there|what|where|who)[`\"]s|(?:i|they|we|what|who|you)[`\"]ve)\\b\n\n# Should be `background` / `intro text` / `introduction` / `prologue` unless it's a brand or relates to _subterfuge_\n(?i)\\bpretext\\b\n\n# Should be `branches`\n# ... unless it's really about the meal that replaces breakfast and lunch.\n\\b[Bb]runches\\b\n\n# Should be `briefcase`\n\\bbrief-case\\b\n\n# Should be `by far` or `far and away`\n\\bby far and away\\b\n\n# Should be `can, not only ..., ... also...`\n\\bcan not only.*can also\\b\n\n# Should be `cannot` (or `can't`)\n# See https://www.grammarly.com/blog/cannot-or-can-not/\n# > 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`.\n# > `Can't` is a contraction of `cannot`, and it's best suited for informal writing.\n# > In formal writing and where contractions are frowned upon, use `cannot`.\n# > 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.`\n# - if you encounter such a case, add a pattern for that case to patterns.txt.\n\\b[Cc]an not\\b(?! only\\b)\n\n# Should be `chart`\n(?i)\\bhelm\\b.*\\bchard\\b\n\n# Do not use `(click) here` links\n# For more information, see:\n# * https://www.w3.org/QA/Tips/noClickHere\n# * https://webaim.org/techniques/hypertext/link_text\n# * https://granicus.com/blog/why-click-here-links-are-bad/\n# * https://heyoka.medium.com/dont-use-click-here-f32f445d1021\n(?i)(?:>|\\[)(?:(?:click |)here|link|(?:read |)more)(?:</|\\]\\()\n\n# Including \"image of\" or \"picture of\" in alt text is unnecessary.\n\\balt=['\"](?:an? |)(?:image|picture) of\n\n# Alt text should be short\n\\balt=(?:'[^']{126,}'|\"[^\"]{126,}\")\n\n# Should be `equals` to `is equal to`\n\\bequals to\\b\n\n# Should be `ECMA` 262 (JavaScript)\n(?i)\\bTS\\/EMCA\\b|\\bEMCA(?: \\d|\\s*Script)|\\bEMCA\\b(?=.*\\bTS\\b)\n\n# Should be `ECMA` 340 (Near Field Communications)\n(?i)EMCA[- ]340\n\n# Should be `fall back`\n\\bfallback(?= to)\\b\n\n# Should be `GitHub`\n(?<![&*.]|// |\\b(?:from|import|type) )\\bGithub\\b(?![{()])\n\n# Should be `GitLab`\n(?<![&*.]|// |\\b(?:from|import|type) )\\bGitlab\\b(?![{()])\n\n# Should probably be `https://`...\n# Markdown generally doesn't assume that links are to urls\n\\]\\(www\\.\\w\n\n# Should be `JavaScript`\n\\bJavascript\\b\n\n# Should be `macOS` or `Mac OS X` or ...\n\\bMacOS\\b\n\n# Should be `Microsoft`\n\\bMicroSoft\\b\n\n# Should be `OAuth`\n(?:^|[^-/*$])[ '\"]oAuth(?: [a-z]|\\d+ |[^ a-zA-Z0-9:;_.()])\n\n# Should be `RabbitMQ`\n\\bRabbitmq\\b\n\n# Should be `TensorFlow`\n\\bTensorflow\\b\n\n# Should be `TypeScript`\n\\bTypescript\\b\n\n# Should be `another`\n\\ban[- ]other(?!-)\\b\n\n# Should be `case-(in)sensitive`\n\\bcase (?:in|)sensitive\\b\n\n# Should be `coinciding`\n\\bco-inciding\\b\n\n# Should be `deprecation warning(s)`\n\\b[Dd]epreciation [Ww]arnings?\\b\n\n# Should be `greater than`\n\\bgreater then\\b\n\n# Should be `has`\n\\b[Ii]t only have\\b\n\n# Should be `here-in`, `the`, `them`, `this`, `these` or reworded in some other way\n\\bthe here(?:\\.|,| (?!and|defined))\n\n# Should be `greater than`\n\\bhigher than\\b\n\n# Should be `ID` (unless it's a flag/property)\n(?<![-\\.])\\bId\\b(?![(])\n\n# Should be `in front of`\n\\bin from of\\b\n\n# Should be `into`\n# when not phrasal and when `in order to` would be wrong:\n# https://thewritepractice.com/into-vs-in-to/\n\\sin to\\s(?!if\\b)\n\n# Should be `use`\n\\sin used by\\b\n\n# Should be `in-depth` if used as an adjective (but `in depth` when used as an adverb)\n\\bin depth\\s(?!rather\\b)\\w{6,}\n\n# Should be `in-flight` or `on the fly` (unless actually talking about airline flights)\n\\bon[- ]flight\\b(?!=\\s+(?:(?:\\w{2}|)\\d+|availability|booking|computer|data|delay|departure|management|performance|radar|reservation|scheduling|software|status|ticket|time|type|.*(?:hotel|taxi)))\n\n# Should be `is obsolete`\n\\bis obsolescent\\b\n\n# Should be `it's` or `its`\n\\bits['’]\n\n# Should be `its`\n\\bit's(?= own\\b)\n\n# Should be `its`\n\\bit's(?= only purpose\\b)\n\n# Should be `for its` (possessive) or `because it is`\n\\bfor it(?:'s| is)\\b\n\n# Should be `log in`\n\\blogin to the\n\n# Should be `long-standing`\n\\blong standing\\b\n\n# `apt-key` is deprecated\n# ... instead you should be writing a pair of files:\n# ... * the gpg key added to a distinct key ring file based on your project/distro/key...\n# ... * the sources.list in a district file -- not simply appended to `/etc/apt/sources.list` -- (there is a newer format [DEB822](https://manpages.debian.org/bookworm/dpkg-dev/deb822.5.en.html)) that references the gpg key.\n# Consider:\n# ````sh\n# curl http://download.something.example.com/$DISTRO/Release.key | \\\n#     gpg --dearmor --yes --output /usr/share/keyrings/something-distro.gpg\n# echo \"deb [signed-by=/usr/share/keyrings/something-distro.gpg] http://download.something.example.com/repositories/home:/$DISTRO ./\" \\\n#     >> /etc/apt/sources.list.d/something-distro.list\n# ````\n\\bapt-key add\\b\n\n# Should be `nearby`\n\\bnear by\\b\n\n# Should probably be a person named `Nick` or the abbreviation `NIC`\n\\bNic\\b\n\n# Should be `not supposed`\n\\bsupposed not\\b\n\n# Should probably be `much more`\n\\bmore much\\b\n\n# Should be `perform its`\n\\bperform it's\\b\n\n# Should be `opt-in`\n(?<!\\scan|for)(?<!\\smust)(?<!\\sif)\\sopt in\\s\n\n# Should be `less than`\n\\bless then\\b\n\n# Should be `load balancer`\n\\b[Ll]oud balancer\n\n# Should be `moot`\n\\bmute point\\b\n\n# Should be `one of`\n(?<!-)\\bon of\\b\n\n# Should be `on the other hand`\n\\b(?i)on another hand\\b\n\n# Reword to `on at runtime` or `enabled at launch`\n# The former if you mean it can be changed dynamically.\n# The latter if you mean that it can be changed without recompiling but not after the program starts.\n\\bswitched on runtime\\b\n\n# Should be `Of course,`\n[?.!]\\s+Of course\\s(?=[-\\w\\s]+[.?;!,])\n\n# Most people only have two hands. Reword.\n\\b(?i)on the third hand\\b\n\n# Should be `OpenShift`\n\\bOpenshift\\b\n\n# Should be `otherwise`\n\\bother[- ]wise\\b\n\n# Should be `; otherwise` or `. Otherwise`\n# https://study.com/learn/lesson/otherwise-in-a-sentence.html\n, [Oo]therwise\\b\n\n# Should probably be `Otherwise,`\n(?<=\\. )Otherwise\\s\n\n# Should be `or (more|less)`\n\\bore (?:more|less)\\b\n\n# Should be `rather than`\n\\brather then\\b\n\n# Should be `Red Hat`\n\\bRed[Hh]at\\b\n\n# Should be `regardless, ...` or `regardless of (whether)`\n\\b[Rr]egardless if you\\b\n\n# Should be `self-signed`\n\\bself signed\\b\n\n# Should be `SendGrid`\n\\bSendgrid\\b\n\n# Should be `set up` (`setup` is a noun / `set up` is a verb)\n\\b[Ss]etup(?= (?:an?|the)\\b)\n\n# Should be `state`\n\\bsate(?=\\b|[A-Z])|(?<=[a-z])Sate(?=\\b|[A-Z])|(?<=[A-Z]{2})Sate(?=\\b|[A-Z])\n\n# Should be `no longer needed`\n\\bno more needed\\b(?! than\\b)\n\n# Should be `<see|look> below for the`\n(?i)\\bfind below the\\b\n\n# Should be `then any` unless there's a comparison before the `,`\n, than any\\b\n\n# Should be `did not exist`\n\\bwere not existent\\b\n\n# Should be `nonexistent`\n\\bnon existing\\b\n\n# Should be `nonexistent`\n\\b[Nn]o[nt][- ]existent\\b\n\n# Should be `our`\n\\bspending out time\\b\n\n# Should be `@brief` / `@details` / `@param` / `@return` / `@retval`\n(?:^\\s*|(?:\\*|//|/*)\\s+`)[\\\\@](?:breif|(?:detail|detials)|(?:params(?!\\.)|prama?)|ret(?:uns?)|retvl)\\b\n\n# Should be `more than` or `more, then`\n\\bmore then\\b\n\n# Should be `Pipeline`/`pipeline`\n(?:(?<=\\b|[A-Z])p|P)ipeLine(?:\\b|(?=[A-Z]))\n\n# Should be `preexisting`\n[Pp]re[- ]existing\n\n# Should be `preempt`\n[Pp]re[- ]empt\\b\n\n# Should be `preemptively`\n[Pp]re[- ]emptively\n\n# Should be `prepopulate`\n[Pp]re[- ]populate\n\n# Should be `prerequisite`\n[Pp]re[- ]requisite\n\n# Should be `recently changed` or `recent changes`\n[Rr]ecent changed\n\n# Should be `reentrancy`\n[Rr]e[- ]entrancy\n\n# Should be `reentrant`\n[Rr]e[- ]entrant\n\n# Should be `room for`\n\\brooms for (?!lease|rent|sale)\n\n# Should be `socioeconomic`\n# https://dictionary.cambridge.org/us/dictionary/english/socioeconomic\nsocio-economic\n\n# Should be `strong suit`\n\\b(?:my|his|her|their) strong suite\\b\n\n# Should probably be `temperatures` unless actually talking about thermal drafts (things birds may fly on)\n\\bthermals\\b\n\n# Should be `there are` or `they are` (or `they're`)\n(?i)\\btheir are\\b\n\n# Should be `understand`\n\\bunder stand\\b\n\n# Should be `URI` or `uri` unless it refers to a person named `Uri` (or a flag)\n(?<![-\\.])\\bUri\\b(?![(])\n\n# Should be `it uses is`\n/\\bis uses is\\b/\n\n# Should be `uses it as`\n(?:^|\\. |and )uses is as (?!an?\\b|follows|livestock|[^.]+\\s+as\\b)\n\n# Should be `was`\n\\bhas been(?= removed in v?\\d)\n\n# Should be `where`\n\\bwere they are\\b\n\n# Should be `why`\n, way(?= is [^.]*\\?)\n\n# should be `vCenter`\n\\bV[Cc]enter\\b\n\n# Should be `VM`\n\\bVm\\b\n\n# Should be `walkthrough(s)`\n\\bwalk-throughs?\\b\n\n# Should be `we'll`\n\\bwe 'll\\b\n\n# Should be `whereas`\n\\bwhere as\\b\n\n# Should be `WinGet`\n\\bWinget\\b\n\n# Should be `without` (unless `out` is a modifier of the next word)\n\\bwith out\\b(?!-)\n\n# Should be `work around`\n\\b[Ww]orkaround(?= an?\\b)\n\n# Should be `workarounds`\n\\bwork[- ]arounds\\b\n\n# Should be `workaround`\n(?:(?:[Aa]|[Tt]he|ugly)\\swork[- ]around\\b|\\swork[- ]around\\s+for)\n\n# Should be `worst`\n(?i)worse-case\n\n# Should be `you are not` or reworded\n\\byour not\\b\n\n# Should be `(coarse|fine)-grained`\n\\b(?:coarse|fine) grained\\b\n\n# Homoglyph (Cyrillic) should be `A`/`B`/`C`/`E`/`H`/`I`/`I`/`J`/`K`/`M`/`O`/`P`/`S`/`T`/`Y`\n# It's possible that your content is intentionally mixing Cyrillic and Latin scripts, but if it isn't, you definitely want to correct this.\n(?<=[A-Z]{2})[АВСЕНІӀЈКМОРЅТУ]|[АВСЕНІӀЈКМОРЅТУ](?=[A-Z]+(?:\\b|[a-z]+)|[a-z]+(?:[^a-z]|$))\n\n# Homoglyph (Cyrillic) should be `a`/`b`/`c`/`e`/`o`/`p`/`x`/`y`\n# It's possible that your content is intentionally mixing Cyrillic and Latin scripts, but if it isn't, you definitely want to correct this.\n[авсеорху](?=[A-Za-z]{2,})|(?<=[A-Za-z]{2})[авсеорху]|(?<=[A-Za-z])[авсеорху](?=[A-Za-z])\n\n# Should be `neither/nor` -- or reword\n(?!<do )\\bnot\\b([^.?!\"/(](?!neither|,.*?,))+\\bnor\\b\n\n# Should be `neither/nor` (plus rewording the beginning)\n# This is probably a double negative...\n\\bnot\\b[^.?!\"/(]*\\bneither\\b[^.?!\"/(]*\\bnor\\b\n\n# In English, duplicated words are generally mistakes\n# There are a few exceptions (e.g. \"that that\").\n# If the highlighted doubled word pair is in:\n# * code, write a pattern to mask it.\n# * prose, have someone read the English before you dismiss this error.\n\\s([A-Z]{3,}|[A-Z][a-z]{2,}|[a-z]{3,})\\s\\g{-1}\\s\n"
  },
  {
    "path": ".github/actions/spelling/patterns.txt",
    "content": "# See https://github.com/check-spelling/check-spelling/wiki/Configuration-Examples:-patterns\n\n# Automatically suggested patterns\n\n# hit-count: 198 file-count: 52\n# https/http/file urls\n(?:\\b(?:https?|ftp|file)://)[-A-Za-z0-9+&@#/*%?=~_|!:,.;]+[-A-Za-z0-9+&@#/*%=~_|]\n\n# hit-count: 22 file-count: 8\n# GitHub actions\n\\buses:\\s+[-\\w.]+/[-\\w./]+@[-\\w.]+\n\n# hit-count: 19 file-count: 5\n# libraries\n(?:\\b|_)[Ll]ib(?:re(?=office)|era(?![lt])|)(?!ero|erty|rar(?:i(?:an|es)|y))(?=[a-z])\n\n# hit-count: 17 file-count: 8\n# version suffix <word>v#\n(?:(?<=[A-Z]{2})V|(?<=[a-z]{2}|[A-Z]{2})v)\\d+(?:\\b|(?=[a-zA-Z_]))\n\n# hit-count: 15 file-count: 7\n# container images\nimage: [-\\w./:@]+\n\n# hit-count: 14 file-count: 9\n# imports\n^import\\s+(?:(?:static|type)\\s+|)(?:[\\w.]|\\{\\s*\\w*?(?:,\\s*(?:\\w*|\\*))+\\s*\\})+\n\n# hit-count: 11 file-count: 2\n# hex digits including css/html color classes:\n(?:[\\\\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\n\n# hit-count: 8 file-count: 5\n# node packages\n([\"'])@[^/'\" ]+/[^/'\" ]+\\g{-1}\n\n# hit-count: 5 file-count: 2\n# css fonts\n\\bfont(?:-family|):[^;}]+\n\n# hit-count: 4 file-count: 4\n# set arguments\n\\b(?:bash|sh|set)(?:\\s+[-+][abefimouxE]{1,2})*\\s+[-+][abefimouxE]{3,}(?:\\s+[-+][abefimouxE]+)*\n\n# hit-count: 4 file-count: 2\n# css url wrappings\n\\burl\\([^)]+\\)\n\n# hit-count: 2 file-count: 2\n# C network byte conversions\n(?:\\d|\\bh)to(?!ken)(?=[a-z])|to(?=[adhiklpun]\\()\n\n# hit-count: 2 file-count: 1\n# GitHub SHA refs\n\\[([0-9a-f]+)\\]\\(https://(?:www\\.|)github.com/[-\\w]+/[-\\w]+/commit/\\g{-1}[0-9a-f]*\n\n# hit-count: 1 file-count: 1\n# copyright\nCopyright (?:\\([Cc]\\)|)(?:[-\\d, ]|and)+(?: [A-Z][a-z]+ [A-Z][a-z]+,?)+\n\n# hit-count: 1 file-count: 1\n# IPv6\n\\b(?:[0-9a-fA-F]{0,4}:){3,7}[0-9a-fA-F]{0,4}\\b\n\n# hit-count: 1 file-count: 1\n# Docker images\n^\\s*(?i)FROM\\s+\\S+:\\S+(?:\\s+AS\\s+\\S+|)\n\n# hit-count: 1 file-count: 1\n# perl run\nperl(?:\\s+-[a-zA-Z]\\w*)+\n\n# hit-count: 1 file-count: 1\n# go install\ngo install(?:\\s+[a-z]+\\.[-@\\w/.]+)+\n\n# hit-count: 1 file-count: 1\n# in check-spelling@v0.0.22+, printf markers aren't automatically consumed\n# printf markers\n(?<!\\\\)\\\\[nrt](?=[a-z]{2,})\n\n# hit-count: 1 file-count: 1\n# tar arguments\n\\b(?:\\\\n|)g?tar(?:\\.exe|)(?:(?:\\s+--[-a-zA-Z]+|\\s+-[a-zA-Z]+|\\s[ABGJMOPRSUWZacdfh-pr-xz]+\\b)(?:=[^ ]*|))+\n\n# Questionably acceptable forms of `in to`\n# Personally, I prefer `log into`, but people object\n# https://www.tprteaching.com/log-into-log-in-to-login/\n\\b(?:(?:[Ll]og(?:g(?=[a-z])|)|[Ss]ign)(?:ed|ing)?) in to\\b\n\n# to opt in\n\\bto opt in\\b\n\n# pass(ed|ing) in\n\\bpass(?:ed|ing) in\\b\n\n# acceptable duplicates\n# ls directory listings\n[-bcdlpsw](?:[-r][-w][-SsTtx]){3}[\\.+*]?\\s+\\d+\\s+\\S+\\s+\\S+\\s+[.\\d]+(?:[KMGT]|)\\s+\n# mount\n\\bmount\\s+-t\\s+(\\w+)\\s+\\g{-1}\\b\n# C types and repeated CSS values\n\\s(auto|buffalo|center|div|inherit|long|LONG|none|normal|solid|thin|transparent|very)(?: \\g{-1})+\\s\n# C enum and struct\n\\b(?:enum|struct)\\s+(\\w+)\\s+\\g{-1}\\b\n# go templates\n\\s(\\w+)\\s+\\g{-1}\\s+\\`(?:graphql|inject|json|yaml):\n# doxygen / javadoc / .net\n(?:[\\\\@](?:brief|defgroup|groupname|link|t?param|return|retval)|(?:public|private|\\[Parameter(?:\\(.+\\)|)\\])(?:\\s+(?:static|override|readonly|required|virtual))*)(?:\\s+\\{\\w+\\}|)\\s+(\\w+)\\s+\\g{-1}\\s\n\n# macOS file path\n(?:Contents\\W+|(?!iOS)/)MacOS\\b\n\n# Python package registry has incorrect spelling for macOS / Mac OS X\n\"Operating System :: MacOS :: MacOS X\"\n\n# \"company\" in Germany\n\\bGmbH\\b\n\n# IntelliJ\n\\bIntelliJ\\b\n\n# Commit message -- Signed-off-by and friends\n^\\s*(?:(?:Based-on-patch|Co-authored|Helped|Mentored|Reported|Reviewed|Signed-off)-by|Thanks-to): (?:[^<]*<[^>]*>|[^<]*)\\s*$\n\n# Autogenerated revert commit message\n^This reverts commit [0-9a-f]{40}\\.$\n\n# ignore long runs of a single character:\n\\b([A-Za-z])\\g{-1}{3,}\\b\n\n# hit-count: 1 file-count: 1\n# microsoft\n\\b(?:https?://|)(?:(?:(?:blogs|download\\.visualstudio|docs|msdn2?|research)\\.|)microsoft|blogs\\.msdn)\\.co(?:m|\\.\\w\\w)/[-_a-zA-Z0-9()=./%]*\n\n# hit-count: 1 file-count: 1\n# data url\n\\bdata:[-a-zA-Z=;:/0-9+]*,\\S*"
  },
  {
    "path": ".github/actions/spelling/reject.txt",
    "content": "^attache$\n^bellows?$\nbenefitting\noccurences?\n^dependan.*\n^develope$\n^developement$\n^developpe\n^Devers?$\n^devex\n^devide\n^Devinn?[ae]\n^devisal\n^devisor\n^diables?$\n^oer$\nSorce\n^[Ss]pae.*\n^Teh$\n^untill$\n^untilling$\n^venders?$\n^wether.*\n"
  },
  {
    "path": ".github/dependabot.yml",
    "content": "version: 2\nupdates:\n  - package-ecosystem: github-actions\n    directory: /\n    schedule:\n      interval: weekly\n    groups:\n      github-actions:\n        patterns:\n          - \"*\"\n    cooldown:\n      default-days: 7\n\n  - package-ecosystem: gomod\n    directory: /\n    schedule:\n      interval: weekly\n    groups:\n      gomod:\n        patterns:\n          - \"*\"\n    cooldown:\n      default-days: 7\n\n  - package-ecosystem: npm\n    directory: /\n    schedule:\n      interval: weekly\n    groups:\n      npm:\n        patterns:\n          - \"*\"\n    cooldown:\n      default-days: 7\n"
  },
  {
    "path": ".github/workflows/asset-verification.yml",
    "content": "name: Asset Build Verification\n\non:\n  push:\n    branches: [\"main\"]\n  pull_request:\n    branches: [\"main\"]\n\npermissions:\n  contents: read\n\njobs:\n  asset_verification:\n    runs-on: ubuntu-24.04\n    steps:\n      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n        with:\n          persist-credentials: false\n\n      - name: build essential\n        run: |\n          sudo apt-get update\n          sudo apt-get install -y build-essential\n\n      - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0\n        with:\n          node-version: \"24.11.0\"\n      - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0\n        with:\n          go-version: \"1.25.7\"\n\n      - name: install node deps\n        run: |\n          npm ci\n\n      - name: Check for uncommitted changes before asset build\n        id: check-changes-before\n        run: |\n          if [[ -n $(git status --porcelain) ]]; then\n            echo \"has_changes=true\" >> $GITHUB_OUTPUT\n          else\n            echo \"has_changes=false\" >> $GITHUB_OUTPUT\n          fi\n\n      - name: Fail if there are uncommitted changes before build\n        if: steps.check-changes-before.outputs.has_changes == 'true'\n        run: |\n          echo \"There are uncommitted changes before running npm run assets\"\n          git status\n          exit 1\n\n      - name: Run asset build\n        run: |\n          npm run assets\n\n      - name: Check for uncommitted changes after asset build\n        id: check-changes-after\n        run: |\n          if [[ -n $(git status --porcelain) ]]; then\n            echo \"has_changes=true\" >> $GITHUB_OUTPUT\n          else\n            echo \"has_changes=false\" >> $GITHUB_OUTPUT\n          fi\n\n      - name: Fail if assets generated changes\n        if: steps.check-changes-after.outputs.has_changes == 'true'\n        run: |\n          echo \"npm run assets generated uncommitted changes. This indicates the repository has outdated generated files.\"\n          echo \"Please run 'npm run assets' locally and commit the changes.\"\n          git status\n          git diff\n          exit 1\n"
  },
  {
    "path": ".github/workflows/dco-check.yaml",
    "content": "name: DCO Check\n\non: [pull_request]\n\njobs:\n  dco_check:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: tisonkun/actions-dco@f1024cd563550b5632e754df11b7d30b73be54a5 # v1.1\n"
  },
  {
    "path": ".github/workflows/docker-pr.yml",
    "content": "name: Docker image builds (pull requests)\n\non:\n  pull_request:\n    branches: [\"main\"]\n\nenv:\n  DOCKER_METADATA_SET_OUTPUT_ENV: \"true\"\n\npermissions:\n  contents: read\n\njobs:\n  build:\n    runs-on: ubuntu-24.04\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n        with:\n          fetch-tags: true\n          fetch-depth: 0\n          persist-credentials: false\n\n      - name: build essential\n        run: |\n          sudo apt-get update\n          sudo apt-get install -y build-essential\n\n      - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0\n        with:\n          node-version: \"24.11.0\"\n      - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0\n        with:\n          go-version: \"stable\"\n\n      - uses: ko-build/setup-ko@d006021bd0c28d1ce33a07e7943d48b079944c8d # v0.9\n\n      - name: Docker meta\n        id: meta\n        uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0\n        with:\n          images: ghcr.io/${{ github.repository }}\n\n      - name: Build and push\n        id: build\n        run: |\n          npm ci\n          npm run container\n        env:\n          PULL_REQUEST_ID: ${{ github.event.number }}\n          DOCKER_REPO: ghcr.io/${{ github.repository }}\n          SLOG_LEVEL: debug\n\n      - run: |\n          echo \"Test this with:\"\n          echo \"docker pull ${DOCKER_IMAGE}\"\n        env:\n          DOCKER_IMAGE: ${{ steps.build.outputs.docker_image }}\n"
  },
  {
    "path": ".github/workflows/docker.yml",
    "content": "name: Docker image builds\n\non:\n  workflow_dispatch:\n  push:\n    branches: [\"main\"]\n    tags: [\"v*\"]\n\nenv:\n  DOCKER_METADATA_SET_OUTPUT_ENV: \"true\"\n\npermissions:\n  contents: read\n  packages: write\n  attestations: write\n  id-token: write\n  pull-requests: write\n\njobs:\n  build:\n    runs-on: ubuntu-24.04\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n        with:\n          fetch-tags: true\n          fetch-depth: 0\n          persist-credentials: false\n\n      - name: build essential\n        run: |\n          sudo apt-get update\n          sudo apt-get install -y build-essential\n\n      - name: Set lowercase image name\n        run: |\n          echo \"IMAGE=ghcr.io/${GITHUB_REPOSITORY,,}\" >> $GITHUB_ENV\n\n      - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0\n        with:\n          node-version: \"24.11.0\"\n      - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0\n        with:\n          go-version: \"stable\"\n\n      - uses: ko-build/setup-ko@d006021bd0c28d1ce33a07e7943d48b079944c8d # v0.9\n\n      - name: Log into registry\n        uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0\n        with:\n          registry: ghcr.io\n          username: ${{ github.repository_owner }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Docker meta\n        id: meta\n        uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0\n        with:\n          images: ${{ env.IMAGE }}\n\n      - name: Build and push\n        id: build\n        run: |\n          npm ci\n          npm run container\n        env:\n          DOCKER_REPO: ${{ env.IMAGE }}\n          SLOG_LEVEL: debug\n\n      - name: Generate artifact attestation\n        uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0\n        with:\n          subject-name: ${{ env.IMAGE }}\n          subject-digest: ${{ steps.build.outputs.digest }}\n          push-to-registry: true\n"
  },
  {
    "path": ".github/workflows/docs-deploy.yml",
    "content": "name: Docs deploy\n\non:\n  workflow_dispatch:\n  push:\n    branches: [\"main\"]\n\npermissions:\n  contents: read\n  packages: write\n  attestations: write\n  id-token: write\n\njobs:\n  build:\n    if: github.repository == 'TecharoHQ/anubis'\n    runs-on: ubuntu-24.04\n\n    steps:\n      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n        with:\n          persist-credentials: false\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0\n\n      - name: Log into registry\n        uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0\n        with:\n          registry: ghcr.io\n          username: techarohq\n          password: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Docker meta\n        id: meta\n        uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0\n        with:\n          images: ghcr.io/techarohq/anubis/docs\n          tags: |\n            type=sha,enable=true,priority=100,prefix=,suffix=,format=long\n            main\n\n      - name: Build and push\n        id: build\n        uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0\n        with:\n          context: ./docs\n          cache-to: type=gha\n          cache-from: type=gha\n          tags: ${{ steps.meta.outputs.tags }}\n          labels: ${{ steps.meta.outputs.labels }}\n          platforms: linux/amd64\n          push: true\n\n      - name: Apply k8s manifests to limsa lominsa\n        uses: actions-hub/kubectl@5ada4e2c02eacc03978c2437e95c8b0f979a9619 # v1.35.2\n        env:\n          KUBE_CONFIG: ${{ secrets.LIMSA_LOMINSA_KUBECONFIG }}\n        with:\n          args: apply -k docs/manifest\n\n      - name: Apply k8s manifests to limsa lominsa\n        uses: actions-hub/kubectl@5ada4e2c02eacc03978c2437e95c8b0f979a9619 # v1.35.2\n        env:\n          KUBE_CONFIG: ${{ secrets.LIMSA_LOMINSA_KUBECONFIG }}\n        with:\n          args: rollout restart -n default deploy/anubis-docs\n"
  },
  {
    "path": ".github/workflows/docs-test.yml",
    "content": "name: Docs test build\n\non:\n  pull_request:\n    branches: [\"main\"]\n\npermissions:\n  contents: read\n  actions: write\n\njobs:\n  build:\n    runs-on: ubuntu-24.04\n\n    steps:\n      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n        with:\n          persist-credentials: false\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0\n\n      - name: Docker meta\n        id: meta\n        uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0\n        with:\n          images: ghcr.io/techarohq/anubis/docs\n          tags: |\n            type=sha,enable=true,priority=100,prefix=,suffix=,format=long\n            main\n\n      - name: Build and push\n        id: build\n        uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0\n        with:\n          context: ./docs\n          cache-to: type=gha\n          cache-from: type=gha\n          tags: ${{ steps.meta.outputs.tags }}\n          labels: ${{ steps.meta.outputs.labels }}\n          platforms: linux/amd64\n          push: false\n"
  },
  {
    "path": ".github/workflows/go-mod-tidy-check.yml",
    "content": "name: Go Mod Tidy Check\n\non:\n  push:\n    branches: [\"main\"]\n  pull_request:\n    branches: [\"main\"]\n\npermissions:\n  contents: read\n\njobs:\n  go_mod_tidy_check:\n    runs-on: ubuntu-24.04\n    steps:\n      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n        with:\n          persist-credentials: false\n\n      - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0\n        with:\n          go-version: \"stable\"\n\n      - name: Check go.mod and go.sum in main directory\n        run: |\n          # Store original file state\n          cp go.mod go.mod.orig\n          cp go.sum go.sum.orig\n\n          # Run go mod tidy\n          go mod tidy\n\n          # Check if files changed\n          if ! diff -q go.mod.orig go.mod > /dev/null 2>&1; then\n            echo \"ERROR: go.mod in main directory has changed after running 'go mod tidy'\"\n            echo \"Please run 'go mod tidy' locally and commit the changes\"\n            diff go.mod.orig go.mod\n            exit 1\n          fi\n\n          if ! diff -q go.sum.orig go.sum > /dev/null 2>&1; then\n            echo \"ERROR: go.sum in main directory has changed after running 'go mod tidy'\"\n            echo \"Please run 'go mod tidy' locally and commit the changes\"\n            diff go.sum.orig go.sum\n            exit 1\n          fi\n\n          echo \"SUCCESS: go.mod and go.sum in main directory are tidy\"\n\n      - name: Check go.mod and go.sum in test directory\n        run: |\n          cd test\n\n          # Store original file state\n          cp go.mod go.mod.orig\n          cp go.sum go.sum.orig\n\n          # Run go mod tidy\n          go mod tidy\n\n          # Check if files changed\n          if ! diff -q go.mod.orig go.mod > /dev/null 2>&1; then\n            echo \"ERROR: go.mod in test directory has changed after running 'go mod tidy'\"\n            echo \"Please run 'go mod tidy' locally and commit the changes\"\n            diff go.mod.orig go.mod\n            exit 1\n          fi\n\n          if ! diff -q go.sum.orig go.sum > /dev/null 2>&1; then\n            echo \"ERROR: go.sum in test directory has changed after running 'go mod tidy'\"\n            echo \"Please run 'go mod tidy' locally and commit the changes\"\n            diff go.sum.orig go.sum\n            exit 1\n          fi\n\n          echo \"SUCCESS: go.mod and go.sum in test directory are tidy\"\n"
  },
  {
    "path": ".github/workflows/go.yml",
    "content": "name: Go\n\non:\n  push:\n    branches: [\"main\"]\n  pull_request:\n    branches: [\"main\"]\n\npermissions:\n  contents: read\n  actions: write\n\njobs:\n  go_tests:\n    #runs-on: alrest-techarohq\n    runs-on: ubuntu-24.04\n    steps:\n      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n        with:\n          persist-credentials: false\n\n      - name: build essential\n        run: |\n          sudo apt-get update\n          sudo apt-get install -y build-essential\n\n      - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0\n        with:\n          node-version: \"24.11.0\"\n      - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0\n        with:\n          go-version: \"stable\"\n\n      - name: Cache playwright binaries\n        uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3\n        id: playwright-cache\n        with:\n          path: |\n            ~/.cache/ms-playwright\n          key: ${{ runner.os }}-playwright-${{ hashFiles('**/go.sum') }}\n\n      - name: install node deps\n        run: |\n          npm ci\n\n      - name: install playwright browsers\n        run: |\n          npx --no-install playwright@1.52.0 install --with-deps\n          npx --no-install playwright@1.52.0 run-server --port 9001 &\n\n      - name: Build\n        run: npm run build\n\n      - name: Test\n        run: npm run test\n\n      - name: Lint with staticcheck\n        uses: dominikh/staticcheck-action@9716614d4101e79b4340dd97b10e54d68234e431 # v1.4.1\n        with:\n          version: \"latest\"\n\n      - name: Govulncheck\n        run: |\n          go tool govulncheck ./... ||:\n"
  },
  {
    "path": ".github/workflows/lint-pr-title.yaml",
    "content": "name: \"Lint PR\"\n\non:\n  pull_request_target:\n    types:\n      - opened\n      - edited\n      - synchronize\n\njobs:\n  lint_pr_title:\n    name: Validate PR title\n    runs-on: ubuntu-latest\n    permissions:\n      pull-requests: read\n    steps:\n      - uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n"
  },
  {
    "path": ".github/workflows/package-builds-stable.yml",
    "content": "name: Package builds (stable)\n\non:\n  workflow_dispatch:\n  # release:\n  #   types: [published]\n\npermissions:\n  contents: write\n  actions: write\n\njobs:\n  package_builds:\n    #runs-on: alrest-techarohq\n    runs-on: ubuntu-24.04\n    steps:\n      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n        with:\n          persist-credentials: false\n          fetch-tags: true\n          fetch-depth: 0\n\n      - name: build essential\n        run: |\n          sudo apt-get update\n          sudo apt-get install -y build-essential\n\n      - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0\n        with:\n          node-version: \"24.11.0\"\n      - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0\n        with:\n          go-version: \"stable\"\n\n      - name: install node deps\n        run: |\n          npm ci\n\n      - name: Build Packages\n        run: |\n          go tool yeet\n\n      - name: Upload released artifacts\n        env:\n          GITHUB_TOKEN: ${{ github.TOKEN }}\n          RELEASE_VERSION: ${{github.event.release.tag_name}}\n        shell: bash\n        run: |\n          RELEASE=\"${RELEASE_VERSION}\"\n          cd var\n          for file in *; do\n            gh release upload $RELEASE $file\n          done\n"
  },
  {
    "path": ".github/workflows/package-builds-unstable.yml",
    "content": "name: Package builds (unstable)\n\non:\n  push:\n    branches: [\"main\"]\n  pull_request:\n    branches: [\"main\"]\n\npermissions:\n  contents: read\n  actions: write\n\njobs:\n  package_builds:\n    #runs-on: alrest-techarohq\n    runs-on: ubuntu-24.04\n    steps:\n      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n        with:\n          persist-credentials: false\n          fetch-tags: true\n          fetch-depth: 0\n\n      - name: build essential\n        run: |\n          sudo apt-get update\n          sudo apt-get install -y build-essential\n\n      - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0\n        with:\n          node-version: \"24.11.0\"\n      - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0\n        with:\n          go-version: \"stable\"\n\n      - name: install node deps\n        run: |\n          npm ci\n\n      - name: Build Packages\n        run: |\n          go tool yeet\n\n      - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0\n        with:\n          name: packages\n          path: var/*\n"
  },
  {
    "path": ".github/workflows/smoke-tests.yml",
    "content": "name: Smoke tests\n\non:\n  push:\n    branches: [\"main\"]\n  pull_request:\n    branches: [\"main\"]\n\npermissions:\n  contents: read\n\njobs:\n  smoke-test:\n    strategy:\n      matrix:\n        test:\n          - default-config-macro\n          - docker-registry\n          - double_slash\n          - forced-language\n          - git-clone\n          - git-push\n          - healthcheck\n          - i18n\n          - log-file\n          - nginx\n          - palemoon/amd64\n          #- palemoon/i386\n          - robots_txt\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n        with:\n          persist-credentials: false\n\n      - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0\n        with:\n          node-version: \"24.11.0\"\n      - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0\n        with:\n          go-version: \"stable\"\n\n      - uses: ko-build/setup-ko@d006021bd0c28d1ce33a07e7943d48b079944c8d # v0.9\n\n      - name: Install utils\n        run: |\n          go install ./utils/cmd/...\n\n      - name: Run test\n        run: |\n          cd test/${{ matrix.test }}\n          backoff-retry --try-count 10 ./test.sh\n\n      - name: Sanitize artifact name\n        if: always()\n        run: echo \"ARTIFACT_NAME=${{ matrix.test }}\" | sed 's|/|-|g' >> $GITHUB_ENV\n\n      - name: Upload artifact\n        uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f\n        if: always()\n        with:\n          name: ${{ env.ARTIFACT_NAME }}\n          path: test/${{ matrix.test }}/var\n"
  },
  {
    "path": ".github/workflows/spelling.yml",
    "content": "name: Check Spelling\n\n# Comment management is handled through a secondary job, for details see:\n# https://github.com/check-spelling/check-spelling/wiki/Feature%3A-Restricted-Permissions\n#\n# `jobs.comment-push` runs when a push is made to a repository and the `jobs.spelling` job needs to make a comment\n#   (in odd cases, it might actually run just to collapse a comment, but that's fairly rare)\n#   it needs `contents: write` in order to add a comment.\n#\n# `jobs.comment-pr` runs when a pull_request is made to a repository and the `jobs.spelling` job needs to make a comment\n#   or collapse a comment (in the case where it had previously made a comment and now no longer needs to show a comment)\n#   it needs `pull-requests: write` in order to manipulate those comments.\n\n# Updating pull request branches is managed via comment handling.\n# For details, see: https://github.com/check-spelling/check-spelling/wiki/Feature:-Update-expect-list\n#\n# These elements work together to make it happen:\n#\n# `on.issue_comment`\n#   This event listens to comments by users asking to update the metadata.\n#\n# `jobs.update`\n#   This job runs in response to an issue_comment and will push a new commit\n#   to update the spelling metadata.\n#\n# `with.experimental_apply_changes_via_bot`\n#   Tells the action to support and generate messages that enable it\n#   to make a commit to update the spelling metadata.\n#\n# `with.ssh_key`\n#   In order to trigger workflows when the commit is made, you can provide a\n#   secret (typically, a write-enabled github deploy key).\n#\n#   For background, see: https://github.com/check-spelling/check-spelling/wiki/Feature:-Update-with-deploy-key\n\n# SARIF reporting\n#\n# Access to SARIF reports is generally restricted (by GitHub) to members of the repository.\n#\n# Requires enabling `security-events: write`\n# and configuring the action with `use_sarif: 1`\n#\n#   For information on the feature, see: https://github.com/check-spelling/check-spelling/wiki/Feature:-SARIF-output\n\n# Minimal workflow structure:\n#\n# on:\n#   push:\n#     ...\n#   pull_request_target:\n#     ...\n# jobs:\n#   # you only want the spelling job, all others should be omitted\n#   spelling:\n#     # remove `security-events: write` and `use_sarif: 1`\n#     # remove `experimental_apply_changes_via_bot: 1`\n#     ... otherwise adjust the `with:` as you wish\n\non:\n  push:\n    branches:\n      - \"**\"\n    tags-ignore:\n      - \"**\"\n  pull_request:\n    branches:\n      - \"**\"\n    types:\n      - \"opened\"\n      - \"reopened\"\n      - \"synchronize\"\n\njobs:\n  spelling:\n    name: Check Spelling\n    permissions:\n      contents: read\n      pull-requests: read\n      actions: read\n      security-events: write\n    outputs:\n      followup: ${{ steps.spelling.outputs.followup }}\n    runs-on: ubuntu-latest\n    if: ${{ contains(github.event_name, 'pull_request') || github.event_name == 'push' }}\n    concurrency:\n      group: spelling-${{ github.event.pull_request.number || github.ref }}\n      # note: If you use only_check_changed_files, you do not want cancel-in-progress\n      cancel-in-progress: true\n    steps:\n      - name: check-spelling\n        id: spelling\n        uses: check-spelling/check-spelling@c635c2f3f714eec2fcf27b643a1919b9a811ef2e # v0.0.25\n        with:\n          suppress_push_for_open_pull_request: ${{ github.actor != 'dependabot[bot]' && 1 }}\n          checkout: true\n          check_file_names: 1\n          post_comment: 0\n          use_magic_file: 1\n          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\n          use_sarif: ${{ (!github.event.pull_request || (github.event.pull_request.head.repo.full_name == github.repository)) && 1 }}\n          check_extra_dictionaries: \"\"\n          dictionary_source_prefixes: >\n            {\n            \"cspell\": \"https://raw.githubusercontent.com/check-spelling/cspell-dicts/v20241114/dictionaries/\"\n            }\n          extra_dictionaries: |\n            cspell:software-terms/softwareTerms.txt\n            cspell:golang/go.txt\n            cspell:npm/npm.txt\n            cspell:k8s/k8s.txt\n            cspell:python/python/python-lib.txt\n            cspell:aws/aws.txt\n            cspell:node/node.txt\n            cspell:html/html.txt\n            cspell:filetypes/filetypes.txt\n            cspell:python/common/extra.txt\n            cspell:docker/docker-words.txt\n            cspell:fullstack/fullstack.txt\n"
  },
  {
    "path": ".github/workflows/ssh-ci-runner-cron.yml",
    "content": "name: Regenerate ssh ci runner image\n\non:\n  # pull_request:\n  #   branches: [\"main\"]\n  schedule:\n    - cron: \"0 0 1,8,15,22 * *\"\n  workflow_dispatch:\n\npermissions:\n  pull-requests: write\n  contents: write\n  packages: write\n\njobs:\n  ssh-ci-rebuild:\n    if: github.repository == 'TecharoHQ/anubis'\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n        with:\n          fetch-tags: true\n          fetch-depth: 0\n          persist-credentials: false\n      - name: Log into registry\n        uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0\n        with:\n          registry: ghcr.io\n          username: ${{ github.repository_owner }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0\n      - name: Build and push\n        run: |\n          cd ./test/ssh-ci\n          docker buildx bake --push\n"
  },
  {
    "path": ".github/workflows/ssh-ci.yml",
    "content": "name: SSH CI\n\non:\n  push:\n    branches: [\"main\"]\n  # pull_request:\n  #   branches: [\"main\"]\n\npermissions:\n  contents: read\n\njobs:\n  ssh:\n    if: github.repository == 'TecharoHQ/anubis'\n    #runs-on: alrest-techarohq\n    runs-on: ubuntu-latest\n    strategy:\n      matrix:\n        host:\n          - riscv64\n          - ppc64le\n          #- aarch64-4k\n          #- aarch64-16k\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n        with:\n          fetch-tags: true\n          fetch-depth: 0\n          persist-credentials: false\n\n      - name: Install CI target SSH key\n        uses: shimataro/ssh-key-action@6b84f2e793b32fa0b03a379cadadec75cc539391 # v2.8.0\n        with:\n          key: ${{ secrets.CI_SSH_KEY }}\n          name: id_rsa\n          known_hosts: ${{ secrets.CI_SSH_KNOWN_HOSTS }}\n\n      - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0\n        with:\n          go-version: \"stable\"\n\n      - name: Run CI\n        run: go run ./utils/cmd/backoff-retry bash test/ssh-ci/rigging.sh ${{ matrix.host }}\n        env:\n          GITHUB_RUN_ID: ${{ github.run_id }}\n"
  },
  {
    "path": ".github/workflows/zizmor.yml",
    "content": "name: zizmor\n\non:\n  push:\n    paths:\n      - \".github/workflows/*.ya?ml\"\n  pull_request:\n    paths:\n      - \".github/workflows/*.ya?ml\"\n\njobs:\n  zizmor:\n    name: zizmor latest via PyPI\n    runs-on: ubuntu-24.04\n    permissions:\n      security-events: write\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n        with:\n          persist-credentials: false\n\n      - name: Install the latest version of uv\n        uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0\n\n      - name: Run zizmor 🌈\n        run: uvx zizmor --format sarif . > results.sarif\n        env:\n          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Upload SARIF file\n        uses: github/codeql-action/upload-sarif@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9\n        with:\n          sarif_file: results.sarif\n          category: zizmor\n"
  },
  {
    "path": ".github/zizmor.yml",
    "content": "rules:\n  unpinned-uses:\n    config:\n      policies:\n        Homebrew/actions/*: any\n"
  },
  {
    "path": ".gitignore",
    "content": ".env\n*.deb\n*.rpm\n\n# Additional package locks\npnpm-lock.yaml\nyarn.lock\n\n# Go binaries and test artifacts\nmain\n*.test\n\nnode_modules\n\n# MacOS\n.DS_store\n\n# Intellij\n.idea\n\n# how does this get here\ndoc/VERSION\n\nweb/static/locales/*.json"
  },
  {
    "path": ".husky/commit-msg",
    "content": "npx --no-install commitlint --edit \"$1\"\n\n# Check if commit message contains Signed-off-by line\nif ! grep -q \"^Signed-off-by:\" \"$1\"; then\n\techo \"Commit message must contain a 'Signed-off-by:' line.\"\n\techo \"Please use 'git commit --signoff' or add a Signed-off-by line to your commit message.\"\n\texit 1\nfi\n"
  },
  {
    "path": ".husky/pre-commit",
    "content": "npm run lint\nnpm run test\n"
  },
  {
    "path": ".ko.yaml",
    "content": "defaultBaseImage: cgr.dev/chainguard/static\ndefaultPlatforms:\n  - linux/arm64\n  - linux/amd64\n  - linux/arm/v7\n\nbuilds:\n  - id: anubis\n    main: ./cmd/anubis\n    ldflags:\n      - -s -w\n      - -extldflags \"-static\"\n      - -X github.com/TecharoHQ/anubis.Version={{.Env.VERSION}}\n"
  },
  {
    "path": ".prettierignore",
    "content": "lib/config/testdata/bad/*\n*.inc\nAGENTS.md\nCLAUDE.md"
  },
  {
    "path": ".vscode/extensions.json",
    "content": "{\n  \"recommendations\": [\n    \"esbenp.prettier-vscode\",\n    \"ms-azuretools.vscode-containers\",\n    \"golang.go\",\n    \"unifiedjs.vscode-mdx\",\n    \"a-h.templ\",\n    \"redhat.vscode-yaml\",\n    \"streetsidesoftware.code-spell-checker\"\n  ]\n}\n"
  },
  {
    "path": ".vscode/launch.json",
    "content": "{\n  // Use IntelliSense to learn about possible attributes.\n  // Hover to view descriptions of existing attributes.\n  // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387\n  \"version\": \"0.2.0\",\n  \"configurations\": [\n    {\n      \"name\": \"Launch Package\",\n      \"type\": \"go\",\n      \"request\": \"launch\",\n      \"mode\": \"auto\",\n      \"program\": \"${fileDirname}\"\n    },\n    {\n      \"name\": \"Anubis [dev]\",\n      \"command\": \"npm run dev\",\n      \"request\": \"launch\",\n      \"type\": \"node-terminal\"\n    },\n    {\n      \"name\": \"Start Docs\",\n      \"command\": \"cd docs && npm ci && npm run start\",\n      \"request\": \"launch\",\n      \"type\": \"node-terminal\"\n    }\n  ]\n}\n"
  },
  {
    "path": ".vscode/settings.json",
    "content": "{\n  \"github.copilot.enable\": {\n    \"*\": false,\n    \"plaintext\": false,\n    \"markdown\": false,\n    \"mdx\": false,\n    \"json\": false,\n    \"scminput\": false,\n    \"yaml\": false,\n    \"go\": false,\n    \"zig\": false,\n    \"javascript\": false,\n    \"properties\": false\n  },\n  \"[markdown]\": {\n    \"editor.wordWrap\": \"wordWrapColumn\",\n    \"editor.wordWrapColumn\": 80,\n    \"editor.wordBasedSuggestions\": \"off\"\n  },\n  \"[mdx]\": {\n    \"editor.wordWrap\": \"wordWrapColumn\",\n    \"editor.wordWrapColumn\": 80,\n    \"editor.wordBasedSuggestions\": \"off\"\n  },\n  \"[nunjucks]\": {\n    \"editor.wordWrap\": \"wordWrapColumn\",\n    \"editor.wordWrapColumn\": 80,\n    \"editor.wordBasedSuggestions\": \"off\"\n  },\n  \"cSpell.enabledFileTypes\": {\n    \"mdx\": true,\n    \"md\": true\n  }\n}\n"
  },
  {
    "path": "AGENTS.md",
    "content": "# Agent instructions\n\nPrimary agent documentation is in `CONTRIBUTING.md`. You MUST read this file before proceeding.\n\n## Useful Commands\n\n```shell\nnpm ci           # install node dependencies\nnpm run assets   # build JS/CSS (required before any Go build/test)\nnpm run build    # assets + go build -> ./var/anubis\nnpm run dev      # assets + run locally with --use-remote-address\n```\n\n## Testing\n\n```shell\nnpm run test\n```\n\n## Linting\n\n```shell\ngo vet ./...\ngo tool staticcheck ./...\ngo tool govulncheck ./...\n```\n\n## Commit Messages\n\nCommit messages follow the [**Conventional Commits**](https://www.conventionalcommits.org/en/v1.0.0/) format:\n\n```text\n<type>[optional scope]: <description>\n\n[optional body]\n\n[optional footer(s)]\n```\n\n**Types**: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`, `chore`, `revert`\n\n- Add `!` after type/scope for breaking changes or include `BREAKING CHANGE:` in the footer.\n- Keep descriptions concise, imperative, lowercase, and without a trailing period.\n- Reference issues/PRs in the footer when applicable.\n- **ALL git commits MUST be made with `--signoff`.** This is mandatory.\n\n### Attribution Requirements\n\nAI agents must disclose what tool and model they are using in the \"Assisted-by\" commit footer:\n\n```text\nAssisted-by: [Model Name] via [Tool Name]\n```\n\nExample:\n\n```text\nAssisted-by: GLM 4.6 via Claude Code\n```\n\n## PR Checklist\n\n- Add description of changes to `[Unreleased]` in `docs/docs/CHANGELOG.md`.\n- Add test cases for bug fixes and behavior changes.\n- Run integration tests: `npm run test:integration`.\n- All commits must have verified (signed) signatures.\n\n## Key Conventions\n\n- **Security-first**: This is security software. Code reviews are strict. Always add tests for bug fixes. Consider adversarial inputs.\n- **Configuration**: YAML-based policy files. Config structs validate via `Valid() error` methods returning sentinel errors.\n- **Store interface**: `lib/store.Interface` abstracts key-value storage.\n- **Environment variables**: Parsed from flags via `flagenv`. Use `.env` files locally (loaded by `godotenv/autoload`). Never commit `.env` files.\n- **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`.\n- **CEL expressions**: Policy rules support CEL (Common Expression Language) expressions for advanced matching. See `lib/policy/expressions/`."
  },
  {
    "path": "Brewfile",
    "content": "# programming languages\nbrew \"go@1.24\"\nbrew \"node\"\nbrew \"ko\"\nbrew \"esbuild\"\nbrew \"zstd\"\nbrew \"brotli\""
  },
  {
    "path": "CLAUDE.md",
    "content": "@AGENTS.md\n@CONTRIBUTING.md\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# Contributing to Anubis\n\nAnubis 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.\n\n## Build & Run\n\nPrerequisites: Go 1.24+, Node.js (any supported version), esbuild, gzip, zstd, brotli. Install all with `brew bundle` if you are using Homebrew.\n\n```shell\nnpm ci           # install node dependencies\nnpm run assets   # build JS/CSS (required before any Go build/test)\nnpm run build    # assets + go build -> ./var/anubis\nnpm run dev      # assets + run locally with --use-remote-address\n```\n\n## Testing\n\n```shell\n# Run all unit tests (assets must be built first)\nnpm run test              # or: make test\n\n# Run a single test by name\ngo test -run TestClampIP ./internal/\n\n# Run a single test file's package\ngo test ./lib/config/\n\n# Run tests with verbose output\ngo test -v -run TestBotValid ./lib/config/\n```\n\n### Smoke tests\n\nThe `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`.\n\n## Linting\n\n```shell\ngo vet ./...\ngo tool staticcheck ./...\ngo tool govulncheck ./...\n```\n\n## Code Generation\n\nThe project uses `go generate` for templ templates and stringer. Always run `npm run generate` (or `make assets`) before building or testing. Generated files include:\n\n- `web/*.templ` -> templ-generated Go code\n- `web/static/` -> bundled/minified JS and CSS (with .gz, .zst, .br variants)\n\n## Project Layout\n\nImportant folders:\n\n- `cmd/anubis`: Main entrypoint for the project. This is the program that runs on servers.\n- `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.\n- `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.\n- `test/*` Smoke tests (see dedicated section for details).\n- `web`: Frontend HTML templates.\n- `xess`: Frontend CSS framework and build logic.\n\n## Code Style\n\n### Go\n\nThis project follows the idioms of the Go standard library. Generally follow the patterns that upstream Go uses, including:\n\n- Prefer packages from the standard library unless there is no other option.\n- Use package import aliases only when package names collide.\n- Use `goimports` to format code. Run with `npm run format`.\n- Use sentinel errors as package-level variables prefixed with `Err` (such as `ErrBotMustHaveName`). Wrap with `fmt.Errorf(\"package: small message giving context: %w\", err)`.\n- 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.\n- Name PublicFunctionsAndTypes in PascalCase. Name privateFunctionsAndTypes in camelCase.\n- Acronyms stay uppercase (`URL`, `HTTP`, `IP`, `DNS`, etc.)\n- Enumerations should use strong types with validation logic for parsing remote input.\n- Be conservative in what you send but liberal in what you accept.\n- Anything reading configuration values should use both `json` and `yaml` struct tags. Use pointer values for optional configuration values.\n- Use [table-driven tests](https://go.dev/wiki/TableDrivenTests) when writing test code.\n- Use [`t.Helper()`](https://pkg.go.dev/testing#T.Helper) in helper code (setup/teardown scaffolding).\n- Use [`t.Cleanup()`](https://pkg.go.dev/testing#T.Cleanup) to tear down per-test or per-suite scaffolding.\n- Use [`errors.Is`](https://pkg.go.dev/errors#Is) for validating function results against sentinel errors.\n- Prefer same-package tests over black-box tests (`_test` packages).\n\n### JavaScript / TypeScript\n\n- Source lives in `web/js/`. Built with esbuild, bundled and minified.\n- Uses Preact (not React).\n- No linter config. Keep functions small. Use `const` by default.\n\n### Templ Templates\n\nAnubis uses [Templ](https://templ.guide) for generating HTML on the server.\n\n- `.templ` files in `web/` generate Go code. Run `go generate ./...` (or `npm run assets`) after modifying them.\n- Templates receive typed Go parameters. Keep logic in Go, not templates.\n\n## Commit Messages\n\nCommit messages follow the [**Conventional Commits**](https://www.conventionalcommits.org/en/v1.0.0/) format:\n\n```text\n<type>[optional scope]: <description>\n\n[optional body]\n\n[optional footer(s)]\n```\n\n**Types**: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`, `chore`, `revert`\n\n- Add `!` after type/scope for breaking changes or include `BREAKING CHANGE:` in the footer.\n- Keep descriptions concise, imperative, lowercase, and without a trailing period.\n- Reference issues/PRs in the footer when applicable.\n- **ALL git commits MUST be made with `--signoff`.** This is mandatory.\n\n### Attribution Requirements\n\nAI agents must disclose what tool and model they are using in the \"Assisted-by\" commit footer:\n\n```text\nAssisted-by: [Model Name] via [Tool Name]\n```\n\nExample:\n\n```text\nAssisted-by: GLM 4.6 via Claude Code\n```\n\n## PR Checklist\n\n- Add description of changes to `[Unreleased]` in `docs/docs/CHANGELOG.md`.\n- Add test cases for bug fixes and behavior changes.\n- Run integration tests: `npm run test:integration`.\n- All commits must have verified (signed) signatures.\n\n## Key Conventions\n\n- **Security-first**: This is security software. Code reviews are strict. Always add tests for bug fixes. Consider adversarial inputs.\n- **Configuration**: YAML-based policy files. Config structs validate via `Valid() error` methods returning sentinel errors.\n- **Store interface**: `lib/store.Interface` abstracts key-value storage.\n- **Environment variables**: Parsed from flags via `flagenv`. Use `.env` files locally (loaded by `godotenv/autoload`). Never commit `.env` files.\n- **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`.\n- **CEL expressions**: Policy rules support CEL (Common Expression Language) expressions for advanced matching. See `lib/policy/expressions/`.\n"
  },
  {
    "path": "LICENSE",
    "content": "Copyright (c) 2025 Xe Iaso <me@xeiaso.net>\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in\nall copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\nTHE SOFTWARE.\n"
  },
  {
    "path": "Makefile",
    "content": "VERSION= $(shell cat ./VERSION)\nGO?= go\nNPM?= npm\n\n.PHONY: build assets deps lint prebaked-build test\n\nall: build\n\ndeps:\n\t$(NPM) ci\n\t$(GO) mod download\n\nassets: PATH:=$(PWD)/node_modules/.bin:$(PATH)\nassets: deps\n\t$(GO) generate ./...\n\t./web/build.sh\n\t./xess/build.sh\n\nbuild: assets\n\t$(GO) build -o ./var/anubis ./cmd/anubis\n\t$(GO) build -o ./var/robots2policy ./cmd/robots2policy\n\t@echo \"Anubis is now built to ./var/anubis\"\n\nlint: assets\n\t$(GO) vet ./...\n\t$(GO) tool staticcheck ./...\n\t\nprebaked-build:\n\t$(GO) build -o ./var/anubis -ldflags \"-X 'github.com/TecharoHQ/anubis.Version=$(VERSION)'\" ./cmd/anubis\n\t$(GO) build -o ./var/robots2policy -ldflags \"-X 'github.com/TecharoHQ/anubis.Version=$(VERSION)'\" ./cmd/robots2policy\n\ntest: assets\n\t$(GO) test ./...\n"
  },
  {
    "path": "README.md",
    "content": "# Anubis\n\n<center>\n<img width=256 src=\"./web/static/img/happy.webp\" alt=\"A smiling chibi dark-skinned anthro jackal with brown hair and tall ears looking victorious with a thumbs-up\" />\n</center>\n\n![enbyware](https://pride-badges.pony.workers.dev/static/v1?label=enbyware&labelColor=%23555&stripeWidth=8&stripeColors=FCF434%2CFFFFFF%2C9C59D1%2C2C2C2C)\n![GitHub Issues or Pull Requests by label](https://img.shields.io/github/issues/TecharoHQ/anubis)\n![GitHub go.mod Go version](https://img.shields.io/github/go-mod/go-version/TecharoHQ/anubis)\n![language count](https://img.shields.io/github/languages/count/TecharoHQ/anubis)\n![repo size](https://img.shields.io/github/repo-size/TecharoHQ/anubis)\n[![GitHub Sponsors](https://img.shields.io/github/sponsors/Xe)](https://github.com/sponsors/Xe)\n\n## Sponsors\n\nAnubis is brought to you by sponsors and donors like:\n\n### Diamond Tier\n\n<a href=\"https://www.raptorcs.com/content/base/products.html\">\n  <img src=\"./docs/static/img/sponsors/raptor-computing-logo.webp\" alt=\"Raptor Computing Systems\" height=64 />\n</a>\n<a href=\"https://databento.com/?utm_source=anubis&utm_medium=sponsor&utm_campaign=anubis\">\n  <img src=\"./docs/static/img/sponsors/databento-logo.webp\" alt=\"Databento\" height=\"64\" />\n</a>\n\n### Gold Tier\n\n<a href=\"https://www.unipromos.com/?utm_campaign=github&utm_medium=referral&utm_content=anubis\">\n  <img src=\"./docs/static/img/sponsors/unipromos.webp\" alt=\"Unipromos\" height=\"64\" />\n</a>\n<a href=\"https://uvensys.de/?utm_campaign=github&utm_medium=referral&utm_content=anubis\">\n  <img src=\"./docs/static/img/sponsors/uvensys.webp\" alt=\"Uvensys\" height=\"64\">\n</a>\n<a href=\"https://distrust.co?utm_campaign=github&utm_medium=referral&utm_content=anubis\">\n  <img src=\"./docs/static/img/sponsors/distrust-logo.webp\" alt=\"Distrust\" height=\"64\">\n</a>\n<a href=\"https://about.gitea.com?utm_campaign=github&utm_medium=referral&utm_content=anubis\">\n  <img src=\"./docs/static/img/sponsors/gitea-logo.webp\" alt=\"Gitea\" height=\"64\">\n</a>\n<a href=\"https://prolocation.net?utm_campaign=github&utm_medium=referral&utm_content=anubis\">\n  <img src=\"./docs/static/img/sponsors/prolocation-logo.svg\" alt=\"Prolocation\" height=\"64\">\n</a>\n<a href=\"https://terminaltrove.com/?utm_campaign=github&utm_medium=referral&utm_content=anubis&utm_source=abgh\">\n  <img src=\"./docs/static/img/sponsors/terminal-trove.webp\" alt=\"Terminal Trove\" height=\"64\">\n</a>\n<a href=\"https://canine.tools?utm_campaign=github&utm_medium=referral&utm_content=anubis\">\n  <img src=\"./docs/static/img/sponsors/caninetools-logo.webp\" alt=\"canine.tools\" height=\"64\">\n</a>\n<a href=\"https://weblate.org/\">\n  <img src=\"./docs/static/img/sponsors/weblate-logo.webp\" alt=\"Weblate\" height=\"64\">\n</a>\n<a href=\"https://uberspace.de/\">\n  <img src=\"./docs/static/img/sponsors/uberspace-logo.webp\" alt=\"Uberspace\" height=\"64\">\n</a>\n<a href=\"https://wildbase.xyz/\">\n  <img src=\"./docs/static/img/sponsors/wildbase-logo.webp\" alt=\"Wildbase\" height=\"64\">\n</a>\n<a href=\"https://emma.pet\">\n  <img\n    src=\"./docs/static/img/sponsors/nepeat-logo.webp\"\n    alt=\"Cat eyes over the word Emma in a serif font\"\n    height=\"64\"\n  />\n</a>\n<a href=\"https://fabulous.systems/\">\n  <img\n    src=\"./docs/static/img/sponsors/fabulous-systems.webp\"\n    alt=\"Cat eyes over the word Emma in a serif font\"\n    height=\"64\"\n  />\n</a>\n<a href=\"https://www.anexia.com/\">\n  <img src=\"./docs/static/img/sponsors/anexia-cloudsolutions-logo.webp\" alt=\"ANEXIA Cloud Solutions\" height=\"64\">\n</a>\n\n## Overview\n\nAnubis 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.\n\nThis 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.\n\nAnubis 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.\n\nIn 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.\n\nIf you want to try this out, visit the Anubis documentation site at [anubis.techaro.lol](https://anubis.techaro.lol).\n\n## Support\n\nIf 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.\n\nFor live chat, please join the [Patreon](https://patreon.com/cadey) and ask in the Patron discord in the channel `#anubis`.\n\n## Star History\n\n<a href=\"https://www.star-history.com/#TecharoHQ/anubis&Date\">\n <picture>\n   <source media=\"(prefers-color-scheme: dark)\" srcset=\"https://api.star-history.com/svg?repos=TecharoHQ/anubis&type=Date&theme=dark\" />\n   <source media=\"(prefers-color-scheme: light)\" srcset=\"https://api.star-history.com/svg?repos=TecharoHQ/anubis&type=Date\" />\n   <img alt=\"Star History Chart\" src=\"https://api.star-history.com/svg?repos=TecharoHQ/anubis&type=Date\" />\n </picture>\n</a>\n\n## Packaging Status\n\n[![Packaging status](https://repology.org/badge/vertical-allrepos/anubis-anti-crawler.svg?columns=3)](https://repology.org/project/anubis-anti-crawler/versions)\n\n## Contributors\n\n<a href=\"https://github.com/TecharoHQ/anubis/graphs/contributors\">\n  <img src=\"https://contrib.rocks/image?repo=TecharoHQ/anubis\" />\n</a>\n\nMade with [contrib.rocks](https://contrib.rocks).\n"
  },
  {
    "path": "SECURITY.md",
    "content": "# Security Policy\n\nTecharo follows the [Semver 2.0 scheme](https://semver.org/).\n\n## Supported Versions\n\nTecharo strives to support the two most recent minor versions of Anubis. Patches to those versions will be published as patch releases.\n\n## Reporting a Vulnerability\n\nEmail security@techaro.lol with details on the vulnerability and reproduction steps. You will get a response as soon as possible.\n\nPlease 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.\n"
  },
  {
    "path": "VERSION",
    "content": "1.25.0\n"
  },
  {
    "path": "anubis.go",
    "content": "// Package anubis contains the version number of Anubis.\npackage anubis\n\nimport \"time\"\n\n// Version is the current version of Anubis.\n//\n// This variable is set at build time using the -X linker flag. If not set,\n// it defaults to \"devel\".\nvar Version = \"devel\"\n\n// CookieName is the name of the cookie that Anubis uses in order to validate\n// access.\nvar CookieName = \"techaro.lol-anubis\"\n\n// TestCookieName is the name of the cookie that Anubis uses in order to check\n// if cookies are enabled on the client's browser.\nvar TestCookieName = \"techaro.lol-anubis-cookie-verification\"\n\n// CookieDefaultExpirationTime is the amount of time before the cookie/JWT expires.\nconst CookieDefaultExpirationTime = 7 * 24 * time.Hour\n\n// BasePrefix is a global prefix for all Anubis endpoints. Can be emptied to remove the prefix entirely.\nvar BasePrefix = \"\"\n\n// PublicUrl is the externally accessible URL for this Anubis instance.\nvar PublicUrl = \"\"\n\n// StaticPath is the location where all static Anubis assets are located.\nconst StaticPath = \"/.within.website/x/cmd/anubis/\"\n\n// APIPrefix is the location where all Anubis API endpoints are located.\nconst APIPrefix = \"/.within.website/x/cmd/anubis/api/\"\n\n// DefaultDifficulty is the default \"difficulty\" (number of leading zeroes)\n// that must be met by the client in order to pass the challenge.\nconst DefaultDifficulty = 4\n\n// ForcedLanguage is the language being used instead of the one of the request's Accept-Language header\n// if being set.\nvar ForcedLanguage = \"\"\n\n// UseSimplifiedExplanation can be set to true for using the simplified explanation\nvar UseSimplifiedExplanation = false\n"
  },
  {
    "path": "cmd/containerbuild/.gitignore",
    "content": "images"
  },
  {
    "path": "cmd/containerbuild/main.go",
    "content": "package main\n\nimport (\n\t\"flag\"\n\t\"fmt\"\n\t\"log\"\n\t\"log/slog\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"strings\"\n\n\t\"github.com/TecharoHQ/anubis/internal\"\n\t\"github.com/facebookgo/flagenv\"\n)\n\nvar (\n\tdockerAnnotations = flag.String(\"docker-annotations\", os.Getenv(\"DOCKER_METADATA_OUTPUT_ANNOTATIONS\"), \"Docker image annotations\")\n\tdockerLabels      = flag.String(\"docker-labels\", os.Getenv(\"DOCKER_METADATA_OUTPUT_LABELS\"), \"Docker image labels\")\n\tdockerRepo        = flag.String(\"docker-repo\", \"registry.int.xeserv.us/techaro/anubis\", \"Docker image repository for Anubis\")\n\tdockerTags        = flag.String(\"docker-tags\", os.Getenv(\"DOCKER_METADATA_OUTPUT_TAGS\"), \"newline separated docker tags including the registry name\")\n\tgithubEventName   = flag.String(\"github-event-name\", \"\", \"GitHub event name\")\n\tpullRequestID     = flag.Int(\"pull-request-id\", -1, \"GitHub pull request ID\")\n\tslogLevel         = flag.String(\"slog-level\", \"INFO\", \"logging level (see https://pkg.go.dev/log/slog#hdr-Levels)\")\n)\n\nfunc main() {\n\tflagenv.Parse()\n\tflag.Parse()\n\n\tslog.SetDefault(internal.InitSlog(*slogLevel, os.Stderr))\n\n\tkoDockerRepo := strings.TrimSuffix(*dockerRepo, \"/\"+filepath.Base(*dockerRepo))\n\n\tif *githubEventName == \"pull_request\" && *pullRequestID != -1 {\n\t\t*dockerRepo = fmt.Sprintf(\"ttl.sh/techaro/pr-%d/anubis\", *pullRequestID)\n\t\t*dockerTags = fmt.Sprintf(\"ttl.sh/techaro/pr-%d/anubis:24h\", *pullRequestID)\n\t\tkoDockerRepo = fmt.Sprintf(\"ttl.sh/techaro/pr-%d\", *pullRequestID)\n\n\t\tslog.Info(\n\t\t\t\"Building image for pull request\",\n\t\t\t\"docker-repo\", *dockerRepo,\n\t\t\t\"docker-tags\", *dockerTags,\n\t\t\t\"github-event-name\", *githubEventName,\n\t\t\t\"pull-request-id\", *pullRequestID,\n\t\t)\n\t}\n\n\tif strings.Contains(*dockerTags, \",\") {\n\t\tnewTags := strings.Join(strings.Split(*dockerTags, \",\"), \"\\n\")\n\t\tdockerTags = &newTags\n\t}\n\n\tsetOutput(\"docker_image\", strings.SplitN(*dockerTags, \"\\n\", 2)[0])\n\n\tversion, err := run(\"git describe --tags --always --dirty\")\n\tif err != nil {\n\t\tlog.Fatal(err)\n\t}\n\n\tcommitTimestamp, err := run(\"git log -1 --format='%ct'\")\n\tif err != nil {\n\t\tlog.Fatal(err)\n\t}\n\n\tslog.Debug(\n\t\t\"ko env\",\n\t\t\"KO_DOCKER_REPO\", koDockerRepo,\n\t\t\"SOURCE_DATE_EPOCH\", commitTimestamp,\n\t\t\"VERSION\", version,\n\t)\n\n\tos.Setenv(\"KO_DOCKER_REPO\", koDockerRepo)\n\tos.Setenv(\"SOURCE_DATE_EPOCH\", commitTimestamp)\n\tos.Setenv(\"VERSION\", version)\n\n\tsetOutput(\"version\", version)\n\n\tif *dockerTags == \"\" {\n\t\tlog.Fatal(\"Must set --docker-tags or DOCKER_METADATA_OUTPUT_TAGS\")\n\t}\n\n\timages, err := parseImageList(*dockerTags)\n\tif err != nil {\n\t\tlog.Fatalf(\"can't parse images: %v\", err)\n\t}\n\n\tfor _, img := range images {\n\t\tif img.repository != *dockerRepo {\n\t\t\tslog.Error(\n\t\t\t\t\"Something weird is going on. Wanted docker repo differs from contents of --docker-tags. Did a flag get set incorrectly?\",\n\t\t\t\t\"wanted\", *dockerRepo,\n\t\t\t\t\"got\", img.repository,\n\t\t\t\t\"docker-tags\", *dockerTags,\n\t\t\t)\n\t\t\tos.Exit(2)\n\t\t}\n\t}\n\n\tvar tags []string\n\tfor _, img := range images {\n\t\ttags = append(tags, img.tag)\n\t}\n\n\toutput, 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))\n\tif err != nil {\n\t\tlog.Fatalf(\"can't run ko build, check stderr: %v\", err)\n\t}\n\n\tsp := strings.SplitN(output, \"@\", 2)\n\n\tsetOutput(\"digest\", sp[1])\n}\n\ntype image struct {\n\trepository string\n\ttag        string\n}\n\nfunc parseImageList(imageList string) ([]image, error) {\n\timages := strings.Split(imageList, \"\\n\")\n\tvar result []image\n\tfor _, img := range images {\n\t\tif img == \"\" {\n\t\t\tcontinue\n\t\t}\n\n\t\t// reg.xeiaso.net/techaro/anubis:latest\n\t\t// repository: reg.xeiaso.net/techaro/anubis\n\t\t// tag:        latest\n\t\tindex := strings.LastIndex(img, \":\")\n\t\tresult = append(result, image{\n\t\t\trepository: img[:index],\n\t\t\ttag:        img[index+1:],\n\t\t})\n\t}\n\n\tif len(result) == 0 {\n\t\treturn nil, fmt.Errorf(\"no images provided, bad flags\")\n\t}\n\n\treturn result, nil\n}\n\n// run executes a command and returns the trimmed output.\nfunc run(command string) (string, error) {\n\tbin, err := exec.LookPath(\"sh\")\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tslog.Debug(\"running command\", \"command\", command)\n\tcmd := exec.Command(bin, \"-c\", command)\n\tcmd.Stderr = os.Stderr\n\tout, err := cmd.Output()\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn strings.TrimSpace(string(out)), nil\n}\n\nfunc setOutput(key, val string) {\n    github_output := os.Getenv(\"GITHUB_OUTPUT\")\n    f, _ := os.OpenFile(github_output, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0644)\n    fmt.Fprintf(f, \"%s=%s\\n\", key, val)\n    f.Close()\n}\n"
  },
  {
    "path": "cmd/robots2policy/batch/batch_process.go",
    "content": "/*\nBatch process robots.txt files from archives like https://github.com/nrjones8/robots-dot-txt-archive-bot/tree/master/data/cleaned\ninto Anubis CEL policies. Usage: go run batch_process.go <directory with robots.txt files>\n*/\npackage main\n\nimport (\n\t\"fmt\"\n\t\"io/fs\"\n\t\"log\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"strings\"\n)\n\nfunc main() {\n\tif len(os.Args) < 2 {\n\t\tfmt.Println(\"Usage: go run batch_process.go <cleaned_directory>\")\n\t\tfmt.Println(\"Example: go run batch_process.go ./cleaned\")\n\t\tos.Exit(1)\n\t}\n\n\tcleanedDir := os.Args[1]\n\toutputDir := \"generated_policies\"\n\n\t// Create output directory\n\tif err := os.MkdirAll(outputDir, 0755); err != nil {\n\t\tlog.Fatalf(\"Failed to create output directory: %v\", err)\n\t}\n\n\tcount := 0\n\terr := filepath.WalkDir(cleanedDir, func(path string, d fs.DirEntry, err error) error {\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// Skip directories\n\t\tif d.IsDir() {\n\t\t\treturn nil\n\t\t}\n\n\t\t// Generate policy name from file path\n\t\trelPath, _ := filepath.Rel(cleanedDir, path)\n\t\tpolicyName := strings.ReplaceAll(relPath, \"/\", \"-\")\n\t\tpolicyName = strings.TrimSuffix(policyName, \"-robots.txt\")\n\t\tpolicyName = strings.ReplaceAll(policyName, \".\", \"-\")\n\n\t\toutputFile := filepath.Join(outputDir, policyName+\".yaml\")\n\n\t\tcmd := exec.Command(\"go\", \"run\", \"main.go\",\n\t\t\t\"-input\", path,\n\t\t\t\"-output\", outputFile,\n\t\t\t\"-name\", policyName,\n\t\t\t\"-format\", \"yaml\")\n\n\t\tif err := cmd.Run(); err != nil {\n\t\t\tfmt.Printf(\"Warning: Failed to process %s: %v\\n\", path, err)\n\t\t\treturn nil // Continue processing other files\n\t\t}\n\n\t\tcount++\n\t\tif count%100 == 0 {\n\t\t\tfmt.Printf(\"Processed %d files...\\n\", count)\n\t\t} else if count%10 == 0 {\n\t\t\tfmt.Print(\".\")\n\t\t}\n\n\t\treturn nil\n\t})\n\n\tif err != nil {\n\t\tlog.Fatalf(\"Error walking directory: %v\", err)\n\t}\n\n\tfmt.Printf(\"Successfully processed %d robots.txt files\\n\", count)\n\tfmt.Printf(\"Generated policies saved to: %s/\\n\", outputDir)\n}\n"
  },
  {
    "path": "cmd/robots2policy/main.go",
    "content": "package main\n\nimport (\n\t\"bufio\"\n\t\"encoding/json\"\n\t\"flag\"\n\t\"fmt\"\n\t\"io\"\n\t\"log\"\n\t\"net/http\"\n\t\"os\"\n\t\"regexp\"\n\t\"slices\"\n\t\"strings\"\n\n\t\"github.com/TecharoHQ/anubis/lib/config\"\n\n\t\"sigs.k8s.io/yaml\"\n)\n\nvar (\n\tinputFile     = flag.String(\"input\", \"\", \"path to robots.txt file (use - for stdin)\")\n\toutputFile    = flag.String(\"output\", \"\", \"output file path (use - for stdout, defaults to stdout)\")\n\toutputFormat  = flag.String(\"format\", \"yaml\", \"output format: yaml or json\")\n\tbaseAction    = flag.String(\"action\", \"CHALLENGE\", \"default action for disallowed paths: ALLOW, DENY, CHALLENGE, WEIGH\")\n\tcrawlDelay    = flag.Int(\"crawl-delay-weight\", 0, \"if > 0, add weight adjustment for crawl-delay (difficulty adjustment)\")\n\tpolicyName    = flag.String(\"name\", \"robots-txt-policy\", \"name for the generated policy\")\n\tuserAgentDeny = flag.String(\"deny-user-agents\", \"DENY\", \"action for specifically blocked user agents: DENY, CHALLENGE\")\n\thelpFlag      = flag.Bool(\"help\", false, \"show help\")\n)\n\ntype RobotsRule struct {\n\tUserAgents  []string\n\tDisallows   []string\n\tAllows      []string\n\tCrawlDelay  int\n\tIsBlacklist bool // true if this is a specifically denied user agent\n}\n\ntype AnubisRule struct {\n\tExpression *config.ExpressionOrList `yaml:\"expression,omitempty\" json:\"expression,omitempty\"`\n\tChallenge  *config.ChallengeRules   `yaml:\"challenge,omitempty\" json:\"challenge,omitempty\"`\n\tWeight     *config.Weight           `yaml:\"weight,omitempty\" json:\"weight,omitempty\"`\n\tName       string                   `yaml:\"name\" json:\"name\"`\n\tAction     string                   `yaml:\"action\" json:\"action\"`\n}\n\nfunc init() {\n\tflag.Usage = func() {\n\t\tfmt.Fprintf(os.Stderr, \"Usage of %s:\\n\", os.Args[0])\n\t\tfmt.Fprintf(os.Stderr, \"%s [options] -input <robots.txt>\\n\\n\", os.Args[0])\n\t\tflag.PrintDefaults()\n\t\tfmt.Fprintln(os.Stderr, \"\\nExamples:\")\n\t\tfmt.Fprintln(os.Stderr, \"  # Convert local robots.txt file\")\n\t\tfmt.Fprintln(os.Stderr, \"  robots2policy -input robots.txt -output policy.yaml\")\n\t\tfmt.Fprintln(os.Stderr, \"\")\n\t\tfmt.Fprintln(os.Stderr, \"  # Convert from URL\")\n\t\tfmt.Fprintln(os.Stderr, \"  robots2policy -input https://example.com/robots.txt -format json\")\n\t\tfmt.Fprintln(os.Stderr, \"\")\n\t\tfmt.Fprintln(os.Stderr, \"  # Read from stdin, write to stdout\")\n\t\tfmt.Fprintln(os.Stderr, \"  curl https://example.com/robots.txt | robots2policy -input -\")\n\t\tos.Exit(2)\n\t}\n}\n\nfunc main() {\n\tflag.Parse()\n\n\tif len(flag.Args()) > 0 || *helpFlag || *inputFile == \"\" {\n\t\tflag.Usage()\n\t}\n\n\t// Read robots.txt\n\tvar input io.Reader\n\tif *inputFile == \"-\" {\n\t\tinput = os.Stdin\n\t} else if strings.HasPrefix(*inputFile, \"http://\") || strings.HasPrefix(*inputFile, \"https://\") {\n\t\tresp, err := http.Get(*inputFile)\n\t\tif err != nil {\n\t\t\tlog.Fatalf(\"failed to fetch robots.txt from URL: %v\", err)\n\t\t}\n\t\tdefer resp.Body.Close()\n\t\tinput = resp.Body\n\t} else {\n\t\tfile, err := os.Open(*inputFile)\n\t\tif err != nil {\n\t\t\tlog.Fatalf(\"failed to open input file: %v\", err)\n\t\t}\n\t\tdefer file.Close()\n\t\tinput = file\n\t}\n\n\t// Parse robots.txt\n\trules, err := parseRobotsTxt(input)\n\tif err != nil {\n\t\tlog.Fatalf(\"failed to parse robots.txt: %v\", err)\n\t}\n\n\t// Convert to Anubis rules\n\tanubisRules := convertToAnubisRules(rules)\n\n\t// Check if any rules were generated\n\tif len(anubisRules) == 0 {\n\t\tlog.Fatal(\"no valid rules generated from robots.txt - file may be empty or contain no disallow directives\")\n\t}\n\n\t// Generate output\n\tvar output []byte\n\tswitch strings.ToLower(*outputFormat) {\n\tcase \"yaml\":\n\t\toutput, err = yaml.Marshal(anubisRules)\n\tcase \"json\":\n\t\toutput, err = json.MarshalIndent(anubisRules, \"\", \"  \")\n\tdefault:\n\t\tlog.Fatalf(\"unsupported output format: %s (use yaml or json)\", *outputFormat)\n\t}\n\n\tif err != nil {\n\t\tlog.Fatalf(\"failed to marshal output: %v\", err)\n\t}\n\n\t// Write output\n\tif *outputFile == \"\" || *outputFile == \"-\" {\n\t\tfmt.Print(string(output))\n\t} else {\n\t\terr = os.WriteFile(*outputFile, output, 0644)\n\t\tif err != nil {\n\t\t\tlog.Fatalf(\"failed to write output file: %v\", err)\n\t\t}\n\t\tfmt.Printf(\"Generated Anubis policy written to %s\\n\", *outputFile)\n\t}\n}\n\nfunc createRuleFromAccumulated(userAgents, disallows, allows []string, crawlDelay int) RobotsRule {\n\trule := RobotsRule{\n\t\tUserAgents: make([]string, len(userAgents)),\n\t\tDisallows:  make([]string, len(disallows)),\n\t\tAllows:     make([]string, len(allows)),\n\t\tCrawlDelay: crawlDelay,\n\t}\n\tcopy(rule.UserAgents, userAgents)\n\tcopy(rule.Disallows, disallows)\n\tcopy(rule.Allows, allows)\n\treturn rule\n}\n\nfunc parseRobotsTxt(input io.Reader) ([]RobotsRule, error) {\n\tscanner := bufio.NewScanner(input)\n\tvar rules []RobotsRule\n\tvar currentUserAgents []string\n\tvar currentDisallows []string\n\tvar currentAllows []string\n\tvar currentCrawlDelay int\n\n\tfor scanner.Scan() {\n\t\tline := strings.TrimSpace(scanner.Text())\n\n\t\t// Skip empty lines and comments\n\t\tif line == \"\" || strings.HasPrefix(line, \"#\") {\n\t\t\tcontinue\n\t\t}\n\n\t\t// Split on first colon\n\t\tparts := strings.SplitN(line, \":\", 2)\n\t\tif len(parts) != 2 {\n\t\t\tcontinue\n\t\t}\n\n\t\tdirective := strings.TrimSpace(strings.ToLower(parts[0]))\n\t\tvalue := strings.TrimSpace(parts[1])\n\n\t\tswitch directive {\n\t\tcase \"user-agent\":\n\t\t\t// If we have accumulated rules with directives and encounter a new user-agent,\n\t\t\t// flush the current rules\n\t\t\tif len(currentUserAgents) > 0 && (len(currentDisallows) > 0 || len(currentAllows) > 0 || currentCrawlDelay > 0) {\n\t\t\t\trule := createRuleFromAccumulated(currentUserAgents, currentDisallows, currentAllows, currentCrawlDelay)\n\t\t\t\trules = append(rules, rule)\n\t\t\t\t// Reset for next group\n\t\t\t\tcurrentUserAgents = nil\n\t\t\t\tcurrentDisallows = nil\n\t\t\t\tcurrentAllows = nil\n\t\t\t\tcurrentCrawlDelay = 0\n\t\t\t}\n\t\t\tcurrentUserAgents = append(currentUserAgents, value)\n\n\t\tcase \"disallow\":\n\t\t\tif len(currentUserAgents) > 0 && value != \"\" {\n\t\t\t\tcurrentDisallows = append(currentDisallows, value)\n\t\t\t}\n\n\t\tcase \"allow\":\n\t\t\tif len(currentUserAgents) > 0 && value != \"\" {\n\t\t\t\tcurrentAllows = append(currentAllows, value)\n\t\t\t}\n\n\t\tcase \"crawl-delay\":\n\t\t\tif len(currentUserAgents) > 0 {\n\t\t\t\tif delay, err := parseIntSafe(value); err == nil {\n\t\t\t\t\tcurrentCrawlDelay = delay\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t// Don't forget the last group of rules\n\tif len(currentUserAgents) > 0 {\n\t\trule := createRuleFromAccumulated(currentUserAgents, currentDisallows, currentAllows, currentCrawlDelay)\n\t\trules = append(rules, rule)\n\t}\n\n\t// Mark blacklisted user agents (those with \"Disallow: /\")\n\tfor i := range rules {\n\t\tif slices.Contains(rules[i].Disallows, \"/\") {\n\t\t\trules[i].IsBlacklist = true\n\t\t}\n\t}\n\n\treturn rules, scanner.Err()\n}\n\nfunc parseIntSafe(s string) (int, error) {\n\tvar result int\n\t_, err := fmt.Sscanf(s, \"%d\", &result)\n\treturn result, err\n}\n\nfunc convertToAnubisRules(robotsRules []RobotsRule) []AnubisRule {\n\tvar anubisRules []AnubisRule\n\truleCounter := 0\n\n\t// Process each robots rule individually\n\tfor _, robotsRule := range robotsRules {\n\t\tuserAgents := robotsRule.UserAgents\n\n\t\t// Handle crawl delay\n\t\tif robotsRule.CrawlDelay > 0 && *crawlDelay > 0 {\n\t\t\truleCounter++\n\t\t\trule := AnubisRule{\n\t\t\t\tName:   fmt.Sprintf(\"%s-crawl-delay-%d\", *policyName, ruleCounter),\n\t\t\t\tAction: \"WEIGH\",\n\t\t\t\tWeight: &config.Weight{Adjust: *crawlDelay},\n\t\t\t}\n\n\t\t\tif len(userAgents) == 1 && userAgents[0] == \"*\" {\n\t\t\t\trule.Expression = &config.ExpressionOrList{\n\t\t\t\t\tAll: []string{\"true\"}, // Always applies\n\t\t\t\t}\n\t\t\t} else if len(userAgents) == 1 {\n\t\t\t\trule.Expression = &config.ExpressionOrList{\n\t\t\t\t\tAll: []string{fmt.Sprintf(\"userAgent.contains(%q)\", userAgents[0])},\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// Multiple user agents - use any block\n\t\t\t\tvar expressions []string\n\t\t\t\tfor _, ua := range userAgents {\n\t\t\t\t\tif ua == \"*\" {\n\t\t\t\t\t\texpressions = append(expressions, \"true\")\n\t\t\t\t\t} else {\n\t\t\t\t\t\texpressions = append(expressions, fmt.Sprintf(\"userAgent.contains(%q)\", ua))\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\trule.Expression = &config.ExpressionOrList{\n\t\t\t\t\tAny: expressions,\n\t\t\t\t}\n\t\t\t}\n\t\t\tanubisRules = append(anubisRules, rule)\n\t\t}\n\n\t\t// Handle blacklisted user agents\n\t\tif robotsRule.IsBlacklist {\n\t\t\truleCounter++\n\t\t\trule := AnubisRule{\n\t\t\t\tName:   fmt.Sprintf(\"%s-blacklist-%d\", *policyName, ruleCounter),\n\t\t\t\tAction: *userAgentDeny,\n\t\t\t}\n\n\t\t\tif len(userAgents) == 1 {\n\t\t\t\tuserAgent := userAgents[0]\n\t\t\t\tif userAgent == \"*\" {\n\t\t\t\t\t// This would block everything - convert to a weight adjustment instead\n\t\t\t\t\trule.Name = fmt.Sprintf(\"%s-global-restriction-%d\", *policyName, ruleCounter)\n\t\t\t\t\trule.Action = \"WEIGH\"\n\t\t\t\t\trule.Weight = &config.Weight{Adjust: 20} // Increase difficulty significantly\n\t\t\t\t\trule.Expression = &config.ExpressionOrList{\n\t\t\t\t\t\tAll: []string{\"true\"}, // Always applies\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\trule.Expression = &config.ExpressionOrList{\n\t\t\t\t\t\tAll: []string{fmt.Sprintf(\"userAgent.contains(%q)\", userAgent)},\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// Multiple user agents - use any block\n\t\t\t\tvar expressions []string\n\t\t\t\tfor _, ua := range userAgents {\n\t\t\t\t\tif ua == \"*\" {\n\t\t\t\t\t\texpressions = append(expressions, \"true\")\n\t\t\t\t\t} else {\n\t\t\t\t\t\texpressions = append(expressions, fmt.Sprintf(\"userAgent.contains(%q)\", ua))\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\trule.Expression = &config.ExpressionOrList{\n\t\t\t\t\tAny: expressions,\n\t\t\t\t}\n\t\t\t}\n\t\t\tanubisRules = append(anubisRules, rule)\n\t\t}\n\n\t\t// Handle specific disallow rules\n\t\tfor _, disallow := range robotsRule.Disallows {\n\t\t\tif disallow == \"/\" {\n\t\t\t\tcontinue // Already handled as blacklist above\n\t\t\t}\n\n\t\t\truleCounter++\n\t\t\trule := AnubisRule{\n\t\t\t\tName:   fmt.Sprintf(\"%s-disallow-%d\", *policyName, ruleCounter),\n\t\t\t\tAction: *baseAction,\n\t\t\t}\n\n\t\t\t// Build CEL expression\n\t\t\tvar conditions []string\n\n\t\t\t// Add user agent conditions\n\t\t\tif len(userAgents) == 1 && userAgents[0] == \"*\" {\n\t\t\t\t// Wildcard user agent - no user agent condition needed\n\t\t\t} else if len(userAgents) == 1 {\n\t\t\t\tconditions = append(conditions, fmt.Sprintf(\"userAgent.contains(%q)\", userAgents[0]))\n\t\t\t} else {\n\t\t\t\t// For multiple user agents, we need to use a more complex expression\n\t\t\t\t// This is a limitation - we can't easily combine any for user agents with all for path\n\t\t\t\t// So we'll create separate rules for each user agent\n\t\t\t\tfor _, ua := range userAgents {\n\t\t\t\t\tif ua == \"*\" {\n\t\t\t\t\t\tcontinue // Skip wildcard as it's handled separately\n\t\t\t\t\t}\n\t\t\t\t\truleCounter++\n\t\t\t\t\tsubRule := AnubisRule{\n\t\t\t\t\t\tName:   fmt.Sprintf(\"%s-disallow-%d\", *policyName, ruleCounter),\n\t\t\t\t\t\tAction: *baseAction,\n\t\t\t\t\t\tExpression: &config.ExpressionOrList{\n\t\t\t\t\t\t\tAll: []string{\n\t\t\t\t\t\t\t\tfmt.Sprintf(\"userAgent.contains(%q)\", ua),\n\t\t\t\t\t\t\t\tbuildPathCondition(disallow),\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t}\n\t\t\t\t\tanubisRules = append(anubisRules, subRule)\n\t\t\t\t}\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\t// Add path condition\n\t\t\tpathCondition := buildPathCondition(disallow)\n\t\t\tconditions = append(conditions, pathCondition)\n\n\t\t\trule.Expression = &config.ExpressionOrList{\n\t\t\t\tAll: conditions,\n\t\t\t}\n\n\t\t\tanubisRules = append(anubisRules, rule)\n\t\t}\n\t}\n\n\treturn anubisRules\n}\n\nfunc buildPathCondition(robotsPath string) string {\n\t// Handle wildcards in robots.txt paths\n\tif strings.Contains(robotsPath, \"*\") || strings.Contains(robotsPath, \"?\") {\n\t\t// Convert robots.txt wildcards to regex\n\t\tregex := regexp.QuoteMeta(robotsPath)\n\t\tregex = strings.ReplaceAll(regex, `\\*`, `.*`) // * becomes .*\n\t\tregex = strings.ReplaceAll(regex, `\\?`, `.`)  // ? becomes .\n\t\tregex = \"^\" + regex\n\t\treturn fmt.Sprintf(\"path.matches(%q)\", regex)\n\t}\n\n\t// Simple prefix match for most cases\n\treturn fmt.Sprintf(\"path.startsWith(%q)\", robotsPath)\n}\n"
  },
  {
    "path": "cmd/robots2policy/robots2policy_test.go",
    "content": "package main\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"reflect\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"gopkg.in/yaml.v3\"\n)\n\ntype TestCase struct {\n\tname         string\n\trobotsFile   string\n\texpectedFile string\n\toptions      TestOptions\n}\n\ntype TestOptions struct {\n\tformat           string\n\taction           string\n\tpolicyName       string\n\tdeniedAction     string\n\tcrawlDelayWeight int\n}\n\nfunc TestDataFileConversion(t *testing.T) {\n\n\ttestCases := []TestCase{\n\t\t{\n\t\t\tname:         \"simple_default\",\n\t\t\trobotsFile:   \"simple.robots.txt\",\n\t\t\texpectedFile: \"simple.yaml\",\n\t\t\toptions:      TestOptions{format: \"yaml\"},\n\t\t},\n\t\t{\n\t\t\tname:         \"simple_json\",\n\t\t\trobotsFile:   \"simple.robots.txt\",\n\t\t\texpectedFile: \"simple.json\",\n\t\t\toptions:      TestOptions{format: \"json\"},\n\t\t},\n\t\t{\n\t\t\tname:         \"simple_deny_action\",\n\t\t\trobotsFile:   \"simple.robots.txt\",\n\t\t\texpectedFile: \"deny-action.yaml\",\n\t\t\toptions:      TestOptions{format: \"yaml\", action: \"DENY\"},\n\t\t},\n\t\t{\n\t\t\tname:         \"simple_custom_name\",\n\t\t\trobotsFile:   \"simple.robots.txt\",\n\t\t\texpectedFile: \"custom-name.yaml\",\n\t\t\toptions:      TestOptions{format: \"yaml\", policyName: \"my-custom-policy\"},\n\t\t},\n\t\t{\n\t\t\tname:         \"blacklist_with_crawl_delay\",\n\t\t\trobotsFile:   \"blacklist.robots.txt\",\n\t\t\texpectedFile: \"blacklist.yaml\",\n\t\t\toptions:      TestOptions{format: \"yaml\", crawlDelayWeight: 3},\n\t\t},\n\t\t{\n\t\t\tname:         \"wildcards\",\n\t\t\trobotsFile:   \"wildcards.robots.txt\",\n\t\t\texpectedFile: \"wildcards.yaml\",\n\t\t\toptions:      TestOptions{format: \"yaml\"},\n\t\t},\n\t\t{\n\t\t\tname:         \"empty_file\",\n\t\t\trobotsFile:   \"empty.robots.txt\",\n\t\t\texpectedFile: \"empty.yaml\",\n\t\t\toptions:      TestOptions{format: \"yaml\"},\n\t\t},\n\t\t{\n\t\t\tname:         \"complex_scenario\",\n\t\t\trobotsFile:   \"complex.robots.txt\",\n\t\t\texpectedFile: \"complex.yaml\",\n\t\t\toptions:      TestOptions{format: \"yaml\", crawlDelayWeight: 5},\n\t\t},\n\t\t{\n\t\t\tname:         \"consecutive_user_agents\",\n\t\t\trobotsFile:   \"consecutive.robots.txt\",\n\t\t\texpectedFile: \"consecutive.yaml\",\n\t\t\toptions:      TestOptions{format: \"yaml\", crawlDelayWeight: 3},\n\t\t},\n\t}\n\n\tfor _, tc := range testCases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\trobotsPath := filepath.Join(\"testdata\", tc.robotsFile)\n\t\t\texpectedPath := filepath.Join(\"testdata\", tc.expectedFile)\n\n\t\t\t// Read robots.txt input\n\t\t\trobotsFile, err := os.Open(robotsPath)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"Failed to open robots file %s: %v\", robotsPath, err)\n\t\t\t}\n\t\t\tdefer robotsFile.Close()\n\n\t\t\t// Parse robots.txt\n\t\t\trules, err := parseRobotsTxt(robotsFile)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"Failed to parse robots.txt: %v\", err)\n\t\t\t}\n\n\t\t\t// Set test options\n\t\t\toldFormat := *outputFormat\n\t\t\toldAction := *baseAction\n\t\t\toldCrawlDelay := *crawlDelay\n\t\t\toldPolicyName := *policyName\n\t\t\toldDeniedAction := *userAgentDeny\n\n\t\t\tif tc.options.format != \"\" {\n\t\t\t\t*outputFormat = tc.options.format\n\t\t\t}\n\t\t\tif tc.options.action != \"\" {\n\t\t\t\t*baseAction = tc.options.action\n\t\t\t}\n\t\t\tif tc.options.crawlDelayWeight > 0 {\n\t\t\t\t*crawlDelay = tc.options.crawlDelayWeight\n\t\t\t}\n\t\t\tif tc.options.policyName != \"\" {\n\t\t\t\t*policyName = tc.options.policyName\n\t\t\t}\n\t\t\tif tc.options.deniedAction != \"\" {\n\t\t\t\t*userAgentDeny = tc.options.deniedAction\n\t\t\t}\n\n\t\t\t// Restore options after test\n\t\t\tdefer func() {\n\t\t\t\t*outputFormat = oldFormat\n\t\t\t\t*baseAction = oldAction\n\t\t\t\t*crawlDelay = oldCrawlDelay\n\t\t\t\t*policyName = oldPolicyName\n\t\t\t\t*userAgentDeny = oldDeniedAction\n\t\t\t}()\n\n\t\t\t// Convert to Anubis rules\n\t\t\tanubisRules := convertToAnubisRules(rules)\n\n\t\t\t// Generate output\n\t\t\tvar actualOutput []byte\n\t\t\tswitch strings.ToLower(*outputFormat) {\n\t\t\tcase \"yaml\":\n\t\t\t\tactualOutput, err = yaml.Marshal(anubisRules)\n\t\t\tcase \"json\":\n\t\t\t\tactualOutput, err = json.MarshalIndent(anubisRules, \"\", \"  \")\n\t\t\t}\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"Failed to marshal output: %v\", err)\n\t\t\t}\n\n\t\t\t// Read expected output\n\t\t\texpectedOutput, err := os.ReadFile(expectedPath)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"Failed to read expected file %s: %v\", expectedPath, err)\n\t\t\t}\n\n\t\t\tif strings.ToLower(*outputFormat) == \"yaml\" {\n\t\t\t\tvar actualData []any\n\t\t\t\tvar expectedData []any\n\n\t\t\t\terr = yaml.Unmarshal(actualOutput, &actualData)\n\t\t\t\tif err != nil {\n\t\t\t\t\tt.Fatalf(\"Failed to unmarshal actual output: %v\", err)\n\t\t\t\t}\n\n\t\t\t\terr = yaml.Unmarshal(expectedOutput, &expectedData)\n\t\t\t\tif err != nil {\n\t\t\t\t\tt.Fatalf(\"Failed to unmarshal expected output: %v\", err)\n\t\t\t\t}\n\n\t\t\t\t// Compare data structures\n\t\t\t\tif !compareData(actualData, expectedData) {\n\t\t\t\t\tactualStr := strings.TrimSpace(string(actualOutput))\n\t\t\t\t\texpectedStr := strings.TrimSpace(string(expectedOutput))\n\t\t\t\t\tt.Errorf(\"Output mismatch for %s\\nExpected:\\n%s\\n\\nActual:\\n%s\", tc.name, expectedStr, actualStr)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tvar actualData []any\n\t\t\t\tvar expectedData []any\n\n\t\t\t\terr = json.Unmarshal(actualOutput, &actualData)\n\t\t\t\tif err != nil {\n\t\t\t\t\tt.Fatalf(\"Failed to unmarshal actual JSON output: %v\", err)\n\t\t\t\t}\n\n\t\t\t\terr = json.Unmarshal(expectedOutput, &expectedData)\n\t\t\t\tif err != nil {\n\t\t\t\t\tt.Fatalf(\"Failed to unmarshal expected JSON output: %v\", err)\n\t\t\t\t}\n\n\t\t\t\t// Compare data structures\n\t\t\t\tif !compareData(actualData, expectedData) {\n\t\t\t\t\tactualStr := strings.TrimSpace(string(actualOutput))\n\t\t\t\t\texpectedStr := strings.TrimSpace(string(expectedOutput))\n\t\t\t\t\tt.Errorf(\"Output mismatch for %s\\nExpected:\\n%s\\n\\nActual:\\n%s\", tc.name, expectedStr, actualStr)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestCaseInsensitiveParsing(t *testing.T) {\n\trobotsTxt := `User-Agent: *\nDisallow: /admin\nCrawl-Delay: 10\n\nUser-agent: TestBot\ndisallow: /test\ncrawl-delay: 5\n\nUSER-AGENT: UpperBot\nDISALLOW: /upper\nCRAWL-DELAY: 20`\n\n\treader := strings.NewReader(robotsTxt)\n\trules, err := parseRobotsTxt(reader)\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to parse case-insensitive robots.txt: %v\", err)\n\t}\n\n\texpectedRules := 3\n\tif len(rules) != expectedRules {\n\t\tt.Errorf(\"Expected %d rules, got %d\", expectedRules, len(rules))\n\t}\n\n\t// Check that all crawl delays were parsed\n\tfor i, rule := range rules {\n\t\texpectedDelays := []int{10, 5, 20}\n\t\tif rule.CrawlDelay != expectedDelays[i] {\n\t\t\tt.Errorf(\"Rule %d: expected crawl delay %d, got %d\", i, expectedDelays[i], rule.CrawlDelay)\n\t\t}\n\t}\n}\n\nfunc TestVariousOutputFormats(t *testing.T) {\n\trobotsTxt := `User-agent: *\nDisallow: /admin`\n\n\treader := strings.NewReader(robotsTxt)\n\trules, err := parseRobotsTxt(reader)\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to parse robots.txt: %v\", err)\n\t}\n\n\toldPolicyName := *policyName\n\t*policyName = \"test-policy\"\n\tdefer func() { *policyName = oldPolicyName }()\n\n\tanubisRules := convertToAnubisRules(rules)\n\n\t// Test YAML output\n\tyamlOutput, err := yaml.Marshal(anubisRules)\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to marshal YAML: %v\", err)\n\t}\n\n\tif !strings.Contains(string(yamlOutput), \"name: test-policy-disallow-1\") {\n\t\tt.Errorf(\"YAML output doesn't contain expected rule name\")\n\t}\n\n\t// Test JSON output\n\tjsonOutput, err := json.MarshalIndent(anubisRules, \"\", \"  \")\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to marshal JSON: %v\", err)\n\t}\n\n\tif !strings.Contains(string(jsonOutput), `\"name\": \"test-policy-disallow-1\"`) {\n\t\tt.Errorf(\"JSON output doesn't contain expected rule name\")\n\t}\n}\n\nfunc TestDifferentActions(t *testing.T) {\n\trobotsTxt := `User-agent: *\nDisallow: /admin`\n\n\ttestActions := []string{\"ALLOW\", \"DENY\", \"CHALLENGE\", \"WEIGH\"}\n\n\tfor _, action := range testActions {\n\t\tt.Run(\"action_\"+action, func(t *testing.T) {\n\t\t\treader := strings.NewReader(robotsTxt)\n\t\t\trules, err := parseRobotsTxt(reader)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"Failed to parse robots.txt: %v\", err)\n\t\t\t}\n\n\t\t\toldAction := *baseAction\n\t\t\t*baseAction = action\n\t\t\tdefer func() { *baseAction = oldAction }()\n\n\t\t\tanubisRules := convertToAnubisRules(rules)\n\n\t\t\tif len(anubisRules) != 1 {\n\t\t\t\tt.Fatalf(\"Expected 1 rule, got %d\", len(anubisRules))\n\t\t\t}\n\n\t\t\tif anubisRules[0].Action != action {\n\t\t\t\tt.Errorf(\"Expected action %s, got %s\", action, anubisRules[0].Action)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestPolicyNaming(t *testing.T) {\n\trobotsTxt := `User-agent: *\nDisallow: /admin\nDisallow: /private\n\nUser-agent: BadBot\nDisallow: /`\n\n\ttestNames := []string{\"custom-policy\", \"my-rules\", \"site-protection\"}\n\n\tfor _, name := range testNames {\n\t\tt.Run(\"name_\"+name, func(t *testing.T) {\n\t\t\treader := strings.NewReader(robotsTxt)\n\t\t\trules, err := parseRobotsTxt(reader)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"Failed to parse robots.txt: %v\", err)\n\t\t\t}\n\n\t\t\toldName := *policyName\n\t\t\t*policyName = name\n\t\t\tdefer func() { *policyName = oldName }()\n\n\t\t\tanubisRules := convertToAnubisRules(rules)\n\n\t\t\t// Check that all rule names use the custom prefix\n\t\t\tfor _, rule := range anubisRules {\n\t\t\t\tif !strings.HasPrefix(rule.Name, name+\"-\") {\n\t\t\t\t\tt.Errorf(\"Rule name %s doesn't start with expected prefix %s-\", rule.Name, name)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestCrawlDelayWeights(t *testing.T) {\n\trobotsTxt := `User-agent: *\nDisallow: /admin\nCrawl-delay: 10\n\nUser-agent: SlowBot\nDisallow: /slow\nCrawl-delay: 60`\n\n\ttestWeights := []int{1, 5, 10, 25}\n\n\tfor _, weight := range testWeights {\n\t\tt.Run(fmt.Sprintf(\"weight_%d\", weight), func(t *testing.T) {\n\t\t\treader := strings.NewReader(robotsTxt)\n\t\t\trules, err := parseRobotsTxt(reader)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"Failed to parse robots.txt: %v\", err)\n\t\t\t}\n\n\t\t\toldWeight := *crawlDelay\n\t\t\t*crawlDelay = weight\n\t\t\tdefer func() { *crawlDelay = oldWeight }()\n\n\t\t\tanubisRules := convertToAnubisRules(rules)\n\n\t\t\t// Count weight rules and verify they have correct weight\n\t\t\tweightRules := 0\n\t\t\tfor _, rule := range anubisRules {\n\t\t\t\tif rule.Action == \"WEIGH\" && rule.Weight != nil {\n\t\t\t\t\tweightRules++\n\t\t\t\t\tif rule.Weight.Adjust != weight {\n\t\t\t\t\t\tt.Errorf(\"Expected weight %d, got %d\", weight, rule.Weight.Adjust)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\texpectedWeightRules := 2 // One for *, one for SlowBot\n\t\t\tif weightRules != expectedWeightRules {\n\t\t\t\tt.Errorf(\"Expected %d weight rules, got %d\", expectedWeightRules, weightRules)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestBlacklistActions(t *testing.T) {\n\trobotsTxt := `User-agent: BadBot\nDisallow: /\n\nUser-agent: SpamBot\nDisallow: /`\n\n\ttestActions := []string{\"DENY\", \"CHALLENGE\"}\n\n\tfor _, action := range testActions {\n\t\tt.Run(\"blacklist_\"+action, func(t *testing.T) {\n\t\t\treader := strings.NewReader(robotsTxt)\n\t\t\trules, err := parseRobotsTxt(reader)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"Failed to parse robots.txt: %v\", err)\n\t\t\t}\n\n\t\t\toldAction := *userAgentDeny\n\t\t\t*userAgentDeny = action\n\t\t\tdefer func() { *userAgentDeny = oldAction }()\n\n\t\t\tanubisRules := convertToAnubisRules(rules)\n\n\t\t\t// All rules should be blacklist rules with the specified action\n\t\t\tfor _, rule := range anubisRules {\n\t\t\t\tif !strings.Contains(rule.Name, \"blacklist\") {\n\t\t\t\t\tt.Errorf(\"Expected blacklist rule, got %s\", rule.Name)\n\t\t\t\t}\n\t\t\t\tif rule.Action != action {\n\t\t\t\t\tt.Errorf(\"Expected action %s, got %s\", action, rule.Action)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\n// compareData performs a deep comparison of two data structures,\n// ignoring differences that are semantically equivalent in YAML/JSON\nfunc compareData(actual, expected any) bool {\n\treturn reflect.DeepEqual(actual, expected)\n}\n"
  },
  {
    "path": "cmd/robots2policy/testdata/blacklist.robots.txt",
    "content": "# Test with blacklisted user agents\nUser-agent: *\nDisallow: /admin\nCrawl-delay: 10\n\nUser-agent: BadBot\nDisallow: /\n\nUser-agent: SpamBot\nDisallow: /\nCrawl-delay: 60\n\nUser-agent: Googlebot\nDisallow: /search\nCrawl-delay: 5"
  },
  {
    "path": "cmd/robots2policy/testdata/blacklist.yaml",
    "content": "- action: WEIGH\n  expression: \"true\"\n  name: robots-txt-policy-crawl-delay-1\n  weight:\n    adjust: 3\n- action: CHALLENGE\n  expression: path.startsWith(\"/admin\")\n  name: robots-txt-policy-disallow-2\n- action: DENY\n  expression: userAgent.contains(\"BadBot\")\n  name: robots-txt-policy-blacklist-3\n- action: WEIGH\n  expression: userAgent.contains(\"SpamBot\")\n  name: robots-txt-policy-crawl-delay-4\n  weight:\n    adjust: 3\n- action: DENY\n  expression: userAgent.contains(\"SpamBot\")\n  name: robots-txt-policy-blacklist-5\n- action: WEIGH\n  expression: userAgent.contains(\"Googlebot\")\n  name: robots-txt-policy-crawl-delay-6\n  weight:\n    adjust: 3\n- action: CHALLENGE\n  expression:\n    all:\n      - userAgent.contains(\"Googlebot\")\n      - path.startsWith(\"/search\")\n  name: robots-txt-policy-disallow-7\n"
  },
  {
    "path": "cmd/robots2policy/testdata/complex.robots.txt",
    "content": "# Complex real-world example\nUser-agent: *\nDisallow: /admin/\nDisallow: /private/\nDisallow: /api/internal/\nAllow: /api/public/\nCrawl-delay: 5\n\nUser-agent: Googlebot\nDisallow: /search/\nAllow: /api/\nCrawl-delay: 2\n\nUser-agent: Bingbot\nDisallow: /search/\nDisallow: /admin/\nCrawl-delay: 10\n\nUser-agent: BadBot\nDisallow: /\n\nUser-agent: SeoBot\nDisallow: /\nCrawl-delay: 300\n\n# Test with various patterns\nUser-agent: TestBot\nDisallow: /*/admin\nDisallow: /temp*.html\nDisallow: /file?.log"
  },
  {
    "path": "cmd/robots2policy/testdata/complex.yaml",
    "content": "- action: WEIGH\n  expression: \"true\"\n  name: robots-txt-policy-crawl-delay-1\n  weight:\n    adjust: 5\n- action: CHALLENGE\n  expression: path.startsWith(\"/admin/\")\n  name: robots-txt-policy-disallow-2\n- action: CHALLENGE\n  expression: path.startsWith(\"/private/\")\n  name: robots-txt-policy-disallow-3\n- action: CHALLENGE\n  expression: path.startsWith(\"/api/internal/\")\n  name: robots-txt-policy-disallow-4\n- action: WEIGH\n  expression: userAgent.contains(\"Googlebot\")\n  name: robots-txt-policy-crawl-delay-5\n  weight:\n    adjust: 5\n- action: CHALLENGE\n  expression:\n    all:\n      - userAgent.contains(\"Googlebot\")\n      - path.startsWith(\"/search/\")\n  name: robots-txt-policy-disallow-6\n- action: WEIGH\n  expression: userAgent.contains(\"Bingbot\")\n  name: robots-txt-policy-crawl-delay-7\n  weight:\n    adjust: 5\n- action: CHALLENGE\n  expression:\n    all:\n      - userAgent.contains(\"Bingbot\")\n      - path.startsWith(\"/search/\")\n  name: robots-txt-policy-disallow-8\n- action: CHALLENGE\n  expression:\n    all:\n      - userAgent.contains(\"Bingbot\")\n      - path.startsWith(\"/admin/\")\n  name: robots-txt-policy-disallow-9\n- action: DENY\n  expression: userAgent.contains(\"BadBot\")\n  name: robots-txt-policy-blacklist-10\n- action: WEIGH\n  expression: userAgent.contains(\"SeoBot\")\n  name: robots-txt-policy-crawl-delay-11\n  weight:\n    adjust: 5\n- action: DENY\n  expression: userAgent.contains(\"SeoBot\")\n  name: robots-txt-policy-blacklist-12\n- action: CHALLENGE\n  expression:\n    all:\n      - userAgent.contains(\"TestBot\")\n      - path.matches(\"^/.*/admin\")\n  name: robots-txt-policy-disallow-13\n- action: CHALLENGE\n  expression:\n    all:\n      - userAgent.contains(\"TestBot\")\n      - path.matches(\"^/temp.*\\\\.html\")\n  name: robots-txt-policy-disallow-14\n- action: CHALLENGE\n  expression:\n    all:\n      - userAgent.contains(\"TestBot\")\n      - path.matches(\"^/file.\\\\.log\")\n  name: robots-txt-policy-disallow-15\n"
  },
  {
    "path": "cmd/robots2policy/testdata/consecutive.robots.txt",
    "content": "# Test consecutive user agents that should be grouped into any: blocks\nUser-agent: *\nDisallow: /admin\nCrawl-delay: 10\n\n# Multiple consecutive user agents - should be grouped\nUser-agent: BadBot\nUser-agent: SpamBot\nUser-agent: EvilBot\nDisallow: /\n\n# Single user agent - should be separate\nUser-agent: GoodBot\nDisallow: /private\n\n# Multiple consecutive user agents with crawl delay\nUser-agent: SlowBot1\nUser-agent: SlowBot2\nCrawl-delay: 5\n\n# Multiple consecutive user agents with specific path\nUser-agent: SearchBot1\nUser-agent: SearchBot2\nUser-agent: SearchBot3\nDisallow: /search "
  },
  {
    "path": "cmd/robots2policy/testdata/consecutive.yaml",
    "content": "- action: WEIGH\n  expression: \"true\"\n  name: robots-txt-policy-crawl-delay-1\n  weight:\n    adjust: 3\n- action: CHALLENGE\n  expression: path.startsWith(\"/admin\")\n  name: robots-txt-policy-disallow-2\n- action: DENY\n  expression:\n    any:\n      - userAgent.contains(\"BadBot\")\n      - userAgent.contains(\"SpamBot\")\n      - userAgent.contains(\"EvilBot\")\n  name: robots-txt-policy-blacklist-3\n- action: CHALLENGE\n  expression:\n    all:\n      - userAgent.contains(\"GoodBot\")\n      - path.startsWith(\"/private\")\n  name: robots-txt-policy-disallow-4\n- action: WEIGH\n  expression:\n    any:\n      - userAgent.contains(\"SlowBot1\")\n      - userAgent.contains(\"SlowBot2\")\n  name: robots-txt-policy-crawl-delay-5\n  weight:\n    adjust: 3\n- action: CHALLENGE\n  expression:\n    all:\n      - userAgent.contains(\"SearchBot1\")\n      - path.startsWith(\"/search\")\n  name: robots-txt-policy-disallow-7\n- action: CHALLENGE\n  expression:\n    all:\n      - userAgent.contains(\"SearchBot2\")\n      - path.startsWith(\"/search\")\n  name: robots-txt-policy-disallow-8\n- action: CHALLENGE\n  expression:\n    all:\n      - userAgent.contains(\"SearchBot3\")\n      - path.startsWith(\"/search\")\n  name: robots-txt-policy-disallow-9\n"
  },
  {
    "path": "cmd/robots2policy/testdata/custom-name.yaml",
    "content": "- action: CHALLENGE\n  expression: path.startsWith(\"/admin/\")\n  name: my-custom-policy-disallow-1\n- action: CHALLENGE\n  expression: path.startsWith(\"/private\")\n  name: my-custom-policy-disallow-2\n"
  },
  {
    "path": "cmd/robots2policy/testdata/deny-action.yaml",
    "content": "- action: DENY\n  expression: path.startsWith(\"/admin/\")\n  name: robots-txt-policy-disallow-1\n- action: DENY\n  expression: path.startsWith(\"/private\")\n  name: robots-txt-policy-disallow-2\n"
  },
  {
    "path": "cmd/robots2policy/testdata/empty.robots.txt",
    "content": "# Empty robots.txt (comments only)\n# No actual rules"
  },
  {
    "path": "cmd/robots2policy/testdata/empty.yaml",
    "content": "[]\n"
  },
  {
    "path": "cmd/robots2policy/testdata/simple.json",
    "content": "[\n  {\n    \"expression\": \"path.startsWith(\\\"/admin/\\\")\",\n    \"name\": \"robots-txt-policy-disallow-1\",\n    \"action\": \"CHALLENGE\"\n  },\n  {\n    \"expression\": \"path.startsWith(\\\"/private\\\")\",\n    \"name\": \"robots-txt-policy-disallow-2\",\n    \"action\": \"CHALLENGE\"\n  }\n]\n"
  },
  {
    "path": "cmd/robots2policy/testdata/simple.robots.txt",
    "content": "# Simple robots.txt test\nUser-agent: *\nDisallow: /admin/\nDisallow: /private\nAllow: /public"
  },
  {
    "path": "cmd/robots2policy/testdata/simple.yaml",
    "content": "- action: CHALLENGE\n  expression: path.startsWith(\"/admin/\")\n  name: robots-txt-policy-disallow-1\n- action: CHALLENGE\n  expression: path.startsWith(\"/private\")\n  name: robots-txt-policy-disallow-2\n"
  },
  {
    "path": "cmd/robots2policy/testdata/wildcards.robots.txt",
    "content": "# Test wildcard patterns\nUser-agent: *\nDisallow: /search*\nDisallow: /*/private\nDisallow: /file?.txt\nDisallow: /admin/*?action=delete"
  },
  {
    "path": "cmd/robots2policy/testdata/wildcards.yaml",
    "content": "- action: CHALLENGE\n  expression: path.matches(\"^/search.*\")\n  name: robots-txt-policy-disallow-1\n- action: CHALLENGE\n  expression: path.matches(\"^/.*/private\")\n  name: robots-txt-policy-disallow-2\n- action: CHALLENGE\n  expression: path.matches(\"^/file.\\\\.txt\")\n  name: robots-txt-policy-disallow-3\n- action: CHALLENGE\n  expression: path.matches(\"^/admin/.*.action=delete\")\n  name: robots-txt-policy-disallow-4\n"
  },
  {
    "path": "data/apps/allow-api-routes.yaml",
    "content": "- name: allow-api-routes\n  action: ALLOW\n  expression:\n    all:\n      - '!(method == \"HEAD\" || method == \"GET\")'\n      - path.startsWith(\"/api/\")\n"
  },
  {
    "path": "data/apps/bookstack-saml.yaml",
    "content": "# Make SASL login work on bookstack with Anubis\n# https://www.bookstackapp.com/docs/admin/saml2-auth/\n- name: allow-bookstack-sasl-login-routes\n  action: ALLOW\n  expression:\n    all:\n      - 'method == \"POST\"'\n      - path.startsWith(\"/saml2/acs\")\n- name: allow-bookstack-sasl-metadata-routes\n  action: ALLOW\n  expression:\n    all:\n      - 'method == \"GET\"'\n      - path.startsWith(\"/saml2/metadata\")\n- name: allow-bookstack-sasl-logout-routes\n  action: ALLOW\n  expression:\n    all:\n      - 'method == \"GET\"'\n      - path.startsWith(\"/saml2/sls\")\n"
  },
  {
    "path": "data/apps/gitea-rss-feeds.yaml",
    "content": "# By Aibrew: https://github.com/TecharoHQ/anubis/discussions/261#discussioncomment-12821065\n- name: gitea-feed-atom\n  action: ALLOW\n  path_regex: ^/[.A-Za-z0-9_-]{1,256}?[./A-Za-z0-9_-]*\\.atom$\n- name: gitea-feed-rss\n  action: ALLOW\n  path_regex: ^/[.A-Za-z0-9_-]{1,256}?[./A-Za-z0-9_-]*\\.rss$\n"
  },
  {
    "path": "data/apps/qualys-ssl-labs.yml",
    "content": "# This policy allows Qualys SSL Labs to fully work. (https://www.ssllabs.com/ssltest)\n# IP ranges are taken from: https://qualys.my.site.com/discussions/s/article/000005823\n- name: qualys-ssl-labs\n  action: ALLOW\n  remote_addresses:\n    - 69.67.183.0/24\n    - 2600:C02:1020:4202::/64\n    - 2602:fdaa:c6:2::/64\n"
  },
  {
    "path": "data/apps/searx-checker.yml",
    "content": "# This policy allows SearXNG's instance tracker to work. (https://searx.space)\n# IPs are taken from `check.searx.space` DNS records.\n# https://toolbox.googleapps.com/apps/dig/#A/check.searx.space\n# https://toolbox.googleapps.com/apps/dig/#AAAA/check.searx.space\n- name: searx-checker\n  action: ALLOW\n  remote_addresses:\n    - 167.235.158.251/32\n    - 2a01:4f8:1c1c:8fc2::1/128\n"
  },
  {
    "path": "data/botPolicies.yaml",
    "content": "## Anubis has the ability to let you import snippets of configuration into the main\n## configuration file. This allows you to break up your config into smaller parts\n## that get logically assembled into one big file.\n##\n## Of note, a bot rule can either have inline bot configuration or import a\n## bot config snippet. You cannot do both in a single bot rule.\n##\n## Import paths can either be prefixed with (data) to import from the common/shared\n## rules in the data folder in the Anubis source tree or will point to absolute/relative\n## paths in your filesystem. If you don't have access to the Anubis source tree, check\n## /usr/share/docs/anubis/data or in the tarball you extracted Anubis from.\n\nbots:\n  # You can import the entire default config with this macro:\n  # - import: (data)/meta/default-config.yaml\n\n  # Pathological bots to deny\n  - # This correlates to data/bots/_deny-pathological.yaml in the source tree\n    # https://github.com/TecharoHQ/anubis/blob/main/data/bots/_deny-pathological.yaml\n    import: (data)/bots/_deny-pathological.yaml\n  - import: (data)/bots/aggressive-brazilian-scrapers.yaml\n\n  # Aggressively block AI/LLM related bots/agents by default\n  - import: (data)/meta/ai-block-aggressive.yaml\n\n  # Consider replacing the aggressive AI policy with more selective policies:\n  # - import: (data)/meta/ai-block-moderate.yaml\n  # - import: (data)/meta/ai-block-permissive.yaml\n\n  # Search engine crawlers to allow, defaults to:\n  #   - Google (so they don't try to bypass Anubis)\n  #   - Apple\n  #   - Bing\n  #   - DuckDuckGo\n  #   - Qwant\n  #   - The Internet Archive\n  #   - Kagi\n  #   - Marginalia\n  #   - Mojeek\n  - import: (data)/crawlers/_allow-good.yaml\n  # Challenge Firefox AI previews\n  - import: (data)/clients/x-firefox-ai.yaml\n\n  # Allow common \"keeping the internet working\" routes (well-known, favicon, robots.txt)\n  - import: (data)/common/keep-internet-working.yaml\n\n  # # Punish any bot with \"bot\" in the user-agent string\n  # # This is known to have a high false-positive rate, use at your own risk\n  # - name: generic-bot-catchall\n  #   user_agent_regex: (?i:bot|crawler)\n  #   action: CHALLENGE\n  #   challenge:\n  #     difficulty: 16 # impossible\n  #     algorithm: slow # intentionally waste CPU cycles and time\n\n  # Requires a subscription to Thoth to use, see\n  # https://anubis.techaro.lol/docs/admin/thoth#geoip-based-filtering\n  - name: countries-with-aggressive-scrapers\n    action: WEIGH\n    geoip:\n      countries:\n        - BR\n        - CN\n    weight:\n      adjust: 10\n\n  # Requires a subscription to Thoth to use, see\n  # https://anubis.techaro.lol/docs/admin/thoth#asn-based-filtering\n  - name: aggressive-asns-without-functional-abuse-contact\n    action: WEIGH\n    asns:\n      match:\n        - 13335 # Cloudflare\n        - 136907 # Huawei Cloud\n        - 45102 # Alibaba Cloud\n    weight:\n      adjust: 10\n\n  # ## System load based checks.\n  # # If the system is under high load, add weight.\n  # - name: high-load-average\n  #   action: WEIGH\n  #   expression: load_1m >= 10.0 # make sure to end the load comparison in a .0\n  #   weight:\n  #     adjust: 20\n\n  ## If your backend service is running on the same operating system as Anubis,\n  ## you can uncomment this rule to make the challenge easier when the system is\n  ## under low load.\n  ##\n  ## If it is not, remove weight.\n  # - name: low-load-average\n  #   action: WEIGH\n  #   expression: load_15m <= 4.0 # make sure to end the load comparison in a .0\n  #   weight:\n  #     adjust: -10\n\n  # Generic catchall rule\n  - name: generic-browser\n    user_agent_regex: >-\n      Mozilla|Opera\n    action: WEIGH\n    weight:\n      adjust: 10\n\ndnsbl: false\n\n# #\n# impressum:\n#   # Displayed at the bottom of every page rendered by Anubis.\n#   footer: >-\n#     This website is hosted by Zombocom. If you have any complaints or notes\n#     about the service, please contact\n#     <a href=\"mailto:contact@domainhere.example\">contact@domainhere.example</a>\n#     and we will assist you as soon as possible.\n\n#   # The imprint page that will be linked to at the footer of every Anubis page.\n#   page:\n#     # The HTML <title> of the page\n#     title: Imprint and Privacy Policy\n#     # The HTML contents of the page. The exact contents of this page can\n#     # and will vary by locale. Please consult with a lawyer if you are not\n#     # sure what to put here\n#     body: >-\n#       <p>Last updated: June 2025</p>\n\n#       <h2>Information that is gathered from visitors</h2>\n\n#       <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>\n\n#       <p>Cookies may be used to remember visitor preferences when interacting with the website.</p>\n\n#       <p>Where registration is required, the visitor's email and a username will be stored on the server.</p>\n\n#       <!-- ... -->\n\n# Open Graph passthrough configuration, see here for more information:\n# https://anubis.techaro.lol/docs/admin/configuration/open-graph/\nopenGraph:\n  # Enables Open Graph passthrough\n  enabled: false\n  # Enables the use of the HTTP host in the cache key, this enables\n  # caching metadata for multiple http hosts at once.\n  considerHost: false\n  # How long cached OpenGraph metadata should last in memory\n  ttl: 24h\n  # # If set, return these opengraph values instead of looking them up with\n  # # the target service.\n  # #\n  # # Correlates to properties in https://ogp.me/\n  # override:\n  #   # og:title is required, it is the title of the website\n  #   \"og:title\": \"Techaro Anubis\"\n  #   \"og:description\": >-\n  #     Anubis is a Web AI Firewall Utility that helps you fight the bots\n  #     away so that you can maintain uptime at work!\n  #   \"description\": >-\n  #     Anubis is a Web AI Firewall Utility that helps you fight the bots\n  #     away so that you can maintain uptime at work!\n\n# By default, send HTTP 200 back to clients that either get issued a challenge\n# or a denial. This seems weird, but this is load-bearing due to the fact that\n# the most aggressive scraper bots seem to really, really, want an HTTP 200 and\n# will stop sending requests once they get it.\nstatus_codes:\n  CHALLENGE: 200\n  DENY: 200\n\n# Anubis can store temporary data in one of a few backends. See the storage\n# backends section of the docs for more information:\n#\n# https://anubis.techaro.lol/docs/admin/policies#storage-backends\nstore:\n  backend: memory\n  parameters: {}\n\n# The weight thresholds for when to trigger individual challenges. Any\n# CHALLENGE will take precedence over this.\n#\n# A threshold has four configuration options:\n#\n#   - name: the name that is reported down the stack and used for metrics\n#   - expression: A CEL expression with the request weight in the variable\n#     weight\n#   - action: the Anubis action to apply, similar to in a bot policy\n#   - challenge: which challenge to send to the user, similar to in a bot policy\n#\n# See https://anubis.techaro.lol/docs/admin/configuration/thresholds for more\n# information.\nthresholds:\n  # By default Anubis ships with the following thresholds:\n  - name: minimal-suspicion # This client is likely fine, its soul is lighter than a feather\n    expression: weight <= 0 # a feather weighs zero units\n    action: ALLOW # Allow the traffic through\n  # For clients that had some weight reduced through custom rules, give them a\n  # lightweight challenge.\n  - name: mild-suspicion\n    expression:\n      all:\n        - weight > 0\n        - weight < 10\n    action: CHALLENGE\n    challenge:\n      # https://anubis.techaro.lol/docs/admin/configuration/challenges/metarefresh\n      algorithm: metarefresh\n      difficulty: 1\n  # For clients that are browser-like but have either gained points from custom rules or\n  # report as a standard browser.\n  - name: moderate-suspicion\n    expression:\n      all:\n        - weight >= 10\n        - weight < 20\n    action: CHALLENGE\n    challenge:\n      # https://anubis.techaro.lol/docs/admin/configuration/challenges/proof-of-work\n      algorithm: fast\n      difficulty: 2 # two leading zeros, very fast for most clients\n  - name: mild-proof-of-work\n    expression:\n      all:\n        - weight >= 20\n        - weight < 30\n    action: CHALLENGE\n    challenge:\n      # https://anubis.techaro.lol/docs/admin/configuration/challenges/proof-of-work\n      algorithm: fast\n      difficulty: 4\n  # For clients that are browser like and have gained many points from custom rules\n  - name: extreme-suspicion\n    expression: weight >= 30\n    action: CHALLENGE\n    challenge:\n      # https://anubis.techaro.lol/docs/admin/configuration/challenges/proof-of-work\n      algorithm: fast\n      difficulty: 6\n"
  },
  {
    "path": "data/bots/_deny-pathological.yaml",
    "content": "- import: (data)/bots/cloudflare-workers.yaml\n- import: (data)/bots/headless-browsers.yaml\n- import: (data)/bots/us-ai-scraper.yaml\n- import: (data)/bots/custom-async-http-client.yaml\n- import: (data)/crawlers/alibaba-cloud.yaml\n- import: (data)/crawlers/huawei-cloud.yaml\n"
  },
  {
    "path": "data/bots/aggressive-brazilian-scrapers.yaml",
    "content": "- name: deny-aggressive-brazilian-scrapers\n  action: WEIGH\n  weight:\n    adjust: 20\n  expression:\n    any:\n      # Internet Explorer should be out of support\n      - userAgent.contains(\"MSIE\")\n      # Trident is the Internet Explorer browser engine\n      - userAgent.contains(\"Trident\")\n      # Opera is a fork of chrome now\n      - userAgent.contains(\"Presto\")\n      # Windows CE is discontinued\n      - userAgent.contains(\"Windows CE\")\n      # Windows 95 is discontinued\n      - userAgent.contains(\"Windows 95\")\n      # Windows 98 is discontinued\n      - userAgent.contains(\"Windows 98\")\n      # Windows 9.x is discontinued\n      - userAgent.contains(\"Win 9x\")\n      # Amazon does not have an Alexa Toolbar.\n      - userAgent.contains(\"Alexa Toolbar\")\n      # This is not released, even Windows 11 calls itself Windows 10\n      - userAgent.contains(\"Windows NT 11.0\")\n      # iPods are not in common use\n      - userAgent.contains(\"iPod\")\n"
  },
  {
    "path": "data/bots/ai-catchall.yaml",
    "content": "# Extensive list of AI-affiliated agents based on https://github.com/ai-robots-txt/ai.robots.txt\n# Add new/undocumented agents here. Where documentation exists, consider moving to dedicated policy files.\n# Notes on various agents:\n#  - Amazonbot: Well documented, but they refuse to state which agent collects training data.\n#  - anthropic-ai/Claude-Web: Undocumented by Anthropic. Possibly deprecated or hallucinations?\n#  - Perplexity*: Well documented, but they refuse to state which agent collects training data.\n# Warning: May contain user agents that _must_ be blocked in robots.txt, or the opt-out will have no effect.\n- name: \"ai-catchall\"\n  user_agent_regex: >-\n    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\n  action: DENY\n"
  },
  {
    "path": "data/bots/ai-robots-txt.yaml",
    "content": "# Warning: Contains user agents that _must_ be blocked in robots.txt, or the opt-out will have no effect.\n# Note: Blocks human-directed/non-training user agents\n#\n# CCBot is allowed because if Common Crawl is allowed, then scrapers don't need to scrape to get the data.\n- name: \"ai-robots-txt\"\n  user_agent_regex: >-\n    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\n  action: DENY\n"
  },
  {
    "path": "data/bots/cloudflare-workers.yaml",
    "content": "- name: cloudflare-workers\n  headers_regex:\n    CF-Worker: .*\n  action: WEIGH\n  weight:\n    adjust: 15\n"
  },
  {
    "path": "data/bots/custom-async-http-client.yaml",
    "content": "- name: \"custom-async-http-client\"\n  user_agent_regex: \"Custom-AsyncHttpClient\"\n  action: WEIGH\n  weight:\n    adjust: 10\n"
  },
  {
    "path": "data/bots/headless-browsers.yaml",
    "content": "- name: lightpanda\n  user_agent_regex: ^LightPanda/.*$\n  action: DENY\n- name: headless-chrome\n  user_agent_regex: HeadlessChrome\n  action: DENY\n- name: headless-chromium\n  user_agent_regex: HeadlessChromium\n  action: DENY\n"
  },
  {
    "path": "data/bots/irc-bots/archlinux-phrik.yaml",
    "content": "# phrik in the Arch Linux IRC channels\n- name: archlinux-phrik\n  action: ALLOW\n  expression:\n    all:\n      - remoteAddress == \"159.69.213.214\" || remoteAddress == \"2a01:4f8:c2c:7bf4::1\"\n      - userAgent == \"Mozilla/5.0 (compatible; utils.web Limnoria module)\"\n      - '\"X-Http-Version\" in headers'\n      - headers[\"X-Http-Version\"] == \"HTTP/1.1\"\n"
  },
  {
    "path": "data/bots/irc-bots/gentoo-chat.yaml",
    "content": "# chat in the gentoo IRC channels\n- name: gentoo-chat\n  action: ALLOW\n  expression:\n    all:\n      - remoteAddress == \"45.76.166.57\"\n      - userAgent == \"Mozilla/5.0 (Linux x86_64; rv:76.0) Gecko/20100101 Firefox/76.0\"\n      - '\"X-Http-Version\" in headers'\n      - headers[\"X-Http-Version\"] == \"HTTP/1.1\"\n"
  },
  {
    "path": "data/bots/us-ai-scraper.yaml",
    "content": "- name: us-artificial-intelligence-scraper\n  user_agent_regex: \\+https\\://github\\.com/US-Artificial-Intelligence/scraper\n  action: DENY\n"
  },
  {
    "path": "data/clients/ai.yaml",
    "content": "# User agents that act on behalf of humans in AI tools, e.g. searching the web.\n# Each entry should have a positive/ALLOW entry created as well, with further documentation.\n# Exceptions:\n#  - Claude-User: No published IP allowlist\n- name: \"ai-clients\"\n  user_agent_regex: >-\n    ChatGPT-User|Claude-User|MistralAI-User|Perplexity-User\n  action: DENY\n"
  },
  {
    "path": "data/clients/docker-client.yaml",
    "content": "- name: allow-docker-client\n  action: ALLOW\n  expression:\n    all:\n      - path.startsWith(\"/v2/\")\n      - userAgent.contains(\"docker/\")\n      - userAgent.contains(\"git-commit/\")\n      - '\"Accept\" in headers'\n      - headers[\"Accept\"].contains(\"vnd.docker.distribution\")\n      - '\"Baggage\" in headers'\n      - headers[\"Baggage\"].contains(\"trigger\")\n\n- name: allow-crane-client\n  action: ALLOW\n  expression:\n    all:\n      - userAgent.contains(\"crane/\")\n      - userAgent.contains(\"go-containerregistry/\")\n\n- name: allow-docker-distribution-api-client\n  action: ALLOW\n  expression:\n    all:\n      - '\"Docker-Distribution-Api-Version\" in headers'\n      - '!(userAgent.contains(\"Mozilla\"))'\n\n- name: allow-go-containerregistry-client\n  action: ALLOW\n  expression:\n    all:\n      - path.startsWith(\"/v2/\")\n      - userAgent.contains(\"go-containerregistry/\")\n\n- name: allow-buildah\n  action: ALLOW\n  expression:\n    all:\n      - path.startsWith(\"/v2/\")\n      - userAgent.contains(\"Buildah/\")\n\n- name: allow-podman\n  action: ALLOW\n  expression:\n    all:\n      - path.startsWith(\"/v2/\")\n      - userAgent.contains(\"containers/\")\n\n- name: allow-containerd\n  action: ALLOW\n  expression:\n    all:\n      - path.startsWith(\"/v2/\")\n      - userAgent.contains(\"containerd/\")\n\n- name: allow-renovate\n  action: ALLOW\n  expression:\n    all:\n      - path.startsWith(\"/v2/\")\n      - userAgent.contains(\"Renovate/\")\n"
  },
  {
    "path": "data/clients/git.yaml",
    "content": "- name: allow-git-clients\n  action: ALLOW\n  expression:\n    all:\n      - >\n        (  \n          userAgent.startsWith(\"git/\") ||\n          userAgent.contains(\"libgit\") ||\n          userAgent.startsWith(\"go-git\") ||\n          userAgent.startsWith(\"JGit/\") ||\n          userAgent.startsWith(\"JGit-\")\n        )\n      - '\"Accept\" in headers'\n      - headers[\"Accept\"] == \"*/*\"\n      - '\"Cache-Control\" in headers'\n      - headers[\"Cache-Control\"] == \"no-cache\"\n      - '\"Pragma\" in headers'\n      - headers[\"Pragma\"] == \"no-cache\"\n      - '\"Accept-Encoding\" in headers'\n      - headers[\"Accept-Encoding\"].contains(\"gzip\")\n"
  },
  {
    "path": "data/clients/go-get.yaml",
    "content": "- name: go-get\n  action: ALLOW\n  expression:\n    all:\n      - userAgent.startsWith(\"Go-http-client/\")\n      - '\"go-get\" in query'\n      - query[\"go-get\"] == \"1\"\n"
  },
  {
    "path": "data/clients/mistral-mistralai-user.yaml",
    "content": "# Acts on behalf of user requests\n# https://docs.mistral.ai/robots/\n- name: mistral-mistralai-user\n  user_agent_regex: MistralAI-User/.+; \\+https\\://docs\\.mistral\\.ai/robots\n  action: ALLOW\n  # https://mistral.ai/mistralai-user-ips.json\n  remote_addresses: [\"20.240.160.161/32\", \"20.240.160.1/32\"]\n"
  },
  {
    "path": "data/clients/openai-chatgpt-user.yaml",
    "content": "# Acts on behalf of user requests\n# https://platform.openai.com/docs/bots/overview-of-openai-crawlers\n- name: openai-chatgpt-user\n  user_agent_regex: ChatGPT-User/.+; \\+https\\://openai\\.com/bot\n  action: ALLOW\n  # https://openai.com/chatgpt-user.json\n  # curl 'https://openai.com/chatgpt-user.json' | jq '.prefixes.[].ipv4Prefix' | sed 's/$/,/'\n  remote_addresses:\n    [\n      \"13.65.138.112/28\",\n      \"23.98.179.16/28\",\n      \"13.65.138.96/28\",\n      \"172.183.222.128/28\",\n      \"20.102.212.144/28\",\n      \"40.116.73.208/28\",\n      \"172.183.143.224/28\",\n      \"52.190.190.16/28\",\n      \"13.83.237.176/28\",\n      \"51.8.155.64/28\",\n      \"74.249.86.176/28\",\n      \"51.8.155.48/28\",\n      \"20.55.229.144/28\",\n      \"135.237.131.208/28\",\n      \"135.237.133.48/28\",\n      \"51.8.155.112/28\",\n      \"135.237.133.112/28\",\n      \"52.159.249.96/28\",\n      \"52.190.137.16/28\",\n      \"52.255.111.112/28\",\n      \"40.84.181.32/28\",\n      \"172.178.141.112/28\",\n      \"52.190.142.64/28\",\n      \"172.178.140.144/28\",\n      \"52.190.137.144/28\",\n      \"172.178.141.128/28\",\n      \"57.154.187.32/28\",\n      \"4.196.118.112/28\",\n      \"20.193.50.32/28\",\n      \"20.215.188.192/28\",\n      \"20.215.214.16/28\",\n      \"4.197.22.112/28\",\n      \"4.197.115.112/28\",\n      \"172.213.21.16/28\",\n      \"172.213.11.144/28\",\n      \"172.213.12.112/28\",\n      \"172.213.21.144/28\",\n      \"20.90.7.144/28\",\n      \"57.154.175.0/28\",\n      \"57.154.174.112/28\",\n      \"52.236.94.144/28\",\n      \"137.135.191.176/28\",\n      \"23.98.186.192/28\",\n      \"23.98.186.96/28\",\n      \"23.98.186.176/28\",\n      \"23.98.186.64/28\",\n      \"68.221.67.192/28\",\n      \"68.221.67.160/28\",\n      \"13.83.167.128/28\",\n      \"20.228.106.176/28\",\n      \"52.159.227.32/28\",\n      \"68.220.57.64/28\",\n      \"172.213.21.112/28\",\n      \"68.221.67.224/28\",\n      \"68.221.75.16/28\",\n      \"20.97.189.96/28\",\n      \"52.252.113.240/28\",\n      \"52.230.163.32/28\",\n      \"172.212.159.64/28\",\n      \"52.255.111.80/28\",\n      \"52.255.111.0/28\",\n      \"4.151.241.240/28\",\n      \"52.255.111.32/28\",\n      \"52.255.111.48/28\",\n      \"52.255.111.16/28\",\n      \"52.230.164.176/28\",\n      \"52.176.139.176/28\",\n      \"52.173.234.16/28\",\n      \"4.151.71.176/28\",\n      \"4.151.119.48/28\",\n      \"52.255.109.112/28\",\n      \"52.255.109.80/28\",\n      \"20.161.75.208/28\",\n      \"68.154.28.96/28\",\n      \"52.255.109.128/28\",\n      \"52.225.75.208/28\",\n      \"52.190.139.48/28\",\n      \"68.221.67.240/28\",\n      \"52.156.77.144/28\",\n      \"52.148.129.32/28\",\n      \"40.84.221.208/28\",\n      \"104.210.139.224/28\",\n      \"40.84.221.224/28\",\n      \"104.210.139.192/28\",\n    ]\n"
  },
  {
    "path": "data/clients/perplexity-user.yaml",
    "content": "# Acts on behalf of user requests\n# https://docs.perplexity.ai/guides/bots\n- name: perplexity-user\n  user_agent_regex: Perplexity-User/.+; \\+https\\://perplexity\\.ai/perplexity-user\n  action: ALLOW\n  # https://www.perplexity.com/perplexity-user.json\n  remote_addresses:\n    [\"44.208.221.197/32\", \"34.193.163.52/32\", \"18.97.21.0/30\", \"18.97.43.80/29\"]\n"
  },
  {
    "path": "data/clients/small-internet-browsers/_permissive.yaml",
    "content": "- import: (data)/clients/small-internet-browsers/netsurf.yaml\n- import: (data)/clients/small-internet-browsers/palemoon.yaml\n"
  },
  {
    "path": "data/clients/small-internet-browsers/netsurf.yaml",
    "content": "- name: \"reduce-weight-netsurf\"\n  user_agent_regex: \"NetSurf\"\n  action: WEIGH\n  weight:\n    adjust: -5\n"
  },
  {
    "path": "data/clients/small-internet-browsers/palemoon.yaml",
    "content": "- name: \"reduce-weight-palemoon\"\n  user_agent_regex: \"PaleMoon\"\n  action: WEIGH\n  weight:\n    adjust: -5\n"
  },
  {
    "path": "data/clients/telegram-preview.yaml",
    "content": "- name: telegrambot\n  action: ALLOW\n  expression:\n    all:\n      - userAgent.matches(\"TelegramBot\")\n      - verifyFCrDNS(remoteAddress, \"ptr\\\\.telegram\\\\.org$\")\n"
  },
  {
    "path": "data/clients/vk-preview.yaml",
    "content": "- name: vkbot\n  action: ALLOW\n  expression:\n    all:\n      - userAgent.matches(\"vkShare[^+]+\\\\+http\\\\://vk\\\\.com/dev/Share\")\n      - verifyFCrDNS(remoteAddress, \"^snipster\\\\d+\\\\.go\\\\.mail\\\\.ru$\")\n"
  },
  {
    "path": "data/clients/x-firefox-ai.yaml",
    "content": "# https://connect.mozilla.org/t5/firefox-labs/try-out-link-previews-in-firefox-labs-138-and-share-your/td-p/92012\n- name: x-firefox-ai\n  action: WEIGH\n  expression: '\"X-Firefox-Ai\" in headers'\n  weight:\n    adjust: 5\n"
  },
  {
    "path": "data/common/acts-like-browser.yaml",
    "content": "# Assert behaviour that only genuine browsers display. This ensures that modern Chrome\n# or Firefox versions will get through without a challenge.\n#\n# These rules have been known to be bypassed by some of the worst automated scrapers.\n# Use at your own risk.\n\n- name: realistic-browser-catchall\n  expression:\n    all:\n      - '\"User-Agent\" in headers'\n      - '( userAgent.contains(\"Firefox\") ) || ( userAgent.contains(\"Chrome\") ) || ( userAgent.contains(\"Safari\") )'\n      - '\"Accept\" in headers'\n      - '\"Sec-Fetch-Dest\" in headers'\n      - '\"Sec-Fetch-Mode\" in headers'\n      - '\"Sec-Fetch-Site\" in headers'\n      - '\"Accept-Encoding\" in headers'\n      - '( headers[\"Accept-Encoding\"].contains(\"zstd\") || headers[\"Accept-Encoding\"].contains(\"br\") )'\n      - '\"Accept-Language\" in headers'\n  action: WEIGH\n  weight:\n    adjust: -10\n\n# The Upgrade-Insecure-Requests header is typically sent by browsers, but not always\n- name: upgrade-insecure-requests\n  expression: '\"Upgrade-Insecure-Requests\" in headers'\n  action: WEIGH\n  weight:\n    adjust: -2\n\n# Chrome should behave like Chrome\n- name: chrome-is-proper\n  expression:\n    all:\n      - userAgent.contains(\"Chrome\")\n      - '\"Sec-Ch-Ua\" in headers'\n      - 'headers[\"Sec-Ch-Ua\"].contains(\"Chromium\")'\n      - '\"Sec-Ch-Ua-Mobile\" in headers'\n      - '\"Sec-Ch-Ua-Platform\" in headers'\n  action: WEIGH\n  weight:\n    adjust: -5\n\n- name: should-have-accept\n  expression: '!(\"Accept\" in headers)'\n  action: WEIGH\n  weight:\n    adjust: 5\n\n# Generic catchall rule\n- name: generic-browser\n  user_agent_regex: >-\n    Mozilla|Opera\n  action: WEIGH\n  weight:\n    adjust: 10\n"
  },
  {
    "path": "data/common/allow-api-like.yaml",
    "content": "- name: allow-api-routes\n  action: ALLOW\n  expression:\n    all:\n      - '!(method == \"HEAD\" || method == \"GET\")'\n      - path.startsWith(\"/api/\")\n"
  },
  {
    "path": "data/common/allow-private-addresses.yaml",
    "content": "- name: ipv4-rfc-1918\n  action: ALLOW\n  remote_addresses:\n    - 10.0.0.0/8\n    - 172.16.0.0/12\n    - 192.168.0.0/16\n    - 100.64.0.0/10\n- name: ipv6-ula\n  action: ALLOW\n  remote_addresses:\n    - fc00::/7\n- name: ipv6-link-local\n  action: ALLOW\n  remote_addresses:\n    - fe80::/10\n"
  },
  {
    "path": "data/common/json-api.yaml",
    "content": "- name: allow-api-requests\n  action: ALLOW\n  expression:\n    all:\n      - '\"Accept\" in headers'\n      - 'headers[\"Accept\"] == \"application/json\"'\n      - 'path.startsWith(\"/api/\")'\n"
  },
  {
    "path": "data/common/keep-internet-working.yaml",
    "content": "# Common \"keeping the internet working\" routes\n- name: well-known\n  path_regex: ^/\\.well-known/.*$\n  action: ALLOW\n- name: favicon\n  path_regex: ^/favicon\\.(?:ico|png|gif|jpg|jpeg|svg)$\n  action: ALLOW\n- name: robots-txt\n  path_regex: ^/robots\\.txt$\n  action: ALLOW\n- name: sitemap\n  path_regex: ^/sitemap\\.xml$\n  action: ALLOW\n"
  },
  {
    "path": "data/common/rfc-violations.yaml",
    "content": "- name: no-user-agent-string\n  action: DENY\n  expression: userAgent == \"\"\n"
  },
  {
    "path": "data/crawlers/_allow-good.yaml",
    "content": "- import: (data)/crawlers/googlebot.yaml\n- import: (data)/crawlers/applebot.yaml\n- import: (data)/crawlers/bingbot.yaml\n- import: (data)/crawlers/duckduckbot.yaml\n- import: (data)/crawlers/qwantbot.yaml\n- import: (data)/crawlers/internet-archive.yaml\n- import: (data)/crawlers/kagibot.yaml\n- import: (data)/crawlers/marginalia.yaml\n- import: (data)/crawlers/mojeekbot.yaml\n- import: (data)/crawlers/commoncrawl.yaml\n- import: (data)/crawlers/wikimedia-citoid.yaml\n- import: (data)/crawlers/yandexbot.yaml\n"
  },
  {
    "path": "data/crawlers/ai-search.yaml",
    "content": "# User agents that index exclusively for search in for AI systems.\n# Each entry should have a positive/ALLOW entry created as well, with further documentation.\n# Exceptions:\n#  - Claude-SearchBot: No published IP allowlist\n- name: \"ai-crawlers-search\"\n  user_agent_regex: >-\n    OAI-SearchBot|Claude-SearchBot|PerplexityBot\n  action: DENY\n"
  },
  {
    "path": "data/crawlers/ai-training.yaml",
    "content": "# User agents that crawl for training AI/LLM systems\n# Each entry should have a positive/ALLOW entry created as well, with further documentation.\n# Exceptions:\n#  - ClaudeBot: No published IP allowlist\n- name: \"ai-crawlers-training\"\n  user_agent_regex: >-\n    GPTBot|ClaudeBot\n  action: DENY\n"
  },
  {
    "path": "data/crawlers/alibaba-cloud.yaml",
    "content": "- name: alibaba-cloud\n  action: DENY\n  # Updated 2025-08-20 from IP addresses for AS45102\n  remote_addresses:\n    - 103.81.186.0/23\n    - 110.76.21.0/24\n    - 110.76.23.0/24\n    - 116.251.64.0/18\n    - 139.95.0.0/23\n    - 139.95.10.0/23\n    - 139.95.12.0/23\n    - 139.95.14.0/23\n    - 139.95.16.0/23\n    - 139.95.18.0/23\n    - 139.95.2.0/23\n    - 139.95.4.0/23\n    - 139.95.6.0/23\n    - 139.95.64.0/24\n    - 139.95.8.0/23\n    - 14.1.112.0/22\n    - 14.1.115.0/24\n    - 140.205.1.0/24\n    - 140.205.122.0/24\n    - 147.139.0.0/17\n    - 147.139.0.0/18\n    - 147.139.128.0/17\n    - 147.139.128.0/18\n    - 147.139.155.0/24\n    - 147.139.192.0/18\n    - 147.139.64.0/18\n    - 149.129.0.0/20\n    - 149.129.0.0/21\n    - 149.129.16.0/23\n    - 149.129.192.0/18\n    - 149.129.192.0/19\n    - 149.129.224.0/19\n    - 149.129.32.0/19\n    - 149.129.64.0/18\n    - 149.129.64.0/19\n    - 149.129.8.0/21\n    - 149.129.96.0/19\n    - 156.227.20.0/24\n    - 156.236.12.0/24\n    - 156.236.17.0/24\n    - 156.240.76.0/23\n    - 156.245.1.0/24\n    - 161.117.0.0/16\n    - 161.117.0.0/17\n    - 161.117.126.0/24\n    - 161.117.127.0/24\n    - 161.117.128.0/17\n    - 161.117.128.0/24\n    - 161.117.129.0/24\n    - 161.117.138.0/24\n    - 161.117.143.0/24\n    - 170.33.104.0/24\n    - 170.33.105.0/24\n    - 170.33.106.0/24\n    - 170.33.107.0/24\n    - 170.33.136.0/24\n    - 170.33.137.0/24\n    - 170.33.138.0/24\n    - 170.33.20.0/24\n    - 170.33.21.0/24\n    - 170.33.22.0/24\n    - 170.33.23.0/24\n    - 170.33.24.0/24\n    - 170.33.29.0/24\n    - 170.33.30.0/24\n    - 170.33.31.0/24\n    - 170.33.32.0/24\n    - 170.33.33.0/24\n    - 170.33.34.0/24\n    - 170.33.35.0/24\n    - 170.33.64.0/24\n    - 170.33.65.0/24\n    - 170.33.66.0/24\n    - 170.33.68.0/24\n    - 170.33.69.0/24\n    - 170.33.72.0/24\n    - 170.33.73.0/24\n    - 170.33.76.0/24\n    - 170.33.77.0/24\n    - 170.33.78.0/24\n    - 170.33.79.0/24\n    - 170.33.80.0/24\n    - 170.33.81.0/24\n    - 170.33.82.0/24\n    - 170.33.83.0/24\n    - 170.33.84.0/24\n    - 170.33.85.0/24\n    - 170.33.86.0/24\n    - 170.33.88.0/24\n    - 170.33.90.0/24\n    - 170.33.92.0/24\n    - 170.33.93.0/24\n    - 185.78.106.0/23\n    - 198.11.128.0/18\n    - 198.11.137.0/24\n    - 198.11.184.0/21\n    - 202.144.199.0/24\n    - 203.107.64.0/24\n    - 203.107.65.0/24\n    - 203.107.66.0/24\n    - 203.107.67.0/24\n    - 203.107.68.0/24\n    - 205.204.102.0/23\n    - 205.204.111.0/24\n    - 205.204.117.0/24\n    - 205.204.125.0/24\n    - 205.204.96.0/19\n    - 223.5.5.0/24\n    - 223.6.6.0/24\n    - 2400:3200::/48\n    - 2400:3200:baba::/48\n    - 2400:b200:4100::/48\n    - 2400:b200:4101::/48\n    - 2400:b200:4102::/48\n    - 2400:b200:4103::/48\n    - 2401:8680:4100::/48\n    - 2401:b180:4100::/48\n    - 2404:2280:1000::/36\n    - 2404:2280:1000::/37\n    - 2404:2280:1800::/37\n    - 2404:2280:2000::/36\n    - 2404:2280:2000::/37\n    - 2404:2280:2800::/37\n    - 2404:2280:3000::/36\n    - 2404:2280:3000::/37\n    - 2404:2280:3800::/37\n    - 2404:2280:4000::/36\n    - 2404:2280:4000::/37\n    - 2404:2280:4800::/37\n    - 2408:4000:1000::/48\n    - 2408:4009:500::/48\n    - 240b:4000::/32\n    - 240b:4000::/33\n    - 240b:4000:8000::/33\n    - 240b:4000:fffe::/48\n    - 240b:4001::/32\n    - 240b:4001::/33\n    - 240b:4001:8000::/33\n    - 240b:4002::/32\n    - 240b:4002::/33\n    - 240b:4002:8000::/33\n    - 240b:4004::/32\n    - 240b:4004::/33\n    - 240b:4004:8000::/33\n    - 240b:4005::/32\n    - 240b:4005::/33\n    - 240b:4005:8000::/33\n    - 240b:4006::/48\n    - 240b:4006:1000::/44\n    - 240b:4006:1000::/45\n    - 240b:4006:1000::/47\n    - 240b:4006:1002::/47\n    - 240b:4006:1008::/45\n    - 240b:4006:1010::/44\n    - 240b:4006:1010::/45\n    - 240b:4006:1018::/45\n    - 240b:4006:1020::/44\n    - 240b:4006:1020::/45\n    - 240b:4006:1028::/45\n    - 240b:4007::/32\n    - 240b:4007::/33\n    - 240b:4007:8000::/33\n    - 240b:4009::/32\n    - 240b:4009::/33\n    - 240b:4009:8000::/33\n    - 240b:400b::/32\n    - 240b:400b::/33\n    - 240b:400b:8000::/33\n    - 240b:400c::/32\n    - 240b:400c::/33\n    - 240b:400c::/40\n    - 240b:400c::/41\n    - 240b:400c:100::/40\n    - 240b:400c:100::/41\n    - 240b:400c:180::/41\n    - 240b:400c:80::/41\n    - 240b:400c:8000::/33\n    - 240b:400c:f00::/48\n    - 240b:400c:f01::/48\n    - 240b:400c:ffff::/48\n    - 240b:400d::/32\n    - 240b:400d::/33\n    - 240b:400d:8000::/33\n    - 240b:400e::/32\n    - 240b:400e::/33\n    - 240b:400e:8000::/33\n    - 240b:400f::/32\n    - 240b:400f::/33\n    - 240b:400f:8000::/33\n    - 240b:4011::/32\n    - 240b:4011::/33\n    - 240b:4011:8000::/33\n    - 240b:4012::/48\n    - 240b:4013::/32\n    - 240b:4013::/33\n    - 240b:4013:8000::/33\n    - 240b:4014::/32\n    - 240b:4014::/33\n    - 240b:4014:8000::/33\n    - 43.100.0.0/15\n    - 43.100.0.0/16\n    - 43.101.0.0/16\n    - 43.102.0.0/20\n    - 43.102.112.0/20\n    - 43.102.16.0/20\n    - 43.102.32.0/20\n    - 43.102.48.0/20\n    - 43.102.64.0/20\n    - 43.102.80.0/20\n    - 43.102.96.0/20\n    - 43.103.0.0/17\n    - 43.103.0.0/18\n    - 43.103.64.0/18\n    - 43.104.0.0/15\n    - 43.104.0.0/16\n    - 43.105.0.0/16\n    - 43.108.0.0/17\n    - 43.108.0.0/18\n    - 43.108.64.0/18\n    - 43.91.0.0/16\n    - 43.91.0.0/17\n    - 43.91.128.0/17\n    - 43.96.10.0/24\n    - 43.96.100.0/24\n    - 43.96.101.0/24\n    - 43.96.102.0/24\n    - 43.96.104.0/24\n    - 43.96.11.0/24\n    - 43.96.20.0/24\n    - 43.96.21.0/24\n    - 43.96.23.0/24\n    - 43.96.24.0/24\n    - 43.96.25.0/24\n    - 43.96.3.0/24\n    - 43.96.32.0/24\n    - 43.96.33.0/24\n    - 43.96.34.0/24\n    - 43.96.35.0/24\n    - 43.96.4.0/24\n    - 43.96.40.0/24\n    - 43.96.5.0/24\n    - 43.96.52.0/24\n    - 43.96.6.0/24\n    - 43.96.66.0/24\n    - 43.96.67.0/24\n    - 43.96.68.0/24\n    - 43.96.69.0/24\n    - 43.96.7.0/24\n    - 43.96.70.0/24\n    - 43.96.71.0/24\n    - 43.96.72.0/24\n    - 43.96.73.0/24\n    - 43.96.74.0/24\n    - 43.96.75.0/24\n    - 43.96.8.0/24\n    - 43.96.80.0/24\n    - 43.96.81.0/24\n    - 43.96.84.0/24\n    - 43.96.85.0/24\n    - 43.96.86.0/24\n    - 43.96.88.0/24\n    - 43.96.9.0/24\n    - 43.96.96.0/24\n    - 43.98.0.0/16\n    - 43.98.0.0/17\n    - 43.98.128.0/17\n    - 43.99.0.0/16\n    - 43.99.0.0/17\n    - 43.99.128.0/17\n    - 45.199.179.0/24\n    - 47.235.0.0/22\n    - 47.235.0.0/23\n    - 47.235.1.0/24\n    - 47.235.10.0/23\n    - 47.235.10.0/24\n    - 47.235.11.0/24\n    - 47.235.12.0/23\n    - 47.235.12.0/24\n    - 47.235.13.0/24\n    - 47.235.16.0/23\n    - 47.235.16.0/24\n    - 47.235.18.0/23\n    - 47.235.18.0/24\n    - 47.235.19.0/24\n    - 47.235.2.0/23\n    - 47.235.20.0/24\n    - 47.235.21.0/24\n    - 47.235.22.0/24\n    - 47.235.23.0/24\n    - 47.235.24.0/22\n    - 47.235.24.0/23\n    - 47.235.26.0/23\n    - 47.235.28.0/23\n    - 47.235.28.0/24\n    - 47.235.29.0/24\n    - 47.235.30.0/24\n    - 47.235.31.0/24\n    - 47.235.4.0/24\n    - 47.235.5.0/24\n    - 47.235.6.0/23\n    - 47.235.6.0/24\n    - 47.235.7.0/24\n    - 47.235.8.0/24\n    - 47.235.9.0/24\n    - 47.236.0.0/15\n    - 47.236.0.0/16\n    - 47.237.0.0/16\n    - 47.237.32.0/20\n    - 47.237.34.0/24\n    - 47.238.0.0/15\n    - 47.238.0.0/16\n    - 47.239.0.0/16\n    - 47.240.0.0/16\n    - 47.240.0.0/17\n    - 47.240.128.0/17\n    - 47.241.0.0/16\n    - 47.241.0.0/17\n    - 47.241.128.0/17\n    - 47.242.0.0/15\n    - 47.242.0.0/16\n    - 47.243.0.0/16\n    - 47.244.0.0/16\n    - 47.244.0.0/17\n    - 47.244.128.0/17\n    - 47.244.73.0/24\n    - 47.245.0.0/18\n    - 47.245.0.0/19\n    - 47.245.128.0/17\n    - 47.245.128.0/18\n    - 47.245.192.0/18\n    - 47.245.32.0/19\n    - 47.245.64.0/18\n    - 47.245.64.0/19\n    - 47.245.96.0/19\n    - 47.246.100.0/22\n    - 47.246.104.0/21\n    - 47.246.104.0/22\n    - 47.246.108.0/22\n    - 47.246.120.0/24\n    - 47.246.122.0/24\n    - 47.246.123.0/24\n    - 47.246.124.0/24\n    - 47.246.125.0/24\n    - 47.246.128.0/22\n    - 47.246.128.0/23\n    - 47.246.130.0/23\n    - 47.246.132.0/22\n    - 47.246.132.0/23\n    - 47.246.134.0/23\n    - 47.246.136.0/21\n    - 47.246.136.0/22\n    - 47.246.140.0/22\n    - 47.246.144.0/23\n    - 47.246.144.0/24\n    - 47.246.145.0/24\n    - 47.246.146.0/23\n    - 47.246.146.0/24\n    - 47.246.147.0/24\n    - 47.246.150.0/23\n    - 47.246.150.0/24\n    - 47.246.151.0/24\n    - 47.246.152.0/23\n    - 47.246.152.0/24\n    - 47.246.153.0/24\n    - 47.246.154.0/24\n    - 47.246.155.0/24\n    - 47.246.156.0/22\n    - 47.246.156.0/23\n    - 47.246.158.0/23\n    - 47.246.160.0/20\n    - 47.246.160.0/21\n    - 47.246.168.0/21\n    - 47.246.176.0/20\n    - 47.246.176.0/21\n    - 47.246.184.0/21\n    - 47.246.192.0/22\n    - 47.246.192.0/23\n    - 47.246.194.0/23\n    - 47.246.196.0/22\n    - 47.246.196.0/23\n    - 47.246.198.0/23\n    - 47.246.32.0/22\n    - 47.246.66.0/24\n    - 47.246.67.0/24\n    - 47.246.68.0/23\n    - 47.246.68.0/24\n    - 47.246.69.0/24\n    - 47.246.72.0/21\n    - 47.246.72.0/22\n    - 47.246.76.0/22\n    - 47.246.80.0/24\n    - 47.246.82.0/23\n    - 47.246.82.0/24\n    - 47.246.83.0/24\n    - 47.246.84.0/22\n    - 47.246.84.0/23\n    - 47.246.86.0/23\n    - 47.246.88.0/22\n    - 47.246.88.0/23\n    - 47.246.90.0/23\n    - 47.246.92.0/23\n    - 47.246.92.0/24\n    - 47.246.93.0/24\n    - 47.246.96.0/21\n    - 47.246.96.0/22\n    - 47.250.0.0/17\n    - 47.250.0.0/18\n    - 47.250.128.0/17\n    - 47.250.128.0/18\n    - 47.250.192.0/18\n    - 47.250.64.0/18\n    - 47.250.99.0/24\n    - 47.251.0.0/16\n    - 47.251.0.0/17\n    - 47.251.128.0/17\n    - 47.251.224.0/22\n    - 47.252.0.0/17\n    - 47.252.0.0/18\n    - 47.252.128.0/17\n    - 47.252.128.0/18\n    - 47.252.192.0/18\n    - 47.252.64.0/18\n    - 47.252.67.0/24\n    - 47.253.0.0/16\n    - 47.253.0.0/17\n    - 47.253.128.0/17\n    - 47.254.0.0/17\n    - 47.254.0.0/18\n    - 47.254.113.0/24\n    - 47.254.128.0/18\n    - 47.254.128.0/19\n    - 47.254.160.0/19\n    - 47.254.192.0/18\n    - 47.254.192.0/19\n    - 47.254.224.0/19\n    - 47.254.64.0/18\n    - 47.52.0.0/16\n    - 47.52.0.0/17\n    - 47.52.128.0/17\n    - 47.56.0.0/15\n    - 47.56.0.0/16\n    - 47.57.0.0/16\n    - 47.74.0.0/18\n    - 47.74.0.0/19\n    - 47.74.0.0/21\n    - 47.74.128.0/17\n    - 47.74.128.0/18\n    - 47.74.192.0/18\n    - 47.74.32.0/19\n    - 47.74.64.0/18\n    - 47.74.64.0/19\n    - 47.74.96.0/19\n    - 47.75.0.0/16\n    - 47.75.0.0/17\n    - 47.75.128.0/17\n    - 47.76.0.0/16\n    - 47.76.0.0/17\n    - 47.76.128.0/17\n    - 47.77.0.0/22\n    - 47.77.0.0/23\n    - 47.77.104.0/21\n    - 47.77.12.0/22\n    - 47.77.128.0/17\n    - 47.77.128.0/18\n    - 47.77.128.0/21\n    - 47.77.136.0/21\n    - 47.77.144.0/21\n    - 47.77.152.0/21\n    - 47.77.16.0/21\n    - 47.77.16.0/22\n    - 47.77.192.0/18\n    - 47.77.2.0/23\n    - 47.77.20.0/22\n    - 47.77.24.0/22\n    - 47.77.24.0/23\n    - 47.77.26.0/23\n    - 47.77.32.0/19\n    - 47.77.32.0/20\n    - 47.77.4.0/22\n    - 47.77.4.0/23\n    - 47.77.48.0/20\n    - 47.77.6.0/23\n    - 47.77.64.0/19\n    - 47.77.64.0/20\n    - 47.77.8.0/21\n    - 47.77.8.0/22\n    - 47.77.80.0/20\n    - 47.77.96.0/20\n    - 47.77.96.0/21\n    - 47.78.0.0/17\n    - 47.78.128.0/17\n    - 47.79.0.0/20\n    - 47.79.0.0/21\n    - 47.79.104.0/21\n    - 47.79.112.0/20\n    - 47.79.128.0/19\n    - 47.79.128.0/20\n    - 47.79.144.0/20\n    - 47.79.16.0/20\n    - 47.79.16.0/21\n    - 47.79.192.0/18\n    - 47.79.192.0/19\n    - 47.79.224.0/19\n    - 47.79.24.0/21\n    - 47.79.32.0/20\n    - 47.79.32.0/21\n    - 47.79.40.0/21\n    - 47.79.48.0/20\n    - 47.79.48.0/21\n    - 47.79.52.0/23\n    - 47.79.54.0/23\n    - 47.79.56.0/21\n    - 47.79.56.0/23\n    - 47.79.58.0/23\n    - 47.79.60.0/23\n    - 47.79.62.0/23\n    - 47.79.64.0/20\n    - 47.79.64.0/21\n    - 47.79.72.0/21\n    - 47.79.8.0/21\n    - 47.79.80.0/20\n    - 47.79.80.0/21\n    - 47.79.83.0/24\n    - 47.79.88.0/21\n    - 47.79.96.0/19\n    - 47.79.96.0/20\n    - 47.80.0.0/18\n    - 47.80.0.0/19\n    - 47.80.128.0/17\n    - 47.80.128.0/18\n    - 47.80.192.0/18\n    - 47.80.32.0/19\n    - 47.80.64.0/18\n    - 47.80.64.0/19\n    - 47.80.96.0/19\n    - 47.81.0.0/18\n    - 47.81.0.0/19\n    - 47.81.128.0/17\n    - 47.81.128.0/18\n    - 47.81.192.0/18\n    - 47.81.32.0/19\n    - 47.81.64.0/18\n    - 47.81.64.0/19\n    - 47.81.96.0/19\n    - 47.82.0.0/18\n    - 47.82.0.0/19\n    - 47.82.10.0/23\n    - 47.82.12.0/23\n    - 47.82.128.0/17\n    - 47.82.128.0/18\n    - 47.82.14.0/23\n    - 47.82.192.0/18\n    - 47.82.32.0/19\n    - 47.82.32.0/21\n    - 47.82.40.0/21\n    - 47.82.48.0/21\n    - 47.82.56.0/21\n    - 47.82.64.0/18\n    - 47.82.64.0/19\n    - 47.82.8.0/23\n    - 47.82.96.0/19\n    - 47.83.0.0/16\n    - 47.83.0.0/17\n    - 47.83.128.0/17\n    - 47.83.32.0/21\n    - 47.83.40.0/21\n    - 47.83.48.0/21\n    - 47.83.56.0/21\n    - 47.84.0.0/16\n    - 47.84.0.0/17\n    - 47.84.128.0/17\n    - 47.84.144.0/21\n    - 47.84.152.0/21\n    - 47.84.160.0/21\n    - 47.84.168.0/21\n    - 47.85.0.0/16\n    - 47.85.0.0/17\n    - 47.85.112.0/22\n    - 47.85.112.0/23\n    - 47.85.114.0/23\n    - 47.85.128.0/17\n    - 47.86.0.0/16\n    - 47.86.0.0/17\n    - 47.86.128.0/17\n    - 47.87.0.0/18\n    - 47.87.0.0/19\n    - 47.87.128.0/18\n    - 47.87.128.0/19\n    - 47.87.160.0/19\n    - 47.87.192.0/22\n    - 47.87.192.0/23\n    - 47.87.194.0/23\n    - 47.87.196.0/22\n    - 47.87.196.0/23\n    - 47.87.198.0/23\n    - 47.87.200.0/22\n    - 47.87.200.0/23\n    - 47.87.202.0/23\n    - 47.87.204.0/22\n    - 47.87.204.0/23\n    - 47.87.206.0/23\n    - 47.87.208.0/22\n    - 47.87.208.0/23\n    - 47.87.210.0/23\n    - 47.87.212.0/22\n    - 47.87.212.0/23\n    - 47.87.214.0/23\n    - 47.87.216.0/22\n    - 47.87.216.0/23\n    - 47.87.218.0/23\n    - 47.87.220.0/22\n    - 47.87.220.0/23\n    - 47.87.222.0/23\n    - 47.87.224.0/22\n    - 47.87.224.0/23\n    - 47.87.226.0/23\n    - 47.87.228.0/22\n    - 47.87.228.0/23\n    - 47.87.230.0/23\n    - 47.87.232.0/22\n    - 47.87.232.0/23\n    - 47.87.234.0/23\n    - 47.87.236.0/22\n    - 47.87.236.0/23\n    - 47.87.238.0/23\n    - 47.87.240.0/22\n    - 47.87.240.0/23\n    - 47.87.242.0/23\n    - 47.87.32.0/19\n    - 47.87.64.0/18\n    - 47.87.64.0/19\n    - 47.87.96.0/19\n    - 47.88.0.0/17\n    - 47.88.0.0/18\n    - 47.88.109.0/24\n    - 47.88.128.0/17\n    - 47.88.128.0/18\n    - 47.88.135.0/24\n    - 47.88.192.0/18\n    - 47.88.41.0/24\n    - 47.88.42.0/24\n    - 47.88.43.0/24\n    - 47.88.64.0/18\n    - 47.89.0.0/18\n    - 47.89.0.0/19\n    - 47.89.100.0/24\n    - 47.89.101.0/24\n    - 47.89.102.0/24\n    - 47.89.103.0/24\n    - 47.89.104.0/21\n    - 47.89.104.0/22\n    - 47.89.108.0/22\n    - 47.89.122.0/24\n    - 47.89.123.0/24\n    - 47.89.124.0/23\n    - 47.89.124.0/24\n    - 47.89.125.0/24\n    - 47.89.128.0/18\n    - 47.89.128.0/19\n    - 47.89.160.0/19\n    - 47.89.192.0/18\n    - 47.89.192.0/19\n    - 47.89.221.0/24\n    - 47.89.224.0/19\n    - 47.89.32.0/19\n    - 47.89.72.0/22\n    - 47.89.72.0/23\n    - 47.89.74.0/23\n    - 47.89.76.0/22\n    - 47.89.76.0/23\n    - 47.89.78.0/23\n    - 47.89.80.0/23\n    - 47.89.82.0/23\n    - 47.89.84.0/24\n    - 47.89.88.0/22\n    - 47.89.88.0/23\n    - 47.89.90.0/23\n    - 47.89.92.0/22\n    - 47.89.92.0/23\n    - 47.89.94.0/23\n    - 47.89.96.0/24\n    - 47.89.97.0/24\n    - 47.89.98.0/23\n    - 47.89.99.0/24\n    - 47.90.0.0/17\n    - 47.90.0.0/18\n    - 47.90.128.0/17\n    - 47.90.128.0/18\n    - 47.90.172.0/24\n    - 47.90.173.0/24\n    - 47.90.174.0/24\n    - 47.90.175.0/24\n    - 47.90.192.0/18\n    - 47.90.64.0/18\n    - 47.91.0.0/19\n    - 47.91.0.0/20\n    - 47.91.112.0/20\n    - 47.91.128.0/17\n    - 47.91.128.0/18\n    - 47.91.16.0/20\n    - 47.91.192.0/18\n    - 47.91.32.0/19\n    - 47.91.32.0/20\n    - 47.91.48.0/20\n    - 47.91.64.0/19\n    - 47.91.64.0/20\n    - 47.91.80.0/20\n    - 47.91.96.0/19\n    - 47.91.96.0/20\n    - 5.181.224.0/23\n    - 59.82.136.0/23\n    - 8.208.0.0/16\n    - 8.208.0.0/17\n    - 8.208.0.0/18\n    - 8.208.0.0/19\n    - 8.208.128.0/17\n    - 8.208.141.0/24\n    - 8.208.32.0/19\n    - 8.209.0.0/19\n    - 8.209.0.0/20\n    - 8.209.128.0/18\n    - 8.209.128.0/19\n    - 8.209.16.0/20\n    - 8.209.160.0/19\n    - 8.209.192.0/18\n    - 8.209.192.0/19\n    - 8.209.224.0/19\n    - 8.209.36.0/23\n    - 8.209.36.0/24\n    - 8.209.37.0/24\n    - 8.209.38.0/23\n    - 8.209.38.0/24\n    - 8.209.39.0/24\n    - 8.209.40.0/22\n    - 8.209.40.0/23\n    - 8.209.42.0/23\n    - 8.209.44.0/22\n    - 8.209.44.0/23\n    - 8.209.46.0/23\n    - 8.209.48.0/20\n    - 8.209.48.0/21\n    - 8.209.56.0/21\n    - 8.209.64.0/18\n    - 8.209.64.0/19\n    - 8.209.96.0/19\n    - 8.210.0.0/16\n    - 8.210.0.0/17\n    - 8.210.128.0/17\n    - 8.210.240.0/24\n    - 8.211.0.0/17\n    - 8.211.0.0/18\n    - 8.211.104.0/21\n    - 8.211.128.0/18\n    - 8.211.128.0/19\n    - 8.211.160.0/19\n    - 8.211.192.0/18\n    - 8.211.192.0/19\n    - 8.211.224.0/19\n    - 8.211.226.0/24\n    - 8.211.64.0/18\n    - 8.211.80.0/21\n    - 8.211.88.0/21\n    - 8.211.96.0/21\n    - 8.212.0.0/17\n    - 8.212.0.0/18\n    - 8.212.128.0/18\n    - 8.212.128.0/19\n    - 8.212.160.0/19\n    - 8.212.192.0/18\n    - 8.212.192.0/19\n    - 8.212.224.0/19\n    - 8.212.64.0/18\n    - 8.213.0.0/17\n    - 8.213.0.0/18\n    - 8.213.128.0/19\n    - 8.213.128.0/20\n    - 8.213.144.0/20\n    - 8.213.160.0/21\n    - 8.213.160.0/22\n    - 8.213.164.0/22\n    - 8.213.176.0/20\n    - 8.213.176.0/21\n    - 8.213.184.0/21\n    - 8.213.192.0/18\n    - 8.213.192.0/19\n    - 8.213.224.0/19\n    - 8.213.251.0/24\n    - 8.213.252.0/24\n    - 8.213.253.0/24\n    - 8.213.64.0/18\n    - 8.214.0.0/16\n    - 8.214.0.0/17\n    - 8.214.128.0/17\n    - 8.215.0.0/16\n    - 8.215.0.0/17\n    - 8.215.128.0/17\n    - 8.215.160.0/24\n    - 8.215.162.0/23\n    - 8.215.168.0/24\n    - 8.215.169.0/24\n    - 8.216.0.0/17\n    - 8.216.0.0/18\n    - 8.216.128.0/17\n    - 8.216.128.0/18\n    - 8.216.148.0/24\n    - 8.216.192.0/18\n    - 8.216.64.0/18\n    - 8.216.69.0/24\n    - 8.216.74.0/24\n    - 8.217.0.0/16\n    - 8.217.0.0/17\n    - 8.217.128.0/17\n    - 8.218.0.0/16\n    - 8.218.0.0/17\n    - 8.218.128.0/17\n    - 8.219.0.0/16\n    - 8.219.0.0/17\n    - 8.219.128.0/17\n    - 8.219.40.0/21\n    - 8.220.116.0/24\n    - 8.220.128.0/18\n    - 8.220.128.0/19\n    - 8.220.147.0/24\n    - 8.220.160.0/19\n    - 8.220.192.0/18\n    - 8.220.192.0/19\n    - 8.220.224.0/19\n    - 8.220.229.0/24\n    - 8.220.64.0/18\n    - 8.220.64.0/19\n    - 8.220.96.0/19\n    - 8.221.0.0/17\n    - 8.221.0.0/18\n    - 8.221.0.0/21\n    - 8.221.128.0/17\n    - 8.221.128.0/18\n    - 8.221.184.0/22\n    - 8.221.188.0/22\n    - 8.221.192.0/18\n    - 8.221.192.0/21\n    - 8.221.200.0/21\n    - 8.221.208.0/21\n    - 8.221.216.0/21\n    - 8.221.48.0/21\n    - 8.221.56.0/21\n    - 8.221.64.0/18\n    - 8.221.8.0/21\n    - 8.222.0.0/20\n    - 8.222.0.0/21\n    - 8.222.112.0/20\n    - 8.222.128.0/17\n    - 8.222.128.0/18\n    - 8.222.16.0/20\n    - 8.222.16.0/21\n    - 8.222.192.0/18\n    - 8.222.24.0/21\n    - 8.222.32.0/20\n    - 8.222.32.0/21\n    - 8.222.40.0/21\n    - 8.222.48.0/20\n    - 8.222.48.0/21\n    - 8.222.56.0/21\n    - 8.222.64.0/20\n    - 8.222.64.0/21\n    - 8.222.72.0/21\n    - 8.222.8.0/21\n    - 8.222.80.0/20\n    - 8.222.80.0/21\n    - 8.222.88.0/21\n    - 8.222.96.0/19\n    - 8.222.96.0/20\n    - 8.223.0.0/17\n    - 8.223.0.0/18\n    - 8.223.128.0/17\n    - 8.223.128.0/18\n    - 8.223.192.0/18\n    - 8.223.64.0/18\n"
  },
  {
    "path": "data/crawlers/applebot.yaml",
    "content": "# Indexing for search and Siri\n# https://support.apple.com/en-us/119829\n- name: applebot\n  user_agent_regex: Applebot\n  action: ALLOW\n  # https://search.developer.apple.com/applebot.json\n  remote_addresses:\n    [\n      \"17.241.208.160/27\",\n      \"17.241.193.160/27\",\n      \"17.241.200.160/27\",\n      \"17.22.237.0/24\",\n      \"17.22.245.0/24\",\n      \"17.22.253.0/24\",\n      \"17.241.75.0/24\",\n      \"17.241.219.0/24\",\n      \"17.241.227.0/24\",\n      \"17.246.15.0/24\",\n      \"17.246.19.0/24\",\n      \"17.246.23.0/24\",\n    ]\n"
  },
  {
    "path": "data/crawlers/bingbot.yaml",
    "content": "- name: bingbot\n  user_agent_regex: \\+http\\://www\\.bing\\.com/bingbot\\.htm\n  action: ALLOW\n  # https://www.bing.com/toolbox/bingbot.json\n  remote_addresses:\n    [\n      \"157.55.39.0/24\",\n      \"207.46.13.0/24\",\n      \"40.77.167.0/24\",\n      \"13.66.139.0/24\",\n      \"13.66.144.0/24\",\n      \"52.167.144.0/24\",\n      \"13.67.10.16/28\",\n      \"13.69.66.240/28\",\n      \"13.71.172.224/28\",\n      \"139.217.52.0/28\",\n      \"191.233.204.224/28\",\n      \"20.36.108.32/28\",\n      \"20.43.120.16/28\",\n      \"40.79.131.208/28\",\n      \"40.79.186.176/28\",\n      \"52.231.148.0/28\",\n      \"20.79.107.240/28\",\n      \"51.105.67.0/28\",\n      \"20.125.163.80/28\",\n      \"40.77.188.0/22\",\n      \"65.55.210.0/24\",\n      \"199.30.24.0/23\",\n      \"40.77.202.0/24\",\n      \"40.77.139.0/25\",\n      \"20.74.197.0/28\",\n      \"20.15.133.160/27\",\n      \"40.77.177.0/24\",\n      \"40.77.178.0/23\",\n    ]\n"
  },
  {
    "path": "data/crawlers/commoncrawl.yaml",
    "content": "- name: common-crawl\n  user_agent_regex: CCBot\n  action: ALLOW\n  # https://index.commoncrawl.org/ccbot.json\n  remote_addresses:\n    [\n      \"2600:1f28:365:80b0::/60\",\n      \"18.97.9.168/29\",\n      \"18.97.14.80/29\",\n      \"18.97.14.88/30\",\n      \"98.85.178.216/32\",\n    ]\n"
  },
  {
    "path": "data/crawlers/duckduckbot.yaml",
    "content": "- name: duckduckbot\n  user_agent_regex: DuckDuckBot/1\\.1; \\(\\+http\\://duckduckgo\\.com/duckduckbot\\.html\\)\n  action: ALLOW\n  # https://duckduckgo.com/duckduckgo-help-pages/results/duckduckbot\n  remote_addresses:\n    [\n      \"57.152.72.128/32\",\n      \"51.8.253.152/32\",\n      \"40.80.242.63/32\",\n      \"20.12.141.99/32\",\n      \"20.49.136.28/32\",\n      \"51.116.131.221/32\",\n      \"51.107.40.209/32\",\n      \"20.40.133.240/32\",\n      \"20.50.168.91/32\",\n      \"51.120.48.122/32\",\n      \"20.193.45.113/32\",\n      \"40.76.173.151/32\",\n      \"40.76.163.7/32\",\n      \"20.185.79.47/32\",\n      \"52.142.26.175/32\",\n      \"20.185.79.15/32\",\n      \"52.142.24.149/32\",\n      \"40.76.162.208/32\",\n      \"40.76.163.23/32\",\n      \"40.76.162.191/32\",\n      \"40.76.162.247/32\",\n      \"40.88.21.235/32\",\n      \"20.191.45.212/32\",\n      \"52.146.59.12/32\",\n      \"52.146.59.156/32\",\n      \"52.146.59.154/32\",\n      \"52.146.58.236/32\",\n      \"20.62.224.44/32\",\n      \"51.104.180.53/32\",\n      \"51.104.180.47/32\",\n      \"51.104.180.26/32\",\n      \"51.104.146.225/32\",\n      \"51.104.146.235/32\",\n      \"20.73.202.147/32\",\n      \"20.73.132.240/32\",\n      \"20.71.12.143/32\",\n      \"20.56.197.58/32\",\n      \"20.56.197.63/32\",\n      \"20.43.150.93/32\",\n      \"20.43.150.85/32\",\n      \"20.44.222.1/32\",\n      \"40.89.243.175/32\",\n      \"13.89.106.77/32\",\n      \"52.143.242.6/32\",\n      \"52.143.241.111/32\",\n      \"52.154.60.82/32\",\n      \"20.197.209.11/32\",\n      \"20.197.209.27/32\",\n      \"20.226.133.105/32\",\n      \"191.234.216.4/32\",\n      \"191.234.216.178/32\",\n      \"20.53.92.211/32\",\n      \"20.53.91.2/32\",\n      \"20.207.99.197/32\",\n      \"20.207.97.190/32\",\n      \"40.81.250.205/32\",\n      \"40.64.106.11/32\",\n      \"40.64.105.247/32\",\n      \"20.72.242.93/32\",\n      \"20.99.255.235/32\",\n      \"20.113.3.121/32\",\n      \"52.224.16.221/32\",\n      \"52.224.21.53/32\",\n      \"52.224.20.204/32\",\n      \"52.224.21.19/32\",\n      \"52.224.20.249/32\",\n      \"52.224.20.203/32\",\n      \"52.224.20.190/32\",\n      \"52.224.16.229/32\",\n      \"52.224.21.20/32\",\n      \"52.146.63.80/32\",\n      \"52.224.20.227/32\",\n      \"52.224.20.193/32\",\n      \"52.190.37.160/32\",\n      \"52.224.21.23/32\",\n      \"52.224.20.223/32\",\n      \"52.224.20.181/32\",\n      \"52.224.21.49/32\",\n      \"52.224.21.55/32\",\n      \"52.224.21.61/32\",\n      \"52.224.19.152/32\",\n      \"52.224.20.186/32\",\n      \"52.224.21.27/32\",\n      \"52.224.21.51/32\",\n      \"52.224.20.174/32\",\n      \"52.224.21.4/32\",\n      \"51.104.164.109/32\",\n      \"51.104.167.71/32\",\n      \"51.104.160.177/32\",\n      \"51.104.162.149/32\",\n      \"51.104.167.95/32\",\n      \"51.104.167.54/32\",\n      \"51.104.166.111/32\",\n      \"51.104.167.88/32\",\n      \"51.104.161.32/32\",\n      \"51.104.163.250/32\",\n      \"51.104.164.189/32\",\n      \"51.104.167.19/32\",\n      \"51.104.160.167/32\",\n      \"51.104.167.110/32\",\n      \"20.191.44.119/32\",\n      \"51.104.167.104/32\",\n      \"20.191.44.234/32\",\n      \"51.104.164.215/32\",\n      \"51.104.167.52/32\",\n      \"20.191.44.22/32\",\n      \"51.104.167.87/32\",\n      \"51.104.167.96/32\",\n      \"20.191.44.16/32\",\n      \"51.104.167.61/32\",\n      \"51.104.164.147/32\",\n      \"20.50.48.159/32\",\n      \"40.114.182.172/32\",\n      \"20.50.50.130/32\",\n      \"20.50.50.163/32\",\n      \"20.50.50.46/32\",\n      \"40.114.182.153/32\",\n      \"20.50.50.118/32\",\n      \"20.50.49.55/32\",\n      \"20.50.49.25/32\",\n      \"40.114.183.251/32\",\n      \"20.50.50.123/32\",\n      \"20.50.49.237/32\",\n      \"20.50.48.192/32\",\n      \"20.50.50.134/32\",\n      \"51.138.90.233/32\",\n      \"40.114.183.196/32\",\n      \"20.50.50.146/32\",\n      \"40.114.183.88/32\",\n      \"20.50.50.145/32\",\n      \"20.50.50.121/32\",\n      \"20.50.49.40/32\",\n      \"51.138.90.206/32\",\n      \"40.114.182.45/32\",\n      \"51.138.90.161/32\",\n      \"20.50.49.0/32\",\n      \"40.119.232.215/32\",\n      \"104.43.55.167/32\",\n      \"40.119.232.251/32\",\n      \"40.119.232.50/32\",\n      \"40.119.232.146/32\",\n      \"40.119.232.218/32\",\n      \"104.43.54.127/32\",\n      \"104.43.55.117/32\",\n      \"104.43.55.116/32\",\n      \"104.43.55.166/32\",\n      \"52.154.169.50/32\",\n      \"52.154.171.70/32\",\n      \"52.154.170.229/32\",\n      \"52.154.170.113/32\",\n      \"52.154.171.44/32\",\n      \"52.154.172.2/32\",\n      \"52.143.244.81/32\",\n      \"52.154.171.87/32\",\n      \"52.154.171.250/32\",\n      \"52.154.170.28/32\",\n      \"52.154.170.122/32\",\n      \"52.143.243.117/32\",\n      \"52.143.247.235/32\",\n      \"52.154.171.235/32\",\n      \"52.154.171.196/32\",\n      \"52.154.171.0/32\",\n      \"52.154.170.243/32\",\n      \"52.154.170.26/32\",\n      \"52.154.169.200/32\",\n      \"52.154.170.96/32\",\n      \"52.154.170.88/32\",\n      \"52.154.171.150/32\",\n      \"52.154.171.205/32\",\n      \"52.154.170.117/32\",\n      \"52.154.170.209/32\",\n      \"191.235.202.48/32\",\n      \"191.233.3.202/32\",\n      \"191.235.201.214/32\",\n      \"191.233.3.197/32\",\n      \"191.235.202.38/32\",\n      \"20.53.78.144/32\",\n      \"20.193.24.10/32\",\n      \"20.53.78.236/32\",\n      \"20.53.78.138/32\",\n      \"20.53.78.123/32\",\n      \"20.53.78.106/32\",\n      \"20.193.27.215/32\",\n      \"20.193.25.197/32\",\n      \"20.193.12.126/32\",\n      \"20.193.24.251/32\",\n      \"20.204.242.101/32\",\n      \"20.207.72.113/32\",\n      \"20.204.242.19/32\",\n      \"20.219.45.67/32\",\n      \"20.207.72.11/32\",\n      \"20.219.45.190/32\",\n      \"20.204.243.55/32\",\n      \"20.204.241.148/32\",\n      \"20.207.72.110/32\",\n      \"20.204.240.172/32\",\n      \"20.207.72.21/32\",\n      \"20.204.246.81/32\",\n      \"20.207.107.181/32\",\n      \"20.204.246.254/32\",\n      \"20.219.43.246/32\",\n      \"52.149.25.43/32\",\n      \"52.149.61.51/32\",\n      \"52.149.58.139/32\",\n      \"52.149.60.38/32\",\n      \"52.148.165.38/32\",\n      \"52.143.95.162/32\",\n      \"52.149.56.151/32\",\n      \"52.149.30.45/32\",\n      \"52.149.58.173/32\",\n      \"52.143.95.204/32\",\n      \"52.149.28.83/32\",\n      \"52.149.58.69/32\",\n      \"52.148.161.87/32\",\n      \"52.149.58.27/32\",\n      \"52.149.28.18/32\",\n      \"20.79.226.26/32\",\n      \"20.79.239.66/32\",\n      \"20.79.238.198/32\",\n      \"20.113.14.159/32\",\n      \"20.75.144.152/32\",\n      \"20.43.172.120/32\",\n      \"20.53.134.160/32\",\n      \"20.201.15.208/32\",\n      \"20.93.28.24/32\",\n      \"20.61.34.40/32\",\n      \"52.242.224.168/32\",\n      \"20.80.129.80/32\",\n      \"20.195.108.47/32\",\n      \"4.195.133.120/32\",\n      \"4.228.76.163/32\",\n      \"4.182.131.108/32\",\n      \"4.209.224.56/32\",\n      \"108.141.83.74/32\",\n      \"4.213.46.14/32\",\n      \"172.169.17.165/32\",\n      \"51.8.71.117/32\",\n      \"20.3.1.178/32\",\n      \"52.149.56.151/32\",\n      \"52.149.30.45/32\",\n      \"52.149.58.173/32\",\n      \"52.143.95.204/32\",\n      \"52.149.28.83/32\",\n      \"52.149.58.69/32\",\n      \"52.148.161.87/32\",\n      \"52.149.58.27/32\",\n      \"52.149.28.18/32\",\n      \"20.79.226.26/32\",\n      \"20.79.239.66/32\",\n      \"20.79.238.198/32\",\n      \"20.113.14.159/32\",\n      \"20.75.144.152/32\",\n      \"20.43.172.120/32\",\n      \"20.53.134.160/32\",\n      \"20.201.15.208/32\",\n      \"20.93.28.24/32\",\n      \"20.61.34.40/32\",\n      \"52.242.224.168/32\",\n      \"20.80.129.80/32\",\n      \"20.195.108.47/32\",\n      \"4.195.133.120/32\",\n      \"4.228.76.163/32\",\n      \"4.182.131.108/32\",\n      \"4.209.224.56/32\",\n      \"108.141.83.74/32\",\n      \"4.213.46.14/32\",\n      \"172.169.17.165/32\",\n      \"51.8.71.117/32\",\n      \"20.3.1.178/32\",\n    ]\n"
  },
  {
    "path": "data/crawlers/googlebot.yaml",
    "content": "- name: googlebot\n  user_agent_regex: \\+http\\://www\\.google\\.com/bot\\.html\n  action: ALLOW\n  # https://developers.google.com/static/search/apis/ipranges/googlebot.json\n  remote_addresses:\n    [\n      \"2001:4860:4801:10::/64\",\n      \"2001:4860:4801:11::/64\",\n      \"2001:4860:4801:12::/64\",\n      \"2001:4860:4801:13::/64\",\n      \"2001:4860:4801:14::/64\",\n      \"2001:4860:4801:15::/64\",\n      \"2001:4860:4801:16::/64\",\n      \"2001:4860:4801:17::/64\",\n      \"2001:4860:4801:18::/64\",\n      \"2001:4860:4801:19::/64\",\n      \"2001:4860:4801:1a::/64\",\n      \"2001:4860:4801:1b::/64\",\n      \"2001:4860:4801:1c::/64\",\n      \"2001:4860:4801:1d::/64\",\n      \"2001:4860:4801:1e::/64\",\n      \"2001:4860:4801:1f::/64\",\n      \"2001:4860:4801:20::/64\",\n      \"2001:4860:4801:21::/64\",\n      \"2001:4860:4801:22::/64\",\n      \"2001:4860:4801:23::/64\",\n      \"2001:4860:4801:24::/64\",\n      \"2001:4860:4801:25::/64\",\n      \"2001:4860:4801:26::/64\",\n      \"2001:4860:4801:27::/64\",\n      \"2001:4860:4801:28::/64\",\n      \"2001:4860:4801:29::/64\",\n      \"2001:4860:4801:2::/64\",\n      \"2001:4860:4801:2a::/64\",\n      \"2001:4860:4801:2b::/64\",\n      \"2001:4860:4801:2c::/64\",\n      \"2001:4860:4801:2d::/64\",\n      \"2001:4860:4801:2e::/64\",\n      \"2001:4860:4801:2f::/64\",\n      \"2001:4860:4801:31::/64\",\n      \"2001:4860:4801:32::/64\",\n      \"2001:4860:4801:33::/64\",\n      \"2001:4860:4801:34::/64\",\n      \"2001:4860:4801:35::/64\",\n      \"2001:4860:4801:36::/64\",\n      \"2001:4860:4801:37::/64\",\n      \"2001:4860:4801:38::/64\",\n      \"2001:4860:4801:39::/64\",\n      \"2001:4860:4801:3a::/64\",\n      \"2001:4860:4801:3b::/64\",\n      \"2001:4860:4801:3c::/64\",\n      \"2001:4860:4801:3d::/64\",\n      \"2001:4860:4801:3e::/64\",\n      \"2001:4860:4801:40::/64\",\n      \"2001:4860:4801:41::/64\",\n      \"2001:4860:4801:42::/64\",\n      \"2001:4860:4801:43::/64\",\n      \"2001:4860:4801:44::/64\",\n      \"2001:4860:4801:45::/64\",\n      \"2001:4860:4801:46::/64\",\n      \"2001:4860:4801:47::/64\",\n      \"2001:4860:4801:48::/64\",\n      \"2001:4860:4801:49::/64\",\n      \"2001:4860:4801:4a::/64\",\n      \"2001:4860:4801:4b::/64\",\n      \"2001:4860:4801:4c::/64\",\n      \"2001:4860:4801:50::/64\",\n      \"2001:4860:4801:51::/64\",\n      \"2001:4860:4801:52::/64\",\n      \"2001:4860:4801:53::/64\",\n      \"2001:4860:4801:54::/64\",\n      \"2001:4860:4801:55::/64\",\n      \"2001:4860:4801:56::/64\",\n      \"2001:4860:4801:60::/64\",\n      \"2001:4860:4801:61::/64\",\n      \"2001:4860:4801:62::/64\",\n      \"2001:4860:4801:63::/64\",\n      \"2001:4860:4801:64::/64\",\n      \"2001:4860:4801:65::/64\",\n      \"2001:4860:4801:66::/64\",\n      \"2001:4860:4801:67::/64\",\n      \"2001:4860:4801:68::/64\",\n      \"2001:4860:4801:69::/64\",\n      \"2001:4860:4801:6a::/64\",\n      \"2001:4860:4801:6b::/64\",\n      \"2001:4860:4801:6c::/64\",\n      \"2001:4860:4801:6d::/64\",\n      \"2001:4860:4801:6e::/64\",\n      \"2001:4860:4801:6f::/64\",\n      \"2001:4860:4801:70::/64\",\n      \"2001:4860:4801:71::/64\",\n      \"2001:4860:4801:72::/64\",\n      \"2001:4860:4801:73::/64\",\n      \"2001:4860:4801:74::/64\",\n      \"2001:4860:4801:75::/64\",\n      \"2001:4860:4801:76::/64\",\n      \"2001:4860:4801:77::/64\",\n      \"2001:4860:4801:78::/64\",\n      \"2001:4860:4801:79::/64\",\n      \"2001:4860:4801:80::/64\",\n      \"2001:4860:4801:81::/64\",\n      \"2001:4860:4801:82::/64\",\n      \"2001:4860:4801:83::/64\",\n      \"2001:4860:4801:84::/64\",\n      \"2001:4860:4801:85::/64\",\n      \"2001:4860:4801:86::/64\",\n      \"2001:4860:4801:87::/64\",\n      \"2001:4860:4801:88::/64\",\n      \"2001:4860:4801:90::/64\",\n      \"2001:4860:4801:91::/64\",\n      \"2001:4860:4801:92::/64\",\n      \"2001:4860:4801:93::/64\",\n      \"2001:4860:4801:94::/64\",\n      \"2001:4860:4801:95::/64\",\n      \"2001:4860:4801:96::/64\",\n      \"2001:4860:4801:a0::/64\",\n      \"2001:4860:4801:a1::/64\",\n      \"2001:4860:4801:a2::/64\",\n      \"2001:4860:4801:a3::/64\",\n      \"2001:4860:4801:a4::/64\",\n      \"2001:4860:4801:a5::/64\",\n      \"2001:4860:4801:c::/64\",\n      \"2001:4860:4801:f::/64\",\n      \"192.178.5.0/27\",\n      \"192.178.6.0/27\",\n      \"192.178.6.128/27\",\n      \"192.178.6.160/27\",\n      \"192.178.6.192/27\",\n      \"192.178.6.32/27\",\n      \"192.178.6.64/27\",\n      \"192.178.6.96/27\",\n      \"34.100.182.96/28\",\n      \"34.101.50.144/28\",\n      \"34.118.254.0/28\",\n      \"34.118.66.0/28\",\n      \"34.126.178.96/28\",\n      \"34.146.150.144/28\",\n      \"34.147.110.144/28\",\n      \"34.151.74.144/28\",\n      \"34.152.50.64/28\",\n      \"34.154.114.144/28\",\n      \"34.155.98.32/28\",\n      \"34.165.18.176/28\",\n      \"34.175.160.64/28\",\n      \"34.176.130.16/28\",\n      \"34.22.85.0/27\",\n      \"34.64.82.64/28\",\n      \"34.65.242.112/28\",\n      \"34.80.50.80/28\",\n      \"34.88.194.0/28\",\n      \"34.89.10.80/28\",\n      \"34.89.198.80/28\",\n      \"34.96.162.48/28\",\n      \"35.247.243.240/28\",\n      \"66.249.64.0/27\",\n      \"66.249.64.128/27\",\n      \"66.249.64.160/27\",\n      \"66.249.64.224/27\",\n      \"66.249.64.32/27\",\n      \"66.249.64.64/27\",\n      \"66.249.64.96/27\",\n      \"66.249.65.0/27\",\n      \"66.249.65.128/27\",\n      \"66.249.65.160/27\",\n      \"66.249.65.192/27\",\n      \"66.249.65.224/27\",\n      \"66.249.65.32/27\",\n      \"66.249.65.64/27\",\n      \"66.249.65.96/27\",\n      \"66.249.66.0/27\",\n      \"66.249.66.128/27\",\n      \"66.249.66.160/27\",\n      \"66.249.66.192/27\",\n      \"66.249.66.224/27\",\n      \"66.249.66.32/27\",\n      \"66.249.66.64/27\",\n      \"66.249.66.96/27\",\n      \"66.249.68.0/27\",\n      \"66.249.68.128/27\",\n      \"66.249.68.32/27\",\n      \"66.249.68.64/27\",\n      \"66.249.68.96/27\",\n      \"66.249.69.0/27\",\n      \"66.249.69.128/27\",\n      \"66.249.69.160/27\",\n      \"66.249.69.192/27\",\n      \"66.249.69.224/27\",\n      \"66.249.69.32/27\",\n      \"66.249.69.64/27\",\n      \"66.249.69.96/27\",\n      \"66.249.70.0/27\",\n      \"66.249.70.128/27\",\n      \"66.249.70.160/27\",\n      \"66.249.70.192/27\",\n      \"66.249.70.224/27\",\n      \"66.249.70.32/27\",\n      \"66.249.70.64/27\",\n      \"66.249.70.96/27\",\n      \"66.249.71.0/27\",\n      \"66.249.71.128/27\",\n      \"66.249.71.160/27\",\n      \"66.249.71.192/27\",\n      \"66.249.71.224/27\",\n      \"66.249.71.32/27\",\n      \"66.249.71.64/27\",\n      \"66.249.71.96/27\",\n      \"66.249.72.0/27\",\n      \"66.249.72.128/27\",\n      \"66.249.72.160/27\",\n      \"66.249.72.192/27\",\n      \"66.249.72.224/27\",\n      \"66.249.72.32/27\",\n      \"66.249.72.64/27\",\n      \"66.249.72.96/27\",\n      \"66.249.73.0/27\",\n      \"66.249.73.128/27\",\n      \"66.249.73.160/27\",\n      \"66.249.73.192/27\",\n      \"66.249.73.224/27\",\n      \"66.249.73.32/27\",\n      \"66.249.73.64/27\",\n      \"66.249.73.96/27\",\n      \"66.249.74.0/27\",\n      \"66.249.74.128/27\",\n      \"66.249.74.160/27\",\n      \"66.249.74.192/27\",\n      \"66.249.74.32/27\",\n      \"66.249.74.64/27\",\n      \"66.249.74.96/27\",\n      \"66.249.75.0/27\",\n      \"66.249.75.128/27\",\n      \"66.249.75.160/27\",\n      \"66.249.75.192/27\",\n      \"66.249.75.224/27\",\n      \"66.249.75.32/27\",\n      \"66.249.75.64/27\",\n      \"66.249.75.96/27\",\n      \"66.249.76.0/27\",\n      \"66.249.76.128/27\",\n      \"66.249.76.160/27\",\n      \"66.249.76.192/27\",\n      \"66.249.76.224/27\",\n      \"66.249.76.32/27\",\n      \"66.249.76.64/27\",\n      \"66.249.76.96/27\",\n      \"66.249.77.0/27\",\n      \"66.249.77.128/27\",\n      \"66.249.77.160/27\",\n      \"66.249.77.192/27\",\n      \"66.249.77.224/27\",\n      \"66.249.77.32/27\",\n      \"66.249.77.64/27\",\n      \"66.249.77.96/27\",\n      \"66.249.78.0/27\",\n      \"66.249.78.32/27\",\n      \"66.249.79.0/27\",\n      \"66.249.79.128/27\",\n      \"66.249.79.160/27\",\n      \"66.249.79.192/27\",\n      \"66.249.79.224/27\",\n      \"66.249.79.32/27\",\n      \"66.249.79.64/27\",\n      \"66.249.79.96/27\",\n    ]\n"
  },
  {
    "path": "data/crawlers/huawei-cloud.yaml",
    "content": "- name: huawei-cloud\n  action: DENY\n  # Updated 2025-08-20 from IP addresses for AS136907\n  remote_addresses:\n    - 1.178.32.0/20\n    - 1.178.48.0/20\n    - 101.44.0.0/20\n    - 101.44.144.0/20\n    - 101.44.16.0/20\n    - 101.44.160.0/20\n    - 101.44.173.0/24\n    - 101.44.176.0/20\n    - 101.44.192.0/20\n    - 101.44.208.0/22\n    - 101.44.212.0/22\n    - 101.44.216.0/22\n    - 101.44.220.0/22\n    - 101.44.224.0/22\n    - 101.44.228.0/22\n    - 101.44.232.0/22\n    - 101.44.236.0/22\n    - 101.44.240.0/22\n    - 101.44.244.0/22\n    - 101.44.248.0/22\n    - 101.44.252.0/24\n    - 101.44.253.0/24\n    - 101.44.254.0/24\n    - 101.44.255.0/24\n    - 101.44.32.0/20\n    - 101.44.48.0/20\n    - 101.44.64.0/20\n    - 101.44.80.0/20\n    - 101.44.96.0/20\n    - 101.46.0.0/20\n    - 101.46.128.0/21\n    - 101.46.136.0/21\n    - 101.46.144.0/21\n    - 101.46.152.0/21\n    - 101.46.160.0/21\n    - 101.46.168.0/21\n    - 101.46.176.0/21\n    - 101.46.184.0/21\n    - 101.46.192.0/21\n    - 101.46.200.0/21\n    - 101.46.208.0/21\n    - 101.46.216.0/21\n    - 101.46.224.0/22\n    - 101.46.232.0/22\n    - 101.46.236.0/22\n    - 101.46.240.0/22\n    - 101.46.244.0/22\n    - 101.46.248.0/22\n    - 101.46.252.0/24\n    - 101.46.253.0/24\n    - 101.46.254.0/24\n    - 101.46.255.0/24\n    - 101.46.32.0/20\n    - 101.46.48.0/20\n    - 101.46.64.0/20\n    - 101.46.80.0/20\n    - 103.198.203.0/24\n    - 103.215.0.0/24\n    - 103.215.1.0/24\n    - 103.215.3.0/24\n    - 103.240.156.0/22\n    - 103.240.157.0/24\n    - 103.255.60.0/22\n    - 103.255.60.0/24\n    - 103.255.61.0/24\n    - 103.255.62.0/24\n    - 103.255.63.0/24\n    - 103.40.100.0/23\n    - 103.84.110.0/24\n    - 110.238.100.0/22\n    - 110.238.104.0/21\n    - 110.238.112.0/21\n    - 110.238.120.0/22\n    - 110.238.124.0/22\n    - 110.238.64.0/21\n    - 110.238.72.0/21\n    - 110.238.80.0/20\n    - 110.238.96.0/24\n    - 110.238.98.0/24\n    - 110.238.99.0/24\n    - 110.239.127.0/24\n    - 110.239.184.0/22\n    - 110.239.188.0/23\n    - 110.239.190.0/23\n    - 110.239.64.0/19\n    - 110.239.96.0/19\n    - 110.41.208.0/24\n    - 110.41.209.0/24\n    - 110.41.210.0/24\n    - 111.119.192.0/20\n    - 111.119.208.0/20\n    - 111.119.224.0/20\n    - 111.119.240.0/20\n    - 111.91.0.0/20\n    - 111.91.112.0/20\n    - 111.91.16.0/20\n    - 111.91.32.0/20\n    - 111.91.48.0/20\n    - 111.91.64.0/20\n    - 111.91.80.0/20\n    - 111.91.96.0/20\n    - 114.119.128.0/19\n    - 114.119.160.0/21\n    - 114.119.168.0/24\n    - 114.119.169.0/24\n    - 114.119.170.0/24\n    - 114.119.171.0/24\n    - 114.119.172.0/22\n    - 114.119.176.0/20\n    - 115.30.32.0/20\n    - 115.30.48.0/20\n    - 119.12.160.0/20\n    - 119.13.112.0/20\n    - 119.13.160.0/24\n    - 119.13.161.0/24\n    - 119.13.162.0/23\n    - 119.13.163.0/24\n    - 119.13.164.0/22\n    - 119.13.168.0/21\n    - 119.13.168.0/24\n    - 119.13.169.0/24\n    - 119.13.170.0/24\n    - 119.13.172.0/24\n    - 119.13.173.0/24\n    - 119.13.32.0/22\n    - 119.13.36.0/22\n    - 119.13.64.0/24\n    - 119.13.65.0/24\n    - 119.13.66.0/23\n    - 119.13.68.0/22\n    - 119.13.72.0/22\n    - 119.13.76.0/22\n    - 119.13.80.0/21\n    - 119.13.88.0/22\n    - 119.13.92.0/22\n    - 119.13.96.0/20\n    - 119.8.0.0/21\n    - 119.8.128.0/24\n    - 119.8.129.0/24\n    - 119.8.130.0/23\n    - 119.8.132.0/22\n    - 119.8.136.0/21\n    - 119.8.144.0/20\n    - 119.8.160.0/19\n    - 119.8.18.0/24\n    - 119.8.192.0/20\n    - 119.8.192.0/21\n    - 119.8.200.0/21\n    - 119.8.208.0/20\n    - 119.8.21.0/24\n    - 119.8.22.0/24\n    - 119.8.224.0/24\n    - 119.8.227.0/24\n    - 119.8.228.0/22\n    - 119.8.23.0/24\n    - 119.8.232.0/21\n    - 119.8.24.0/21\n    - 119.8.240.0/23\n    - 119.8.242.0/23\n    - 119.8.244.0/24\n    - 119.8.245.0/24\n    - 119.8.246.0/24\n    - 119.8.247.0/24\n    - 119.8.248.0/24\n    - 119.8.249.0/24\n    - 119.8.250.0/24\n    - 119.8.253.0/24\n    - 119.8.254.0/23\n    - 119.8.32.0/19\n    - 119.8.4.0/24\n    - 119.8.64.0/22\n    - 119.8.68.0/24\n    - 119.8.69.0/24\n    - 119.8.70.0/24\n    - 119.8.71.0/24\n    - 119.8.72.0/21\n    - 119.8.8.0/21\n    - 119.8.80.0/20\n    - 119.8.96.0/19\n    - 121.91.152.0/21\n    - 121.91.168.0/21\n    - 121.91.200.0/21\n    - 121.91.200.0/24\n    - 121.91.201.0/24\n    - 121.91.204.0/24\n    - 121.91.205.0/24\n    - 122.8.128.0/20\n    - 122.8.144.0/20\n    - 122.8.160.0/20\n    - 122.8.176.0/21\n    - 122.8.184.0/22\n    - 122.8.188.0/22\n    - 124.243.128.0/18\n    - 124.243.156.0/24\n    - 124.243.157.0/24\n    - 124.243.158.0/24\n    - 124.243.159.0/24\n    - 124.71.248.0/24\n    - 124.71.249.0/24\n    - 124.71.250.0/24\n    - 124.71.252.0/24\n    - 124.71.253.0/24\n    - 124.81.0.0/20\n    - 124.81.112.0/20\n    - 124.81.128.0/20\n    - 124.81.144.0/20\n    - 124.81.16.0/20\n    - 124.81.160.0/20\n    - 124.81.176.0/20\n    - 124.81.192.0/20\n    - 124.81.208.0/20\n    - 124.81.224.0/20\n    - 124.81.240.0/20\n    - 124.81.32.0/20\n    - 124.81.48.0/20\n    - 124.81.64.0/20\n    - 124.81.80.0/20\n    - 124.81.96.0/20\n    - 139.9.98.0/24\n    - 139.9.99.0/24\n    - 14.137.132.0/22\n    - 14.137.136.0/22\n    - 14.137.140.0/22\n    - 14.137.152.0/24\n    - 14.137.153.0/24\n    - 14.137.154.0/24\n    - 14.137.155.0/24\n    - 14.137.156.0/24\n    - 14.137.157.0/24\n    - 14.137.161.0/24\n    - 14.137.163.0/24\n    - 14.137.169.0/24\n    - 14.137.170.0/23\n    - 14.137.172.0/22\n    - 146.174.128.0/20\n    - 146.174.144.0/20\n    - 146.174.160.0/20\n    - 146.174.176.0/20\n    - 148.145.160.0/20\n    - 148.145.192.0/20\n    - 148.145.208.0/20\n    - 148.145.224.0/23\n    - 148.145.234.0/23\n    - 148.145.236.0/23\n    - 148.145.238.0/23\n    - 149.232.128.0/20\n    - 149.232.144.0/20\n    - 150.40.128.0/20\n    - 150.40.144.0/20\n    - 150.40.160.0/20\n    - 150.40.176.0/20\n    - 150.40.182.0/24\n    - 150.40.192.0/20\n    - 150.40.208.0/20\n    - 150.40.224.0/20\n    - 150.40.240.0/20\n    - 154.220.192.0/19\n    - 154.81.16.0/20\n    - 154.83.0.0/23\n    - 154.86.32.0/20\n    - 154.86.48.0/20\n    - 154.93.100.0/23\n    - 154.93.104.0/23\n    - 156.227.22.0/23\n    - 156.230.32.0/21\n    - 156.230.40.0/21\n    - 156.230.64.0/18\n    - 156.232.16.0/20\n    - 156.240.128.0/18\n    - 156.249.32.0/20\n    - 156.253.16.0/20\n    - 157.254.211.0/24\n    - 157.254.212.0/24\n    - 159.138.0.0/20\n    - 159.138.112.0/21\n    - 159.138.114.0/24\n    - 159.138.120.0/22\n    - 159.138.124.0/24\n    - 159.138.125.0/24\n    - 159.138.126.0/23\n    - 159.138.128.0/20\n    - 159.138.144.0/20\n    - 159.138.152.0/21\n    - 159.138.16.0/22\n    - 159.138.160.0/20\n    - 159.138.176.0/23\n    - 159.138.178.0/24\n    - 159.138.179.0/24\n    - 159.138.180.0/24\n    - 159.138.181.0/24\n    - 159.138.182.0/23\n    - 159.138.188.0/23\n    - 159.138.190.0/23\n    - 159.138.192.0/20\n    - 159.138.20.0/22\n    - 159.138.208.0/21\n    - 159.138.216.0/22\n    - 159.138.220.0/23\n    - 159.138.224.0/20\n    - 159.138.24.0/21\n    - 159.138.240.0/20\n    - 159.138.32.0/20\n    - 159.138.48.0/20\n    - 159.138.64.0/21\n    - 159.138.67.0/24\n    - 159.138.76.0/24\n    - 159.138.77.0/24\n    - 159.138.78.0/24\n    - 159.138.79.0/24\n    - 159.138.80.0/20\n    - 159.138.96.0/20\n    - 166.108.192.0/20\n    - 166.108.208.0/20\n    - 166.108.224.0/20\n    - 166.108.240.0/20\n    - 176.52.128.0/20\n    - 176.52.144.0/20\n    - 180.87.192.0/20\n    - 180.87.208.0/20\n    - 180.87.224.0/20\n    - 180.87.240.0/20\n    - 182.160.0.0/20\n    - 182.160.16.0/24\n    - 182.160.17.0/24\n    - 182.160.18.0/23\n    - 182.160.20.0/22\n    - 182.160.20.0/24\n    - 182.160.24.0/21\n    - 182.160.36.0/22\n    - 182.160.49.0/24\n    - 182.160.52.0/22\n    - 182.160.56.0/21\n    - 182.160.56.0/24\n    - 182.160.57.0/24\n    - 182.160.58.0/24\n    - 182.160.59.0/24\n    - 182.160.60.0/24\n    - 182.160.61.0/24\n    - 182.160.62.0/24\n    - 183.87.112.0/20\n    - 183.87.128.0/20\n    - 183.87.144.0/20\n    - 183.87.32.0/20\n    - 183.87.48.0/20\n    - 183.87.64.0/20\n    - 183.87.80.0/20\n    - 183.87.96.0/20\n    - 188.119.192.0/20\n    - 188.119.208.0/20\n    - 188.119.224.0/20\n    - 188.119.240.0/20\n    - 188.239.0.0/20\n    - 188.239.16.0/20\n    - 188.239.32.0/20\n    - 188.239.48.0/20\n    - 189.1.192.0/20\n    - 189.1.208.0/20\n    - 189.1.224.0/20\n    - 189.1.240.0/20\n    - 189.28.112.0/20\n    - 189.28.96.0/20\n    - 190.92.192.0/19\n    - 190.92.224.0/19\n    - 190.92.248.0/24\n    - 190.92.252.0/24\n    - 190.92.253.0/24\n    - 190.92.254.0/24\n    - 201.77.32.0/20\n    - 202.170.88.0/21\n    - 202.76.128.0/20\n    - 202.76.144.0/20\n    - 202.76.160.0/20\n    - 202.76.176.0/20\n    - 203.123.80.0/20\n    - 203.167.20.0/23\n    - 203.167.22.0/24\n    - 212.34.192.0/20\n    - 212.34.208.0/20\n    - 213.250.128.0/20\n    - 213.250.144.0/20\n    - 213.250.160.0/20\n    - 213.250.176.0/21\n    - 213.250.184.0/21\n    - 219.83.0.0/20\n    - 219.83.112.0/22\n    - 219.83.116.0/23\n    - 219.83.118.0/23\n    - 219.83.121.0/24\n    - 219.83.122.0/24\n    - 219.83.123.0/24\n    - 219.83.124.0/24\n    - 219.83.16.0/20\n    - 219.83.32.0/20\n    - 219.83.76.0/23\n    - 2404:a140:43::/48\n    - 2405:f080::/39\n    - 2405:f080:1::/48\n    - 2405:f080:1000::/39\n    - 2405:f080:1200::/39\n    - 2405:f080:1400::/48\n    - 2405:f080:1401::/48\n    - 2405:f080:1402::/48\n    - 2405:f080:1403::/48\n    - 2405:f080:1500::/40\n    - 2405:f080:1600::/48\n    - 2405:f080:1602::/48\n    - 2405:f080:1603::/48\n    - 2405:f080:1800::/39\n    - 2405:f080:1800::/44\n    - 2405:f080:1810::/48\n    - 2405:f080:1811::/48\n    - 2405:f080:1812::/48\n    - 2405:f080:1813::/48\n    - 2405:f080:1814::/48\n    - 2405:f080:1815::/48\n    - 2405:f080:1900::/40\n    - 2405:f080:1e02::/47\n    - 2405:f080:1e04::/47\n    - 2405:f080:1e06::/47\n    - 2405:f080:1e1e::/47\n    - 2405:f080:1e20::/47\n    - 2405:f080:200::/48\n    - 2405:f080:2000::/39\n    - 2405:f080:201::/48\n    - 2405:f080:202::/48\n    - 2405:f080:2040::/48\n    - 2405:f080:2200::/39\n    - 2405:f080:2280::/48\n    - 2405:f080:2281::/48\n    - 2405:f080:2282::/48\n    - 2405:f080:2283::/48\n    - 2405:f080:2284::/48\n    - 2405:f080:2285::/48\n    - 2405:f080:2286::/48\n    - 2405:f080:2287::/48\n    - 2405:f080:2288::/48\n    - 2405:f080:2289::/48\n    - 2405:f080:228a::/48\n    - 2405:f080:228b::/48\n    - 2405:f080:228c::/48\n    - 2405:f080:228d::/48\n    - 2405:f080:228e::/48\n    - 2405:f080:228f::/48\n    - 2405:f080:2400::/39\n    - 2405:f080:2600::/39\n    - 2405:f080:2800::/48\n    - 2405:f080:2a00::/48\n    - 2405:f080:2e00::/47\n    - 2405:f080:3000::/38\n    - 2405:f080:3000::/40\n    - 2405:f080:3100::/40\n    - 2405:f080:3200::/48\n    - 2405:f080:3201::/48\n    - 2405:f080:3202::/48\n    - 2405:f080:3203::/48\n    - 2405:f080:3204::/48\n    - 2405:f080:3205::/48\n    - 2405:f080:3400::/38\n    - 2405:f080:3400::/40\n    - 2405:f080:3500::/40\n    - 2405:f080:3600::/48\n    - 2405:f080:3601::/48\n    - 2405:f080:3602::/48\n    - 2405:f080:3603::/48\n    - 2405:f080:3604::/48\n    - 2405:f080:3605::/48\n    - 2405:f080:400::/39\n    - 2405:f080:4000::/40\n    - 2405:f080:4100::/48\n    - 2405:f080:4102::/48\n    - 2405:f080:4103::/48\n    - 2405:f080:4104::/48\n    - 2405:f080:4200::/40\n    - 2405:f080:4300::/40\n    - 2405:f080:600::/48\n    - 2405:f080:800::/40\n    - 2405:f080:810::/44\n    - 2405:f080:a00::/39\n    - 2405:f080:a11::/48\n    - 2405:f080:e02::/48\n    - 2405:f080:e03::/48\n    - 2405:f080:e04::/47\n    - 2405:f080:e05::/48\n    - 2405:f080:e06::/48\n    - 2405:f080:e07::/48\n    - 2405:f080:e0e::/47\n    - 2405:f080:e10::/47\n    - 2405:f080:edff::/48\n    - 27.106.0.0/20\n    - 27.106.112.0/20\n    - 27.106.16.0/20\n    - 27.106.32.0/20\n    - 27.106.48.0/20\n    - 27.106.64.0/20\n    - 27.106.80.0/20\n    - 27.106.96.0/20\n    - 27.255.0.0/23\n    - 27.255.10.0/23\n    - 27.255.12.0/23\n    - 27.255.14.0/23\n    - 27.255.16.0/23\n    - 27.255.18.0/23\n    - 27.255.2.0/23\n    - 27.255.20.0/23\n    - 27.255.22.0/23\n    - 27.255.26.0/23\n    - 27.255.28.0/23\n    - 27.255.30.0/23\n    - 27.255.32.0/23\n    - 27.255.34.0/23\n    - 27.255.36.0/23\n    - 27.255.38.0/23\n    - 27.255.4.0/23\n    - 27.255.40.0/23\n    - 27.255.42.0/23\n    - 27.255.44.0/23\n    - 27.255.46.0/23\n    - 27.255.48.0/23\n    - 27.255.50.0/23\n    - 27.255.52.0/23\n    - 27.255.54.0/23\n    - 27.255.58.0/23\n    - 27.255.6.0/23\n    - 27.255.60.0/23\n    - 27.255.62.0/23\n    - 27.255.8.0/23\n    - 42.201.128.0/20\n    - 42.201.144.0/20\n    - 42.201.160.0/20\n    - 42.201.176.0/20\n    - 42.201.192.0/20\n    - 42.201.208.0/20\n    - 42.201.224.0/20\n    - 42.201.240.0/20\n    - 43.225.140.0/22\n    - 43.255.104.0/22\n    - 45.194.104.0/21\n    - 45.199.144.0/22\n    - 45.202.128.0/19\n    - 45.202.160.0/20\n    - 45.202.176.0/21\n    - 45.202.184.0/21\n    - 45.203.40.0/21\n    - 46.250.160.0/20\n    - 46.250.176.0/20\n    - 49.0.192.0/21\n    - 49.0.200.0/21\n    - 49.0.224.0/22\n    - 49.0.228.0/22\n    - 49.0.232.0/21\n    - 49.0.240.0/20\n    - 62.245.0.0/20\n    - 62.245.16.0/20\n    - 80.238.128.0/22\n    - 80.238.132.0/22\n    - 80.238.136.0/22\n    - 80.238.140.0/22\n    - 80.238.144.0/22\n    - 80.238.148.0/22\n    - 80.238.152.0/22\n    - 80.238.156.0/22\n    - 80.238.164.0/22\n    - 80.238.164.0/24\n    - 80.238.165.0/24\n    - 80.238.168.0/22\n    - 80.238.168.0/24\n    - 80.238.169.0/24\n    - 80.238.170.0/24\n    - 80.238.171.0/24\n    - 80.238.172.0/22\n    - 80.238.176.0/22\n    - 80.238.180.0/24\n    - 80.238.181.0/24\n    - 80.238.183.0/24\n    - 80.238.184.0/24\n    - 80.238.185.0/24\n    - 80.238.186.0/24\n    - 80.238.190.0/24\n    - 80.238.192.0/20\n    - 80.238.208.0/20\n    - 80.238.224.0/20\n    - 80.238.240.0/20\n    - 83.101.0.0/21\n    - 83.101.104.0/21\n    - 83.101.16.0/21\n    - 83.101.24.0/21\n    - 83.101.32.0/21\n    - 83.101.48.0/21\n    - 83.101.56.0/23\n    - 83.101.58.0/23\n    - 83.101.64.0/21\n    - 83.101.72.0/21\n    - 83.101.8.0/23\n    - 83.101.80.0/21\n    - 83.101.88.0/24\n    - 83.101.89.0/24\n    - 83.101.96.0/21\n    - 87.119.12.0/24\n    - 89.150.192.0/20\n    - 89.150.208.0/20\n    - 94.244.128.0/20\n    - 94.244.144.0/20\n    - 94.244.160.0/20\n    - 94.244.176.0/20\n    - 94.45.160.0/19\n    - 94.45.160.0/24\n    - 94.45.161.0/24\n    - 94.45.163.0/24\n    - 94.74.112.0/21\n    - 94.74.120.0/21\n    - 94.74.64.0/20\n    - 94.74.80.0/20\n    - 94.74.96.0/20\n"
  },
  {
    "path": "data/crawlers/internet-archive.yaml",
    "content": "- name: internet-archive\n  action: ALLOW\n  # https://ipinfo.io/AS7941\n  remote_addresses: [\"207.241.224.0/20\", \"208.70.24.0/21\", \"2620:0:9c0::/48\"]\n"
  },
  {
    "path": "data/crawlers/kagibot.yaml",
    "content": "- name: kagibot\n  user_agent_regex: \\+https\\://kagi\\.com/bot\n  action: ALLOW\n  # https://kagi.com/bot\n  remote_addresses:\n    [\n      \"216.18.205.234/32\",\n      \"35.212.27.76/32\",\n      \"104.254.65.50/32\",\n      \"209.151.156.194/32\",\n    ]\n"
  },
  {
    "path": "data/crawlers/marginalia.yaml",
    "content": "- name: marginalia\n  user_agent_regex: search\\.marginalia\\.nu\n  action: ALLOW\n  # Received directly over email\n  remote_addresses:\n    [\n      \"193.183.0.162/31\",\n      \"193.183.0.164/30\",\n      \"193.183.0.168/30\",\n      \"193.183.0.172/31\",\n      \"193.183.0.174/32\",\n    ]\n"
  },
  {
    "path": "data/crawlers/mojeekbot.yaml",
    "content": "- name: mojeekbot\n  user_agent_regex: \\+https\\://www\\.mojeek\\.com/bot\\.html\n  action: ALLOW\n  # https://www.mojeek.com/bot.html\n  remote_addresses: [\"5.102.173.71/32\"]\n"
  },
  {
    "path": "data/crawlers/openai-gptbot.yaml",
    "content": "# Collects AI training data\n# https://platform.openai.com/docs/bots/overview-of-openai-crawlers\n- name: openai-gptbot\n  user_agent_regex: GPTBot/1\\.1; \\+https\\://openai\\.com/gptbot\n  action: ALLOW\n  # https://openai.com/gptbot.json\n  remote_addresses:\n    [\n      \"52.230.152.0/24\",\n      \"20.171.206.0/24\",\n      \"20.171.207.0/24\",\n      \"4.227.36.0/25\",\n      \"20.125.66.80/28\",\n      \"172.182.204.0/24\",\n      \"172.182.214.0/24\",\n      \"172.182.215.0/24\",\n    ]\n"
  },
  {
    "path": "data/crawlers/openai-searchbot.yaml",
    "content": "# Indexing for search, does not collect training data\n# https://platform.openai.com/docs/bots/overview-of-openai-crawlers\n- name: openai-searchbot\n  user_agent_regex: OAI-SearchBot/1\\.0; \\+https\\://openai\\.com/searchbot\n  action: ALLOW\n  # https://openai.com/searchbot.json\n  remote_addresses:\n    [\n      \"20.42.10.176/28\",\n      \"172.203.190.128/28\",\n      \"104.210.140.128/28\",\n      \"51.8.102.0/24\",\n      \"135.234.64.0/24\",\n    ]\n"
  },
  {
    "path": "data/crawlers/perplexitybot.yaml",
    "content": "# Indexing for search, does not collect training data\n# https://docs.perplexity.ai/guides/bots\n- name: perplexitybot\n  user_agent_regex: PerplexityBot/.+; \\+https\\://perplexity\\.ai/perplexitybot\n  action: ALLOW\n  # https://www.perplexity.com/perplexitybot.json\n  remote_addresses:\n    [\n      \"107.20.236.150/32\",\n      \"3.224.62.45/32\",\n      \"18.210.92.235/32\",\n      \"3.222.232.239/32\",\n      \"3.211.124.183/32\",\n      \"3.231.139.107/32\",\n      \"18.97.1.228/30\",\n      \"18.97.9.96/29\",\n    ]\n"
  },
  {
    "path": "data/crawlers/qwantbot.yaml",
    "content": "- name: qwantbot\n  user_agent_regex: \\+https\\://help\\.qwant\\.com/bot/\n  action: ALLOW\n  # https://help.qwant.com/wp-content/uploads/sites/2/2025/01/qwantbot.json\n  remote_addresses: [\"91.242.162.0/24\"]\n"
  },
  {
    "path": "data/crawlers/tencent-cloud.yaml",
    "content": "# Tencent Cloud crawler IP ranges\n- name: tencent-cloud\n  action: DENY\n  remote_addresses:\n    - 101.32.0.0/17\n    - 101.32.176.0/20\n    - 101.32.192.0/18\n    - 101.33.116.0/22\n    - 101.33.120.0/21\n    - 101.33.16.0/20\n    - 101.33.2.0/23\n    - 101.33.32.0/19\n    - 101.33.4.0/22\n    - 101.33.64.0/19\n    - 101.33.8.0/21\n    - 101.33.96.0/20\n    - 119.28.28.0/24\n    - 119.29.29.0/24\n    - 124.156.0.0/16\n    - 129.226.0.0/18\n    - 129.226.128.0/18\n    - 129.226.224.0/19\n    - 129.226.96.0/19\n    - 150.109.0.0/18\n    - 150.109.128.0/20\n    - 150.109.160.0/19\n    - 150.109.192.0/18\n    - 150.109.64.0/20\n    - 150.109.80.0/21\n    - 150.109.88.0/22\n    - 150.109.96.0/19\n    - 162.14.60.0/22\n    - 162.62.0.0/18\n    - 162.62.128.0/20\n    - 162.62.144.0/21\n    - 162.62.152.0/22\n    - 162.62.172.0/22\n    - 162.62.176.0/20\n    - 162.62.192.0/19\n    - 162.62.255.0/24\n    - 162.62.80.0/20\n    - 162.62.96.0/19\n    - 170.106.0.0/16\n    - 43.128.0.0/14\n    - 43.132.0.0/22\n    - 43.132.12.0/22\n    - 43.132.128.0/17\n    - 43.132.16.0/22\n    - 43.132.28.0/22\n    - 43.132.32.0/22\n    - 43.132.40.0/22\n    - 43.132.52.0/22\n    - 43.132.60.0/24\n    - 43.132.64.0/22\n    - 43.132.69.0/24\n    - 43.132.70.0/23\n    - 43.132.72.0/21\n    - 43.132.80.0/21\n    - 43.132.88.0/22\n    - 43.132.92.0/23\n    - 43.132.96.0/19\n    - 43.133.0.0/16\n    - 43.134.0.0/16\n    - 43.135.0.0/17\n    - 43.135.128.0/18\n    - 43.135.192.0/19\n    - 43.152.0.0/21\n    - 43.152.11.0/24\n    - 43.152.12.0/22\n    - 43.152.128.0/22\n    - 43.152.133.0/24\n    - 43.152.134.0/23\n    - 43.152.136.0/21\n    - 43.152.144.0/20\n    - 43.152.160.0/22\n    - 43.152.16.0/21\n    - 43.152.164.0/23\n    - 43.152.166.0/24\n    - 43.152.168.0/21\n    - 43.152.178.0/23\n    - 43.152.180.0/22\n    - 43.152.184.0/21\n    - 43.152.192.0/18\n    - 43.152.24.0/22\n    - 43.152.31.0/24\n    - 43.152.32.0/23\n    - 43.152.35.0/24\n    - 43.152.36.0/22\n    - 43.152.40.0/21\n    - 43.152.48.0/20\n    - 43.152.74.0/23\n    - 43.152.76.0/22\n    - 43.152.80.0/22\n    - 43.152.8.0/23\n    - 43.152.92.0/23\n    - 43.153.0.0/16\n    - 43.154.0.0/15\n    - 43.156.0.0/15\n    - 43.158.0.0/16\n    - 43.159.0.0/20\n    - 43.159.128.0/17\n    - 43.159.64.0/23\n    - 43.159.70.0/23\n    - 43.159.72.0/21\n    - 43.159.81.0/24\n    - 43.159.82.0/23\n    - 43.159.85.0/24\n    - 43.159.86.0/23\n    - 43.159.88.0/21\n    - 43.159.96.0/19\n    - 43.160.0.0/15\n    - 43.162.0.0/16\n    - 43.163.0.0/17\n    - 43.163.128.0/18\n    - 43.163.192.255/32\n    - 43.163.193.0/24\n    - 43.163.194.0/23\n    - 43.163.196.0/22\n    - 43.163.200.0/21\n    - 43.163.208.0/20\n    - 43.163.224.0/19\n    - 43.164.0.0/18\n    - 43.164.128.0/17\n    - 43.165.0.0/16\n    - 43.166.128.0/18\n    - 43.166.224.0/19\n    - 43.168.0.0/20\n    - 43.168.16.0/21\n    - 43.168.24.0/22\n    - 43.168.255.0/24\n    - 43.168.32.0/19\n    - 43.168.64.0/20\n    - 43.168.80.0/22\n    - 43.169.0.0/16\n    - 43.170.0.0/16\n    - 43.174.0.0/18\n    - 43.174.128.0/17\n    - 43.174.64.0/22\n    - 43.174.68.0/23\n    - 43.174.71.0/24\n    - 43.174.74.0/23\n    - 43.174.76.0/22\n    - 43.174.80.0/20\n    - 43.174.96.0/19\n    - 43.175.0.0/20\n    - 43.175.113.0/24\n    - 43.175.114.0/23\n    - 43.175.116.0/22\n    - 43.175.120.0/21\n    - 43.175.128.0/18\n    - 43.175.16.0/22\n    - 43.175.192.0/20\n    - 43.175.20.0/23\n    - 43.175.208.0/21\n    - 43.175.216.0/22\n    - 43.175.220.0/23\n    - 43.175.22.0/24\n    - 43.175.222.0/24\n    - 43.175.224.0/20\n    - 43.175.25.0/24\n    - 43.175.26.0/23\n    - 43.175.28.0/22\n    - 43.175.32.0/19\n    - 43.175.64.0/19\n    - 43.175.96.0/20\n"
  },
  {
    "path": "data/crawlers/wikimedia-citoid.yaml",
    "content": "# Wikimedia Foundation citation services\n# https://www.mediawiki.org/wiki/Citoid\n\n- name: wikimedia-citoid\n  user_agent_regex: \"Citoid/WMF\"\n  action: ALLOW\n  remote_addresses: [\n    \"208.80.152.0/22\",\n    \"2620:0:860::/46\",\n  ]\n\n- name: wikimedia-zotero-translation-server\n  user_agent_regex: \"ZoteroTranslationServer/WMF\"\n  action: ALLOW\n  remote_addresses: [\n    \"208.80.152.0/22\",\n    \"2620:0:860::/46\",\n  ]"
  },
  {
    "path": "data/crawlers/yandexbot.yaml",
    "content": "- name: yandexbot\n  action: ALLOW\n  expression:\n    all:\n      - userAgent.matches(\"\\\\+http\\\\://yandex\\\\.com/bots\")\n      - verifyFCrDNS(remoteAddress, \"^.*\\\\.yandex\\\\.(ru|com|net)$\")\n"
  },
  {
    "path": "data/embed.go",
    "content": "package data\n\nimport \"embed\"\n\nvar (\n\t//go:embed botPolicies.yaml all:apps all:bots all:clients all:common all:crawlers all:meta all:services\n\tBotPolicies embed.FS\n)\n"
  },
  {
    "path": "data/embed_test.go",
    "content": "package data\n\nimport (\n\t\"path/filepath\"\n\t\"strings\"\n\t\"testing\"\n)\n\n// TestBotPoliciesEmbed ensures all YAML files in the directory tree\n// are accessible in the embedded BotPolicies filesystem.\nfunc TestBotPoliciesEmbed(t *testing.T) {\n\tyamlFiles, err := filepath.Glob(\"./**/*.yaml\")\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to glob YAML files: %v\", err)\n\t}\n\n\tif len(yamlFiles) == 0 {\n\t\tt.Fatal(\"No YAML files found in directory tree\")\n\t}\n\n\tt.Logf(\"Found %d YAML files to verify\", len(yamlFiles))\n\n\tfor _, filePath := range yamlFiles {\n\t\tembeddedPath := strings.TrimPrefix(filePath, \"./\")\n\n\t\tt.Run(embeddedPath, func(t *testing.T) {\n\t\t\tcontent, err := BotPolicies.ReadFile(embeddedPath)\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"Failed to read %s from embedded filesystem: %v\", embeddedPath, err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif len(content) == 0 {\n\t\t\t\tt.Errorf(\"File %s exists in embedded filesystem but is empty\", embeddedPath)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "data/meta/README.md",
    "content": "# meta policies\n\nContains policies that exclusively reference policies in _multiple_ other data folders.\n\nAkin to \"stances\" that the administrator can take, with reference to various topics, such as AI/LLM systems.\n"
  },
  {
    "path": "data/meta/ai-block-aggressive.yaml",
    "content": "# Blocks all AI/LLM associated user agents, regardless of purpose or human agency\n# Warning: To completely block some AI/LLM training, such as with Google, you _must_ place flags in robots.txt.\n- import: (data)/bots/ai-catchall.yaml\n- import: (data)/clients/ai.yaml\n- import: (data)/crawlers/ai-search.yaml\n- import: (data)/crawlers/ai-training.yaml\n"
  },
  {
    "path": "data/meta/ai-block-moderate.yaml",
    "content": "# Blocks all AI/LLM bots used for training or unknown/undocumented purposes.\n# Permits user agents with explicitly documented non-training use, and published IP allowlists.\n- import: (data)/bots/ai-catchall.yaml\n- import: (data)/crawlers/ai-training.yaml\n- import: (data)/crawlers/openai-searchbot.yaml\n- import: (data)/crawlers/perplexitybot.yaml\n- import: (data)/clients/openai-chatgpt-user.yaml\n- import: (data)/clients/mistral-mistralai-user.yaml\n- import: (data)/clients/perplexity-user.yaml\n"
  },
  {
    "path": "data/meta/ai-block-permissive.yaml",
    "content": "# Permits all well documented AI/LLM user agents with published IP allowlists.\n- import: (data)/bots/ai-catchall.yaml\n- import: (data)/crawlers/openai-searchbot.yaml\n- import: (data)/crawlers/openai-gptbot.yaml\n- import: (data)/crawlers/perplexitybot.yaml\n- import: (data)/clients/openai-chatgpt-user.yaml\n- import: (data)/clients/mistral-mistralai-user.yaml\n- import: (data)/clients/perplexity-user.yaml\n"
  },
  {
    "path": "data/meta/default-config.yaml",
    "content": "- # Pathological bots to deny\n  # This correlates to data/bots/_deny-pathological.yaml in the source tree\n  # https://github.com/TecharoHQ/anubis/blob/main/data/bots/_deny-pathological.yaml\n  import: (data)/bots/_deny-pathological.yaml\n- import: (data)/bots/aggressive-brazilian-scrapers.yaml\n\n# Aggressively block AI/LLM related bots/agents by default\n- import: (data)/meta/ai-block-aggressive.yaml\n\n# Consider replacing the aggressive AI policy with more selective policies:\n# - import: (data)/meta/ai-block-moderate.yaml\n# - import: (data)/meta/ai-block-permissive.yaml\n\n# Search engine crawlers to allow, defaults to:\n#   - Google (so they don't try to bypass Anubis)\n#   - Apple\n#   - Bing\n#   - DuckDuckGo\n#   - Qwant\n#   - The Internet Archive\n#   - Kagi\n#   - Marginalia\n#   - Mojeek\n- import: (data)/crawlers/_allow-good.yaml\n# Challenge Firefox AI previews\n- import: (data)/clients/x-firefox-ai.yaml\n\n# Allow common \"keeping the internet working\" routes (well-known, favicon, robots.txt)\n- import: (data)/common/keep-internet-working.yaml\n\n# # Punish any bot with \"bot\" in the user-agent string\n# # This is known to have a high false-positive rate, use at your own risk\n# - name: generic-bot-catchall\n#   user_agent_regex: (?i:bot|crawler)\n#   action: CHALLENGE\n#   challenge:\n#     difficulty: 16  # impossible\n#     algorithm: slow # intentionally waste CPU cycles and time\n\n# Requires a subscription to Thoth to use, see\n# https://anubis.techaro.lol/docs/admin/thoth#geoip-based-filtering\n- name: countries-with-aggressive-scrapers\n  action: WEIGH\n  geoip:\n    countries:\n      - BR\n      - CN\n  weight:\n    adjust: 10\n\n# Requires a subscription to Thoth to use, see\n# https://anubis.techaro.lol/docs/admin/thoth#asn-based-filtering\n- name: aggressive-asns-without-functional-abuse-contact\n  action: WEIGH\n  asns:\n    match:\n      - 13335 # Cloudflare\n      - 136907 # Huawei Cloud\n      - 45102 # Alibaba Cloud\n  weight:\n    adjust: 10\n\n# ## System load based checks.\n# # If the system is under high load, add weight.\n# - name: high-load-average\n#   action: WEIGH\n#   expression: load_1m >= 10.0 # make sure to end the load comparison in a .0\n#   weight:\n#     adjust: 20\n\n## If your backend service is running on the same operating system as Anubis,\n## you can uncomment this rule to make the challenge easier when the system is\n## under low load.\n##\n## If it is not, remove weight.\n# - name: low-load-average\n#   action: WEIGH\n#   expression: load_15m <= 4.0 # make sure to end the load comparison in a .0\n#   weight:\n#     adjust: -10\n\n# Generic catchall rule\n- name: generic-browser\n  user_agent_regex: >-\n    Mozilla|Opera\n  action: WEIGH\n  weight:\n    adjust: 10\n"
  },
  {
    "path": "data/meta/messengers-preview.yaml",
    "content": "- import: (data)/clients/telegram-preview.yaml\n- import: (data)/clients/vk-preview.yaml\n"
  },
  {
    "path": "data/services/updown.yaml",
    "content": "# https://updown.io/about\n- name: updown\n  user_agent_regex: updown.io\n  action: ALLOW\n  remote_addresses: [\n    \"45.32.74.41/32\",\n    \"104.238.136.194/32\",\n    \"192.99.37.47/32\",\n    \"91.121.222.175/32\",\n    \"104.238.159.87/32\",\n    \"102.212.60.78/32\",\n    \"135.181.102.135/32\",\n    \"45.32.107.181/32\",\n    \"45.76.104.117/32\",\n    \"45.63.29.207/32\",\n    \"2001:19f0:6001:2c6::1/128\",\n    \"2001:19f0:9002:11a::1/128\",\n    \"2607:5300:60:4c2f::1/128\",\n    \"2001:41d0:2:85af::1/128\",\n    \"2001:19f0:6c01:145::1/128\",\n    \"2c0f:c40:4003:4::2/128\",\n    \"2a01:4f9:c010:d5f9::1/128\",\n    \"2001:19f0:4400:402e::1/128\",\n    \"2001:19f0:7001:45a::1/128\",\n    \"2001:19f0:5801:1d8::1/128\"\n  ]"
  },
  {
    "path": "data/services/uptime-robot.yaml",
    "content": "- name: uptime-robot\n  user_agent_regex: UptimeRobot\n  action: ALLOW\n  # https://api.uptimerobot.com/meta/ips\n  remote_addresses:\n    [\n      \"3.12.251.153/32\",\n      \"3.20.63.178/32\",\n      \"3.77.67.4/32\",\n      \"3.79.134.69/32\",\n      \"3.105.133.239/32\",\n      \"3.105.190.221/32\",\n      \"3.133.226.214/32\",\n      \"3.149.57.90/32\",\n      \"3.212.128.62/32\",\n      \"5.161.61.238/32\",\n      \"5.161.73.160/32\",\n      \"5.161.75.7/32\",\n      \"5.161.113.195/32\",\n      \"5.161.117.52/32\",\n      \"5.161.177.47/32\",\n      \"5.161.194.92/32\",\n      \"5.161.215.244/32\",\n      \"5.223.43.32/32\",\n      \"5.223.53.147/32\",\n      \"5.223.57.22/32\",\n      \"18.116.205.62/32\",\n      \"18.180.208.214/32\",\n      \"18.192.166.72/32\",\n      \"18.193.252.127/32\",\n      \"24.144.78.39/32\",\n      \"24.144.78.185/32\",\n      \"34.198.201.66/32\",\n      \"45.55.123.175/32\",\n      \"45.55.127.146/32\",\n      \"49.13.24.81/32\",\n      \"49.13.130.29/32\",\n      \"49.13.134.145/32\",\n      \"49.13.164.148/32\",\n      \"49.13.167.123/32\",\n      \"52.15.147.27/32\",\n      \"52.22.236.30/32\",\n      \"52.28.162.93/32\",\n      \"52.59.43.236/32\",\n      \"52.87.72.16/32\",\n      \"54.64.67.106/32\",\n      \"54.79.28.129/32\",\n      \"54.87.112.51/32\",\n      \"54.167.223.174/32\",\n      \"54.249.170.27/32\",\n      \"63.178.84.147/32\",\n      \"64.225.81.248/32\",\n      \"64.225.82.147/32\",\n      \"69.162.124.227/32\",\n      \"69.162.124.235/32\",\n      \"69.162.124.238/32\",\n      \"78.46.190.63/32\",\n      \"78.46.215.1/32\",\n      \"78.47.98.55/32\",\n      \"78.47.173.76/32\",\n      \"88.99.80.227/32\",\n      \"91.99.101.207/32\",\n      \"128.140.41.193/32\",\n      \"128.140.106.114/32\",\n      \"129.212.132.140/32\",\n      \"134.199.240.137/32\",\n      \"138.197.53.117/32\",\n      \"138.197.53.138/32\",\n      \"138.197.54.143/32\",\n      \"138.197.54.247/32\",\n      \"138.197.63.92/32\",\n      \"139.59.50.44/32\",\n      \"142.132.180.39/32\",\n      \"143.198.249.237/32\",\n      \"143.198.250.89/32\",\n      \"143.244.196.21/32\",\n      \"143.244.196.211/32\",\n      \"143.244.221.177/32\",\n      \"144.126.251.21/32\",\n      \"146.190.9.187/32\",\n      \"152.42.149.135/32\",\n      \"157.90.155.240/32\",\n      \"157.90.156.63/32\",\n      \"159.69.158.189/32\",\n      \"159.223.243.219/32\",\n      \"161.35.247.201/32\",\n      \"167.99.18.52/32\",\n      \"167.235.143.113/32\",\n      \"168.119.53.160/32\",\n      \"168.119.96.239/32\",\n      \"168.119.123.75/32\",\n      \"170.64.250.64/32\",\n      \"170.64.250.132/32\",\n      \"170.64.250.235/32\",\n      \"178.156.181.172/32\",\n      \"178.156.184.20/32\",\n      \"178.156.185.127/32\",\n      \"178.156.185.231/32\",\n      \"178.156.187.238/32\",\n      \"178.156.189.113/32\",\n      \"178.156.189.249/32\",\n      \"188.166.201.79/32\",\n      \"206.189.241.133/32\",\n      \"209.38.49.1/32\",\n      \"209.38.49.206/32\",\n      \"209.38.49.226/32\",\n      \"209.38.51.43/32\",\n      \"209.38.53.7/32\",\n      \"209.38.124.252/32\",\n      \"216.144.248.18/31\",\n      \"216.144.248.21/32\",\n      \"216.144.248.22/31\",\n      \"216.144.248.24/30\",\n      \"216.144.248.28/31\",\n      \"216.144.248.30/32\",\n      \"216.245.221.83/32\",\n      \"2400:6180:10:200::56a0:b000/128\",\n      \"2400:6180:10:200::56a0:c000/128\",\n      \"2400:6180:10:200::56a0:e000/128\",\n      \"2400:6180:100:d0::94b6:4001/128\",\n      \"2400:6180:100:d0::94b6:5001/128\",\n      \"2400:6180:100:d0::94b6:7001/128\",\n      \"2406:da14:94d:8601:9d0d:7754:bedf:e4f5/128\",\n      \"2406:da14:94d:8601:b325:ff58:2bba:7934/128\",\n      \"2406:da14:94d:8601:db4b:c5ac:2cbe:9a79/128\",\n      \"2406:da1c:9c8:dc02:7ae1:f2ea:ab91:2fde/128\",\n      \"2406:da1c:9c8:dc02:7db9:f38b:7b9f:402e/128\",\n      \"2406:da1c:9c8:dc02:82b2:f0fd:ee96:579/128\",\n      \"2600:1f16:775:3a00:ac3:c5eb:7081:942e/128\",\n      \"2600:1f16:775:3a00:37bf:6026:e54a:f03a/128\",\n      \"2600:1f16:775:3a00:3f24:5bb0:95d7:5a6b/128\",\n      \"2600:1f16:775:3a00:8c2c:2ba6:778f:5be5/128\",\n      \"2600:1f16:775:3a00:91ac:3120:ff38:92b5/128\",\n      \"2600:1f16:775:3a00:dbbe:36b0:3c45:da32/128\",\n      \"2600:1f18:179:f900:71:af9a:ade7:d772/128\",\n      \"2600:1f18:179:f900:2406:9399:4ae6:c5d3/128\",\n      \"2600:1f18:179:f900:4696:7729:7bb3:f52f/128\",\n      \"2600:1f18:179:f900:4b7d:d1cc:2d10:211/128\",\n      \"2600:1f18:179:f900:5c68:91b6:5d75:5d7/128\",\n      \"2600:1f18:179:f900:e8dd:eed1:a6c:183b/128\",\n      \"2604:a880:800:14:0:1:68ba:d000/128\",\n      \"2604:a880:800:14:0:1:68ba:e000/128\",\n      \"2604:a880:800:14:0:1:68bb:0/128\",\n      \"2604:a880:800:14:0:1:68bb:1000/128\",\n      \"2604:a880:800:14:0:1:68bb:3000/128\",\n      \"2604:a880:800:14:0:1:68bb:4000/128\",\n      \"2604:a880:800:14:0:1:68bb:5000/128\",\n      \"2604:a880:800:14:0:1:68bb:6000/128\",\n      \"2604:a880:800:14:0:1:68bb:7000/128\",\n      \"2604:a880:800:14:0:1:68bb:a000/128\",\n      \"2604:a880:800:14:0:1:68bb:b000/128\",\n      \"2604:a880:800:14:0:1:68bb:c000/128\",\n      \"2604:a880:800:14:0:1:68bb:d000/128\",\n      \"2604:a880:800:14:0:1:68bb:e000/128\",\n      \"2604:a880:800:14:0:1:68bb:f000/128\",\n      \"2607:ff68:107::4/128\",\n      \"2607:ff68:107::14/128\",\n      \"2607:ff68:107::33/128\",\n      \"2607:ff68:107::48/127\",\n      \"2607:ff68:107::50/125\",\n      \"2607:ff68:107::58/127\",\n      \"2607:ff68:107::60/128\",\n      \"2a01:4f8:c0c:83fa::1/128\",\n      \"2a01:4f8:c17:42e4::1/128\",\n      \"2a01:4f8:c2c:9fc6::1/128\",\n      \"2a01:4f8:c2c:beae::1/128\",\n      \"2a01:4f8:1c1a:3d53::1/128\",\n      \"2a01:4f8:1c1b:4ef4::1/128\",\n      \"2a01:4f8:1c1b:5b5a::1/128\",\n      \"2a01:4f8:1c1b:7ecc::1/128\",\n      \"2a01:4f8:1c1c:11aa::1/128\",\n      \"2a01:4f8:1c1c:5353::1/128\",\n      \"2a01:4f8:1c1c:7240::1/128\",\n      \"2a01:4f8:1c1c:a98a::1/128\",\n      \"2a01:4f8:c012:c60e::1/128\",\n      \"2a01:4f8:c013:c18::1/128\",\n      \"2a01:4f8:c013:34c0::1/128\",\n      \"2a01:4f8:c013:3b0f::1/128\",\n      \"2a01:4f8:c013:3c52::1/128\",\n      \"2a01:4f8:c013:3c53::1/128\",\n      \"2a01:4f8:c013:3c54::1/128\",\n      \"2a01:4f8:c013:3c55::1/128\",\n      \"2a01:4f8:c013:3c56::1/128\",\n      \"2a01:4ff:f0:bfd::1/128\",\n      \"2a01:4ff:f0:2219::1/128\",\n      \"2a01:4ff:f0:3e03::1/128\",\n      \"2a01:4ff:f0:5f80::1/128\",\n      \"2a01:4ff:f0:7fad::1/128\",\n      \"2a01:4ff:f0:9c5f::1/128\",\n      \"2a01:4ff:f0:b2f2::1/128\",\n      \"2a01:4ff:f0:b6f1::1/128\",\n      \"2a01:4ff:f0:d283::1/128\",\n      \"2a01:4ff:f0:d3cd::1/128\",\n      \"2a01:4ff:f0:e516::1/128\",\n      \"2a01:4ff:f0:e9cf::1/128\",\n      \"2a01:4ff:f0:eccb::1/128\",\n      \"2a01:4ff:f0:efd1::1/128\",\n      \"2a01:4ff:f0:fdc7::1/128\",\n      \"2a01:4ff:2f0:193c::1/128\",\n      \"2a01:4ff:2f0:27de::1/128\",\n      \"2a01:4ff:2f0:3b3a::1/128\",\n      \"2a03:b0c0:2:f0::bd91:f001/128\",\n      \"2a03:b0c0:2:f0::bd92:1/128\",\n      \"2a03:b0c0:2:f0::bd92:1001/128\",\n      \"2a03:b0c0:2:f0::bd92:2001/128\",\n      \"2a03:b0c0:2:f0::bd92:4001/128\",\n      \"2a03:b0c0:2:f0::bd92:5001/128\",\n      \"2a03:b0c0:2:f0::bd92:6001/128\",\n      \"2a03:b0c0:2:f0::bd92:7001/128\",\n      \"2a03:b0c0:2:f0::bd92:8001/128\",\n      \"2a03:b0c0:2:f0::bd92:9001/128\",\n      \"2a03:b0c0:2:f0::bd92:a001/128\",\n      \"2a03:b0c0:2:f0::bd92:b001/128\",\n      \"2a03:b0c0:2:f0::bd92:c001/128\",\n      \"2a03:b0c0:2:f0::bd92:e001/128\",\n      \"2a03:b0c0:2:f0::bd92:f001/128\",\n      \"2a05:d014:1815:3400:6d:9235:c1c0:96ad/128\",\n      \"2a05:d014:1815:3400:654f:bd37:724c:212b/128\",\n      \"2a05:d014:1815:3400:90b4:4ef9:5631:b170/128\",\n      \"2a05:d014:1815:3400:9779:d8e9:100a:9642/128\",\n      \"2a05:d014:1815:3400:af29:e95e:64ff:df81/128\",\n      \"2a05:d014:1815:3400:c7d6:f7f3:6cc1:30d1/128\",\n      \"2a05:d014:1815:3400:d784:e5dd:8e0:67cb/128\",\n    ]\n"
  },
  {
    "path": "decaymap/decaymap.go",
    "content": "package decaymap\n\nimport (\n\t\"sync\"\n\t\"time\"\n)\n\nfunc Zilch[T any]() T {\n\tvar zero T\n\treturn zero\n}\n\n// 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.\ntype Impl[K comparable, V any] struct {\n\tdata map[K]decayMapEntry[V]\n\n\t// deleteCh receives decay-deletion requests from readers.\n\tdeleteCh chan deleteReq[K]\n\t// stopCh stops the background cleanup worker.\n\tstopCh chan struct{}\n\twg     sync.WaitGroup\n\tlock   sync.RWMutex\n}\n\ntype decayMapEntry[V any] struct {\n\tValue  V\n\texpiry time.Time\n}\n\n// deleteReq is a request to remove a key if its expiry timestamp still matches\n// the observed one. This prevents racing with concurrent Set updates.\ntype deleteReq[K comparable] struct {\n\tkey    K\n\texpiry time.Time\n}\n\n// New creates a new DecayMap of key type K and value type V.\n//\n// Key types must be comparable to work with maps.\nfunc New[K comparable, V any]() *Impl[K, V] {\n\tm := &Impl[K, V]{\n\t\tdata:     make(map[K]decayMapEntry[V]),\n\t\tdeleteCh: make(chan deleteReq[K], 1024),\n\t\tstopCh:   make(chan struct{}),\n\t}\n\tm.wg.Add(1)\n\tgo m.cleanupWorker()\n\treturn m\n}\n\n// expire forcibly expires a key by setting its time-to-live one second in the past.\nfunc (m *Impl[K, V]) expire(key K) bool {\n\t// Use a single write lock to avoid RUnlock->Lock convoy.\n\tm.lock.Lock()\n\tdefer m.lock.Unlock()\n\tval, ok := m.data[key]\n\tif !ok {\n\t\treturn false\n\t}\n\tval.expiry = time.Now().Add(-1 * time.Second)\n\tm.data[key] = val\n\treturn true\n}\n\n// Delete a value from the DecayMap by key.\n//\n// If the value does not exist, return false. Return true after\n// deletion.\nfunc (m *Impl[K, V]) Delete(key K) bool {\n\t// Use a single write lock to avoid RUnlock->Lock convoy.\n\tm.lock.Lock()\n\tdefer m.lock.Unlock()\n\t_, ok := m.data[key]\n\tif ok {\n\t\tdelete(m.data, key)\n\t}\n\treturn ok\n}\n\n// Get gets a value from the DecayMap by key.\n//\n// If a value has expired, forcibly delete it if it was not updated.\nfunc (m *Impl[K, V]) Get(key K) (V, bool) {\n\tm.lock.RLock()\n\tvalue, ok := m.data[key]\n\tm.lock.RUnlock()\n\n\tif !ok {\n\t\treturn Zilch[V](), false\n\t}\n\n\tif time.Now().After(value.expiry) {\n\t\t// Defer decay deletion to the background worker to avoid convoy.\n\t\tselect {\n\t\tcase m.deleteCh <- deleteReq[K]{key: key, expiry: value.expiry}:\n\t\tdefault:\n\t\t\t// Channel full: drop request; a future Cleanup() or Get will retry.\n\t\t}\n\n\t\treturn Zilch[V](), false\n\t}\n\n\treturn value.Value, true\n}\n\n// Set sets a key value pair in the map.\nfunc (m *Impl[K, V]) Set(key K, value V, ttl time.Duration) {\n\tm.lock.Lock()\n\tdefer m.lock.Unlock()\n\n\tm.data[key] = decayMapEntry[V]{\n\t\tValue:  value,\n\t\texpiry: time.Now().Add(ttl),\n\t}\n}\n\n// Cleanup removes all expired entries from the DecayMap.\nfunc (m *Impl[K, V]) Cleanup() {\n\tm.lock.Lock()\n\tdefer m.lock.Unlock()\n\n\tnow := time.Now()\n\tfor key, entry := range m.data {\n\t\tif now.After(entry.expiry) {\n\t\t\tdelete(m.data, key)\n\t\t}\n\t}\n}\n\n// Len returns the number of entries in the DecayMap.\nfunc (m *Impl[K, V]) Len() int {\n\tm.lock.RLock()\n\tdefer m.lock.RUnlock()\n\treturn len(m.data)\n}\n\n// Close stops the background cleanup worker. It's optional to call; maps live\n// for the process lifetime in many cases. Call in tests or when you know you no\n// longer need the map to avoid goroutine leaks.\nfunc (m *Impl[K, V]) Close() {\n\tclose(m.stopCh)\n\tm.wg.Wait()\n}\n\n// cleanupWorker batches decay deletions to minimize lock contention.\nfunc (m *Impl[K, V]) cleanupWorker() {\n\tdefer m.wg.Done()\n\tbatch := make([]deleteReq[K], 0, 64)\n\tticker := time.NewTicker(500 * time.Millisecond)\n\tdefer ticker.Stop()\n\n\tflush := func() {\n\t\tif len(batch) == 0 {\n\t\t\treturn\n\t\t}\n\t\tm.applyDeletes(batch)\n\t\t// reset batch without reallocating\n\t\tbatch = batch[:0]\n\t}\n\n\tfor {\n\t\tselect {\n\t\tcase req := <-m.deleteCh:\n\t\t\tbatch = append(batch, req)\n\t\tcase <-ticker.C:\n\t\t\tflush()\n\t\tcase <-m.stopCh:\n\t\t\t// Drain any remaining requests then exit\n\t\t\tfor {\n\t\t\t\tselect {\n\t\t\t\tcase req := <-m.deleteCh:\n\t\t\t\t\tbatch = append(batch, req)\n\t\t\t\tdefault:\n\t\t\t\t\tflush()\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc (m *Impl[K, V]) applyDeletes(batch []deleteReq[K]) {\n\tnow := time.Now()\n\tm.lock.Lock()\n\tfor _, req := range batch {\n\t\tentry, ok := m.data[req.key]\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\t\t// Only delete if the expiry is unchanged and already past.\n\t\tif entry.expiry.Equal(req.expiry) && now.After(entry.expiry) {\n\t\t\tdelete(m.data, req.key)\n\t\t}\n\t}\n\tm.lock.Unlock()\n}\n"
  },
  {
    "path": "decaymap/decaymap_test.go",
    "content": "package decaymap\n\nimport (\n\t\"testing\"\n\t\"time\"\n)\n\nfunc TestImpl(t *testing.T) {\n\tdm := New[string, string]()\n\tt.Cleanup(dm.Close)\n\n\tdm.Set(\"test\", \"hi\", 5*time.Minute)\n\n\tval, ok := dm.Get(\"test\")\n\tif !ok {\n\t\tt.Error(\"somehow the test key was not set\")\n\t}\n\n\tif val != \"hi\" {\n\t\tt.Errorf(\"wanted value %q, got: %q\", \"hi\", val)\n\t}\n\n\tok = dm.expire(\"test\")\n\tif !ok {\n\t\tt.Error(\"somehow could not force-expire the test key\")\n\t}\n\n\t_, ok = dm.Get(\"test\")\n\tif ok {\n\t\tt.Error(\"got value even though it was supposed to be expired\")\n\t}\n\n\t// Deletion of expired entries after Get is deferred to a background worker.\n\t// Assert it eventually disappears from the map.\n\tdeadline := time.Now().Add(700 * time.Millisecond)\n\tfor time.Now().Before(deadline) {\n\t\tif dm.Len() == 0 {\n\t\t\tbreak\n\t\t}\n\t\ttime.Sleep(5 * time.Millisecond)\n\t}\n\tif dm.Len() != 0 {\n\t\tt.Fatalf(\"expected background cleanup to remove expired key; len=%d\", dm.Len())\n\t}\n}\n\nfunc TestCleanup(t *testing.T) {\n\tdm := New[string, string]()\n\tt.Cleanup(dm.Close)\n\n\tdm.Set(\"test1\", \"hi1\", 1*time.Second)\n\tdm.Set(\"test2\", \"hi2\", 2*time.Second)\n\tdm.Set(\"test3\", \"hi3\", 3*time.Second)\n\n\tdm.expire(\"test1\") // Force expire test1\n\tdm.expire(\"test2\") // Force expire test2\n\n\tdm.Cleanup()\n\n\tfinalLen := dm.Len() // Get the length after cleanup\n\n\tif finalLen != 1 { // \"test3\" should be the only one left\n\t\tt.Errorf(\"Cleanup failed to remove expired entries. Expected length 1, got %d\", finalLen)\n\t}\n\n\tif _, ok := dm.Get(\"test1\"); ok { // Verify Get still behaves correctly after Cleanup\n\t\tt.Error(\"test1 should not be found after cleanup\")\n\t}\n\tif _, ok := dm.Get(\"test2\"); ok {\n\t\tt.Error(\"test2 should not be found after cleanup\")\n\t}\n\tif val, ok := dm.Get(\"test3\"); !ok || val != \"hi3\" {\n\t\tt.Error(\"test3 should still be found after cleanup\")\n\t}\n}\n"
  },
  {
    "path": "docs/.dockerignore",
    "content": "# Dependencies\n/node_modules\n\n# Production\n/build\n\n# Generated files\n.docusaurus\n.cache-loader\n\n# Misc\n.DS_Store\n.env.local\n.env.development.local\n.env.test.local\n.env.production.local\n\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\n\n"
  },
  {
    "path": "docs/.gitignore",
    "content": "# Dependencies\n/node_modules\n\n# Production\n/build\n\n# Generated files\n.docusaurus\n.cache-loader\n\n# Misc\n.DS_Store\n.env.local\n.env.development.local\n.env.test.local\n.env.production.local\n\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\n"
  },
  {
    "path": "docs/Dockerfile",
    "content": "FROM docker.io/library/node:lts AS build\n\nWORKDIR /app\nCOPY . .\n\nRUN npm ci && npm run build\n\nFROM ghcr.io/xe/nginx-micro\nCOPY --from=build /app/build /www\nCOPY ./manifest/cfg/nginx/nginx.conf /conf\nLABEL org.opencontainers.image.source=\"https://github.com/TecharoHQ/anubis\""
  },
  {
    "path": "docs/README.md",
    "content": "# Website\n\nThis website is built using [Docusaurus](https://docusaurus.io/), a modern static website generator.\n\n### Installation\n\n```\n$ yarn\n```\n\n### Local Development\n\n```\n$ yarn start\n```\n\nThis command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server.\n\n### Build\n\n```\n$ yarn build\n```\n\nThis command generates static content into the `build` directory and can be served using any static contents hosting service.\n\n### Deployment\n\nUsing SSH:\n\n```\n$ USE_SSH=true yarn deploy\n```\n\nNot using SSH:\n\n```\n$ GIT_USER=<Your GitHub username> yarn deploy\n```\n\nIf you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch.\n"
  },
  {
    "path": "docs/blog/2025-06-16-welcome/index.mdx",
    "content": "---\nslug: welcome\ntitle: Welcome to the Anubis blog!\nauthors: [xe]\ntags: [intro]\n---\n\nHello, world!\n\nAt 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.\n\nMore details to come soon!\n\n{/* truncate */}\n"
  },
  {
    "path": "docs/blog/2025-06-27-release-1.20.0/index.mdx",
    "content": "---\nslug: release/v1.20.0\ntitle: Anubis v1.20.0 is now available!\nauthors: [xe]\ntags: [release]\nimage: sunburst.webp\n---\n\n![](./sunburst.webp)\n\nHey all!\n\nToday 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!\n\n{/* truncate */}\n\n## Sponsoring the product\n\nIf 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).\n\nI am waiting to hear back from NLNet on if Anubis was selected for funding or not. Let's hope it is!\n\n## Deprecation warning: `DIFFICULTY`\n\nAnubis 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.\n\nIf this becomes an imposition in practice, this will be reverted.\n\n## Chrome won't show \"invalid response\" after \"Success!\"\n\nThere 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.\n\nWhen 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.\n\nI suspect this was why Vanadium and Cromite were having sporadic issues as well.\n\n## New Features\n\nThe 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:\n\n```yaml\n- name: gitea-session-token\n  action: WEIGH\n  expression:\n    all:\n      # Check if the request has a Cookie header\n      - '\"Cookie\" in headers'\n      # Check if the request's Cookie header contains the Gitea session token\n      - headers[\"Cookie\"].contains(\"i_love_gitea=\")\n  # Remove 5 weight points\n  weight:\n    adjust: -5\n```\n\nThis 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.\n\nWeight 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:\n\n```yaml\nthresholds:\n  - name: minimal-suspicion # This client is likely fine, its soul is lighter than a feather\n    expression: weight <= 0 # a feather weighs zero units\n    action: ALLOW # Allow the traffic through\n  # For clients that had some weight reduced through custom rules, give them a\n  # lightweight challenge.\n  - name: mild-suspicion\n    expression:\n      all:\n        - weight > 0\n        - weight < 10\n    action: CHALLENGE\n    challenge:\n      # https://anubis.techaro.lol/docs/admin/configuration/challenges/metarefresh\n      algorithm: metarefresh\n      difficulty: 1\n      report_as: 1\n  # For clients that are browser-like but have either gained points from custom rules or\n  # report as a standard browser.\n  - name: moderate-suspicion\n    expression:\n      all:\n        - weight >= 10\n        - weight < 20\n    action: CHALLENGE\n    challenge:\n      # https://anubis.techaro.lol/docs/admin/configuration/challenges/proof-of-work\n      algorithm: fast\n      difficulty: 2 # two leading zeros, very fast for most clients\n      report_as: 2\n  # For clients that are browser like and have gained many points from custom rules\n  - name: extreme-suspicion\n    expression: weight >= 20\n    action: CHALLENGE\n    challenge:\n      # https://anubis.techaro.lol/docs/admin/configuration/challenges/proof-of-work\n      algorithm: fast\n      difficulty: 4\n      report_as: 4\n```\n\n:::note\n\nIf 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.\n\n:::\n\nThis 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.\n\nThreshold 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.\n\n### Imprint/Impressum Support\n\nEuropean 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.\n\nAnubis v1.20.0 and later [has support for showing imprints](/docs/admin/configuration/impressum). You can configure two kinds of imprints:\n\n1. An imprint that is shown in the footer of every Anubis page.\n2. 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).\n\nImprints are configured in [the policy file](/docs/admin/policies/):\n\n```yaml\nimpressum:\n  # Displayed at the bottom of every page rendered by Anubis.\n  footer: >-\n    This website is hosted by Zombocom. If you have any complaints or notes \n    about the service, please contact\n    <a href=\"mailto:contact@zombocom.example\">contact@zombocom.example</a> and\n    we will assist you as soon as possible.\n\n  # The imprint page that will be linked to at the footer of every Anubis page.\n  page:\n    # The HTML <title> of the page\n    title: Imprint and Privacy Policy\n    # The HTML contents of the page. The exact contents of this page can\n    # and will vary by locale. Please consult with a lawyer if you are not\n    # sure what to put here.\n    body: >-\n      <p>Last updated: June 2025</p>\n\n      <h2>Information that is gathered from visitors</h2>\n\n      <p>In common with other websites, log files are stored on the web server\n      saving details such as the visitor's IP address, browser type, referring\n      page and time of visit.</p>\n\n      <p>Cookies may be used to remember visitor preferences when interacting\n      with the website.</p>\n\n      <p>Where registration is required, the visitor's email and a username\n      will be stored on the server.</p>\n\n      <!-- ... -->\n```\n\nIf 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.\n\n### No-JS Challenge\n\nOne 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.\n\nWhen 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.\n\nIn 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).\n\nIn 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).\n\nIf 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.\n\n### `robots2policy`\n\nAnubis 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.\n\nAnubis now ships with a [`robots2policy` tool](/docs/admin/robots2policy) that lets you convert your `robots.txt` file to an Anubis policy.\n\n```text\nrobots2policy -input https://github.com/robots.txt\n```\n\n:::note\n\nIf you installed Anubis from [an OS package](/docs/admin/native-install), you may need to run `anubis-robots2policy` instead of `robots2policy`.\n\n:::\n\nWe 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.\n\n### Open Graph configuration is being moved to the policy file\n\nAnubis 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.\n\nIn 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).\n\nYou can also set default Open Graph tags:\n\n```yaml\nopenGraph:\n  enabled: true\n  ttl: 24h\n  # If set, return these opengraph values instead of looking them up with\n  # the target service.\n  #\n  # Correlates to properties in https://ogp.me/\n  override:\n    # og:title is required, it is the title of the website\n    \"og:title\": \"Techaro Anubis\"\n    \"og:description\": >-\n      Anubis is a Web AI Firewall Utility that helps you fight the bots\n      away so that you can maintain uptime at work!\n    \"description\": >-\n      Anubis is a Web AI Firewall Utility that helps you fight the bots\n      away so that you can maintain uptime at work!\n```\n\n## Improvements and optimizations\n\nOne 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.\n\nAnubis 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).\n\n## What's up next?\n\nv1.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.\n\nSo far Anubis supports the following languages:\n\n- English (Simplified and Traditional)\n- French\n- Portuguese (Brazil)\n- Spanish\n\nIf 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.\n\nOther things we plan to do:\n\n- Move configuration to the policy file\n- Support reloading the policy file at runtime without having to restart Anubis\n- Detecting if a client is \"brand new\"\n- A [Valkey](https://valkey.io/)-backed store for sharing information between instances of Anubis\n- Augmenting No-JS support in the paid product\n- TLS fingerprinting\n- Automated testing improvements in CI (FreeBSD CI support, better automated integration/functional testing, etc.)\n\n## Conclusion\n\nI 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.\n\nIf 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.\n"
  },
  {
    "path": "docs/blog/2025-07-09-incident-report/index.mdx",
    "content": "---\nslug: incident/TI-20250709-0001\ntitle: \"TI-20250709-0001: IPv4 traffic failures for Techaro services\"\nauthors: [xe]\ntags: [incident]\nimage: ./window-portal.jpg\n---\n\n![](./window-portal.jpg)\n\nTecharo 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!\n\n{/* truncate */}\n\n:::note\n\nIn 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.\n\nEverything past this point follows my standard incident root cause meeting template.\n\n:::\n\nThis 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.\n\n## Timeline\n\nAll events take place on July 9th, 2025.\n\n| Time (UTC) | Description                                                                                                                                                                                  |\n| :--------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| 12:32      | Uptime Kuma reports that another unrelated website on the same cluster was timing out.                                                                                                       |\n| 12:33      | Uptime Kuma reports that Thoth's production endpoint is failing gRPC health checks.                                                                                                          |\n| 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.                           |\n| 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.                          |\n| 12:41      | IPv4 traffic resumes long enough for Uptime Kuma to report uptime, but then immediately fails again.                                                                                         |\n| 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) |\n| 12:48      | First reply from the hosting provider.                                                                                                                                                       |\n| 12:57      | Reply to hosting provider, ask to reboot the load balancer.                                                                                                                                  |\n| 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.     |\n| 13:20      | Incident responder ended meeting and went back to monitoring downtime and preparing this document.                                                                                           |\n| 13:34      | IPv4 traffic starts to show up in the `ingress-nginx` logs.                                                                                                                                  |\n| 13:35      | All services start to report healthy. Incident status changes to monitoring.                                                                                                                 |\n| 13:48      | Incident closed.                                                                                                                                                                             |\n| 14:07      | Incident re-opened. Issues seem to be manifesting as BGP issues in the upstream provider.                                                                                                    |\n| 14:10      | IPv4 traffic resumes and then stops.                                                                                                                                                         |\n| 14:18      | IPv4 traffic resumes again. Incident status changes to monitoring.                                                                                                                           |\n| 14:40      | Incident closed.                                                                                                                                                                             |\n\n## Services affected\n\n| Service name                                        | User impact        |\n| :-------------------------------------------------- | :----------------- |\n| [Anubis Docs](https://anubis.techaro.lol) (IPv4)    | Connection timeout |\n| [Anubis Docs](https://anubis.techaro.lol) (IPv6)    | None               |\n| [Thoth](/docs/admin/thoth/) (IPv4)                  | Connection timeout |\n| [Thoth](/docs/admin/thoth/) (IPv6)                  | None               |\n| Other websites colocated on the same cluster (IPv4) | Connection timeout |\n| Other websites colocated on the same cluster (IPv6) | None               |\n\n## Root cause analysis\n\nIn 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:\n\n```mermaid\n---\ntitle: Overall architecture\n---\n\nflowchart LR\n    UT(User Traffic)\n    subgraph Provider Infrastructure\n      LB[Load Balancer]\n    end\n    subgraph Kubernetes\n        IN(ingress-nginx)\n        TH(Thoth)\n        AN(Anubis Docs)\n        OS(Other sites)\n\n        IN --> TH\n        IN --> AN\n        IN --> OS\n    end\n\n    UT --> LB --> IN\n```\n\nTecharo 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).\n\nIf 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.\n\nThe 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.\n\n## Where we got lucky\n\nThe 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.\n\nThe `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.\n\n## Action items\n\n- A single instance of downtime like this is not enough reason to move providers. Moving providers because of this is thus out of scope.\n- Techaro needs a status page hosted on a different cloud provider than is used for the production cluster (`TecharoHQ/TODO#6`).\n- Health checks for IPv4 and IPv6 traffic need to be created (`TecharoHQ/TODO#7`).\n- 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).\n"
  },
  {
    "path": "docs/blog/2025-07-22-release-1.21.1/index.mdx",
    "content": "---\nslug: release/v1.21.1\ntitle: Anubis v1.21.1 is now available!\nauthors: [xe]\ntags: [release]\nimage: anubis-i18n.webp\n---\n\n![](./anubis-i18n.webp)\n\nHey all!\n\nRecently 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!\n\n{/* truncate */}\n\nIn 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.\n\nThis 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.\n\n## What's in this release?\n\nThe 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).\n\nThis release gives Anubis the following features:\n\n- [Internationalization support](#internationalization), allowing Anubis to render its messages in the human language you speak.\n- Anubis now supports the [`missingHeader`](#missingHeader-function) function to assert the absence of headers in requests.\n- Anubis now has the ability to [store data persistently on the server](#persistent-data-storage).\n- Anubis can use [the system load average](#load-average-checks) as a factor to determine if it needs to filter traffic or not.\n- 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)\n- Sets cookie defaults to use [SameSite: None](https://web.dev/articles/samesite-cookies-explained)\n- Allow [Common Crawl](https://commoncrawl.org/) by default so scrapers have less incentive to scrape\n- Add `/healthz` metrics route for use in platform-based health checks.\n- Start exposing JA4H fingerprints for later use in CEL expressions.\n\nAnd this release also fixes the following bugs:\n\n- [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.\n- In order to reduce confusion, the \"Success\" interstitial that shows up when you pass a proof of work challenge has been removed.\n- Don't block Anubis starting up if [Thoth](/docs/admin/thoth/) health checks fail.\n- The \"Try again\" button on the error page has been fixed. Previously it meant \"try the solution again\" instead of \"try the challenge again\".\n- 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.\n- \"Proof of work\" has been removed from the branding due to some users having extremely negative connotations with it.\n\nWe try to avoid introducing breaking changes as much as possible, but these are the changes that may be relevant for you as an administrator:\n\n- The [challenge format](#challenge-format-change) has been changed in order to account for [the new challenge issuance flow](#challenge-flow-v2).\n- The [systemd service `RuntimeDirectory` has been changed](#breaking-change-systemd-runtimedirectory-change).\n\n### Sponsoring the project\n\nIf 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).\n\nOnce 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):\n\n```mermaid\npie title Funding update\n    \"GitHub Sponsors\" : 29\n    \"Patreon\" : 14\n    \"Remaining\" : 56\n```\n\nI am waiting to hear back from NLNet on if Anubis was selected for funding or not. Let's hope it is!\n\n## New features\n\n### Internationalization\n\nAnubis 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:\n\n- [Brazilian Portuguese](https://github.com/TecharoHQ/anubis/pull/726)\n- [Chinese (Simplified)](https://github.com/TecharoHQ/anubis/pull/774)\n- [Chinese (Traditional)](https://github.com/TecharoHQ/anubis/pull/759)\n- [Czech](https://github.com/TecharoHQ/anubis/pull/849)\n- English\n- [Estonian](https://github.com/TecharoHQ/anubis/pull/783)\n- [Filipino](https://github.com/TecharoHQ/anubis/pull/775)\n- [Finnish](https://github.com/TecharoHQ/anubis/pull/863)\n- [French](https://github.com/TecharoHQ/anubis/pull/716)\n- [German](https://github.com/TecharoHQ/anubis/pull/741)\n- [Japanese](https://github.com/TecharoHQ/anubis/pull/772)\n- [Icelandic](https://github.com/TecharoHQ/anubis/pull/780)\n- [Italian](https://github.com/TecharoHQ/anubis/pull/778)\n- [Norwegian](https://github.com/TecharoHQ/anubis/pull/855)\n- [Russian](https://github.com/TecharoHQ/anubis/pull/882)\n- [Spanish](https://github.com/TecharoHQ/anubis/pull/716)\n- [Turkish](https://github.com/TecharoHQ/anubis/pull/751)\n\nIf facts or local regulations demand, you can set Anubis default language with the `FORCED_LANGUAGE` environment variable or the `--forced-language` command line argument:\n\n```sh\nFORCED_LANGUAGE=de\n```\n\n## Big ticket bug fixes\n\nThese issues affect every user of Anubis. Administrators should upgrade Anubis as soon as possible to mitigate them.\n\n### Fix event loop thrashing when solving a proof of work challenge\n\nAnubis 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:\n\n```js\nif (\n  (nonce > oldNonce) | 1023 && // we've wrapped past 1024\n  (nonce >> 10) % threads === threadId // and it's our turn\n) {\n  postMessage(nonce);\n}\n```\n\nThe 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.\n\nFor 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:\n\n| Iteration | Worker ID | Nonce |\n| :-------- | :-------- | :---- |\n| 1         | 0         | 0     |\n| 1         | 1         | 1     |\n| 2         | 0         | 2     |\n| 2         | 1         | 3     |\n\nAnd 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.\n\nThe 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.\n\nThis is bad for the event loop.\n\nInstead, 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.\n\nThe 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.\n\nEither way, thanks much to [@wolfbeast](https://github.com/wolfbeast) and the Pale Moon community for finding this. This will make Anubis faster for everyone!\n\n### Fix potential memory leak when discovering a solution\n\nIn 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:\n\n1. A worker sends a message indicating it found a solution to the proof of work challenge.\n2. The `onmessage` handler for that worker calls `terminate()`\n3. Inside `terminate()`, the parent process loops through all other workers and calls `w.terminate()` on them.\n4. It's possible that terminating a worker could lead to the `onerror` event handler.\n5. This would create a recursive loop of `onmessage` -> `terminate` -> `onerror` -> `terminate` -> `onerror` and so on.\n\nThis 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.\n\nThe fix adds a boolean `settled` flag to prevent termination from running more than once.\n\n## Expressions features\n\nAnubis v1.21.1 adds additional [expressions](/docs/admin/configuration/expressions) features so that you can make your request matching even more granular.\n\n### `missingHeader` function\n\nAnubis [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.\n\nLet'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.\n\n```yaml\n# Adds weight clients that claim to be Google Chrome without setting Sec-Ch-Ua\n- name: old-chrome\n  action: WEIGH\n  weight:\n    adjust: 10\n  expression:\n    all:\n      - userAgent.matches(\"Chrome/[1-9][0-9]?\\\\.0\\\\.0\\\\.0\")\n      - missingHeader(headers, \"Sec-Ch-Ua\")\n```\n\nWhen 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.\n\n### Load average checks\n\nAnubis 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:\n\n```yaml\n## System load based checks.\n# If the system is under high load for the last minute, add weight.\n- name: high-load-average\n  action: WEIGH\n  expression: load_1m >= 10.0 # make sure to end the load comparison in a .0\n  weight:\n    adjust: 20\n\n# If it is not for the last 15 minutes, remove weight.\n- name: low-load-average\n  action: WEIGH\n  expression: load_15m <= 4.0 # make sure to end the load comparison in a .0\n  weight:\n    adjust: -10\n```\n\nSomething 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:\n\n|      Kind | Core count | Load threshold |\n| --------: | :--------- | :------------- |\n| high load | 4          | `8.0`          |\n|  low load | 4          | `2.0`          |\n| high load | 16         | `32.0`         |\n|  low load | 16         | `8`            |\n\nAlso 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.\n\n:::note\n\nThis does not work if you are using Kubernetes.\n\n:::\n\nWhen combined with [weight thresholds](/docs/admin/configuration/thresholds), this allows you to make incoming sessions \"back off\" while the server is under high load.\n\n## Challenge flow v2\n\nThe 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/).\n\nPreviously the Anubis challenge flow looked like this:\n\n```mermaid\n---\ntitle: Old Anubis challenge flow\n---\nflowchart LR\n    user(User Browser)\n    subgraph Anubis\n        mIC{Challenge?}\n        ic(Issue Challenge)\n        rp(Proxy to service)\n        mIC -->|User needs a challenge| ic\n        mIC -->|User does not need a challenge| rp\n    end\n    target(Target Service)\n    rp --> target\n    user --> mIC\n    ic -->|Pass a challenge| user\n    target -->|Site data| users\n```\n\nIn 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:\n\n- The client's User-Agent string.\n- The client [`Accept-Language` header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Accept-Language) value.\n- The client's IP address.\n\nAnubis 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:\n\n```mermaid\n---\ntitle: Anubis challenge string idempotency\n---\nsequenceDiagram\n    User->>+Anubis: GET /wiki/some-page\n    Anubis->>+Make Challenge: Generate a challenge string\n    Make Challenge->>-Anubis: Challenge string: taco salad\n    Anubis->>-User: HTTP 401 solve a challenge\n    User->>+Anubis: GET internal-api/pass-challenge\n    Anubis->>+Make Challenge: Generate a challenge string\n    Make Challenge->>-Anubis: Challenge string: burrito bar\n    Anubis->>+User: Error: invalid response\n```\n\nVarious attempts were made to fix this. All of these ended up failing. Many difficulties were discovered including but not limited to:\n\n- 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.\n- [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`).\n- 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.\n- [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.\n- 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.\n\nAmusingly 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.\n\nAs a result, the entire subsystem that generated challenges before had to be ripped out and rewritten from scratch.\n\nIt 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:\n\n```mermaid\n---\ntitle: New challenge flow\n---\nsequenceDiagram\n    User->>+Anubis: GET /wiki/some-page\n    Anubis->>+Make Challenge: Generate a challenge string\n    Make Challenge->>+Store: Store info for challenge 1234\n    Make Challenge->>-Anubis: Challenge string: taco salad, ID 1234\n    Anubis->>-User: HTTP 401 solve a challenge\n    User->>+Anubis: GET internal-api/pass-challenge, challenge 1234\n    Anubis->>+Validate Challenge: verify challenge 1234\n    Validate Challenge->>+Store: Get info for challenge 1234\n    Store->>-Validate Challenge: Here you go!\n    Validate Challenge->>-Anubis: Valid ✅\n    Anubis->>+User: Here's a cookie to get past Anubis\n```\n\nAs 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.\n\n### Challenge format change\n\nPreviously 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.\n\nDuring 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).\n\nIf you are using the in-memory storage backend, please consider using [a different storage backend](/docs/admin/policies/#storage-backends).\n\n### Storage\n\nAnubis offers a few different storage backends depending on your needs:\n\n| Backend                                  | Description                                                                                                    |\n| :--------------------------------------- | :------------------------------------------------------------------------------------------------------------- |\n| [`memory`](/docs/admin/policies/#memory) | An in-memory hashmap that is cleared when Anubis is restarted.                                                 |\n| [`bbolt`](/docs/admin/policies/#bbolt)   | A memory-mapped key/value store that can persist between Anubis restarts.                                      |\n| [`valkey`](/docs/admin/policies/#valkey) | A networked key/value store that can persist between Anubis restarts and coordinate across multiple instances. |\n\nPlease review the documentation for each storage method to figure out the one best for your needs. If you aren't sure, consult this diagram:\n\n```mermaid\n---\ntitle: What storage backend do I need?\n---\nflowchart TD\n    OneInstance{Do you only have\none instance of\nAnubis?}\n    Persistence{Do you have\npersistent disk\naccess in your\nenvironment?}\n    bbolt[(bbolt)]\n    memory[(memory)]\n    valkey[(valkey)]\n    OneInstance -->|Yes| Persistence\n    OneInstance -->|No| valkey\n    Persistence -->|Yes| bbolt\n    Persistence -->|No| memory\n```\n\n## Breaking change: systemd `RuntimeDirectory` change\n\nThe following potentially breaking change applies to native installs with systemd only:\n\nEach 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.\n\nIf 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.\n\nIf you need the legacy behaviour, install this [systemd unit dropin](https://www.flatcar.org/docs/latest/setup/systemd/drop-in-units/):\n\n```systemd\n# /etc/systemd/system/anubis@.service.d/50-runtimedir.conf\n[Service]\nRuntimeDirectory=anubis\n```\n\nJust keep in mind that this will cause problems when Anubis restarts.\n\n## What's up next?\n\nThe biggest things we want to do in the next release (in no particular order):\n\n- A rewrite of bot checking rule configuration syntax to make it less ambiguous.\n- [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.\n- Advanced heuristics for [users of the unbranded variant of Anubis](/docs/admin/botstopper/).\n- 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.\n- Add \"hot reloading\" support to Anubis, allowing administrators to update the rules without restarting the service.\n- Fix [multiple slash support](https://github.com/TecharoHQ/anubis/issues/754) for web applications that require optional path variables.\n- Add weight to \"brand new\" clients.\n- 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.\n- Open [Thoth-based advanced checks](/docs/admin/thoth/) to more users with an easier onboarding flow.\n- More smoke tests including for browsers like [Pale Moon](https://www.palemoon.org/).\n"
  },
  {
    "path": "docs/blog/2025-08-18-funding-update/index.mdx",
    "content": "---\nslug: 2025/funding-update\ntitle: Funding update\nauthors: [xe]\ntags: [funding]\nimage: around-the-bend.webp\n---\n\n![](./around-the-bend.webp)\n\nAs 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.\n\n{/* truncate */}\n\nNote 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.\n\n:::note\n\nAll currency figures in this article are USD (United States Dollars) unless denoted otherwise.\n\n:::\n\nHere's the funding breakdown by income stream:\n\n```mermaid\npie title Funding update August 2025\n    \"GitHub Sponsors\" : 3500\n    \"Patreon\" : 1500\n    \"Liberapay\" : 100\n    \"Remaining\" : 4800\n```\n\nAssuming 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).\n\nAs a reminder, here's the rough plan for the phases I want to hit based on the _recurring_ donation totals:\n\n| Monthly donations           | Details                                                                                                                                                                     |\n| :-------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| $0-5,000 per month          | Anubis is a nights and weekends project based on how much spare time and energy I have.                                                                                     |\n| $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.                                                                          |\n| $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. |\n| $15,000 per month and above | I start planning hiring for Techaro.                                                                                                                                        |\n\nIf 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.\n\n## New funding platform: Liberapay\n\nAfter 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.\n\n## Next goals\n\nHere's my short term goals for the immediate future:\n\n1. Finish [Thoth](/docs/admin/thoth/) and run a backfill to mass issue API keys.\n2. Document and publish the writeup for the multi-region Google Cloud spot instance setup that Thoth is built upon.\n3. Release v1.22.0 of Anubis with Traefik support and other important fixes.\n4. Continue growing the project into a sustainable business.\n5. 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.\n\nThank you for supporting Anubis! It's only going to get better from here.\n"
  },
  {
    "path": "docs/blog/2025-08-28-cpu-core-odd/ProofOfWorkDiagram/index.jsx",
    "content": "import React, { useState, useEffect, useMemo } from \"react\";\nimport styles from \"./styles.module.css\";\n\n// A helper function to perform SHA-256 hashing.\n// It takes a string, encodes it, hashes it, and returns a hex string.\nasync function sha256(message) {\n  try {\n    const msgBuffer = new TextEncoder().encode(message);\n    const hashBuffer = await crypto.subtle.digest(\"SHA-256\", msgBuffer);\n    const hashArray = Array.from(new Uint8Array(hashBuffer));\n    const hashHex = hashArray\n      .map((b) => b.toString(16).padStart(2, \"0\"))\n      .join(\"\");\n    return hashHex;\n  } catch (error) {\n    console.error(\"Hashing failed:\", error);\n    return \"Error hashing data\";\n  }\n}\n\n// Generates a random hex string of a given byte length\nconst generateRandomHex = (bytes = 16) => {\n  const buffer = new Uint8Array(bytes);\n  crypto.getRandomValues(buffer);\n  return Array.from(buffer)\n    .map((byte) => byte.toString(16).padStart(2, \"0\"))\n    .join(\"\");\n};\n\n// Icon components for better visual feedback\nconst CheckIcon = () => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    className={styles.iconGreen}\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    stroke=\"currentColor\"\n  >\n    <path\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth={2}\n      d=\"M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z\"\n    />\n  </svg>\n);\n\nconst XCircleIcon = () => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    className={styles.iconRed}\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    stroke=\"currentColor\"\n  >\n    <path\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth={2}\n      d=\"M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z\"\n    />\n  </svg>\n);\n\n// Main Application Component\nexport default function App() {\n  // State for the challenge, initialized with a random 16-byte hex string.\n  const [challenge, setChallenge] = useState(() => generateRandomHex(16));\n  // State for the nonce, which is the variable we can change\n  const [nonce, setNonce] = useState(0);\n  // State to store the resulting hash\n  const [hash, setHash] = useState(\"\");\n  // A flag to indicate if the current hash is the \"winning\" one\n  const [isMining, setIsMining] = useState(false);\n  const [isFound, setIsFound] = useState(false);\n\n  // The mining difficulty, i.e., the required number of leading zeros\n  const difficulty = \"00\";\n\n  // Memoize the combined data to avoid recalculating on every render\n  const combinedData = useMemo(\n    () => `${challenge}${nonce}`,\n    [challenge, nonce],\n  );\n\n  // This effect hook recalculates the hash whenever the combinedData changes.\n  useEffect(() => {\n    let isMounted = true;\n    const calculateHash = async () => {\n      const calculatedHash = await sha256(combinedData);\n      if (isMounted) {\n        setHash(calculatedHash);\n        setIsFound(calculatedHash.startsWith(difficulty));\n      }\n    };\n    calculateHash();\n    return () => {\n      isMounted = false;\n    };\n  }, [combinedData, difficulty]);\n\n  // This effect handles the automatic mining process\n  useEffect(() => {\n    if (!isMining) return;\n\n    let miningNonce = nonce;\n    let continueMining = true;\n\n    const mine = async () => {\n      while (continueMining) {\n        const currentData = `${challenge}${miningNonce}`;\n        const currentHash = await sha256(currentData);\n\n        if (currentHash.startsWith(difficulty)) {\n          setNonce(miningNonce);\n          setIsMining(false);\n          break;\n        }\n\n        miningNonce++;\n        // Update the UI periodically to avoid freezing the browser\n        if (miningNonce % 100 === 0) {\n          setNonce(miningNonce);\n          await new Promise((resolve) => setTimeout(resolve, 0)); // Yield to the browser\n        }\n      }\n    };\n\n    mine();\n\n    return () => {\n      continueMining = false;\n    };\n  }, [isMining, challenge, nonce, difficulty]);\n\n  const handleMineClick = () => {\n    setIsMining(true);\n  };\n\n  const handleStopClick = () => {\n    setIsMining(false);\n  };\n\n  const handleResetClick = () => {\n    setIsMining(false);\n    setNonce(0);\n  };\n\n  const handleNewChallengeClick = () => {\n    setIsMining(false);\n    setChallenge(generateRandomHex(16));\n    setNonce(0);\n  };\n\n  // Helper to render the hash with colored leading characters\n  const renderHash = () => {\n    if (!hash) return <span>...</span>;\n    const prefix = hash.substring(0, difficulty.length);\n    const suffix = hash.substring(difficulty.length);\n    const prefixColor = isFound ? styles.hashPrefixGreen : styles.hashPrefixRed;\n    return (\n      <>\n        <span className={`${prefixColor} ${styles.hashPrefix}`}>{prefix}</span>\n        <span className={styles.hashSuffix}>{suffix}</span>\n      </>\n    );\n  };\n\n  return (\n    <div className={styles.container}>\n      <div className={styles.innerContainer}>\n        <div className={styles.grid}>\n          {/* Challenge Block */}\n          <div className={styles.block}>\n            <h2 className={styles.blockTitle}>1. Challenge</h2>\n            <p className={styles.challengeText}>{challenge}</p>\n          </div>\n\n          {/* Nonce Control Block */}\n          <div className={styles.block}>\n            <h2 className={styles.blockTitle}>2. Nonce</h2>\n            <div className={styles.nonceControls}>\n              <button\n                onClick={() => setNonce((n) => n - 1)}\n                disabled={isMining}\n                className={styles.nonceButton}\n              >\n                <svg\n                  xmlns=\"http://www.w3.org/2000/svg\"\n                  className={styles.iconSmall}\n                  fill=\"none\"\n                  viewBox=\"0 0 24 24\"\n                  stroke=\"currentColor\"\n                >\n                  <path\n                    strokeLinecap=\"round\"\n                    strokeLinejoin=\"round\"\n                    strokeWidth={2}\n                    d=\"M20 12H4\"\n                  />\n                </svg>\n              </button>\n              <span className={styles.nonceValue}>{nonce}</span>\n              <button\n                onClick={() => setNonce((n) => n + 1)}\n                disabled={isMining}\n                className={styles.nonceButton}\n              >\n                <svg\n                  xmlns=\"http://www.w3.org/2000/svg\"\n                  className={styles.iconSmall}\n                  fill=\"none\"\n                  viewBox=\"0 0 24 24\"\n                  stroke=\"currentColor\"\n                >\n                  <path\n                    strokeLinecap=\"round\"\n                    strokeLinejoin=\"round\"\n                    strokeWidth={2}\n                    d=\"M12 4v16m8-8H4\"\n                  />\n                </svg>\n              </button>\n            </div>\n          </div>\n\n          {/* Combined Data Block */}\n          <div className={styles.block}>\n            <h2 className={styles.blockTitle}>3. Combined Data</h2>\n            <p className={styles.combinedDataText}>{combinedData}</p>\n          </div>\n        </div>\n\n        {/* Arrow pointing down */}\n        <div className={styles.arrowContainer}>\n          <svg\n            xmlns=\"http://www.w3.org/2000/svg\"\n            className={styles.iconGray}\n            fill=\"none\"\n            viewBox=\"0 0 24 24\"\n            stroke=\"currentColor\"\n          >\n            <path\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n              strokeWidth={2}\n              d=\"M19 14l-7 7m0 0l-7-7m7 7V3\"\n            />\n          </svg>\n        </div>\n\n        {/* Hash Output Block */}\n        <div\n          className={`${styles.hashContainer} ${isFound ? styles.hashContainerSuccess : styles.hashContainerError}`}\n        >\n          <div className={styles.hashContent}>\n            <div className={styles.hashText}>\n              <h2 className={styles.blockTitle}>4. Resulting Hash (SHA-256)</h2>\n              <p className={styles.hashValue}>{renderHash()}</p>\n            </div>\n            <div className={styles.hashIcon}>\n              {isFound ? <CheckIcon /> : <XCircleIcon />}\n            </div>\n          </div>\n        </div>\n\n        {/* Mining Controls */}\n        <div className={styles.buttonContainer}>\n          {!isMining ? (\n            <button\n              onClick={handleMineClick}\n              className={`${styles.button} ${styles.buttonCyan}`}\n            >\n              Auto-Mine\n            </button>\n          ) : (\n            <button\n              onClick={handleStopClick}\n              className={`${styles.button} ${styles.buttonYellow}`}\n            >\n              Stop Mining\n            </button>\n          )}\n          <button\n            onClick={handleNewChallengeClick}\n            className={`${styles.button} ${styles.buttonIndigo}`}\n          >\n            New Challenge\n          </button>\n          <button\n            onClick={handleResetClick}\n            className={`${styles.button} ${styles.buttonGray}`}\n          >\n            Reset Nonce\n          </button>\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "docs/blog/2025-08-28-cpu-core-odd/ProofOfWorkDiagram/styles.module.css",
    "content": "/* Main container styles */\n.container {\n  display: flex;\n  flex-direction: column;\n  align-items: center;\n  justify-content: center;\n  color: white;\n  font-family: ui-sans-serif, system-ui, sans-serif;\n  margin-top: 2rem;\n  margin-bottom: 2rem;\n}\n\n.innerContainer {\n  width: 100%;\n  max-width: 56rem;\n  margin: 0 auto;\n}\n\n/* Header styles */\n.header {\n  text-align: center;\n  margin-bottom: 2.5rem;\n}\n\n.title {\n  font-size: 2.25rem;\n  font-weight: 700;\n  color: rgb(34 211 238);\n}\n\n.subtitle {\n  font-size: 1.125rem;\n  color: rgb(156 163 175);\n  margin-top: 0.5rem;\n}\n\n/* Grid layout styles */\n.grid {\n  display: grid;\n  grid-template-columns: repeat(3, 1fr);\n  gap: 1rem;\n  align-items: center;\n  text-align: center;\n}\n\n/* Block styles */\n.block {\n  background-color: rgb(31 41 55);\n  padding: 1.5rem;\n  border-radius: 0.5rem;\n  box-shadow:\n    0 10px 15px -3px rgb(0 0 0 / 0.1),\n    0 4px 6px -4px rgb(0 0 0 / 0.1);\n  height: 100%;\n  display: flex;\n  flex-direction: column;\n  justify-content: center;\n}\n\n.blockTitle {\n  font-size: 1.125rem;\n  font-weight: 600;\n  color: rgb(34 211 238);\n  margin-bottom: 0.5rem;\n}\n\n.challengeText {\n  font-size: 0.875rem;\n  color: rgb(209 213 219);\n  word-break: break-all;\n  font-family: ui-monospace, SFMono-Regular, monospace;\n}\n\n.combinedDataText {\n  font-size: 0.875rem;\n  color: rgb(156 163 175);\n  word-break: break-all;\n  font-family: ui-monospace, SFMono-Regular, monospace;\n}\n\n/* Nonce control styles */\n.nonceControls {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  gap: 1rem;\n}\n\n.nonceButton {\n  background-color: rgb(55 65 81);\n  border-radius: 9999px;\n  padding: 0.5rem;\n  transition: background-color 200ms;\n}\n\n.nonceButton:hover:not(:disabled) {\n  background-color: rgb(34 211 238);\n}\n\n.nonceButton:disabled {\n  opacity: 0.5;\n  cursor: not-allowed;\n}\n\n.nonceValue {\n  font-size: 1.5rem;\n  font-family: ui-monospace, SFMono-Regular, monospace;\n  width: 6rem;\n  text-align: center;\n}\n\n/* Icon styles */\n.icon {\n  height: 2rem;\n  width: 2rem;\n}\n\n.iconGreen {\n  height: 2rem;\n  width: 2rem;\n  color: rgb(74 222 128);\n}\n\n.iconRed {\n  height: 2rem;\n  width: 2rem;\n  color: rgb(248 113 113);\n}\n\n.iconSmall {\n  height: 1.5rem;\n  width: 1.5rem;\n}\n\n.iconGray {\n  height: 2.5rem;\n  width: 2.5rem;\n  color: rgb(75 85 99);\n  animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;\n}\n\n/* Arrow animation */\n@keyframes pulse {\n  0%,\n  100% {\n    opacity: 1;\n  }\n  50% {\n    opacity: 0.5;\n  }\n}\n\n.arrowContainer {\n  display: flex;\n  justify-content: center;\n  margin: 1.5rem 0;\n}\n\n/* Hash output styles */\n.hashContainer {\n  padding: 1.5rem;\n  border-radius: 0.5rem;\n  box-shadow:\n    0 10px 15px -3px rgb(0 0 0 / 0.1),\n    0 4px 6px -4px rgb(0 0 0 / 0.1);\n  transition: all 300ms;\n  border: 2px solid;\n}\n\n.hashContainerSuccess {\n  background-color: rgb(20 83 45 / 0.5);\n  border-color: rgb(74 222 128);\n}\n\n.hashContainerError {\n  background-color: rgb(127 29 29 / 0.5);\n  border-color: rgb(248 113 113);\n}\n\n.hashContent {\n  display: flex;\n  flex-direction: column;\n  align-items: center;\n  justify-content: space-between;\n}\n\n.hashText {\n  text-align: center;\n}\n\n.hashTextLg {\n  text-align: left;\n}\n\n.hashValue {\n  font-size: 0.875rem;\n  word-break: break-all;\n}\n\n.hashValueLg {\n  font-size: 1rem;\n  word-break: break-all;\n}\n\n.hashIcon {\n  margin-top: 1rem;\n}\n\n.hashIconLg {\n  margin-top: 0;\n}\n\n/* Hash highlighting */\n.hashPrefix {\n  font-family: ui-monospace, SFMono-Regular, monospace;\n}\n\n.hashPrefixGreen {\n  color: rgb(74 222 128);\n}\n\n.hashPrefixRed {\n  color: rgb(248 113 113);\n}\n\n.hashSuffix {\n  font-family: ui-monospace, SFMono-Regular, monospace;\n  color: rgb(156 163 175);\n}\n\n/* Button styles */\n.buttonContainer {\n  margin-top: 2rem;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  gap: 1rem;\n}\n\n.button {\n  font-weight: 700;\n  padding: 0.75rem 1.5rem;\n  border-radius: 0.5rem;\n  transition: transform 150ms;\n}\n\n.button:hover {\n  transform: scale(1.05);\n}\n\n.buttonCyan {\n  background-color: rgb(8 145 178);\n  color: white;\n}\n\n.buttonCyan:hover {\n  background-color: rgb(6 182 212);\n}\n\n.buttonYellow {\n  background-color: rgb(202 138 4);\n  color: white;\n}\n\n.buttonYellow:hover {\n  background-color: rgb(245 158 11);\n}\n\n.buttonIndigo {\n  background-color: rgb(79 70 229);\n  color: white;\n}\n\n.buttonIndigo:hover {\n  background-color: rgb(99 102 241);\n}\n\n.buttonGray {\n  background-color: rgb(55 65 81);\n  color: white;\n}\n\n.buttonGray:hover {\n  background-color: rgb(75 85 99);\n}\n\n/* Responsive styles */\n@media (min-width: 768px) {\n  .title {\n    font-size: 3rem;\n  }\n\n  .grid {\n    grid-template-columns: repeat(3, 1fr);\n    gap: 1rem;\n  }\n\n  .hashContent {\n    flex-direction: row;\n  }\n\n  .hashText {\n    text-align: left;\n  }\n\n  .hashValue {\n    font-size: 1rem;\n  }\n\n  .hashIcon {\n    margin-top: 0;\n  }\n}\n\n@media (max-width: 767px) {\n  .grid {\n    display: flex;\n    flex-direction: column;\n    gap: 1rem;\n  }\n}\n\n@media (prefers-color-scheme: light) {\n  .block {\n    background-color: oklch(93% 0.034 272.788);\n  }\n\n  .challengeText {\n    color: oklch(12.9% 0.042 264.695);\n  }\n\n  .combinedDataText {\n    color: oklch(12.9% 0.042 264.695);\n  }\n\n  .nonceButton {\n    background-color: oklch(88.2% 0.059 254.128);\n  }\n\n  .nonceValue {\n    color: oklch(12.9% 0.042 264.695);\n  }\n\n  .blockTitle {\n    color: oklch(45% 0.085 224.283);\n  }\n\n  .hashContainerSuccess {\n    background-color: oklch(95% 0.052 163.051);\n    border-color: rgb(74 222 128);\n  }\n\n  .hashContainerError {\n    background-color: oklch(94.1% 0.03 12.58);\n    border-color: rgb(248 113 113);\n  }\n\n  .hashPrefixGreen {\n    color: oklch(53.2% 0.157 131.589);\n    font-weight: 600;\n  }\n\n  .hashPrefixRed {\n    color: oklch(45.5% 0.188 13.697);\n  }\n\n  .hashSuffix {\n    color: oklch(27.9% 0.041 260.031);\n  }\n}\n"
  },
  {
    "path": "docs/blog/2025-08-28-cpu-core-odd/index.mdx",
    "content": "---\nslug: 2025/cpu-core-odd\ntitle: Sometimes CPU cores are odd\ndescription: \"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.\"\nauthors: [xe]\ntags:\n  - bugfix\n  - implementation\nimage: parc-dsilence.webp\n---\n\nimport ProofOfWorkDiagram from \"./ProofOfWorkDiagram\";\n\n![](./parc-dsilence.webp)\n\nOne 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.\n\nToday 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.\n\n{/* truncate */}\n\n## How this happened\n\nAnubis 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.\n\n:::note\n\nIn 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.\n\n:::\n\nIn 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.\n\nThis 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.\n\n## Another \"invalid response\" bug\n\nOne 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.\n\nIn order to demonstrate this better, I've made a little interactive diagram for the proof of work process:\n\n<ProofOfWorkDiagram />\n\nI'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.\n\nLet's consider [the line of code](https://github.com/TecharoHQ/anubis/blob/main/web/js/algorithms/fast.mjs) that was causing this issue:\n\n```js\nthreads = Math.max(navigator.hardwareConcurrency / 2, 1),\n```\n\nThis 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.\n\nOne 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?\n\nHere's all the devices I use to test Anubis _and their core counts_:\n\n| Device Name                  | Core Count |\n| :--------------------------- | :--------- |\n| MacBook Pro M3 Max           | 16         |\n| MacBook Pro M4 Max           | 16         |\n| AMD Ryzen 9 7950x3D          | 32         |\n| Google Pixel 9a (GrapheneOS) | 8          |\n| iPhone 15 Pro Max            | 6          |\n| iPad Pro (M1)                | 8          |\n| iPad mini                    | 6          |\n| Steam Deck                   | 8          |\n| Core i5 10600 (homelab)      | 12         |\n| ROG Ally                     | 16         |\n\nNotice 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?\n\nLet's replace the [`navigator.hardwareConcurrency`](https://developer.mozilla.org/en-US/docs/Web/API/Navigator/hardwareConcurrency) with the Pixel 8 Pro's 9 cores:\n\n```js\nthreads = Math.max(9 / 2, 1),\n```\n\nThen divide it by two:\n\n```js\nthreads = Math.max(4.5, 1),\n```\n\nOops, 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:\n\n```js\nthreads = 4.5,\n```\n\nThis 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.\n\nI 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.\n\nHere is the fix:\n\n```js\nthreads = Math.trunc(Math.max(navigator.hardwareConcurrency / 2, 1)),\n```\n\nThis 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.\n\n## Today I learned this was possible\n\nThis 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:\n\n| Core type          | Core model           | Number |\n| :----------------- | :------------------- | :----- |\n| High performance   | 3 Ghz Cortex X3      | 1      |\n| Medium performance | 2.45 Ghz Cortex A715 | 4      |\n| High efficiency    | 2.15 Cortex A510     | 4      |\n| Total              |                      | 9      |\n\nI guess every assumption that developers have about CPU design is probably wrong.\n\nThis 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.\n\nThe 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.\n\nI'm looking into the following:\n\n1. 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.\n2. 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.\n\nI'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.\n\nIn 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!\n\nThank you to the people that have been polite and helpful when trying to root cause and fix this issue.\n"
  },
  {
    "path": "docs/blog/2025-10-31-file-abuse-reports/index.mdx",
    "content": "---\nslug: 2025/file-abuse-reports\ntitle: Taking steps to end abusive traffic from cloud providers\ndescription: \"Learn how to effectively file abuse reports with cloud providers to stop malicious traffic at its source and protect your services from automated abuse.\"\nauthors: [xe]\ntags: [abuse, cloud, security, networking]\nimage: goose-pond.webp\n---\n\n![A peaceful goose pond](./goose-pond.webp)\n\nAs 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.\n\n{/* truncate */}\n\nIn general, there are two kinds of IP addresses:\n\n- 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).\n- 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).\n\nIn 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.\n\n:::note\n\nKeep 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.\n\n:::\n\nWhat 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.\n\n## How to make an abuse report\n\nIn general, the best abuse reports contain the following information:\n\n- Time of abusive requests.\n- IP address, User-Agent header, or other unique identifiers that can help the abuse team educate the customer about their misbehaving infrastructure.\n- Does the abusive IP address request robots.txt? If not, be sure to include that information.\n- 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.\n- Context as to what your service is, what it does, and why they should care.\n\nFor 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:\n\n> Hello,\n>\n> 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.\n>\n> 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.\n>\n> 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.\n>\n> Please let me know what other information you need on your end.\n>\n> Sincerely,\n>\n> [normal email signature]\n\nThen 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:\n\n```\n$ whois 1.1.1.1 | grep -i abuse\n% Abuse contact for '1.1.1.0 - 1.1.1.255' is 'helpdesk@apnic.net'\nabuse-c:        AA1412-AP\nremarks:        All Cloudflare abuse reporting can be done via\nremarks:        resolver-abuse@cloudflare.com\nabuse-mailbox:  helpdesk@apnic.net\nrole:           ABUSE APNICRANDNETAU\nabuse-mailbox:  helpdesk@apnic.net\nmnt-by:         APNIC-ABUSE\n```\n\nThe 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.\n\nOnce 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.\n\nJust 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.\n"
  },
  {
    "path": "docs/blog/authors.yml",
    "content": "xe:\n  name: Xe Iaso\n  title: CEO @ Techaro\n  url: https://github.com/Xe\n  image_url: https://github.com/Xe.png\n  email: xe@techaro.lol\n  page: true\n  socials:\n    github: Xe\n"
  },
  {
    "path": "docs/docs/CHANGELOG.md",
    "content": "---\nsidebar_position: 999\n---\n\n# Changelog\n\nAll notable changes to this project will be documented in this file.\n\nThe format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),\nand this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n\n## [Unreleased]\n\n- fix: prevent nil pointer panic in challenge validation when threshold rules match during PassChallenge (#1463)\n- Instruct reverse proxies to not cache error pages.\n- Fixed mixed tab/space indentation in Caddy documentation code block\n\n<!-- This changes the project to: -->\n\n## v1.25.0: Necron\n\nHey all,\n\nI'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!\n\nI'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.\n\nI'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.\n\nI'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.\n\nAnyways, 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.\n\nIf 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.\n\nAt 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.\n\nBe well,\n\nXe\n\nPS: 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.\n\n- Add iplist2rule tool that lets admins turn an IP address blocklist into an Anubis ruleset.\n- Add Polish locale ([#1292](https://github.com/TecharoHQ/anubis/pull/1309))\n- Fix honeypot and imprint links missing `BASE_PREFIX` when deployed behind a path prefix ([#1402](https://github.com/TecharoHQ/anubis/issues/1402))\n- Add ANEXIA Sponsor logo to docs ([#1409](https://github.com/TecharoHQ/anubis/pull/1409))\n- Improve idle performance in memory storage\n- Add HAProxy Configurations to Docs ([#1424](https://github.com/TecharoHQ/anubis/pull/1424))\n\n## v1.24.0: Y'shtola Rhul\n\nAnubis is back and better than ever! Lots of minor fixes with some big ones interspersed.\n\n- Fix panic when validating challenges after privacy-mode browsers strip headers and the follow-up request matches an `ALLOW` threshold.\n- Expose WEIGHT rule matches as Prometheus metrics.\n- Allow more OCI registry clients [based on feedback](https://github.com/TecharoHQ/anubis/pull/1253#issuecomment-3506744184).\n- Expose services directory in the embedded `(data)` filesystem.\n- Add Ukrainian locale ([#1044](https://github.com/TecharoHQ/anubis/pull/1044)).\n- Allow Renovate as an OCI registry client.\n- Properly handle 4in6 addresses so that IP matching works with those addresses.\n- Add support to simple Valkey/Redis cluster mode\n- 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))\n- Stabilize the CVE-2025-24369 regression test by always submitting an invalid proof instead of relying on random POW failures.\n- Refine the check that ensures the presence of the Accept header to avoid breaking docker clients.\n- Removed rules intended to reward actual browsers due to abuse in the wild.\n\n### Dataset poisoning\n\nAnubis 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.\n\nThere 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.\n\n### Deprecate `report_as` in challenge configuration\n\nPreviously Anubis let you lie to users about the difficulty of a challenge to interfere with operators of malicious scrapers as a psychological attack:\n\n```yaml\nbots:\n  # Punish any bot with \"bot\" in the user-agent string\n  # This is known to have a high false-positive rate, use at your own risk\n  - name: generic-bot-catchall\n    user_agent_regex: (?i:bot|crawler)\n    action: CHALLENGE\n    challenge:\n      difficulty: 16 # impossible\n      report_as: 4 # lie to the operator\n      algorithm: slow # intentionally waste CPU cycles and time\n```\n\nThis 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:\n\n```json\n{\n  \"time\": \"2025-11-25T23:10:31.092201549-05:00\",\n  \"level\": \"WARN\",\n  \"source\": {\n    \"function\": \"github.com/TecharoHQ/anubis/lib/policy.ParseConfig\",\n    \"file\": \"/home/xe/code/TecharoHQ/anubis/lib/policy/policy.go\",\n    \"line\": 201\n  },\n  \"msg\": \"use of deprecated report_as setting detected, please remove this from your policy file when possible\",\n  \"at\": \"config-validate\",\n  \"name\": \"mild-suspicion\"\n}\n```\n\nTo remove this warning, remove this setting from your policy file.\n\n### Logging customization\n\nAnubis 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:\n\n```yaml\nlogging:\n  level: \"warn\" # much less verbose logging\n  sink: file # log to a file\n  parameters:\n    file: \"./var/anubis.log\"\n    maxBackups: 3 # keep at least 3 old copies\n    maxBytes: 67108864 # each file can have up to 64 Mi of logs\n    maxAge: 7 # rotate files out every n days\n    oldFileTimeFormat: 2006-01-02T15-04-05 # RFC 3339-ish\n    compress: true # gzip-compress old log files\n    useLocalTime: false # timezone for rotated files is UTC\n```\n\nAdditionally, information about [how Anubis uses each logging level](./admin/policies.mdx#log-levels) has been added to the documentation.\n\n### DNS Features\n\n- CEL expressions for:\n  - FCrDNS checks\n  - Forward DNS queries\n  - Reverse DNS queries\n  - `arpaReverseIP` to transform IPv4/6 addresses into ARPA reverse IP notation.\n  - `regexSafe` to escape regex special characters (useful for including `remoteAddress` or headers in regular expressions).\n- DNS cache and other optimizations to minimize unnecessary DNS queries.\n\nThe DNS cache TTL can be changed in the bots config like this:\n\n```yaml\ndns_ttl:\n  forward: 600\n  reverse: 600\n```\n\nThe default value for both forward and reverse queries is 300 seconds.\n\nThe `verifyFCrDNS` CEL function has two overloads:\n\n- `(addr)`\n  Simply verifies that the remote side has PTR records pointing to the target address.\n- `(addr, ptrPattern)`\n  Verifies that the remote side refers to a specific domain and that this domain points to the target IP.\n\n## v1.23.1: Lyse Hext - Echo 1\n\n- Fix `SERVE_ROBOTS_TXT` setting after the double slash fix broke it.\n\n### Potentially breaking changes\n\n#### Remove default Tencent Cloud block rule\n\nv1.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:\n\n- Time of abusive requests.\n- IP address, User-Agent header, or other unique identifiers that can help the abuse team educate the customer about their misbehaving infrastructure.\n- Does the abusive IP address request robots.txt? If not, be sure to include that information.\n- 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.\n- Context as to what your service is, what it does, and why they should care.\n\nMention 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.\n\n#### Docker / OCI registry clients\n\nAnubis v1.23.0 accidentally blocked Docker / OCI registry clients. In order to explicitly allow them, add an import for `(data)/clients/docker-client.yaml`:\n\n```yaml\nbots:\n  - import: (data)/meta/default-config.yaml\n  - import: (data)/clients/docker-client.yaml\n```\n\nThis 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.\n\n## v1.23.0: Lyse Hext\n\n- Add default tencent cloud DENY rule.\n- Added `(data)/meta/default-config.yaml` for importing the entire default configuration at once.\n- Add `-custom-real-ip-header` flag to get the original request IP from a different header than `x-real-ip`.\n- Add `contentLength` variable to bot expressions.\n- Add `COOKIE_SAME_SITE_MODE` to force anubis cookies SameSite value, and downgrade automatically from `None` to `Lax` if cookie is insecure.\n- Fix lock convoy problem in decaymap ([#1103](https://github.com/TecharoHQ/anubis/issues/1103)).\n- Fix lock convoy problem in bbolt by implementing the actor pattern ([#1103](https://github.com/TecharoHQ/anubis/issues/1103)).\n- Remove bbolt actorify implementation due to causing production issues.\n- 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)).\n- Add validation warning when persistent storage is used without setting signing keys.\n- 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)).\n- Make the `fast` algorithm prefer purejs when running in an insecure context.\n- Add the [`s3api` storage backend](./admin/policies.mdx#s3api) to allow Anubis to use S3 API compatible object storage as its storage backend.\n- 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`.\n- Make `cmd/containerbuild` support commas for separating elements of the `--docker-tags` argument as well as newlines.\n- 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)).\n- Ported the client-side JS to TypeScript to avoid egregious errors in the future.\n- Fixes concurrency problems with very old browsers ([#1082](https://github.com/TecharoHQ/anubis/issues/1082)).\n- Randomly use the Refresh header instead of the meta refresh tag in the metarefresh challenge.\n- Update OpenRC service to truncate the runtime directory before starting Anubis.\n- Make the git client profile more strictly match how the git client behaves.\n- Make the default configuration reward users using normal browsers.\n- Allow multiple consecutive slashes in a row in application paths ([#754](https://github.com/TecharoHQ/anubis/issues/754)).\n- 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)).\n- The Preact challenge has been removed from the default configuration. It will be deprecated in the future.\n- An open redirect when in subrequest mode has been fixed.\n\n### Potentially breaking changes\n\n#### Multiple checks at once has and-like semantics instead of or-like semantics\n\nAnubis lets you stack multiple checks at once with blocks like this:\n\n```yaml\nname: allow-prometheus\naction: ALLOW\nuser_agent_regex: ^prometheus-probe$\nremote_addresses:\n  - 192.168.2.0/24\n```\n\nPreviously, 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.\n\n### Better error messages\n\nIn 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.\n\n### Bug Fixes\n\nSometimes 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.\n\n## v1.22.0: Yda Hext\n\n> Someone has to make an effort at reconciliation if these conflicts are ever going to end.\n\nIn 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.\n\n### Big ticket items\n\n#### Proof of React challenge\n\nA 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.\n\n#### Smaller features\n\n- The [`segments`](./admin/configuration/expressions.mdx#segments) function was added for splitting a path into its slash-separated segments.\n- Added possibility to disable HTTP keep-alive to support backends not properly handling it.\n- 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))\n- 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.\n- Proof of work solving has had a complete overhaul and rethink based on feedback from browser engine developers, frontend experts, and overall performance profiling.\n- Optimize the performance of the pure-JS Anubis solver.\n- Web Workers are stored as dedicated JavaScript files in `static/js/workers/*.mjs`.\n- 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).\n- Legacy JavaScript code has been eliminated.\n- 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.\n- The hard dependency on WebCrypto has been removed, allowing a proof of work challenge to work over plain (unencrypted) HTTP.\n- The Anubis version number is put in the footer of every page.\n- Add a default block rule for Huawei Cloud.\n- Add a default block rule for Alibaba Cloud.\n- Added support to use Traefik forwardAuth middleware.\n- Add X-Request-URI support so that Subrequest Authentication has path support.\n- 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.\n\n### Fixes\n\n#### Odd numbers of CPU cores are properly supported\n\nSome 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).\n\n#### Smaller fixes\n\n- 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.\n- Added a missing link to the Caddy installation environment in the installation documentation.\n- 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)).\n- The [Thoth client](https://anubis.techaro.lol/docs/admin/thoth) is now public in the repo instead of being an internal package.\n- [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)).\n- Add option for replacing the default explanation text with a custom one ([#747](https://github.com/TecharoHQ/anubis/pull/747))\n- The contact email in the LibreJS header has been changed.\n- 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.\n- 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.\n- The `--cookie-prefix` flag has been fixed so that it is fully respected.\n- The default patterns in `data/common/keep-internet-working.yaml` have been updated to appropriately escape the '.' character in the regular expression patterns.\n- Add optional restrictions for JWT based on the value of a header ([#697](https://github.com/TecharoHQ/anubis/pull/697))\n- 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.\n- Bump AI-robots.txt to version 1.39\n- Inject adversarial input to break AI coding assistants.\n- Add better logging when using Subrequest Authentication.\n\n### Security-relevant changes\n\n- 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.\n\n#### Fix potential double-spend for challenges\n\nAnubis 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.\n\nThe 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.\n\nThis 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.\n\nWith 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.\n\nWebsites 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.\n\nThanks to [@taviso](https://github.com/taviso) for reporting this issue.\n\n### Breaking changes\n\n- 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.\n- 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.\n\n### New Locales\n\n- Lithuanian [#972](https://github.com/TecharoHQ/anubis/pull/972)\n- Vietnamese [#926](https://github.com/TecharoHQ/anubis/pull/926)\n\n## v1.21.3: Minfilia Warde - Echo 3\n\n### Added\n\n#### New locales\n\nAnubis now supports these new languages:\n\n- [Swedish](https://github.com/TecharoHQ/anubis/pull/913)\n\n### Fixes\n\n#### Fixes a problem with nonstandard URLs and redirects\n\nFixes [GHSA-jhjj-2g64-px7c](https://github.com/TecharoHQ/anubis/security/advisories/GHSA-jhjj-2g64-px7c).\n\nThis 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.\n\nThis has been fixed by disallowing any URLs without the scheme `http` or `https`.\n\nAdditionally, the \"Try again\" button has been fixed to completely ignore the user-supplied redirect location. It now redirects to the home page (`/`).\n\n## v1.21.2: Minfilia Warde - Echo 2\n\nThis 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.\n\n## v1.21.1: Minfilia Warde - Echo 1\n\n- Expired records are now properly removed from bbolt databases ([#848](https://github.com/TecharoHQ/anubis/pull/848)).\n- Fix hanging on service restart ([#853](https://github.com/TecharoHQ/anubis/issues/853))\n\n### Added\n\nAnubis now supports the [`missingHeader`](./admin/configuration/expressions.mdx#missingHeader) to assert the absence of headers in requests.\n\n#### New locales\n\nAnubis now supports these new languages:\n\n- [Czech](https://github.com/TecharoHQ/anubis/pull/849)\n- [Finnish](https://github.com/TecharoHQ/anubis/pull/863)\n- [Norwegian Bokmål](https://github.com/TecharoHQ/anubis/pull/855)\n- [Norwegian Nynorsk](https://github.com/TecharoHQ/anubis/pull/855)\n- [Russian](https://github.com/TecharoHQ/anubis/pull/882)\n\n### Fixes\n\n#### 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\n\nv1.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.\n\nThis has been fixed by making Anubis throw a new challenge page instead.\n\n#### Fix event loop thrashing when solving a proof of work challenge\n\nPreviously 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.\n\nThis has been fixed in the following ways:\n\n1. The complicated boolean logic has been totally removed in favour of a worker-local iteration counter.\n2. The progress bar is updated by worker `0` instead of all workers.\n\nHopefully 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.\n\n#### Fix potential memory leak when discovering a solution\n\nIn 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.\n\n## v1.21.0: Minfilia Warde\n\n> Please, be at ease. You are among friends here.\n\nIn 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.\n\n### Big ticket changes\n\nThe 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.\n\nIn order to reduce confusion, the \"Success\" interstitial that shows up when you pass a proof of work challenge has been removed.\n\n#### Storage\n\nAnubis 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.\n\n#### Localization\n\nAnubis 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:\n\n- [Brazilian Portugese](https://github.com/TecharoHQ/anubis/pull/726)\n- [Chinese (Simplified)](https://github.com/TecharoHQ/anubis/pull/774)\n- [Chinese (Traditional)](https://github.com/TecharoHQ/anubis/pull/759)\n- English\n- [Estonian](https://github.com/TecharoHQ/anubis/pull/783)\n- [Filipino](https://github.com/TecharoHQ/anubis/pull/775)\n- [French](https://github.com/TecharoHQ/anubis/pull/716)\n- [German](https://github.com/TecharoHQ/anubis/pull/741)\n- [Icelandic](https://github.com/TecharoHQ/anubis/pull/780)\n- [Italian](https://github.com/TecharoHQ/anubis/pull/778)\n- [Japanese](https://github.com/TecharoHQ/anubis/pull/772)\n- [Spanish](https://github.com/TecharoHQ/anubis/pull/716)\n- [Turkish](https://github.com/TecharoHQ/anubis/pull/751)\n\nIf facts or local regulations demand, you can set Anubis default language with the `FORCED_LANGUAGE` environment variable or the `--forced-language` command line argument:\n\n```sh\nFORCED_LANGUAGE=de\n```\n\n#### Load average\n\nAnubis 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:\n\n```yaml\n## System load based checks.\n# If the system is under high load for the last minute, add weight.\n- name: high-load-average\n  action: WEIGH\n  expression: load_1m >= 10.0 # make sure to end the load comparison in a .0\n  weight:\n    adjust: 20\n\n# If it is not for the last 15 minutes, remove weight.\n- name: low-load-average\n  action: WEIGH\n  expression: load_15m <= 4.0 # make sure to end the load comparison in a .0\n  weight:\n    adjust: -10\n```\n\nSomething 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:\n\n|      Kind | Core count | Load threshold |\n| --------: | :--------- | :------------- |\n| high load | 4          | `8.0`          |\n|  low load | 4          | `2.0`          |\n| high load | 16         | `32.0`         |\n|  low load | 16         | `8`            |\n\nAlso 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.\n\n### Other features and fixes\n\nThere are a bunch of other assorted features and fixes too:\n\n- 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)\n- Sets cookie defaults to use [SameSite: None](https://web.dev/articles/samesite-cookies-explained)\n- Determine the `BIND_NETWORK`/`--bind-network` value from the bind address ([#677](https://github.com/TecharoHQ/anubis/issues/677)).\n- Implement a [development container](https://containers.dev/) manifest to make contributions easier.\n- Fix dynamic cookie domains functionality ([#731](https://github.com/TecharoHQ/anubis/pull/731))\n- Add option for custom cookie prefix ([#732](https://github.com/TecharoHQ/anubis/pull/732))\n- 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.\n- Allow [Common Crawl](https://commoncrawl.org/) by default so scrapers have less incentive to scrape\n- The [bbolt storage backend](./admin/policies.mdx#bbolt) now runs its cleanup every hour instead of every five minutes.\n- Don't block Anubis starting up if [Thoth](./admin/thoth.mdx) health checks fail.\n- 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.\n- The \"Try again\" button on the error page has been fixed. Previously it meant \"try the solution again\" instead of \"try the challenge again\".\n- 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.\n- Start exposing JA4H fingerprints for later use in CEL expressions.\n- Add `/healthz` route for use in platform-based health checks.\n\n### Potentially breaking changes\n\nWe try to introduce breaking changes as much as possible, but these are the changes that may be relevant for you as an administrator:\n\n#### Challenge format change\n\nPreviously 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.\n\nDuring 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).\n\nIf you are using the in-memory storage backend, please consider using [a different storage backend](./admin/policies.mdx#storage-backends).\n\n#### Systemd service changes\n\nThe following potentially breaking change applies to native installs with systemd only:\n\nEach 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).\n\nIf 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.\n\nIf you need the legacy behaviour, install this [systemd unit dropin](https://www.flatcar.org/docs/latest/setup/systemd/drop-in-units/):\n\n```systemd\n# /etc/systemd/system/anubis@.service.d/50-runtimedir.conf\n[Service]\nRuntimeDirectory=anubis\n```\n\nJust keep in mind that this will cause problems when Anubis restarts.\n\n## v1.20.0: Thancred Waters\n\nThe big ticket items are as follows:\n\n- Implement a no-JS challenge method: [`metarefresh`](./admin/configuration/challenges/metarefresh.mdx) ([#95](https://github.com/TecharoHQ/anubis/issues/95))\n- Implement request \"weight\", allowing administrators to customize the behaviour of Anubis based on specific criteria\n- Implement GeoIP and ASN based checks via [Thoth](https://anubis.techaro.lol/docs/admin/thoth) ([#206](https://github.com/TecharoHQ/anubis/issues/206))\n- Add [custom weight thresholds](./admin/configuration/thresholds.mdx) via CEL ([#688](https://github.com/TecharoHQ/anubis/pull/688))\n- Move Open Graph configuration [to the policy file](./admin/configuration/open-graph.mdx)\n- Enable support for Open Graph metadata to be returned by default instead of doing lookups against the target\n- Add `robots2policy` CLI utility to convert robots.txt files to Anubis challenge policies using CEL expressions ([#409](https://github.com/TecharoHQ/anubis/issues/409))\n- Refactor challenge presentation logic to use a challenge registry\n- Allow challenge implementations to register HTTP routes\n- [Imprint/Impressum support](./admin/configuration/impressum.mdx) ([#362](https://github.com/TecharoHQ/anubis/issues/362))\n- Fix \"invalid response\" after \"Success!\" in Chromium ([#564](https://github.com/TecharoHQ/anubis/issues/564))\n\nA lot of performance improvements have been made:\n\n- Replace internal SHA256 hashing with xxhash for 4-6x performance improvement in policy evaluation and cache operations\n- Optimized the OGTags subsystem with reduced allocations and runtime per request by up to 66%\n- Replace cidranger with bart for IP range checking, improving IP matching performance by 3-20x with zero heap\n  allocations\n\nAnd some cleanups/refactors were added:\n\n- Fix OpenGraph passthrough ([#717](https://github.com/TecharoHQ/anubis/issues/717))\n- Remove the unused `/test-error` endpoint and update the testing endpoint `/make-challenge` to only be enabled in\n  development\n- Add `--xff-strip-private` flag/envvar to toggle skipping X-Forwarded-For private addresses or not\n- Bump AI-robots.txt to version 1.37\n- Make progress bar styling more compatible (UXP, etc)\n- Add `--strip-base-prefix` flag/envvar to strip the base prefix from request paths when forwarding to target servers\n- Fix an off-by-one in the default threshold config\n- Add functionality for HS512 JWT algorithm\n- Add support for dynamic cookie domains with the `--cookie-dynamic-domain`/`COOKIE_DYNAMIC_DOMAIN` flag/envvar\n\nRequest 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.\n\nTL;DR when you have one or more WEIGHT rules like this:\n\n```yaml\nbots:\n  - name: gitea-session-token\n    action: WEIGH\n    expression:\n      all:\n        - '\"Cookie\" in headers'\n        - headers[\"Cookie\"].contains(\"i_love_gitea=\")\n    # Remove 5 weight points\n    weight:\n      adjust: -5\n```\n\nYou can configure custom thresholds like this:\n\n```yaml\nthresholds:\n  - name: minimal-suspicion # This client is likely fine, its soul is lighter than a feather\n    expression: weight < 0 # a feather weighs zero units\n    action: ALLOW # Allow the traffic through\n\n  # For clients that had some weight reduced through custom rules, give them a\n  # lightweight challenge.\n  - name: mild-suspicion\n    expression:\n      all:\n        - weight >= 0\n        - weight < 10\n    action: CHALLENGE\n    challenge:\n      # https://anubis.techaro.lol/docs/admin/configuration/challenges/metarefresh\n      algorithm: metarefresh\n      difficulty: 1\n      report_as: 1\n\n  # For clients that are browser-like but have either gained points from custom\n  # rules or report as a standard browser.\n  - name: moderate-suspicion\n    expression:\n      all:\n        - weight >= 10\n        - weight < 20\n    action: CHALLENGE\n    challenge:\n      # https://anubis.techaro.lol/docs/admin/configuration/challenges/proof-of-work\n      algorithm: fast\n      difficulty: 2 # two leading zeros, very fast for most clients\n      report_as: 2\n\n  # For clients that are browser like and have gained many points from custom\n  # rules\n  - name: extreme-suspicion\n    expression: weight >= 20\n    action: CHALLENGE\n    challenge:\n      # https://anubis.techaro.lol/docs/admin/configuration/challenges/proof-of-work\n      algorithm: fast\n      difficulty: 4\n      report_as: 4\n```\n\nThese thresholds apply when no other `ALLOW`, `DENY`, or `CHALLENGE` rule matches the request. `WEIGHT` rules add and remove request weight as needed:\n\n```yaml\nbots:\n  - name: gitea-session-token\n    action: WEIGH\n    expression:\n      all:\n        - '\"Cookie\" in headers'\n        - headers[\"Cookie\"].contains(\"i_love_gitea=\")\n    # Remove 5 weight points\n    weight:\n      adjust: -5\n\n  - name: bot-like-user-agent\n    action: WEIGH\n    expression: '\"Bot\" in userAgent'\n    # Add 5 weight points\n    weight:\n      adjust: 5\n```\n\nOf note: the default \"generic browser\" rule assigns 10 weight points:\n\n```yaml\n# Generic catchall rule\n- name: generic-browser\n  user_agent_regex: >-\n    Mozilla|Opera\n  action: WEIGH\n  weight:\n    adjust: 10\n```\n\nAdjust this as you see fit.\n\n## v1.19.1: Jenomis cen Lexentale - Echo 1\n\n- Return `data/bots/ai-robots-txt.yaml` to avoid breaking configs [#599](https://github.com/TecharoHQ/anubis/issues/599)\n\n## v1.19.0: Jenomis cen Lexentale\n\nMostly a bunch of small features, no big ticket things this time.\n\n- 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))\n- Ensure that clients that are shown a challenge support storing cookies\n- Imprint the version number into challenge pages\n- Encode challenge pages with gzip level 1\n- Add PowerPC 64 bit little-endian builds (`GOARCH=ppc64le`)\n- Add `check-spelling` for spell checking\n- Add `--target-insecure-skip-verify` flag/envvar to allow Anubis to hit a self-signed HTTPS backend\n- Minor adjustments to FreeBSD rc.d script to allow for more flexible configuration.\n- Added Podman and Docker support for running Playwright tests\n- Add a default rule to throw challenges when a request with the `X-Firefox-Ai` header is set\n- Updated the nonce value in the challenge JWT cookie to be a string instead of a number\n- Rename cookies in response to user feedback\n- Ensure cookie renaming is consistent across configuration options\n- Add Bookstack app in data\n- Truncate everything but the first five characters of Accept-Language headers when making challenges\n- Ensure client JavaScript is served with Content-Type text/javascript.\n- Add `--target-host` flag/envvar to allow changing the value of the Host header in requests forwarded to the target service\n- Bump AI-robots.txt to version 1.31\n- Add `RuntimeDirectory` to systemd unit settings so native packages can listen over unix sockets\n- Added SearXNG instance tracker whitelist policy\n- Added Qualys SSL Labs whitelist policy\n- Fixed cookie deletion logic ([#520](https://github.com/TecharoHQ/anubis/issues/520), [#522](https://github.com/TecharoHQ/anubis/pull/522))\n- Add `--target-sni` flag/envvar to allow changing the value of the TLS handshake hostname in requests forwarded to the target service\n- Fixed CEL expression matching validator to now properly error out when it receives empty expressions\n- Added OpenRC init.d script\n- Added `--version` flag\n- Added `anubis_proxied_requests_total` metric to count proxied requests\n- Add `Applebot` as \"good\" web crawler\n- Reorganize AI/LLM crawler blocking into three separate stances, maintaining existing status quo as default\n- Split out AI/LLM user agent blocking policies, adding documentation for each\n\n## v1.18.0: Varis zos Galvus\n\nThe 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.\n\nThese can be as simple as:\n\n```yaml\n- name: allow-api-requests\n  action: ALLOW\n  expression:\n    all:\n      - '\"Accept\" in headers'\n      - 'headers[\"Accept\"] == \"application/json\"'\n      - 'path.startsWith(\"/api/\")'\n```\n\nOr as complicated as:\n\n```yaml\n- name: allow-git-clients\n  action: ALLOW\n  expression:\n    all:\n      - >-\n        (\n          userAgent.startsWith(\"git/\") ||\n          userAgent.contains(\"libgit\") ||\n          userAgent.startsWith(\"go-git\") ||\n          userAgent.startsWith(\"JGit/\") ||\n          userAgent.startsWith(\"JGit-\")\n        )\n      - '\"Git-Protocol\" in headers'\n      - headers[\"Git-Protocol\"] == \"version=2\"\n```\n\nThe docs have more information, but here's a tl;dr of the variables you have access to in expressions:\n\n| Name            | Type                  | Explanation                                                                                                                               | Example                                                      |\n| :-------------- | :-------------------- | :---------------------------------------------------------------------------------------------------------------------------------------- | :----------------------------------------------------------- |\n| `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\"}` |\n| `host`          | `string`              | The [HTTP hostname](https://web.dev/articles/url-parts#host) the request is targeted to.                                                  | `anubis.techaro.lol`                                         |\n| `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.                                |\n| `path`          | `string`              | The [path](https://web.dev/articles/url-parts#pathname) of the request being processed.                                                   | `/`, `/api/memes/create`                                     |\n| `query`         | `map[string, string]` | The [query parameters](https://web.dev/articles/url-parts#query) of the request being processed.                                          | `?foo=bar` -> `{\"foo\": \"bar\"}`                               |\n| `remoteAddress` | `string`              | The IP address of the client.                                                                                                             | `1.1.1.1`                                                    |\n| `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`                   |\n\nThis 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.\n\nOther changes:\n\n- Use CSS variables to deduplicate styles\n- Fixed native packages not containing the stdlib and botPolicies.yaml\n- Change import syntax to allow multi-level imports\n- Changed the startup logging to use JSON formatting as all the other logs do\n- Added the ability to do [expression matching with CEL](./admin/configuration/expressions.mdx)\n- Add a warning for clients that don't store cookies\n- Disable Open Graph passthrough by default ([#435](https://github.com/TecharoHQ/anubis/issues/435))\n- Clarify the license of the mascot images ([#442](https://github.com/TecharoHQ/anubis/issues/442))\n- Started Suppressing 'Context canceled' errors from http in the logs ([#446](https://github.com/TecharoHQ/anubis/issues/446))\n\n## v1.17.1: Asahi sas Brutus: Echo 1\n\n- Added customization of authorization cookie expiration time with `--cookie-expiration-time` flag or envvar\n- Updated the `OG_PASSTHROUGH` to be true by default, thereby allowing Open Graph tags to be passed through by default\n- Added the ability to [customize Anubis' HTTP status codes](./admin/configuration/custom-status-codes.mdx) ([#355](https://github.com/TecharoHQ/anubis/issues/355))\n\n## v1.17.0: Asahi sas Brutus\n\n- Ensure regexes can't end in newlines ([#372](https://github.com/TecharoHQ/anubis/issues/372))\n- Add documentation for default allow behavior (implicit rule)\n- Enable [importing configuration snippets](./admin/configuration/import.mdx) ([#321](https://github.com/TecharoHQ/anubis/pull/321))\n- Refactor check logic to be more generic and work on a Checker type\n- Add more AI user agents based on the [ai.robots.txt](https://github.com/ai-robots-txt/ai.robots.txt) project\n- Embedded challenge data in initial HTML response to improve performance\n- Added support to use Nginx' `auth_request` directive with Anubis\n- Added support to allow to restrict the allowed redirect domains\n- Whitelisted [DuckDuckBot](https://duckduckgo.com/duckduckgo-help-pages/results/duckduckbot/) in botPolicies\n- Improvements to build scripts to make them less independent of the build host\n- Improved the Open Graph error logging\n- Added `Opera` to the `generic-browser` bot policy rule\n- Added FreeBSD rc.d script so can be run as a FreeBSD daemon\n- Allow requests from the Internet Archive\n- Added example nginx configuration to documentation\n- Added example Apache configuration to the documentation [#277](https://github.com/TecharoHQ/anubis/issues/277)\n- Move per-environment configuration details into their own pages\n- Added support for running anubis behind a prefix (e.g. `/myapp`)\n- Added headers support to bot policy rules\n- Moved configuration file from JSON to YAML by default\n- Added documentation on how to use Anubis with Traefik in Docker\n- Improved error handling in some edge cases\n- Disable `generic-bot-catchall` rule because of its high false positive rate in real-world scenarios\n- Moved all CSS inline to the Xess package, changed colors to be CSS variables\n- Set or append to `X-Forwarded-For` header unless the remote connects over a loopback address [#328](https://github.com/TecharoHQ/anubis/issues/328)\n- Fixed mojeekbot user agent regex\n- Reduce Anubis' paranoia with user cookies ([#365](https://github.com/TecharoHQ/anubis/pull/365))\n- Added support for Open Graph passthrough while using unix sockets\n- The Open Graph subsystem now passes the HTTP `HOST` header through to the origin\n- Updated the `OG_PASSTHROUGH` to be true by default, thereby allowing Open Graph tags to be passed through by default\n\n## v1.16.0\n\nFordola rem Lupis\n\n> 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!\n\nThe following features are the \"big ticket\" items:\n\n- Added support for native Debian, Red Hat, and tarball packaging strategies including installation and use directions\n- A prebaked tarball has been added, allowing distros to build Anubis like they could in v1.15.x\n- The placeholder Anubis mascot has been replaced with a design by [CELPHASE](https://bsky.app/profile/celphase.bsky.social)\n- Verification page now shows hash rate and a progress bar for completion probability\n- 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))\n- Added support for passing the ed25519 signing key in a file with `-ed25519-private-key-hex-file` or `ED25519_PRIVATE_KEY_HEX_FILE`\n\nThe other small fixes have been made:\n\n- Added a periodic cleanup routine for the decaymap that removes expired entries, ensuring stale data is properly pruned\n- Added a no-store Cache-Control header to the challenge page\n- Hide the directory listings for Anubis' internal static content\n- Changed `--debug-x-real-ip-default` to `--use-remote-address`, getting the IP address from the request's socket address instead\n- DroneBL lookups have been disabled by default\n- Static asset builds are now done on demand instead of the results being committed to source control\n- The Dockerfile has been removed as it is no longer in use\n- Developer documentation has been added to the docs site\n- Show more errors when some predictable challenge page errors happen ([#150](https://github.com/TecharoHQ/anubis/issues/150))\n- Added the `--debug-benchmark-js` flag for testing proof-of-work performance during development\n- Use `TrimSuffix` instead of `TrimRight` on containerbuild\n- Fix the startup logs to correctly show the address and port the server is listening on\n- Add [LibreJS](https://www.gnu.org/software/librejs/) banner to Anubis JavaScript to allow LibreJS users to run the challenge\n- Added a wait with button continue + 30 second auto continue after 30s if you click \"Why am I seeing this?\"\n- Fixed a typo in the challenge page title\n- 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))\n- Fixed minor typos\n- Added a Makefile to enable comfortable workflows for downstream packagers\n- Added `zizmor` for GitHub Actions static analysis\n- Fixed most `zizmor` findings\n- Enabled Dependabot\n- Added an air config for autoreload support in development ([#195](https://github.com/TecharoHQ/anubis/pull/195))\n- Added an `--extract-resources` flag to extract static resources to a local folder\n- Add noindex flag to all Anubis pages ([#227](https://github.com/TecharoHQ/anubis/issues/227))\n- 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))\n- Hash pinned all GitHub Actions\n\n## v1.15.1\n\nZenos yae Galvus: Echo 1\n\nFixes a recurrence of [CVE-2025-24369](https://github.com/Xe/x/security/advisories/GHSA-56w8-8ppj-2p4f)\ndue to an incorrect logic change in a refactor. This allows an attacker to mint a valid\naccess token by passing any SHA-256 hash instead of one that matches the proof-of-work\ntest.\n\nThis case has been added as a regression test. It was not when CVE-2025-24369 was released\ndue to the project not having the maturity required to enable this kind of regression testing.\n\n## v1.15.0\n\nZenos yae Galvus\n\n> Yes...the coming days promise to be most interesting. Most interesting.\n\nHeadline changes:\n\n- 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\n- Add the ability to set the cookie domain with the envvar `COOKIE_DOMAIN=techaro.lol` for all domains under `techaro.lol`\n- Add the ability to set the cookie partitioned flag with the envvar `COOKIE_PARTITIONED=true`\n\nMany other small changes were made, including but not limited to:\n\n- Fixed and clarified installation instructions\n- Introduced integration tests using Playwright\n- Refactor & Split up Anubis into cmd and lib.go\n- Fixed bot check to only apply if address range matches\n- Fix default difficulty setting that was broken in a refactor\n- Linting fixes\n- Make dark mode diff lines readable in the documentation\n- Fix CI based browser smoke test\n\nUsers 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).\n\n## v1.14.2\n\nLivia sas Junius: Echo 2\n\n- Remove default RSS reader rule as it may allow for a targeted attack against rails apps\n  [#67](https://github.com/TecharoHQ/anubis/pull/67)\n- Whitelist MojeekBot in botPolicies [#47](https://github.com/TecharoHQ/anubis/issues/47)\n- botPolicies regex has been cleaned up [#66](https://github.com/TecharoHQ/anubis/pull/66)\n\n## v1.14.1\n\nLivia sas Junius: Echo 1\n\n- Set the `X-Real-Ip` header based on the contents of `X-Forwarded-For`\n  [#62](https://github.com/TecharoHQ/anubis/issues/62)\n\n## v1.14.0\n\nLivia sas Junius\n\n> Fail to do as my lord commands...and I will spare him the trouble of blocking you.\n\n- Add explanation of what Anubis is doing to the challenge page [#25](https://github.com/TecharoHQ/anubis/issues/25)\n- Administrators can now define artificially hard challenges using the \"slow\" algorithm:\n\n  ```json\n  {\n    \"name\": \"generic-bot-catchall\",\n    \"user_agent_regex\": \"(?i:bot|crawler)\",\n    \"action\": \"CHALLENGE\",\n    \"challenge\": {\n      \"difficulty\": 16,\n      \"report_as\": 4,\n      \"algorithm\": \"slow\"\n    }\n  }\n  ```\n\n  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.\n\n- Docker images now explicitly call `docker.io/library/<thing>` to increase compatibility with Podman et. al\n  [#21](https://github.com/TecharoHQ/anubis/pull/21)\n- Don't overflow the image when browser windows are small (eg. on phones)\n  [#27](https://github.com/TecharoHQ/anubis/pull/27)\n- Lower the default difficulty to 5 from 4\n- Don't duplicate work across multiple threads [#36](https://github.com/TecharoHQ/anubis/pull/36)\n- Documentation has been moved to https://anubis.techaro.lol/ with sources in docs/\n- Removed several visible AI artifacts (e.g., 6 fingers) [#37](https://github.com/TecharoHQ/anubis/pull/37)\n- [KagiBot](https://kagi.com/bot) is allowed through the filter [#44](https://github.com/TecharoHQ/anubis/pull/44)\n- Fixed hang when navigator.hardwareConcurrency is undefined\n- Support Unix domain sockets [#45](https://github.com/TecharoHQ/anubis/pull/45)\n- Allow filtering by remote addresses:\n\n  ```json\n  {\n    \"name\": \"qwantbot\",\n    \"user_agent_regex\": \"\\\\+https\\\\:\\\\/\\\\/help\\\\.qwant\\\\.com/bot/\",\n    \"action\": \"ALLOW\",\n    \"remote_addresses\": [\"91.242.162.0/24\"]\n  }\n  ```\n\n  This also works at an IP range level:\n\n  ```json\n  {\n    \"name\": \"internal-network\",\n    \"action\": \"ALLOW\",\n    \"remote_addresses\": [\"100.64.0.0/10\"]\n  }\n  ```\n\n## 1.13.0\n\n- Proof-of-work challenges are drastically sped up [#19](https://github.com/TecharoHQ/anubis/pull/19)\n- Docker images are now built with the timestamp set to the commit timestamp\n- The README now points to TecharoHQ/anubis instead of Xe/x\n- Images are built using ko instead of `docker buildx build`\n  [#13](https://github.com/TecharoHQ/anubis/pull/13)\n\n## 1.12.1\n\n- Phrasing in the `<noscript>` warning was replaced from its original placeholder text to\n  something more suitable for general consumption\n  ([fd6903a](https://github.com/TecharoHQ/anubis/commit/fd6903aeed315b8fddee32890d7458a9271e4798)).\n- Footer links on the check page now point to Techaro's brand\n  ([4ebccb1](https://github.com/TecharoHQ/anubis/commit/4ebccb197ec20d024328d7f92cad39bbbe4d6359))\n- Anubis was imported from [Xe/x](https://github.com/Xe/x)\n"
  },
  {
    "path": "docs/docs/admin/_category_.json",
    "content": "{\n  \"label\": \"Administrative guides\",\n  \"position\": 40,\n  \"link\": {\n    \"type\": \"generated-index\",\n    \"description\": \"Tradeoffs and considerations you may want to keep in mind when using Anubis.\"\n  }\n}\n"
  },
  {
    "path": "docs/docs/admin/botstopper.mdx",
    "content": "---\ntitle: \"Commercial support and an unbranded version\"\n---\n\nIf 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:\n\n- Custom images for different states of the challenge process (in process, success, failure)\n- Custom CSS and fonts\n- Custom titles for the challenge and error pages\n- \"Anubis\" replaced with \"BotStopper\" across the UI\n- A private bug tracker for issues\n\nIn the near future this will expand to:\n\n- A private challenge implementation that does advanced fingerprinting to check if the client is a genuine browser or not\n- Advanced fingerprinting via [Thoth-based advanced checks](./thoth.mdx)\n\nIn order to sign up for BotStopper, please do one of the following:\n\n- Sign up [on GitHub Sponsors](https://github.com/sponsors/Xe) at the $50 per month tier or higher\n- 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\n\n## Installation\n\nInstall BotStopper like you would Anubis, but replace the image reference. EG:\n\n```diff\n-ghcr.io/techarohq/anubis:latest\n+ghcr.io/techarohq/botstopper/anubis:latest\n```\n\n### Binary packages\n\nBinary 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.\n\n### Docker / Podman\n\nIn 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).\n\n```text\ndocker login ghcr.io -u your-username --password-stdin\n```\n\nThen you can use the image as normal.\n\n### Kubernetes\n\nIf you are using Kubernetes, you will need to create an image pull secret:\n\n```text\nkubectl create secret docker-registry \\\n  techarohq-botstopper \\\n  --docker-server ghcr.io \\\n  --docker-username any-username \\\n  --docker-password <your-access-token> \\\n```\n\nThen attach it to your Deployment:\n\n```diff\n     spec:\n       securityContext:\n         fsGroup: 1000\n+      imagePullSecrets:\n+      - name: techarohq-botstopper\n```\n\n## Configuration\n\n### Docker compose\n\nFollow [the upstream Docker compose directions](https://anubis.techaro.lol/docs/admin/environments/docker-compose) with the following additional options:\n\n```diff\n   anubis:\n     image: ghcr.io/techarohq/botstopper/anubis:latest\n     environment:\n       BIND: \":8080\"\n       DIFFICULTY: \"4\"\n       METRICS_BIND: \":9090\"\n       SERVE_ROBOTS_TXT: \"true\"\n       TARGET: \"http://nginx\"\n       OG_PASSTHROUGH: \"true\"\n       OG_EXPIRY_TIME: \"24h\"\n\n+      # botstopper config here\n+      CHALLENGE_TITLE: \"Doing math for your connection!\"\n+      ERROR_TITLE: \"Something went wrong!\"\n+      OVERLAY_FOLDER: /assets\n+    volumes:\n+      - \"./your_folder:/assets\"\n```\n\n#### Example\n\nThere is an example in [docker-compose.yaml](https://github.com/TecharoHQ/botstopper/blob/main/docker-compose.yaml). Start the example with `docker compose up`:\n\n```text\ndocker compose up -d\n```\n\nAnd then open [https://botstopper.local.cetacean.club:8443](https://botstopper.local.cetacean.club:8443) in your browser.\n\n> [!NOTE]  \n> This uses locally signed sacrificial TLS certificates stored in `./demo/pki`. Your browser will rightly reject these. Here is what the example looks like:\n>\n> ![](/img/botstopper/example-screenshot.webp)\n\n## Custom images and CSS\n\nAnubis 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.\n\nYour directory tree should look like this, assuming your data is in `./your_folder`:\n\n```text\n./your_folder\n└── static\n    ├── css\n    │   └── custom.css\n    └── img\n        ├── happy.webp\n        ├── pensive.webp\n        └── reject.webp\n```\n\nFor 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.\n\n### Header-based overlay dispatch\n\nIf 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:\n\n```text\n$OVERLAY_FOLDER\n├── static\n│   ├── css\n│   │   ├── custom.css\n│   │   └── eyesore.css\n│   └── img\n│       ├── happy.webp\n│       ├── pensive.webp\n│       └── reject.webp\n└── test.anubis.techaro.lol\n    └── static\n        ├── css\n        │   └── custom.css\n        └── img\n            ├── happy.webp\n            ├── pensive.webp\n            └── reject.webp\n```\n\nRequests 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`.\n\nFor an example, look at [the testdata folder in the BotStopper repo](https://github.com/TecharoHQ/botstopper/tree/main/testdata).\n\n### Custom CSS\n\nCSS 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.\n\n### Custom fonts\n\nIf 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):\n\n```css\n@font-face {\n  font-family: \"Oswald\";\n  font-style: normal;\n  font-weight: 200 900;\n  font-display: swap;\n  src: url(\"./fonts/oswald.woff2\") format(\"woff2\");\n}\n```\n\nThen adjust your CSS variables accordingly:\n\n```css\n:root {\n  --body-sans-font: Oswald, sans-serif;\n  --body-preformatted-font: monospace;\n  --body-title-font: serif;\n}\n```\n\nTo 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:\n\n```console\n$ woff2_compress oswald.ttf\nProcessing oswald.ttf => oswald.woff2\nCompressed 159517 to 70469.\n```\n\nThen you can import and use it as normal.\n\n### Customizing images\n\nAnubis uses three images to visually communicate the state of the program. These are:\n\n| Image name     | Intended message                                 | Example                           |\n| :------------- | :----------------------------------------------- | :-------------------------------- |\n| `happy.webp`   | You have passed validation, all is good          | ![](/img/botstopper/happy.webp)   |\n| `pensive.webp` | Checking is running, hold steady until it's done | ![](/img/botstopper/pensive.webp) |\n| `reject.webp`  | Something went wrong, this is a terminal state   | ![](/img/botstopper/reject.webp)  |\n\nTo make your own images at the optimal quality, use the following ffmpeg command:\n\n```text\nffmpeg -i /path/to/image -vf scale=-1:384 happy.webp\n```\n\n`ffprobe` should report something like this on the generated images:\n\n```text\nInput #0, webp_pipe, from 'happy.webp':\n  Duration: N/A, bitrate: N/A\n  Stream #0:0: Video: webp, none, 25 fps, 25 tbr, 25 tbn\n```\n\nIn testing 384 by 384 pixels gives the best balance between filesize, quality, and clarity.\n\n```text\n$ du -hs *\n4.0K    happy.webp\n 12K    pensive.webp\n8.0K    reject.webp\n```\n\n## Custom HTML templates\n\nIf 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.\n\nIn order to use this, you must define the following templates:\n\n| Template path                              | Usage                                           |\n| :----------------------------------------- | :---------------------------------------------- |\n| `$OVERLAY_FOLDER/templates/challenge.tmpl` | Challenge pages                                 |\n| `$OVERLAY_FOLDER/templates/error.tmpl`     | Error pages                                     |\n| `$OVERLAY_FOLDER/templates/impressum.tmpl` | [Impressum](./configuration/impressum.mdx) page |\n\n:::note\n\nCurrently 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.\n\n:::\n\nHere are minimal (but working) examples for each template:\n\n<details>\n<summary>`challenge.tmpl`</summary>\n\n:::note\n\nYou **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.\n\n:::\n\n```html\n<!DOCTYPE html>\n<html lang=\"{{ .Lang }}\">\n  <head>\n    {{ .Head }}\n  </head>\n  <body>\n    {{ .Body }}\n  </body>\n</html>\n```\n\n</details>\n\n<details>\n<summary>`error.tmpl`</summary>\n\n```html\n<!DOCTYPE html>\n<html lang=\"{{ .Lang }}\">\n  <body>\n    {{ .Body }}\n  </body>\n</html>\n```\n\n</details>\n\n<details>\n<summary>`impressum.tmpl`</summary>\n\n```html\n<!DOCTYPE html>\n<html lang=\"{{ .Lang }}\">\n  <body>\n    {{ .Body }}\n  </body>\n</html>\n```\n\n</details>\n\n### Template functions\n\nIn order to make life easier, the following template functions are defined:\n\n#### `Asset`\n\nConstructs the path for a static asset in the [overlay folder](#custom-images-and-css)'s `static` directory.\n\n```go\nfunc Asset(string) string\n```\n\nUsage:\n\n```html\n<link rel=\"stylesheet\" href=\"{{ Asset \"css/example.css\" }}\" />\n```\n\nGenerates:\n\n```html\n<link\n  rel=\"stylesheet\"\n  href=\"/.within.website/x/cmd/anubis/static/css/example.css\"\n/>\n```\n\n## Customizing messages\n\nYou can customize messages using the following environment variables:\n\n| Message              | Environment variable | Default                                    |\n| :------------------- | :------------------- | :----------------------------------------- |\n| Challenge page title | `CHALLENGE_TITLE`    | `Ensuring the security of your connection` |\n| Error page title     | `ERROR_TITLE`        | `Error`                                    |\n\nFor example:\n\n```sh\n# /etc/techaro-botstopper/gitea.env\nCHALLENGE_TITLE=\"Wait a moment please!\"\nERROR_TITLE=\"Client error\"\n```\n"
  },
  {
    "path": "docs/docs/admin/caveats-gitea-forgejo.mdx",
    "content": "---\ntitle: When using Caddy with Gitea/Forgejo\n---\n\nGitea/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:\n\n```python\nellenjoe.int.within.lgbt {\n  # ...\n  # diff-remove\n  reverse_proxy http://localhost:3000\n  # diff-add\n  reverse_proxy http://localhost:3000 {\n    # diff-add\n    header_up X-Real-Ip {remote_host}\n  # diff-add\n  }\n  # ...\n}\n```\n\nEnsure that Gitea/Forgejo have `[security].REVERSE_PROXY_TRUSTED_PROXIES` set to the IP ranges that Anubis will appear from. Typically this is sufficient:\n\n```ini\n[security]\nREVERSE_PROXY_TRUSTED_PROXIES = 127.0.0.0/8,::1/128\n```\n\nHowever 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:\n\n```ini\n[security]\nREVERSE_PROXY_TRUSTED_PROXIES = 10.192.0.0/12\n```\n"
  },
  {
    "path": "docs/docs/admin/caveats-xff.mdx",
    "content": "# Client IP Headers\n\nCurrently 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.\n\n- Private (`XFF_STRIP_PRIVATE`, enabled by default)\n- CGNAT (always stripped)\n- Link-local Unicast (always stripped)\n\n```\nIncoming: X-Forwarded-For: 1.2.3.4, 5.6.7.8, 10.0.0.1\nUpstream: X-Forwarded-For: 5.6.7.8\n```\n\nThis 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.\n\n```\nIncoming: X-Forwarded-For: REAL_CLIENT_IP, CF_IP\nUpstream: X-Forwarded-For: CF_IP\n```\n\nAs 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`.\n\nIf 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.\n\nThe `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.\n\nSee [Cloudflare](environments/cloudflare.mdx) for an example configuration.\n"
  },
  {
    "path": "docs/docs/admin/configuration/_category_.json",
    "content": "{\n  \"label\": \"Configuration\",\n  \"position\": 10,\n  \"link\": {\n    \"type\": \"generated-index\",\n    \"description\": \"Detailed information about configuring parts of Anubis.\"\n  }\n}\n"
  },
  {
    "path": "docs/docs/admin/configuration/challenges/_category_.json",
    "content": "{\n  \"label\": \"Challenges\",\n  \"position\": 10,\n  \"link\": null\n}\n"
  },
  {
    "path": "docs/docs/admin/configuration/challenges/index.mdx",
    "content": "# Challenge Methods\n\nAnubis supports multiple challenge methods:\n\n- [Meta Refresh](./metarefresh.mdx)\n- [Preact](./preact.mdx)\n- [Proof of Work](./proof-of-work.mdx)\n\nRead the documentation to know which method is best for you.\n"
  },
  {
    "path": "docs/docs/admin/configuration/challenges/metarefresh.mdx",
    "content": "# Meta Refresh (No JavaScript)\n\nThe `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.\n\nTo use it in your Anubis configuration:\n\n```yaml\n# Generic catchall rule\n- name: generic-browser\n  user_agent_regex: >-\n    Mozilla|Opera\n  action: CHALLENGE\n  challenge:\n    difficulty: 1 # Number of seconds to wait before refreshing the page\n    algorithm: metarefresh # Specify a non-JS challenge method\n```\n\nThis 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.\n"
  },
  {
    "path": "docs/docs/admin/configuration/challenges/preact.mdx",
    "content": "# Preact\n\nThe `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.\n\nTo use it in your Anubis configuration:\n\n```yaml\n# Generic catchall rule\n- name: generic-browser\n  user_agent_regex: >-\n    Mozilla|Opera\n  action: CHALLENGE\n  challenge:\n    difficulty: 1 # Number of seconds to wait before refreshing the page\n    algorithm: preact\n```\n\nThis is the default challenge method for most clients.\n"
  },
  {
    "path": "docs/docs/admin/configuration/challenges/proof-of-work.mdx",
    "content": "# Proof of Work (JavaScript)\n\nWhen 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.\n\nA `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.\n"
  },
  {
    "path": "docs/docs/admin/configuration/custom-status-codes.mdx",
    "content": "# Custom status codes for Anubis errors\n\nOut 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.\n\nThis is configured in the `status_codes` block of your [bot policy file](../policies.mdx):\n\n```yaml\nstatus_codes:\n  CHALLENGE: 200\n  DENY: 200\n```\n\nTo match CloudFlare's behavior, use a configuration like this:\n\n```yaml\nstatus_codes:\n  CHALLENGE: 403\n  DENY: 403\n```\n"
  },
  {
    "path": "docs/docs/admin/configuration/expressions.mdx",
    "content": "# Expression-based rule matching\n\nMost 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.\n\nAs an example, here is a rule that lets you allow JSON API requests through Anubis:\n\n```yaml\n- name: allow-api-requests\n  action: ALLOW\n  expression:\n    all:\n      - '\"Accept\" in headers'\n      - 'headers[\"Accept\"] == \"application/json\"'\n      - 'path.startsWith(\"/api/\")'\n```\n\nThis is an advanced feature and as such it is easy to get yourself in trouble with it. Use this with care.\n\n## Common Expression Language (CEL)\n\nCEL 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.\n\nImagine a CEL expression as the contents of an `if` statement in JavaScript or the `WHERE` clause in SQL. Consider this example expression:\n\n```python\nuserAgent == \"\"\n```\n\nThis is roughly equivalent to the following in JavaScript:\n\n```js\nif (userAgent == \"\") {\n  // Do something\n}\n```\n\nUsing 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).\n\n## How Anubis uses CEL\n\nAnubis uses CEL to let administrators create complicated filter rules. Anubis has several modes of using CEL:\n\n- Validating requests against single expressions\n- Validating multiple expressions and ensuring at least one of them are true (`any`)\n- Validating multiple expressions and ensuring all of them are true (`all`)\n\nThe common pattern is that every Anubis expression returns `true`, `false`, or raises an error.\n\n### Single expressions\n\nA 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.\n\nFor example, consider this rule:\n\n```yaml\n- name: no-user-agent-string\n  action: DENY\n  expression: userAgent == \"\"\n```\n\nFor 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.\n\n### `any` blocks\n\nAn `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.\n\nFor example, consider this rule:\n\n```yaml\n- name: known-banned-user\n  action: DENY\n  expression:\n    any:\n      - remoteAddress == \"8.8.8.8\"\n      - remoteAddress == \"1.1.1.1\"\n```\n\nFor 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.\n\n### `all` blocks\n\nAn `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.\n\nFor example, consider this rule:\n\n```yaml\n- name: go-get\n  action: ALLOW\n  expression:\n    all:\n      - userAgent.startsWith(\"Go-http-client/\")\n      - '\"go-get\" in query'\n      - query[\"go-get\"] == \"1\"\n```\n\nFor 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.\n\n## Variables exposed to Anubis expressions\n\nAnubis exposes the following variables to expressions:\n\n| Name            | Type                  | Explanation                                                                                                                                   | Example                                                      |\n| :-------------- | :-------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------- | :----------------------------------------------------------- |\n| `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\"}` |\n| `host`          | `string`              | The [HTTP hostname](https://web.dev/articles/url-parts#host) the request is targeted to.                                                      | `anubis.techaro.lol`                                         |\n| `contentLength` | `int64`               | The numerical value of the `Content-Length` header.                                                                                           |\n| `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).      |\n| `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).    |\n| `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). |\n| `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.                                |\n| `path`          | `string`              | The [path](https://web.dev/articles/url-parts#pathname) of the request being processed.                                                       | `/`, `/api/memes/create`                                     |\n| `query`         | `map[string, string]` | The [query parameters](https://web.dev/articles/url-parts#query) of the request being processed.                                              | `?foo=bar` -> `{\"foo\": \"bar\"}`                               |\n| `remoteAddress` | `string`              | The IP address of the client.                                                                                                                 | `1.1.1.1`                                                    |\n| `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`                   |\n\nOf 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.\n\nIn 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:\n\n```yaml\n- name: challenge-wiki-history-page\n  action: CHALLENGE\n  all:\n    - 'path == \"/index.php\"'\n    - '\"title\" in query'\n    - '\"action\" in query'\n    - 'query[\"action\"] == \"history\"'\n```\n\nThis rule throws a challenge if and only if all of the following conditions are true:\n\n- The URL path is `/index.php`\n- The URL query string contains a `title` value\n- The URL query string contains an `action` value\n- The URL query string's `action` value is `\"history\"`\n\nSo given an HTTP request like this:\n\n```text\nGET /index.php?title=Index&action=history HTTP/1.1\nUser-Agent: Mozilla/5.0 Gecko/20100101 Firefox/137.0\nHost: wiki.int.techaro.lol\nX-Real-Ip: 8.8.8.8\n```\n\nAnubis would return a challenge because all of those conditions are true.\n\n### Using the system load average\n\nIn 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.\n\nAnubis 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.\n\nThis 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:\n\n```yaml\n## System load based checks.\n# If the system is under high load for the last minute, add weight.\n- name: high-load-average\n  action: WEIGH\n  expression: load_1m >= 10.0 # make sure to end the load comparison in a .0\n  weight:\n    adjust: 20\n\n# If it is not for the last 15 minutes, remove weight.\n- name: low-load-average\n  action: WEIGH\n  expression: load_15m <= 4.0 # make sure to end the load comparison in a .0\n  weight:\n    adjust: -10\n```\n\nThis combination of rules makes Anubis dynamically react to the system load and only kick in when the system is under attack.\n\nSomething 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:\n\n|      Kind | Core count | Load threshold |\n| --------: | :--------- | :------------- |\n| high load | 4          | `8.0`          |\n|  low load | 4          | `2.0`          |\n| high load | 16         | `32.0`         |\n|  low load | 16         | `8`            |\n\nAlso 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.\n\n## Functions exposed to Anubis expressions\n\nAnubis expressions can be augmented with the following functions:\n\n### `missingHeader`\n\nAvailable in `bot` expressions.\n\n```ts\nfunction missingHeader(headers: Record<string, string>, key: string) bool\n```\n\n`missingHeader` returns `true` if the request does not contain a header. This is useful when you are trying to assert behavior such as:\n\n```yaml\n# Adds weight to old versions of Chrome\n- name: old-chrome\n  action: WEIGH\n  weight:\n    adjust: 10\n  expression:\n    all:\n      - userAgent.matches(\"Chrome/[1-9][0-9]?\\\\.0\\\\.0\\\\.0\")\n      - missingHeader(headers, \"Sec-Ch-Ua\")\n```\n\n### `randInt`\n\nAvailable in all expressions.\n\n```ts\nfunction randInt(n: int): int;\n```\n\nrandInt 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.\n\nThis is best applied when doing explicit block rules, eg:\n\n```yaml\n# Denies LightPanda about 75% of the time on average\n- name: deny-lightpanda-sometimes\n  action: DENY\n  expression:\n    all:\n      - userAgent.matches(\"LightPanda\")\n      - randInt(16) >= 4\n```\n\nIt 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.\n\n### `regexSafe`\n\nAvailable in `bot` expressions.\n\n```ts\nfunction regexSafe(input: string): string;\n```\n\n`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`.\n\n| Input                      | Output          |\n| :------------------------- | :-------------- |\n| `regexSafe(\"1.2.3.4\")`     | `1\\\\.2\\\\.3\\\\.4` |\n| `regexSafe(\"techaro.lol\")` | `techaro\\\\.lol` |\n| `regexSafe(\"star*\")`       | `star\\\\*`       |\n| `regexSafe(\"plus+\")`       | `plus\\\\+`       |\n| `regexSafe(\"{braces}\")`    | `\\\\{braces\\\\}`  |\n| `regexSafe(\"start^\")`      | `start\\\\^`      |\n| `regexSafe(\"back\\\\slash\")` | `back\\\\\\\\slash` |\n| `regexSafe(\"dash-dash\")`   | `dash\\\\-dash`   |\n\n### `segments`\n\nAvailable in `bot` expressions.\n\n```ts\nfunction segments(path: string): string[];\n```\n\n`segments` returns the number of slash-separated path segments, ignoring the leading slash. Here is what it will return with some common paths:\n\n| Input                    | Output                 |\n| :----------------------- | :--------------------- |\n| `segments(\"/\")`          | `[\"\"]`                 |\n| `segments(\"/foo/bar\")`   | `[\"foo\", \"bar\"] `      |\n| `segments(\"/users/xe/\")` | `[\"users\", \"xe\", \"\"] ` |\n\n:::note\n\nIf 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.\n\n:::\n\nThis 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:\n\n```yaml\n- name: two-path-segments-no-query\n  action: ALLOW\n  expression:\n    all:\n      - size(query) == 0\n      - size(segments(path)) < 2\n```\n\n### DNS Functions\n\nAnubis 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.\n\n#### `arpaReverseIP`\n\nAvailable in `bot` expressions.\n\n```ts\nfunction arpaReverseIP(ip: string): string;\n```\n\n`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.\n\n| Input                          | Output                                                            |\n| :----------------------------- | :---------------------------------------------------------------- |\n| `arpaReverseIP(\"1.2.3.4\")`     | `4.3.2.1`                                                         |\n| `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` |\n\n#### `lookupHost`\n\nAvailable in `bot` expressions.\n\n```ts\nfunction lookupHost(host: string): string[];\n```\n\n`lookupHost` performs a DNS lookup for the given hostname and returns a list of IP addresses.\n\n```yaml\n- name: cloudflare-ip-in-host-header\n  action: DENY\n  expression: '\"104.16.0.0\" in lookupHost(headers[\"Host\"])'\n```\n\n#### `reverseDNS`\n\nAvailable in `bot` expressions.\n\n```ts\nfunction reverseDNS(ip: string): string[];\n```\n\n`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.\n\n```yaml\n- name: allow-googlebot\n  action: ALLOW\n  expression: 'reverseDNS(remoteAddress).endsWith(\".googlebot.com\")'\n```\n\n::: warning\n\nDo 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.\n\n:::\n\n#### `verifyFCrDNS`\n\nAvailable in `bot` expressions.\n\n```ts\nfunction verifyFCrDNS(ip: string): bool;\nfunction verifyFCrDNS(ip: string, pattern: string): bool;\n```\n\n`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:\n\n- `verifyFCrDNS(remoteAddress)` will check that the reverse DNS of the remote address resolves back to the remote address. If no PTR records, returns true.\n- `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.\n\nThis is best used in rules like this:\n\n```yaml\n- name: require-fcrdns-for-post\n  action: DENY\n  expression:\n    all:\n      - method == \"POST\"\n      - \"!verifyFCrDNS(remoteAddress)\"\n```\n\nHere is an another example that allows requests from telegram:\n\n```yaml\n- name: telegrambot\n  action: ALLOW\n  expression:\n    all:\n      - userAgent.matches(\"TelegramBot\")\n      - verifyFCrDNS(remoteAddress, \"ptr\\\\.telegram\\\\.org$\")\n```\n\n## Life advice\n\nExpressions 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.\n"
  },
  {
    "path": "docs/docs/admin/configuration/import.mdx",
    "content": "# Importing configuration rules\n\nimport Tabs from \"@theme/Tabs\";\nimport TabItem from \"@theme/TabItem\";\n\nAnubis 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.\n\nEG:\n\n```yaml\nbots:\n  # Pathological bots to deny\n  - # This correlates to data/bots/ai-catchall.yaml in the source tree\n    import: (data)/bots/ai-catchall.yaml\n  - import: (data)/bots/cloudflare-workers.yaml\n  # Import all the rules in the default configuration\n  - import: (data)/meta/default-config.yaml\n```\n\nOf 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.\n\n```yaml\nbots:\n  - import: (data)/bots/ai-catchall.yaml\n    name: generic-browser\n    user_agent_regex: >\n      Mozilla|Opera\n    action: CHALLENGE\n```\n\nThis will return an error like this:\n\n```text\nconfig is not valid:\nconfig.BotOrImport: rule definition is invalid, you must set either bot rules or an import statement, not both\n```\n\nPaths 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.\n\n## Importing the default configuration\n\nIf you want to base your configuration off of the default configuration, import `(data)/meta/default-config.yaml`:\n\n```yaml\nbots:\n  - import: (data)/meta/default-config.yaml\n  # Write your rules here\n```\n\nThis will keep your configuration up to date as Anubis adapts to emerging threats.\n\n## How do I exempt most modern browsers from Anubis challenges?\n\nIf you want to exempt most modern browsers from Anubis challenges, import `(data)/common/acts-like-browser.yaml`:\n\n```yaml\nbots:\n  - import: (data)/meta/default-config.yaml\n  - import: (data)/common/acts-like-browser.yaml\n  # Write your rules here\n```\n\nThese 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.\n\nUse these rules at your own risk.\n\n## Importing from imports\n\nYou can also import from an imported file in case you want to import an entire folder of rules at once.\n\n```yaml\nbots:\n  - import: (data)/bots/_deny-pathological.yaml\n```\n\nThis lets you import an entire ruleset at once:\n\n```yaml\n# (data)/bots/_deny-pathological.yaml\n- import: (data)/bots/cloudflare-workers.yaml\n- import: (data)/bots/headless-browsers.yaml\n- import: (data)/bots/us-ai-scraper.yaml\n```\n\nUse 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.\n\n## Writing snippets\n\nSnippets 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.\n\nHere is an example snippet that allows [IPv6 Unique Local Addresses](https://en.wikipedia.org/wiki/Unique_local_address) through Anubis:\n\n```yaml\n- name: ipv6-ula\n  action: ALLOW\n  remote_addresses:\n    - fc00::/7\n```\n\n## Extracting Anubis' embedded filesystem\n\nYou can always extract the list of rules embedded into the Anubis binary with this command:\n\n```text\nanubis --extract-resources=static\n```\n\nThis will dump the contents of Anubis' embedded data to a new folder named `static`:\n\n```text\nstatic\n├── apps\n│   └── gitea-rss-feeds.yaml\n├── botPolicies.json\n├── botPolicies.yaml\n├── bots\n│   ├── ai-catchall.yaml\n│   ├── cloudflare-workers.yaml\n│   ├── headless-browsers.yaml\n│   └── us-ai-scraper.yaml\n├── common\n│   ├── allow-private-addresses.yaml\n│   └── keep-internet-working.yaml\n└── crawlers\n    ├── bingbot.yaml\n    ├── duckduckbot.yaml\n    ├── googlebot.yaml\n    ├── internet-archive.yaml\n    ├── kagibot.yaml\n    ├── marginalia.yaml\n    ├── mojeekbot.yaml\n    └── qwantbot.yaml\n```\n"
  },
  {
    "path": "docs/docs/admin/configuration/impressum.mdx",
    "content": "# Imprint / Impressum configuration\n\nSome 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:\n\n```yaml\nimpressum:\n  # Displayed at the bottom of every page rendered by Anubis.\n  footer: >-\n    This website is hosted by Techaro. If you have any complaints or notes \n    about the service, please contact\n    <a href=\"mailto:contact@techaro.lol\">contact@techaro.lol</a> and we\n    will assist you as soon as possible.\n\n  # The imprint page that will be linked to at the footer of every Anubis page.\n  page:\n    # The HTML <title> of the page\n    title: Imprint and Privacy Policy\n    # The HTML contents of the page. The exact contents of this page can\n    # and will vary by locale. Please consult with a lawyer if you are not\n    # sure what to put here\n    body: >-\n      <p>Last updated: June 2025</p>\n\n      <h2>Information that is gathered from visitors</h2>\n\n      <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>\n\n      <p>Cookies may be used to remember visitor preferences when interacting with the website.</p>\n\n      <p>Where registration is required, the visitor's email and a username will be stored on the server.</p>\n\n      <!-- ... -->\n```\n\nIf you are subscribed to and using [advanced classification features](../thoth.mdx), be sure to disclose the following:\n\n```html\n<h2>Techaro Anubis</h2>\n\n<p>\n  This website uses a service called\n  <a href=\"https://anubis.techaro.lol\">Anubis</a> by\n  <a href=\"https://techaro.lol\">Techaro</a> to filter malicious traffic. Anubis\n  requires the use of browser cookies to ensure that web clients are running\n  conformant software. Anubis also may report the following data to Techaro to\n  improve service quality:\n</p>\n\n<ul>\n  <li>\n    IP address (for purposes of matching against geo-location and BGP autonomous\n    systems numbers), which is stored in-memory and not persisted to disk.\n  </li>\n  <li>\n    Unique browser fingerprints (such as HTTP request fingerprints and\n    encryption system fingerprints), which may be stored on Techaro's side for a\n    period of up to one month.\n  </li>\n  <li>\n    HTTP request metadata that may include things such as the User-Agent header\n    and other identifiers.\n  </li>\n</ul>\n\n<p>\n  This data is processed and stored for the legitimate interest of combatting\n  abusive web clients. This data is encrypted at rest as much as possible and is\n  only decrypted in memory for the purposes of fulfilling requests.\n</p>\n```\n"
  },
  {
    "path": "docs/docs/admin/configuration/open-graph.mdx",
    "content": "---\nid: open-graph\ntitle: Open Graph Configuration\n---\n\n# Open Graph Configuration\n\nThis 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.\n\n## Configuration Options\n\nOpen Graph settings are configured in the `openGraph` section of the [Policy File](../policies.mdx).\n\n```yaml\nopenGraph:\n  # Enables Open Graph passthrough\n  enabled: true\n  # Enables the use of the HTTP host in the cache key, this enables\n  # caching metadata for multiple http hosts at once.\n  considerHost: true\n  # How long cached OpenGraph metadata should last in memory\n  ttl: 24h\n  # If set, return these opengraph values instead of looking them up with\n  # the target service.\n  #\n  # Correlates to properties in https://ogp.me/\n  override:\n    # og:title is required, it is the title of the website\n    \"og:title\": \"Techaro Anubis\"\n    \"og:description\": >-\n      Anubis is a Web AI Firewall Utility that helps you fight the bots\n      away so that you can maintain uptime at work!\n    \"description\": >-\n      Anubis is a Web AI Firewall Utility that helps you fight the bots\n      away so that you can maintain uptime at work!\n```\n\n<details>\n<summary>Configuration flags / envvars (old)</summary>\n\nOpen Graph passthrough used to be configured with configuration flags / environment variables. Reference to these settings are maintained for backwards compatibility's sake.\n\n| Name                     | Description                                               | Type     | Default | Example                       |\n| ------------------------ | --------------------------------------------------------- | -------- | ------- | ----------------------------- |\n| `OG_PASSTHROUGH`         | Enables or disables the Open Graph tag passthrough system | Boolean  | `true`  | `OG_PASSTHROUGH=true`         |\n| `OG_EXPIRY_TIME`         | Configurable cache expiration time for Open Graph tags    | Duration | `24h`   | `OG_EXPIRY_TIME=1h`           |\n| `OG_CACHE_CONSIDER_HOST` | Enables or disables the use of the host in the cache key  | Boolean  | `false` | `OG_CACHE_CONSIDER_HOST=true` |\n\n</details>\n\n## Usage\n\nTo configure Open Graph tags, you can set the following environment variables, environment file or as flags in your Anubis configuration:\n\n```sh\nexport OG_PASSTHROUGH=true\nexport OG_EXPIRY_TIME=1h\nexport OG_CACHE_CONSIDER_HOST=false\n```\n\n## Implementation Details\n\nWhen `OG_PASSTHROUGH` is enabled, Anubis will:\n\n1. Check a local cache for the requested URL's Open Graph tags.\n2. If a cached entry exists and is still valid, return the cached tags.\n3. 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.\n\nThe cache expiration time is controlled by `OG_EXPIRY_TIME`.\n\nWhen `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.\n\n## Example\n\nHere is an example of how to configure Open Graph tags in your Anubis setup:\n\n```sh\nexport OG_PASSTHROUGH=true\nexport OG_EXPIRY_TIME=1h\nexport OG_CACHE_CONSIDER_HOST=false\n```\n\nWith 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.\n\n## When to Enable `OG_CACHE_CONSIDER_HOST`\n\nIn 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:\n\n1. **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.\n\n2. **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.\n\n3. **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.\n\nFor more information, refer to the [installation guide](../installation).\n"
  },
  {
    "path": "docs/docs/admin/configuration/redirect-domains.mdx",
    "content": "---\ntitle: Redirect Domain Configuration\n---\n\nimport Tabs from \"@theme/Tabs\";\nimport TabItem from \"@theme/TabItem\";\n\nAnubis 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.\n\nThis flow looks something like this:\n\n```mermaid\nsequenceDiagram\n  participant User\n  participant Challenge\n  participant Validation\n  participant Backend\n\n  User->>+Challenge: GET /\n  Challenge->>+User: Solve this challenge\n  User->>+Validation: Here's the solution, send me to /\n  Validation->>+User: Here's a cookie, go to /\n  User->>+Backend: GET /\n```\n\nHowever, 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:\n\n```mermaid\nsequenceDiagram\n  participant Hacker\n  participant User\n  participant Validation\n  participant Evil Site\n\n  Hacker->>+User: Click on example.org with this solution\n  User->>+Validation: Here's a solution, send me to evilsite.com\n  Validation->>+User: Here's a cookie, go to evilsite.com\n  User->>+Evil Site: GET evilsite.com\n```\n\nIf this happens, Anubis will throw an error like this:\n\n```text\nRedirect domain not allowed\n```\n\n## Configuring allowed redirect domains\n\nBy 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.\nOne can restrict the domains that Anubis can redirect to when passing a challenge by setting up `REDIRECT_DOMAINS` environment variable.\nIf you need to set more than one domain, fill the environment variable with a comma-separated list of domain names.\nThere 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.\n\n:::note\n\nIf 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.\n\n:::\n\n<Tabs>\n  <TabItem value=\"env-file\" label=\"Environment file\" default>\n\n```shell\n# anubis.env\n\nREDIRECT_DOMAINS=\"example.org,secretplans.example.org,*.test.example.org\"\n# ...\n```\n\n  </TabItem>\n  <TabItem value=\"docker-compose\" label=\"Docker Compose\">\n\n```yaml\nservices:\n  anubis-nginx:\n    image: ghcr.io/techarohq/anubis:latest\n    environment:\n      REDIRECT_DOMAINS: \"example.org,secretplans.example.org,*.test.example.org\"\n      # ...\n```\n\n  </TabItem>\n  <TabItem value=\"k8s\" label=\"Kubernetes\">\n\nInside your Deployment, StatefulSet, or Pod:\n\n```yaml\n- name: anubis\n  image: ghcr.io/techarohq/anubis:latest\n  env:\n    - name: REDIRECT_DOMAINS\n      value: \"example.org,secretplans.example.org,*.test.example.org\"\n    # ...\n```\n\n  </TabItem>\n</Tabs>\n"
  },
  {
    "path": "docs/docs/admin/configuration/subrequest-auth.mdx",
    "content": "---\ntitle: Subrequest Authentication\n---\n\nimport Tabs from \"@theme/Tabs\";\nimport TabItem from \"@theme/TabItem\";\n\nAnubis can act in one of two modes:\n\n1. 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.\n2. 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.\n\n:::note\n\nSubrequest authentication requires changing the default policy because nginx interprets the default `DENY` status code `200` as successful authentication and allows the request.\n\n```yaml\nstatus_codes:\n  CHALLENGE: 200\n  DENY: 403\n```\n\n[See policy definitions](../policies.mdx).\n\n:::\n\n## Nginx\n\nAnubis 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:\n\nThe `TARGET` environment variable in Anubis must be set to a space, eg:\n\n<Tabs>\n  <TabItem value=\"env-file\" label=\"Environment file\" default>\n\n```shell\n# anubis.env\n\nTARGET=\" \"\n# ...\n```\n\n  </TabItem>\n  <TabItem value=\"docker-compose\" label=\"Docker Compose\">\n\n```yaml\nservices:\n  anubis-nginx:\n    image: ghcr.io/techarohq/anubis:latest\n    environment:\n      TARGET: \" \"\n      # ...\n```\n\n  </TabItem>\n  <TabItem value=\"k8s\" label=\"Kubernetes\">\n\nInside your Deployment, StatefulSet, or Pod:\n\n```yaml\n- name: anubis\n  image: ghcr.io/techarohq/anubis:latest\n  env:\n    - name: TARGET\n      value: \" \"\n    # ...\n```\n\n  </TabItem>\n</Tabs>\n\nIn order to configure this, you need to add the following location blocks to each server pointing to the service you want to protect:\n\n```nginx\nlocation /.within.website/ {\n    # Assumption: Anubis is running in the same network namespace as\n    # nginx on localhost TCP port 8923\n    proxy_pass http://127.0.0.1:8923;\n    proxy_set_header X-Real-IP $remote_addr;\n    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n    proxy_set_header Host $http_host;\n    proxy_pass_request_body off;\n    proxy_set_header content-length \"\";\n    auth_request off;\n}\n\nlocation @redirectToAnubis {\n    return 307 /.within.website/?redir=$scheme://$host$request_uri;\n    auth_request off;\n}\n```\n\nThis 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.\n\nFinally, add this to your root location block:\n\n```nginx\nlocation / {\n    # diff-add\n    auth_request /.within.website/x/cmd/anubis/api/check;\n    # diff-add\n    error_page 401 = @redirectToAnubis;\n}\n```\n\nThis will check all requests that don't match other locations with Anubis to ensure the client is genuine.\n\nThis 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:\n\n```nginx\nlocation /secret {\n    # diff-add\n    auth_request off;\n\n    # ...\n}\n```\n\nHere is a complete example of an Nginx server listening over TLS and pointing to Anubis:\n\n<details>\n  <summary>Complete example</summary>\n\n```nginx\n# /etc/nginx/conf.d/nginx.local.cetacean.club.conf\n\nserver {\n  listen 443 ssl;\n  listen [::]:443 ssl;\n  server_name         nginx.local.cetacean.club;\n  ssl_certificate     /etc/techaro/pki/nginx.local.cetacean.club/tls.crt;\n  ssl_certificate_key /etc/techaro/pki/nginx.local.cetacean.club/tls.key;\n  ssl_protocols       TLSv1.2 TLSv1.3;\n  ssl_ciphers         HIGH:!aNULL:!MD5;\n\n  proxy_set_header X-Real-IP $remote_addr;\n  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n\n  location /.within.website/ {\n    proxy_pass http://localhost:8923;\n    auth_request off;\n  }\n\n  location @redirectToAnubis {\n    return 307 /.within.website/?redir=$scheme://$host$request_uri;\n    auth_request off;\n  }\n\n  location / {\n    auth_request /.within.website/x/cmd/anubis/api/check;\n    error_page 401 = @redirectToAnubis;\n    root /usr/share/nginx/html;\n    index index.html index.htm;\n  }\n}\n```\n\n</details>\n\n## Caddy\n\nAnubis can be used with the [`forward_auth`](https://caddyserver.com/docs/caddyfile/directives/forward_auth) directive in Caddy.\n\nFirst, the `TARGET` environment variable in Anubis must be set to a space, eg:\n\n<Tabs>\n  <TabItem value=\"env-file\" label=\"Environment file\" default>\n\n```shell\n# anubis.env\n\nTARGET=\" \"\n# ...\n```\n\n  </TabItem>\n  <TabItem value=\"docker-compose\" label=\"Docker Compose\">\n\n```yaml\nservices:\n  anubis-caddy:\n    image: ghcr.io/techarohq/anubis:latest\n    environment:\n      TARGET: \" \"\n      # ...\n```\n\n  </TabItem>\n  <TabItem value=\"k8s\" label=\"Kubernetes\">\n\nInside your Deployment, StatefulSet, or Pod:\n\n```yaml\n- name: anubis\n  image: ghcr.io/techarohq/anubis:latest\n  env:\n    - name: TARGET\n      value: \" \"\n    # ...\n```\n\n  </TabItem>\n</Tabs>\n\nThen configure the necessary directives in your site block:\n\n```caddy\nroute {\n    # Assumption: Anubis is running in the same network namespace as\n    # caddy on localhost TCP port 8923\n    reverse_proxy /.within.website/* 127.0.0.1:8923\n    forward_auth 127.0.0.1:8923 {\n        uri /.within.website/x/cmd/anubis/api/check\n        trusted_proxies private_ranges\n        @unauthorized status 401\n        handle_response @unauthorized {\n            redir * /.within.website/?redir={uri} 307\n        }\n    }\n}\n```\n\nIf 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.\n"
  },
  {
    "path": "docs/docs/admin/configuration/thresholds.mdx",
    "content": "# Weight Threshold Configuration\n\nAnubis 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:\n\n```yaml\n# botPolicies.yaml\n\nbots:\n  - name: generic-browser\n    user_agent_regex: >-\n      Mozilla|Opera\n    action: WEIGH\n    weight:\n      adjust: 10\n```\n\nThresholds 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`.\n\n:::note\n\nThresholds 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.\n\n:::\n\n```yaml\n# botPolicies.yaml\n\nbots: ...\n\nthresholds:\n  - name: minimal-suspicion\n    expression: weight < 0\n    action: ALLOW\n\n  - name: mild-suspicion\n    expression:\n      all:\n        - weight >= 0\n        - weight < 10\n    action: CHALLENGE\n    challenge:\n      algorithm: metarefresh\n      difficulty: 1\n\n  - name: moderate-suspicion\n    expression:\n      all:\n        - weight >= 10\n        - weight < 20\n    action: CHALLENGE\n    challenge:\n      algorithm: fast\n      difficulty: 2\n\n  - name: extreme-suspicion\n    expression: weight >= 20\n    action: CHALLENGE\n    challenge:\n      algorithm: fast\n      difficulty: 4\n```\n\nThis defines a suite of 4 thresholds:\n\n1. If the request weight is less than zero, allow it through.\n2. If the request weight is greater than or equal to zero, but less than ten: give it [a very lightweight challenge](./challenges/metarefresh.mdx).\n3. 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).\n4. Otherwise, give it [the heaviest challenge](./challenges/proof-of-work.mdx).\n\nThresholds can be configured with the following options:\n\n<table>\n  <thead>\n  <tr>\n    <th>Name</th>\n    <th>Description</th>\n    <th>Example</th>\n    </tr>\n  </thead>\n  <tbody>\n    <tr>\n      <td>`name`</td>\n      <td>The human-readable name for this threshold.</td>\n      <td>\n\n```yaml\nname: extreme-suspicion\n```\n\n      </td>\n    </tr>\n    <tr>\n    <td>`expression`</td>\n    <td>A [CEL](https://cel.dev/) expression taking the request weight and returning true or false</td>\n    <td>\n\nTo check if the request weight is less than zero:\n\n```yaml\nexpression: weight < 0\n```\n\nTo check if it's between 0 and 10 (inclusive):\n\n```yaml\nexpression:\n  all:\n    - weight >= 0\n    - weight < 10\n```\n\n    </td>\n    </tr>\n    <tr>\n    <td>`action`</td>\n    <td>The Anubis action to apply: `ALLOW`, `CHALLENGE`, or `DENY`</td>\n    <td>\n\n```yaml\naction: ALLOW\n```\n\nIf you set the CHALLENGE action, you must set challenge details:\n\n```yaml\naction: CHALLENGE\nchallenge:\n  algorithm: metarefresh\n  difficulty: 1\n```\n\n    </td>\n    </tr>\n\n  </tbody>\n</table>\n"
  },
  {
    "path": "docs/docs/admin/default-allow-behavior.mdx",
    "content": "---\ntitle: Default allow behavior\n---\n\nimport Tabs from \"@theme/Tabs\";\nimport TabItem from \"@theme/TabItem\";\n\n# Default allow behavior\n\nAnubis is designed to be as unintrusive as possible to your existing infrastructure.\n\nBy default, it allows all traffic unless a request matches a rule that explicitly denies or challenges it.\n\nOnly requests matching a DENY or CHALLENGE rule are blocked or challenged. All other requests are allowed. This is called \"the implicit rule\".\n\n## Example: Minimal policy\n\nIf your policy only blocks a specific bot, all other requests will be allowed:\n\n<Tabs>\n<TabItem value=\"json\" label=\"JSON\" default>\n\n```json\n{\n  \"bots\": [\n    {\n      \"name\": \"block-amazonbot\",\n      \"user_agent_regex\": \"Amazonbot\",\n      \"action\": \"DENY\"\n    }\n  ]\n}\n```\n\n</TabItem>\n<TabItem value=\"yaml\" label=\"YAML\">\n\n```yaml\n- name: block-amazonbot\n  user_agent_regex: Amazonbot\n  action: DENY\n```\n\n</TabItem>\n</Tabs>\n\n## How to deny by default\n\nIf 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.\n\n<Tabs>\n<TabItem value=\"json\" label=\"JSON\" default>\n\n```json\n{\n  \"bots\": [\n    {\n      \"name\": \"allow-goodbot\",\n      \"user_agent_regex\": \"GoodBot\",\n      \"action\": \"ALLOW\"\n    },\n    {\n      \"name\": \"catch-all-deny\",\n      \"path_regex\": \".*\",\n      \"action\": \"DENY\"\n    }\n  ]\n}\n```\n\n</TabItem>\n<TabItem value=\"yaml\" label=\"YAML\">\n\n```yaml\n- name: allow-goodbot\n  user_agent_regex: GoodBot\n  action: ALLOW\n- name: catch-all-deny\n  path_regex: .*\n  action: DENY\n```\n\n</TabItem>\n</Tabs>\n\n## Final remarks\n\n- Rules are evaluated in order; the first match wins.\n- The implicit allow rule is always last and cannot be removed.\n- Use your logs to monitor what traffic is being allowed by default.\n\nSee [Policy Definitions](./policies) for more details on writing rules.\n"
  },
  {
    "path": "docs/docs/admin/environments/_category_.json",
    "content": "{\n  \"label\": \"Environments\",\n  \"position\": 20,\n  \"link\": {\n    \"type\": \"generated-index\",\n    \"description\": \"Detailed information about individual environments (such as HTTP servers, platforms, etc.) Anubis is known to work with.\"\n  }\n}\n"
  },
  {
    "path": "docs/docs/admin/environments/apache.mdx",
    "content": "# Apache\n\nimport Tabs from \"@theme/Tabs\";\nimport TabItem from \"@theme/TabItem\";\n\nAnubis 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:\n\n```mermaid\n---\ntitle: Apache as tls terminator and HTTP router\n---\n\nflowchart LR\n    T(User Traffic)\n    subgraph Apache 2\n        TCP(TCP 80/443)\n        US(TCP 3001)\n    end\n\n    An(Anubis)\n    B(Backend)\n\n    T --> |TLS termination| TCP\n    TCP --> |Traffic filtering| An\n    An --> |Happy traffic| US\n    US --> |whatever you're doing| B\n```\n\nEffectively 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.\n\n:::note\n\nThese 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.\n\n:::\n\n## Configuration\n\nAssuming you are protecting `anubistest.techaro.lol`, you need the following server configuration blocks:\n\n1. A block on port 80 that forwards HTTP to HTTPS\n2. A block on port 443 that terminates TLS and forwards to Anubis\n3. A block on port 3001 that actually serves your websites\n\n```text\n# Plain HTTP redirect to HTTPS\n<VirtualHost *:80>\n       ServerAdmin your@email.here\n       ServerName anubistest.techaro.lol\n       DocumentRoot /var/www/anubistest.techaro.lol\n       ErrorLog /var/log/httpd/anubistest.techaro.lol_error.log\n       CustomLog /var/log/httpd/anubistest.techaro.lol_access.log combined\n       RewriteEngine on\n       RewriteCond %{SERVER_NAME} =anubistest.techaro.lol\n       RewriteRule ^ https://%{SERVER_NAME}%{REQUEST_URI} [END,NE,R=permanent]\n</VirtualHost>\n\n# HTTPS listener that forwards to Anubis\n<IfModule mod_proxy.c>\n<VirtualHost *:443>\n       ServerAdmin your@email.here\n       ServerName anubistest.techaro.lol\n       DocumentRoot /var/www/anubistest.techaro.lol\n       ErrorLog /var/log/httpd/anubistest.techaro.lol_error.log\n       CustomLog /var/log/httpd/anubistest.techaro.lol_access.log combined\n\n       SSLCertificateFile /etc/letsencrypt/live/anubistest.techaro.lol/fullchain.pem\n       SSLCertificateKeyFile /etc/letsencrypt/live/anubistest.techaro.lol/privkey.pem\n       Include /etc/letsencrypt/options-ssl-apache.conf\n\n       # These headers need to be set or else Anubis will\n       # throw an \"admin misconfiguration\" error.\n       RequestHeader set \"X-Real-Ip\" expr=%{REMOTE_ADDR}\n       RequestHeader set X-Forwarded-Proto \"https\"\n       RequestHeader set \"X-Http-Version\" \"%{SERVER_PROTOCOL}s\"\n\n       ProxyPreserveHost On\n\n       ProxyRequests Off\n       ProxyVia Off\n\n       # Replace 9000 with the port Anubis listens on\n       ProxyPass / http://[::1]:9000/\n       ProxyPassReverse / http://[::1]:9000/\n</VirtualHost>\n</IfModule>\n\n# Actual website config\n<VirtualHost *:3001>\n       ServerAdmin your@email.here\n       ServerName anubistest.techaro.lol\n       DocumentRoot /var/www/anubistest.techaro.lol\n       ErrorLog /var/log/httpd/anubistest.techaro.lol_error.log\n       CustomLog /var/log/httpd/anubistest.techaro.lol_access.log combined\n\n       # Pass the remote IP to the proxied application instead of 127.0.0.1\n       # This requires mod_remoteip\n       RemoteIPHeader X-Real-IP\n       RemoteIPTrustedProxy 127.0.0.1/32\n</VirtualHost>\n```\n\nMake sure to add a separate configuration file for the listener on port 3001:\n\n```text\n# /etc/httpd/conf.d/listener-3001.conf\n\nListen [::1]:3001\n```\n\nIn case you are running an IPv4-only system, use the following configuration instead:\n\n```text\n# /etc/httpd/conf.d/listener-3001.conf\n\nListen 127.0.0.1:3001\n```\n\nThis 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.\n\nThen reload your Apache config and load your website. You should see Anubis protecting your apps!\n\n```text\nsudo systemctl reload httpd.service\n```\n\n## Troubleshooting\n\nHere are some answers to questions that came in in testing:\n\n### I'm running on a Red Hat distribution and Apache is saying \"service unavailable\" for every page load\n\nIf 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:\n\n```text\n*****  Plugin catchall_boolean (89.3 confidence) suggests   ******************\n\nIf you want to allow HTTPD scripts and modules to connect to the network using TCP.\nThen you must tell SELinux about this by enabling the 'httpd_can_network_connect' boolean.\n\nDo\nsetsebool -P httpd_can_network_connect 1\n```\n\nThis will fix the error immediately.\n"
  },
  {
    "path": "docs/docs/admin/environments/caddy.mdx",
    "content": "# Caddy\n\nTo use Anubis with Caddy, stick Anubis between Caddy and your backend. For example, consider this application setup:\n\n```mermaid\n---\ntitle: Caddy with Anubis in the middle\n---\n\nflowchart LR\n    T(User Traffic)\n    TCP(TCP 80/443)\n    An(Anubis)\n    B(Backend)\n    Blocked\n\n    T --> TCP\n    TCP --> |Traffic filtering| An\n    An --> |Happy traffic| B\n    An --> |Malicious traffic| Blocked\n```\n\nInstead 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.\n\nTo set up Anubis with Docker compose and Caddy, start with a `docker-compose` configuration like this:\n\n```yaml\nservices:\n  caddy:\n    image: caddy:2\n    ports:\n      - 80:80\n      - 443:443\n      - 443:443/udp\n    volumes:\n      - ./conf:/etc/caddy\n      - caddy_config:/config\n      - caddy_data:/data\n\n  anubis:\n    image: ghcr.io/techarohq/anubis:latest\n    pull_policy: always\n    environment:\n      BIND: \":3000\"\n      TARGET: http://httpdebug:3000\n\n  httpdebug:\n    image: ghcr.io/xe/x/httpdebug\n    pull_policy: always\n\nvolumes:\n  caddy_data:\n  caddy_config:\n```\n\nAnd then put the following in `conf/Caddyfile`:\n\n```Caddyfile\n# conf/Caddyfile\n\nyourdomain.example.com {\n  tls your@email.address\n\n  reverse_proxy http://anubis:3000 {\n    header_up X-Real-Ip {remote_host}\n    header_up X-Http-Version {http.request.proto}\n  }\n}\n```\n\nIf 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.\n"
  },
  {
    "path": "docs/docs/admin/environments/cloudflare.mdx",
    "content": "# Cloudflare\n\nIf 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.\n\nExample configuration with Caddy:\n\n```Caddyfile\n{\n    servers {\n        # Cloudflare IP ranges from https://www.cloudflare.com/en-gb/ips/\n        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\n        # Use CF-Connecting-IP to determine the client IP instead of XFF\n        # https://caddyserver.com/docs/caddyfile/options#client-ip-headers\n        client_ip_headers CF-Connecting-IP\n    }\n}\n\nexample.com {\n    reverse_proxy http://anubis:3000 {\n        # Pass the client IP read from CF-Connecting-IP\n        header_up X-Forwarded-For {client_ip}\n        header_up X-Real-IP {client_ip}\n        header_up X-Http-Version {http.request.proto}\n    }\n}\n```\n"
  },
  {
    "path": "docs/docs/admin/environments/docker-compose.mdx",
    "content": "# Docker compose\n\nDocker 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`:\n\n```yaml\nservices:\n  anubis:\n    image: ghcr.io/techarohq/anubis:latest\n    environment:\n      BIND: \":8080\"\n      DIFFICULTY: \"4\"\n      METRICS_BIND: \":9090\"\n      SERVE_ROBOTS_TXT: \"true\"\n      TARGET: \"http://nginx\"\n      POLICY_FNAME: \"/data/cfg/botPolicy.yaml\"\n      OG_PASSTHROUGH: \"true\"\n      OG_EXPIRY_TIME: \"24h\"\n    healthcheck:\n      test: [\"CMD\", \"anubis\", \"--healthcheck\"]\n      interval: 5s\n      timeout: 30s\n      retries: 5\n      start_period: 500ms\n    ports:\n      - 8080:8080\n    volumes:\n      - \"./botPolicy.yaml:/data/cfg/botPolicy.yaml:ro\"\n\n  nginx:\n    image: nginx\n    volumes:\n      - \"./www:/usr/share/nginx/html\"\n```\n"
  },
  {
    "path": "docs/docs/admin/environments/haproxy/advanced-config-policy.yml",
    "content": "# /etc/anubis/challenge-any.yml\n\nbots:\n  - name: any\n    action: CHALLENGE\n    user_agent_regex: .*\n\nstatus_codes:\n  CHALLENGE: 403\n  DENY: 403\n\nthresholds: []\n\ndnsbl: false\n\n"
  },
  {
    "path": "docs/docs/admin/environments/haproxy/advanced-config.env",
    "content": "# /etc/anubis/default.env\n\nBIND=/run/anubis/default.sock\nBIND_NETWORK=unix\nDIFFICULTY=4\nMETRICS_BIND=:9090\n# target is irrelevant here, backend routing happens in HAProxy\nTARGET=http://0.0.0.0\nHS512_SECRET=<SECRET-HERE>\nCOOKIE_DYNAMIC_DOMAIN=True\nPOLICY_FNAME=/etc/anubis/challenge-any.yml\n"
  },
  {
    "path": "docs/docs/admin/environments/haproxy/advanced-haproxy.cfg",
    "content": "# /etc/haproxy/haproxy.cfg\n\nfrontend FE-multiple-applications\n  mode http\n  bind :80\n  # ssl offloading on port 443 using a certificate from /etc/haproxy/ssl/ directory\n  bind :443 ssl crt /etc/haproxy/ssl/ alpn h2,http/1.1 ssl-min-ver TLSv1.2 no-tls-tickets\n\n  # set X-Real-IP header required for Anubis\n  http-request set-header X-Real-IP \"%[src]\"\n\n  # redirect HTTP to HTTPS\n  http-request redirect scheme https code 301 unless { ssl_fc }\n  # add HSTS header\n  http-response set-header Strict-Transport-Security \"max-age=31536000; includeSubDomains; preload\"\n\n  # only force Anubis challenge for app1 and app2\n  acl acl_anubis_required hdr(host) -i \"app1.example.com\"\n  acl acl_anubis_required hdr(host) -i \"app2.example.com\"\n\n  # exclude Anubis for a specific path\n  acl acl_anubis_ignore path /excluded/path\n\n  # use Anubis if auth cookie not found\n  use_backend BE-anubis if acl_anubis_required !acl_anubis_ignore !{ req.cook(techaro.lol-anubis-auth) -m found }\n\n  # get payload of the JWT such as algorithm, expire time, restrictions\n  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\n  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\n  http-request set-var(txn.anubis_jwt_res) cook(techaro.lol-anubis-auth),jwt_payload_query('$.restriction') if acl_anubis_required !acl_anubis_ignore\n  http-request set-var(txn.srcip) req.fhdr(X-Real-IP) if acl_anubis_required !acl_anubis_ignore\n  http-request set-var(txn.now) date() if acl_anubis_required !acl_anubis_ignore\n\n  # use Anubis if JWT has wrong algorithm, is expired, restrictions don't match or isn't signed with the correct key\n  use_backend BE-anubis if acl_anubis_required !acl_anubis_ignore !{ var(txn.anubis_jwt_alg) -m str HS512 }\n  use_backend BE-anubis if acl_anubis_required !acl_anubis_ignore { var(txn.anubis_jwt_exp),sub(txn.now) -m int lt 0 }\n  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 }\n  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 }\n\n  # custom routing in HAProxy\n  use_backend BE-app1 if { hdr(host) -i \"app1.example.com\" }\n  use_backend BE-app2 if { hdr(host) -i \"app2.example.com\" }\n  use_backend BE-app3 if { hdr(host) -i \"app3.example.com\" }\n\nbackend BE-app1\n  mode http\n  server app1-server 127.0.0.1:3000\n\nbackend BE-app2\n  mode http\n  server app2-server 127.0.0.1:4000\n\nbackend BE-app3\n  mode http\n  server app3-server 127.0.0.1:5000\n\nBE-anubis\n  mode http\n  server anubis /run/anubis/default.sock\n"
  },
  {
    "path": "docs/docs/admin/environments/haproxy/simple-config.env",
    "content": "# /etc/anubis/default.env\n\nBIND=/run/anubis/default.sock\nBIND_NETWORK=unix\nSOCKET_MODE=0666\nDIFFICULTY=4\nMETRICS_BIND=:9090\nCOOKIE_DYNAMIC_DOMAIN=true\n# address and port of the actual application\nTARGET=http://localhost:3000\n"
  },
  {
    "path": "docs/docs/admin/environments/haproxy/simple-haproxy.cfg",
    "content": "# /etc/haproxy/haproxy.cfg\n\nfrontend FE-application\n  mode http\n  bind :80\n  # ssl offloading on port 443 using a certificate from /etc/haproxy/ssl/ directory\n  bind :443 ssl crt /etc/haproxy/ssl/ alpn h2,http/1.1 ssl-min-ver TLSv1.2 no-tls-tickets\n\n  # set X-Real-IP header required for Anubis\n  http-request set-header X-Real-IP \"%[src]\"\n\n  # redirect HTTP to HTTPS\n  http-request redirect scheme https code 301 unless { ssl_fc }\n  # add HSTS header\n  http-response set-header Strict-Transport-Security \"max-age=31536000; includeSubDomains; preload\"\n\n  # route to Anubis backend by default\n  default_backend BE-anubis-application\n\nBE-anubis-application\n  mode http\n  server anubis /run/anubis/default.sock\n"
  },
  {
    "path": "docs/docs/admin/environments/haproxy.mdx",
    "content": "# HAProxy\n\nimport CodeBlock from \"@theme/CodeBlock\";\n\nTo use Anubis with HAProxy, you have two variants:\n  - simple - stick Anubis between HAProxy and your application backend (simple)\n    - perfect if you only have a single application in general\n  - advanced - force Anubis challenge by default and route to the application backend by HAProxy if the challenge is correct\n    - useful for complex setups\n    - routing can be done in HAProxy\n    - define ACLs in HAProxy for domains, paths etc which are required/excluded regarding Anubis\n    - HAProxy 3.0 recommended\n\n## Simple Variant\n\n```mermaid\n---\ntitle: HAProxy with simple config\n---\nflowchart LR\n    T(User Traffic)\n    HAProxy(HAProxy Port 80/443)\n    Anubis\n    Application\n\n    T --> HAProxy\n    HAProxy --> Anubis\n    Anubis --> |Happy Traffic| Application\n```\n\nYour Anubis env file configuration may look like this:\n\nimport simpleAnubis from \"!!raw-loader!./haproxy/simple-config.env\";\n\n<CodeBlock language=\"bash\">{simpleAnubis}</CodeBlock>\n\nThe 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.\n\nYour frontend and backend configuration of HAProxy may look like the following:\n\nimport simpleHAProxy from \"!!raw-loader!./haproxy/simple-haproxy.cfg\";\n\n<CodeBlock language=\"bash\">{simpleHAProxy}</CodeBlock>\n\nThis simply enables SSL offloading, sets some useful and required headers and routes to Anubis directly.\n\n## Advanced Variant\n\nDue 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.\n\nMind 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.\n\nIn 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.\n\n```mermaid\n---\ntitle: HAProxy with advanced config\n---\n\nflowchart LR\n    T(User Traffic)\n    HAProxy(HAProxy Port 80/443)\n    B1(App1)\n    B2(App2)\n    B3(App3)\n    Anubis\n\n    T --> HAProxy\n    HAProxy --> |Traffic for App1 and App2 without valid challenge| Anubis\n    HAProxy --> |app1.example.com | B1\n    HAProxy --> |app2.example.com| B2\n    HAProxy --> |app3.example.com| B3\n```\n\n:::note\n\nFor an improved JWT decoding performance, it's recommended to use HAProxy version 3.0 or above.\n\n:::\n\nYour Anubis env file configuration may look like this:\n\nimport advancedAnubis from \"!!raw-loader!./haproxy/advanced-config.env\";\n\n<CodeBlock language=\"bash\">{advancedAnubis}</CodeBlock>\n\nIt's important to use `HS512_SECRET` which HAProxy understands. Please replace `<SECRET-HERE>` with your own secret string (alphanumerical string with 128 characters recommended).\n\nYou can set Anubis to force a challenge for every request using the following policy file:\n\nimport advancedAnubisPolicy from \"!!raw-loader!./haproxy/advanced-config-policy.yml\";\n\n<CodeBlock language=\"yaml\">{advancedAnubisPolicy}</CodeBlock>\n\nThe HAProxy config file may look like this:\n\nimport advancedHAProxy from \"!!raw-loader!./haproxy/advanced-haproxy.cfg\";\n\n<CodeBlock language=\"haproxy\">{advancedHAProxy}</CodeBlock>\n\nPlease replace `<SECRET-HERE>` with the same secret from the Anubis config.\n"
  },
  {
    "path": "docs/docs/admin/environments/kubernetes.mdx",
    "content": "# Kubernetes\n\n:::note\nLeave the `PUBLIC_URL` environment variable unset in this sidecar/standalone setup. Setting it here makes redirect construction fail (`redir=null`).\n:::\n\nWhen setting up Anubis in Kubernetes, you want to make sure that you thread requests through Anubis kinda like this:\n\n```mermaid\n---\ntitle: Anubis embedded into workload pods\n---\n\nflowchart LR\n    T(User Traffic)\n\n    IngressController(IngressController)\n\n    subgraph Service\n        AnPort(Anubis Port)\n        BPort(Backend Port)\n    end\n\n    subgraph Pod\n        An(Anubis)\n        B(Backend)\n    end\n\n    T -->  IngressController\n    IngressController --> AnPort\n    AnPort --> An\n    An --> B\n```\n\nAnubis 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).\n\nThis example makes the following assumptions:\n\n- Your target service is listening on TCP port `5000`.\n- Anubis will be listening on port `8080`.\n\nAdjust these values as facts and circumstances demand.\n\nCreate a secret with the signing key Anubis should use for its responses:\n\n```\nkubectl create secret generic anubis-key \\\n  --namespace default \\\n  --from-literal=ED25519_PRIVATE_KEY_HEX=$(openssl rand -hex 32)\n```\n\nAttach Anubis to your Deployment:\n\n```yaml\ncontainers:\n  # ...\n  - name: anubis\n    image: ghcr.io/techarohq/anubis:latest\n    imagePullPolicy: Always\n    env:\n      - name: \"BIND\"\n        value: \":8080\"\n      - name: \"DIFFICULTY\"\n        value: \"4\"\n      - name: ED25519_PRIVATE_KEY_HEX\n        valueFrom:\n          secretKeyRef:\n            name: anubis-key\n            key: ED25519_PRIVATE_KEY_HEX\n      - name: \"METRICS_BIND\"\n        value: \":9090\"\n      - name: \"SERVE_ROBOTS_TXT\"\n        value: \"true\"\n      - name: \"TARGET\"\n        value: \"http://localhost:5000\"\n      - name: \"OG_PASSTHROUGH\"\n        value: \"true\"\n      - name: \"OG_EXPIRY_TIME\"\n        value: \"24h\"\n    resources:\n      limits:\n        cpu: 750m\n        memory: 256Mi\n      requests:\n        cpu: 250m\n        memory: 256Mi\n    securityContext:\n      runAsUser: 1000\n      runAsGroup: 1000\n      runAsNonRoot: true\n      allowPrivilegeEscalation: false\n      capabilities:\n        drop:\n          - ALL\n      seccompProfile:\n        type: RuntimeDefault\n```\n\nThen add a Service entry for Anubis:\n\n```yaml\n# ...\nspec:\n  ports:\n    # diff-add\n    - protocol: TCP\n      # diff-add\n      port: 8080\n      # diff-add\n      targetPort: 8080\n      # diff-add\n      name: anubis\n```\n\nThen point your Ingress to the Anubis port:\n\n```yaml\n  rules:\n  - host: git.xeserv.us\n    http:\n      paths:\n      - pathType: Prefix\n        path: \"/\"\n        backend:\n          service:\n            name: git\n            port:\n              # diff-remove\n              name: http\n              # diff-add\n              name: anubis\n```\n\n## Envoy Gateway\n\nIf 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:\n\nHTTPRoute:\n```yaml\napiVersion: gateway.networking.k8s.io/v1\nkind: HTTPRoute\nmetadata:\n  name: app-route\nspec:\n  hostnames: [\"app.domain.tld\"]\n  parentRefs:\n    - name: envoy-external\n      namespace: network\n      sectionName: https\n  rules:\n    - backendRefs:\n        - identifier: *app\n          port: anubis\n      filters:\n        - type: RequestHeaderModifier\n          requestHeaderModifier:\n            set:\n              - name: X-Real-Ip\n                value: \"%DOWNSTREAM_REMOTE_ADDRESS_WITHOUT_PORT%\"\n```\n\nApplying to any number of Gateways:\n```yaml\napiVersion: gateway.envoyproxy.io/v1alpha1\nkind: ClientTrafficPolicy\nmetadata:\n  name: envoy\nspec:\n  headers:\n    earlyRequestHeaders:\n      set:\n        - name: X-Real-Ip\n          value: \"%DOWNSTREAM_REMOTE_ADDRESS_WITHOUT_PORT%\"\n  clientIPDetection:\n    xForwardedFor:\n      trustedCIDRs:\n        - 10.96.0.0/16 # Cluster pod CIDR\n  targetSelectors: # These will apply to all Gateways\n    - group: gateway.networking.k8s.io\n      kind: Gateway\n```\n"
  },
  {
    "path": "docs/docs/admin/environments/nginx/conf-anubis.inc",
    "content": "# /etc/nginx/conf-anubis.inc # Forward to anubis location / { proxy_set_header\nHost $host; proxy_set_header X-Real-IP $remote_addr; proxy_pass http://anubis; }\n"
  },
  {
    "path": "docs/docs/admin/environments/nginx/server-anubistest-techaro-lol.conf",
    "content": "# /etc/nginx/conf.d/server-anubistest-techaro-lol.conf\n\n# HTTP - Redirect all HTTP traffic to HTTPS\nserver {\n  listen 80;\n  listen [::]:80;\n\n  server_name anubistest.techaro.lol;\n\n  location / {\n    return 301 https://$host$request_uri;\n  }\n}\n\n# TLS termination server, this will listen over TLS (https) and then\n# proxy all traffic to the target via Anubis.\nserver {\n  # Listen on TCP port 443 with TLS (https) and HTTP/2\n  listen 443 ssl;\n  listen [::]:443 ssl;\n  http2 on;\n\n  location / {\n    proxy_set_header Host $host;\n    proxy_set_header X-Real-IP $remote_addr;\n    proxy_set_header X-Http-Version $server_protocol;\n    proxy_pass http://anubis;\n  }\n\n  server_name anubistest.techaro.lol;\n\n  ssl_certificate /path/to/your/certs/anubistest.techaro.lol.crt;\n  ssl_certificate_key /path/to/your/certs/anubistest.techaro.lol.key;\n}\n\n# Backend server, this is where your webapp should actually live.\nserver {\n  listen unix:/run/nginx/nginx.sock;\n\n  server_name anubistest.techaro.lol;\n  root \"/srv/http/anubistest.techaro.lol\";\n  index index.html;\n\n  # Get the visiting IP from the TLS termination server\n  set_real_ip_from unix:;\n  real_ip_header X-Real-IP;\n\n  # Your normal configuration can go here\n  # location .php { fastcgi...} etc.\n}"
  },
  {
    "path": "docs/docs/admin/environments/nginx/server-mimi-techaro-lol.conf",
    "content": "# /etc/nginx/conf.d/server-mimi-techaro-lol.conf\n\nserver {\n  # Listen on 443 with SSL\n  listen 443 ssl;\n  listen [::]:443 ssl;\n  http2 on;\n\n  # Slipstream via Anubis\n  include \"conf-anubis.inc\";\n\n  server_name mimi.techaro.lol;\n\n  ssl_certificate /path/to/your/certs/mimi.techaro.lol.crt;\n  ssl_certificate_key /path/to/your/certs/mimi.techaro.lol.key;\n}\n\nserver {\n  listen unix:/run/nginx/nginx.sock;\n\n  server_name mimi.techaro.lol;\n\n  port_in_redirect off;\n  root \"/srv/http/mimi.techaro.lol\";\n  index index.html;\n\n  # Your normal configuration can go here\n  # location .php { fastcgi...} etc.\n}"
  },
  {
    "path": "docs/docs/admin/environments/nginx/upstream-anubis.conf",
    "content": "# /etc/nginx/conf.d/upstream-anubis.conf\n\nupstream anubis {\n  # Make sure this matches the values you set for `BIND` and `BIND_NETWORK`.\n  # If this does not match, your services will not be protected by Anubis.\n\n  # Try anubis first over a UNIX socket\n  server unix:/run/anubis/nginx.sock;\n  #server 127.0.0.1:8923;\n\n  # Optional: fall back to serving the websites directly. This allows your\n  # websites to be resilient against Anubis failing, at the risk of exposing\n  # them to the raw internet without protection. This is a tradeoff and can\n  # be worth it in some edge cases.\n  #server unix:/run/nginx.sock backup;\n}"
  },
  {
    "path": "docs/docs/admin/environments/nginx.mdx",
    "content": "# Nginx\n\nimport CodeBlock from \"@theme/CodeBlock\";\n\nAnubis 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:\n\n```mermaid\n---\ntitle: Nginx as tls terminator and HTTP router\n---\n\nflowchart LR\n    T(User Traffic)\n    subgraph Nginx\n        TCP(TCP 80/443)\n        US(Unix Socket or\nanother TCP port)\n    end\n\n    An(Anubis)\n    B(Backend)\n\n    T --> |TLS termination| TCP\n    TCP --> |Traffic filtering| An\n    An --> |Happy traffic| US\n    US --> |whatever you're doing| B\n```\n\nInstead 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.\n\nEffectively, 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.\n\n:::note\n\nThese 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.\n\n:::\n\nAssuming that we are protecting `anubistest.techaro.lol`, here's what the server configuration file would look like:\n\nimport anubisTest from \"!!raw-loader!./nginx/server-anubistest-techaro-lol.conf\";\n\n<CodeBlock language=\"nginx\">{anubisTest}</CodeBlock>\n\n:::tip\n\nYou can copy the `location /` block into a separate file named something like `conf-anubis.inc` and then include it inline to other `server` blocks:\n\nimport anubisInclude from \"!!raw-loader!./nginx/conf-anubis.inc\";\n\n<CodeBlock language=\"nginx\">{anubisInclude}</CodeBlock>\n\nThen in a server block:\n\n<details>\n<summary>Full nginx config</summary>\n\nimport mimiTecharoLol from \"!!raw-loader!./nginx/server-mimi-techaro-lol.conf\";\n\n<CodeBlock language=\"nginx\">{mimiTecharoLol}</CodeBlock>\n\n</details>\n\n:::\n\nCreate an upstream for Anubis.\n\nimport anubisUpstream from \"!!raw-loader!./nginx/upstream-anubis.conf\";\n\n<CodeBlock language=\"nginx\">{anubisUpstream}</CodeBlock>\n\nThis 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.\n\nThen reload your nginx config and load your website. You should see Anubis protecting your apps!\n\n```text\nsudo systemctl reload nginx.service\n```\n"
  },
  {
    "path": "docs/docs/admin/environments/traefik.mdx",
    "content": "---\nid: traefik\ntitle: Traefik\n---\n\n:::note\n\nThis only talks about integration through Compose,\nbut it also applies to docker cli options.\n\n:::\n\nIn this example, we will use 4 Containers:\n\n- `traefik` - the Traefik instance\n- `anubis` - the Anubis instance\n- `target` - our service to protect (`traefik/whoami` in this case)\n- `target2` - a second service that isn't supposed to be protected (`traefik/whoami` in this case)\n\n## Diagram of Flow\n\nThis is a small diagram depicting the flow.\nKeep in mind that `8080` or `80` can be anything depending on your containers.\n\n```mermaid\nflowchart LR\nuser[User]\ntraefik[Traefik]\nanubis[Anubis]\ntarget[Target]\n\nuser-->|:443 - Requesting Service|traefik\ntraefik-->|:8080 - Check authorization to Anubis|anubis\nanubis-->|redirect if failed|traefik\nuser-->|:8080 - make the challenge|traefik\nanubis-->|redirect back to target|traefik\ntraefik-->|:80 - Passing to the target|target\n```\n\n## Full Example Config\n\nThis example contains 3 services: anubis, one that is protected and the other one that is not.\n\n**compose.yml**\n\n```yml\nservices:\n  traefik:\n    image: traefik:v3.3\n    ports:\n      - 80:80\n      - 443:443\n    volumes:\n      - /var/run/docker.sock:/var/run/docker.sock\n      - ./letsencrypt:/letsencrypt\n      - ./traefik.yml:/traefik.yml:ro\n    networks:\n      - traefik\n    labels:\n      # Enable Traefik\n      - traefik.enable=true\n      - traefik.docker.network=traefik\n      # Anubis middleware\n      - traefik.http.middlewares.anubis.forwardauth.address=http://anubis:8080/.within.website/x/cmd/anubis/api/check\n      # Redirect any HTTP to HTTPS\n      - traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https\n      - traefik.http.routers.web.rule=PathPrefix(`/`)\n      - traefik.http.routers.web.entrypoints=web\n      - traefik.http.routers.web.middlewares=redirect-to-https\n      - traefik.http.routers.web.tls=false\n\n  anubis:\n    image: ghcr.io/techarohq/anubis:main\n    environment:\n      # Telling Anubis, where to listen for Traefik\n      - BIND=:8080\n      # Telling Anubis to do redirect — ensure there is a space after '='\n      - \"TARGET= \"\n      # Specifies which domains Anubis is allowed to redirect to.\n      - REDIRECT_DOMAINS=example.com\n      # Should be the full external URL for Anubis (including scheme)\n      - PUBLIC_URL=https://anubis.example.com\n      # Should match your domain for proper cookie scoping\n      - COOKIE_DOMAIN=example.com\n    networks:\n      - traefik\n    labels:\n      - traefik.enable=true # Enabling Traefik\n      - traefik.docker.network=traefik # Telling Traefik which network to use\n      - traefik.http.routers.anubis.rule=Host(`anubis.example.com`) # Only Matching Requests for example.com\n      - traefik.http.routers.anubis.entrypoints=websecure # Listen on HTTPS\n      - traefik.http.services.anubis.loadbalancer.server.port=8080 # Telling Traefik where to receive requests\n      - traefik.http.routers.anubis.service=anubis # Telling Traefik to use the above specified port\n      - traefik.http.routers.anubis.tls.certresolver=le # Telling Traefik to resolve a Cert for Anubis\n\n  # Protected by Anubis\n  target:\n    image: traefik/whoami:latest\n    networks:\n      - traefik\n    labels:\n      - traefik.enable=true # Enabling Traefik\n      - traefik.docker.network=traefik # Telling Traefik which network to use\n      - traefik.http.routers.target.rule=Host(`example.com`) # Only Matching Requests for example.com\n      - traefik.http.routers.target.entrypoints=websecure # Listening on the exclusive Anubis Network\n      - traefik.http.services.target.loadbalancer.server.port=80 # Telling Traefik where to receive requests\n      - traefik.http.routers.target.service=target # Telling Traefik to use the above specified port\n      - traefik.http.routers.target.tls.certresolver=le # Telling Traefik to resolve a Cert for Anubis\n      - traefik.http.routers.target.middlewares=anubis@docker # Use the Anubis middleware\n\n  # Not Protected by Anubis\n  target2:\n    image: traefik/whoami:latest\n    networks:\n      - traefik\n    labels:\n      - traefik.enable=true # Enabling Traefik\n      - traefik.docker.network=traefik # Telling Traefik which network to use\n      - traefik.http.routers.target2.rule=Host(`another.example.com`) # Only Matching Requests for example.com\n      - traefik.http.routers.target2.entrypoints=websecure # Listening on the exclusive Anubis Network\n      - traefik.http.services.target2.loadbalancer.server.port=80 # Telling Traefik where to receive requests\n      - traefik.http.routers.target2.service=target2 # Telling Traefik to use the above specified port\n      - traefik.http.routers.target2.tls.certresolver=le # Telling Traefik to resolve a Cert for this Target\n\nnetworks:\n  traefik:\n    name: traefik\n```\n\n**traefik.yml**\n\n```yml\napi:\n  insecure: false # shouldn't be enabled in prod\n\nentryPoints:\n  # Web\n  web:\n    address: \":80\"\n  websecure:\n    address: \":443\"\n\ncertificatesResolvers:\n  le:\n    acme:\n      tlsChallenge: {}\n      email: \"admin@example.com\"\n      storage: \"/letsencrypt/acme.json\"\n\nproviders:\n  docker: {}\n```\n"
  },
  {
    "path": "docs/docs/admin/frameworks/_category_.json",
    "content": "{\n  \"label\": \"Frameworks\",\n  \"position\": 30,\n  \"link\": {\n    \"type\": \"generated-index\",\n    \"description\": \"Information about getting specific frameworks or tools working with Anubis.\"\n  }\n}\n"
  },
  {
    "path": "docs/docs/admin/frameworks/htmx.mdx",
    "content": "# HTMX\n\nimport Tabs from \"@theme/Tabs\";\nimport TabItem from \"@theme/TabItem\";\n\n[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.\n\nTo 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:\n\n```yaml\n- name: allow-htmx-iff-already-passed-challenge\n  action: ALLOW\n  expression:\n    all:\n      - '\"Cookie\" in headers'\n      - 'headers[\"Cookie\"].contains(\"anubis-auth\")'\n      - '\"Hx-Request\" in headers'\n      - 'headers[\"Hx-Request\"] == \"true\"'\n```\n\nThis 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.\n"
  },
  {
    "path": "docs/docs/admin/frameworks/wordpress.mdx",
    "content": "# WordPress\n\nWordPress is the most popular blog engine on the planet.\n\n## Using a multi-site setup with Anubis\n\nIf you have a multi-site setup where traffic goes through Anubis like this:\n\n```mermaid\n---\ntitle: Apache as tls terminator and HTTP router\n---\n\nflowchart LR\n    T(User Traffic)\n    subgraph Apache 2\n        TCP(TCP 80/443)\n        US(TCP 3001)\n    end\n\n    An(Anubis)\n    B(Backend)\n\n    T --> |TLS termination| TCP\n    TCP --> |Traffic filtering| An\n    An --> |Happy traffic| US\n    US --> |whatever you're doing| B\n```\n\nWordPress 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:\n\n```php\nif (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') {\n    $_SERVER['HTTPS'] = 'on';\n    $_SERVER['SERVER_PORT'] = 443;\n}\n```\n\nThis will make WordPress think that your connection is over HTTPS instead of plain HTTP.\n"
  },
  {
    "path": "docs/docs/admin/honeypot/_category_.json",
    "content": "{\n  \"label\": \"Honeypot\",\n  \"position\": 40,\n  \"link\": {\n    \"type\": \"generated-index\",\n    \"description\": \"Honeypot features in Anubis, allowing Anubis to passively detect malicious crawlers.\"\n  }\n}\n"
  },
  {
    "path": "docs/docs/admin/honeypot/overview.mdx",
    "content": "---\ntitle: Dataset poisoning\n---\n\nAnubis 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.\n\nIn 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:\n\n- These pages are _cheap_ to render, rendering in at most ten milliseconds on decently specced hardware.\n- 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.\n- These pages are _fairly large_ so that scrapers don't think that the pages are error pages or are otherwise devoid of content.\n- These pages are _fully self-contained_ so that they load fast without incurring additional load from resource fetches.\n\nIn 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:\n\n> 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.\n\nThis should be fairly transparent to humans that this is pseudoprofound anti-content and is a signal to click away.\n\n## Plans\n\nFuture versions of this feature will allow for more customization. In the near future this will be configurable via the following mechanisms:\n\n- WebAssembly logic for customizing how the poisoning data is generated (with examples including the existing spintax method).\n- Weight thresholds and logic for how they are interpreted by Anubis.\n- Other configuration settings as facts and circumstances dictate.\n\n## Implementation notes\n\nIn its current implementation, the Anubis dataset poisoning feature has the following flaws that may hinder production deployments:\n\n- All Anubis instances use the same method for generating dataset poisoning information. This may be easy for malicious actors to detect and ignore.\n- 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.\n\nRight now Anubis assigns 30 weight points if the following criteria are met:\n\n- A client's User-Agent has been observed in the dataset poisoning maze at least 25 times.\n- The network-clamped IP address (/24 for IPv4 and /48 for IPv6) has been observed in the dataset poisoning maze at least 25 times.\n\nAdditionally, 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).\n"
  },
  {
    "path": "docs/docs/admin/installation.mdx",
    "content": "---\ntitle: Setting up Anubis\n---\n\nimport EnterpriseOnly from \"@site/src/components/EnterpriseOnly\";\nimport RandomKey from \"@site/src/components/RandomKey\";\n\nexport const EO = () => (\n  <>\n    <EnterpriseOnly link=\"./botstopper/\" />\n    <div style={{ marginBottom: \"0.5rem\" }} />\n  </>\n);\n\nAnubis 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.\n\n<center>\n\n```mermaid\n---\ntitle: With Anubis installed\n---\n\nflowchart LR\n    LB(Load balancer /\nTLS terminator)\n    Anubis(Anubis)\n    App(App)\n\n    LB --> Anubis --> App\n```\n\n</center>\n\n## Docker image conventions\n\nAnubis 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:\n\n| Tag                 | Meaning                                                                                                                            |\n| :------------------ | :--------------------------------------------------------------------------------------------------------------------------------- |\n| `latest`            | The latest [tagged release](https://github.com/TecharoHQ/anubis/releases), if you are in doubt, start here.                        |\n| `v<version number>` | The Anubis image for [any given tagged release](https://github.com/TecharoHQ/anubis/tags)                                          |\n| `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`. |\n\nThe 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.\n\nAnubis 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.\n\n## Native packages\n\nFor more detailed information on installing Anubis with native packages, please read [the native install directions](./native-install.mdx).\n\n## Configuration\n\nAnubis 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.\n\n### Configuration via the policy file\n\nCurrently the following settings are configurable via the policy file:\n\n- [Bot policies](./policies.mdx)\n- [Open Graph passthrough](./configuration/open-graph.mdx)\n- [Weight thresholds](./configuration/thresholds.mdx)\n\n### Environment variables\n\nAnubis uses these environment variables for configuration:\n\n| Environment Variable           | Default value           | Explanation                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |\n| :----------------------------- | :---------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `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.                                                                                                                                                                                                                                                                                                                            |\n| `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.                                                                                                                                                                                                                                    |\n| `BIND`                         | `:8923`                 | The network address that Anubis listens on. For `unix`, set this to a path: `/run/anubis/instance.sock`                                                                                                                                                                                                                                                                                                                                                                                                                                        |\n| `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.                                                                                                                                                                                                                                                                                                                                                                                                     |\n| `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.                                                                                                                                                                                                                                                                                                                 |\n| `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.                                                                                                                         |\n| `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`.                                                                                                                                                                                                                                                                                                                      |\n| `COOKIE_EXPIRATION_TIME`       | `168h`                  | The amount of time the authorization cookie is valid for.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      |\n| `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.                                                                                                                                                                                                                                                                                                                                                                                                                                      |\n| `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.                                                                                                                                                                                                                                                                                                                 |\n| `COOKIE_PREFIX`                | `anubis-cookie`         | The prefix used for browser cookies created by Anubis. Useful for customization or avoiding conflicts with other applications.                                                                                                                                                                                                                                                                                                                                                                                                                 |\n| `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                                                                                                                                                                                                                                          |\n| `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. |\n| `DIFFICULTY`                   | `4`                     | The difficulty of the challenge, or the number of leading zeroes that must be in successful responses.                                                                                                                                                                                                                                                                                                                                                                                                                                         |\n| `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.                                                                                                                                                                                                                                                                                                                                                                   |\n| `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.                                                                                                                             |\n| `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.                                                                                                                                                                                                       |\n| `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.                                                                                                                                                                                                                                                                                                                                      |\n| `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`.                                                                                                                                                                                                                                                                                                                                            |\n| `METRICS_BIND`                 | `:9090`                 | The network address that Anubis serves Prometheus metrics on. See `BIND` for more information.                                                                                                                                                                                                                                                                                                                                                                                                                                                 |\n| `METRICS_BIND_NETWORK`         | `tcp`                   | The address family that the Anubis metrics server listens on. See `BIND_NETWORK` for more information.                                                                                                                                                                                                                                                                                                                                                                                                                                         |\n| `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.                                                                                                                                                                                                                                                                                                                                                                                        |\n| `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.                                                                                                                                                                                                                                                                                                                                                                         |\n| `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.                                                                                                                                                                                                                                                                                                                                                         |\n| `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.                                                                                                                                                                                                                                                                                                                                              |\n| `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.                                                                                                                                                                                                                                                                                                                                                                     |\n| `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`.                                                                                                                                                                                                                                                                         |\n| `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.                                                                                                                                                                                                                                                                                                                                                   |\n| `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.                                                                                                                                                                                                                                                       |\n| `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.                                                                                                                                                                                                                                                                                                                                                             |\n| `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.                                                                                                                                                                                                                                                                                                                                                                                                      |\n| `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`.                                                                                                                                                                                                          |\n| `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`.                                                                                                                                                                                                                                                                                                                                                                                   |\n| `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.                                                                                                                                                                                                                                                                                                                              |\n| `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.                                                                                                                                                                                                                                                                                                                                                                                                           |\n| `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.                                                                                                                                                                                                                                                                                                                                                              |\n| `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.                                                                                                                                                                                                                                                                                                                                                                                                 |\n| `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`.                                                                                                                                                                                                                                                                                                                                                                                          |\n\n<details>\n<summary>Advanced configuration settings</summary>\n\n:::note\n\nIf you don't know or understand what these settings mean, ignore them. These are intended to work around very specific issues.\n\n:::\n\n| Environment Variable          | Default value | Explanation                                                                                                                                                                                                                                                                                                                                                                                     |\n| :---------------------------- | :------------ | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `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).                                                                                                                                                                                        |\n| `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. |\n| `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.                                                                                                                                                                                                                                                           |\n| `TARGET_HOST`                 | unset         | If set, overrides the Host header in requests forwarded to `TARGET`.                                                                                                                                                                                                                                                                                                                            |\n| `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.                                                                                                                                                                                                                                             |\n| `TARGET_SNI`                  | unset         | If set, TLS handshake hostname when forwarding requests to the `TARGET`. If set to auto, use Host header.                                                                                                                                                                                                                                                                                       |\n\n</details>\n\nFor more detailed information on configuring Open Graph tags, please refer to the [Open Graph Configuration](./configuration/open-graph.mdx) page.\n\n### Using Base Prefix\n\nThe `BASE_PREFIX` environment variable allows you to run Anubis behind a path prefix. This is useful when:\n\n- You want to host multiple services on the same domain\n- You're using a reverse proxy that routes based on path prefixes\n- You need to integrate Anubis with an existing application structure\n\nFor example, if you set `BASE_PREFIX=/myapp`, Anubis will:\n\n- Serve its challenge page at `/myapp/` instead of `/`\n- Serve its API endpoints at `/myapp/.within.website/x/cmd/anubis/api/` instead of `/.within.website/x/cmd/anubis/api/`\n- Serve its static assets at `/myapp/.within.website/x/cmd/anubis/` instead of `/.within.website/x/cmd/anubis/`\n\nWhen using this feature with a reverse proxy:\n\n1. Configure your reverse proxy to route requests for the specified path prefix to Anubis\n2. Set the `BASE_PREFIX` environment variable to match the path prefix in your reverse proxy configuration\n3. Ensure that your reverse proxy preserves the path when forwarding requests to Anubis\n\nExample with Nginx:\n\n```nginx\nlocation /myapp/ {\n    proxy_pass http://anubis:8923/myapp;\n    proxy_set_header Host $host;\n    proxy_set_header X-Real-IP $remote_addr;\n}\n```\n\nWith corresponding Anubis configuration:\n\n```\nBASE_PREFIX=/myapp\n```\n\n#### Stripping Base Prefix\n\nIf your target service doesn't expect to receive the base prefix in request paths, you can use the `STRIP_BASE_PREFIX` option:\n\n```\nBASE_PREFIX=/myapp\nSTRIP_BASE_PREFIX=true\n```\n\nWith this configuration:\n\n- A request to `/myapp/api/users` would be forwarded to your target service as `/api/users`\n- A request to `/myapp/` would be forwarded as `/`\n\nThis 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.\n\n### Key generation\n\nTo generate an ed25519 private key, you can use this command:\n\n```text\nopenssl rand -hex 32\n```\n\nAlternatively here is a key generated by your browser:\n\n<RandomKey />\n\n## Next steps\n\nTo 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.\n\n- [Apache](./environments/apache.mdx)\n- [Caddy](./environments/caddy.mdx)\n- [Docker compose](./environments/docker-compose.mdx)\n- [Kubernetes](./environments/kubernetes.mdx)\n- [Nginx](./environments/nginx.mdx)\n- [Traefik](./environments/traefik.mdx)\n- [HAProxy](./environments/haproxy.mdx)\n\n:::note\n\nAnubis 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.\n\n:::\n"
  },
  {
    "path": "docs/docs/admin/iplist2rule.mdx",
    "content": "---\ntitle: iplist2rule CLI tool\n---\n\nThe `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.\n\n## Installation\n\nInstall directly with Go\n\n```bash\ngo install github.com/TecharoHQ/anubis/utils/cmd/iplist2rule@latest\n```\n\n## Usage\n\nBasic conversion from URL:\n\n```bash\niplist2rule https://raw.githubusercontent.com/7c/torfilter/refs/heads/main/lists/txt/torfilter-1m-flat.txt filter-tor.yaml\n```\n\nExplicitly allow every IP address on a list:\n\n```bash\niplist2rule --action ALLOW https://raw.githubusercontent.com/7c/torfilter/refs/heads/main/lists/txt/torfilter-1m-flat.txt filter-tor.yaml\n```\n\nAdd weight to requests matching IP addresses on a list:\n\n```bash\niplist2rule --action WEIGH --weight 20 https://raw.githubusercontent.com/7c/torfilter/refs/heads/main/lists/txt/torfilter-1m-flat.txt filter-tor.yaml\n```\n\n## Options\n\n| Flag          | Description                                                                                      | Default                           |\n| :------------ | :----------------------------------------------------------------------------------------------- | :-------------------------------- |\n| `--action`    | The Anubis action to take for the IP address in question, must be in ALL CAPS.                   | `DENY` (forbids traffic)          |\n| `--rule-name` | The name for the generated Anubis rule, should be in kebab-case.                                 | (not set, inferred from filename) |\n| `--weight`    | When `--action=WEIGH`, how many weight points should be added or removed from matching requests? | 0 (not set)                       |\n\n## Using the Generated Policy\n\nSave the output and import it in your main policy file:\n\n```yaml\nbots:\n  - import: \"./filter-tor.yaml\"\n```\n"
  },
  {
    "path": "docs/docs/admin/native-install.mdx",
    "content": "---\ntitle: Installing Anubis with a native package\n---\n\nimport Tabs from \"@theme/Tabs\";\nimport TabItem from \"@theme/TabItem\";\n\nDownload the package for your system from [the most recent release on GitHub](https://github.com/TecharoHQ/anubis/releases).\n\nInstall the Anubis package using your package manager of choice:\n\n<Tabs>\n  <TabItem value=\"deb\" label=\"Debian-based (apt)\" default>\n  \nInstall Anubis with `apt`:\n\n```text\nsudo apt install ./anubis-$VERSION-$ARCH.deb\n```\n\n  </TabItem>\n  <TabItem value=\"tarball\" label=\"Tarball\">\n  \nExtract the tarball to a folder:\n\n```text\ntar zxf ./anubis-$VERSION-$OS-$ARCH.tar.gz\ncd anubis-$VERSION-$OS-$ARCH\n```\n\nInstall the binary to your system:\n\n```text\nsudo install -D ./bin/anubis /usr/local/bin\n```\n\nEdit the systemd unit to point to `/usr/local/bin/anubis` instead of `/usr/bin/anubis`:\n\n```text\nperl -pi -e 's$/usr/bin/anubis$/usr/local/bin/anubis$g' ./run/anubis@.service\n```\n\nInstall the systemd unit to your system:\n\n```text\nsudo install -D ./run/anubis@.service /etc/systemd/system\n```\n\nInstall the default configuration file to your system:\n\n```text\nsudo install -D ./run/default.env /etc/anubis/default.env\n```\n\n  </TabItem>\n  <TabItem value=\"rpm\" label=\"Red Hat-based (rpm)\">\n  \nInstall Anubis with `dnf`:\n\n```text\nsudo dnf -y install ./anubis-$VERSION.$ARCH.rpm\n```\n\nOR\n\nInstall Anubis with `yum`:\n\n```text\nsudo yum -y install ./anubis-$VERSION.$ARCH.rpm\n```\n\nOR\n\nInstall Anubis with `rpm`:\n\n```\nsudo rpm -ivh ./anubis-$VERSION.$ARCH.rpm\n```\n\n  </TabItem>\n  <TabItem value=\"distro\" label=\"Package managers\">\n\nSome 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`.\n\nIf you use a systemd-flavoured distribution, then follow the setup instructions for Debian or Red Hat Linux.\n\n  </TabItem>\n</Tabs>\n\nOnce 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:\n\n```text\nsudo cp /etc/anubis/default.env /etc/anubis/gitea.env\n```\n\nCopy the default bot policies file to `/etc/anubis/gitea.botPolicies.yaml`:\n\n<Tabs>\n<TabItem value=\"debrpm\" label=\"Debian or Red Hat\" default>\n\n```text\nsudo cp /usr/share/doc/anubis/botPolicies.yaml /etc/anubis/gitea.botPolicies.yaml\n```\n\n</TabItem>\n<TabItem value=\"tarball\" label=\"Tarball\">\n\n```text\nsudo cp ./doc/botPolicies.yaml /etc/anubis/gitea.botPolicies.yaml\n```\n\n</TabItem>\n\n</Tabs>\n\nThen 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:\n\n```sh\nBIND=[::1]:8239\nBIND_NETWORK=tcp\nDIFFICULTY=4\nMETRICS_BIND=[::1]:8240\nMETRICS_BIND_NETWORK=tcp\nPOLICY_FNAME=/etc/anubis/gitea.botPolicies.yaml\nTARGET=http://localhost:3000\n```\n\nThen start Anubis with `systemctl enable --now`:\n\n```text\nsudo systemctl enable --now anubis@gitea.service\n```\n\nTest to make sure it's running with `curl`:\n\n```text\ncurl http://localhost:8240/metrics\n```\n\nThen 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.\n\nFor more details on particular reverse proxies, see here:\n\n- [Apache](./environments/apache.mdx)\n- [Nginx](./environments/nginx.mdx)\n- [HAProxy](./environments/haproxy.mdx)\n"
  },
  {
    "path": "docs/docs/admin/policies.mdx",
    "content": "---\ntitle: Policy Definitions\n---\n\nimport Tabs from \"@theme/Tabs\";\nimport TabItem from \"@theme/TabItem\";\n\nOut 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.\n\nAnubis 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.\n\n## Bot Policies\n\nBot 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:\n\n- Request path\n- User agent string\n- HTTP request header values\n- [Importing other configuration snippets](./configuration/import.mdx)\n\nAs of version v1.17.0 or later, configuration can be written in either JSON or YAML.\n\nHere's an example rule that denies [Amazonbot](https://developer.amazon.com/en/amazonbot):\n\n```yaml\n- name: amazonbot\n  user_agent_regex: Amazonbot\n  action: DENY\n```\n\nWhen 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.\n\nRight now the only kinds of policies you can write are bot policies. Other forms of policies will be added in the future.\n\nHere is a minimal policy file that will protect against most scraper bots:\n\n```yaml\nbots:\n  - name: cloudflare-workers\n    headers_regex:\n      CF-Worker: .*\n    action: DENY\n  - name: well-known\n    path_regex: ^/.well-known/.*$\n    action: ALLOW\n  - name: favicon\n    path_regex: ^/favicon.ico$\n    action: ALLOW\n  - name: robots-txt\n    path_regex: ^/robots.txt$\n    action: ALLOW\n  - name: generic-browser\n    user_agent_regex: Mozilla\n    action: CHALLENGE\n```\n\nThis 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.\n\nIf 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).\n\n### Writing your own rules\n\nThere are four actions that can be returned from a rule:\n\n| Action      | Effects                                                                                                                             |\n| :---------- | :---------------------------------------------------------------------------------------------------------------------------------- |\n| `ALLOW`     | Bypass all further checks and send the request to the backend.                                                                      |\n| `DENY`      | Deny the request and send back an error message that scrapers think is a success.                                                   |\n| `CHALLENGE` | Show a challenge page and/or validate that clients have passed a challenge.                                                         |\n| `WEIGH`     | Change the [request weight](#request-weight) for this request. See the [request weight](#request-weight) docs for more information. |\n\nName your rules in lower case using kebab-case. Rule names will be exposed in Prometheus metrics.\n\n### Challenge configuration\n\nRules 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:\n\nThis rule has been known to have a high false positive rate in testing. Please use this with care.\n\n```yaml\n# Punish any bot with \"bot\" in the user-agent string\n- name: generic-bot-catchall\n  user_agent_regex: (?i:bot|crawler)\n  action: CHALLENGE\n  challenge:\n    difficulty: 16 # impossible\n    algorithm: slow # intentionally waste CPU cycles and time\n```\n\nChallenges can be configured with these settings:\n\n| Key          | Example  | Description                                                                                                                                                      |\n| :----------- | :------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `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. |\n| `algorithm`  | `\"fast\"` | The challenge method to use. See [the list of challenge methods](./configuration/challenges/) for more information.                                              |\n\n### Remote IP based filtering\n\nThe `remote_addresses` field of a Bot rule allows you to set the IP range that this ruleset applies to.\n\nFor example, you can allow a search engine to connect if and only if its IP address matches the ones they published:\n\n```yaml\n- name: qwantbot\n  user_agent_regex: \\+https\\://help\\.qwant\\.com/bot/\n  action: ALLOW\n  # https://help.qwant.com/wp-content/uploads/sites/2/2025/01/qwantbot.json\n  remote_addresses: [\"91.242.162.0/24\"]\n```\n\nThis also works at an IP range level without any other checks:\n\n```yaml\nname: internal-network\naction: ALLOW\nremote_addresses:\n  - 100.64.0.0/10\n```\n\n## Imprint / Impressum support\n\nAnubis 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.\n\n## Storage backends\n\nAnubis 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.\n\nAnubis offers the following storage backends:\n\n- [`memory`](#memory) -- A simple in-memory hashmap\n- [`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\n- [`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)\n\nIf 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:\n\n```yaml\nstore:\n  backend: memory\n  parameters: {}\n```\n\n### `memory`\n\nThe 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.\n\n| Should I use this backend?                                    | Yes/no |\n| :------------------------------------------------------------ | :----- |\n| Are you running only one instance of Anubis for this service? | ✅ Yes |\n| Does your service get a lot of traffic?                       | 🚫 No  |\n| Do you want to store data persistently when Anubis restarts?  | 🚫 No  |\n| Do you run Anubis without mutable filesystem storage?         | ✅ Yes |\n\nThe 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.\n\n:::warning\n\nThe 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.\n\n:::\n\n#### Configuration\n\nThe memory backend does not require any configuration to use.\n\n### `bbolt`\n\nAn 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.\n\n| Should I use this backend?                                    | Yes/no |\n| :------------------------------------------------------------ | :----- |\n| Are you running only one instance of Anubis for this service? | ✅ Yes |\n| Does your service get a lot of traffic?                       | ✅ Yes |\n| Do you want to store data persistently when Anubis restarts?  | ✅ Yes |\n| Do you run Anubis without mutable filesystem storage?         | 🚫 No  |\n\nWhen 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.\n\n#### Configuration\n\nThe `bbolt` backend takes the following configuration options:\n\n| Name   | Type | Example            | Description                                                                                                                  |\n| :----- | :--- | :----------------- | :--------------------------------------------------------------------------------------------------------------------------- |\n| `path` | path | `/data/anubis.bdb` | The filesystem path for the Anubis bbolt database. Anubis requires write access to the folder containing the bbolt database. |\n\nExample:\n\nIf you have persistent storage mounted to `/data`, then your store configuration could look like this:\n\n```yaml\nstore:\n  backend: bbolt\n  parameters:\n    path: /data/anubis.bdb\n```\n\n### `s3api`\n\nA 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:\n\n- [AWS S3](https://aws.amazon.com/s3/)\n- [Cloudflare R2](https://www.cloudflare.com/developer-platform/products/r2/)\n- [Hetzner Object Storage](https://www.hetzner.com/storage/object-storage/)\n- [Minio](https://www.min.io/)\n- [Tigris](https://www.tigrisdata.com/)\n\nIf 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/).\n\n| Should I use this backend?                                    | Yes/no |\n| :------------------------------------------------------------ | :----- |\n| Are you running only one instance of Anubis for this service? | 🚫 No  |\n| Does your service get a lot of traffic?                       | ✅ Yes |\n| Do you want to store data persistently when Anubis restarts?  | ✅ Yes |\n| Do you run Anubis without mutable filesystem storage?         | ✅ Yes |\n\n:::note\n\nUsing 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.\n\n:::\n\n#### Configuration\n\nThe `s3api` backend takes the following configuration options:\n\n| Name         | Type    | Example       | Description                                                                                                                                 |\n| :----------- | :------ | :------------ | :------------------------------------------------------------------------------------------------------------------------------------------ |\n| `bucketName` | string  | `anubis-data` | (Required) The name of the dedicated bucket for Anubis to store information in.                                                             |\n| `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. |\n\n:::note\n\nYou should probably enable a lifecycle expiration rule for buckets containing Anubis data. Here is an example policy:\n\n```json\n{\n  \"Rules\": [\n    {\n      \"Status\": \"Enabled\",\n      \"Expiration\": {\n        \"Days\": 7\n      }\n    }\n  ]\n}\n```\n\nAdjust this as facts and circumstances demand, but 7 days should be enough for anyone.\n\n:::\n\nExample:\n\nAssuming your environment looks like this:\n\n```sh\n# All of the following are fake credentials that look like real ones.\nAWS_ACCESS_KEY_ID=accordingToAllKnownRulesOfAviation\nAWS_SECRET_ACCESS_KEY=thereIsNoWayABeeShouldBeAbleToFly\nAWS_REGION=yow\nAWS_ENDPOINT_URL_S3=https://yow.s3.probably-not-malware.lol\n```\n\nThen your configuration would look like this:\n\n```yaml\nstore:\n  backend: s3api\n  parameters:\n    bucketName: techaro-prod-anubis\n    pathStyle: false\n```\n\n### `valkey`\n\n[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.\n\n:::note\n\nYou can also use [Redis™](http://redis.io/) with Anubis.\n\n:::\n\nThis 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).\n\n| Should I use this backend?                                    | Yes/no |\n| :------------------------------------------------------------ | :----- |\n| Are you running only one instance of Anubis for this service? | 🚫 No  |\n| Does your service get a lot of traffic?                       | ✅ Yes |\n| Do you want to store data persistently when Anubis restarts?  | ✅ Yes |\n| Do you run Anubis without mutable filesystem storage?         | ✅ Yes |\n| Do you have Redis™ or Valkey installed?                       | ✅ Yes |\n\n#### Configuration\n\nThe `valkey` backend takes the following configuration options:\n\n| Name       | Type   | Example                 | Description                                                                                                                                       |\n| :--------- | :----- | :---------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------ |\n| `cluster`  | bool   | `false`                 | If true, use [Redis™ Clustering](https://redis.io/topics/cluster-spec) for storing Anubis data.                                                   |\n| `sentinel` | object | `{}`                    | See [Redis™ Sentinel docs](#redis-sentinel) for more detail and examples                                                                          |\n| `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. |\n\nExample:\n\nIf you have an instance of Valkey running with the hostname `valkey.int.techaro.lol`, then your store configuration could look like this:\n\n```yaml\nstore:\n  backend: valkey\n  parameters:\n    url: \"redis://valkey.int.techaro.lol:6379/0\"\n```\n\nThis would have the Valkey client connect to host `valkey.int.techaro.lol` on port `6379` with database `0` (the default database).\n\n#### Redis™ Sentinel\n\nIf 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:\n\n| Name         | Type                     | Example               | Description                                                                                                                                               |\n| :----------- | :----------------------- | :-------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `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. |\n| `clientName` | string                   | `Anubis`              | The client name reported to Redis™ Sentinel. Set this if you want to track Anubis connections to your Redis™ Sentinel.                                    |\n| `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.             |\n| `username`   | string                   | `azurediamond`        | The username used to authenticate against the Redis™ Sentinel and Redis™ servers.                                                                         |\n| `password`   | string                   | `hunter2`             | The password used to authenticate against the Redis™ Sentinel and Redis™ servers.                                                                         |\n\n## Logging management\n\nAnubis 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.\n\nAnubis 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.\n\nAnubis exposes the following logging settings in the policy file:\n\n| Name         | Type                     | Example         | Description                                                                                                                              |\n| :----------- | :----------------------- | :-------------- | :--------------------------------------------------------------------------------------------------------------------------------------- |\n| `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. |\n| `sink`       | string                   | `stdio`, `file` | The sink where the logs drain to as they are being recorded in Anubis.                                                                   |\n| `parameters` | object                   |                 | Parameters for the given logging sink. This will vary based on the logging sink of choice. See below for more information.               |\n\nAnubis supports the following logging sinks:\n\n1. `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).\n2. `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.\n\n### Log levels\n\nAnubis 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:\n\n| Log level | Use in Anubis                                                                                                                                             |\n| :-------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `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.                              |\n| `INFO`    | The default logging level, fairly verbose in order to make it easier for automation to parse.                                                             |\n| `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. |\n| `ERROR`   | Only log error messages.                                                                                                                                  |\n\nAdditionally, you can set a \"slightly higher\" log level if you need to, such as:\n\n```yaml\nlogging:\n  sink: stdio\n  level: \"INFO+1\"\n```\n\nThis isn't currently used by Anubis, but will be in the future for \"slightly important\" information.\n\n### `file` sink\n\nThe `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:\n\n| Name           | Type            | Example               | Description                                                                                                                                                                                                                    |\n| :------------- | :-------------- | :-------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `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.                                                                            |\n| `maxBackups`   | number          | `3`                   | The number of old log files that should be maintained when log files are rotated out.                                                                                                                                          |\n| `maxBytes`     | number of bytes | `67108864` (64Mi)     | The maximum size of each log file before it is rotated out.                                                                                                                                                                    |\n| `maxAge`       | number of days  | `7`                   | If a log file is more than this many days old, rotate it out.                                                                                                                                                                  |\n| `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.                            |\n| `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. |\n\n```yaml\nlogging:\n  sink: file\n  parameters:\n    file: \"./var/anubis.log\"\n    maxBackups: 3 # keep at least 3 old copies\n    maxBytes: 67108864 # each file can have up to 64 Mi of logs\n    maxAge: 7 # rotate files out every n days\n    compress: true # gzip-compress old log files\n    useLocalTime: false # timezone for rotated files is UTC\n```\n\nWhen 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).\n\n:::note\n\nIf 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:\n\n```text\n# /etc/systemd/anubis@instance-name.service.d/50-var-log-readwrite.conf\n[Service]\nReadWritePaths=/run /var/log/anubis\n```\n\nOnce you write this to the correct place, reload the systemd configuration:\n\n```text\nsudo systemctl daemon-reload\n```\n\nAnd then restart Anubis:\n\n```text\nsudo systemctl restart anubis@instance-name\n```\n\nYou may be required to make drop-ins for each Anubis instance depending on the facts and circumstances of your deployment.\n\n:::\n\n### `stdio` sink\n\nBy default, Anubis logs everything to the standard error stream of its process. This requires no configuration:\n\n```yaml\nlogging:\n  sink: stdio\n```\n\nIf you use a service orchestration platform that does not capture the standard error stream of processes, you need to use a different logging sink.\n\n## Risk calculation for downstream services\n\nIn case your service needs it for risk calculation reasons, Anubis exposes information about the rules that any requests match using a few headers:\n\n| Header            | Explanation                                          | Example          |\n| :---------------- | :--------------------------------------------------- | :--------------- |\n| `X-Anubis-Rule`   | The name of the rule that was matched                | `bot/lightpanda` |\n| `X-Anubis-Action` | The action that Anubis took in response to that rule | `CHALLENGE`      |\n| `X-Anubis-Status` | The status and how strict Anubis was in its checks   | `PASS`           |\n\nPolicy 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.\n\n## Request Weight\n\nAnubis 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`:\n\n```yaml\n- name: gitea-session-token\n  action: WEIGH\n  expression:\n    all:\n      - '\"Cookie\" in headers'\n      - headers[\"Cookie\"].contains(\"i_love_gitea=\")\n  # Remove 5 weight points\n  weight:\n    adjust: -5\n```\n\nThis 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.\n\n### Weight Thresholds\n\nFor more information on configuring weight thresholds, see [Weight Threshold Configuration](./configuration/thresholds.mdx)\n\n### Advice\n\nWeight 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:\n\n- The default weight for browser-like clients is 10. This triggers an aggressive challenge.\n- Remove and add weight in multiples of five.\n- Be careful with how you configure weight.\n"
  },
  {
    "path": "docs/docs/admin/robots2policy.mdx",
    "content": "---\ntitle: robots2policy CLI Tool\nsidebar_position: 50\n---\n\nThe `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.\n\n## Installation\n\nInstall directly with Go:\n\n```bash\ngo install github.com/TecharoHQ/anubis/cmd/robots2policy@latest\n```\n\n## Usage\n\nBasic conversion from URL:\n\n```bash\nrobots2policy -input https://www.example.com/robots.txt\n```\n\nConvert local file to YAML:\n\n```bash\nrobots2policy -input robots.txt -output policy.yaml\n```\n\nConvert with custom settings:\n\n```bash\nrobots2policy -input robots.txt -action DENY -format json\n```\n\n## Options\n\n| Flag                  | Description                                                        | Default             |\n| --------------------- | ------------------------------------------------------------------ | ------------------- |\n| `-input`              | robots.txt file path or URL (use `-` for stdin)                    | _required_          |\n| `-output`             | Output file (use `-` for stdout)                                   | stdout              |\n| `-format`             | Output format: `yaml` or `json`                                    | `yaml`              |\n| `-action`             | Action for disallowed paths: `ALLOW`, `DENY`, `CHALLENGE`, `WEIGH` | `CHALLENGE`         |\n| `-name`               | Policy name prefix                                                 | `robots-txt-policy` |\n| `-crawl-delay-weight` | Weight adjustment for crawl-delay rules                            | `3`                 |\n| `-deny-user-agents`   | Action for blacklisted user agents                                 | `DENY`              |\n\n## Example\n\nInput robots.txt:\n\n```txt\nUser-agent: *\nDisallow: /admin/\nDisallow: /private\n\nUser-agent: BadBot\nDisallow: /\n```\n\nGenerated policy:\n\n```yaml\n- name: robots-txt-policy-disallow-1\n  action: CHALLENGE\n  expression:\n    single: path.startsWith(\"/admin/\")\n- name: robots-txt-policy-disallow-2\n  action: CHALLENGE\n  expression:\n    single: path.startsWith(\"/private\")\n- name: robots-txt-policy-blacklist-3\n  action: DENY\n  expression:\n    single: userAgent.contains(\"BadBot\")\n```\n\n## Using the Generated Policy\n\nSave the output and import it in your main policy file:\n\n```yaml\nbots:\n  - import: \"./robots-policy.yaml\"\n```\n\nThe tool handles wildcard patterns, user-agent specific rules, and blacklisted bots automatically.\n"
  },
  {
    "path": "docs/docs/admin/roles/_category_.json",
    "content": "{\n  \"label\": \"Server Roles\",\n  \"position\": 40,\n  \"link\": {\n    \"type\": \"generated-index\",\n    \"description\": \"Various server roles you will need to keep in mind with Anubis.\"\n  }\n}\n"
  },
  {
    "path": "docs/docs/admin/roles/oci-registry.mdx",
    "content": "# OCI Registries\n\nIf 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:\n\n```yaml\nbots:\n  - import: (data)/meta/default-config.yaml\n  - import: (data)/clients/docker-client.yaml\n# ... the rest of your config\n```\n"
  },
  {
    "path": "docs/docs/admin/thoth.mdx",
    "content": "# Thoth-based advanced checks\n\nStatus: Beta\n\nAnubis 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.\n\nThus, 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.\n\n:::note\n\nThoth 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.\n\nTo 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.\n\n:::\n\n## Implementation\n\nThoth 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).\n\nThoth 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.\n\n## Thoth features\n\nThoth is currently in active development. Currently, Thoth provides the following features to Anubis:\n\n- BGP Autonomous System (ASN) based filtering\n- GeoIP location based filtering\n\n### ASN-based filtering\n\nWhen 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.\n\nAnubis 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:\n\n```yaml\n- name: aggressive-asns-without-functional-abuse-contact\n  action: WEIGH\n  asns:\n    match:\n      - 13335 # Cloudflare\n      - 136907 # Huawei Cloud\n      - 45102 # Alibaba Cloud\n  weight:\n    adjust: 10\n```\n\nYou can look up details for [AS13335](https://bgp.tools/as/13335) or any of these other top offenders on [bgp.tools](https://bgp.tools).\n\n### GeoIP-based filtering\n\nIn 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.\n\nAnubis 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.\n\nFor example, to add 10 weight points to requests from Brazil and China:\n\n```yaml\n- name: countries-with-aggressive-scrapers\n  action: WEIGH\n  geoip:\n    countries:\n      - BR\n      - CN\n  weight:\n    adjust: 10\n```\n\nUse this with care.\n\n## Work-in-progress features\n\nThis section is a bit aspirational and is where Thoth will end up rather than things you can use today.\n\nIn 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:\n\n- Private rulesets for advanced patterns, current known exploits, and other recognition tactics that need to be kept cloak and dagger for operational security reasons\n- Private challenge implementations via WebAssembly, including advanced browser detection logic\n- 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\n- APIs for trusted administrators to report abusive request fingerprints so that Anubis can react to threats as they evolve\n- A way for Anubis to periodically report the pass rate per ASN and other fingerprints so that methodology can be improved\n"
  },
  {
    "path": "docs/docs/design/_category_.json",
    "content": "{\n  \"label\": \"Design\",\n  \"position\": 10,\n  \"link\": {\n    \"type\": \"generated-index\",\n    \"description\": \"How Anubis is designed and the tradeoffs it makes.\"\n  }\n}\n"
  },
  {
    "path": "docs/docs/design/how-anubis-works.mdx",
    "content": "---\ntitle: How Anubis works\n---\n\nAnubis 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.\n\n```mermaid\n---\ntitle: Challenge generation and validation\n---\n\nflowchart TD\n    Backend(\"Backend\")\n    Fail(\"Fail\")\n\n    style PresentChallenge color:#FFFFFF, fill:#AA00FF, stroke:#AA00FF\n    style ValidateChallenge color:#FFFFFF, fill:#AA00FF, stroke:#AA00FF\n    style Backend color:#FFFFFF, stroke:#00C853, fill:#00C853\n    style Fail color:#FFFFFF, stroke:#FF2962, fill:#FF2962\n\n    subgraph Server\n        PresentChallenge(\"Present Challenge\")\n        ValidateChallenge(\"Validate Challenge\")\n    end\n\n    subgraph Client\n        Main(\"main.mjs\")\n        Worker(\"Worker\")\n    end\n\n    Main -- Request challenge --> PresentChallenge\n    PresentChallenge -- Return challenge & difficulty --> Main\n    Main -- Spawn worker --> Worker\n    Worker -- Successful challenge --> Main\n    Main -- Validate challenge --> ValidateChallenge\n    ValidateChallenge -- Return cookie --> Backend\n    ValidateChallenge -- If anything is wrong --> Fail\n```\n\n## Challenge presentation\n\nAnubis decides to present a challenge using this logic:\n\n- User-Agent contains `\"Mozilla\"`\n- Request path is not in `/.well-known`, `/robots.txt`, or `/favicon.ico`\n- Request path is not obviously an RSS feed (ends with `.rss`, `.xml`, or `.atom`)\n\nThis 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.\n\n```mermaid\n---\ntitle: Challenge presentation logic\n---\n\nflowchart LR\n    Request(\"Request\")\n    Backend(\"Backend\")\n    %%Fail(\"Fail\")\n    PresentChallenge(\"Present\nchallenge\")\n    HasMozilla{\"Is browser\nor scraper?\"}\n    HasCookie{\"Has cookie?\"}\n    HasExpired{\"Cookie expired?\"}\n    HasSignature{\"Has valid\nsignature?\"}\n    RandomJitter{\"Secondary\nscreening?\"}\n    POWPass{\"Proof of\nwork valid?\"}\n\n    style PresentChallenge color:#FFFFFF, fill:#AA00FF, stroke:#AA00FF\n    style Backend color:#FFFFFF, stroke:#00C853, fill:#00C853\n    %%style Fail color:#FFFFFF, stroke:#FF2962, fill:#FF2962\n\n    Request --> HasMozilla\n    HasMozilla -- Yes --> HasCookie\n    HasMozilla -- No --> Backend\n    HasCookie -- Yes --> HasExpired\n    HasCookie -- No --> PresentChallenge\n    HasExpired -- Yes --> PresentChallenge\n    HasExpired -- No --> HasSignature\n    HasSignature -- Yes --> RandomJitter\n    HasSignature -- No --> PresentChallenge\n    RandomJitter -- Yes --> POWPass\n    RandomJitter -- No --> Backend\n    POWPass -- Yes --> Backend\n    PowPass -- No --> PresentChallenge\n    PresentChallenge -- Back again for another cycle --> Request\n```\n\n## Proof of passing challenges\n\nWhen 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:\n\n- `challenge`: The challenge string derived from user request metadata\n- `nonce`: The nonce / iteration number used to generate the passing response\n- `response`: The hash that passed Anubis' checks\n- `iat`: When the token was issued\n- `nbf`: One minute prior to when the token was issued\n- `exp`: The token's expiry week after the token was issued\n\nThis 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.\n\n## JWT signing\n\nAnubis 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.\n"
  },
  {
    "path": "docs/docs/design/why-proof-of-work.mdx",
    "content": "---\ntitle: Why does Anubis use Proof-of-Work?\n---\n\nAnubis 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.\n\n## How Anubis' proof of work scheme works\n\nA sha256 hash is a bunch of bytes like this:\n\n```text\n394d1cc82924c2368d4e34fa450c6b30d5d02f8ae4bb6310e2296593008ff89f\n```\n\nWe 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:\n\n```js\nconst hash = await sha256(`${challenge}${nonce}`);\n```\n\nIn 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.\n\nUltimately, 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.\n"
  },
  {
    "path": "docs/docs/developer/_category_.json",
    "content": "{\n  \"label\": \"Developer guides\",\n  \"position\": 50,\n  \"link\": {\n    \"type\": \"generated-index\",\n    \"description\": \"Guides and suggestions to make Anubis development go smoothly for everyone.\"\n  }\n}\n"
  },
  {
    "path": "docs/docs/developer/ai-coding-policy.md",
    "content": "# AI Coding Policy\n\nAt some level it would be nice to be able to have the following AI coding policy from an ideological standpoint:\n\n> 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.\n\nHowever, 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).\n\nAs 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.\n\nIf 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.\n\nIn 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.\n"
  },
  {
    "path": "docs/docs/developer/building-anubis.md",
    "content": "---\ntitle: Building Anubis without Docker\n---\n\n:::note\n\nThese 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).\n\n:::\n\n## Entirely from source\n\nIf you are doing a build entirely from source, here's what you need to do:\n\n:::info\n\nIf 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.\n\n:::\n\n### Tools needed\n\nIn order to build a production-ready binary of Anubis, you need the following packages in your environment:\n\n- [Go](https://go.dev) at least version 1.24 - the programming language that Anubis is written in\n- [esbuild](https://esbuild.github.io/) - the JavaScript bundler Anubis uses for its production JS assets\n- [Node.JS & NPM](https://nodejs.org/en) - manages some build dependencies\n- `gzip` - compresses production JS (part of coreutils)\n- `zstd` - compresses production JS\n- `brotli` - compresses production JS\n\nTo 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):\n\n```text\ngo install golang.org/dl/go1.24.2@latest\ngo1.24.2 download\n```\n\n### Install dependencies\n\n```text\nmake deps\n```\n\nThis will download Go and NPM dependencies.\n\n### Building static assets\n\n```text\nmake assets\n```\n\nThis will build all static assets (CSS, JavaScript) for distribution.\n\n### Building Anubis to the `./var` folder\n\n```text\nmake build\n```\n\nFrom this point it is up to you to make sure that `./var/anubis` and `./var/robots2policy` end up in\nthe right place. You may want to consult the `./run` folder for useful files such as a systemd unit\nand `anubis.env.default` file.\n\n## \"Pre-baked\" tarball\n\nThe `anubis-src-with-vendor` tarball has many pre-build steps already done, including:\n\n- Go module dependencies are present in `./vendor`\n- Static assets (JS, CSS, etc.) are already built in CI\n\nThis means you do not have to manage Go, NPM, or other ecosystem dependencies.\n\nWhen using this tarball, all you need to do is build `./cmd/anubis`:\n\n```text\nmake prebaked-build\n```\n\nAnubis will be built to `./var/anubis` and the robots2policy tool to `./var/robots2policy`.\n\n## Development dependencies\n\nOptionally, you can install the following dependencies for development:\n\n- [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)\n"
  },
  {
    "path": "docs/docs/developer/local-dev.md",
    "content": "---\ntitle: Local development\n---\n\nIf 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.\n\nThis enables you to contribute from [GitHub Codespaces](https://github.com/features/codespaces) or other web-based editors.\n\n:::note\n\nTL;DR: `npm ci && npm run dev`\n\n:::\n\nAnubis requires the following tools to be installed to do local development:\n\n- [Go](https://go.dev) - the programming language that Anubis is written in\n- [esbuild](https://esbuild.github.io/) - the JavaScript bundler Anubis uses for its production JS assets\n- [Node.JS & NPM](https://nodejs.org/en) - manages some build dependencies\n- `gzip` - compresses production JS (part of coreutils)\n- `zstd` - compresses production JS\n- `brotli` - compresses production JS\n\nIf you have [Homebrew](https://brew.sh) installed, you can install all the dependencies with one command:\n\n```text\nbrew bundle\n```\n\nIf you don't, you may need to figure out equivalents to the packages in Homebrew.\n\n## Running Anubis locally\n\n```text\nnpm run dev\n```\n\nOr to do it manually:\n\n- Run `npm run assets` every time you change the CSS/JavaScript\n- `go run ./cmd/anubis` with any CLI flags you want\n\n## Building JS/CSS assets\n\n```text\nnpm run assets\n```\n\nIf you change the build process, make sure to update `build.sh` accordingly.\n\n## Production-ready builds\n\n```text\nnpm run container\n```\n\nThis 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:\n\n```text\nDOCKER_REPO=registry.host/org/repo DOCKER_METADATA_OUTPUT_TAGS=registry.host/org/repo:latest npm run container\n```\n\n## Building packages\n\nFor 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).\n\nInstall `yeet`:\n\n:::note\n\n`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.\n\n:::\n\n```text\ngo install within.website/x/cmd/yeet@v1.13.4\n```\n\nInstall the dependencies for Anubis:\n\n```text\nnpm ci\ngo mod download\n```\n\nBuild the packages into `./var`:\n\n```text\nyeet\n```\n"
  },
  {
    "path": "docs/docs/developer/signed-commits.md",
    "content": "---\ntitle: Signed commits\n---\n\nAnubis 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/).\n\nWe 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).\n"
  },
  {
    "path": "docs/docs/funding.md",
    "content": "---\nsidebar_position: 998\ntitle: Supporting Anubis financially\n---\n\nAnubis 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.\n\nIf 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.\n\nYou can donate to the project [on Patreon](https://patreon.com/cadey) or via [GitHub Sponsors](https://github.com/sponsors/Xe).\n"
  },
  {
    "path": "docs/docs/index.mdx",
    "content": "---\nsidebar_position: 1\ntitle: Anubis\n---\n\n<img\n  width={256}\n  src=\"/img/happy.webp\"\n  alt=\"A smiling chibi dark-skinned anthro jackal with brown hair and tall ears looking victorious with a thumbs-up\"\n/>\n\n![enbyware](https://pride-badges.pony.workers.dev/static/v1?label=enbyware&labelColor=%23555&stripeWidth=8&stripeColors=FCF434%2CFFFFFF%2C9C59D1%2C2C2C2C)\n![GitHub Issues or Pull Requests by label](https://img.shields.io/github/issues/TecharoHQ/anubis)\n![GitHub go.mod Go version](https://img.shields.io/github/go-mod/go-version/TecharoHQ/anubis)\n![language count](https://img.shields.io/github/languages/count/TecharoHQ/anubis)\n![repo size](https://img.shields.io/github/repo-size/TecharoHQ/anubis)\n[![GitHub Sponsors](https://img.shields.io/github/sponsors/Xe)](https://github.com/sponsors/Xe)\n\n## Sponsors\n\nAnubis is brought to you by sponsors and donors like:\n\n### Diamond Tier\n\n<a href=\"https://www.raptorcs.com/content/base/products.html\">\n  <img\n    src=\"/img/sponsors/raptor-computing-logo.webp\"\n    alt=\"Raptor Computing Systems\"\n    height=\"64\"\n  />\n</a>\n<a href=\"https://databento.com/?utm_source=anubis&utm_medium=sponsor&utm_campaign=anubis\">\n  <img src=\"/img/sponsors/databento-logo.webp\" alt=\"Databento\" height=\"64\" />\n</a>\n\n### Gold Tier\n\n<a href=\"https://www.unipromos.com/?utm_campaign=github&utm_medium=referral&utm_content=anubis\">\n  <img src=\"/img/sponsors/unipromos.webp\" alt=\"Uvensys\" height=\"64\" />\n</a>\n<a href=\"https://uvensys.de/?utm_campaign=github&utm_medium=referral&utm_content=anubis\">\n  <img src=\"/img/sponsors/uvensys.webp\" alt=\"Uvensys\" height=\"64\" />\n</a>\n<a href=\"https://distrust.co?utm_campaign=github&utm_medium=referral&utm_content=anubis\">\n  <img src=\"/img/sponsors/distrust-logo.webp\" alt=\"Distrust\" height=\"64\" />\n</a>\n<a href=\"https://about.gitea.com?utm_campaign=github&utm_medium=referral&utm_content=anubis\">\n  <img src=\"/img/sponsors/gitea-logo.webp\" alt=\"Gitea\" height=\"64\" />\n</a>\n<a href=\"https://prolocation.net?utm_campaign=github&utm_medium=referral&utm_content=anubis\">\n  <img src=\"/img/sponsors/prolocation-logo.svg\" alt=\"Prolocation\" height=\"64\" />\n</a>\n<a href=\"https://terminaltrove.com/?utm_campaign=github&utm_medium=referral&utm_content=anubis&utm_source=abgh\">\n  <img\n    src=\"/img/sponsors/terminal-trove.webp\"\n    alt=\"Terminal Trove\"\n    height=\"64\"\n  />\n</a>\n<a href=\"https://canine.tools?utm_campaign=github&utm_medium=referral&utm_content=anubis\">\n  <img\n    src=\"/img/sponsors/caninetools-logo.webp\"\n    alt=\"canine.tools\"\n    height=\"64\"\n  />\n</a>\n<a href=\"https://weblate.org/\">\n  <img src=\"/img/sponsors/weblate-logo.webp\" alt=\"Weblate\" height=\"64\" />\n</a>\n<a href=\"https://uberspace.de/\">\n  <img src=\"/img/sponsors/uberspace-logo.webp\" alt=\"Uberspace\" height=\"64\" />\n</a>\n<a href=\"https://wildbase.xyz/\">\n  <img src=\"/img/sponsors/wildbase-logo.webp\" alt=\"Wildbase\" height=\"64\" />\n</a>\n<a href=\"https://emma.pet\">\n  <img\n    src=\"/img/sponsors/nepeat-logo.webp\"\n    alt=\"Cat eyes over the word Emma in a serif font\"\n    height=\"64\"\n  />\n</a>\n<a href=\"https://fabulous.systems/\">\n  <img\n    src=\"/img/sponsors/fabulous-systems.webp\"\n    alt=\"Cat eyes over the word Emma in a serif font\"\n    height=\"64\"\n  />\n</a>\n\n## Overview\n\nAnubis 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.\n\nThis 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.\n\nAnubis 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.\n\nIn 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.\n\n## Support\n\nIf 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.\n\nFor 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`.\n\n## Star History\n\n<a href=\"https://www.star-history.com/#TecharoHQ/anubis&Date\">\n  <picture>\n    <source\n      media=\"(prefers-color-scheme: dark)\"\n      srcSet=\"https://api.star-history.com/svg?repos=TecharoHQ/anubis&type=Date&theme=dark\"\n    />\n    <source\n      media=\"(prefers-color-scheme: light)\"\n      srcSet=\"https://api.star-history.com/svg?repos=TecharoHQ/anubis&type=Date\"\n    />\n    <img\n      alt=\"Star History Chart\"\n      src=\"https://api.star-history.com/svg?repos=TecharoHQ/anubis&type=Date\"\n    />\n  </picture>\n</a>\n\n## Packaging Status\n\n[![Packaging status](https://repology.org/badge/vertical-allrepos/anubis-anti-crawler.svg?columns=3)](https://repology.org/project/anubis-anti-crawler/versions)\n\n## Contributors\n\n<a href=\"https://github.com/TecharoHQ/anubis/graphs/contributors\">\n  <img src=\"https://contrib.rocks/image?repo=TecharoHQ/anubis\" />\n</a>\n\nMade with [contrib.rocks](https://contrib.rocks).\n"
  },
  {
    "path": "docs/docs/user/_category_.json",
    "content": "{\n  \"label\": \"User guides\",\n  \"position\": 60,\n  \"link\": {\n    \"type\": \"generated-index\",\n    \"description\": \"Information for users on sites that use Anubis.\"\n  }\n}\n"
  },
  {
    "path": "docs/docs/user/frequently-asked-questions.mdx",
    "content": "# Frequently Asked Questions\n\n## 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?\n\nA 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.\n\nIt 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.\n\nTechnically 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.\n\nIf [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.\n\n## 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?\n\nAnubis 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:\n\n1. 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.\n2. 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.\n\nIf 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.\n\n## Does Anubis mine Bitcoin?\n\nNo. Anubis does not mine Bitcoin or any other cryptocurrency.\n"
  },
  {
    "path": "docs/docs/user/known-broken-extensions.md",
    "content": "---\ntitle: List of known browser extensions that can break Anubis\n---\n\nThis 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.\n\n## [JShelter](https://jshelter.org/)\n\n| Extension    | JShelter                                                                                                                                           |\n| :----------- | :------------------------------------------------------------------------------------------------------------------------------------------------- |\n| Website      | [jshelter.org](https://jshelter.org/)                                                                                                              |\n| GitHub issue | https://github.com/TecharoHQ/anubis/issues/25                                                                                                      |\n| 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) |\n\n### Workaround steps (recommended):\n\n1. 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)).\n2. Expand JavaScript Shield settings by clicking on the `Modify` button.\n3. Click on the `Detail tweaks of JS shield for this site` button.\n4. Click and drag the `WebWorker` slider to the left until `Remove` is replaced by the `Unprotected`.\n5. Refresh the page, for example, by clicking on the `Refresh page` button at the top of the JShelter pop up window.\n6. You might want to restore the Worker settings once you go through the challenge.\n\n### Workaround steps (alternative if you do not want to dig in JShelter's pop up):\n\n1. 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)).\n2. Expand JavaScript Shield settings by clicking on the `Modify` button.\n3. Choose \"Turn JavaScript Shield off\"\n4. Refresh the page, for example, by clicking on the `Refresh page` button at the top of the JShelter pop up window.\n\n:::note\n\nTaking 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.\n\n:::\n\n### Workaround steps (alternative if you do not like JShelter's pop up):\n\n1. Open JShelter extension settings\n2. Click on JS Shield details\n3. Enter in the domain for a website protected by Anubis\n4. Choose \"Turn JavaScript Shield off\"\n5. Hit \"Add to list\"\n\n:::note\n\nTaking 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.\n\n:::\n"
  },
  {
    "path": "docs/docs/user/known-instances.md",
    "content": "---\ntitle: List of known websites using Anubis\n---\n\nThis page contains a non-exhaustive list with all websites using Anubis.\n\n- https://azurlane.koumakan.jp/\n- https://bugs.winehq.org/\n- https://bugzilla.proxmox.com\n- https://canine.tools/\n- https://clew.se/\n- https://code.hackerspace.pl/\n- https://codeberg.org/\n- https://dev.haiku-os.org\n- https://dev.sanctum.geek.nz/\n- https://ebird.org/\n- https://extensions.typo3.org/\n- https://fabulous.systems/\n- https://git.aya.so/\n- https://git.devuan.org/\n- https://git.enlightenment.org/\n- https://gitea.com/\n- https://gitlab.freedesktop.org/\n- https://gitlab.gnome.org/\n- https://gitlab.postmarketos.org/\n- https://hosted.weblate.org/\n- https://hydra.nixos.org/\n- https://lab.civicrm.org/\n- https://marginalia-search.com/\n- https://mozillazine.org/\n- https://openwrt.org/\n- https://pluralpedia.org/\n- https://reddit.nerdvpn.de/\n- https://repositorio.ufrn.br/home/\n- https://rpmfusion.org/\n- https://scioly.org/\n- https://source.puri.sm/\n- https://squirreljme.cc/\n- https://superlove.sayitditto.net/\n- https://svnweb.freebsd.org/\n- https://tumfatig.net/\n- https://wiki.archlinux.org/\n- https://wiki.freepascal.org/\n- https://wiki.koha-community.org/\n- https://www.cfaarchive.org/\n- https://www.indiemag.fr/\n- https://xeiaso.net/\n- <details>\n  <summary>archlinux32.org</summary>\n  - https://www.archlinux32.org/packages/\n  - https://bbs.archlinux32.org/\n  - https://bugs.archlinux32.org/\n  </details>\n- <details>\n  <summary>Dolphin Emulator</summary>\n  - https://forums.dolphin-emu.org/\n  - https://wiki.dolphin-emu.org/\n  </details>\n- <details>\n  <summary>Duke University</summary>\n  - https://repository.duke.edu/\n  - https://archives.lib.duke.edu/\n  - https://find.library.duke.edu/\n  - https://nicholas.duke.edu/\n  </details>\n- <details>\n  <summary>FFmpeg</summary>\n  - https://git.ffmpeg.org/\n  - https://trac.ffmpeg.org/\n  </details>\n- <details>\n  <summary>Forschungszentrum Jülich</summary>\n  - https://juser.fz-juelich.de/\n  </details>\n- <details>\n  <summary>FreeCAD</summary>\n  - https://forum.freecad.org/\n  - https://wiki.freecad.org/\n  </details>\n- <details>\n  <summary>HackLab.TO</summary>\n  - https://hacklab.to/\n  - https://knowledge.hacklab.to/\n  </details>\n- <details>\n  <summary>hebis (Alliance of Hessian Libraries)</summary>\n  - https://ubmr.hds.hebis.de/\n  - https://tufind.hds.hebis.de/\n  - https://karla.hds.hebis.de/\n  - and many more (see https://www.hebis.de/dienste/hebis-discovery-system/)\n  </details>\n- <details>\n  <summary>ReactOS</summary>\n  - https://reactos.org/forum\n  - https://reactos.org/wiki\n  - https://git.reactos.org\n  </details>\n- <details>\n  <summary>ScummVM</summary>\n  - https://bugs.scummvm.org/\n  - https://forums.scummvm.org/\n  - https://wiki.scummvm.org/\n  </details>\n- <details>\n  <summary>Slackware</summary>\n  - https://git.slackware.nl/\n  - https://git.liveslak.org/\n  </details>\n- <details>\n  <summary>Sourceware</summary>\n  - https://sourceware.org/cgit\n  - https://sourceware.org/glibc/wiki\n  - https://builder.sourceware.org/testruns/\n  - https://patchwork.sourceware.org/\n  - https://gcc.gnu.org/bugzilla/\n  - https://gcc.gnu.org/cgit\n  </details>\n- <details>\n  <summary>The Linux Foundation</summary>\n  - https://git.kernel.org/\n  - https://lore.kernel.org/\n  </details>\n- <details>\n  <summary>Valve Corporation</summary>\n  - https://developer.valvesoftware.com/wiki/Main_Page\n  - https://wiki.teamfortress.com/wiki/Main_Page\n  </details>\n"
  },
  {
    "path": "docs/docs/user/why-see-challenge.md",
    "content": "---\ntitle: Why is Anubis showing up on a website?\n---\n\nYou 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.\n\nAnubis 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.\n\nUltimately, 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.\n"
  },
  {
    "path": "docs/docusaurus.config.ts",
    "content": "import { themes as prismThemes } from \"prism-react-renderer\";\nimport type { Config } from \"@docusaurus/types\";\nimport type * as Preset from \"@docusaurus/preset-classic\";\n\n// This runs in Node.js - Don't use client-side code here (browser APIs, JSX...)\n\nconst config: Config = {\n  title: \"Anubis\",\n  tagline: \"Weigh the soul of incoming HTTP requests to protect your website!\",\n  favicon: \"img/favicon.ico\",\n\n  // Set the production url of your site here\n  url: \"https://anubis.techaro.lol\",\n  // Set the /<baseUrl>/ pathname under which your site is served\n  // For GitHub pages deployment, it is often '/<projectName>/'\n  baseUrl: \"/\",\n\n  // GitHub pages deployment config.\n  // If you aren't using GitHub pages, you don't need these.\n  organizationName: \"TecharoHQ\", // Usually your GitHub org/user name.\n  projectName: \"anubis\", // Usually your repo name.\n\n  onBrokenLinks: \"throw\",\n  onBrokenMarkdownLinks: \"warn\",\n\n  // Even if you don't use internationalization, you can use this field to set\n  // useful metadata like html lang. For example, if your site is Chinese, you\n  // may want to replace \"en\" with \"zh-Hans\".\n  i18n: {\n    defaultLocale: \"en\",\n    locales: [\"en\"],\n  },\n\n  markdown: {\n    mermaid: true,\n  },\n  themes: [\"@docusaurus/theme-mermaid\"],\n\n  presets: [\n    [\n      \"classic\",\n      {\n        blog: {\n          showReadingTime: true,\n          feedOptions: {\n            type: [\"rss\", \"atom\", \"json\"],\n            xslt: true,\n          },\n          editUrl: \"https://github.com/TecharoHQ/anubis/tree/main/docs/\",\n          onInlineTags: \"warn\",\n          onInlineAuthors: \"warn\",\n          onUntruncatedBlogPosts: \"throw\",\n        },\n        docs: {\n          sidebarPath: \"./sidebars.ts\",\n          editUrl: \"https://github.com/TecharoHQ/anubis/tree/main/docs/\",\n        },\n        theme: {\n          customCss: \"./src/css/custom.css\",\n        },\n      } satisfies Preset.Options,\n    ],\n  ],\n\n  themeConfig: {\n    colorMode: {\n      respectPrefersColorScheme: true,\n    },\n    // Replace with your project's social card\n    image: \"img/social-card.jpg\",\n    navbar: {\n      title: \"Anubis\",\n      logo: {\n        alt: \"A happy jackal woman with brown hair and red eyes\",\n        src: \"img/favicon.webp\",\n      },\n      items: [\n        { to: \"/blog\", label: \"Blog\", position: \"left\" },\n        {\n          type: \"docSidebar\",\n          sidebarId: \"tutorialSidebar\",\n          position: \"left\",\n          label: \"Docs\",\n        },\n        {\n          to: \"/docs/admin/botstopper\",\n          label: \"Unbranded Version\",\n          position: \"left\",\n        },\n        {\n          href: \"https://github.com/TecharoHQ/anubis\",\n          label: \"GitHub\",\n          position: \"right\",\n        },\n        {\n          href: \"https://github.com/sponsors/Xe\",\n          label: \"Sponsor the Project\",\n          position: \"right\",\n        },\n      ],\n    },\n    footer: {\n      style: \"dark\",\n      links: [\n        {\n          title: \"Docs\",\n          items: [\n            {\n              label: \"Intro\",\n              to: \"/docs/\",\n            },\n            {\n              label: \"Installation\",\n              to: \"/docs/admin/installation\",\n            },\n          ],\n        },\n        {\n          title: \"Community\",\n          items: [\n            {\n              label: \"GitHub Discussions\",\n              href: \"https://github.com/TecharoHQ/anubis/discussions\",\n            },\n            {\n              label: \"Bluesky\",\n              href: \"https://bsky.app/profile/techaro.lol\",\n            },\n          ],\n        },\n        {\n          title: \"More\",\n          items: [\n            {\n              label: \"Blog\",\n              to: \"/blog\",\n            },\n            {\n              label: \"GitHub\",\n              href: \"https://github.com/TecharoHQ/anubis\",\n            },\n            {\n              label: \"Status\",\n              href: \"https://techarohq.github.io/status/\",\n            },\n          ],\n        },\n      ],\n      copyright: `Copyright © ${new Date().getFullYear()} Techaro. Made with ❤️ in 🇨🇦.`,\n    },\n    prism: {\n      theme: prismThemes.github,\n      darkTheme: prismThemes.dracula,\n      magicComments: [\n        {\n          className: \"code-block-diff-add-line\",\n          line: \"diff-add\",\n        },\n        {\n          className: \"code-block-diff-remove-line\",\n          line: \"diff-remove\",\n        },\n      ],\n    },\n  } satisfies Preset.ThemeConfig,\n};\n\nexport default config;\n"
  },
  {
    "path": "docs/fly.toml",
    "content": "app = 'anubis-docs'\nprimary_region = 'yyz'\n\n[build]\n  image = \"ghcr.io/techarohq/anubis/docs:main\"\n\n[http_service]\n  internal_port = 80\n  force_https = true\n  auto_stop_machines = true\n  auto_start_machines = true\n  min_machines_running = 0\n  processes = ['app']\n\n[[vm]]\n  cpu_kind = 'shared'\n  cpus = 1\n  memory_mb = 256\n\n"
  },
  {
    "path": "docs/manifest/1password.yaml",
    "content": "apiVersion: onepassword.com/v1\nkind: OnePasswordItem\nmetadata:\n  name: anubis-docs-thoth\nspec:\n  itemPath: \"vaults/lc5zo4zjz3if3mkeuhufjmgmui/items/pwguumqcmtxvqbeb7y4gj7l36i\"\n"
  },
  {
    "path": "docs/manifest/cfg/anubis/botPolicies.yaml",
    "content": "## Anubis has the ability to let you import snippets of configuration into the main\n## configuration file. This allows you to break up your config into smaller parts\n## that get logically assembled into one big file.\n##\n## Of note, a bot rule can either have inline bot configuration or import a\n## bot config snippet. You cannot do both in a single bot rule.\n##\n## Import paths can either be prefixed with (data) to import from the common/shared\n## rules in the data folder in the Anubis source tree or will point to absolute/relative\n## paths in your filesystem. If you don't have access to the Anubis source tree, check\n## /usr/share/docs/anubis/data or in the tarball you extracted Anubis from.\n\nbots:\n  - import: (data)/crawlers/commoncrawl.yaml\n  # Pathological bots to deny\n  - # This correlates to data/bots/deny-pathological.yaml in the source tree\n    # https://github.com/TecharoHQ/anubis/blob/main/data/bots/deny-pathological.yaml\n    import: (data)/bots/_deny-pathological.yaml\n  - import: (data)/bots/aggressive-brazilian-scrapers.yaml\n\n  # Aggressively block AI/LLM related bots/agents by default\n  - import: (data)/meta/ai-block-aggressive.yaml\n\n  # Consider replacing the aggressive AI policy with more selective policies:\n  # - import: (data)/meta/ai-block-moderate.yaml\n  # - import: (data)/meta/ai-block-permissive.yaml\n\n  # Search engine crawlers to allow, defaults to:\n  #   - Google (so they don't try to bypass Anubis)\n  #   - Apple\n  #   - Bing\n  #   - DuckDuckGo\n  #   - Qwant\n  #   - The Internet Archive\n  #   - Kagi\n  #   - Marginalia\n  #   - Mojeek\n  - import: (data)/crawlers/_allow-good.yaml\n  # Challenge Firefox AI previews\n  - import: (data)/clients/x-firefox-ai.yaml\n\n  # Allow common \"keeping the internet working\" routes (well-known, favicon, robots.txt)\n  - import: (data)/common/keep-internet-working.yaml\n\n  # # Punish any bot with \"bot\" in the user-agent string\n  # # This is known to have a high false-positive rate, use at your own risk\n  # - name: generic-bot-catchall\n  #   user_agent_regex: (?i:bot|crawler)\n  #   action: CHALLENGE\n  #   challenge:\n  #     difficulty: 16  # impossible\n  #     algorithm: slow # intentionally waste CPU cycles and time\n\n  - name: rss-feed-blog\n    action: ALLOW\n    expression:\n      any:\n        - path.startsWith(\"/blog/atom.\")\n        - path.startsWith(\"/blog/rss.\")\n\n  # Generic catchall rule\n  - name: base-weight\n    expression: \"true\"\n    action: WEIGH\n    weight:\n      adjust: 10\n\n  - name: http2-client-protocol\n    expression:\n      all:\n        - '\"X-Http-Protocol\" in headers'\n        - headers[\"X-Http-Protocol\"] == \"HTTP/2.0\"\n    action: WEIGH\n    weight:\n      adjust: -5\n\n# The weight thresholds for when to trigger individual challenges. Any\n# CHALLENGE will take precedence over this.\n#\n# A threshold has four configuration options:\n#\n#   - name: the name that is reported down the stack and used for metrics\n#   - expression: A CEL expression with the request weight in the variable\n#     weight\n#   - action: the Anubis action to apply, similar to in a bot policy\n#   - challenge: which challenge to send to the user, similar to in a bot policy\n#\n# See https://anubis.techaro.lol/docs/admin/configuration/thresholds for more\n# information.\nthresholds:\n  # By default Anubis ships with the following thresholds:\n  - name: minimal-suspicion # This client is likely fine, its soul is lighter than a feather\n    expression: weight <= 0 # a feather weighs zero units\n    action: ALLOW # Allow the traffic through\n  # For clients that had some weight reduced through custom rules, give them a\n  # lightweight challenge.\n  - name: mild-suspicion\n    expression:\n      all:\n        - weight > 0\n        - weight < 10\n    action: CHALLENGE\n    challenge:\n      # https://anubis.techaro.lol/docs/admin/configuration/challenges/metarefresh\n      algorithm: metarefresh\n      difficulty: 1\n  # For clients that are browser-like but have either gained points from custom rules or\n  # report as a standard browser.\n  - name: moderate-suspicion\n    expression:\n      all:\n        - weight >= 10\n        - weight < 20\n    action: CHALLENGE\n    challenge:\n      # https://anubis.techaro.lol/docs/admin/configuration/challenges/preact\n      #\n      # This challenge proves the client can run a webapp written with Preact.\n      # The preact webapp simply loads, calculates the SHA-256 checksum of the\n      # challenge data, and forwards that to the client.\n      algorithm: preact\n      difficulty: 1\n  - name: mild-proof-of-work\n    expression:\n      all:\n        - weight >= 20\n        - weight < 30\n    action: CHALLENGE\n    challenge:\n      # https://anubis.techaro.lol/docs/admin/configuration/challenges/proof-of-work\n      algorithm: fast\n      difficulty: 2 # two leading zeros, very fast for most clients\n  # For clients that are browser like and have gained many points from custom rules\n  - name: extreme-suspicion\n    expression: weight >= 30\n    action: CHALLENGE\n    challenge:\n      # https://anubis.techaro.lol/docs/admin/configuration/challenges/proof-of-work\n      algorithm: fast\n      difficulty: 4\n\ndnsbl: false\n\nimpressum:\n  footer: |\n    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.\n\n  page:\n    title: Privacy Policy\n    body: |\n      <p>Last updated: June 2025</p>\n\n      <h2>Information that is gathered from visitors</h2>\n\n      <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>\n\n      <p>Cookies may be used to remember visitor preferences when interacting with the website.</p>\n\n      <p>Where registration is required, the visitor's email and a username will be stored on the server.</p>\n\n      <h2>How the Information is used</h2>\n\n      <p>The information is used to enhance the visitor's experience when using the website to display personalised content and possibly advertising.</p>\n\n      <p>E-mail addresses will not be sold, rented or leased to 3rd parties.</p>\n\n      <p>E-mail may be sent to inform you of news of our services or offers by us or our affiliates.</p>\n\n      <h2>Visitor Options</h2>\n\n      <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>\n\n      <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>\n\n      <h2>Cookies</h2>\n\n      <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>\n\n      <p>3rd party advertising companies may also use cookies for tracking purposes.</p>\n\n      <h2>Techaro Anubis</h2>\n\n      <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>\n\n      <ul>\n        <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>\n        <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>\n        <li>HTTP request metadata that may include things such as the User-Agent header and other identifiers.</li>\n      </ul>\n\n      <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>\n\n# By default, send HTTP 200 back to clients that either get issued a challenge\n# or a denial. This seems weird, but this is load-bearing due to the fact that\n# the most aggressive scraper bots seem to really, really, want an HTTP 200 and\n# will stop sending requests once they get it.\nstatus_codes:\n  CHALLENGE: 200\n  DENY: 200\n\nstore:\n  backend: bbolt\n  parameters:\n    path: /xe/data/anubis/data.bdb\n"
  },
  {
    "path": "docs/manifest/cfg/nginx/mime.types",
    "content": "\ntypes {\n  text/html html htm shtml;\n  text/css css;\n  text/xml xml;\n  image/gif gif;\n  image/jpeg jpeg jpg;\n  application/javascript js;\n  application/atom+xml atom;\n  application/rss+xml rss;\n\n  text/mathml mml;\n  text/plain txt;\n  text/vnd.sun.j2me.app-descriptor jad;\n  text/vnd.wap.wml wml;\n  text/x-component htc;\n\n  image/avif avif;\n  image/png png;\n  image/svg+xml svg svgz;\n  image/tiff tif tiff;\n  image/vnd.wap.wbmp wbmp;\n  image/webp webp;\n  image/x-icon ico;\n  image/x-jng jng;\n  image/x-ms-bmp bmp;\n\n  font/woff woff;\n  font/woff2 woff2;\n\n  application/java-archive jar war ear;\n  application/json json;\n  application/mac-binhex40 hqx;\n  application/msword doc;\n  application/pdf pdf;\n  application/postscript ps eps ai;\n  application/rtf rtf;\n  application/vnd.apple.mpegurl m3u8;\n  application/vnd.google-earth.kml+xml kml;\n  application/vnd.google-earth.kmz kmz;\n  application/vnd.ms-excel xls;\n  application/vnd.ms-fontobject eot;\n  application/vnd.ms-powerpoint ppt;\n  application/vnd.oasis.opendocument.graphics odg;\n  application/vnd.oasis.opendocument.presentation odp;\n  application/vnd.oasis.opendocument.spreadsheet ods;\n  application/vnd.oasis.opendocument.text odt;\n  application/vnd.openxmlformats-officedocument.presentationml.presentation\n  pptx;\n  application/vnd.openxmlformats-officedocument.spreadsheetml.sheet\n  xlsx;\n  application/vnd.openxmlformats-officedocument.wordprocessingml.document\n  docx;\n  application/vnd.wap.wmlc wmlc;\n  application/wasm wasm;\n  application/x-7z-compressed 7z;\n  application/x-cocoa cco;\n  application/x-java-archive-diff jardiff;\n  application/x-java-jnlp-file jnlp;\n  application/x-makeself run;\n  application/x-perl pl pm;\n  application/x-pilot prc pdb;\n  application/x-rar-compressed rar;\n  application/x-redhat-package-manager rpm;\n  application/x-sea sea;\n  application/x-shockwave-flash swf;\n  application/x-stuffit sit;\n  application/x-tcl tcl tk;\n  application/x-x509-ca-cert der pem crt;\n  application/x-xpinstall xpi;\n  application/xhtml+xml xhtml;\n  application/xspf+xml xspf;\n  application/zip zip;\n\n  application/octet-stream bin exe dll;\n  application/octet-stream deb;\n  application/octet-stream dmg;\n  application/octet-stream iso img;\n  application/octet-stream msi msp msm;\n\n  audio/midi mid midi kar;\n  audio/mpeg mp3;\n  audio/ogg ogg;\n  audio/x-m4a m4a;\n  audio/x-realaudio ra;\n\n  video/3gpp 3gpp 3gp;\n  video/mp2t ts;\n  video/mp4 mp4;\n  video/mpeg mpeg mpg;\n  video/quicktime mov;\n  video/webm webm;\n  video/x-flv flv;\n  video/x-m4v m4v;\n  video/x-mng mng;\n  video/x-ms-asf asx asf;\n  video/x-ms-wmv wmv;\n  video/x-msvideo avi;\n}\n"
  },
  {
    "path": "docs/manifest/cfg/nginx/nginx.conf",
    "content": "user nginx;\nworker_processes 2;\nerror_log /dev/stdout warn;\npid /nginx.pid;\n\nevents {\n  worker_connections 1024;\n}\n\nhttp {\n  include mime.types;\n  default_type application/octet-stream;\n  access_log /dev/stdout;\n\n  sendfile on;\n  keepalive_timeout 65;\n\n  server {\n    listen 80 default_server;\n    server_name _;\n\n    error_page 404 /404.html;\n\n    root /www;\n    index index.html;\n\n    location / {\n      try_files $uri $uri/ =404;\n    }\n  }\n}"
  },
  {
    "path": "docs/manifest/deployment.yaml",
    "content": "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: anubis-docs\nspec:\n  selector:\n    matchLabels:\n      app: anubis-docs\n  template:\n    metadata:\n      labels:\n        app: anubis-docs\n    spec:\n      volumes:\n        - name: anubis\n          configMap:\n            name: anubis-cfg\n        - name: nginx\n          configMap:\n            name: nginx-cfg\n        - name: temporary-data\n          emptyDir: {}\n      containers:\n        - name: anubis-docs\n          image: ghcr.io/techarohq/anubis/docs:main\n          imagePullPolicy: Always\n          resources:\n            limits:\n              memory: \"128Mi\"\n              cpu: \"500m\"\n            requests:\n              cpu: 250m\n              memory: 128Mi\n          volumeMounts:\n            - name: nginx\n              mountPath: /conf\n          ports:\n            - containerPort: 80\n          readinessProbe:\n            httpGet:\n              path: /\n              port: 80\n            initialDelaySeconds: 1\n            periodSeconds: 10\n          livenessProbe:\n            httpGet:\n              path: /\n              port: 80\n            initialDelaySeconds: 10\n            periodSeconds: 20\n        - name: anubis\n          image: ghcr.io/techarohq/anubis:main\n          imagePullPolicy: Always\n          env:\n            - name: \"BIND\"\n              value: \":8081\"\n            - name: \"DIFFICULTY\"\n              value: \"4\"\n            - name: \"METRICS_BIND\"\n              value: \":9090\"\n            - name: \"OG_PASSTHROUGH\"\n              value: \"true\"\n            - name: \"POLICY_FNAME\"\n              value: \"/xe/cfg/anubis/botPolicies.yaml\"\n            - name: \"SERVE_ROBOTS_TXT\"\n              value: \"false\"\n            - name: \"TARGET\"\n              value: \"http://localhost:80\"\n            # - name: \"SLOG_LEVEL\"\n            #   value: \"debug\"\n          volumeMounts:\n            - name: anubis\n              mountPath: /xe/cfg/anubis\n            - name: temporary-data\n              mountPath: /xe/data/anubis\n          resources:\n            limits:\n              cpu: 500m\n              memory: 128Mi\n            requests:\n              cpu: 250m\n              memory: 128Mi\n          securityContext:\n            runAsUser: 1000\n            runAsGroup: 1000\n            runAsNonRoot: true\n            allowPrivilegeEscalation: false\n            capabilities:\n              drop:\n                - ALL\n            seccompProfile:\n              type: RuntimeDefault\n          envFrom:\n            - secretRef:\n                name: anubis-docs-thoth\n          readinessProbe:\n            httpGet:\n              path: /healthz\n              port: 9090\n            initialDelaySeconds: 1\n            periodSeconds: 10\n          livenessProbe:\n            httpGet:\n              path: /healthz\n              port: 9090\n            initialDelaySeconds: 10\n            periodSeconds: 20\n"
  },
  {
    "path": "docs/manifest/ingress.yaml",
    "content": "apiVersion: networking.k8s.io/v1\nkind: Ingress\nmetadata:\n  name: anubis-docs\n  annotations:\n    cert-manager.io/cluster-issuer: \"letsencrypt-prod\"\n    nginx.ingress.kubernetes.io/limit-rps: \"10\"\nspec:\n  ingressClassName: nginx\n  tls:\n    - hosts:\n        - anubis.techaro.lol\n      secretName: anubis-techaro-lol-public-tls\n  rules:\n    - host: anubis.techaro.lol\n      http:\n        paths:\n          - pathType: Prefix\n            path: \"/\"\n            backend:\n              service:\n                name: anubis-docs\n                port:\n                  name: anubis\n"
  },
  {
    "path": "docs/manifest/kustomization.yaml",
    "content": "resources:\n  - 1password.yaml\n  - deployment.yaml\n  - ingress.yaml\n  - onionservice.yaml\n  - poddisruptionbudget.yaml\n  - service.yaml\n\nconfigMapGenerator:\n  - name: anubis-cfg\n    behavior: create\n    files:\n      - ./cfg/anubis/botPolicies.yaml\n  - name: nginx-cfg\n    behavior: create\n    files:\n      - ./cfg/nginx/mime.types\n      - ./cfg/nginx/nginx.conf\n"
  },
  {
    "path": "docs/manifest/onionservice.yaml",
    "content": "apiVersion: tor.k8s.torproject.org/v1alpha2\nkind: OnionService\nmetadata:\n  name: anubis-docs\nspec:\n  version: 3\n  rules:\n    - port:\n        number: 80\n      backend:\n        service:\n          name: anubis-docs\n          port:\n            number: 80\n"
  },
  {
    "path": "docs/manifest/poddisruptionbudget.yaml",
    "content": "apiVersion: policy/v1\nkind: PodDisruptionBudget\nmetadata:\n  name: anubis-docs\nspec:\n  minAvailable: 1\n  selector:\n    matchLabels:\n      app: anubis-docs\n"
  },
  {
    "path": "docs/manifest/service.yaml",
    "content": "apiVersion: v1\nkind: Service\nmetadata:\n  name: anubis-docs\nspec:\n  selector:\n    app: anubis-docs\n  ports:\n    - port: 80\n      targetPort: 80\n      name: http\n    - port: 8081\n      targetPort: 8081\n      name: anubis\n"
  },
  {
    "path": "docs/package.json",
    "content": "{\n  \"name\": \"docs\",\n  \"version\": \"0.0.0\",\n  \"private\": true,\n  \"scripts\": {\n    \"docusaurus\": \"docusaurus\",\n    \"start\": \"docusaurus start --host 0.0.0.0\",\n    \"build\": \"docusaurus build\",\n    \"swizzle\": \"docusaurus swizzle\",\n    \"deploy\": \"echo 'use CI' && exit 1\",\n    \"clear\": \"docusaurus clear\",\n    \"serve\": \"docusaurus serve\",\n    \"write-translations\": \"docusaurus write-translations\",\n    \"write-heading-ids\": \"docusaurus write-heading-ids\",\n    \"typecheck\": \"tsc\"\n  },\n  \"dependencies\": {\n    \"@docusaurus/core\": \"^3.8.1\",\n    \"@docusaurus/preset-classic\": \"^3.8.1\",\n    \"@docusaurus/theme-mermaid\": \"^3.8.1\",\n    \"@mdx-js/react\": \"^3.0.0\",\n    \"clsx\": \"^2.0.0\",\n    \"prism-react-renderer\": \"^2.3.0\",\n    \"raw-loader\": \"^4.0.2\",\n    \"react\": \"^19.0.0\",\n    \"react-dom\": \"^19.0.0\"\n  },\n  \"devDependencies\": {\n    \"@docusaurus/module-type-aliases\": \"^3.0.1\",\n    \"@docusaurus/tsconfig\": \"^3.8.1\",\n    \"@docusaurus/types\": \"^3.8.1\",\n    \"typescript\": \"~5.6.2\"\n  },\n  \"browserslist\": {\n    \"production\": [\n      \">0.5%\",\n      \"not dead\",\n      \"not op_mini all\"\n    ],\n    \"development\": [\n      \"last 3 chrome version\",\n      \"last 3 firefox version\",\n      \"last 5 safari version\"\n    ]\n  },\n  \"engines\": {\n    \"node\": \">=18.0\"\n  }\n}\n"
  },
  {
    "path": "docs/sidebars.ts",
    "content": "import type { SidebarsConfig } from \"@docusaurus/plugin-content-docs\";\n\n// This runs in Node.js - Don't use client-side code here (browser APIs, JSX...)\n\n/**\n * Creating a sidebar enables you to:\n - create an ordered group of docs\n - render a sidebar for each doc of that group\n - provide next/previous navigation\n\n The sidebars can be generated from the filesystem, or explicitly defined here.\n\n Create as many sidebars as you want.\n */\nconst sidebars: SidebarsConfig = {\n  // By default, Docusaurus generates a sidebar from the docs folder structure\n  tutorialSidebar: [{ type: \"autogenerated\", dirName: \".\" }],\n\n  // But you can create a sidebar manually\n  /*\n  tutorialSidebar: [\n    'intro',\n    'hello',\n    {\n      type: 'category',\n      label: 'Tutorial',\n      items: ['tutorial-basics/create-a-document'],\n    },\n  ],\n   */\n};\n\nexport default sidebars;\n"
  },
  {
    "path": "docs/src/components/EnterpriseOnly/index.jsx",
    "content": "import styles from \"./styles.module.css\";\n\nexport default function EnterpriseOnly({ link }) {\n  return (\n    <a className={styles.link} href={link}>\n      <div className={styles.container}>\n        <span className={styles.label}>BotStopper Only</span>\n      </div>\n    </a>\n  );\n}\n"
  },
  {
    "path": "docs/src/components/EnterpriseOnly/styles.module.css",
    "content": ".link {\n  text-decoration: none;\n}\n\n.container {\n  background-color: #16a34a; /* green-500 */\n  color: #ffffff;\n  font-weight: 700;\n  padding: 0.5rem 1rem; /* py-2 px-4 */\n  border-radius: 9999px; /* rounded-full */\n  box-shadow:\n    0 10px 15px -3px rgba(0, 0, 0, 0.1),\n    0 4px 6px -2px rgba(0, 0, 0, 0.05); /* shadow-lg approximation */\n  display: inline-flex; /* flex */\n  align-items: center; /* items-center */\n}\n\n.label {\n  line-height: 1;\n}\n"
  },
  {
    "path": "docs/src/components/HomepageFeatures/index.tsx",
    "content": "import type { ReactNode } from \"react\";\nimport clsx from \"clsx\";\nimport Heading from \"@theme/Heading\";\nimport styles from \"./styles.module.css\";\n\ntype FeatureItem = {\n  title: string;\n  imageURL: string;\n  description: ReactNode;\n};\n\nconst FeatureList: FeatureItem[] = [\n  {\n    title: \"Easy to Use\",\n    imageURL: require(\"@site/static/img/anubis/happy.webp\").default,\n    description: (\n      <>\n        Anubis sits in the background and weighs the risk of incoming requests.\n        If it asks a client to complete a challenge, no user interaction is\n        required.\n      </>\n    ),\n  },\n  {\n    title: \"Lightweight\",\n    imageURL: require(\"@site/static/img/anubis/pensive.webp\").default,\n    description: (\n      <>\n        Anubis is so lightweight you'll forget it's there until you look at your\n        hosting bill. On average it uses less than 128 MB of ram.\n      </>\n    ),\n  },\n  {\n    title: \"Block the scrapers\",\n    imageURL: require(\"@site/static/img/anubis/reject.webp\").default,\n    description: (\n      <>\n        Anubis uses a combination of heuristics to identify and block bots\n        before they take your website down. You can customize the rules with{\" \"}\n        <a href=\"/docs/admin/policies\">your own policies</a>.\n      </>\n    ),\n  },\n];\n\nfunction Feature({ title, description, imageURL }: FeatureItem) {\n  return (\n    <div className={clsx(\"col col--4\")}>\n      <div className=\"text--center\">\n        <img src={imageURL} className={styles.featureSvg} role=\"img\" />\n      </div>\n      <div className=\"text--center padding-horiz--md\">\n        <Heading as=\"h3\">{title}</Heading>\n        <p>{description}</p>\n      </div>\n    </div>\n  );\n}\n\nexport default function HomepageFeatures(): ReactNode {\n  return (\n    <section className={styles.features}>\n      <div className=\"container\">\n        <div className=\"row\">\n          {FeatureList.map((props, idx) => (\n            <Feature key={idx} {...props} />\n          ))}\n        </div>\n      </div>\n    </section>\n  );\n}\n"
  },
  {
    "path": "docs/src/components/HomepageFeatures/styles.module.css",
    "content": ".features {\n  display: flex;\n  align-items: center;\n  padding: 2rem 0;\n  width: 100%;\n}\n\n.featureSvg {\n  height: 200px;\n  width: 200px;\n}\n"
  },
  {
    "path": "docs/src/components/RandomKey/index.tsx",
    "content": "import { useState, useCallback } from \"react\";\nimport Code from \"@theme/CodeInline\";\nimport BrowserOnly from \"@docusaurus/BrowserOnly\";\n\n// https://www.xaymar.com/articles/2020/12/08/fastest-uint8array-to-hex-string-conversion-in-javascript/\nfunction toHex(buffer) {\n  return Array.prototype.map\n    .call(buffer, (x) => (\"00\" + x.toString(16)).slice(-2))\n    .join(\"\");\n}\n\nexport const genRandomKey = (): String => {\n  const array = new Uint8Array(32);\n  self.crypto.getRandomValues(array);\n  return toHex(array);\n};\n\nexport default function RandomKey() {\n  return (\n    <BrowserOnly fallback={<div>Loading...</div>}>\n      {() => {\n        const [key, setKey] = useState<String>(genRandomKey());\n        const genRandomKeyCb = useCallback(() => {\n          setKey(genRandomKey());\n        });\n        return (\n          <span>\n            <Code>{key}</Code>\n            <span style={{ marginLeft: \"0.25rem\", marginRight: \"0.25rem\" }} />\n            <button\n              onClick={() => {\n                genRandomKeyCb();\n              }}\n            >\n              ♻️\n            </button>\n          </span>\n        );\n      }}\n    </BrowserOnly>\n  );\n}\n"
  },
  {
    "path": "docs/src/css/custom.css",
    "content": "/**\n * Any CSS included here will be global. The classic template\n * bundles Infima by default. Infima is a CSS framework designed to\n * work well for content-centric websites.\n */\n\n/* You can override the default Infima variables here. */\n:root {\n  --ifm-color-primary: #ff5630;\n  --ifm-color-primary-dark: #ad422a;\n  --ifm-color-primary-darker: #8f3521;\n  --ifm-color-primary-darkest: #592115;\n  --ifm-color-primary-light: #ff7152;\n  --ifm-color-primary-lighter: #ff9178;\n  --ifm-color-primary-lightest: #ffb09e;\n  --ifm-code-font-size: 95%;\n  --docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.1);\n  --code-block-diff-add-line-color: #ccffd8;\n  --code-block-diff-remove-line-color: #ffebe9;\n}\n\n/* For readability concerns, you should choose a lighter palette in dark mode. */\n[data-theme=\"dark\"] {\n  --ifm-color-primary: #e64a19;\n  --ifm-color-primary-dark: #b73a12;\n  --ifm-color-primary-darker: #8c2c0e;\n  --ifm-color-primary-darkest: #5a1e0a;\n  --ifm-color-primary-light: #eb6d45;\n  --ifm-color-primary-lighter: #f09178;\n  --ifm-color-primary-lightest: #f5b5a6;\n  --ifm-code-font-size: 95%;\n  --docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.25);\n  --code-block-diff-add-line-color: #2d5a2c;\n  --code-block-diff-remove-line-color: #5a2d2c;\n}\n\n.code-block-diff-add-line {\n  background-color: var(--code-block-diff-add-line-color);\n  display: block;\n  margin: 0 -40px;\n  padding: 0 40px;\n}\n\n.code-block-diff-add-line::before {\n  position: absolute;\n  left: 8px;\n  padding-right: 8px;\n  content: \"+\";\n}\n\n.code-block-diff-remove-line {\n  background-color: var(--code-block-diff-remove-line-color);\n  display: block;\n  margin: 0 -40px;\n  padding: 0 40px;\n}\n\n.code-block-diff-remove-line::before {\n  position: absolute;\n  left: 8px;\n  padding-right: 8px;\n  content: \"-\";\n}\n\n/**\n * use magic comments to mark diff blocks\n */\npre code:has(.code-block-diff-add-line) {\n  padding-left: 40px !important;\n}\n\npre code:has(.code-block-diff-remove-line) {\n  padding-left: 40px !important;\n}\n"
  },
  {
    "path": "docs/src/pages/index.module.css",
    "content": "/**\n * CSS files with the .module.css suffix will be treated as CSS modules\n * and scoped locally.\n */\n\n.heroBanner {\n  padding: 4rem 0;\n  text-align: center;\n  position: relative;\n  overflow: hidden;\n}\n\n@media screen and (max-width: 996px) {\n  .heroBanner {\n    padding: 2rem;\n  }\n}\n\n.buttons {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n}\n"
  },
  {
    "path": "docs/src/pages/index.tsx",
    "content": "import type { ReactNode } from \"react\";\nimport clsx from \"clsx\";\nimport Link from \"@docusaurus/Link\";\nimport useDocusaurusContext from \"@docusaurus/useDocusaurusContext\";\nimport Layout from \"@theme/Layout\";\nimport HomepageFeatures from \"@site/src/components/HomepageFeatures\";\nimport Heading from \"@theme/Heading\";\n\nimport styles from \"./index.module.css\";\n\nfunction HomepageHeader() {\n  const { siteConfig } = useDocusaurusContext();\n  return (\n    <header className={clsx(\"hero hero--primary\", styles.heroBanner)}>\n      <div className=\"container\">\n        <Heading as=\"h1\" className=\"hero__title\">\n          {siteConfig.title}\n        </Heading>\n        <p className=\"hero__subtitle\">{siteConfig.tagline}</p>\n        <div className={styles.buttons}>\n          <Link\n            className=\"button button--secondary button--lg\"\n            to=\"/docs/category/environments\"\n          >\n            Get started\n          </Link>\n        </div>\n      </div>\n    </header>\n  );\n}\n\nexport default function Home(): ReactNode {\n  const { siteConfig } = useDocusaurusContext();\n  return (\n    <Layout\n      title={`Anubis: Web AI Firewall Utility`}\n      description=\"Weigh the soul of incoming HTTP requests to protect your website!\"\n    >\n      <HomepageHeader />\n      <main>\n        <HomepageFeatures />\n      </main>\n    </Layout>\n  );\n}\n"
  },
  {
    "path": "docs/static/.nojekyll",
    "content": ""
  },
  {
    "path": "docs/tsconfig.json",
    "content": "{\n  // This file is not used in compilation. It is here just for a nice editor experience.\n  \"extends\": \"@docusaurus/tsconfig\",\n  \"compilerOptions\": {\n    \"baseUrl\": \".\"\n  },\n  \"exclude\": [\".docusaurus\", \"build\"]\n}\n"
  },
  {
    "path": "go.mod",
    "content": "module github.com/TecharoHQ/anubis\n\ngo 1.24.2\n\nrequire (\n\tgithub.com/TecharoHQ/thoth-proto v0.5.0\n\tgithub.com/a-h/templ v0.3.960\n\tgithub.com/aws/aws-sdk-go-v2 v1.41.0\n\tgithub.com/aws/aws-sdk-go-v2/config v1.32.5\n\tgithub.com/aws/aws-sdk-go-v2/service/s3 v1.93.2\n\tgithub.com/cespare/xxhash/v2 v2.3.0\n\tgithub.com/facebookgo/flagenv v0.0.0-20160425205200-fcd59fca7456\n\tgithub.com/fahedouch/go-logrotate v0.3.0\n\tgithub.com/gaissmai/bart v0.26.0\n\tgithub.com/golang-jwt/jwt/v5 v5.3.0\n\tgithub.com/google/cel-go v0.26.1\n\tgithub.com/google/uuid v1.6.0\n\tgithub.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0\n\tgithub.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3\n\tgithub.com/joho/godotenv v1.5.1\n\tgithub.com/lum8rjack/go-ja4h v0.0.0-20250828030157-fa5266d50650\n\tgithub.com/nicksnyder/go-i18n/v2 v2.6.0\n\tgithub.com/nikandfor/spintax v0.0.0-20181023094358-fc346b245bb3\n\tgithub.com/playwright-community/playwright-go v0.5200.1\n\tgithub.com/prometheus/client_golang v1.23.2\n\tgithub.com/redis/go-redis/v9 v9.17.2\n\tgithub.com/sebest/xff v0.0.0-20210106013422-671bd2870b3a\n\tgithub.com/shirou/gopsutil/v4 v4.25.11\n\tgithub.com/testcontainers/testcontainers-go v0.40.0\n\tgo.etcd.io/bbolt v1.4.3\n\tgolang.org/x/net v0.48.0\n\tgolang.org/x/text v0.32.0\n\tgoogle.golang.org/grpc v1.77.0\n\tgopkg.in/yaml.v3 v3.0.1\n\tk8s.io/apimachinery v0.34.3\n\tsigs.k8s.io/yaml v1.6.0\n)\n\nrequire (\n\tal.essio.dev/pkg/shellescape v1.6.0 // indirect\n\tbuf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20251209175733-2a1774d88802.1 // indirect\n\tcel.dev/expr v0.25.1 // indirect\n\tdario.cat/mergo v1.0.2 // indirect\n\tgithub.com/AlekSi/pointer v1.2.0 // indirect\n\tgithub.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect\n\tgithub.com/BurntSushi/toml v1.5.0 // indirect\n\tgithub.com/Masterminds/goutils v1.1.1 // indirect\n\tgithub.com/Masterminds/semver/v3 v3.4.0 // indirect\n\tgithub.com/Masterminds/sprig/v3 v3.3.0 // indirect\n\tgithub.com/Microsoft/go-winio v0.6.2 // indirect\n\tgithub.com/ProtonMail/go-crypto v1.3.0 // indirect\n\tgithub.com/Songmu/gitconfig v0.2.1 // indirect\n\tgithub.com/TecharoHQ/yeet v0.6.3 // indirect\n\tgithub.com/a-h/parse v0.0.0-20250122154542-74294addb73e // indirect\n\tgithub.com/andybalholm/brotli v1.2.0 // indirect\n\tgithub.com/antlr4-go/antlr/v4 v4.13.1 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/credentials v1.19.5 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/service/signin v1.0.4 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/service/sso v1.30.7 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/service/sts v1.41.5 // indirect\n\tgithub.com/aws/smithy-go v1.24.0 // indirect\n\tgithub.com/beorn7/perks v1.0.1 // indirect\n\tgithub.com/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb // indirect\n\tgithub.com/cavaliergopher/cpio v1.0.1 // indirect\n\tgithub.com/cenkalti/backoff/v4 v4.3.0 // indirect\n\tgithub.com/cli/browser v1.3.0 // indirect\n\tgithub.com/cli/go-gh/v2 v2.12.1 // indirect\n\tgithub.com/cli/safeexec v1.0.1 // indirect\n\tgithub.com/cloudflare/circl v1.6.1 // indirect\n\tgithub.com/containerd/errdefs v1.0.0 // indirect\n\tgithub.com/containerd/errdefs/pkg v0.3.0 // indirect\n\tgithub.com/containerd/log v0.1.0 // indirect\n\tgithub.com/containerd/platforms v0.2.1 // indirect\n\tgithub.com/cpuguy83/dockercfg v0.3.2 // indirect\n\tgithub.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect\n\tgithub.com/cyphar/filepath-securejoin v0.4.1 // indirect\n\tgithub.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect\n\tgithub.com/deckarep/golang-set/v2 v2.8.0 // indirect\n\tgithub.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect\n\tgithub.com/distribution/reference v0.6.0 // indirect\n\tgithub.com/djherbis/times v1.6.0 // indirect\n\tgithub.com/dlclark/regexp2 v1.11.5 // indirect\n\tgithub.com/docker/docker v28.5.1+incompatible // indirect\n\tgithub.com/docker/go-connections v0.6.0 // indirect\n\tgithub.com/docker/go-units v0.5.0 // indirect\n\tgithub.com/dop251/goja v0.0.0-20250630131328-58d95d85e994 // indirect\n\tgithub.com/ebitengine/purego v0.9.1 // indirect\n\tgithub.com/emirpasic/gods v1.18.1 // indirect\n\tgithub.com/facebookgo/ensure v0.0.0-20160127193407-b4ab57deab51 // indirect\n\tgithub.com/facebookgo/stack v0.0.0-20160209184415-751773369052 // indirect\n\tgithub.com/facebookgo/subset v0.0.0-20150612182917-8dac2c3c4870 // indirect\n\tgithub.com/fatih/color v1.18.0 // indirect\n\tgithub.com/felixge/httpsnoop v1.0.4 // indirect\n\tgithub.com/fsnotify/fsnotify v1.9.0 // indirect\n\tgithub.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect\n\tgithub.com/go-git/go-billy/v5 v5.6.2 // indirect\n\tgithub.com/go-git/go-git/v5 v5.16.2 // indirect\n\tgithub.com/go-jose/go-jose/v3 v3.0.4 // indirect\n\tgithub.com/go-logr/logr v1.4.3 // indirect\n\tgithub.com/go-logr/stdr v1.2.2 // indirect\n\tgithub.com/go-ole/go-ole v1.3.0 // indirect\n\tgithub.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect\n\tgithub.com/go-stack/stack v1.8.1 // indirect\n\tgithub.com/gobwas/glob v0.2.3 // indirect\n\tgithub.com/goccy/go-yaml v1.18.0 // indirect\n\tgithub.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect\n\tgithub.com/google/go-github/v70 v70.0.0 // indirect\n\tgithub.com/google/go-querystring v1.1.0 // indirect\n\tgithub.com/google/pprof v0.0.0-20250630185457-6e76a2b096b5 // indirect\n\tgithub.com/google/rpmpack v0.7.1 // indirect\n\tgithub.com/goreleaser/chglog v0.7.3 // indirect\n\tgithub.com/goreleaser/fileglob v1.3.0 // indirect\n\tgithub.com/goreleaser/nfpm/v2 v2.43.0 // indirect\n\tgithub.com/hashicorp/go-version v1.7.0 // indirect\n\tgithub.com/huandu/xstrings v1.5.0 // indirect\n\tgithub.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect\n\tgithub.com/kevinburke/ssh_config v1.2.0 // indirect\n\tgithub.com/klauspost/compress v1.18.0 // indirect\n\tgithub.com/klauspost/pgzip v1.2.6 // indirect\n\tgithub.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 // indirect\n\tgithub.com/magiconair/properties v1.8.10 // indirect\n\tgithub.com/mattn/go-colorable v0.1.14 // indirect\n\tgithub.com/mattn/go-isatty v0.0.20 // indirect\n\tgithub.com/mitchellh/copystructure v1.2.0 // indirect\n\tgithub.com/mitchellh/reflectwalk v1.0.2 // indirect\n\tgithub.com/moby/docker-image-spec v1.3.1 // indirect\n\tgithub.com/moby/go-archive v0.1.0 // indirect\n\tgithub.com/moby/patternmatcher v0.6.0 // indirect\n\tgithub.com/moby/sys/sequential v0.6.0 // indirect\n\tgithub.com/moby/sys/user v0.4.0 // indirect\n\tgithub.com/moby/sys/userns v0.1.0 // indirect\n\tgithub.com/moby/term v0.5.2 // indirect\n\tgithub.com/morikuni/aec v1.0.0 // indirect\n\tgithub.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect\n\tgithub.com/natefinch/atomic v1.0.1 // indirect\n\tgithub.com/opencontainers/go-digest v1.0.0 // indirect\n\tgithub.com/opencontainers/image-spec v1.1.1 // indirect\n\tgithub.com/pjbgf/sha1cd v0.4.0 // indirect\n\tgithub.com/pkg/errors v0.9.1 // indirect\n\tgithub.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect\n\tgithub.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect\n\tgithub.com/prometheus/client_model v0.6.2 // indirect\n\tgithub.com/prometheus/common v0.67.4 // indirect\n\tgithub.com/prometheus/procfs v0.19.2 // indirect\n\tgithub.com/russross/blackfriday/v2 v2.1.0 // indirect\n\tgithub.com/sergi/go-diff v1.4.0 // indirect\n\tgithub.com/shopspring/decimal v1.4.0 // indirect\n\tgithub.com/sirupsen/logrus v1.9.3 // indirect\n\tgithub.com/skeema/knownhosts v1.3.1 // indirect\n\tgithub.com/spf13/afero v1.14.0 // indirect\n\tgithub.com/spf13/cast v1.9.2 // indirect\n\tgithub.com/stoewer/go-strcase v1.3.1 // indirect\n\tgithub.com/stretchr/testify v1.11.1 // indirect\n\tgithub.com/suzuki-shunsuke/logrus-error v0.1.4 // indirect\n\tgithub.com/suzuki-shunsuke/pinact v1.6.0 // indirect\n\tgithub.com/suzuki-shunsuke/urfave-cli-help-all v0.0.4 // indirect\n\tgithub.com/tklauser/go-sysconf v0.3.16 // indirect\n\tgithub.com/tklauser/numcpus v0.11.0 // indirect\n\tgithub.com/ulikunitz/xz v0.5.14 // indirect\n\tgithub.com/urfave/cli/v2 v2.27.7 // indirect\n\tgithub.com/xanzy/ssh-agent v0.3.3 // indirect\n\tgithub.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect\n\tgithub.com/yusufpapurcu/wmi v1.2.4 // indirect\n\tgitlab.com/digitalxero/go-conventional-commit v1.0.7 // indirect\n\tgo.opentelemetry.io/auto/sdk v1.2.1 // indirect\n\tgo.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 // indirect\n\tgo.opentelemetry.io/otel v1.38.0 // indirect\n\tgo.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 // indirect\n\tgo.opentelemetry.io/otel/metric v1.38.0 // indirect\n\tgo.opentelemetry.io/otel/trace v1.38.0 // indirect\n\tgo.opentelemetry.io/proto/otlp v1.7.0 // indirect\n\tgo.yaml.in/yaml/v2 v2.4.3 // indirect\n\tgo.yaml.in/yaml/v3 v3.0.4 // indirect\n\tgolang.org/x/crypto v0.46.0 // indirect\n\tgolang.org/x/exp v0.0.0-20251209150349-8475f28825e9 // indirect\n\tgolang.org/x/exp/typeparams v0.0.0-20250718183923-645b1fa84792 // indirect\n\tgolang.org/x/mod v0.31.0 // indirect\n\tgolang.org/x/oauth2 v0.32.0 // indirect\n\tgolang.org/x/sync v0.19.0 // indirect\n\tgolang.org/x/sys v0.39.0 // indirect\n\tgolang.org/x/telemetry v0.0.0-20251203150158-8fff8a5912fc // indirect\n\tgolang.org/x/term v0.38.0 // indirect\n\tgolang.org/x/tools v0.40.0 // indirect\n\tgolang.org/x/vuln v1.1.4 // indirect\n\tgoogle.golang.org/genproto/googleapis/api v0.0.0-20251213004720-97cd9d5aeac2 // indirect\n\tgoogle.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 // indirect\n\tgoogle.golang.org/protobuf v1.36.11 // indirect\n\tgopkg.in/warnings.v0 v0.1.2 // indirect\n\thonnef.co/go/tools v0.6.1 // indirect\n\tmvdan.cc/sh/v3 v3.12.0 // indirect\n\tsigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect\n)\n\ntool (\n\tgithub.com/TecharoHQ/yeet/cmd/yeet\n\tgithub.com/a-h/templ/cmd/templ\n\tgithub.com/nicksnyder/go-i18n/v2/goi18n\n\tgithub.com/suzuki-shunsuke/pinact/cmd/pinact\n\tgolang.org/x/tools/cmd/deadcode\n\tgolang.org/x/tools/cmd/goimports\n\tgolang.org/x/tools/cmd/stringer\n\tgolang.org/x/vuln/cmd/govulncheck\n\thonnef.co/go/tools/cmd/staticcheck\n)\n"
  },
  {
    "path": "go.sum",
    "content": "al.essio.dev/pkg/shellescape v1.6.0 h1:NxFcEqzFSEVCGN2yq7Huv/9hyCEGVa/TncnOOBBeXHA=\nal.essio.dev/pkg/shellescape v1.6.0/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890=\nbuf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20251209175733-2a1774d88802.1 h1:j9yeqTWEFrtimt8Nng2MIeRrpoCvQzM9/g25XTvqUGg=\nbuf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20251209175733-2a1774d88802.1/go.mod h1:tvtbpgaVXZX4g6Pn+AnzFycuRK3MOz5HJfEGeEllXYM=\ncel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4=\ncel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=\ndario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=\ndario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=\ngithub.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=\ngithub.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=\ngithub.com/AlekSi/pointer v1.2.0 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w=\ngithub.com/AlekSi/pointer v1.2.0/go.mod h1:gZGfd3dpW4vEc/UlyfKKi1roIqcCgwOIvb0tSNSBle0=\ngithub.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=\ngithub.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=\ngithub.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=\ngithub.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=\ngithub.com/DataDog/zstd v1.5.5 h1:oWf5W7GtOLgp6bciQYDmhHHjdhYkALu6S/5Ni9ZgSvQ=\ngithub.com/DataDog/zstd v1.5.5/go.mod h1:g4AWEaM3yOg3HYfnJ3YIawPnVdXJh9QME85blwSAmyw=\ngithub.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI=\ngithub.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU=\ngithub.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=\ngithub.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=\ngithub.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs=\ngithub.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0=\ngithub.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=\ngithub.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=\ngithub.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=\ngithub.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=\ngithub.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=\ngithub.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f h1:tCbYj7/299ekTTXpdwKYF8eBlsYsDVoggDAuAjoK66k=\ngithub.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f/go.mod h1:gcr0kNtGBqin9zDW9GOHcVntrwnjrK+qdJ06mWYBybw=\ngithub.com/ProtonMail/gopenpgp/v2 v2.7.1 h1:Awsg7MPc2gD3I7IFac2qE3Gdls0lZW8SzrFZ3k1oz0s=\ngithub.com/ProtonMail/gopenpgp/v2 v2.7.1/go.mod h1:/BU5gfAVwqyd8EfC3Eu7zmuhwYQpKs+cGD8M//iiaxs=\ngithub.com/ProtonMail/gopenpgp/v3 v3.3.0 h1:N6rHCH5PWwB6zSRMgRj1EbAMQHUAAHxH3Oo4KibsPwY=\ngithub.com/ProtonMail/gopenpgp/v3 v3.3.0/go.mod h1:J+iNPt0/5EO9wRt7Eit9dRUlzyu3hiGX3zId6iuaKOk=\ngithub.com/Songmu/gitconfig v0.2.1 h1:cZsqELfMtxWVI8ovq17gbvsR4qLfoYLAiXy5GwtJWbk=\ngithub.com/Songmu/gitconfig v0.2.1/go.mod h1:XM4O3SoXFnli9Ql2G7qXK2Fg7LJwf7Hs8GLFEOJlzmM=\ngithub.com/TecharoHQ/thoth-proto v0.5.0 h1:Fa663s4soYiURSU8MfW9tZ2wF+LsCRSaYmjUSyagfBM=\ngithub.com/TecharoHQ/thoth-proto v0.5.0/go.mod h1:C/U7FqTxpVn4V/qebC/GcW32I0h9xzsmWehF27KFOJs=\ngithub.com/TecharoHQ/yeet v0.6.3 h1:Iev6TYt/tpFYU73kbkNIYjCObYTvlihtby+htGF4Us8=\ngithub.com/TecharoHQ/yeet v0.6.3/go.mod h1:ltt+PWPjnvmQJxEHsdJ5K9u3GoWK83vSLWCCp8XbxqI=\ngithub.com/a-h/parse v0.0.0-20250122154542-74294addb73e h1:HjVbSQHy+dnlS6C3XajZ69NYAb5jbGNfHanvm1+iYlo=\ngithub.com/a-h/parse v0.0.0-20250122154542-74294addb73e/go.mod h1:3mnrkvGpurZ4ZrTDbYU84xhwXW2TjTKShSwjRi2ihfQ=\ngithub.com/a-h/templ v0.3.960 h1:trshEpGa8clF5cdI39iY4ZrZG8Z/QixyzEyUnA7feTM=\ngithub.com/a-h/templ v0.3.960/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo=\ngithub.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=\ngithub.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=\ngithub.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=\ngithub.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=\ngithub.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ=\ngithub.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw=\ngithub.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=\ngithub.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=\ngithub.com/aws/aws-sdk-go-v2 v1.41.0 h1:tNvqh1s+v0vFYdA1xq0aOJH+Y5cRyZ5upu6roPgPKd4=\ngithub.com/aws/aws-sdk-go-v2 v1.41.0/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0=\ngithub.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU=\ngithub.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4=\ngithub.com/aws/aws-sdk-go-v2/config v1.32.5 h1:pz3duhAfUgnxbtVhIK39PGF/AHYyrzGEyRD9Og0QrE8=\ngithub.com/aws/aws-sdk-go-v2/config v1.32.5/go.mod h1:xmDjzSUs/d0BB7ClzYPAZMmgQdrodNjPPhd6bGASwoE=\ngithub.com/aws/aws-sdk-go-v2/credentials v1.19.5 h1:xMo63RlqP3ZZydpJDMBsH9uJ10hgHYfQFIk1cHDXrR4=\ngithub.com/aws/aws-sdk-go-v2/credentials v1.19.5/go.mod h1:hhbH6oRcou+LpXfA/0vPElh/e0M3aFeOblE1sssAAEk=\ngithub.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 h1:80+uETIWS1BqjnN9uJ0dBUaETh+P1XwFy5vwHwK5r9k=\ngithub.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16/go.mod h1:wOOsYuxYuB/7FlnVtzeBYRcjSRtQpAW0hCP7tIULMwo=\ngithub.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 h1:rgGwPzb82iBYSvHMHXc8h9mRoOUBZIGFgKb9qniaZZc=\ngithub.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16/go.mod h1:L/UxsGeKpGoIj6DxfhOWHWQ/kGKcd4I1VncE4++IyKA=\ngithub.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 h1:1jtGzuV7c82xnqOVfx2F0xmJcOw5374L7N6juGW6x6U=\ngithub.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16/go.mod h1:M2E5OQf+XLe+SZGmmpaI2yy+J326aFf6/+54PoxSANc=\ngithub.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=\ngithub.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=\ngithub.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16 h1:CjMzUs78RDDv4ROu3JnJn/Ig1r6ZD7/T2DXLLRpejic=\ngithub.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16/go.mod h1:uVW4OLBqbJXSHJYA9svT9BluSvvwbzLQ2Crf6UPzR3c=\ngithub.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E=\ngithub.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow=\ngithub.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7 h1:DIBqIrJ7hv+e4CmIk2z3pyKT+3B6qVMgRsawHiR3qso=\ngithub.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7/go.mod h1:vLm00xmBke75UmpNvOcZQ/Q30ZFjbczeLFqGx5urmGo=\ngithub.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 h1:oHjJHeUy0ImIV0bsrX0X91GkV5nJAyv1l1CC9lnO0TI=\ngithub.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16/go.mod h1:iRSNGgOYmiYwSCXxXaKb9HfOEj40+oTKn8pTxMlYkRM=\ngithub.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16 h1:NSbvS17MlI2lurYgXnCOLvCFX38sBW4eiVER7+kkgsU=\ngithub.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16/go.mod h1:SwT8Tmqd4sA6G1qaGdzWCJN99bUmPGHfRwwq3G5Qb+A=\ngithub.com/aws/aws-sdk-go-v2/service/s3 v1.93.2 h1:U3ygWUhCpiSPYSHOrRhb3gOl9T5Y3kB8k5Vjs//57bE=\ngithub.com/aws/aws-sdk-go-v2/service/s3 v1.93.2/go.mod h1:79S2BdqCJpScXZA2y+cpZuocWsjGjJINyXnOsf5DTz8=\ngithub.com/aws/aws-sdk-go-v2/service/signin v1.0.4 h1:HpI7aMmJ+mm1wkSHIA2t5EaFFv5EFYXePW30p1EIrbQ=\ngithub.com/aws/aws-sdk-go-v2/service/signin v1.0.4/go.mod h1:C5RdGMYGlfM0gYq/tifqgn4EbyX99V15P2V3R+VHbQU=\ngithub.com/aws/aws-sdk-go-v2/service/sso v1.30.7 h1:eYnlt6QxnFINKzwxP5/Ucs1vkG7VT3Iezmvfgc2waUw=\ngithub.com/aws/aws-sdk-go-v2/service/sso v1.30.7/go.mod h1:+fWt2UHSb4kS7Pu8y+BMBvJF0EWx+4H0hzNwtDNRTrg=\ngithub.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 h1:AHDr0DaHIAo8c9t1emrzAlVDFp+iMMKnPdYy6XO4MCE=\ngithub.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12/go.mod h1:GQ73XawFFiWxyWXMHWfhiomvP3tXtdNar/fi8z18sx0=\ngithub.com/aws/aws-sdk-go-v2/service/sts v1.41.5 h1:SciGFVNZ4mHdm7gpD1dgZYnCuVdX1s+lFTg4+4DOy70=\ngithub.com/aws/aws-sdk-go-v2/service/sts v1.41.5/go.mod h1:iW40X4QBmUxdP+fZNOpfmkdMZqsovezbAeO+Ubiv2pk=\ngithub.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk=\ngithub.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=\ngithub.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=\ngithub.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=\ngithub.com/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb h1:m935MPodAbYS46DG4pJSv7WO+VECIWUQ7OJYSoTrMh4=\ngithub.com/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb/go.mod h1:PkYb9DJNAwrSvRx5DYA+gUcOIgTGVMNkfSCbZM8cWpI=\ngithub.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=\ngithub.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=\ngithub.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=\ngithub.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=\ngithub.com/caarlos0/testfs v0.4.4 h1:3PHvzHi5Lt+g332CiShwS8ogTgS3HjrmzZxCm6JCDr8=\ngithub.com/caarlos0/testfs v0.4.4/go.mod h1:bRN55zgG4XCUVVHZCeU+/Tz1Q6AxEJOEJTliBy+1DMk=\ngithub.com/cavaliergopher/cpio v1.0.1 h1:KQFSeKmZhv0cr+kawA3a0xTQCU4QxXF1vhU7P7av2KM=\ngithub.com/cavaliergopher/cpio v1.0.1/go.mod h1:pBdaqQjnvXxdS/6CvNDwIANIFSP0xRKI16PX4xejRQc=\ngithub.com/cavaliergopher/rpm v1.3.0 h1:UHX46sasX8MesUXXQ+UbkFLUX4eUWTlEcX8jcnRBIgI=\ngithub.com/cavaliergopher/rpm v1.3.0/go.mod h1:vEumo1vvtrHM1Ov86f6+k8j7zNKOxQfHDCAIcR/36ZI=\ngithub.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=\ngithub.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=\ngithub.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=\ngithub.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=\ngithub.com/cli/browser v1.3.0 h1:LejqCrpWr+1pRqmEPDGnTZOjsMe7sehifLynZJuqJpo=\ngithub.com/cli/browser v1.3.0/go.mod h1:HH8s+fOAxjhQoBUAsKuPCbqUuxZDhQ2/aD+SzsEfBTk=\ngithub.com/cli/go-gh/v2 v2.12.1 h1:SVt1/afj5FRAythyMV3WJKaUfDNsxXTIe7arZbwTWKA=\ngithub.com/cli/go-gh/v2 v2.12.1/go.mod h1:+5aXmEOJsH9fc9mBHfincDwnS02j2AIA/DsTH0Bk5uw=\ngithub.com/cli/safeexec v1.0.1 h1:e/C79PbXF4yYTN/wauC4tviMxEV13BwljGj0N9j+N00=\ngithub.com/cli/safeexec v1.0.1/go.mod h1:Z/D4tTN8Vs5gXYHDCbaM1S/anmEDnJb1iW0+EJ5zx3Q=\ngithub.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=\ngithub.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=\ngithub.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=\ngithub.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=\ngithub.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=\ngithub.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=\ngithub.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=\ngithub.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=\ngithub.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=\ngithub.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=\ngithub.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=\ngithub.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=\ngithub.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=\ngithub.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=\ngithub.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=\ngithub.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=\ngithub.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s=\ngithub.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI=\ngithub.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=\ngithub.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/deckarep/golang-set/v2 v2.8.0 h1:swm0rlPCmdWn9mESxKOjWk8hXSqoxOp+ZlfuyaAdFlQ=\ngithub.com/deckarep/golang-set/v2 v2.8.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4=\ngithub.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=\ngithub.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=\ngithub.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=\ngithub.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=\ngithub.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c=\ngithub.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0=\ngithub.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=\ngithub.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=\ngithub.com/docker/docker v28.5.1+incompatible h1:Bm8DchhSD2J6PsFzxC35TZo4TLGR2PdW/E69rU45NhM=\ngithub.com/docker/docker v28.5.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=\ngithub.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=\ngithub.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=\ngithub.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=\ngithub.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=\ngithub.com/dop251/goja v0.0.0-20250630131328-58d95d85e994 h1:aQYWswi+hRL2zJqGacdCZx32XjKYV8ApXFGntw79XAM=\ngithub.com/dop251/goja v0.0.0-20250630131328-58d95d85e994/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=\ngithub.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A=\ngithub.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=\ngithub.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=\ngithub.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=\ngithub.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=\ngithub.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=\ngithub.com/facebookgo/ensure v0.0.0-20160127193407-b4ab57deab51 h1:0JZ+dUmQeA8IIVUMzysrX4/AKuQwWhV2dYQuPZdvdSQ=\ngithub.com/facebookgo/ensure v0.0.0-20160127193407-b4ab57deab51/go.mod h1:Yg+htXGokKKdzcwhuNDwVvN+uBxDGXJ7G/VN1d8fa64=\ngithub.com/facebookgo/flagenv v0.0.0-20160425205200-fcd59fca7456 h1:CkmB2l68uhvRlwOTPrwnuitSxi/S3Cg4L5QYOcL9MBc=\ngithub.com/facebookgo/flagenv v0.0.0-20160425205200-fcd59fca7456/go.mod h1:zFhibDvPDWmtk4dAQ05sRobtyoffEHygEt3wSNuAzz8=\ngithub.com/facebookgo/stack v0.0.0-20160209184415-751773369052 h1:JWuenKqqX8nojtoVVWjGfOF9635RETekkoH6Cc9SX0A=\ngithub.com/facebookgo/stack v0.0.0-20160209184415-751773369052/go.mod h1:UbMTZqLaRiH3MsBH8va0n7s1pQYcu3uTb8G4tygF4Zg=\ngithub.com/facebookgo/subset v0.0.0-20150612182917-8dac2c3c4870 h1:E2s37DuLxFhQDg5gKsWoLBOB0n+ZW8s599zru8FJ2/Y=\ngithub.com/facebookgo/subset v0.0.0-20150612182917-8dac2c3c4870/go.mod h1:5tD+neXqOorC30/tWg0LCSkrqj/AR6gu8yY8/fpw1q0=\ngithub.com/fahedouch/go-logrotate v0.3.0 h1:XP+dHIDgWZ1ckz43mG6gl5ASer3PZDVr755SVMyzaUQ=\ngithub.com/fahedouch/go-logrotate v0.3.0/go.mod h1:X49m0bvPLkk71MHNCQ1yEfVEw8W/u+qvHa/hOnhCYf4=\ngithub.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=\ngithub.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=\ngithub.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=\ngithub.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=\ngithub.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=\ngithub.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=\ngithub.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=\ngithub.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=\ngithub.com/gaissmai/bart v0.26.0 h1:xOZ57E9hJLBiQaSyeZa9wgWhGuzfGACgqp4BE77OkO0=\ngithub.com/gaissmai/bart v0.26.0/go.mod h1:GREWQfTLRWz/c5FTOsIw+KkscuFkIV5t8Rp7Nd1Td5c=\ngithub.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=\ngithub.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=\ngithub.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=\ngithub.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=\ngithub.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM=\ngithub.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU=\ngithub.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=\ngithub.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=\ngithub.com/go-git/go-git/v5 v5.16.2 h1:fT6ZIOjE5iEnkzKyxTHK1W4HGAsPhqEqiSAssSO77hM=\ngithub.com/go-git/go-git/v5 v5.16.2/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8=\ngithub.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY=\ngithub.com/go-jose/go-jose/v3 v3.0.4/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ=\ngithub.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=\ngithub.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=\ngithub.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=\ngithub.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=\ngithub.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=\ngithub.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=\ngithub.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=\ngithub.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=\ngithub.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=\ngithub.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=\ngithub.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q=\ngithub.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=\ngithub.com/go-stack/stack v1.8.1 h1:ntEHSVwIt7PNXNpgPmVfMrNhLtgjlmnZha2kOpuRiDw=\ngithub.com/go-stack/stack v1.8.1/go.mod h1:dcoOX6HbPZSZptuspn9bctJ+N/CnF5gGygcUP3XYfe4=\ngithub.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=\ngithub.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=\ngithub.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=\ngithub.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=\ngithub.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=\ngithub.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=\ngithub.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=\ngithub.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=\ngithub.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=\ngithub.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=\ngithub.com/google/cel-go v0.26.1 h1:iPbVVEdkhTX++hpe3lzSk7D3G3QSYqLGoHOcEio+UXQ=\ngithub.com/google/cel-go v0.26.1/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM=\ngithub.com/google/go-cmdtest v0.4.1-0.20220921163831-55ab3332a786 h1:rcv+Ippz6RAtvaGgKxc+8FQIpxHgsF+HBzPyYL2cyVU=\ngithub.com/google/go-cmdtest v0.4.1-0.20220921163831-55ab3332a786/go.mod h1:apVn/GCasLZUVpAJ6oWAuyP7Ne7CEsQbTnc0plM3m+o=\ngithub.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=\ngithub.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=\ngithub.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=\ngithub.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=\ngithub.com/google/go-github/v70 v70.0.0 h1:/tqCp5KPrcvqCc7vIvYyFYTiCGrYvaWoYMGHSQbo55o=\ngithub.com/google/go-github/v70 v70.0.0/go.mod h1:xBUZgo8MI3lUL/hwxl3hlceJW1U8MVnXP3zUyI+rhQY=\ngithub.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=\ngithub.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=\ngithub.com/google/pprof v0.0.0-20250630185457-6e76a2b096b5 h1:xhMrHhTJ6zxu3gA4enFM9MLn9AY7613teCdFnlUVbSQ=\ngithub.com/google/pprof v0.0.0-20250630185457-6e76a2b096b5/go.mod h1:5hDyRhoBCxViHszMt12TnOpEI4VVi+U8Gm9iphldiMA=\ngithub.com/google/renameio v0.1.0 h1:GOZbcHa3HfsPKPlmyPyN2KEohoMXOhdMbHrvbpl2QaA=\ngithub.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=\ngithub.com/google/rpmpack v0.7.1 h1:YdWh1IpzOjBz60Wvdw0TU0A5NWP+JTVHA5poDqwMO2o=\ngithub.com/google/rpmpack v0.7.1/go.mod h1:h1JL16sUTWCLI/c39ox1rDaTBo3BXUQGjczVJyK4toU=\ngithub.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=\ngithub.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=\ngithub.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=\ngithub.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=\ngithub.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g=\ngithub.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k=\ngithub.com/goreleaser/chglog v0.7.3 h1:eCKJrvsDgG+F1F2fhwM6qX+S5yMiZgsQ4VNTPFl9qEM=\ngithub.com/goreleaser/chglog v0.7.3/go.mod h1:HXPf4avc1kTD00a46LuTEH0i1dZctLq8Xs2BxUfROnY=\ngithub.com/goreleaser/fileglob v1.3.0 h1:/X6J7U8lbDpQtBvGcwwPS6OpzkNVlVEsFUVRx9+k+7I=\ngithub.com/goreleaser/fileglob v1.3.0/go.mod h1:Jx6BoXv3mbYkEzwm9THo7xbr5egkAraxkGorbJb4RxU=\ngithub.com/goreleaser/nfpm/v2 v2.43.0 h1:o5oureuZkhu55RK0M9WSN8JLW7hu6MymtMh7LypInlk=\ngithub.com/goreleaser/nfpm/v2 v2.43.0/go.mod h1:f//PE8PjNHjaPCbd7Jkok+aPKdLTrzM+fuIWg3PfVRg=\ngithub.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 h1:QGLs/O40yoNK9vmy4rhUGBVyMf1lISBGtXRpsu/Qu/o=\ngithub.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0/go.mod h1:hM2alZsMUni80N33RBe6J0e423LB+odMj7d3EMP9l20=\ngithub.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 h1:B+8ClL/kCQkRiU82d9xajRPKYMrB7E0MbtzWVi1K4ns=\ngithub.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3/go.mod h1:NbCUVmiS4foBGBHOYlCT25+YmGpJ32dZPi75pGEUpj4=\ngithub.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo=\ngithub.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI=\ngithub.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY=\ngithub.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=\ngithub.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI=\ngithub.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=\ngithub.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=\ngithub.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=\ngithub.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=\ngithub.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=\ngithub.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=\ngithub.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=\ngithub.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=\ngithub.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=\ngithub.com/kjk/lzma v0.0.0-20161016003348-3fd93898850d h1:RnWZeH8N8KXfbwMTex/KKMYMj0FJRCF6tQubUuQ02GM=\ngithub.com/kjk/lzma v0.0.0-20161016003348-3fd93898850d/go.mod h1:phT/jsRPBAEqjAibu1BurrabCBNTYiVI+zbmyCZJY6Q=\ngithub.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=\ngithub.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=\ngithub.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU=\ngithub.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=\ngithub.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=\ngithub.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=\ngithub.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=\ngithub.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=\ngithub.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=\ngithub.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=\ngithub.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=\ngithub.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=\ngithub.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=\ngithub.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 h1:PpXWgLPs+Fqr325bN2FD2ISlRRztXibcX6e8f5FR5Dc=\ngithub.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=\ngithub.com/lum8rjack/go-ja4h v0.0.0-20250828030157-fa5266d50650 h1:hhx/Mo6+Hk0mAQS5MW311ON1VlSzp0D1cYhY27IcmnI=\ngithub.com/lum8rjack/go-ja4h v0.0.0-20250828030157-fa5266d50650/go.mod h1:bMqyXOakqQIdx82d4vcnk5TIZLptZ2gLqju9xmPrWYA=\ngithub.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=\ngithub.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=\ngithub.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE=\ngithub.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=\ngithub.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=\ngithub.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=\ngithub.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=\ngithub.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=\ngithub.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=\ngithub.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=\ngithub.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc=\ngithub.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg=\ngithub.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=\ngithub.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=\ngithub.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=\ngithub.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=\ngithub.com/moby/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ=\ngithub.com/moby/go-archive v0.1.0/go.mod h1:G9B+YoujNohJmrIYFBpSd54GTUB4lt9S+xVQvsJyFuo=\ngithub.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=\ngithub.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=\ngithub.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=\ngithub.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs=\ngithub.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=\ngithub.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=\ngithub.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=\ngithub.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=\ngithub.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=\ngithub.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=\ngithub.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=\ngithub.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=\ngithub.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=\ngithub.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=\ngithub.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=\ngithub.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=\ngithub.com/natefinch/atomic v1.0.1 h1:ZPYKxkqQOx3KZ+RsbnP/YsgvxWQPGxjC0oBt2AhwV0A=\ngithub.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM=\ngithub.com/nicksnyder/go-i18n/v2 v2.6.0 h1:C/m2NNWNiTB6SK4Ao8df5EWm3JETSTIGNXBpMJTxzxQ=\ngithub.com/nicksnyder/go-i18n/v2 v2.6.0/go.mod h1:88sRqr0C6OPyJn0/KRNaEz1uWorjxIKP7rUUcvycecE=\ngithub.com/nikandfor/spintax v0.0.0-20181023094358-fc346b245bb3 h1:foZ9X1bz2KmW7b8Yx5V0LAQKhTazdllv5rnGUe6iGTY=\ngithub.com/nikandfor/spintax v0.0.0-20181023094358-fc346b245bb3/go.mod h1:wwDYKfVF3WHdY0rugsAZoIpyQjDA3bn9wEzo/QXPx1Y=\ngithub.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4=\ngithub.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog=\ngithub.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=\ngithub.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=\ngithub.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=\ngithub.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=\ngithub.com/pjbgf/sha1cd v0.4.0 h1:NXzbL1RvjTUi6kgYZCX3fPwwl27Q1LJndxtUDVfJGRY=\ngithub.com/pjbgf/sha1cd v0.4.0/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A=\ngithub.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=\ngithub.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=\ngithub.com/playwright-community/playwright-go v0.5200.1 h1:Sm2oOuhqt0M5Y4kUi/Qh9w4cyyi3ZIWTBeGKImc2UVo=\ngithub.com/playwright-community/playwright-go v0.5200.1/go.mod h1:UnnyQZaqUOO5ywAZu60+N4EiWReUqX1MQBBA3Oofvf8=\ngithub.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=\ngithub.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=\ngithub.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=\ngithub.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=\ngithub.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=\ngithub.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=\ngithub.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=\ngithub.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=\ngithub.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=\ngithub.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc=\ngithub.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI=\ngithub.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=\ngithub.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=\ngithub.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI=\ngithub.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=\ngithub.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=\ngithub.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=\ngithub.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=\ngithub.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=\ngithub.com/sassoftware/go-rpmutils v0.4.0 h1:ojND82NYBxgwrV+mX1CWsd5QJvvEZTKddtCdFLPWhpg=\ngithub.com/sassoftware/go-rpmutils v0.4.0/go.mod h1:3goNWi7PGAT3/dlql2lv3+MSN5jNYPjT5mVcQcIsYzI=\ngithub.com/sebest/xff v0.0.0-20210106013422-671bd2870b3a h1:iLcLb5Fwwz7g/DLK89F+uQBDeAhHhwdzB5fSlVdhGcM=\ngithub.com/sebest/xff v0.0.0-20210106013422-671bd2870b3a/go.mod h1:wozgYq9WEBQBaIJe4YZ0qTSFAMxmcwBhQH0fO0R34Z0=\ngithub.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=\ngithub.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=\ngithub.com/shirou/gopsutil/v4 v4.25.11 h1:X53gB7muL9Gnwwo2evPSE+SfOrltMoR6V3xJAXZILTY=\ngithub.com/shirou/gopsutil/v4 v4.25.11/go.mod h1:EivAfP5x2EhLp2ovdpKSozecVXn1TmuG7SMzs/Wh4PU=\ngithub.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=\ngithub.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=\ngithub.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=\ngithub.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=\ngithub.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=\ngithub.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8=\ngithub.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY=\ngithub.com/smarty/assertions v1.15.0 h1:cR//PqUBUiQRakZWqBiFFQ9wb8emQGDb0HeGdqGByCY=\ngithub.com/smarty/assertions v1.15.0/go.mod h1:yABtdzeQs6l1brC900WlRNwj6ZR55d7B+E8C6HtKdec=\ngithub.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sSznIX1xY=\ngithub.com/smartystreets/goconvey v1.8.1/go.mod h1:+/u4qLyY6x1jReYOp7GOM2FSt8aP9CzCZL03bI28W60=\ngithub.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA=\ngithub.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo=\ngithub.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE=\ngithub.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=\ngithub.com/stoewer/go-strcase v1.3.1 h1:iS0MdW+kVTxgMoE1LAZyMiYJFKlOzLooE4MxjirtkAs=\ngithub.com/stoewer/go-strcase v1.3.1/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo=\ngithub.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=\ngithub.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=\ngithub.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=\ngithub.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=\ngithub.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=\ngithub.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=\ngithub.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=\ngithub.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=\ngithub.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=\ngithub.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=\ngithub.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=\ngithub.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=\ngithub.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=\ngithub.com/suzuki-shunsuke/logrus-error v0.1.4 h1:nWo98uba1fANHdZ9Y5pJ2RKs/PpVjrLzRp5m+mRb9KE=\ngithub.com/suzuki-shunsuke/logrus-error v0.1.4/go.mod h1:WsVvvw6SKSt08/fB2qbnsKIMJA4K1MYCUprqsBJbMiM=\ngithub.com/suzuki-shunsuke/pinact v1.6.0 h1:2QvSzREOquwLwKXhF9Hj0AInE/Rl63SZz9dKkHFC6so=\ngithub.com/suzuki-shunsuke/pinact v1.6.0/go.mod h1:FDUMck0mmL0mcnNZ23Vjh/aOR5cIdZhF1IIpGksT4dQ=\ngithub.com/suzuki-shunsuke/urfave-cli-help-all v0.0.4 h1:YGHgrVjGTYHY98II6zijXUHP+OyvrzSCvd8m9iUcaK8=\ngithub.com/suzuki-shunsuke/urfave-cli-help-all v0.0.4/go.mod h1:sSi6xaUaHfaqu32ECLeyE7NTMv+ZM5dW0JikhllaalY=\ngithub.com/testcontainers/testcontainers-go v0.40.0 h1:pSdJYLOVgLE8YdUY2FHQ1Fxu+aMnb6JfVz1mxk7OeMU=\ngithub.com/testcontainers/testcontainers-go v0.40.0/go.mod h1:FSXV5KQtX2HAMlm7U3APNyLkkap35zNLxukw9oBi/MY=\ngithub.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=\ngithub.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=\ngithub.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=\ngithub.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=\ngithub.com/ulikunitz/xz v0.5.14 h1:uv/0Bq533iFdnMHZdRBTOlaNMdb1+ZxXIlHDZHIHcvg=\ngithub.com/ulikunitz/xz v0.5.14/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=\ngithub.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU=\ngithub.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4=\ngithub.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=\ngithub.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=\ngithub.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 h1:nIPpBwaJSVYIxUFsDv3M8ofmx9yWTog9BfvIu0q41lo=\ngithub.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos=\ngithub.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg=\ngithub.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=\ngithub.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=\ngithub.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=\ngithub.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=\ngithub.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=\ngithub.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=\ngitlab.com/digitalxero/go-conventional-commit v1.0.7 h1:8/dO6WWG+98PMhlZowt/YjuiKhqhGlOCwlIV8SqqGh8=\ngitlab.com/digitalxero/go-conventional-commit v1.0.7/go.mod h1:05Xc2BFsSyC5tKhK0y+P3bs0AwUtNuTp+mTpbCU/DZ0=\ngo.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=\ngo.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=\ngo.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=\ngo.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=\ngo.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 h1:Hf9xI/XLML9ElpiHVDNwvqI0hIFlzV8dgIr35kV1kRU=\ngo.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0/go.mod h1:NfchwuyNoMcZ5MLHwPrODwUF1HWCXWrL31s8gSAdIKY=\ngo.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=\ngo.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=\ngo.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 h1:Ahq7pZmv87yiyn3jeFz/LekZmPLLdKejuO3NcK9MssM=\ngo.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0/go.mod h1:MJTqhM0im3mRLw1i8uGHnCvUEeS7VwRyxlLC78PA18M=\ngo.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg=\ngo.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU=\ngo.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=\ngo.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=\ngo.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=\ngo.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=\ngo.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=\ngo.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=\ngo.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=\ngo.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=\ngo.opentelemetry.io/proto/otlp v1.7.0 h1:jX1VolD6nHuFzOYso2E73H85i92Mv8JQYk0K9vz09os=\ngo.opentelemetry.io/proto/otlp v1.7.0/go.mod h1:fSKjH6YJ7HDlwzltzyMj036AJ3ejJLCgCSHGj4efDDo=\ngo.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=\ngo.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=\ngo.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=\ngo.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=\ngo.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=\ngo.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=\ngolang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=\ngolang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=\ngolang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=\ngolang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=\ngolang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=\ngolang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=\ngolang.org/x/exp v0.0.0-20251209150349-8475f28825e9 h1:MDfG8Cvcqlt9XXrmEiD4epKn7VJHZO84hejP9Jmp0MM=\ngolang.org/x/exp v0.0.0-20251209150349-8475f28825e9/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=\ngolang.org/x/exp/typeparams v0.0.0-20250718183923-645b1fa84792 h1:54/e+WfmhvjR2Zuz8Q7dzLGxIBM+s5WZpvo1QfVDGB8=\ngolang.org/x/exp/typeparams v0.0.0-20250718183923-645b1fa84792/go.mod h1:LKZHyeOpPuZcMgxeHjJp4p5yvxrCX1xDvH10zYHhjjQ=\ngolang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=\ngolang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=\ngolang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=\ngolang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=\ngolang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=\ngolang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=\ngolang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=\ngolang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=\ngolang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=\ngolang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=\ngolang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=\ngolang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=\ngolang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY=\ngolang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=\ngolang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=\ngolang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=\ngolang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=\ngolang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=\ngolang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=\ngolang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=\ngolang.org/x/telemetry v0.0.0-20251203150158-8fff8a5912fc h1:bH6xUXay0AIFMElXG2rQ4uiE+7ncwtiOdPfYK1NK2XA=\ngolang.org/x/telemetry v0.0.0-20251203150158-8fff8a5912fc/go.mod h1:hKdjCMrbv9skySur+Nek8Hd0uJ0GuxJIoIX2payrIdQ=\ngolang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=\ngolang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=\ngolang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=\ngolang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=\ngolang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=\ngolang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=\ngolang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=\ngolang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=\ngolang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=\ngolang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=\ngolang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=\ngolang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=\ngolang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=\ngolang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=\ngolang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=\ngolang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=\ngolang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=\ngolang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=\ngolang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=\ngolang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=\ngolang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=\ngolang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=\ngolang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=\ngolang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=\ngolang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM=\ngolang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=\ngolang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM=\ngolang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8=\ngolang.org/x/vuln v1.1.4 h1:Ju8QsuyhX3Hk8ma3CesTbO8vfJD9EvUBgHvkxHBzj0I=\ngolang.org/x/vuln v1.1.4/go.mod h1:F+45wmU18ym/ca5PLTPLsSzr2KppzswxPP603ldA67s=\ngolang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngolang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=\ngonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=\ngoogle.golang.org/genproto/googleapis/api v0.0.0-20251213004720-97cd9d5aeac2 h1:7LRqPCEdE4TP4/9psdaB7F2nhZFfBiGJomA5sojLWdU=\ngoogle.golang.org/genproto/googleapis/api v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto=\ngoogle.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA=\ngoogle.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=\ngoogle.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=\ngoogle.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=\ngoogle.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=\ngoogle.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=\ngopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=\ngopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=\ngopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=\ngopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=\ngopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=\ngopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=\ngopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=\ngopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=\ngopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=\ngopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\ngopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=\ngopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\ngotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=\ngotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=\nhonnef.co/go/tools v0.6.1 h1:R094WgE8K4JirYjBaOpz/AvTyUu/3wbmAoskKN/pxTI=\nhonnef.co/go/tools v0.6.1/go.mod h1:3puzxxljPCe8RGJX7BIy1plGbxEOZni5mR2aXe3/uk4=\nk8s.io/apimachinery v0.34.3 h1:/TB+SFEiQvN9HPldtlWOTp0hWbJ+fjU+wkxysf/aQnE=\nk8s.io/apimachinery v0.34.3/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw=\nmvdan.cc/sh/v3 v3.12.0 h1:ejKUR7ONP5bb+UGHGEG/k9V5+pRVIyD+LsZz7o8KHrI=\nmvdan.cc/sh/v3 v3.12.0/go.mod h1:Se6Cj17eYSn+sNooLZiEUnNNmNxg0imoYlTu4CyaGyg=\npault.ag/go/debian v0.18.0 h1:nr0iiyOU5QlG1VPnhZLNhnCcHx58kukvBJp+dvaM6CQ=\npault.ag/go/debian v0.18.0/go.mod h1:JFl0XWRCv9hWBrB5MDDZjA5GSEs1X3zcFK/9kCNIUmE=\npault.ag/go/topsort v0.1.1 h1:L0QnhUly6LmTv0e3DEzbN2q6/FGgAcQvaEw65S53Bg4=\npault.ag/go/topsort v0.1.1/go.mod h1:r1kc/L0/FZ3HhjezBIPaNVhkqv8L0UJ9bxRuHRVZ0q4=\nsigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg=\nsigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=\nsigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=\nsigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=\n"
  },
  {
    "path": "internal/actorify/actorify.go",
    "content": "// Package actorify lets you transform a parallel operation into a serialized\n// operation via the Actor pattern[1].\n//\n// [1]: https://en.wikipedia.org/wiki/Actor_model\npackage actorify\n\nimport (\n\t\"context\"\n\t\"errors\"\n)\n\nfunc z[Z any]() Z {\n\tvar z Z\n\treturn z\n}\n\nvar (\n\t// ErrActorDied is returned when the actor inbox or reply channel was closed.\n\tErrActorDied = errors.New(\"actorify: the actor inbox or reply channel was closed\")\n)\n\n// Handler is a function alias for the underlying logic the Actor should call.\ntype Handler[Input, Output any] func(ctx context.Context, input Input) (Output, error)\n\n// Actor is a serializing wrapper that runs a function in a background goroutine.\n// Whenever the Call method is invoked, a message is sent to the actor's inbox and then\n// the callee waits for a response. Depending on how busy the actor is, this may take\n// a moment.\ntype Actor[Input, Output any] struct {\n\thandler Handler[Input, Output]\n\tinbox   chan *message[Input, Output]\n}\n\ntype message[Input, Output any] struct {\n\tctx   context.Context\n\targ   Input\n\treply chan reply[Output]\n}\n\ntype reply[Output any] struct {\n\toutput Output\n\terr    error\n}\n\n// New constructs a new Actor and starts its background thread. Cancel the context and you cancel\n// the Actor.\nfunc New[Input, Output any](ctx context.Context, handler Handler[Input, Output]) *Actor[Input, Output] {\n\tresult := &Actor[Input, Output]{\n\t\thandler: handler,\n\t\tinbox:   make(chan *message[Input, Output], 32),\n\t}\n\n\tgo result.handle(ctx)\n\n\treturn result\n}\n\nfunc (a *Actor[Input, Output]) handle(ctx context.Context) {\n\tfor {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\tclose(a.inbox)\n\t\t\treturn\n\t\tcase msg, ok := <-a.inbox:\n\t\t\tif !ok {\n\t\t\t\tif msg.reply != nil {\n\t\t\t\t\tclose(msg.reply)\n\t\t\t\t}\n\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tresult, err := a.handler(msg.ctx, msg.arg)\n\n\t\t\treply := reply[Output]{\n\t\t\t\toutput: result,\n\t\t\t\terr:    err,\n\t\t\t}\n\n\t\t\tmsg.reply <- reply\n\t\t}\n\t}\n}\n\n// Call calls the Actor with a given Input and returns the handler's Output.\n//\n// This only works with unary functions by design. If you need to have more inputs, define\n// a struct type to use as a container.\nfunc (a *Actor[Input, Output]) Call(ctx context.Context, input Input) (Output, error) {\n\treplyCh := make(chan reply[Output])\n\n\ta.inbox <- &message[Input, Output]{\n\t\targ:   input,\n\t\treply: replyCh,\n\t}\n\n\tselect {\n\tcase reply, ok := <-replyCh:\n\t\tif !ok {\n\t\t\treturn z[Output](), ErrActorDied\n\t\t}\n\n\t\treturn reply.output, reply.err\n\tcase <-ctx.Done():\n\t\treturn z[Output](), context.Cause(ctx)\n\t}\n}\n"
  },
  {
    "path": "internal/clampip.go",
    "content": "package internal\n\nimport \"net/netip\"\n\nfunc ClampIP(addr netip.Addr) (netip.Prefix, bool) {\n\tswitch {\n\tcase addr.Is4():\n\t\tresult, err := addr.Prefix(24)\n\t\tif err != nil {\n\t\t\treturn netip.Prefix{}, false\n\t\t}\n\t\treturn result, true\n\n\tcase addr.Is4In6():\n\t\t// Extract the IPv4 address from IPv4-mapped IPv6 and clamp it\n\t\tipv4 := addr.Unmap()\n\t\tresult, err := ipv4.Prefix(24)\n\t\tif err != nil {\n\t\t\treturn netip.Prefix{}, false\n\t\t}\n\t\treturn result, true\n\n\tcase addr.Is6():\n\t\tresult, err := addr.Prefix(48)\n\t\tif err != nil {\n\t\t\treturn netip.Prefix{}, false\n\t\t}\n\t\treturn result, true\n\n\tdefault:\n\t\treturn netip.Prefix{}, false\n\t}\n}\n"
  },
  {
    "path": "internal/clampip_test.go",
    "content": "package internal\n\nimport (\n\t\"net/netip\"\n\t\"testing\"\n)\n\nfunc TestClampIP(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tinput    string\n\t\texpected string\n\t}{\n\t\t// IPv4 addresses\n\t\t{\n\t\t\tname:     \"IPv4 normal address\",\n\t\t\tinput:    \"192.168.1.100\",\n\t\t\texpected: \"192.168.1.0/24\",\n\t\t},\n\t\t{\n\t\t\tname:     \"IPv4 boundary - network address\",\n\t\t\tinput:    \"192.168.1.0\",\n\t\t\texpected: \"192.168.1.0/24\",\n\t\t},\n\t\t{\n\t\t\tname:     \"IPv4 boundary - broadcast address\",\n\t\t\tinput:    \"192.168.1.255\",\n\t\t\texpected: \"192.168.1.0/24\",\n\t\t},\n\t\t{\n\t\t\tname:     \"IPv4 class A address\",\n\t\t\tinput:    \"10.0.0.1\",\n\t\t\texpected: \"10.0.0.0/24\",\n\t\t},\n\t\t{\n\t\t\tname:     \"IPv4 loopback\",\n\t\t\tinput:    \"127.0.0.1\",\n\t\t\texpected: \"127.0.0.0/24\",\n\t\t},\n\t\t{\n\t\t\tname:     \"IPv4 link-local\",\n\t\t\tinput:    \"169.254.0.1\",\n\t\t\texpected: \"169.254.0.0/24\",\n\t\t},\n\t\t{\n\t\t\tname:     \"IPv4 public address\",\n\t\t\tinput:    \"203.0.113.1\",\n\t\t\texpected: \"203.0.113.0/24\",\n\t\t},\n\n\t\t// IPv6 addresses\n\t\t{\n\t\t\tname:     \"IPv6 normal address\",\n\t\t\tinput:    \"2001:db8::1\",\n\t\t\texpected: \"2001:db8::/48\",\n\t\t},\n\t\t{\n\t\t\tname:     \"IPv6 with full expansion\",\n\t\t\tinput:    \"2001:0db8:0000:0000:0000:0000:0000:0001\",\n\t\t\texpected: \"2001:db8::/48\",\n\t\t},\n\t\t{\n\t\t\tname:     \"IPv6 loopback\",\n\t\t\tinput:    \"::1\",\n\t\t\texpected: \"::/48\",\n\t\t},\n\t\t{\n\t\t\tname:     \"IPv6 unspecified address\",\n\t\t\tinput:    \"::\",\n\t\t\texpected: \"::/48\",\n\t\t},\n\t\t{\n\t\t\tname:     \"IPv6 link-local\",\n\t\t\tinput:    \"fe80::1\",\n\t\t\texpected: \"fe80::/48\",\n\t\t},\n\t\t{\n\t\t\tname:     \"IPv6 unique local\",\n\t\t\tinput:    \"fc00::1\",\n\t\t\texpected: \"fc00::/48\",\n\t\t},\n\t\t{\n\t\t\tname:     \"IPv6 documentation prefix\",\n\t\t\tinput:    \"2001:db8:abcd:ef01::1234\",\n\t\t\texpected: \"2001:db8:abcd::/48\",\n\t\t},\n\t\t{\n\t\t\tname:     \"IPv6 global unicast\",\n\t\t\tinput:    \"2606:4700:4700::1111\",\n\t\t\texpected: \"2606:4700:4700::/48\",\n\t\t},\n\t\t{\n\t\t\tname:     \"IPv6 multicast\",\n\t\t\tinput:    \"ff02::1\",\n\t\t\texpected: \"ff02::/48\",\n\t\t},\n\n\t\t// IPv4-mapped IPv6 addresses\n\t\t{\n\t\t\tname:     \"IPv4-mapped IPv6 address\",\n\t\t\tinput:    \"::ffff:192.168.1.100\",\n\t\t\texpected: \"192.168.1.0/24\",\n\t\t},\n\t\t{\n\t\t\tname:     \"IPv4-mapped IPv6 with different format\",\n\t\t\tinput:    \"::ffff:10.0.0.1\",\n\t\t\texpected: \"10.0.0.0/24\",\n\t\t},\n\t\t{\n\t\t\tname:     \"IPv4-mapped IPv6 loopback\",\n\t\t\tinput:    \"::ffff:127.0.0.1\",\n\t\t\texpected: \"127.0.0.0/24\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\taddr := netip.MustParseAddr(tt.input)\n\n\t\t\tresult, ok := ClampIP(addr)\n\t\t\tif !ok {\n\t\t\t\tt.Fatalf(\"ClampIP(%s) returned false, want true\", tt.input)\n\t\t\t}\n\n\t\t\tif result.String() != tt.expected {\n\t\t\t\tt.Errorf(\"ClampIP(%s) = %s, want %s\", tt.input, result.String(), tt.expected)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestClampIPSuccess(t *testing.T) {\n\t// Test that valid inputs return success\n\ttests := []struct {\n\t\tname  string\n\t\tinput string\n\t}{\n\t\t{\n\t\t\tname:  \"IPv4 address\",\n\t\t\tinput: \"192.168.1.100\",\n\t\t},\n\t\t{\n\t\t\tname:  \"IPv6 address\",\n\t\t\tinput: \"2001:db8::1\",\n\t\t},\n\t\t{\n\t\t\tname:  \"IPv4-mapped IPv6\",\n\t\t\tinput: \"::ffff:192.168.1.100\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\taddr := netip.MustParseAddr(tt.input)\n\n\t\t\tresult, ok := ClampIP(addr)\n\t\t\tif !ok {\n\t\t\t\tt.Fatalf(\"ClampIP(%s) returned false, want true\", tt.input)\n\t\t\t}\n\n\t\t\t// For valid inputs, we should get the clamped prefix\n\t\t\tif addr.Is4() || addr.Is4In6() {\n\t\t\t\tif result.Bits() != 24 {\n\t\t\t\t\tt.Errorf(\"Expected 24 bits for IPv4, got %d\", result.Bits())\n\t\t\t\t}\n\t\t\t} else if addr.Is6() {\n\t\t\t\tif result.Bits() != 48 {\n\t\t\t\t\tt.Errorf(\"Expected 48 bits for IPv6, got %d\", result.Bits())\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestClampIPZeroValue(t *testing.T) {\n\t// Test that when ClampIP fails, it returns zero value\n\t// Note: It's hard to make addr.Prefix() fail with valid inputs,\n\t// so this test demonstrates the expected behavior\n\taddr := netip.MustParseAddr(\"192.168.1.100\")\n\n\t// Manually create a zero value for comparison\n\tzeroPrefix := netip.Prefix{}\n\n\t// Call ClampIP - it should succeed with valid input\n\tresult, ok := ClampIP(addr)\n\n\t// Verify the function succeeded\n\tif !ok {\n\t\tt.Error(\"ClampIP should succeed with valid input\")\n\t}\n\n\t// Verify that the result is not a zero value\n\tif result == zeroPrefix {\n\t\tt.Error(\"Result should not be zero value for successful operation\")\n\t}\n}\n\nfunc TestClampIPSpecialCases(t *testing.T) {\n\ttests := []struct {\n\t\tname            string\n\t\tinput           string\n\t\texpectedPrefix  int\n\t\texpectedNetwork string\n\t}{\n\t\t{\n\t\t\tname:            \"Minimum IPv4\",\n\t\t\tinput:           \"0.0.0.0\",\n\t\t\texpectedPrefix:  24,\n\t\t\texpectedNetwork: \"0.0.0.0\",\n\t\t},\n\t\t{\n\t\t\tname:            \"Maximum IPv4\",\n\t\t\tinput:           \"255.255.255.255\",\n\t\t\texpectedPrefix:  24,\n\t\t\texpectedNetwork: \"255.255.255.0\",\n\t\t},\n\t\t{\n\t\t\tname:            \"Minimum IPv6\",\n\t\t\tinput:           \"::\",\n\t\t\texpectedPrefix:  48,\n\t\t\texpectedNetwork: \"::\",\n\t\t},\n\t\t{\n\t\t\tname:            \"Maximum IPv6 prefix part\",\n\t\t\tinput:           \"ffff:ffff:ffff::\",\n\t\t\texpectedPrefix:  48,\n\t\t\texpectedNetwork: \"ffff:ffff:ffff::\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\taddr := netip.MustParseAddr(tt.input)\n\n\t\t\tresult, ok := ClampIP(addr)\n\t\t\tif !ok {\n\t\t\t\tt.Fatalf(\"ClampIP(%s) returned false, want true\", tt.input)\n\t\t\t}\n\n\t\t\tif result.Bits() != tt.expectedPrefix {\n\t\t\t\tt.Errorf(\"ClampIP(%s) bits = %d, want %d\", tt.input, result.Bits(), tt.expectedPrefix)\n\t\t\t}\n\n\t\t\tif result.Addr().String() != tt.expectedNetwork {\n\t\t\t\tt.Errorf(\"ClampIP(%s) network = %s, want %s\", tt.input, result.Addr().String(), tt.expectedNetwork)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// Benchmark to ensure the function is performant\nfunc BenchmarkClampIP(b *testing.B) {\n\tipv4 := netip.MustParseAddr(\"192.168.1.100\")\n\tipv6 := netip.MustParseAddr(\"2001:db8::1\")\n\tipv4mapped := netip.MustParseAddr(\"::ffff:192.168.1.100\")\n\n\tb.Run(\"IPv4\", func(b *testing.B) {\n\t\tfor i := 0; i < b.N; i++ {\n\t\t\tClampIP(ipv4)\n\t\t}\n\t})\n\n\tb.Run(\"IPv6\", func(b *testing.B) {\n\t\tfor i := 0; i < b.N; i++ {\n\t\t\tClampIP(ipv6)\n\t\t}\n\t})\n\n\tb.Run(\"IPv4-mapped\", func(b *testing.B) {\n\t\tfor i := 0; i < b.N; i++ {\n\t\t\tClampIP(ipv4mapped)\n\t\t}\n\t})\n}"
  },
  {
    "path": "internal/dns/cache.go",
    "content": "package dns\n\nimport (\n\t\"log/slog\"\n\t\"time\"\n\n\t\"github.com/TecharoHQ/anubis/lib/store\"\n\n\t_ \"github.com/TecharoHQ/anubis/lib/store/all\"\n)\n\ntype DnsCache struct {\n\tforward    store.JSON[[]string]\n\treverse    store.JSON[[]string]\n\tforwardTTL time.Duration\n\treverseTTL time.Duration\n}\n\nfunc NewDNSCache(forwardTTL int, reverseTTL int, backend store.Interface) *DnsCache {\n\treturn &DnsCache{\n\t\tforward: store.JSON[[]string]{\n\t\t\tUnderlying: backend,\n\t\t\tPrefix:     \"forwardDNS\",\n\t\t},\n\t\treverse: store.JSON[[]string]{\n\t\t\tUnderlying: backend,\n\t\t\tPrefix:     \"reverseDNS\",\n\t\t},\n\t\tforwardTTL: time.Duration(forwardTTL) * time.Second,\n\t\treverseTTL: time.Duration(reverseTTL) * time.Second,\n\t}\n}\n\nfunc (d *Dns) getCachedForward(host string) ([]string, bool) {\n\tif d.cache == nil {\n\t\treturn nil, false\n\t}\n\tif cached, err := d.cache.forward.Get(d.ctx, host); err == nil {\n\t\tslog.Debug(\"DNS: forward cache hit\", \"name\", host, \"ips\", cached)\n\t\treturn cached, true\n\t}\n\tslog.Debug(\"DNS: forward cache miss\", \"name\", host)\n\treturn nil, false\n}\n\nfunc (d *Dns) getCachedReverse(addr string) ([]string, bool) {\n\tif d.cache == nil {\n\t\treturn nil, false\n\t}\n\tif cached, err := d.cache.reverse.Get(d.ctx, addr); err == nil {\n\t\tslog.Debug(\"DNS: reverse cache hit\", \"addr\", addr, \"names\", cached)\n\t\treturn cached, true\n\t}\n\tslog.Debug(\"DNS: reverse cache miss\", \"addr\", addr)\n\treturn nil, false\n}\n\nfunc (d *Dns) forwardCachePut(host string, entries []string) {\n\tif d.cache == nil {\n\t\treturn\n\t}\n\td.cache.forward.Set(d.ctx, host, entries, d.cache.forwardTTL)\n}\n\nfunc (d *Dns) reverseCachePut(addr string, entries []string) {\n\tif d.cache == nil {\n\t\treturn\n\t}\n\td.cache.reverse.Set(d.ctx, addr, entries, d.cache.reverseTTL)\n}\n"
  },
  {
    "path": "internal/dns/dns.go",
    "content": "package dns\n\nimport (\n\t\"context\"\n\t\"encoding/hex\"\n\t\"errors\"\n\t\"fmt\"\n\t\"log/slog\"\n\t\"net\"\n\t\"regexp\"\n\t\"slices\"\n\t\"strings\"\n)\n\nvar (\n\tDNSLookupAddr = net.LookupAddr\n\tDNSLookupHost = net.LookupHost\n)\n\ntype Dns struct {\n\tcache *DnsCache\n\tctx   context.Context\n}\n\nfunc New(ctx context.Context, cache *DnsCache) *Dns {\n\treturn &Dns{\n\t\tcache: cache,\n\t\tctx:   ctx,\n\t}\n}\n\n// ReverseDNS performs a reverse DNS lookup for the given IP address and trims the trailing dot from the results.\nfunc (d *Dns) ReverseDNS(addr string) ([]string, error) {\n\tslog.Debug(\"DNS: performing reverse lookup\", \"addr\", addr)\n\n\tif cached, ok := d.getCachedReverse(addr); ok {\n\t\treturn cached, nil\n\t}\n\n\tnames, err := DNSLookupAddr(addr)\n\tif err != nil {\n\t\tif dnsErr, ok := err.(*net.DNSError); ok && dnsErr.IsNotFound {\n\t\t\tslog.Debug(\"DNS: no PTR record found\", \"addr\", addr)\n\t\t\treturn []string{}, nil\n\t\t}\n\t\tslog.Error(\"DNS: reverse lookup failed\", \"addr\", addr, \"err\", err)\n\t\treturn nil, err\n\t}\n\n\tslog.Debug(\"DNS: reverse lookup successful\", \"addr\", addr, \"names\", names)\n\n\ttrimmedNames := make([]string, len(names))\n\tfor i, name := range names {\n\t\ttrimmedNames[i] = strings.TrimSuffix(name, \".\")\n\t}\n\td.reverseCachePut(addr, trimmedNames)\n\n\treturn trimmedNames, nil\n}\n\n// LookupHost performs a forward DNS lookup for the given hostname.\nfunc (d *Dns) LookupHost(host string) ([]string, error) {\n\tslog.Debug(\"DNS: performing forward lookup\", \"host\", host)\n\n\tif cached, ok := d.getCachedForward(host); ok {\n\t\treturn cached, nil\n\t}\n\n\taddrs, err := DNSLookupHost(host)\n\tif err != nil {\n\t\tif dnsErr, ok := err.(*net.DNSError); ok && dnsErr.IsNotFound {\n\t\t\tslog.Debug(\"DNS: no A/AAAA record found\", \"host\", host)\n\t\t\treturn []string{}, nil\n\t\t}\n\t\tslog.Error(\"DNS: forward lookup failed\", \"host\", host, \"err\", err)\n\t\treturn nil, err\n\t}\n\n\tslog.Debug(\"DNS: forward lookup successful\", \"host\", host, \"addrs\", addrs)\n\td.forwardCachePut(host, addrs)\n\treturn addrs, nil\n}\n\n// verifyFCrDNSInternal performs the second half of the FCrDNS check, using a\n// pre-fetched list of names to perform the forward lookups.\nfunc (d *Dns) verifyFCrDNSInternal(addr string, names []string) bool {\n\tfor _, name := range names {\n\t\tif cached, err := d.LookupHost(name); err == nil {\n\t\t\tif slices.Contains(cached, addr) {\n\t\t\t\tslog.Info(\"DNS: forward lookup confirmed original IP\", \"name\", name, \"addr\", addr)\n\t\t\t\treturn true\n\t\t\t}\n\t\t\tcontinue\n\t\t}\n\t}\n\n\tslog.Info(\"DNS: could not confirm original IP in forward lookups\", \"addr\", addr)\n\treturn false\n}\n\n// VerifyFCrDNS performs a forward-confirmed reverse DNS (FCrDNS) lookup for the given IP address,\n// optionally matching against a provided pattern.\nfunc (d *Dns) VerifyFCrDNS(addr string, pattern *string) bool {\n\tvar patternVal string\n\tif pattern != nil {\n\t\tpatternVal = *pattern\n\t}\n\tslog.Debug(\"DNS: performing FCrDNS lookup\", \"addr\", addr, \"pattern\", patternVal)\n\n\tnames, err := d.ReverseDNS(addr)\n\tif err != nil {\n\t\treturn false\n\t}\n\tif len(names) == 0 {\n\t\treturn pattern == nil // If no pattern specified, check is passed\n\t}\n\n\t// If a pattern is provided, check for a match.\n\tif pattern != nil {\n\t\tanyNameMatched := false\n\t\tfor _, name := range names {\n\t\t\tmatched, err := regexp.MatchString(*pattern, name)\n\t\t\tif err != nil {\n\t\t\t\tslog.Error(\"DNS: verifyFCrDNS invalid regex pattern\", \"err\", err)\n\t\t\t\treturn false // Invalid pattern is a failure.\n\t\t\t}\n\t\t\tif matched {\n\t\t\t\tanyNameMatched = true\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\tif !anyNameMatched {\n\t\t\tslog.Debug(\"DNS: FCrDNS no PTR matches the pattern\", \"addr\", addr, \"pattern\", *pattern)\n\t\t\treturn false\n\t\t}\n\t\tslog.Debug(\"DNS: FCrDNS PTR matched pattern, proceeding with forward check\", \"addr\", addr, \"pattern\", *pattern)\n\t}\n\n\t// If we're here, either there was no pattern, or the pattern matched.\n\t// Proceed with the forward lookup confirmation.\n\treturn d.verifyFCrDNSInternal(addr, names)\n}\n\n// ArpaReverseIP performs translation from ip v4/v6 to arpa reverse notation\nfunc (d *Dns) ArpaReverseIP(addr string) (string, error) {\n\tip := net.ParseIP(addr)\n\tif ip == nil {\n\t\treturn addr, errors.New(\"invalid IP address\")\n\t}\n\n\tif ipv4 := ip.To4(); ipv4 != nil {\n\t\treturn fmt.Sprintf(\"%d.%d.%d.%d\", ipv4[3], ipv4[2], ipv4[1], ipv4[0]), nil\n\t}\n\n\tipv6 := ip.To16()\n\tif ipv6 == nil {\n\t\treturn addr, errors.New(\"invalid IPv6 address\")\n\t}\n\n\thexBytes := make([]byte, hex.EncodedLen(len(ipv6)))\n\thex.Encode(hexBytes, ipv6)\n\n\tvar sb strings.Builder\n\tsb.Grow(len(hexBytes)*2 - 1)\n\n\tfor i := len(hexBytes) - 1; i >= 0; i-- {\n\t\tsb.WriteByte(hexBytes[i])\n\t\tif i > 0 {\n\t\t\tsb.WriteByte('.')\n\t\t}\n\t}\n\treturn sb.String(), nil\n}\n"
  },
  {
    "path": "internal/dns/dns_test.go",
    "content": "package dns\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"net\"\n\t\"reflect\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/TecharoHQ/anubis/lib/store/memory\"\n)\n\n// newTestDNS is a helper function to create a new Dns object with an in-memory cache for testing.\nfunc newTestDNS(forwardTTL int, reverseTTL int) *Dns {\n\tctx := context.Background()\n\tmemStore := memory.New(ctx)\n\tcache := NewDNSCache(forwardTTL, reverseTTL, memStore)\n\treturn New(ctx, cache)\n}\n\n// mockLookupAddr is a mock implementation of the net.LookupAddr function.\nfunc mockLookupAddr(addr string) ([]string, error) {\n\tswitch addr {\n\tcase \"8.8.8.8\":\n\t\treturn []string{\"dns.google.\"}, nil\n\tcase \"1.1.1.1\":\n\t\treturn []string{\"one.one.one.one.\"}, nil\n\tcase \"208.67.222.222\":\n\t\treturn []string{\"resolver1.opendns.com.\"}, nil\n\tcase \"9.9.9.9\":\n\t\treturn nil, &net.DNSError{Err: \"no such host\", Name: \"9.9.9.9\", IsNotFound: true}\n\tcase \"1.2.3.4\":\n\t\treturn nil, errors.New(\"unknown error\")\n\tdefault:\n\t\treturn nil, &net.DNSError{Err: \"no such host\", Name: addr, IsNotFound: true}\n\t}\n}\n\n// mockLookupHost is a mock implementation of the net.LookupHost function.\nfunc mockLookupHost(host string) ([]string, error) {\n\tswitch host {\n\tcase \"dns.google\":\n\t\treturn []string{\"8.8.8.8\", \"8.8.4.4\"}, nil\n\tcase \"one.one.one.one\":\n\t\treturn []string{\"1.1.1.1\", \"1.0.0.1\"}, nil\n\tcase \"resolver1.opendns.com\":\n\t\treturn []string{\"208.67.222.222\"}, nil\n\tcase \"example.com\":\n\t\treturn nil, &net.DNSError{Err: \"no such host\", Name: \"example.com\", IsNotFound: true}\n\tdefault:\n\t\treturn nil, &net.DNSError{Err: \"no such host\", Name: host, IsNotFound: true}\n\t}\n}\n\nfunc TestMain(m *testing.M) {\n\t// Before all tests\n\toriginalLookupAddr := DNSLookupAddr\n\toriginalLookupHost := DNSLookupHost\n\n\tDNSLookupAddr = mockLookupAddr\n\tDNSLookupHost = mockLookupHost\n\n\t// Run tests\n\texitCode := m.Run()\n\n\t// After all tests\n\tDNSLookupAddr = originalLookupAddr\n\tDNSLookupHost = originalLookupHost\n\n\t// Exit\n\tif exitCode != 0 {\n\t\tpanic(exitCode)\n\t}\n}\n\nfunc TestDns_ArpaReverseIP(t *testing.T) {\n\td := newTestDNS(0, 0)\n\ttests := []struct {\n\t\tname    string\n\t\tip      string\n\t\twant    string\n\t\twantErr bool\n\t}{\n\t\t{\"ipv4\", \"192.0.2.1\", \"1.2.0.192\", false},\n\t\t{\"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},\n\t\t{\"invalid ip\", \"invalid\", \"invalid\", true},\n\t\t{\"ipv4-mapped ipv6\", \"::ffff:192.0.2.1\", \"1.2.0.192\", false},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot, err := d.ArpaReverseIP(tt.ip)\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"ArpaReverseIP() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif got != tt.want {\n\t\t\t\tt.Errorf(\"ArpaReverseIP() = %v, want %v\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestDns_ReverseDNS(t *testing.T) {\n\td := newTestDNS(1, 1) // short TTL for testing cache\n\n\t// First call - cache miss\n\tt.Run(\"cache miss\", func(t *testing.T) {\n\t\tgot, err := d.ReverseDNS(\"8.8.8.8\")\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"ReverseDNS() error = %v\", err)\n\t\t}\n\t\twant := []string{\"dns.google\"}\n\t\tif !reflect.DeepEqual(got, want) {\n\t\t\tt.Errorf(\"ReverseDNS() = %v, want %v\", got, want)\n\t\t}\n\t})\n\n\t// Second call - cache hit\n\tt.Run(\"cache hit\", func(t *testing.T) {\n\t\t// Temporarily replace lookup function to ensure cache is used\n\t\toriginalLookupAddr := DNSLookupAddr\n\t\tDNSLookupAddr = func(addr string) ([]string, error) {\n\t\t\treturn nil, errors.New(\"should not be called\")\n\t\t}\n\t\tdefer func() { DNSLookupAddr = originalLookupAddr }()\n\n\t\tgot, err := d.ReverseDNS(\"8.8.8.8\")\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"ReverseDNS() error = %v\", err)\n\t\t}\n\t\twant := []string{\"dns.google\"}\n\t\tif !reflect.DeepEqual(got, want) {\n\t\t\tt.Errorf(\"ReverseDNS() = %v, want %v\", got, want)\n\t\t}\n\t})\n\n\t// Test cache expiration\n\tt.Run(\"cache expiration\", func(t *testing.T) {\n\t\ttime.Sleep(2 * time.Second)\n\t\t// Now the cache should be expired\n\t\t// We expect the mock to be called again\n\t\t// To test this we will change the mock to return something different\n\t\toriginalLookupAddr := DNSLookupAddr\n\t\tDNSLookupAddr = func(addr string) ([]string, error) {\n\t\t\tif addr == \"8.8.8.8\" {\n\t\t\t\treturn []string{\"expired.google.\"}, nil\n\t\t\t}\n\t\t\treturn mockLookupAddr(addr)\n\t\t}\n\t\tdefer func() { DNSLookupAddr = originalLookupAddr }()\n\n\t\tgot, err := d.ReverseDNS(\"8.8.8.8\")\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"ReverseDNS() error = %v\", err)\n\t\t}\n\t\twant := []string{\"expired.google\"}\n\t\tif !reflect.DeepEqual(got, want) {\n\t\t\tt.Errorf(\"ReverseDNS() = %v, want %v\", got, want)\n\t\t}\n\t})\n\n\t// Test not found\n\tt.Run(\"not found\", func(t *testing.T) {\n\t\tgot, err := d.ReverseDNS(\"9.9.9.9\")\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"ReverseDNS() error = %v\", err)\n\t\t}\n\t\tif len(got) != 0 {\n\t\t\tt.Errorf(\"ReverseDNS() = %v, want empty slice\", got)\n\t\t}\n\t})\n}\n\nfunc TestDns_LookupHost(t *testing.T) {\n\td := newTestDNS(1, 1)\n\n\tt.Run(\"cache miss\", func(t *testing.T) {\n\t\tgot, err := d.LookupHost(\"dns.google\")\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"LookupHost() error = %v\", err)\n\t\t}\n\t\twant := []string{\"8.8.8.8\", \"8.8.4.4\"}\n\t\tif !reflect.DeepEqual(got, want) {\n\t\t\tt.Errorf(\"LookupHost() = %v, want %v\", got, want)\n\t\t}\n\t})\n\n\tt.Run(\"cache hit\", func(t *testing.T) {\n\t\toriginalLookupHost := DNSLookupHost\n\t\tDNSLookupHost = func(host string) ([]string, error) {\n\t\t\treturn nil, errors.New(\"should not be called\")\n\t\t}\n\t\tdefer func() { DNSLookupHost = originalLookupHost }()\n\n\t\tgot, err := d.LookupHost(\"dns.google\")\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"LookupHost() error = %v\", err)\n\t\t}\n\t\twant := []string{\"8.8.8.8\", \"8.8.4.4\"}\n\t\tif !reflect.DeepEqual(got, want) {\n\t\t\tt.Errorf(\"LookupHost() = %v, want %v\", got, want)\n\t\t}\n\t})\n\n\tt.Run(\"cache expiration\", func(t *testing.T) {\n\t\ttime.Sleep(2 * time.Second)\n\t\toriginalLookupHost := DNSLookupHost\n\t\tDNSLookupHost = func(host string) ([]string, error) {\n\t\t\tif host == \"dns.google\" {\n\t\t\t\treturn []string{\"9.9.9.9\"}, nil\n\t\t\t}\n\t\t\treturn mockLookupHost(host)\n\t\t}\n\t\tdefer func() { DNSLookupHost = originalLookupHost }()\n\n\t\tgot, err := d.LookupHost(\"dns.google\")\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"LookupHost() error = %v\", err)\n\t\t}\n\t\twant := []string{\"9.9.9.9\"}\n\t\tif !reflect.DeepEqual(got, want) {\n\t\t\tt.Errorf(\"LookupHost() = %v, want %v\", got, want)\n\t\t}\n\t})\n\n\tt.Run(\"not found\", func(t *testing.T) {\n\t\tgot, err := d.LookupHost(\"example.com\")\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"LookupHost() error = %v\", err)\n\t\t}\n\t\tif len(got) != 0 {\n\t\t\tt.Errorf(\"LookupHost() = %v, want empty slice\", got)\n\t\t}\n\t})\n}\n\nfunc TestDns_VerifyFCrDNS(t *testing.T) {\n\td := newTestDNS(1, 1)\n\n\t// Helper to convert string to *string\n\tp := func(s string) *string {\n\t\treturn &s\n\t}\n\n\ttests := []struct {\n\t\tname    string\n\t\tip      string\n\t\tpattern *string\n\t\twant    bool\n\t}{\n\t\t// Cases without pattern\n\t\t{\"valid no pattern\", \"8.8.8.8\", nil, true},\n\t\t{\"valid partial no pattern\", \"1.1.1.1\", nil, true},\n\t\t{\"not found no pattern\", \"9.9.9.9\", nil, true},\n\t\t{\"unknown error no pattern\", \"1.2.3.4\", nil, false},\n\n\t\t// Cases with pattern\n\t\t{\"valid match\", \"8.8.8.8\", p(`.*\\.google$`), true},\n\t\t{\"valid no match\", \"8.8.8.8\", p(`\\.com$`), false},\n\t\t{\"not found with pattern\", \"9.9.9.9\", p(\".*\"), false},\n\t\t{\"unknown error with pattern\", \"1.2.3.4\", p(\".*\"), false},\n\t\t{\"invalid pattern\", \"8.8.8.8\", p(`[`), false},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tif got := d.VerifyFCrDNS(tt.ip, tt.pattern); got != tt.want {\n\t\t\t\tt.Errorf(\"VerifyFCrDNS() = %v, want %v\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n\n\tt.Run(\"reverse cache hit\", func(t *testing.T) {\n\t\t// Prime the cache\n\t\tif got := d.VerifyFCrDNS(\"8.8.8.8\", nil); got != true {\n\t\t\tt.Fatalf(\"VerifyFCrDNS() priming failed, got %v, want true\", got)\n\t\t}\n\n\t\t// Now test with a failing lookup to ensure cache is used\n\t\toriginalLookupAddr := DNSLookupAddr\n\t\tDNSLookupAddr = func(addr string) ([]string, error) {\n\t\t\treturn nil, errors.New(\"should not be called\")\n\t\t}\n\t\tdefer func() { DNSLookupAddr = originalLookupAddr }()\n\n\t\tif got := d.VerifyFCrDNS(\"8.8.8.8\", nil); got != true {\n\t\t\tt.Errorf(\"VerifyFCrDNS() = %v, want true\", got)\n\t\t}\n\t})\n\n\tt.Run(\"forward cache hit\", func(t *testing.T) {\n\t\t// Prime the cache\n\t\tif got := d.VerifyFCrDNS(\"8.8.8.8\", nil); got != true {\n\t\t\tt.Fatalf(\"VerifyFCrDNS() priming failed, got %v, want true\", got)\n\t\t}\n\n\t\t// Now test with a failing lookup to ensure cache is used\n\t\toriginalLookupHost := DNSLookupHost\n\t\tDNSLookupHost = func(host string) ([]string, error) {\n\t\t\treturn nil, errors.New(\"should not be called\")\n\t\t}\n\t\tdefer func() { DNSLookupHost = originalLookupHost }()\n\n\t\tif got := d.VerifyFCrDNS(\"8.8.8.8\", nil); got != true {\n\t\t\tt.Errorf(\"VerifyFCrDNS() = %v, want true\", got)\n\t\t}\n\t})\n}\n"
  },
  {
    "path": "internal/dnsbl/dnsbl.go",
    "content": "package dnsbl\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"net\"\n\t\"strings\"\n)\n\n//go:generate go tool golang.org/x/tools/cmd/stringer -type=DroneBLResponse\n\ntype DroneBLResponse byte\n\nconst (\n\tAllGood               DroneBLResponse = 0\n\tIRCDrone              DroneBLResponse = 3\n\tBottler               DroneBLResponse = 5\n\tUnknownSpambotOrDrone DroneBLResponse = 6\n\tDDOSDrone             DroneBLResponse = 7\n\tSOCKSProxy            DroneBLResponse = 8\n\tHTTPProxy             DroneBLResponse = 9\n\tProxyChain            DroneBLResponse = 10\n\tOpenProxy             DroneBLResponse = 11\n\tOpenDNSResolver       DroneBLResponse = 12\n\tBruteForceAttackers   DroneBLResponse = 13\n\tOpenWingateProxy      DroneBLResponse = 14\n\tCompromisedRouter     DroneBLResponse = 15\n\tAutoRootingWorms      DroneBLResponse = 16\n\tAutoDetectedBotIP     DroneBLResponse = 17\n\tUnknown               DroneBLResponse = 255\n)\n\nfunc Reverse(ip net.IP) string {\n\tif ip.To4() != nil {\n\t\treturn reverse4(ip)\n\t}\n\n\treturn reverse6(ip)\n}\n\nfunc reverse4(ip net.IP) string {\n\tsplitAddress := strings.Split(ip.String(), \".\")\n\n\t// swap first and last octet\n\tsplitAddress[0], splitAddress[3] = splitAddress[3], splitAddress[0]\n\t// swap middle octets\n\tsplitAddress[1], splitAddress[2] = splitAddress[2], splitAddress[1]\n\n\treturn strings.Join(splitAddress, \".\")\n}\n\nfunc reverse6(ip net.IP) string {\n\tipBytes := []byte(ip)\n\tvar sb strings.Builder\n\n\tfor i := len(ipBytes) - 1; i >= 0; i-- {\n\t\t// Split the byte into two nibbles\n\t\thighNibble := ipBytes[i] >> 4\n\t\tlowNibble := ipBytes[i] & 0x0F\n\n\t\t// Append the nibbles in reversed order\n\t\tsb.WriteString(fmt.Sprintf(\"%x.%x.\", lowNibble, highNibble))\n\t}\n\n\treturn sb.String()[:len(sb.String())-1]\n}\n\nfunc Lookup(ipStr string) (DroneBLResponse, error) {\n\tip := net.ParseIP(ipStr)\n\tif ip == nil {\n\t\treturn Unknown, errors.New(\"dnsbl: input is not an IP address\")\n\t}\n\n\trevIP := Reverse(ip) + \".dnsbl.dronebl.org\"\n\n\tips, err := net.LookupIP(revIP)\n\tif err != nil {\n\t\tvar dnserr *net.DNSError\n\t\tif errors.As(err, &dnserr) {\n\t\t\tif dnserr.IsNotFound {\n\t\t\t\treturn AllGood, nil\n\t\t\t}\n\t\t}\n\n\t\treturn Unknown, err\n\t}\n\n\tif len(ips) != 0 {\n\t\tfor _, ip := range ips {\n\t\t\treturn DroneBLResponse(ip.To4()[3]), nil\n\t\t}\n\t}\n\n\treturn UnknownSpambotOrDrone, nil\n}\n"
  },
  {
    "path": "internal/dnsbl/dnsbl_test.go",
    "content": "package dnsbl\n\nimport (\n\t\"fmt\"\n\t\"net\"\n\t\"os\"\n\t\"testing\"\n)\n\nfunc TestReverse4(t *testing.T) {\n\tcases := []struct {\n\t\tinp, out string\n\t}{\n\t\t{\"1.2.3.4\", \"4.3.2.1\"},\n\t}\n\n\tfor _, cs := range cases {\n\t\tt.Run(fmt.Sprintf(\"%s->%s\", cs.inp, cs.out), func(t *testing.T) {\n\t\t\tout := reverse4(net.ParseIP(cs.inp))\n\n\t\t\tif out != cs.out {\n\t\t\t\tt.Errorf(\"wanted %s\\ngot:   %s\", cs.out, out)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestReverse6(t *testing.T) {\n\tcases := []struct {\n\t\tinp, out string\n\t}{\n\t\t{\n\t\t\tinp: \"1234:5678:9ABC:DEF0:1234:5678:9ABC:DEF0\",\n\t\t\tout: \"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\",\n\t\t},\n\t}\n\n\tfor _, cs := range cases {\n\t\tt.Run(fmt.Sprintf(\"%s->%s\", cs.inp, cs.out), func(t *testing.T) {\n\t\t\tout := reverse6(net.ParseIP(cs.inp))\n\n\t\t\tif out != cs.out {\n\t\t\t\tt.Errorf(\"wanted %s, got: %s\", cs.out, out)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestLookup(t *testing.T) {\n\tif os.Getenv(\"DONT_USE_NETWORK\") != \"\" {\n\t\tt.Skip(\"test requires network egress\")\n\t\treturn\n\t}\n\n\tresp, err := Lookup(\"27.65.243.194\")\n\tif err != nil {\n\t\tt.Fatalf(\"it broked: %v\", err)\n\t}\n\n\tt.Logf(\"response: %d\", resp)\n}\n"
  },
  {
    "path": "internal/dnsbl/droneblresponse_string.go",
    "content": "// Code generated by \"stringer -type=DroneBLResponse\"; DO NOT EDIT.\n\npackage dnsbl\n\nimport \"strconv\"\n\nfunc _() {\n\t// An \"invalid array index\" compiler error signifies that the constant values have changed.\n\t// Re-run the stringer command to generate them again.\n\tvar x [1]struct{}\n\t_ = x[AllGood-0]\n\t_ = x[IRCDrone-3]\n\t_ = x[Bottler-5]\n\t_ = x[UnknownSpambotOrDrone-6]\n\t_ = x[DDOSDrone-7]\n\t_ = x[SOCKSProxy-8]\n\t_ = x[HTTPProxy-9]\n\t_ = x[ProxyChain-10]\n\t_ = x[OpenProxy-11]\n\t_ = x[OpenDNSResolver-12]\n\t_ = x[BruteForceAttackers-13]\n\t_ = x[OpenWingateProxy-14]\n\t_ = x[CompromisedRouter-15]\n\t_ = x[AutoRootingWorms-16]\n\t_ = x[AutoDetectedBotIP-17]\n\t_ = x[Unknown-255]\n}\n\nconst (\n\t_DroneBLResponse_name_0 = \"AllGood\"\n\t_DroneBLResponse_name_1 = \"IRCDrone\"\n\t_DroneBLResponse_name_2 = \"BottlerUnknownSpambotOrDroneDDOSDroneSOCKSProxyHTTPProxyProxyChainOpenProxyOpenDNSResolverBruteForceAttackersOpenWingateProxyCompromisedRouterAutoRootingWormsAutoDetectedBotIP\"\n\t_DroneBLResponse_name_3 = \"Unknown\"\n)\n\nvar (\n\t_DroneBLResponse_index_2 = [...]uint8{0, 7, 28, 37, 47, 56, 66, 75, 90, 109, 125, 142, 158, 175}\n)\n\nfunc (i DroneBLResponse) String() string {\n\tswitch {\n\tcase i == 0:\n\t\treturn _DroneBLResponse_name_0\n\tcase i == 3:\n\t\treturn _DroneBLResponse_name_1\n\tcase 5 <= i && i <= 17:\n\t\ti -= 5\n\t\treturn _DroneBLResponse_name_2[_DroneBLResponse_index_2[i]:_DroneBLResponse_index_2[i+1]]\n\tcase i == 255:\n\t\treturn _DroneBLResponse_name_3\n\tdefault:\n\t\treturn \"DroneBLResponse(\" + strconv.FormatInt(int64(i), 10) + \")\"\n\t}\n}\n"
  },
  {
    "path": "internal/glob/glob.go",
    "content": "package glob\n\nimport \"strings\"\n\nconst GLOB = \"*\"\n\nconst maxGlobParts = 5\n\n// Glob will test a string pattern, potentially containing globs, against a\n// subject string. The result is a simple true/false, determining whether or\n// not the glob pattern matched the subject text.\nfunc Glob(pattern, subj string) bool {\n\t// Empty pattern can only match empty subject\n\tif pattern == \"\" {\n\t\treturn subj == pattern\n\t}\n\n\t// If the pattern _is_ a glob, it matches everything\n\tif pattern == GLOB {\n\t\treturn true\n\t}\n\n\tparts := strings.Split(pattern, GLOB)\n\n\tif len(parts) > maxGlobParts {\n\t\treturn false // Pattern is too complex, reject it.\n\t}\n\n\tif len(parts) == 1 {\n\t\t// No globs in pattern, so test for equality\n\t\treturn subj == pattern\n\t}\n\n\tleadingGlob := strings.HasPrefix(pattern, GLOB)\n\ttrailingGlob := strings.HasSuffix(pattern, GLOB)\n\tend := len(parts) - 1\n\n\t// Go over the leading parts and ensure they match.\n\tfor i := range end {\n\t\tidx := strings.Index(subj, parts[i])\n\n\t\tswitch i {\n\t\tcase 0:\n\t\t\t// Check the first section. Requires special handling.\n\t\t\tif !leadingGlob && idx != 0 {\n\t\t\t\treturn false\n\t\t\t}\n\t\tdefault:\n\t\t\t// Check that the middle parts match.\n\t\t\tif idx < 0 {\n\t\t\t\treturn false\n\t\t\t}\n\t\t}\n\n\t\t// Trim evaluated text from subj as we loop over the pattern.\n\t\tsubj = subj[idx+len(parts[i]):]\n\t}\n\n\t// Reached the last section. Requires special handling.\n\treturn trailingGlob || strings.HasSuffix(subj, parts[end])\n}\n"
  },
  {
    "path": "internal/glob/glob_test.go",
    "content": "package glob\n\nimport \"testing\"\n\nfunc TestGlob_EqualityAndEmpty(t *testing.T) {\n\tcases := []struct {\n\t\tname    string\n\t\tpattern string\n\t\tsubj    string\n\t\twant    bool\n\t}{\n\t\t{\"exact match\", \"hello\", \"hello\", true},\n\t\t{\"exact mismatch\", \"hello\", \"hell\", false},\n\t\t{\"empty pattern and subject\", \"\", \"\", true},\n\t\t{\"empty pattern with non-empty subject\", \"\", \"x\", false},\n\t\t{\"pattern star matches empty\", \"*\", \"\", true},\n\t\t{\"pattern star matches anything\", \"*\", \"anything at all\", true},\n\t}\n\tfor _, tc := range cases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tif got := Glob(tc.pattern, tc.subj); got != tc.want {\n\t\t\t\tt.Fatalf(\"Glob(%q,%q) = %v, want %v\", tc.pattern, tc.subj, got, tc.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestGlob_LeadingAndTrailing(t *testing.T) {\n\tcases := []struct {\n\t\tname    string\n\t\tpattern string\n\t\tsubj    string\n\t\twant    bool\n\t}{\n\t\t{\"prefix match - minimal\", \"foo*\", \"foo\", true},\n\t\t{\"prefix match - extended\", \"foo*\", \"foobar\", true},\n\t\t{\"prefix mismatch - not at start\", \"foo*\", \"xfoo\", false},\n\t\t{\"suffix match - minimal\", \"*foo\", \"foo\", true},\n\t\t{\"suffix match - extended\", \"*foo\", \"xfoo\", true},\n\t\t{\"suffix mismatch - not at end\", \"*foo\", \"foox\", false},\n\t\t{\"contains match\", \"*foo*\", \"barfoobaz\", true},\n\t\t{\"contains mismatch - missing needle\", \"*foo*\", \"f\", false},\n\t}\n\tfor _, tc := range cases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tif got := Glob(tc.pattern, tc.subj); got != tc.want {\n\t\t\t\tt.Fatalf(\"Glob(%q,%q) = %v, want %v\", tc.pattern, tc.subj, got, tc.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestGlob_MiddleAndOrder(t *testing.T) {\n\tcases := []struct {\n\t\tname    string\n\t\tpattern string\n\t\tsubj    string\n\t\twant    bool\n\t}{\n\t\t{\"middle wildcard basic\", \"f*o\", \"fo\", true},\n\t\t{\"middle wildcard gap\", \"f*o\", \"fZZZo\", true},\n\t\t{\"middle wildcard requires start f\", \"f*o\", \"xfyo\", false},\n\t\t{\"order enforced across parts\", \"a*b*c*d\", \"axxbxxcxxd\", true},\n\t\t{\"order mismatch fails\", \"a*b*c*d\", \"abdc\", false},\n\t\t{\"must end with last part when no trailing *\", \"*foo*bar\", \"zzfooqqbar\", true},\n\t\t{\"failing when trailing chars remain\", \"*foo*bar\", \"zzfooqqbarzz\", false},\n\t\t{\"first part must start when no leading *\", \"foo*bar\", \"zzfooqqbar\", false},\n\t\t{\"works with overlapping content\", \"ab*ba\", \"ababa\", true},\n\t\t{\"needle not found fails\", \"foo*bar\", \"foobaz\", false},\n\t}\n\tfor _, tc := range cases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tif got := Glob(tc.pattern, tc.subj); got != tc.want {\n\t\t\t\tt.Fatalf(\"Glob(%q,%q) = %v, want %v\", tc.pattern, tc.subj, got, tc.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestGlob_ConsecutiveStarsAndEmptyParts(t *testing.T) {\n\tcases := []struct {\n\t\tname    string\n\t\tpattern string\n\t\tsubj    string\n\t\twant    bool\n\t}{\n\t\t{\"double star matches anything\", \"**\", \"\", true},\n\t\t{\"double star matches anything non-empty\", \"**\", \"abc\", true},\n\t\t{\"consecutive stars behave like single\", \"a**b\", \"ab\", true},\n\t\t{\"consecutive stars with gaps\", \"a**b\", \"axxxb\", true},\n\t\t{\"consecutive stars + trailing star\", \"a**b*\", \"axxbzzz\", true},\n\t\t{\"consecutive stars still enforce anchors\", \"a**b\", \"xaBy\", false},\n\t}\n\tfor _, tc := range cases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tif got := Glob(tc.pattern, tc.subj); got != tc.want {\n\t\t\t\tt.Fatalf(\"Glob(%q,%q) = %v, want %v\", tc.pattern, tc.subj, got, tc.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestGlob_MaxPartsLimit(t *testing.T) {\n\t// Allowed: up to 4 '*' (5 parts)\n\tallowed := []struct {\n\t\tpattern string\n\t\tsubj    string\n\t\twant    bool\n\t}{\n\t\t{\"a*b*c*d*e\", \"axxbxxcxxdxxe\", true}, // 4 stars -> 5 parts\n\t\t{\"*a*b*c*d\", \"zzzaaaabbbcccddd\", true},\n\t\t{\"a*b*c*d*e\", \"abcde\", true},\n\t\t{\"a*b*c*d*e\", \"abxdxe\", false}, // missing 'c' should fail\n\t}\n\tfor _, tc := range allowed {\n\t\tif got := Glob(tc.pattern, tc.subj); got != tc.want {\n\t\t\tt.Fatalf(\"allowed pattern Glob(%q,%q) = %v, want %v\", tc.pattern, tc.subj, got, tc.want)\n\t\t}\n\t}\n\n\t// Disallowed: 5 '*' (6 parts) -> always false by complexity check\n\tdisallowed := []struct {\n\t\tpattern string\n\t\tsubj    string\n\t}{\n\t\t{\"a*b*c*d*e*f\", \"aXXbYYcZZdQQeRRf\"},\n\t\t{\"*a*b*c*d*e*\", \"abcdef\"},\n\t\t{\"******\", \"anything\"}, // 6 stars -> 7 parts\n\t}\n\tfor _, tc := range disallowed {\n\t\tif got := Glob(tc.pattern, tc.subj); got {\n\t\t\tt.Fatalf(\"disallowed pattern should fail Glob(%q,%q) = %v, want false\", tc.pattern, tc.subj, got)\n\t\t}\n\t}\n}\n\nfunc TestGlob_CaseSensitivity(t *testing.T) {\n\tcases := []struct {\n\t\tpattern string\n\t\tsubj    string\n\t\twant    bool\n\t}{\n\t\t{\"FOO*\", \"foo\", false},\n\t\t{\"*Bar\", \"bar\", false},\n\t\t{\"Foo*Bar\", \"FooZZZBar\", true},\n\t}\n\tfor _, tc := range cases {\n\t\tif got := Glob(tc.pattern, tc.subj); got != tc.want {\n\t\t\tt.Fatalf(\"Glob(%q,%q) = %v, want %v\", tc.pattern, tc.subj, got, tc.want)\n\t\t}\n\t}\n}\n\nfunc TestGlob_EmptySubjectInteractions(t *testing.T) {\n\tcases := []struct {\n\t\tpattern string\n\t\tsubj    string\n\t\twant    bool\n\t}{\n\t\t{\"*a\", \"\", false},\n\t\t{\"a*\", \"\", false},\n\t\t{\"**\", \"\", true},\n\t\t{\"*\", \"\", true},\n\t}\n\tfor _, tc := range cases {\n\t\tif got := Glob(tc.pattern, tc.subj); got != tc.want {\n\t\t\tt.Fatalf(\"Glob(%q,%q) = %v, want %v\", tc.pattern, tc.subj, got, tc.want)\n\t\t}\n\t}\n}\n\nfunc BenchmarkGlob(b *testing.B) {\n\tpatterns := []string{\n\t\t\"*\", \"*foo*\", \"foo*bar\", \"a*b*c*d*e\", \"a**b*\", \"*needle*end\",\n\t}\n\tsubjects := []string{\n\t\t\"\", \"foo\", \"barfoo\", \"foobarbaz\", \"axxbxxcxxdxxe\", \"zzfooqqbarzz\",\n\t\t\"lorem ipsum dolor sit amet, consectetur adipiscing elit\",\n\t}\n\tfor _, p := range patterns {\n\t\tfor _, s := range subjects {\n\t\t\tb.Run(p+\"::\"+s, func(b *testing.B) {\n\t\t\t\tfor i := 0; i < b.N; i++ {\n\t\t\t\t\t_ = Glob(p, s)\n\t\t\t\t}\n\t\t\t})\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "internal/gzip.go",
    "content": "package internal\n\nimport (\n\t\"compress/gzip\"\n\t\"net/http\"\n\t\"strings\"\n)\n\nfunc GzipMiddleware(level int, next http.Handler) http.Handler {\n\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif !strings.Contains(r.Header.Get(\"Accept-Encoding\"), \"gzip\") {\n\t\t\tnext.ServeHTTP(w, r)\n\t\t\treturn\n\t\t}\n\n\t\tw.Header().Set(\"Content-Encoding\", \"gzip\")\n\t\tgz, err := gzip.NewWriterLevel(w, level)\n\t\tif err != nil {\n\t\t\tpanic(err)\n\t\t}\n\t\tdefer gz.Close()\n\n\t\tgrw := gzipResponseWriter{ResponseWriter: w, sink: gz}\n\t\tnext.ServeHTTP(grw, r)\n\t})\n}\n\ntype gzipResponseWriter struct {\n\thttp.ResponseWriter\n\tsink *gzip.Writer\n}\n\nfunc (w gzipResponseWriter) Write(b []byte) (int, error) {\n\treturn w.sink.Write(b)\n}\n"
  },
  {
    "path": "internal/hash.go",
    "content": "package internal\n\nimport (\n\t\"crypto/sha256\"\n\t\"encoding/hex\"\n\t\"strconv\"\n\n\t\"github.com/cespare/xxhash/v2\"\n)\n\n// SHA256sum computes a cryptographic hash. Still used for proof-of-work challenges\n// where we need the security properties of a cryptographic hash function.\nfunc SHA256sum(text string) string {\n\thash := sha256.New()\n\thash.Write([]byte(text))\n\treturn hex.EncodeToString(hash.Sum(nil))\n}\n\n// FastHash is a high-performance non-cryptographic hash function suitable for\n// internal caching, policy rule identification, and other performance-critical\n// use cases where cryptographic security is not required.\nfunc FastHash(text string) string {\n\th := xxhash.Sum64String(text)\n\treturn strconv.FormatUint(h, 16)\n}\n"
  },
  {
    "path": "internal/hash_bench_test.go",
    "content": "package internal\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\t\"testing\"\n)\n\n// XXHash64sum is a test alias for FastHash to benchmark against SHA256\nfunc XXHash64sum(text string) string {\n\treturn FastHash(text)\n}\n\n// Test data that matches real usage patterns in the codebase\nvar (\n\t// Typical policy checker inputs\n\tpolicyInputs = []string{\n\t\t\"User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36\",\n\t\t\"User-Agent: bot/1.0\",\n\t\t\"User-Agent: GoogleBot/2.1\",\n\t\t\"/robots.txt\",\n\t\t\"/api/.*\",\n\t\t\"10.0.0.0/8\",\n\t\t\"192.168.1.0/24\",\n\t\t\"172.16.0.0/12\",\n\t}\n\n\t// Challenge data from challengeFor function\n\tchallengeInputs = []string{\n\t\t\"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\",\n\t\t\"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\",\n\t\t\"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\",\n\t}\n\n\t// Bot rule patterns\n\tbotRuleInputs = []string{\n\t\t\"GoogleBot::path:/robots.txt\",\n\t\t\"BingBot::useragent:Mozilla/5.0 (compatible; bingbot/2.0)\",\n\t\t\"FacebookBot::headers:Accept-Language,User-Agent\",\n\t\t\"TwitterBot::cidr:192.168.1.0/24\",\n\t}\n\n\t// CEL expressions from policy rules\n\tcelInputs = []string{\n\t\t`request.headers[\"User-Agent\"].contains(\"bot\")`,\n\t\t`request.path.startsWith(\"/api/\") && request.method == \"POST\"`,\n\t\t`request.remoteAddress in [\"192.168.1.0/24\", \"10.0.0.0/8\"]`,\n\t\t`request.userAgent.matches(\".*[Bb]ot.*\") || request.userAgent.matches(\".*[Cc]rawler.*\")`,\n\t}\n\n\t// Thoth ASN checker inputs\n\tasnInputs = []string{\n\t\t\"ASNChecker\\nAS 15169\\nAS 8075\\nAS 32934\",\n\t\t\"ASNChecker\\nAS 13335\\nAS 16509\\nAS 14061\",\n\t\t\"ASNChecker\\nAS 36351\\nAS 20940\\nAS 8100\",\n\t}\n)\n\nfunc BenchmarkSHA256_PolicyInputs(b *testing.B) {\n\tb.ResetTimer()\n\tfor i := 0; i < b.N; i++ {\n\t\tinput := policyInputs[i%len(policyInputs)]\n\t\t_ = SHA256sum(input)\n\t}\n}\n\nfunc BenchmarkXXHash_PolicyInputs(b *testing.B) {\n\tb.ResetTimer()\n\tfor i := 0; i < b.N; i++ {\n\t\tinput := policyInputs[i%len(policyInputs)]\n\t\t_ = XXHash64sum(input)\n\t}\n}\n\nfunc BenchmarkSHA256_ChallengeInputs(b *testing.B) {\n\tb.ResetTimer()\n\tfor i := 0; i < b.N; i++ {\n\t\tinput := challengeInputs[i%len(challengeInputs)]\n\t\t_ = SHA256sum(input)\n\t}\n}\n\nfunc BenchmarkXXHash_ChallengeInputs(b *testing.B) {\n\tb.ResetTimer()\n\tfor i := 0; i < b.N; i++ {\n\t\tinput := challengeInputs[i%len(challengeInputs)]\n\t\t_ = XXHash64sum(input)\n\t}\n}\n\nfunc BenchmarkSHA256_BotRuleInputs(b *testing.B) {\n\tb.ResetTimer()\n\tfor i := 0; i < b.N; i++ {\n\t\tinput := botRuleInputs[i%len(botRuleInputs)]\n\t\t_ = SHA256sum(input)\n\t}\n}\n\nfunc BenchmarkXXHash_BotRuleInputs(b *testing.B) {\n\tb.ResetTimer()\n\tfor i := 0; i < b.N; i++ {\n\t\tinput := botRuleInputs[i%len(botRuleInputs)]\n\t\t_ = XXHash64sum(input)\n\t}\n}\n\nfunc BenchmarkSHA256_CELInputs(b *testing.B) {\n\tb.ResetTimer()\n\tfor i := 0; i < b.N; i++ {\n\t\tinput := celInputs[i%len(celInputs)]\n\t\t_ = SHA256sum(input)\n\t}\n}\n\nfunc BenchmarkXXHash_CELInputs(b *testing.B) {\n\tb.ResetTimer()\n\tfor i := 0; i < b.N; i++ {\n\t\tinput := celInputs[i%len(celInputs)]\n\t\t_ = XXHash64sum(input)\n\t}\n}\n\nfunc BenchmarkSHA256_ASNInputs(b *testing.B) {\n\tb.ResetTimer()\n\tfor i := 0; i < b.N; i++ {\n\t\tinput := asnInputs[i%len(asnInputs)]\n\t\t_ = SHA256sum(input)\n\t}\n}\n\nfunc BenchmarkXXHash_ASNInputs(b *testing.B) {\n\tb.ResetTimer()\n\tfor i := 0; i < b.N; i++ {\n\t\tinput := asnInputs[i%len(asnInputs)]\n\t\t_ = XXHash64sum(input)\n\t}\n}\n\n// Benchmark the policy list hashing used in checker.go\nfunc BenchmarkSHA256_PolicyList(b *testing.B) {\n\tb.ResetTimer()\n\tfor i := 0; i < b.N; i++ {\n\t\tvar sb strings.Builder\n\t\tfor _, input := range policyInputs {\n\t\t\tfmt.Fprintln(&sb, SHA256sum(input))\n\t\t}\n\t\t_ = SHA256sum(sb.String())\n\t}\n}\n\nfunc BenchmarkXXHash_PolicyList(b *testing.B) {\n\tb.ResetTimer()\n\tfor i := 0; i < b.N; i++ {\n\t\tvar sb strings.Builder\n\t\tfor _, input := range policyInputs {\n\t\t\tfmt.Fprintln(&sb, XXHash64sum(input))\n\t\t}\n\t\t_ = XXHash64sum(sb.String())\n\t}\n}\n\n// Tests that xxhash doesn't have collisions in realistic scenarios\nfunc TestHashCollisions(t *testing.T) {\n\tallInputs := append(append(append(append(policyInputs, challengeInputs...), botRuleInputs...), celInputs...), asnInputs...)\n\n\t// Start with realistic inputs from actual usage\n\txxhashHashes := make(map[string]string)\n\tfor _, input := range allInputs {\n\t\thash := XXHash64sum(input)\n\t\tif existing, exists := xxhashHashes[hash]; exists {\n\t\t\tt.Errorf(\"XXHash collision detected: %q and %q both hash to %s\", input, existing, hash)\n\t\t}\n\t\txxhashHashes[hash] = input\n\t}\n\n\tt.Logf(\"Basic test: %d realistic inputs, no collisions\", len(allInputs))\n\n\t// Test similar strings that might cause hash collisions\n\tprefixes := []string{\"User-Agent: \", \"X-Real-IP: \", \"Accept-Language: \", \"Host: \"}\n\tsuffixes := []string{\"bot\", \"crawler\", \"spider\", \"scraper\", \"Mozilla\", \"Chrome\", \"Safari\", \"Firefox\"}\n\tvariations := []string{\"\", \"/1.0\", \"/2.0\", \" (compatible)\", \" (Windows)\", \" (Linux)\", \" (Mac)\"}\n\n\tstressCount := 0\n\tfor _, prefix := range prefixes {\n\t\tfor _, suffix := range suffixes {\n\t\t\tfor _, variation := range variations {\n\t\t\t\tfor i := range 100 {\n\t\t\t\t\tinput := fmt.Sprintf(\"%s%s%s-%d\", prefix, suffix, variation, i)\n\t\t\t\t\thash := XXHash64sum(input)\n\t\t\t\t\tif existing, exists := xxhashHashes[hash]; exists {\n\t\t\t\t\t\tt.Errorf(\"XXHash collision in stress test: %q and %q both hash to %s\", input, existing, hash)\n\t\t\t\t\t}\n\t\t\t\t\txxhashHashes[hash] = input\n\t\t\t\t\tstressCount++\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\tt.Logf(\"Stress test 1: %d similar string variations, no collisions\", stressCount)\n\n\t// Test sequential patterns that might be problematic\n\tpatterns := []string{\n\t\t\"192.168.1.%d\",\n\t\t\"10.0.0.%d\",\n\t\t\"172.16.%d.1\",\n\t\t\"challenge-%d\",\n\t\t\"bot-rule-%d\",\n\t\t\"policy-%016x\",\n\t\t\"session-%016x\",\n\t}\n\n\tseqCount := 0\n\tfor _, pattern := range patterns {\n\t\tfor i := range 10000 {\n\t\t\tinput := fmt.Sprintf(pattern, i)\n\t\t\thash := XXHash64sum(input)\n\t\t\tif existing, exists := xxhashHashes[hash]; exists {\n\t\t\t\tt.Errorf(\"XXHash collision in sequential test: %q and %q both hash to %s\", input, existing, hash)\n\t\t\t}\n\t\t\txxhashHashes[hash] = input\n\t\t\tseqCount++\n\t\t}\n\t}\n\tt.Logf(\"Stress test 2: %d sequential patterns, no collisions\", seqCount)\n\n\ttotalInputs := len(allInputs) + stressCount + seqCount\n\tt.Logf(\"TOTAL: Tested %d inputs across realistic scenarios - NO COLLISIONS\", totalInputs)\n}\n\n// Verify xxhash output works as cache keys\nfunc TestXXHashFormat(t *testing.T) {\n\ttestCases := []string{\n\t\t\"short\",\n\t\t\"\",\n\t\t\"very long string with lots of content that might be used in policy checking and other internal hashing scenarios\",\n\t\t\"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\",\n\t}\n\n\tfor _, input := range testCases {\n\t\thash := XXHash64sum(input)\n\n\t\t// Check it's valid hex\n\t\tif len(hash) == 0 {\n\t\t\tt.Errorf(\"Empty hash for input %q\", input)\n\t\t}\n\n\t\t// xxhash is 64-bit so max 16 hex chars\n\t\tif len(hash) > 16 {\n\t\t\tt.Errorf(\"Hash too long for input %q: %s (length %d)\", input, hash, len(hash))\n\t\t}\n\n\t\t// Make sure it's all hex characters\n\t\tfor _, char := range hash {\n\t\t\tif !((char >= '0' && char <= '9') || (char >= 'a' && char <= 'f')) {\n\t\t\t\tt.Errorf(\"Non-hex character %c in hash %s for input %q\", char, hash, input)\n\t\t\t}\n\t\t}\n\n\t\tt.Logf(\"Input: %q -> Hash: %s\", input, hash)\n\t}\n}\n"
  },
  {
    "path": "internal/headers.go",
    "content": "package internal\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"log/slog\"\n\t\"net\"\n\t\"net/http\"\n\t\"net/netip\"\n\t\"strings\"\n\n\t\"github.com/TecharoHQ/anubis\"\n\t\"github.com/sebest/xff\"\n)\n\ntype realIPKey struct{}\n\nfunc RealIP(r *http.Request) (netip.Addr, bool) {\n\tresult, ok := r.Context().Value(realIPKey{}).(netip.Addr)\n\treturn result, ok\n}\n\n// TODO: move into config\ntype XFFComputePreferences struct {\n\tStripPrivate  bool\n\tStripLoopback bool\n\tStripCGNAT    bool\n\tStripLLU      bool\n\tFlatten       bool\n}\n\nvar CGNat = netip.MustParsePrefix(\"100.64.0.0/10\")\n\n// UnchangingCache sets the Cache-Control header to cache a response for 1 year if\n// and only if the application is compiled in \"release\" mode by Docker.\nfunc UnchangingCache(next http.Handler) http.Handler {\n\t//goland:noinspection GoBoolExpressions\n\tif anubis.Version == \"devel\" {\n\t\treturn next\n\t}\n\n\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.Header().Set(\"Cache-Control\", \"public, max-age=31536000\")\n\t\tnext.ServeHTTP(w, r)\n\t})\n}\n\n// CustomXRealIPHeader sets the X-Real-IP header to the value of a\n// different header.\n// Used in environments where the upstream proxy sets the request's\n// origin IP in a custom header.\nfunc CustomRealIPHeader(customRealIPHeaderValue string, next http.Handler) http.Handler {\n\tif customRealIPHeaderValue == \"\" {\n\t\tslog.Debug(\"skipping middleware, customRealIPHeaderValue is empty\")\n\t\treturn next\n\t}\n\n\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tr.Header.Set(\"X-Real-IP\", r.Header.Get(customRealIPHeaderValue))\n\t\tnext.ServeHTTP(w, r)\n\t})\n}\n\n// RemoteXRealIP sets the X-Real-Ip header to the request's real IP if\n// the setting is enabled by the user.\nfunc RemoteXRealIP(useRemoteAddress bool, bindNetwork string, next http.Handler) http.Handler {\n\tif !useRemoteAddress {\n\t\tslog.Debug(\"skipping middleware, useRemoteAddress is empty\")\n\t\treturn next\n\t}\n\n\tif bindNetwork == \"unix\" {\n\t\t// For local sockets there is no real remote address but the localhost\n\t\t// address should be sensible.\n\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tr.Header.Set(\"X-Real-Ip\", \"127.0.0.1\")\n\t\t\tnext.ServeHTTP(w, r)\n\t\t})\n\t}\n\n\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\thost, _, err := net.SplitHostPort(r.RemoteAddr)\n\t\tif err != nil {\n\t\t\tpanic(err) // this should never happen\n\t\t}\n\t\tr.Header.Set(\"X-Real-Ip\", host)\n\t\tif addr, err := netip.ParseAddr(host); err == nil {\n\t\t\tr = r.WithContext(context.WithValue(r.Context(), realIPKey{}, addr))\n\t\t}\n\t\tnext.ServeHTTP(w, r)\n\t})\n}\n\n// XForwardedForToXRealIP sets the X-Real-Ip header based on the contents\n// of the X-Forwarded-For header.\nfunc XForwardedForToXRealIP(next http.Handler) http.Handler {\n\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif xffHeader := r.Header.Get(\"X-Forwarded-For\"); r.Header.Get(\"X-Real-Ip\") == \"\" && xffHeader != \"\" {\n\t\t\tip := xff.Parse(xffHeader)\n\t\t\tslog.Debug(\"setting X-Real-Ip from X-Forwarded-For\", \"to\", ip, \"x-forwarded-for\", xffHeader)\n\t\t\tr.Header.Set(\"X-Real-Ip\", ip)\n\t\t\tif addr, err := netip.ParseAddr(ip); err == nil {\n\t\t\t\tr = r.WithContext(context.WithValue(r.Context(), realIPKey{}, addr))\n\t\t\t}\n\t\t}\n\n\t\tnext.ServeHTTP(w, r)\n\t})\n}\n\n// XForwardedForUpdate sets or updates the X-Forwarded-For header, adding\n// the known remote address to an existing chain if present\nfunc XForwardedForUpdate(stripPrivate bool, next http.Handler) http.Handler {\n\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tdefer next.ServeHTTP(w, r)\n\n\t\tpref := XFFComputePreferences{\n\t\t\tStripPrivate:  stripPrivate,\n\t\t\tStripLoopback: true,\n\t\t\tStripCGNAT:    true,\n\t\t\tFlatten:       true,\n\t\t\tStripLLU:      true,\n\t\t}\n\n\t\tremoteAddr := r.RemoteAddr\n\t\torigXFFHeader := r.Header.Get(\"X-Forwarded-For\")\n\n\t\tif remoteAddr == \"@\" {\n\t\t\t// remote is a unix socket\n\t\t\t// do not touch chain\n\t\t\treturn\n\t\t}\n\n\t\txffHeaderString, err := computeXFFHeader(remoteAddr, origXFFHeader, pref)\n\t\tif err != nil {\n\t\t\tslog.Debug(\"computing X-Forwarded-For header failed\", \"err\", err)\n\t\t\treturn\n\t\t}\n\n\t\tif len(xffHeaderString) == 0 {\n\t\t\tr.Header.Del(\"X-Forwarded-For\")\n\t\t} else {\n\t\t\tr.Header.Set(\"X-Forwarded-For\", xffHeaderString)\n\t\t}\n\t})\n}\n\nvar (\n\tErrCantSplitHostParse = errors.New(\"internal: unable to net.SplitHostParse\")\n\tErrCantParseRemoteIP  = errors.New(\"internal: unable to parse remote IP\")\n)\n\nfunc computeXFFHeader(remoteAddr string, origXFFHeader string, pref XFFComputePreferences) (string, error) {\n\tremoteIP, _, err := net.SplitHostPort(remoteAddr)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"%w: %w\", ErrCantSplitHostParse, err)\n\t}\n\tparsedRemoteIP, err := netip.ParseAddr(remoteIP)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"%w: %w\", ErrCantParseRemoteIP, err)\n\t}\n\n\torigForwardedList := make([]string, 0, 4)\n\tif origXFFHeader != \"\" {\n\t\torigForwardedList = strings.Split(origXFFHeader, \",\")\n\t\tfor i := range origForwardedList {\n\t\t\torigForwardedList[i] = strings.TrimSpace(origForwardedList[i])\n\t\t}\n\t}\n\torigForwardedList = append(origForwardedList, parsedRemoteIP.String())\n\tforwardedList := make([]string, 0, len(origForwardedList))\n\t// this behavior is equivalent to\n\t// ingress-nginx \"compute-full-forwarded-for\"\n\t// https://kubernetes.github.io/ingress-nginx/user-guide/nginx-configuration/configmap/#compute-full-forwarded-for\n\t//\n\t// this would be the correct place to strip and/or flatten this list\n\t//\n\t// strip - iterate backwards and eliminate configured trusted IPs\n\t// flatten - only return the last element to avoid spoofing confusion\n\t//\n\t// many applications handle this in different ways, but\n\t// generally they'd be expected to do these two things on\n\t// their own end to find the first non-spoofed IP\n\tfor i := len(origForwardedList) - 1; i >= 0; i-- {\n\t\tsegmentIP, err := netip.ParseAddr(strings.TrimSpace(origForwardedList[i]))\n\t\tif err != nil {\n\t\t\t// can't assess this element, so the remainder of the chain\n\t\t\t// can't be trusted. not a fatal error, since anyone can\n\t\t\t// spoof an XFF header\n\t\t\tslog.Debug(\"failed to parse XFF segment\", \"err\", err)\n\t\t\tbreak\n\t\t}\n\t\tif pref.StripPrivate && segmentIP.IsPrivate() {\n\t\t\tcontinue\n\t\t}\n\t\tif pref.StripLoopback && segmentIP.IsLoopback() {\n\t\t\tcontinue\n\t\t}\n\t\tif pref.StripLLU && segmentIP.IsLinkLocalUnicast() {\n\t\t\tcontinue\n\t\t}\n\t\tif pref.StripCGNAT && CGNat.Contains(segmentIP) {\n\t\t\tcontinue\n\t\t}\n\t\tforwardedList = append([]string{segmentIP.String()}, forwardedList...)\n\t}\n\tvar xffHeaderString string\n\tif len(forwardedList) == 0 {\n\t\txffHeaderString = \"\"\n\t\treturn xffHeaderString, nil\n\t}\n\tif pref.Flatten {\n\t\txffHeaderString = forwardedList[len(forwardedList)-1]\n\t} else {\n\t\txffHeaderString = strings.Join(forwardedList, \",\")\n\t}\n\treturn xffHeaderString, nil\n}\n\n// NoStoreCache sets the Cache-Control header to no-store for the response.\nfunc NoStoreCache(next http.Handler) http.Handler {\n\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.Header().Set(\"Cache-Control\", \"no-store\")\n\t\tnext.ServeHTTP(w, r)\n\t})\n}\n\n// NoBrowsing prevents directory browsing by returning a 404 for any request that ends with a \"/\".\nfunc NoBrowsing(next http.Handler) http.Handler {\n\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif strings.HasSuffix(r.URL.Path, \"/\") {\n\t\t\thttp.NotFound(w, r)\n\t\t\treturn\n\t\t}\n\t\tnext.ServeHTTP(w, r)\n\t})\n}\n"
  },
  {
    "path": "internal/health.go",
    "content": "package internal\n\nimport (\n\t\"context\"\n\n\t\"google.golang.org/grpc/health\"\n\thealthv1 \"google.golang.org/grpc/health/grpc_health_v1\"\n)\n\nvar HealthSrv = health.NewServer()\n\nfunc SetHealth(svc string, status healthv1.HealthCheckResponse_ServingStatus) {\n\tHealthSrv.SetServingStatus(svc, status)\n}\n\nfunc GetHealth(svc string) (healthv1.HealthCheckResponse_ServingStatus, bool) {\n\tst, err := HealthSrv.Check(context.Background(), &healthv1.HealthCheckRequest{\n\t\tService: svc,\n\t})\n\tif err != nil {\n\t\treturn healthv1.HealthCheckResponse_UNKNOWN, false\n\t}\n\n\treturn st.GetStatus(), true\n}\n"
  },
  {
    "path": "internal/honeypot/honeypot.go",
    "content": "package honeypot\n\nimport (\n\t\"time\"\n\n\t\"github.com/prometheus/client_golang/prometheus\"\n\t\"github.com/prometheus/client_golang/prometheus/promauto\"\n)\n\nvar Timings = promauto.NewHistogramVec(prometheus.HistogramOpts{\n\tNamespace: \"anubis\",\n\tSubsystem: \"honeypot\",\n\tName:      \"pagegen_timings\",\n\tHelp:      \"The amount of time honeypot page generation takes per method\",\n\tBuckets:   prometheus.ExponentialBuckets(0.5, 2, 32),\n}, []string{\"method\"})\n\ntype Info struct {\n\tCreatedAt time.Time `json:\"createdAt\"`\n\tUserAgent string    `json:\"userAgent\"`\n\tIPAddress string    `json:\"ipAddress\"`\n\tHitCount  int       `json:\"hitCount\"`\n}\n"
  },
  {
    "path": "internal/honeypot/naive/100bytes.css",
    "content": "html {\n  max-width: 70ch;\n  padding: 3em 1em;\n  margin: auto;\n  line-height: 1.75;\n  font-size: 1.25em;\n}\n"
  },
  {
    "path": "internal/honeypot/naive/affirmations.txt",
    "content": "{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!}"
  },
  {
    "path": "internal/honeypot/naive/naive.go",
    "content": "package naive\n\nimport (\n\t\"context\"\n\t_ \"embed\"\n\t\"fmt\"\n\t\"log/slog\"\n\t\"math/rand/v2\"\n\t\"net/http\"\n\t\"net/netip\"\n\t\"time\"\n\n\t\"github.com/TecharoHQ/anubis/internal\"\n\t\"github.com/TecharoHQ/anubis/internal/honeypot\"\n\t\"github.com/TecharoHQ/anubis/lib/policy/checker\"\n\t\"github.com/TecharoHQ/anubis/lib/store\"\n\t\"github.com/a-h/templ\"\n\t\"github.com/google/uuid\"\n\t\"github.com/nikandfor/spintax\"\n)\n\n//go:generate go tool github.com/a-h/templ/cmd/templ generate\n\n// 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.\n//\n// It feels somewhat poetic to use spammer technology in Anubis.\n//\n// [1]: https://outboundly.ai/blogs/what-is-spintax-and-how-to-use-it/\n//\n//go:embed spintext.txt\nvar spintext string\n\n//go:embed titles.txt\nvar titles string\n\n//go:embed affirmations.txt\nvar affirmations string\n\nfunc New(st store.Interface, lg *slog.Logger) (*Impl, error) {\n\taffirmation, err := spintax.Parse(affirmations)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"can't parse affirmations: %w\", err)\n\t}\n\n\tbody, err := spintax.Parse(spintext)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"can't parse bodies: %w\", err)\n\t}\n\n\ttitle, err := spintax.Parse(titles)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"can't parse titles: %w\", err)\n\t}\n\n\tlg.Debug(\"initialized basic bullshit generator\", \"affirmations\", affirmation.Count(), \"bodies\", body.Count(), \"titles\", title.Count())\n\n\treturn &Impl{\n\t\tst:            st,\n\t\tinfos:         store.JSON[honeypot.Info]{Underlying: st, Prefix: \"honeypot:info\"},\n\t\tuaWeight:      store.JSON[int]{Underlying: st, Prefix: \"honeypot:user-agent\"},\n\t\tnetworkWeight: store.JSON[int]{Underlying: st, Prefix: \"honeypot:network\"},\n\t\taffirmation:   affirmation,\n\t\tbody:          body,\n\t\ttitle:         title,\n\t\tlg:            lg.With(\"component\", \"honeypot/naive\"),\n\t}, nil\n}\n\ntype Impl struct {\n\tst            store.Interface\n\tinfos         store.JSON[honeypot.Info]\n\tuaWeight      store.JSON[int]\n\tnetworkWeight store.JSON[int]\n\tlg            *slog.Logger\n\n\taffirmation, body, title spintax.Spintax\n}\n\nfunc (i *Impl) incrementUA(ctx context.Context, userAgent string) int {\n\tresult, _ := i.uaWeight.Get(ctx, internal.SHA256sum(userAgent))\n\tresult++\n\ti.uaWeight.Set(ctx, internal.SHA256sum(userAgent), result, time.Hour)\n\treturn result\n}\n\nfunc (i *Impl) incrementNetwork(ctx context.Context, network string) int {\n\tresult, _ := i.networkWeight.Get(ctx, internal.SHA256sum(network))\n\tresult++\n\ti.networkWeight.Set(ctx, internal.SHA256sum(network), result, time.Hour)\n\treturn result\n}\n\nfunc (i *Impl) CheckUA() checker.Impl {\n\treturn checker.Func(func(r *http.Request) (bool, error) {\n\t\tresult, _ := i.uaWeight.Get(r.Context(), internal.SHA256sum(r.UserAgent()))\n\t\tif result >= 25 {\n\t\t\treturn true, nil\n\t\t}\n\n\t\treturn false, nil\n\t})\n}\n\nfunc (i *Impl) CheckNetwork() checker.Impl {\n\treturn checker.Func(func(r *http.Request) (bool, error) {\n\t\tresult, _ := i.uaWeight.Get(r.Context(), internal.SHA256sum(r.UserAgent()))\n\t\tif result >= 25 {\n\t\t\treturn true, nil\n\t\t}\n\n\t\treturn false, nil\n\t})\n}\n\nfunc (i *Impl) Hash() string {\n\treturn internal.SHA256sum(\"naive honeypot\")\n}\n\nfunc (i *Impl) makeAffirmations() []string {\n\tcount := rand.IntN(5) + 1\n\n\tvar result []string\n\tfor range count {\n\t\tresult = append(result, i.affirmation.Spin())\n\t}\n\n\treturn result\n}\n\nfunc (i *Impl) makeSpins() []string {\n\tcount := rand.IntN(5) + 1\n\n\tvar result []string\n\tfor range count {\n\t\tresult = append(result, i.body.Spin())\n\t}\n\n\treturn result\n}\n\nfunc (i *Impl) makeTitle() string {\n\treturn i.title.Spin()\n}\n\nfunc (i *Impl) ServeHTTP(w http.ResponseWriter, r *http.Request) {\n\tt0 := time.Now()\n\tlg := internal.GetRequestLogger(i.lg, r)\n\n\tid := r.PathValue(\"id\")\n\tif id == \"\" {\n\t\tid = uuid.NewString()\n\t}\n\n\trealIP, _ := internal.RealIP(r)\n\tif !realIP.IsValid() {\n\t\trealIP = netip.MustParseAddr(r.Header.Get(\"X-Real-Ip\"))\n\t}\n\n\tnetwork, ok := internal.ClampIP(realIP)\n\tif !ok {\n\t\tlg.Error(\"clampIP failed\", \"output\", network, \"ok\", ok)\n\t\thttp.Error(w, \"The cake is a lie\", http.StatusTeapot)\n\t\treturn\n\t}\n\n\tnetworkCount := i.incrementNetwork(r.Context(), network.String())\n\tuaCount := i.incrementUA(r.Context(), r.UserAgent())\n\n\tstage := r.PathValue(\"stage\")\n\n\tif stage == \"init\" {\n\t\tlg.Debug(\"found new entrance point\", \"id\", id, \"stage\", stage, \"userAgent\", r.UserAgent(), \"clampedIP\", network)\n\t} else {\n\t\tswitch {\n\t\tcase networkCount%256 == 0, uaCount%256 == 0:\n\t\t\tlg.Warn(\"found possible crawler\", \"id\", id, \"network\", network)\n\t\t}\n\t}\n\n\tspins := i.makeSpins()\n\taffirmations := i.makeAffirmations()\n\ttitle := i.makeTitle()\n\n\tvar links []link\n\tfor _, affirmation := range affirmations {\n\t\tlinks = append(links, link{\n\t\t\thref: uuid.NewString(),\n\t\t\tbody: affirmation,\n\t\t})\n\t}\n\n\ttempl.Handler(\n\t\tbase(title, i.maze(spins, links)),\n\t\ttempl.WithStreaming(),\n\t\ttempl.WithStatus(http.StatusOK),\n\t).ServeHTTP(w, r)\n\n\tt1 := time.Since(t0)\n\thoneypot.Timings.WithLabelValues(\"naive\").Observe(float64(t1.Milliseconds()))\n}\n\ntype link struct {\n\thref string\n\tbody string\n}\n"
  },
  {
    "path": "internal/honeypot/naive/page.templ",
    "content": "package naive\n\nimport \"fmt\"\n\ntempl base(title string, body templ.Component) {\n\t<!DOCTYPE html>\n\t<html>\n\t\t<head>\n\t\t\t<style>\n        html {\n          max-width: 70ch;\n          padding: 3em 1em;\n          margin: auto;\n          line-height: 1.75;\n          font-size: 1.25em;\n        }\n      </style>\n\t\t\t<title>{ title }</title>\n\t\t</head>\n\t\t<body>\n\t\t\t<h1>{ title }</h1>\n\t\t\t@body\n\t\t</body>\n\t</html>\n}\n\ntempl (i Impl) maze(body []string, links []link) {\n\tfor _, paragraph := range body {\n\t\t<p>{ paragraph }</p>\n\t}\n\t<ul>\n\t\tfor _, link := range links {\n\t\t\t<li><a href={ templ.SafeURL(fmt.Sprintf(\"./%s\", link.href)) }>{ link.body }</a></li>\n\t\t}\n\t</ul>\n}\n"
  },
  {
    "path": "internal/honeypot/naive/page_templ.go",
    "content": "// Code generated by templ - DO NOT EDIT.\n\n// templ: version: v0.3.960\npackage naive\n\n//lint:file-ignore SA4006 This context is only used if a nested component is present.\n\nimport \"github.com/a-h/templ\"\nimport templruntime \"github.com/a-h/templ/runtime\"\n\nimport \"fmt\"\n\nfunc base(title string, body templ.Component) templ.Component {\n\treturn templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {\n\t\ttempl_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context\n\t\tif templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {\n\t\t\treturn templ_7745c5c3_CtxErr\n\t\t}\n\t\ttempl_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)\n\t\tif !templ_7745c5c3_IsBuffer {\n\t\t\tdefer func() {\n\t\t\t\ttempl_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)\n\t\t\t\tif templ_7745c5c3_Err == nil {\n\t\t\t\t\ttempl_7745c5c3_Err = templ_7745c5c3_BufErr\n\t\t\t\t}\n\t\t\t}()\n\t\t}\n\t\tctx = templ.InitializeContext(ctx)\n\t\ttempl_7745c5c3_Var1 := templ.GetChildren(ctx)\n\t\tif templ_7745c5c3_Var1 == nil {\n\t\t\ttempl_7745c5c3_Var1 = templ.NopComponent\n\t\t}\n\t\tctx = templ.ClearChildren(ctx)\n\t\ttempl_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, \"<!doctype html><html><head><style>\\n        html {\\n          max-width: 70ch;\\n          padding: 3em 1em;\\n          margin: auto;\\n          line-height: 1.75;\\n          font-size: 1.25em;\\n        }\\n      </style><title>\")\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\tvar templ_7745c5c3_Var2 string\n\t\ttempl_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(title)\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ.Error{Err: templ_7745c5c3_Err, FileName: `page.templ`, Line: 18, Col: 17}\n\t\t}\n\t\t_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\ttempl_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, \"</title></head><body><h1>\")\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\tvar templ_7745c5c3_Var3 string\n\t\ttempl_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(title)\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ.Error{Err: templ_7745c5c3_Err, FileName: `page.templ`, Line: 21, Col: 14}\n\t\t}\n\t\t_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\ttempl_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, \"</h1>\")\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\ttempl_7745c5c3_Err = body.Render(ctx, templ_7745c5c3_Buffer)\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\ttempl_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, \"</body></html>\")\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\treturn nil\n\t})\n}\n\nfunc (i Impl) maze(body []string, links []link) templ.Component {\n\treturn templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {\n\t\ttempl_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context\n\t\tif templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {\n\t\t\treturn templ_7745c5c3_CtxErr\n\t\t}\n\t\ttempl_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)\n\t\tif !templ_7745c5c3_IsBuffer {\n\t\t\tdefer func() {\n\t\t\t\ttempl_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)\n\t\t\t\tif templ_7745c5c3_Err == nil {\n\t\t\t\t\ttempl_7745c5c3_Err = templ_7745c5c3_BufErr\n\t\t\t\t}\n\t\t\t}()\n\t\t}\n\t\tctx = templ.InitializeContext(ctx)\n\t\ttempl_7745c5c3_Var4 := templ.GetChildren(ctx)\n\t\tif templ_7745c5c3_Var4 == nil {\n\t\t\ttempl_7745c5c3_Var4 = templ.NopComponent\n\t\t}\n\t\tctx = templ.ClearChildren(ctx)\n\t\tfor _, paragraph := range body {\n\t\t\ttempl_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, \"<p>\")\n\t\t\tif templ_7745c5c3_Err != nil {\n\t\t\t\treturn templ_7745c5c3_Err\n\t\t\t}\n\t\t\tvar templ_7745c5c3_Var5 string\n\t\t\ttempl_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(paragraph)\n\t\t\tif templ_7745c5c3_Err != nil {\n\t\t\t\treturn templ.Error{Err: templ_7745c5c3_Err, FileName: `page.templ`, Line: 29, Col: 16}\n\t\t\t}\n\t\t\t_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))\n\t\t\tif templ_7745c5c3_Err != nil {\n\t\t\t\treturn templ_7745c5c3_Err\n\t\t\t}\n\t\t\ttempl_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, \"</p>\")\n\t\t\tif templ_7745c5c3_Err != nil {\n\t\t\t\treturn templ_7745c5c3_Err\n\t\t\t}\n\t\t}\n\t\ttempl_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, \"<ul>\")\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\tfor _, link := range links {\n\t\t\ttempl_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, \"<li><a href=\\\"\")\n\t\t\tif templ_7745c5c3_Err != nil {\n\t\t\t\treturn templ_7745c5c3_Err\n\t\t\t}\n\t\t\tvar templ_7745c5c3_Var6 templ.SafeURL\n\t\t\ttempl_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf(\"./%s\", link.href)))\n\t\t\tif templ_7745c5c3_Err != nil {\n\t\t\t\treturn templ.Error{Err: templ_7745c5c3_Err, FileName: `page.templ`, Line: 33, Col: 62}\n\t\t\t}\n\t\t\t_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))\n\t\t\tif templ_7745c5c3_Err != nil {\n\t\t\t\treturn templ_7745c5c3_Err\n\t\t\t}\n\t\t\ttempl_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, \"\\\">\")\n\t\t\tif templ_7745c5c3_Err != nil {\n\t\t\t\treturn templ_7745c5c3_Err\n\t\t\t}\n\t\t\tvar templ_7745c5c3_Var7 string\n\t\t\ttempl_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(link.body)\n\t\t\tif templ_7745c5c3_Err != nil {\n\t\t\t\treturn templ.Error{Err: templ_7745c5c3_Err, FileName: `page.templ`, Line: 33, Col: 76}\n\t\t\t}\n\t\t\t_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))\n\t\t\tif templ_7745c5c3_Err != nil {\n\t\t\t\treturn templ_7745c5c3_Err\n\t\t\t}\n\t\t\ttempl_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, \"</a></li>\")\n\t\t\tif templ_7745c5c3_Err != nil {\n\t\t\t\treturn templ_7745c5c3_Err\n\t\t\t}\n\t\t}\n\t\ttempl_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, \"</ul>\")\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\treturn nil\n\t})\n}\n\nvar _ = templruntime.GeneratedTemplate\n"
  },
  {
    "path": "internal/honeypot/naive/spintext.txt",
    "content": "{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}."
  },
  {
    "path": "internal/honeypot/naive/titles.txt",
    "content": "{{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}}"
  },
  {
    "path": "internal/ja4h.go",
    "content": "package internal\n\nimport (\n\t\"net/http\"\n\n\t\"github.com/lum8rjack/go-ja4h\"\n)\n\nfunc JA4H(next http.Handler) http.Handler {\n\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tr.Header.Add(\"X-Http-Fingerprint-JA4H\", ja4h.JA4H(r))\n\t\tnext.ServeHTTP(w, r)\n\t})\n}\n"
  },
  {
    "path": "internal/listor.go",
    "content": "package internal\n\nimport (\n\t\"encoding/json\"\n)\n\n// ListOr[T any] is a slice that can contain either a single T or multiple T values.\n// During JSON unmarshaling, it checks if the first character is '[' to determine\n// whether to treat the JSON as an array or a single value.\ntype ListOr[T any] []T\n\nfunc (lo *ListOr[T]) UnmarshalJSON(data []byte) error {\n\tif len(data) == 0 {\n\t\treturn nil\n\t}\n\n\t// Check if first non-whitespace character is '['\n\tfirstChar := data[0]\n\tfor i := range data {\n\t\tif data[i] != ' ' && data[i] != '\\t' && data[i] != '\\n' && data[i] != '\\r' {\n\t\t\tfirstChar = data[i]\n\t\t\tbreak\n\t\t}\n\t}\n\n\tif firstChar == '[' {\n\t\t// It's an array, unmarshal directly\n\t\treturn json.Unmarshal(data, (*[]T)(lo))\n\t} else {\n\t\t// It's a single value, unmarshal as a single item in a slice\n\t\tvar single T\n\t\tif err := json.Unmarshal(data, &single); err != nil {\n\t\t\treturn err\n\t\t}\n\t\t*lo = ListOr[T]{single}\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "internal/listor_test.go",
    "content": "package internal\n\nimport (\n\t\"encoding/json\"\n\t\"testing\"\n)\n\nfunc TestListOr_UnmarshalJSON(t *testing.T) {\n\tt.Run(\"single value should be unmarshaled as single item\", func(t *testing.T) {\n\t\tvar lo ListOr[string]\n\n\t\terr := json.Unmarshal([]byte(`\"hello\"`), &lo)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"Failed to unmarshal single string: %v\", err)\n\t\t}\n\n\t\tif len(lo) != 1 {\n\t\t\tt.Fatalf(\"Expected 1 item, got %d\", len(lo))\n\t\t}\n\n\t\tif lo[0] != \"hello\" {\n\t\t\tt.Errorf(\"Expected 'hello', got %q\", lo[0])\n\t\t}\n\t})\n\n\tt.Run(\"array should be unmarshaled as multiple items\", func(t *testing.T) {\n\t\tvar lo ListOr[string]\n\n\t\terr := json.Unmarshal([]byte(`[\"hello\", \"world\"]`), &lo)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"Failed to unmarshal array: %v\", err)\n\t\t}\n\n\t\tif len(lo) != 2 {\n\t\t\tt.Fatalf(\"Expected 2 items, got %d\", len(lo))\n\t\t}\n\n\t\tif lo[0] != \"hello\" {\n\t\t\tt.Errorf(\"Expected 'hello', got %q\", lo[0])\n\t\t}\n\t\tif lo[1] != \"world\" {\n\t\t\tt.Errorf(\"Expected 'world', got %q\", lo[1])\n\t\t}\n\t})\n\n\tt.Run(\"single number should be unmarshaled as single item\", func(t *testing.T) {\n\t\tvar lo ListOr[int]\n\n\t\terr := json.Unmarshal([]byte(`42`), &lo)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"Failed to unmarshal single number: %v\", err)\n\t\t}\n\n\t\tif len(lo) != 1 {\n\t\t\tt.Fatalf(\"Expected 1 item, got %d\", len(lo))\n\t\t}\n\n\t\tif lo[0] != 42 {\n\t\t\tt.Errorf(\"Expected 42, got %d\", lo[0])\n\t\t}\n\t})\n\n\tt.Run(\"array of numbers should be unmarshaled as multiple items\", func(t *testing.T) {\n\t\tvar lo ListOr[int]\n\n\t\terr := json.Unmarshal([]byte(`[1, 2, 3]`), &lo)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"Failed to unmarshal number array: %v\", err)\n\t\t}\n\n\t\tif len(lo) != 3 {\n\t\t\tt.Fatalf(\"Expected 3 items, got %d\", len(lo))\n\t\t}\n\n\t\tif lo[0] != 1 || lo[1] != 2 || lo[2] != 3 {\n\t\t\tt.Errorf(\"Expected [1, 2, 3], got %v\", lo)\n\t\t}\n\t})\n}"
  },
  {
    "path": "internal/log.go",
    "content": "package internal\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"log\"\n\t\"log/slog\"\n\t\"net/http\"\n\t\"os\"\n\t\"strings\"\n)\n\nfunc InitSlog(level string, sink io.Writer) *slog.Logger {\n\tvar programLevel slog.Level\n\tif err := (&programLevel).UnmarshalText([]byte(level)); err != nil {\n\t\tfmt.Fprintf(os.Stderr, \"invalid log level %s: %v, using info\\n\", level, err)\n\t\tprogramLevel = slog.LevelInfo\n\t}\n\n\tleveler := &slog.LevelVar{}\n\tleveler.Set(programLevel)\n\n\th := slog.NewJSONHandler(sink, &slog.HandlerOptions{\n\t\tAddSource: true,\n\t\tLevel:     leveler,\n\t})\n\tresult := slog.New(h)\n\treturn result\n}\n\nfunc GetRequestLogger(base *slog.Logger, r *http.Request) *slog.Logger {\n\thost := r.Host\n\tif host == \"\" {\n\t\thost = r.Header.Get(\"X-Forwarded-Host\")\n\t}\n\n\treturn base.With(\n\t\t\"host\", host,\n\t\t\"method\", r.Method,\n\t\t\"path\", r.URL.Path,\n\t\t\"user_agent\", r.UserAgent(),\n\t\t\"accept_language\", r.Header.Get(\"Accept-Language\"),\n\t\t\"priority\", r.Header.Get(\"Priority\"),\n\t\t\"x-forwarded-for\", r.Header.Get(\"X-Forwarded-For\"),\n\t\t\"x-real-ip\", r.Header.Get(\"X-Real-Ip\"),\n\t)\n}\n\n// ErrorLogFilter is used to suppress \"context canceled\" logs from the http server when a request is canceled (e.g., when a client disconnects).\ntype ErrorLogFilter struct {\n\tUnwrap *log.Logger\n}\n\nfunc (elf *ErrorLogFilter) Write(p []byte) (n int, err error) {\n\tlogMessage := string(p)\n\tif strings.Contains(logMessage, \"context canceled\") {\n\t\treturn len(p), nil // Suppress the log by doing nothing\n\t}\n\tif strings.Contains(logMessage, \"Unsolicited response received on idle HTTP channel\") {\n\t\treturn len(p), nil\n\t}\n\tif elf.Unwrap != nil {\n\t\treturn elf.Unwrap.Writer().Write(p)\n\t}\n\treturn len(p), nil\n}\n\nfunc GetFilteredHTTPLogger() *log.Logger {\n\tstdErrLogger := log.New(os.Stderr, \"\", log.LstdFlags) // essentially what the default logger is.\n\treturn log.New(&ErrorLogFilter{Unwrap: stdErrLogger}, \"\", 0)\n}\n"
  },
  {
    "path": "internal/log_test.go",
    "content": "package internal\n\nimport (\n\t\"bytes\"\n\t\"log\"\n\t\"log/slog\"\n\t\"net/http\"\n\t\"strings\"\n\t\"testing\"\n)\n\nfunc TestErrorLogFilter(t *testing.T) {\n\tvar buf bytes.Buffer\n\tdestLogger := log.New(&buf, \"\", 0)\n\terrorFilterWriter := &ErrorLogFilter{Unwrap: destLogger}\n\ttestErrorLogger := log.New(errorFilterWriter, \"\", 0)\n\n\t// Test Case 1: Suppressed message\n\tsuppressedMessage := \"http: proxy error: context canceled\"\n\ttestErrorLogger.Println(suppressedMessage)\n\n\tif buf.Len() != 0 {\n\t\tt.Errorf(\"Suppressed message was written to output. Output: %q\", buf.String())\n\t}\n\tbuf.Reset()\n\n\t// Test Case 2: Allowed message\n\tallowedMessage := \"http: another error occurred\"\n\ttestErrorLogger.Println(allowedMessage)\n\n\toutput := buf.String()\n\tif !strings.Contains(output, allowedMessage) {\n\t\tt.Errorf(\"Allowed message was not written to output. Output: %q\", output)\n\t}\n\tif !strings.HasSuffix(output, \"\\n\") {\n\t\tt.Errorf(\"Allowed message output is missing newline. Output: %q\", output)\n\t}\n\tbuf.Reset()\n\n\t// Test Case 3: Partially matching message (should be suppressed)\n\tpartiallyMatchingMessage := \"Some other log before http: proxy error: context canceled and after\"\n\ttestErrorLogger.Println(partiallyMatchingMessage)\n\n\tif buf.Len() != 0 {\n\t\tt.Errorf(\"Partially matching message was written to output. Output: %q\", buf.String())\n\t}\n\tbuf.Reset()\n}\n\nfunc TestGetRequestLogger(t *testing.T) {\n\t// Test case 1: Normal request with Host header\n\treq1, _ := http.NewRequest(\"GET\", \"http://example.com/test\", nil)\n\treq1.Host = \"example.com\"\n\n\tlogger := slog.Default()\n\treqLogger := GetRequestLogger(logger, req1)\n\n\t// We can't easily test the actual log output without setting up a test handler,\n\t// but we can verify the function doesn't panic and returns a logger\n\tif reqLogger == nil {\n\t\tt.Error(\"GetRequestLogger returned nil\")\n\t}\n\n\t// Test case 2: Subrequest auth mode with X-Forwarded-Host\n\treq2, _ := http.NewRequest(\"GET\", \"http://test.com/auth\", nil)\n\treq2.Host = \"\"\n\treq2.Header.Set(\"X-Forwarded-Host\", \"original-site.com\")\n\n\treqLogger2 := GetRequestLogger(logger, req2)\n\tif reqLogger2 == nil {\n\t\tt.Error(\"GetRequestLogger returned nil for X-Forwarded-Host case\")\n\t}\n\n\t// Test case 3: No host information available\n\treq3, _ := http.NewRequest(\"GET\", \"http://test.com/nohost\", nil)\n\treq3.Host = \"\"\n\n\treqLogger3 := GetRequestLogger(logger, req3)\n\tif reqLogger3 == nil {\n\t\tt.Error(\"GetRequestLogger returned nil for no host case\")\n\t}\n}\n"
  },
  {
    "path": "internal/mimetype.go",
    "content": "package internal\n\nimport \"mime\"\n\nfunc init() {\n\tmime.AddExtensionType(\".mjs\", \"text/javascript\")\n}\n"
  },
  {
    "path": "internal/ogtags/cache.go",
    "content": "package ogtags\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"log/slog\"\n\t\"net/url\"\n\t\"strings\"\n\t\"syscall\"\n\t\"time\"\n)\n\n// GetOGTags is the main function that retrieves Open Graph tags for a URL\nfunc (c *OGTagCache) GetOGTags(ctx context.Context, url *url.URL, originalHost string) (map[string]string, error) {\n\tif url == nil {\n\t\treturn nil, errors.New(\"nil URL provided, cannot fetch OG tags\")\n\t}\n\n\tif len(c.ogOverride) != 0 {\n\t\treturn c.ogOverride, nil\n\t}\n\n\ttarget := c.getTarget(url)\n\tcacheKey := c.generateCacheKey(target, originalHost)\n\n\t// Check cache first\n\tif cachedTags := c.checkCache(ctx, cacheKey); cachedTags != nil {\n\t\treturn cachedTags, nil\n\t}\n\n\t// Fetch HTML content, passing the original host\n\tdoc, err := c.fetchHTMLDocumentWithCache(ctx, target, originalHost, cacheKey)\n\tif errors.Is(err, syscall.ECONNREFUSED) {\n\t\tslog.Debug(\"Connection refused, returning empty tags\")\n\t\treturn nil, nil\n\t} else if errors.Is(err, ErrOgHandled) {\n\t\t// Error was handled in fetchHTMLDocument, return empty tags\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Extract OG tags\n\togTags := c.extractOGTags(doc)\n\n\t// Store in cache\n\tc.cache.Set(ctx, cacheKey, ogTags, c.ogTimeToLive)\n\n\tfor k, v := range ogTags {\n\t\tswitch {\n\t\tcase strings.HasSuffix(k, \"image\"), strings.HasSuffix(k, \"audio\"), strings.HasSuffix(k, \"secure_url\"), strings.HasSuffix(k, \"video\"):\n\t\t\tv, _ = strings.CutPrefix(v, \"http://\")\n\t\t\tv, _ = strings.CutPrefix(v, \"https://\")\n\t\t\tslog.Debug(\"setting ogtags allow for\", \"url\", k)\n\t\t\tif err := c.cache.Underlying.Set(ctx, \"ogtags:allow:\"+v, []byte(k), time.Hour); err != nil {\n\t\t\t\tslog.Debug(\"can't set ogtag allow cache\", \"err\", err)\n\t\t\t}\n\t\t}\n\t}\n\n\treturn ogTags, nil\n}\n\nfunc (c *OGTagCache) generateCacheKey(target string, originalHost string) string {\n\tvar cacheKey string\n\n\tif c.ogCacheConsiderHost {\n\t\tcacheKey = target + \"|\" + originalHost\n\t} else {\n\t\tcacheKey = target\n\t}\n\treturn cacheKey\n}\n\n// checkCache checks if we have the tags cached and returns them if so\nfunc (c *OGTagCache) checkCache(ctx context.Context, cacheKey string) map[string]string {\n\tif cachedTags, err := c.cache.Get(ctx, cacheKey); err == nil {\n\t\tslog.Debug(\"cache hit\", \"tags\", cachedTags)\n\t\treturn cachedTags\n\t}\n\tslog.Debug(\"cache miss\", \"url\", cacheKey)\n\treturn nil\n}\n"
  },
  {
    "path": "internal/ogtags/cache_test.go",
    "content": "package ogtags\n\nimport (\n\t\"errors\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"net/url\"\n\t\"reflect\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/TecharoHQ/anubis/lib/config\"\n\t\"github.com/TecharoHQ/anubis/lib/store\"\n\t\"github.com/TecharoHQ/anubis/lib/store/memory\"\n)\n\nfunc TestCacheReturnsDefault(t *testing.T) {\n\twant := map[string]string{\n\t\t\"og:title\":       \"Foo bar\",\n\t\t\"og:description\": \"The best website ever made!!!1!\",\n\t}\n\tcache := NewOGTagCache(\"\", config.OpenGraph{\n\t\tEnabled:      true,\n\t\tTimeToLive:   time.Minute,\n\t\tConsiderHost: false,\n\t\tOverride:     want,\n\t}, memory.New(t.Context()), TargetOptions{})\n\n\tu, err := url.Parse(\"https://anubis.techaro.lol\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tresult, err := cache.GetOGTags(t.Context(), u, \"anubis.techaro.lol\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tfor k, v := range want {\n\t\tt.Run(k, func(t *testing.T) {\n\t\t\tif got := result[k]; got != v {\n\t\t\t\tt.Logf(\"want: tags[%q] = %q\", k, v)\n\t\t\t\tt.Logf(\"got:  tags[%q] = %q\", k, got)\n\t\t\t\tt.Error(\"invalid result from function\")\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestCheckCache(t *testing.T) {\n\tcache := NewOGTagCache(\"http://example.com\", config.OpenGraph{\n\t\tEnabled:      true,\n\t\tTimeToLive:   time.Minute,\n\t\tConsiderHost: false,\n\t}, memory.New(t.Context()), TargetOptions{})\n\n\t// Set up test data\n\turlStr := \"http://example.com/page\"\n\texpectedTags := map[string]string{\n\t\t\"og:title\":       \"Test Title\",\n\t\t\"og:description\": \"Test Description\",\n\t}\n\tcacheKey := cache.generateCacheKey(urlStr, \"example.com\")\n\n\t// Test cache miss\n\ttags := cache.checkCache(t.Context(), cacheKey)\n\tif tags != nil {\n\t\tt.Errorf(\"expected nil tags on cache miss, got %v\", tags)\n\t}\n\n\t// Manually add to cache\n\tcache.cache.Set(t.Context(), cacheKey, expectedTags, time.Minute)\n\n\t// Test cache hit\n\ttags = cache.checkCache(t.Context(), cacheKey)\n\tif tags == nil {\n\t\tt.Fatal(\"expected non-nil tags on cache hit, got nil\")\n\t}\n\n\tfor key, expectedValue := range expectedTags {\n\t\tif value, ok := tags[key]; !ok || value != expectedValue {\n\t\t\tt.Errorf(\"expected %s: %s, got: %s\", key, expectedValue, value)\n\t\t}\n\t}\n}\n\nfunc TestGetOGTags(t *testing.T) {\n\tvar loadCount int // Counter to track how many times the test route is loaded\n\n\t// Create a test server to serve a sample HTML page with OG tags\n\tts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tloadCount++\n\t\tif loadCount > 1 {\n\t\t\tt.Fatalf(\"Test route loaded more than once, cache failed\")\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"text/html\")\n\t\tw.Write([]byte(`\n\t\t\t<!DOCTYPE html>\n\t\t\t<html>\n\t\t\t<head>\n\t\t\t\t<meta property=\"og:title\" content=\"Test Title\" />\n\t\t\t\t<meta property=\"og:description\" content=\"Test Description\" />\n\t\t\t\t<meta property=\"og:image\" content=\"http://example.com/image.jpg\" />\n\t\t\t</head>\n\t\t\t<body>\n\t\t\t\t<p>Hello, world!</p>\n\t\t\t</body>\n\t\t\t</html>\n\t\t`))\n\t}))\n\tdefer ts.Close()\n\n\t// Create an instance of OGTagCache with a short TTL for testing\n\tcache := NewOGTagCache(ts.URL, config.OpenGraph{\n\t\tEnabled:      true,\n\t\tTimeToLive:   time.Minute,\n\t\tConsiderHost: false,\n\t}, memory.New(t.Context()), TargetOptions{})\n\n\t// Parse the test server URL\n\tparsedURL, err := url.Parse(ts.URL)\n\tif err != nil {\n\t\tt.Fatalf(\"failed to parse test server URL: %v\", err)\n\t}\n\n\t// Test fetching OG tags from the test server\n\t// Pass the host from the parsed test server URL\n\togTags, err := cache.GetOGTags(t.Context(), parsedURL, parsedURL.Host)\n\tif err != nil {\n\t\tt.Fatalf(\"failed to get OG tags: %v\", err)\n\t}\n\n\t// Verify the fetched OG tags\n\texpectedTags := map[string]string{\n\t\t\"og:title\":       \"Test Title\",\n\t\t\"og:description\": \"Test Description\",\n\t\t\"og:image\":       \"http://example.com/image.jpg\",\n\t}\n\n\tfor key, expectedValue := range expectedTags {\n\t\tif value, ok := ogTags[key]; !ok || value != expectedValue {\n\t\t\tt.Errorf(\"expected %s: %s, got: %s\", key, expectedValue, value)\n\t\t}\n\t}\n\n\t// Test fetching OG tags from the cache\n\t// Pass the host from the parsed test server URL\n\togTags, err = cache.GetOGTags(t.Context(), parsedURL, parsedURL.Host)\n\tif err != nil {\n\t\tt.Fatalf(\"failed to get OG tags from cache: %v\", err)\n\t}\n\n\t// Test fetching OG tags from the cache (3rd time)\n\t// Pass the host from the parsed test server URL\n\tnewOgTags, err := cache.GetOGTags(t.Context(), parsedURL, parsedURL.Host)\n\tif err != nil {\n\t\tt.Fatalf(\"failed to get OG tags from cache: %v\", err)\n\t}\n\n\t// Verify the cached OG tags\n\tfor key, expectedValue := range expectedTags {\n\t\tif value, ok := ogTags[key]; !ok || value != expectedValue {\n\t\t\tt.Errorf(\"expected %s: %s, got: %s\", key, expectedValue, value)\n\t\t}\n\n\t\tinitialValue := ogTags[key]\n\t\tcachedValue, ok := newOgTags[key]\n\t\tif !ok || initialValue != cachedValue {\n\t\t\tt.Errorf(\"Cache does not line up: expected %s: %s, got: %s\", key, initialValue, cachedValue)\n\t\t}\n\t}\n\n\tt.Run(\"ensure image is cached as allow\", func(t *testing.T) {\n\t\tif _, err := cache.cache.Underlying.Get(t.Context(), \"ogtags:allow:example.com/image.jpg\"); errors.Is(err, store.ErrNotFound) {\n\t\t\tt.Fatal(\"ogtags allow caching for example.com/image.jpg did not work\")\n\t\t}\n\t})\n}\n\n// TestGetOGTagsWithHostConsideration tests the behavior of the cache with and without host consideration and for multiple hosts in a theoretical setup.\nfunc TestGetOGTagsWithHostConsideration(t *testing.T) {\n\tvar loadCount int // Counter to track how many times the test route is loaded\n\n\t// Create a test server\n\tts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tloadCount++ // Increment counter on each request to the server\n\t\tw.Header().Set(\"Content-Type\", \"text/html\")\n\t\tw.Write([]byte(`\n\t\t\t<!DOCTYPE html>\n\t\t\t<html>\n\t\t\t<head>\n\t\t\t\t<meta property=\"og:title\" content=\"Test Title\" />\n\t\t\t\t<meta property=\"og:description\" content=\"Test Description\" />\n\t\t\t</head>\n\t\t\t<body><p>Content</p></body>\n\t\t\t</html>\n\t\t`))\n\t}))\n\tdefer ts.Close()\n\n\tparsedURL, err := url.Parse(ts.URL)\n\tif err != nil {\n\t\tt.Fatalf(\"failed to parse test server URL: %v\", err)\n\t}\n\n\texpectedTags := map[string]string{\n\t\t\"og:title\":       \"Test Title\",\n\t\t\"og:description\": \"Test Description\",\n\t}\n\n\ttestCases := []struct {\n\t\tname     string\n\t\trequests []struct {\n\t\t\thost              string\n\t\t\texpectedLoadCount int\n\t\t}\n\t\togCacheConsiderHost bool // Expected load count *after* this request\n\t}{\n\t\t{\n\t\t\tname:                \"Host Not Considered - Same Host\",\n\t\t\togCacheConsiderHost: false,\n\t\t\trequests: []struct {\n\t\t\t\thost              string\n\t\t\t\texpectedLoadCount int\n\t\t\t}{\n\t\t\t\t{\"host1\", 1}, // First request, miss\n\t\t\t\t{\"host1\", 1}, // Second request, same host, hit (host ignored)\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:                \"Host Not Considered - Different Host\",\n\t\t\togCacheConsiderHost: false,\n\t\t\trequests: []struct {\n\t\t\t\thost              string\n\t\t\t\texpectedLoadCount int\n\t\t\t}{\n\t\t\t\t{\"host1\", 1}, // First request, miss\n\t\t\t\t{\"host2\", 1}, // Second request, different host, hit (host ignored)\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:                \"Host Considered - Same Host\",\n\t\t\togCacheConsiderHost: true,\n\t\t\trequests: []struct {\n\t\t\t\thost              string\n\t\t\t\texpectedLoadCount int\n\t\t\t}{\n\t\t\t\t{\"host1\", 1}, // First request, miss\n\t\t\t\t{\"host1\", 1}, // Second request, same host, hit\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:                \"Host Considered - Different Host\",\n\t\t\togCacheConsiderHost: true,\n\t\t\trequests: []struct {\n\t\t\t\thost              string\n\t\t\t\texpectedLoadCount int\n\t\t\t}{\n\t\t\t\t{\"host1\", 1}, // First request, miss\n\t\t\t\t{\"host2\", 2}, // Second request, different host, miss\n\t\t\t\t{\"host2\", 2}, // Third request, same as second, hit\n\t\t\t\t{\"host1\", 2}, // Fourth request, same as first, hit\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, tc := range testCases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tloadCount = 0 // Reset load count for each test case\n\t\t\tcache := NewOGTagCache(ts.URL, config.OpenGraph{\n\t\t\t\tEnabled:      true,\n\t\t\t\tTimeToLive:   time.Minute,\n\t\t\t\tConsiderHost: tc.ogCacheConsiderHost,\n\t\t\t}, memory.New(t.Context()), TargetOptions{})\n\n\t\t\tfor i, req := range tc.requests {\n\t\t\t\togTags, err := cache.GetOGTags(t.Context(), parsedURL, req.host)\n\t\t\t\tif err != nil {\n\t\t\t\t\tt.Errorf(\"Request %d (host: %s): unexpected error: %v\", i+1, req.host, err)\n\t\t\t\t\tcontinue // Skip further checks for this request if error occurred\n\t\t\t\t}\n\n\t\t\t\t// Verify tags are correct (should always be the same in this setup)\n\t\t\t\tif !reflect.DeepEqual(ogTags, expectedTags) {\n\t\t\t\t\tt.Errorf(\"Request %d (host: %s): expected tags %v, got %v\", i+1, req.host, expectedTags, ogTags)\n\t\t\t\t}\n\n\t\t\t\t// Verify the load count to check cache hit/miss behavior\n\t\t\t\tif loadCount != req.expectedLoadCount {\n\t\t\t\t\tt.Errorf(\"Request %d (host: %s): expected load count %d, got %d (cache hit/miss mismatch)\", i+1, req.host, req.expectedLoadCount, loadCount)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "internal/ogtags/fetch.go",
    "content": "package ogtags\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"log/slog\"\n\t\"mime\"\n\t\"net\"\n\t\"net/http\"\n\n\t\"golang.org/x/net/html\"\n)\n\nvar (\n\tErrOgHandled = errors.New(\"og: handled error\") // used to indicate that the error was handled and should not be logged\n\temptyMap     = map[string]string{}             // used to indicate an empty result in the cache. Can't use nil as it would be a cache miss.\n)\n\n// fetchHTMLDocumentWithCache fetches the HTML document from the given URL string,\n// preserving the original host header.\nfunc (c *OGTagCache) fetchHTMLDocumentWithCache(ctx context.Context, urlStr string, originalHost string, cacheKey string) (*html.Node, error) {\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", urlStr, nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create http request: %w\", err)\n\t}\n\n\t// Set the Host header to the original host\n\tvar hostForRequest string\n\tswitch {\n\tcase c.targetHost != \"\":\n\t\thostForRequest = c.targetHost\n\tcase originalHost != \"\":\n\t\thostForRequest = originalHost\n\t}\n\tif hostForRequest != \"\" {\n\t\treq.Host = hostForRequest\n\t}\n\n\t// Add proxy headers\n\treq.Header.Set(\"X-Forwarded-Proto\", \"https\")\n\treq.Header.Set(\"User-Agent\", \"Anubis-OGTag-Fetcher/1.0\") // For tracking purposes\n\n\tserverName := hostForRequest\n\tif serverName == \"\" {\n\t\tserverName = req.URL.Hostname()\n\t}\n\tclient := c.clientForSNI(serverName)\n\n\t// Send the request\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\tvar netErr net.Error\n\t\tif errors.As(err, &netErr) && netErr.Timeout() {\n\t\t\tslog.Debug(\"og: request timed out\", \"url\", urlStr)\n\t\t\tc.cache.Set(ctx, cacheKey, emptyMap, c.ogTimeToLive/2) // Cache empty result for half the TTL to not spam the server\n\t\t}\n\t\treturn nil, fmt.Errorf(\"http get failed: %w\", err)\n\t}\n\n\t// Ensure the response body is closed\n\tdefer func(Body io.ReadCloser) {\n\t\terr := Body.Close()\n\t\tif err != nil {\n\t\t\tslog.Debug(\"og: error closing response body\", \"url\", urlStr, \"error\", err)\n\t\t}\n\t}(resp.Body)\n\n\tif resp.StatusCode != http.StatusOK {\n\t\tslog.Debug(\"og: received non-OK status code\", \"url\", urlStr, \"status\", resp.StatusCode)\n\t\tc.cache.Set(ctx, cacheKey, emptyMap, c.ogTimeToLive) // Cache empty result for non-successful status codes\n\t\treturn nil, fmt.Errorf(\"%w: page not found\", ErrOgHandled)\n\t}\n\n\t// Check content type\n\tct := resp.Header.Get(\"Content-Type\")\n\tif ct == \"\" {\n\t\treturn nil, fmt.Errorf(\"missing Content-Type header\")\n\t} else {\n\t\tmediaType, _, err := mime.ParseMediaType(ct)\n\t\tif err != nil {\n\t\t\tslog.Debug(\"og: malformed Content-Type header\", \"url\", urlStr, \"contentType\", ct)\n\t\t\treturn nil, fmt.Errorf(\"%w malformed Content-Type header: %w\", ErrOgHandled, err)\n\t\t}\n\n\t\tif mediaType != \"text/html\" && mediaType != \"application/xhtml+xml\" {\n\t\t\tslog.Debug(\"og: unsupported Content-Type\", \"url\", urlStr, \"contentType\", mediaType)\n\t\t\treturn nil, fmt.Errorf(\"%w unsupported Content-Type: %s\", ErrOgHandled, mediaType)\n\t\t}\n\t}\n\n\tresp.Body = http.MaxBytesReader(nil, resp.Body, maxContentLength)\n\n\tdoc, err := html.Parse(resp.Body)\n\tif err != nil {\n\t\t// Check if the error is specifically because the limit was exceeded\n\t\tvar maxBytesErr *http.MaxBytesError\n\t\tif errors.As(err, &maxBytesErr) {\n\t\t\tslog.Debug(\"og: content exceeded max length\", \"url\", urlStr, \"limit\", maxContentLength)\n\t\t\treturn nil, fmt.Errorf(\"content too large: exceeded %d bytes\", maxContentLength)\n\t\t}\n\t\treturn nil, fmt.Errorf(\"failed to parse HTML: %w\", err)\n\t}\n\n\treturn doc, nil\n}\n"
  },
  {
    "path": "internal/ogtags/fetch_test.go",
    "content": "package ogtags\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/TecharoHQ/anubis/lib/config\"\n\t\"github.com/TecharoHQ/anubis/lib/store/memory\"\n\t\"golang.org/x/net/html\"\n)\n\nfunc TestFetchHTMLDocument(t *testing.T) {\n\ttests := []struct {\n\t\tname          string\n\t\thtmlContent   string\n\t\tcontentType   string\n\t\tstatusCode    int\n\t\tcontentLength int64\n\t\texpectError   bool\n\t}{\n\t\t{\n\t\t\tname: \"Valid HTML\",\n\t\t\thtmlContent: `<!DOCTYPE html>\n\t\t\t\t<html>\n\t\t\t\t<head><title>Test</title></head>\n\t\t\t\t<body><p>Test content</p></body>\n\t\t\t\t</html>`,\n\t\t\tcontentType: \"text/html\",\n\t\t\tstatusCode:  http.StatusOK,\n\t\t\texpectError: false,\n\t\t},\n\t\t{\n\t\t\tname:        \"Empty HTML\",\n\t\t\thtmlContent: \"\",\n\t\t\tcontentType: \"text/html\",\n\t\t\tstatusCode:  http.StatusOK,\n\t\t\texpectError: false,\n\t\t},\n\t\t{\n\t\t\tname:        \"Not found error\",\n\t\t\thtmlContent: \"\",\n\t\t\tcontentType: \"text/html\",\n\t\t\tstatusCode:  http.StatusNotFound,\n\t\t\texpectError: true,\n\t\t},\n\t\t{\n\t\t\tname:        \"Unsupported Content-Type\",\n\t\t\thtmlContent: \"*Insert rick roll here*\",\n\t\t\tcontentType: \"video/mp4\",\n\t\t\tstatusCode:  http.StatusOK,\n\t\t\texpectError: true,\n\t\t},\n\t\t{\n\t\t\tname:          \"Too large content\",\n\t\t\tcontentType:   \"text/html\",\n\t\t\tstatusCode:    http.StatusOK,\n\t\t\texpectError:   true,\n\t\t\tcontentLength: 5 * 1024 * 1024, // 5MB (over 2MB limit)\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tif tt.contentType != \"\" {\n\t\t\t\t\tw.Header().Set(\"Content-Type\", tt.contentType)\n\t\t\t\t}\n\t\t\t\tif tt.contentLength > 0 {\n\t\t\t\t\t// Simulate content length but avoid sending too much actual data\n\t\t\t\t\tw.Header().Set(\"Content-Length\", fmt.Sprintf(\"%d\", tt.contentLength))\n\t\t\t\t\tio.CopyN(w, strings.NewReader(\"X\"), tt.contentLength)\n\t\t\t\t} else {\n\t\t\t\t\tw.WriteHeader(tt.statusCode)\n\t\t\t\t\tw.Write([]byte(tt.htmlContent))\n\t\t\t\t}\n\t\t\t}))\n\t\t\tdefer ts.Close()\n\n\t\t\tcache := NewOGTagCache(\"\", config.OpenGraph{\n\t\t\t\tEnabled:      true,\n\t\t\t\tTimeToLive:   time.Minute,\n\t\t\t\tConsiderHost: false,\n\t\t\t}, memory.New(t.Context()), TargetOptions{})\n\t\t\tdoc, err := cache.fetchHTMLDocument(t.Context(), ts.URL, \"anything\")\n\n\t\t\tif tt.expectError {\n\t\t\t\tif err == nil {\n\t\t\t\t\tt.Error(\"expected error, got nil\")\n\t\t\t\t}\n\t\t\t\tif doc != nil {\n\t\t\t\t\tt.Error(\"expected nil document on error, got non-nil\")\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tif err != nil {\n\t\t\t\t\tt.Errorf(\"unexpected error: %v\", err)\n\t\t\t\t}\n\t\t\t\tif doc == nil {\n\t\t\t\t\tt.Error(\"expected non-nil document, got nil\")\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestFetchHTMLDocumentInvalidURL(t *testing.T) {\n\tif os.Getenv(\"DONT_USE_NETWORK\") != \"\" {\n\t\tt.Skip(\"test requires theoretical network egress\")\n\t}\n\n\tcache := NewOGTagCache(\"\", config.OpenGraph{\n\t\tEnabled:      true,\n\t\tTimeToLive:   time.Minute,\n\t\tConsiderHost: false,\n\t}, memory.New(t.Context()), TargetOptions{})\n\n\tdoc, err := cache.fetchHTMLDocument(t.Context(), \"http://invalid.url.that.doesnt.exist.example\", \"anything\")\n\n\tif err == nil {\n\t\tt.Error(\"expected error for invalid URL, got nil\")\n\t}\n\n\tif doc != nil {\n\t\tt.Error(\"expected nil document for invalid URL, got non-nil\")\n\t}\n}\n\n// fetchHTMLDocument allows you to call fetchHTMLDocumentWithCache without a duplicate generateCacheKey call\nfunc (c *OGTagCache) fetchHTMLDocument(ctx context.Context, urlStr string, originalHost string) (*html.Node, error) {\n\tcacheKey := c.generateCacheKey(urlStr, originalHost)\n\treturn c.fetchHTMLDocumentWithCache(ctx, urlStr, originalHost, cacheKey)\n}\n"
  },
  {
    "path": "internal/ogtags/integration_test.go",
    "content": "package ogtags\n\nimport (\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"net/url\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/TecharoHQ/anubis/lib/config\"\n\t\"github.com/TecharoHQ/anubis/lib/store/memory\"\n)\n\nfunc TestIntegrationGetOGTags(t *testing.T) {\n\tts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.Header().Set(\"Content-Type\", \"text/html\")\n\n\t\tswitch r.URL.Path {\n\t\tcase \"/simple\":\n\t\t\tw.Write([]byte(`\n\t\t\t\t<!DOCTYPE html>\n\t\t\t\t<html>\n\t\t\t\t<head>\n\t\t\t\t\t<meta property=\"og:title\" content=\"Simple Page\" />\n\t\t\t\t\t<meta property=\"og:type\" content=\"website\" />\n\t\t\t\t</head>\n\t\t\t\t<body><p>Simple page content</p></body>\n\t\t\t\t</html>\n\t\t\t`))\n\t\tcase \"/complete\":\n\t\t\tw.Write([]byte(`\n\t\t\t\t<!DOCTYPE html>\n\t\t\t\t<html>\n\t\t\t\t<head>\n\t\t\t\t\t<meta property=\"og:title\" content=\"Complete Page\" />\n\t\t\t\t\t<meta property=\"og:description\" content=\"A page with many OG tags\" />\n\t\t\t\t\t<meta property=\"og:image\" content=\"http://example.com/image.jpg\" />\n\t\t\t\t\t<meta property=\"og:url\" content=\"http://example.com/complete\" />\n\t\t\t\t\t<meta property=\"og:type\" content=\"article\" />\n\t\t\t\t</head>\n\t\t\t\t<body><p>Complete page content</p></body>\n\t\t\t\t</html>\n\t\t\t`))\n\t\tcase \"/no-og\":\n\t\t\tw.Write([]byte(`\n\t\t\t\t<!DOCTYPE html>\n\t\t\t\t<html>\n\t\t\t\t<head>\n\t\t\t\t\t<title>No OG Tags</title>\n\t\t\t\t</head>\n\t\t\t\t<body><p>No OG tags here</p></body>\n\t\t\t\t</html>\n\t\t\t`))\n\t\tdefault:\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t}\n\t}))\n\tdefer ts.Close()\n\n\t// Test with different configurations\n\ttestCases := []struct {\n\t\texpectedTags map[string]string\n\t\tname         string\n\t\tpath         string\n\t\tquery        string\n\t\texpectError  bool\n\t}{\n\t\t{\n\t\t\tname:  \"Simple page\",\n\t\t\tpath:  \"/simple\",\n\t\t\tquery: \"\",\n\t\t\texpectedTags: map[string]string{\n\t\t\t\t\"og:title\": \"Simple Page\",\n\t\t\t\t\"og:type\":  \"website\",\n\t\t\t},\n\t\t\texpectError: false,\n\t\t},\n\t\t{\n\t\t\tname:  \"Complete page\",\n\t\t\tpath:  \"/complete\",\n\t\t\tquery: \"ref=test\",\n\t\t\texpectedTags: map[string]string{\n\t\t\t\t\"og:title\":       \"Complete Page\",\n\t\t\t\t\"og:description\": \"A page with many OG tags\",\n\t\t\t\t\"og:image\":       \"http://example.com/image.jpg\",\n\t\t\t\t\"og:url\":         \"http://example.com/complete\",\n\t\t\t\t\"og:type\":        \"article\",\n\t\t\t},\n\t\t\texpectError: false,\n\t\t},\n\t\t{\n\t\t\tname:         \"Page with no OG tags\",\n\t\t\tpath:         \"/no-og\",\n\t\t\tquery:        \"\",\n\t\t\texpectedTags: map[string]string{},\n\t\t\texpectError:  false,\n\t\t},\n\t\t{\n\t\t\tname:         \"Nonexistent page\",\n\t\t\tpath:         \"/not-found\",\n\t\t\tquery:        \"\",\n\t\t\texpectedTags: nil,\n\t\t\texpectError:  false,\n\t\t},\n\t}\n\n\tfor _, tc := range testCases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\t// Create cache instance\n\t\t\tcache := NewOGTagCache(ts.URL, config.OpenGraph{\n\t\t\t\tEnabled:      true,\n\t\t\t\tTimeToLive:   time.Minute,\n\t\t\t\tConsiderHost: false,\n\t\t\t}, memory.New(t.Context()), TargetOptions{})\n\n\t\t\t// Create URL for test\n\t\t\ttestURL, _ := url.Parse(ts.URL)\n\t\t\ttestURL.Path = tc.path\n\t\t\ttestURL.RawQuery = tc.query\n\n\t\t\t// Get OG tags\n\t\t\t// Pass the host from the test URL\n\t\t\togTags, err := cache.GetOGTags(t.Context(), testURL, testURL.Host)\n\n\t\t\t// Check error expectation\n\t\t\tif tc.expectError {\n\t\t\t\tif err == nil {\n\t\t\t\t\tt.Error(\"expected error, got nil\")\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t\t\t}\n\n\t\t\t// Verify all expected tags are present\n\t\t\tfor key, expectedValue := range tc.expectedTags {\n\t\t\t\tif value, ok := ogTags[key]; !ok || value != expectedValue {\n\t\t\t\t\tt.Errorf(\"expected %s: %s, got: %s\", key, expectedValue, value)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Verify no extra tags are present\n\t\t\tif len(ogTags) != len(tc.expectedTags) {\n\t\t\t\tt.Errorf(\"expected %d tags, got %d\", len(tc.expectedTags), len(ogTags))\n\t\t\t}\n\n\t\t\t// Test cache retrieval\n\t\t\t// Pass the host from the test URL\n\t\t\tcachedOGTags, err := cache.GetOGTags(t.Context(), testURL, testURL.Host)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"failed to get OG tags from cache: %v\", err)\n\t\t\t}\n\n\t\t\t// Verify cached tags match\n\t\t\tfor key, expectedValue := range tc.expectedTags {\n\t\t\t\tif value, ok := cachedOGTags[key]; !ok || value != expectedValue {\n\t\t\t\t\tt.Errorf(\"cached value - expected %s: %s, got: %s\", key, expectedValue, value)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "internal/ogtags/mem_test.go",
    "content": "package ogtags\n\nimport (\n\t\"net/url\"\n\t\"runtime\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/TecharoHQ/anubis/lib/config\"\n\t\"github.com/TecharoHQ/anubis/lib/store/memory\"\n\t\"golang.org/x/net/html\"\n)\n\nfunc BenchmarkGetTarget(b *testing.B) {\n\ttests := []struct {\n\t\tname   string\n\t\ttarget string\n\t\tpaths  []string\n\t}{\n\t\t{\n\t\t\tname:   \"HTTP\",\n\t\t\ttarget: \"http://example.com\",\n\t\t\tpaths:  []string{\"/\", \"/path\", \"/path/to/resource\", \"/path?query=1&foo=bar\"},\n\t\t},\n\t\t{\n\t\t\tname:   \"Unix\",\n\t\t\ttarget: \"unix:///var/run/app.sock\",\n\t\t\tpaths:  []string{\"/\", \"/api/endpoint\", \"/api/endpoint?param=value\"},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tb.Run(tt.name, func(b *testing.B) {\n\t\t\tcache := NewOGTagCache(tt.target, config.OpenGraph{}, memory.New(b.Context()), TargetOptions{})\n\t\t\turls := make([]*url.URL, len(tt.paths))\n\t\t\tfor i, path := range tt.paths {\n\t\t\t\tu, _ := url.Parse(path)\n\t\t\t\turls[i] = u\n\t\t\t}\n\n\t\t\tb.ResetTimer()\n\t\t\tb.ReportAllocs()\n\n\t\t\tfor i := 0; i < b.N; i++ {\n\t\t\t\t_ = cache.getTarget(urls[i%len(urls)])\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc BenchmarkExtractOGTags(b *testing.B) {\n\thtmlSamples := []string{\n\t\t`<html><head>\n\t\t\t<meta property=\"og:title\" content=\"Test Title\">\n\t\t\t<meta property=\"og:description\" content=\"Test Description\">\n\t\t\t<meta name=\"keywords\" content=\"test,keywords\">\n\t\t</head><body></body></html>`,\n\t\t`<html><head>\n\t\t\t<meta property=\"og:title\" content=\"Page Title\">\n\t\t\t<meta property=\"og:type\" content=\"website\">\n\t\t\t<meta property=\"og:url\" content=\"https://example.com\">\n\t\t\t<meta property=\"og:image\" content=\"https://example.com/image.jpg\">\n\t\t\t<meta property=\"twitter:card\" content=\"summary_large_image\">\n\t\t\t<meta property=\"twitter:title\" content=\"Twitter Title\">\n\t\t\t<meta name=\"description\" content=\"Page description\">\n\t\t\t<meta name=\"author\" content=\"John Doe\">\n\t\t</head><body><div><p>Content</p></div></body></html>`,\n\t}\n\n\tcache := NewOGTagCache(\"http://example.com\", config.OpenGraph{}, memory.New(b.Context()), TargetOptions{})\n\tdocs := make([]*html.Node, len(htmlSamples))\n\n\tfor i, sample := range htmlSamples {\n\t\tdoc, _ := html.Parse(strings.NewReader(sample))\n\t\tdocs[i] = doc\n\t}\n\n\tb.ResetTimer()\n\tb.ReportAllocs()\n\n\tfor i := 0; i < b.N; i++ {\n\t\t_ = cache.extractOGTags(docs[i%len(docs)])\n\t}\n}\n\n// Memory usage test\nfunc TestMemoryUsage(t *testing.T) {\n\tcache := NewOGTagCache(\"http://example.com\", config.OpenGraph{}, memory.New(t.Context()), TargetOptions{})\n\n\t// Force GC and wait for it to complete\n\truntime.GC()\n\n\tvar m1 runtime.MemStats\n\truntime.ReadMemStats(&m1)\n\n\t// Run getTarget many times\n\tu, _ := url.Parse(\"/path/to/resource?query=1&foo=bar&baz=qux\")\n\tfor range 10000 {\n\t\t_ = cache.getTarget(u)\n\t}\n\n\t// Force GC after operations\n\truntime.GC()\n\n\tvar m2 runtime.MemStats\n\truntime.ReadMemStats(&m2)\n\n\tallocatedBytes := int64(m2.TotalAlloc) - int64(m1.TotalAlloc)\n\tallocatedKB := float64(allocatedBytes) / 1024.0\n\tallocatedPerOp := float64(allocatedBytes) / 10000.0\n\n\tt.Logf(\"Memory allocated for 10k getTarget calls:\")\n\tt.Logf(\"  Total: %.2f KB (%.2f MB)\", allocatedKB, allocatedKB/1024.0)\n\tt.Logf(\"  Per operation: %.2f bytes\", allocatedPerOp)\n\n\t// Test extractOGTags memory usage\n\thtmlDoc := `<html><head>\n\t\t<meta property=\"og:title\" content=\"Test Title\">\n\t\t<meta property=\"og:description\" content=\"Test Description\">\n\t\t<meta property=\"og:image\" content=\"https://example.com/image.jpg\">\n\t\t<meta property=\"twitter:card\" content=\"summary\">\n\t\t<meta name=\"keywords\" content=\"test,keywords,example\">\n\t\t<meta name=\"author\" content=\"Test Author\">\n\t\t<meta property=\"unknown:tag\" content=\"Should be ignored\">\n\t</head><body></body></html>`\n\n\tdoc, _ := html.Parse(strings.NewReader(htmlDoc))\n\n\truntime.GC()\n\truntime.ReadMemStats(&m1)\n\n\tfor range 1000 {\n\t\t_ = cache.extractOGTags(doc)\n\t}\n\n\truntime.GC()\n\truntime.ReadMemStats(&m2)\n\n\tallocatedBytes = int64(m2.TotalAlloc) - int64(m1.TotalAlloc)\n\tallocatedKB = float64(allocatedBytes) / 1024.0\n\tallocatedPerOp = float64(allocatedBytes) / 1000.0\n\n\tt.Logf(\"Memory allocated for 1k extractOGTags calls:\")\n\tt.Logf(\"  Total: %.2f KB (%.2f MB)\", allocatedKB, allocatedKB/1024.0)\n\tt.Logf(\"  Per operation: %.2f bytes\", allocatedPerOp)\n\n\t// Sanity checks\n\tif allocatedPerOp > 10000 {\n\t\tt.Errorf(\"extractOGTags allocating too much memory per operation: %.2f bytes\", allocatedPerOp)\n\t}\n}\n"
  },
  {
    "path": "internal/ogtags/ogtags.go",
    "content": "package ogtags\n\nimport (\n\t\"context\"\n\t\"crypto/tls\"\n\t\"log/slog\"\n\t\"net\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/TecharoHQ/anubis/lib/config\"\n\t\"github.com/TecharoHQ/anubis/lib/store\"\n)\n\nconst (\n\tmaxContentLength = 8 << 20         // 8 MiB is enough for anyone\n\thttpTimeout      = 5 * time.Second /*todo: make this configurable?*/\n\n\tschemeSeparatorLength = 3 // Length of \"://\"\n\tquerySeparatorLength  = 1 // Length of \"?\" for query strings\n)\n\ntype OGTagCache struct {\n\togOverride map[string]string\n\ttargetURL  *url.URL\n\tclient     *http.Client\n\ttransport  *http.Transport\n\tcache      store.JSON[map[string]string]\n\n\t// Pre-built strings for optimization\n\tunixPrefix          string // \"http://unix\"\n\ttargetSNI           string\n\ttargetHost          string\n\tapprovedPrefixes    []string\n\tapprovedTags        []string\n\togTimeToLive        time.Duration\n\togPassthrough       bool\n\togCacheConsiderHost bool\n\ttargetSNIAuto       bool\n\tinsecureSkipVerify  bool\n\tsniClients          map[string]*http.Client\n\ttransportMu         sync.RWMutex\n}\n\ntype TargetOptions struct {\n\tHost               string\n\tSNI                string\n\tInsecureSkipVerify bool\n}\n\nfunc NewOGTagCache(target string, conf config.OpenGraph, backend store.Interface, targetOpts TargetOptions) *OGTagCache {\n\t// Predefined approved tags and prefixes\n\tdefaultApprovedTags := []string{\"description\", \"keywords\", \"author\"}\n\tdefaultApprovedPrefixes := []string{\"og:\", \"twitter:\", \"fediverse:\"}\n\n\tvar parsedTargetURL *url.URL\n\tvar err error\n\n\tif target == \"\" {\n\t\t// Default to localhost if target is empty\n\t\tparsedTargetURL, _ = url.Parse(\"http://localhost\")\n\t} else {\n\t\tparsedTargetURL, err = url.Parse(target)\n\t\tif err != nil {\n\t\t\tslog.Debug(\"og: failed to parse target URL, treating as non-unix\", \"target\", target, \"error\", err)\n\t\t\t// If parsing fails, treat it as a non-unix target for backward compatibility or default behavior\n\t\t\t// For now, assume it's not a scheme issue but maybe an invalid char, etc.\n\t\t\t// A simple string target might be intended if it's not a full URL.\n\t\t\tparsedTargetURL = &url.URL{Scheme: \"http\", Host: target} // Assume http if scheme missing and host-like\n\t\t\tif !strings.Contains(target, \"://\") && !strings.HasPrefix(target, \"unix:\") {\n\t\t\t\t// 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)\n\t\t\t\tparsedTargetURL, _ = url.Parse(\"http://\" + target) // fetch cares about scheme but anubis doesn't\n\t\t\t}\n\t\t}\n\t}\n\n\ttransport := http.DefaultTransport.(*http.Transport).Clone()\n\n\t// Configure custom transport for Unix sockets\n\tif parsedTargetURL.Scheme == \"unix\" {\n\t\tsocketPath := parsedTargetURL.Path // For unix scheme, path is the socket path\n\t\ttransport.DialContext = func(_ context.Context, _, _ string) (net.Conn, error) {\n\t\t\treturn net.Dial(\"unix\", socketPath)\n\t\t}\n\t}\n\n\ttargetSNIAuto := targetOpts.SNI == \"auto\"\n\n\tif targetOpts.SNI != \"\" && !targetSNIAuto {\n\t\tif transport.TLSClientConfig == nil {\n\t\t\ttransport.TLSClientConfig = &tls.Config{}\n\t\t}\n\t\ttransport.TLSClientConfig.ServerName = targetOpts.SNI\n\t}\n\n\tif targetOpts.InsecureSkipVerify {\n\t\tif transport.TLSClientConfig == nil {\n\t\t\ttransport.TLSClientConfig = &tls.Config{}\n\t\t}\n\t\ttransport.TLSClientConfig.InsecureSkipVerify = true\n\t}\n\n\tclient := &http.Client{\n\t\tTimeout:   httpTimeout,\n\t\tTransport: transport,\n\t}\n\n\treturn &OGTagCache{\n\t\tcache: store.JSON[map[string]string]{\n\t\t\tUnderlying: backend,\n\t\t\tPrefix:     \"ogtags:\",\n\t\t},\n\t\ttargetURL:           parsedTargetURL,\n\t\togPassthrough:       conf.Enabled,\n\t\togTimeToLive:        conf.TimeToLive,\n\t\togCacheConsiderHost: conf.ConsiderHost,\n\t\togOverride:          conf.Override,\n\t\tapprovedTags:        defaultApprovedTags,\n\t\tapprovedPrefixes:    defaultApprovedPrefixes,\n\t\tclient:              client,\n\t\ttransport:           transport,\n\t\tunixPrefix:          \"http://unix\",\n\t\ttargetHost:          targetOpts.Host,\n\t\ttargetSNI:           targetOpts.SNI,\n\t\ttargetSNIAuto:       targetSNIAuto,\n\t\tinsecureSkipVerify:  targetOpts.InsecureSkipVerify,\n\t\tsniClients:          make(map[string]*http.Client),\n\t}\n}\n\n// getTarget constructs the target URL string for fetching OG tags.\n// Optimized to minimize allocations by building strings directly.\nfunc (c *OGTagCache) getTarget(u *url.URL) string {\n\tvar escapedPath = u.EscapedPath() // will cause an allocation if path contains special characters\n\tif c.targetURL.Scheme == \"unix\" {\n\t\t// Build URL string directly without creating intermediate URL object\n\t\tvar sb strings.Builder\n\t\tsb.Grow(len(c.unixPrefix) + len(escapedPath) + len(u.RawQuery) + querySeparatorLength) // Pre-allocate\n\t\tsb.WriteString(c.unixPrefix)\n\t\tsb.WriteString(escapedPath)\n\t\tif u.RawQuery != \"\" {\n\t\t\tsb.WriteByte('?')\n\t\t\tsb.WriteString(u.RawQuery)\n\t\t}\n\t\treturn sb.String()\n\t}\n\n\t// For regular http/https targets, build URL string directly\n\tvar sb strings.Builder\n\t// Pre-calculate size: scheme + \"://\" + host + path + \"?\" + query\n\testimatedSize := len(c.targetURL.Scheme) + schemeSeparatorLength + len(c.targetURL.Host) + len(escapedPath) + len(u.RawQuery) + querySeparatorLength\n\tsb.Grow(estimatedSize)\n\n\tsb.WriteString(c.targetURL.Scheme)\n\tsb.WriteString(\"://\")\n\tsb.WriteString(c.targetURL.Host)\n\tsb.WriteString(escapedPath)\n\tif u.RawQuery != \"\" {\n\t\tsb.WriteByte('?')\n\t\tsb.WriteString(u.RawQuery)\n\t}\n\n\treturn sb.String()\n}\n"
  },
  {
    "path": "internal/ogtags/ogtags_fuzz_test.go",
    "content": "package ogtags\n\nimport (\n\t\"context\"\n\t\"net/url\"\n\t\"slices\"\n\t\"strings\"\n\t\"testing\"\n\t\"unicode/utf8\"\n\n\t\"github.com/TecharoHQ/anubis/lib/config\"\n\t\"github.com/TecharoHQ/anubis/lib/store/memory\"\n\t\"golang.org/x/net/html\"\n)\n\n// FuzzGetTarget tests getTarget with various inputs\nfunc FuzzGetTarget(f *testing.F) {\n\t// Seed corpus with interesting test cases\n\ttestCases := []struct {\n\t\ttarget string\n\t\tpath   string\n\t\tquery  string\n\t}{\n\t\t{\"http://example.com\", \"/\", \"\"},\n\t\t{\"http://example.com\", \"/path\", \"q=1\"},\n\t\t{\"unix:///tmp/socket\", \"/api\", \"key=value\"},\n\t\t{\"https://example.com:8080\", \"/path/to/resource\", \"a=1&b=2\"},\n\t\t{\"http://example.com\", \"/path with spaces\", \"q=hello world\"},\n\t\t{\"http://example.com\", \"/path/❤️/emoji\", \"emoji=🎉\"},\n\t\t{\"http://example.com\", \"/path/../../../etc/passwd\", \"\"},\n\t\t{\"http://example.com\", \"/path%2F%2E%2E%2F\", \"q=%3Cscript%3E\"},\n\t\t{\"unix:///var/run/app.sock\", \"/../../etc/passwd\", \"\"},\n\t\t{\"http://[::1]:8080\", \"/ipv6\", \"test=1\"},\n\t\t{\"http://example.com\", strings.Repeat(\"/very/long/path\", 100), strings.Repeat(\"param=value&\", 100)},\n\t\t{\"http://example.com\", \"/path%20with%20encoded\", \"q=%20encoded%20\"},\n\t\t{\"http://example.com\", \"/пример/кириллица\", \"q=тест\"},\n\t\t{\"http://example.com\", \"/中文/路径\", \"查询=值\"},\n\t\t{\"\", \"/path\", \"q=1\"}, // Empty target\n\t}\n\n\tfor _, tc := range testCases {\n\t\tf.Add(tc.target, tc.path, tc.query)\n\t}\n\n\tf.Fuzz(func(t *testing.T, target, path, query string) {\n\t\t// Skip invalid UTF-8 to focus on realistic inputs\n\t\tif !utf8.ValidString(target) || !utf8.ValidString(path) || !utf8.ValidString(query) {\n\t\t\tt.Skip()\n\t\t}\n\n\t\t// Create cache - should not panic\n\t\tcache := NewOGTagCache(target, config.OpenGraph{}, memory.New(context.Background()), TargetOptions{})\n\n\t\t// Create URL\n\t\tu := &url.URL{\n\t\t\tPath:     path,\n\t\t\tRawQuery: query,\n\t\t}\n\n\t\t// Call getTarget - should not panic\n\t\tresult := cache.getTarget(u)\n\n\t\t// Basic validation\n\t\tif result == \"\" {\n\t\t\tt.Errorf(\"getTarget returned empty string for target=%q, path=%q, query=%q\", target, path, query)\n\t\t}\n\n\t\t// Verify result is a valid URL (for non-empty targets)\n\t\tif target != \"\" {\n\t\t\tparsedResult, err := url.Parse(result)\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"getTarget produced invalid URL %q: %v\", result, err)\n\t\t\t} else {\n\t\t\t\t// For unix sockets, verify the scheme is http\n\t\t\t\tif strings.HasPrefix(target, \"unix:\") && parsedResult.Scheme != \"http\" {\n\t\t\t\t\tt.Errorf(\"Unix socket URL should have http scheme, got %q\", parsedResult.Scheme)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// Ensure no memory corruption by calling multiple times\n\t\tfor range 3 {\n\t\t\tresult2 := cache.getTarget(u)\n\t\t\tif result != result2 {\n\t\t\t\tt.Errorf(\"getTarget not deterministic: %q != %q\", result, result2)\n\t\t\t}\n\t\t}\n\t})\n}\n\n// FuzzExtractOGTags tests extractOGTags with various HTML inputs\nfunc FuzzExtractOGTags(f *testing.F) {\n\t// Seed corpus with interesting HTML cases\n\thtmlCases := []string{\n\t\t`<html><head><meta property=\"og:title\" content=\"Test\"></head></html>`,\n\t\t`<meta property=\"og:title\" content=\"No HTML tags\">`,\n\t\t`<html><head>` + strings.Repeat(`<meta property=\"og:title\" content=\"Many tags\">`, 1000) + `</head></html>`,\n\t\t`<html><head><meta property=\"og:title\" content=\"<script>alert('xss')</script>\"></head></html>`,\n\t\t`<html><head><meta property=\"og:title\" content=\"Line1&#10;Line2\"></head></html>`,\n\t\t`<html><head><meta property=\"og:emoji\" content=\"❤️🎉🎊\"></head></html>`,\n\t\t`<html><head><meta property=\"og:title\" content=\"` + strings.Repeat(\"A\", 10000) + `\"></head></html>`,\n\t\t`<html><head><meta property=\"og:title\" content='Single quotes'></head></html>`,\n\t\t`<html><head><meta property=og:title content=no-quotes></head></html>`,\n\t\t`<html><head><meta name=\"keywords\" content=\"test,keywords\"></head></html>`,\n\t\t`<html><head><meta property=\"unknown:tag\" content=\"Should be ignored\"></head></html>`,\n\t\t`<html><head><meta property=\"` + strings.Repeat(\"og:\", 100) + `title\" content=\"Nested prefixes\"></head></html>`,\n\t\t`<html>` + strings.Repeat(`<div>`, 1000) + `<meta property=\"og:title\" content=\"Deep nesting\">` + strings.Repeat(`</div>`, 1000) + `</html>`,\n\t\t`<!DOCTYPE html><html xmlns=\"http://www.w3.org/1999/xhtml\"><head><meta property=\"og:title\" content=\"With doctype\"/></head></html>`,\n\t\t`<html><head><meta property=\"\" content=\"Empty property\"></head></html>`,\n\t\t`<html><head><meta content=\"Content only\"></head></html>`,\n\t\t`<html><head><meta property=\"og:title\"></head></html>`, // No content\n\t\t``, // Empty HTML\n\t\t`<html><head><meta property=\"og:title\" content=\"Кириллица\"></head></html>`,\n\t\t`<html><head><meta property=\"og:title\" content=\"中文内容\"></head></html>`,\n\t\t`<html><head><!--<meta property=\"og:title\" content=\"Commented out\">--></head></html>`,\n\t\t`<html><head><META PROPERTY=\"OG:TITLE\" CONTENT=\"UPPERCASE\"></head></html>`,\n\t}\n\n\tfor _, htmlc := range htmlCases {\n\t\tf.Add(htmlc)\n\t}\n\n\tf.Fuzz(func(t *testing.T, htmlContent string) {\n\t\t// Skip invalid UTF-8\n\t\tif !utf8.ValidString(htmlContent) {\n\t\t\tt.Skip()\n\t\t}\n\n\t\t// Parse HTML - may fail on invalid input\n\t\tdoc, err := html.Parse(strings.NewReader(htmlContent))\n\t\tif err != nil {\n\t\t\t// This is expected for malformed HTML\n\t\t\treturn\n\t\t}\n\n\t\tcache := NewOGTagCache(\"http://example.com\", config.OpenGraph{}, memory.New(context.Background()), TargetOptions{})\n\n\t\t// Should not panic\n\t\ttags := cache.extractOGTags(doc)\n\n\t\t// Validate results\n\t\tfor property, content := range tags {\n\t\t\t// Ensure property is approved\n\t\t\tapproved := false\n\t\t\tfor _, prefix := range cache.approvedPrefixes {\n\t\t\t\tif strings.HasPrefix(property, prefix) {\n\t\t\t\t\tapproved = true\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t\tif !approved {\n\t\t\t\tif slices.Contains(cache.approvedTags, property) {\n\t\t\t\t\tapproved = true\n\t\t\t\t}\n\t\t\t}\n\t\t\tif !approved {\n\t\t\t\tt.Errorf(\"Unapproved property %q was extracted\", property)\n\t\t\t}\n\n\t\t\t// Ensure content is valid string\n\t\t\tif !utf8.ValidString(content) {\n\t\t\t\tt.Errorf(\"Invalid UTF-8 in content for property %q\", property)\n\t\t\t}\n\t\t}\n\n\t\t// Test determinism\n\t\ttags2 := cache.extractOGTags(doc)\n\t\tif len(tags) != len(tags2) {\n\t\t\tt.Errorf(\"extractOGTags not deterministic: different lengths %d != %d\", len(tags), len(tags2))\n\t\t}\n\t\tfor k, v := range tags {\n\t\t\tif tags2[k] != v {\n\t\t\t\tt.Errorf(\"extractOGTags not deterministic: %q=%q != %q=%q\", k, v, k, tags2[k])\n\t\t\t}\n\t\t}\n\t})\n}\n\n// FuzzGetTargetRoundTrip tests that getTarget produces valid URLs that can be parsed back\nfunc FuzzGetTargetRoundTrip(f *testing.F) {\n\tf.Add(\"http://example.com\", \"/path/to/resource\", \"key=value&foo=bar\")\n\tf.Add(\"unix:///tmp/socket\", \"/api/endpoint\", \"param=test\")\n\n\tf.Fuzz(func(t *testing.T, target, path, query string) {\n\t\tif !utf8.ValidString(target) || !utf8.ValidString(path) || !utf8.ValidString(query) {\n\t\t\tt.Skip()\n\t\t}\n\n\t\tcache := NewOGTagCache(target, config.OpenGraph{}, memory.New(context.Background()), TargetOptions{})\n\t\tu := &url.URL{Path: path, RawQuery: query}\n\n\t\tresult := cache.getTarget(u)\n\t\tif result == \"\" {\n\t\t\treturn\n\t\t}\n\n\t\t// Parse the result back\n\t\tparsed, err := url.Parse(result)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"getTarget produced unparseable URL: %v\", err)\n\t\t\treturn\n\t\t}\n\n\t\t// For non-unix targets, verify path preservation (accounting for encoding)\n\t\tif !strings.HasPrefix(target, \"unix:\") && target != \"\" {\n\t\t\t// The paths should match after normalization\n\t\t\texpectedPath := u.EscapedPath()\n\t\t\tif parsed.EscapedPath() != expectedPath {\n\t\t\t\tt.Errorf(\"Path not preserved: want %q, got %q\", expectedPath, parsed.EscapedPath())\n\t\t\t}\n\n\t\t\t// Query should be preserved exactly\n\t\t\tif parsed.RawQuery != query {\n\t\t\t\tt.Errorf(\"Query not preserved: want %q, got %q\", query, parsed.RawQuery)\n\t\t\t}\n\t\t}\n\t})\n}\n\n// FuzzExtractMetaTagInfo tests the extractMetaTagInfo function directly\nfunc FuzzExtractMetaTagInfo(f *testing.F) {\n\t// Seed with various attribute combinations\n\tf.Add(\"og:title\", \"Test Title\", \"property\")\n\tf.Add(\"keywords\", \"test,keywords\", \"name\")\n\tf.Add(\"og:description\", \"A description with \\\"quotes\\\"\", \"property\")\n\tf.Add(\"twitter:card\", \"summary\", \"property\")\n\tf.Add(\"unknown:tag\", \"Should be filtered\", \"property\")\n\tf.Add(\"\", \"Content without property\", \"property\")\n\tf.Add(\"og:title\", \"\", \"property\") // Property without content\n\n\tf.Fuzz(func(t *testing.T, propertyValue, contentValue, propertyKey string) {\n\t\tif !utf8.ValidString(propertyValue) || !utf8.ValidString(contentValue) || !utf8.ValidString(propertyKey) {\n\t\t\tt.Skip()\n\t\t}\n\n\t\t// Create a meta node\n\t\tnode := &html.Node{\n\t\t\tType: html.ElementNode,\n\t\t\tData: \"meta\",\n\t\t\tAttr: []html.Attribute{\n\t\t\t\t{Key: propertyKey, Val: propertyValue},\n\t\t\t\t{Key: \"content\", Val: contentValue},\n\t\t\t},\n\t\t}\n\n\t\tcache := NewOGTagCache(\"http://example.com\", config.OpenGraph{}, memory.New(context.Background()), TargetOptions{})\n\n\t\t// Should not panic\n\t\tproperty, content := cache.extractMetaTagInfo(node)\n\n\t\t// If property is returned, it must be approved\n\t\tif property != \"\" {\n\t\t\tapproved := false\n\t\t\tfor _, prefix := range cache.approvedPrefixes {\n\t\t\t\tif strings.HasPrefix(property, prefix) {\n\t\t\t\t\tapproved = true\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t\tif !approved {\n\t\t\t\tif slices.Contains(cache.approvedTags, property) {\n\t\t\t\t\tapproved = true\n\t\t\t\t}\n\t\t\t}\n\t\t\tif !approved {\n\t\t\t\tt.Errorf(\"extractMetaTagInfo returned unapproved property: %q\", property)\n\t\t\t}\n\t\t}\n\n\t\t// Content should match input if property is approved\n\t\tif property != \"\" && content != contentValue {\n\t\t\tt.Errorf(\"Content mismatch: want %q, got %q\", contentValue, content)\n\t\t}\n\t})\n}\n\n// Benchmark comparison for the fuzzed scenarios\nfunc BenchmarkFuzzedGetTarget(b *testing.B) {\n\t// Test with various challenging inputs found during fuzzing\n\tinputs := []struct {\n\t\tname   string\n\t\ttarget string\n\t\tpath   string\n\t\tquery  string\n\t}{\n\t\t{\"Simple\", \"http://example.com\", \"/api\", \"k=v\"},\n\t\t{\"LongPath\", \"http://example.com\", strings.Repeat(\"/segment\", 50), \"\"},\n\t\t{\"LongQuery\", \"http://example.com\", \"/\", strings.Repeat(\"param=value&\", 50)},\n\t\t{\"Unicode\", \"http://example.com\", \"/путь/路径/path\", \"q=значение\"},\n\t\t{\"Encoded\", \"http://example.com\", \"/path%20with%20spaces\", \"q=%3Cscript%3E\"},\n\t\t{\"Unix\", \"unix:///tmp/socket.sock\", \"/api/v1/resource\", \"id=123&format=json\"},\n\t}\n\n\tfor _, input := range inputs {\n\t\tb.Run(input.name, func(b *testing.B) {\n\t\t\tcache := NewOGTagCache(input.target, config.OpenGraph{}, memory.New(context.Background()), TargetOptions{})\n\t\t\tu := &url.URL{Path: input.path, RawQuery: input.query}\n\n\t\t\tb.ResetTimer()\n\t\t\tb.ReportAllocs()\n\n\t\t\tfor i := 0; i < b.N; i++ {\n\t\t\t\t_ = cache.getTarget(u)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "internal/ogtags/ogtags_test.go",
    "content": "package ogtags\n\nimport (\n\t\"context\"\n\t\"crypto/rand\"\n\t\"crypto/rsa\"\n\t\"crypto/tls\"\n\t\"crypto/x509\"\n\t\"crypto/x509/pkix\"\n\t\"errors\"\n\t\"fmt\"\n\t\"math/big\"\n\t\"net\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"net/url\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"reflect\"\n\t\"strings\"\n\t\"sync\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/TecharoHQ/anubis/lib/config\"\n\t\"github.com/TecharoHQ/anubis/lib/store/memory\"\n)\n\nfunc TestNewOGTagCache(t *testing.T) {\n\ttests := []struct {\n\t\tname          string\n\t\ttarget        string\n\t\togPassthrough bool\n\t\togTimeToLive  time.Duration\n\t}{\n\t\t{\n\t\t\tname:          \"Basic initialization\",\n\t\t\ttarget:        \"http://example.com\",\n\t\t\togPassthrough: true,\n\t\t\togTimeToLive:  5 * time.Minute,\n\t\t},\n\t\t{\n\t\t\tname:          \"Empty target\",\n\t\t\ttarget:        \"\",\n\t\t\togPassthrough: false,\n\t\t\togTimeToLive:  10 * time.Minute,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tcache := NewOGTagCache(tt.target, config.OpenGraph{\n\t\t\t\tEnabled:      tt.ogPassthrough,\n\t\t\t\tTimeToLive:   tt.ogTimeToLive,\n\t\t\t\tConsiderHost: false,\n\t\t\t}, memory.New(t.Context()), TargetOptions{})\n\n\t\t\tif cache == nil {\n\t\t\t\tt.Fatal(\"expected non-nil cache, got nil\")\n\t\t\t}\n\n\t\t\t// Check the parsed targetURL, handling the default case for empty target\n\t\t\texpectedURLStr := tt.target\n\t\t\tif tt.target == \"\" {\n\t\t\t\t// Default behavior when target is empty is now http://localhost\n\t\t\t\texpectedURLStr = \"http://localhost\"\n\t\t\t} else if !strings.Contains(tt.target, \"://\") && !strings.HasPrefix(tt.target, \"unix:\") {\n\t\t\t\t// Handle case where target is just host or host:port (and not unix)\n\t\t\t\texpectedURLStr = \"http://\" + tt.target\n\t\t\t}\n\t\t\tif cache.targetURL.String() != expectedURLStr {\n\t\t\t\tt.Errorf(\"expected targetURL %s, got %s\", expectedURLStr, cache.targetURL.String())\n\t\t\t}\n\n\t\t\tif cache.ogPassthrough != tt.ogPassthrough {\n\t\t\t\tt.Errorf(\"expected ogPassthrough %v, got %v\", tt.ogPassthrough, cache.ogPassthrough)\n\t\t\t}\n\n\t\t\tif cache.ogTimeToLive != tt.ogTimeToLive {\n\t\t\t\tt.Errorf(\"expected ogTimeToLive %v, got %v\", tt.ogTimeToLive, cache.ogTimeToLive)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestNewOGTagCache_UnixSocket specifically tests unix socket initialization\nfunc TestNewOGTagCache_UnixSocket(t *testing.T) {\n\ttempDir := t.TempDir()\n\tsocketPath := filepath.Join(tempDir, \"test.sock\")\n\ttarget := \"unix://\" + socketPath\n\n\tcache := NewOGTagCache(target, config.OpenGraph{\n\t\tEnabled:      true,\n\t\tTimeToLive:   5 * time.Minute,\n\t\tConsiderHost: false,\n\t}, memory.New(t.Context()), TargetOptions{})\n\n\tif cache == nil {\n\t\tt.Fatal(\"expected non-nil cache, got nil\")\n\t}\n\n\tif cache.targetURL.Scheme != \"unix\" {\n\t\tt.Errorf(\"expected targetURL scheme 'unix', got '%s'\", cache.targetURL.Scheme)\n\t}\n\tif cache.targetURL.Path != socketPath {\n\t\tt.Errorf(\"expected targetURL path '%s', got '%s'\", socketPath, cache.targetURL.Path)\n\t}\n\n\t// Check if the client transport is configured for Unix sockets\n\ttransport, ok := cache.client.Transport.(*http.Transport)\n\tif !ok {\n\t\tt.Fatalf(\"expected client transport to be *http.Transport, got %T\", cache.client.Transport)\n\t}\n\tif transport.DialContext == nil {\n\t\tt.Fatal(\"expected client transport DialContext to be non-nil for unix socket\")\n\t}\n\n\t// Attempt a dummy dial to see if it uses the correct path (optional, more involved check)\n\tdummyConn, err := transport.DialContext(context.Background(), \"\", \"\")\n\tif err == nil {\n\t\tdummyConn.Close()\n\t\tt.Log(\"DialContext seems functional, but couldn't verify path without a listener\")\n\t} else if !strings.Contains(err.Error(), \"connect: connection refused\") && !strings.Contains(err.Error(), \"connect: no such file or directory\") {\n\t\t// We expect connection refused or not found if nothing is listening\n\t\tt.Errorf(\"DialContext failed with unexpected error: %v\", err)\n\t}\n}\n\nfunc TestGetTarget(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\ttarget   string\n\t\tpath     string\n\t\tquery    string\n\t\texpected string\n\t}{\n\t\t{\n\t\t\tname:     \"No path or query\",\n\t\t\ttarget:   \"http://example.com\",\n\t\t\tpath:     \"\",\n\t\t\tquery:    \"\",\n\t\t\texpected: \"http://example.com\",\n\t\t},\n\t\t{\n\t\t\tname:   \"With complex path\",\n\t\t\ttarget: \"http://example.com\",\n\t\t\tpath:   \"/pag(#*((#@)ΓΓΓΓe/Γ\",\n\t\t\tquery:  \"id=123\",\n\t\t\t// Expect URL encoding and query parameter\n\t\t\texpected: \"http://example.com/pag%28%23%2A%28%28%23@%29%CE%93%CE%93%CE%93%CE%93e/%CE%93?id=123\",\n\t\t},\n\t\t{\n\t\t\tname:     \"With query and path\",\n\t\t\ttarget:   \"http://example.com\",\n\t\t\tpath:     \"/page\",\n\t\t\tquery:    \"id=123\",\n\t\t\texpected: \"http://example.com/page?id=123\",\n\t\t},\n\t\t{\n\t\t\tname:     \"Unix socket target\",\n\t\t\ttarget:   \"unix:/tmp/anubis.sock\",\n\t\t\tpath:     \"/some/path\",\n\t\t\tquery:    \"key=value&flag=true\",\n\t\t\texpected: \"http://unix/some/path?key=value&flag=true\", // Scheme becomes http, host is 'unix'\n\t\t},\n\t\t{\n\t\t\tname:     \"Unix socket target with ///\",\n\t\t\ttarget:   \"unix:///var/run/anubis.sock\",\n\t\t\tpath:     \"/\",\n\t\t\tquery:    \"\",\n\t\t\texpected: \"http://unix/\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tcache := NewOGTagCache(tt.target, config.OpenGraph{\n\t\t\t\tEnabled:      true,\n\t\t\t\tTimeToLive:   time.Minute,\n\t\t\t\tConsiderHost: false,\n\t\t\t}, memory.New(t.Context()), TargetOptions{})\n\n\t\t\tu := &url.URL{\n\t\t\t\tPath:     tt.path,\n\t\t\t\tRawQuery: tt.query,\n\t\t\t}\n\n\t\t\tresult := cache.getTarget(u)\n\n\t\t\tif result != tt.expected {\n\t\t\t\tt.Errorf(\"expected %s, got %s\", tt.expected, result)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestIntegrationGetOGTags_UnixSocket tests fetching OG tags via a Unix socket.\nfunc TestIntegrationGetOGTags_UnixSocket(t *testing.T) {\n\ttempDir := t.TempDir()\n\n\t// XXX(Xe): if this is named longer, macOS fails with `bind: invalid argument`\n\t// because the unix socket path is too long. I love computers.\n\tsocketPath := filepath.Join(tempDir, \"t\")\n\n\t// Ensure the socket does not exist initially\n\t_ = os.Remove(socketPath)\n\n\t// Create a simple HTTP server listening on the Unix socket\n\tlistener, err := net.Listen(\"unix\", socketPath)\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to listen on unix socket %s: %v\", socketPath, err)\n\t}\n\tdefer func(listener net.Listener, socketPath string) {\n\t\tif listener != nil {\n\t\t\tif err := listener.Close(); err != nil && !errors.Is(err, net.ErrClosed) {\n\t\t\t\tt.Logf(\"Error closing listener: %v\", err)\n\t\t\t}\n\t\t}\n\n\t\tif _, err := os.Stat(socketPath); err == nil {\n\t\t\tif err := os.Remove(socketPath); err != nil {\n\t\t\t\tt.Logf(\"Error removing socket file %s: %v\", socketPath, err)\n\t\t\t}\n\t\t}\n\t}(listener, socketPath)\n\n\tserver := &http.Server{\n\t\tHandler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tw.Header().Set(\"Content-Type\", \"text/html\")\n\t\t\tfmt.Fprintln(w, `<!DOCTYPE html><html><head><meta property=\"og:title\" content=\"Unix Socket Test\" /></head><body>Test</body></html>`)\n\t\t}),\n\t}\n\tgo func() {\n\t\tif err := server.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) {\n\t\t\tt.Logf(\"Unix socket server error: %v\", err)\n\t\t}\n\t}()\n\tdefer func(server *http.Server, ctx context.Context) {\n\t\terr := server.Shutdown(ctx)\n\t\tif err != nil {\n\t\t\tt.Logf(\"Error shutting down server: %v\", err)\n\t\t}\n\t}(server, context.Background()) // Ensure server is shut down\n\n\t// Wait a moment for the server to start\n\ttime.Sleep(100 * time.Millisecond)\n\n\t// Create cache instance pointing to the Unix socket\n\ttargetURL := \"unix://\" + socketPath\n\tcache := NewOGTagCache(targetURL, config.OpenGraph{\n\t\tEnabled:      true,\n\t\tTimeToLive:   time.Minute,\n\t\tConsiderHost: false,\n\t}, memory.New(t.Context()), TargetOptions{})\n\n\t// Create a dummy URL for the request (path and query matter)\n\ttestReqURL, _ := url.Parse(\"/some/page?query=1\")\n\n\t// Get OG tags\n\t// Pass an empty string for host, as it's irrelevant for unix sockets\n\togTags, err := cache.GetOGTags(t.Context(), testReqURL, \"\")\n\n\tif err != nil {\n\t\tt.Fatalf(\"GetOGTags failed for unix socket: %v\", err)\n\t}\n\n\texpectedTags := map[string]string{\n\t\t\"og:title\": \"Unix Socket Test\",\n\t}\n\n\tif !reflect.DeepEqual(ogTags, expectedTags) {\n\t\tt.Errorf(\"Expected OG tags %v, got %v\", expectedTags, ogTags)\n\t}\n\n\t// Test cache retrieval (should hit cache)\n\t// Pass an empty string for host\n\tcachedTags, err := cache.GetOGTags(t.Context(), testReqURL, \"\")\n\tif err != nil {\n\t\tt.Fatalf(\"GetOGTags (cache hit) failed for unix socket: %v\", err)\n\t}\n\tif !reflect.DeepEqual(cachedTags, expectedTags) {\n\t\tt.Errorf(\"Expected cached OG tags %v, got %v\", expectedTags, cachedTags)\n\t}\n}\n\nfunc TestGetOGTagsWithTargetHostOverride(t *testing.T) {\n\toriginalHost := \"example.test\"\n\toverrideHost := \"backend.internal\"\n\tseenHosts := make(chan string, 10)\n\n\tts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tseenHosts <- r.Host\n\t\tw.Header().Set(\"Content-Type\", \"text/html\")\n\t\tfmt.Fprintln(w, `<!DOCTYPE html><html><head><meta property=\"og:title\" content=\"HostOverride\" /></head><body>ok</body></html>`)\n\t}))\n\tdefer ts.Close()\n\n\ttargetURL, err := url.Parse(ts.URL)\n\tif err != nil {\n\t\tt.Fatalf(\"failed to parse server URL: %v\", err)\n\t}\n\n\tconf := config.OpenGraph{\n\t\tEnabled:      true,\n\t\tTimeToLive:   time.Minute,\n\t\tConsiderHost: false,\n\t}\n\n\tt.Run(\"default host uses original\", func(t *testing.T) {\n\t\tcache := NewOGTagCache(ts.URL, conf, memory.New(t.Context()), TargetOptions{})\n\t\tif _, err := cache.GetOGTags(t.Context(), targetURL, originalHost); err != nil {\n\t\t\tt.Fatalf(\"GetOGTags failed: %v\", err)\n\t\t}\n\t\tselect {\n\t\tcase host := <-seenHosts:\n\t\t\tif host != originalHost {\n\t\t\t\tt.Fatalf(\"expected host %q, got %q\", originalHost, host)\n\t\t\t}\n\t\tcase <-time.After(time.Second):\n\t\t\tt.Fatal(\"server did not receive request\")\n\t\t}\n\t})\n\n\tt.Run(\"override host respected\", func(t *testing.T) {\n\t\tcache := NewOGTagCache(ts.URL, conf, memory.New(t.Context()), TargetOptions{\n\t\t\tHost: overrideHost,\n\t\t})\n\t\tif _, err := cache.GetOGTags(t.Context(), targetURL, originalHost); err != nil {\n\t\t\tt.Fatalf(\"GetOGTags failed: %v\", err)\n\t\t}\n\t\tselect {\n\t\tcase host := <-seenHosts:\n\t\t\tif host != overrideHost {\n\t\t\t\tt.Fatalf(\"expected host %q, got %q\", overrideHost, host)\n\t\t\t}\n\t\tcase <-time.After(time.Second):\n\t\t\tt.Fatal(\"server did not receive request\")\n\t\t}\n\t})\n}\n\nfunc TestGetOGTagsWithInsecureSkipVerify(t *testing.T) {\n\thandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.Header().Set(\"Content-Type\", \"text/html\")\n\t\tfmt.Fprintln(w, `<!DOCTYPE html><html><head><meta property=\"og:title\" content=\"Self-Signed\" /></head><body>hello</body></html>`)\n\t})\n\tts := httptest.NewTLSServer(handler)\n\tdefer ts.Close()\n\n\tparsedURL, err := url.Parse(ts.URL)\n\tif err != nil {\n\t\tt.Fatalf(\"failed to parse server URL: %v\", err)\n\t}\n\n\tconf := config.OpenGraph{\n\t\tEnabled:      true,\n\t\tTimeToLive:   time.Minute,\n\t\tConsiderHost: false,\n\t}\n\n\t// Without skip verify we should get a TLS error\n\tcacheStrict := NewOGTagCache(ts.URL, conf, memory.New(t.Context()), TargetOptions{})\n\tif _, err := cacheStrict.GetOGTags(t.Context(), parsedURL, parsedURL.Host); err == nil {\n\t\tt.Fatal(\"expected TLS verification error without InsecureSkipVerify\")\n\t}\n\n\tcacheSkip := NewOGTagCache(ts.URL, conf, memory.New(t.Context()), TargetOptions{\n\t\tInsecureSkipVerify: true,\n\t})\n\n\ttags, err := cacheSkip.GetOGTags(t.Context(), parsedURL, parsedURL.Host)\n\tif err != nil {\n\t\tt.Fatalf(\"expected successful fetch with InsecureSkipVerify, got: %v\", err)\n\t}\n\tif tags[\"og:title\"] != \"Self-Signed\" {\n\t\tt.Fatalf(\"expected og:title to be %q, got %q\", \"Self-Signed\", tags[\"og:title\"])\n\t}\n}\n\nfunc TestGetOGTagsWithTargetSNI(t *testing.T) {\n\toriginalHost := \"hecate.test\"\n\tconf := config.OpenGraph{\n\t\tEnabled:      true,\n\t\tTimeToLive:   time.Minute,\n\t\tConsiderHost: false,\n\t}\n\n\tt.Run(\"explicit SNI override\", func(t *testing.T) {\n\t\texpectedSNI := \"backend.internal\"\n\t\tts, recorder := newSNIServer(t, `<!DOCTYPE html><html><head><meta property=\"og:title\" content=\"SNI Works\" /></head><body>ok</body></html>`)\n\t\tdefer ts.Close()\n\n\t\ttargetURL, err := url.Parse(ts.URL)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"failed to parse server URL: %v\", err)\n\t\t}\n\n\t\tcacheExplicit := NewOGTagCache(ts.URL, conf, memory.New(t.Context()), TargetOptions{\n\t\t\tSNI:                expectedSNI,\n\t\t\tInsecureSkipVerify: true,\n\t\t})\n\t\tif _, err := cacheExplicit.GetOGTags(t.Context(), targetURL, originalHost); err != nil {\n\t\t\tt.Fatalf(\"expected successful fetch with explicit SNI, got: %v\", err)\n\t\t}\n\t\tif got := recorder.last(); got != expectedSNI {\n\t\t\tt.Fatalf(\"expected server to see SNI %q, got %q\", expectedSNI, got)\n\t\t}\n\t})\n\n\tt.Run(\"auto SNI uses original host\", func(t *testing.T) {\n\t\tts, recorder := newSNIServer(t, `<!DOCTYPE html><html><head><meta property=\"og:title\" content=\"SNI Auto\" /></head><body>ok</body></html>`)\n\t\tdefer ts.Close()\n\n\t\ttargetURL, err := url.Parse(ts.URL)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"failed to parse server URL: %v\", err)\n\t\t}\n\n\t\tcacheAuto := NewOGTagCache(ts.URL, conf, memory.New(t.Context()), TargetOptions{\n\t\t\tSNI:                \"auto\",\n\t\t\tInsecureSkipVerify: true,\n\t\t})\n\t\tif _, err := cacheAuto.GetOGTags(t.Context(), targetURL, originalHost); err != nil {\n\t\t\tt.Fatalf(\"expected successful fetch with auto SNI, got: %v\", err)\n\t\t}\n\t\tif got := recorder.last(); got != originalHost {\n\t\t\tt.Fatalf(\"expected server to see SNI %q with auto, got %q\", originalHost, got)\n\t\t}\n\t})\n\n\tt.Run(\"default SNI uses backend host\", func(t *testing.T) {\n\t\tts, recorder := newSNIServer(t, `<!DOCTYPE html><html><head><meta property=\"og:title\" content=\"SNI Default\" /></head><body>ok</body></html>`)\n\t\tdefer ts.Close()\n\n\t\ttargetURL, err := url.Parse(ts.URL)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"failed to parse server URL: %v\", err)\n\t\t}\n\n\t\tcacheDefault := NewOGTagCache(ts.URL, conf, memory.New(t.Context()), TargetOptions{\n\t\t\tInsecureSkipVerify: true,\n\t\t})\n\t\tif _, err := cacheDefault.GetOGTags(t.Context(), targetURL, originalHost); err != nil {\n\t\t\tt.Fatalf(\"expected successful fetch without explicit SNI, got: %v\", err)\n\t\t}\n\t\twantSNI := \"\"\n\t\tif net.ParseIP(targetURL.Hostname()) == nil {\n\t\t\twantSNI = targetURL.Hostname()\n\t\t}\n\t\tif got := recorder.last(); got != wantSNI {\n\t\t\tt.Fatalf(\"expected default SNI %q, got %q\", wantSNI, got)\n\t\t}\n\t})\n}\n\nfunc newSNIServer(t *testing.T, body string) (*httptest.Server, *sniRecorder) {\n\tt.Helper()\n\thandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.Header().Set(\"Content-Type\", \"text/html\")\n\t\tfmt.Fprint(w, body)\n\t})\n\n\trecorder := &sniRecorder{}\n\tts := httptest.NewUnstartedServer(handler)\n\tcert := mustCertificateForHost(t, \"sni.test\")\n\tts.TLS = &tls.Config{\n\t\tGetCertificate: func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {\n\t\t\trecorder.record(hello.ServerName)\n\t\t\treturn &cert, nil\n\t\t},\n\t}\n\tts.StartTLS()\n\treturn ts, recorder\n}\n\nfunc mustCertificateForHost(t *testing.T, host string) tls.Certificate {\n\tt.Helper()\n\tpriv, err := rsa.GenerateKey(rand.Reader, 2048)\n\tif err != nil {\n\t\tt.Fatalf(\"failed to generate key: %v\", err)\n\t}\n\n\ttemplate := &x509.Certificate{\n\t\tSerialNumber: big.NewInt(1),\n\t\tSubject: pkix.Name{\n\t\t\tCommonName: host,\n\t\t},\n\t\tNotBefore:             time.Now().Add(-time.Hour),\n\t\tNotAfter:              time.Now().Add(time.Hour),\n\t\tExtKeyUsage:           []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},\n\t\tKeyUsage:              x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,\n\t\tBasicConstraintsValid: true,\n\t\tDNSNames:              []string{host},\n\t}\n\n\tder, err := x509.CreateCertificate(rand.Reader, template, template, &priv.PublicKey, priv)\n\tif err != nil {\n\t\tt.Fatalf(\"failed to create certificate: %v\", err)\n\t}\n\n\treturn tls.Certificate{\n\t\tCertificate: [][]byte{der},\n\t\tPrivateKey:  priv,\n\t}\n}\n\ntype sniRecorder struct {\n\tmu    sync.Mutex\n\tnames []string\n}\n\nfunc (r *sniRecorder) record(name string) {\n\tr.mu.Lock()\n\tdefer r.mu.Unlock()\n\tr.names = append(r.names, name)\n}\n\nfunc (r *sniRecorder) last() string {\n\tr.mu.Lock()\n\tdefer r.mu.Unlock()\n\tif len(r.names) == 0 {\n\t\treturn \"\"\n\t}\n\treturn r.names[len(r.names)-1]\n}\n"
  },
  {
    "path": "internal/ogtags/parse.go",
    "content": "package ogtags\n\nimport (\n\t\"slices\"\n\t\"strings\"\n\n\t\"golang.org/x/net/html\"\n)\n\n// extractOGTags traverses the HTML document and extracts approved Open Graph tags\nfunc (c *OGTagCache) extractOGTags(doc *html.Node) map[string]string {\n\togTags := make(map[string]string)\n\n\tvar traverseNodes func(*html.Node)\n\ttraverseNodes = func(n *html.Node) {\n\t\tif isOGMetaTag(n) {\n\t\t\tproperty, content := c.extractMetaTagInfo(n)\n\t\t\tif property != \"\" {\n\t\t\t\togTags[property] = content\n\t\t\t}\n\t\t}\n\t\tfor child := n.FirstChild; child != nil; child = child.NextSibling {\n\t\t\ttraverseNodes(child)\n\t\t}\n\t}\n\n\ttraverseNodes(doc)\n\treturn ogTags\n}\n\n// isOGMetaTag checks if a node is *any* meta tag\nfunc isOGMetaTag(n *html.Node) bool {\n\tif n == nil {\n\t\treturn false\n\t}\n\treturn n.Type == html.ElementNode && n.Data == \"meta\"\n}\n\n// extractMetaTagInfo extracts property and content from a meta tag\nfunc (c *OGTagCache) extractMetaTagInfo(n *html.Node) (property, content string) {\n\tvar propertyKey string\n\n\t// Single pass through attributes, using range to avoid bounds checking\n\tfor _, attr := range n.Attr {\n\t\tswitch attr.Key {\n\t\tcase \"property\", \"name\":\n\t\t\tpropertyKey = attr.Val\n\t\tcase \"content\":\n\t\t\tcontent = attr.Val\n\t\t}\n\t\t// Early exit if we have both\n\t\tif propertyKey != \"\" && content != \"\" {\n\t\t\tbreak\n\t\t}\n\t}\n\n\tif propertyKey == \"\" {\n\t\treturn \"\", content\n\t}\n\n\t// Check prefixes first (more common case)\n\tfor _, prefix := range c.approvedPrefixes {\n\t\tif strings.HasPrefix(propertyKey, prefix) {\n\t\t\treturn propertyKey, content\n\t\t}\n\t}\n\n\t// Check exact matches\n\tif slices.Contains(c.approvedTags, propertyKey) {\n\t\treturn propertyKey, content\n\t}\n\n\treturn \"\", content\n}\n"
  },
  {
    "path": "internal/ogtags/parse_test.go",
    "content": "package ogtags\n\nimport (\n\t\"reflect\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/TecharoHQ/anubis/lib/config\"\n\t\"github.com/TecharoHQ/anubis/lib/store/memory\"\n\t\"golang.org/x/net/html\"\n)\n\n// TestExtractOGTags updated with correct expectations based on filtering logic\nfunc TestExtractOGTags(t *testing.T) {\n\t// Use a cache instance that reflects the default approved lists\n\ttestCache := NewOGTagCache(\"\", config.OpenGraph{\n\t\tEnabled:      false,\n\t\tConsiderHost: false,\n\t\tTimeToLive:   time.Minute,\n\t}, memory.New(t.Context()), TargetOptions{})\n\t// Manually set approved tags/prefixes based on the user request for clarity\n\ttestCache.approvedTags = []string{\"description\"}\n\ttestCache.approvedPrefixes = []string{\"og:\"}\n\n\ttests := []struct {\n\t\texpected map[string]string\n\t\tname     string\n\t\thtmlStr  string\n\t}{\n\t\t{\n\t\t\tname: \"Basic OG tags\", // Includes standard 'description' meta tag\n\t\t\thtmlStr: `<!DOCTYPE html>\n\t\t\t\t<html>\n\t\t\t\t<head>\n\t\t\t\t\t<meta property=\"og:title\" content=\"Test Title\" />\n\t\t\t\t\t<meta property=\"og:description\" content=\"Test Description\" />\n\t\t\t\t\t<meta name=\"description\" content=\"Regular Description\" />\n\t\t\t\t\t<meta name=\"keywords\" content=\"test, keyword\" />\n\t\t\t\t</head>\n\t\t\t\t<body></body>\n\t\t\t\t</html>`,\n\t\t\texpected: map[string]string{\n\t\t\t\t\"og:title\":       \"Test Title\",\n\t\t\t\t\"og:description\": \"Test Description\",\n\t\t\t\t\"description\":    \"Regular Description\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"OG tags with name attribute\",\n\t\t\thtmlStr: `<!DOCTYPE html>\n\t\t\t\t<html>\n\t\t\t\t<head>\n\t\t\t\t\t<meta name=\"og:title\" content=\"Test Title\" />\n\t\t\t\t\t<meta property=\"og:description\" content=\"Test Description\" />\n\t\t\t\t\t<meta name=\"twitter:card\" content=\"summary\" />\n\t\t\t\t</head>\n\t\t\t\t<body></body>\n\t\t\t\t</html>`,\n\t\t\texpected: map[string]string{\n\t\t\t\t\"og:title\":       \"Test Title\",\n\t\t\t\t\"og:description\": \"Test Description\",\n\t\t\t\t// twitter:card is still not approved\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"No approved OG tags\", // Contains only standard 'description'\n\t\t\thtmlStr: `<!DOCTYPE html>\n\t\t\t\t<html>\n\t\t\t\t<head>\n\t\t\t\t\t<meta name=\"description\" content=\"Test Description\" />\n\t\t\t\t\t<meta name=\"keywords\" content=\"Test\" />\n\t\t\t\t</head>\n\t\t\t\t<body></body>\n\t\t\t\t</html>`,\n\t\t\texpected: map[string]string{\n\t\t\t\t\"description\": \"Test Description\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"Empty content\",\n\t\t\thtmlStr: `<!DOCTYPE html>\n\t\t\t\t<html>\n\t\t\t\t<head>\n\t\t\t\t\t<meta property=\"og:title\" content=\"\" />\n\t\t\t\t\t<meta property=\"og:description\" content=\"Test Description\" />\n\t\t\t\t</head>\n\t\t\t\t<body></body>\n\t\t\t\t</html>`,\n\t\t\texpected: map[string]string{\n\t\t\t\t\"og:title\":       \"\",\n\t\t\t\t\"og:description\": \"Test Description\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"Explicitly approved tag\",\n\t\t\thtmlStr: `<!DOCTYPE html>\n\t\t\t\t\t\t<html>\n\t\t\t\t\t\t<head>\n\t\t\t\t\t\t\t<meta property=\"description\" content=\"Approved Description Tag\" />\n\t\t\t\t\t\t</head>\n\t\t\t\t\t\t<body></body>\n\t\t\t\t\t\t</html>`,\n\t\t\texpected: map[string]string{\n\t\t\t\t// This is approved because \"description\" is in cache.approvedTags\n\t\t\t\t\"description\": \"Approved Description Tag\",\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tdoc, err := html.Parse(strings.NewReader(tt.htmlStr))\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"failed to parse HTML: %v\", err)\n\t\t\t}\n\n\t\t\togTags := testCache.extractOGTags(doc)\n\n\t\t\tif !reflect.DeepEqual(ogTags, tt.expected) {\n\t\t\t\tt.Errorf(\"expected %v, got %v\", tt.expected, ogTags)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestIsOGMetaTag(t *testing.T) {\n\ttests := []struct {\n\t\tname       string\n\t\tnodeHTML   string\n\t\ttargetNode string // Helper to find the right node in parsed fragment\n\t\texpected   bool\n\t}{\n\t\t{\n\t\t\tname:       \"Meta OG tag\",\n\t\t\tnodeHTML:   `<meta property=\"og:title\" content=\"Test\">`,\n\t\t\ttargetNode: \"meta\",\n\t\t\texpected:   true,\n\t\t},\n\t\t{\n\t\t\tname:       \"Regular meta tag\",\n\t\t\tnodeHTML:   `<meta name=\"description\" content=\"Test\">`,\n\t\t\ttargetNode: \"meta\",\n\t\t\texpected:   true,\n\t\t},\n\t\t{\n\t\t\tname:       \"Not a meta tag\",\n\t\t\tnodeHTML:   `<div>Test</div>`,\n\t\t\ttargetNode: \"div\",\n\t\t\texpected:   false,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\t// Wrap the partial HTML in basic structure for parsing\n\t\t\tfullHTML := \"<html><head>\" + tt.nodeHTML + \"</head><body></body></html>\"\n\t\t\tdoc, err := html.Parse(strings.NewReader(fullHTML))\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"failed to parse HTML: %v\", err)\n\t\t\t}\n\n\t\t\t// Find the target element node (meta or div based on targetNode)\n\t\t\tvar node *html.Node\n\t\t\tvar findNode func(*html.Node)\n\t\t\tfindNode = func(n *html.Node) {\n\t\t\t\t// Skip finding if already found\n\t\t\t\tif node != nil {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\t// Check if current node matches type and tag data\n\t\t\t\tif n.Type == html.ElementNode && n.Data == tt.targetNode {\n\t\t\t\t\tnode = n\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\t// Recursively check children\n\t\t\t\tfor c := n.FirstChild; c != nil; c = c.NextSibling {\n\t\t\t\t\tfindNode(c)\n\t\t\t\t}\n\t\t\t}\n\t\t\tfindNode(doc) // Start search from root\n\n\t\t\tif node == nil {\n\t\t\t\tt.Fatalf(\"Could not find target node '%s' in test HTML\", tt.targetNode)\n\t\t\t}\n\n\t\t\t// Call the function under test\n\t\t\tresult := isOGMetaTag(node)\n\t\t\tif result != tt.expected {\n\t\t\t\tt.Errorf(\"expected %v, got %v\", tt.expected, result)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestExtractMetaTagInfo(t *testing.T) {\n\t// Use a cache instance that reflects the default approved lists\n\ttestCache := NewOGTagCache(\"\", config.OpenGraph{\n\t\tEnabled:      false,\n\t\tConsiderHost: false,\n\t\tTimeToLive:   time.Minute,\n\t}, memory.New(t.Context()), TargetOptions{})\n\ttestCache.approvedTags = []string{\"description\"}\n\ttestCache.approvedPrefixes = []string{\"og:\"}\n\n\ttests := []struct {\n\t\tname             string\n\t\tnodeHTML         string\n\t\texpectedProperty string\n\t\texpectedContent  string\n\t}{\n\t\t{\n\t\t\tname:             \"OG title with property (approved by prefix)\",\n\t\t\tnodeHTML:         `<meta property=\"og:title\" content=\"Test Title\">`,\n\t\t\texpectedProperty: \"og:title\",\n\t\t\texpectedContent:  \"Test Title\",\n\t\t},\n\t\t{\n\t\t\tname:             \"OG description with name (approved by prefix)\",\n\t\t\tnodeHTML:         `<meta name=\"og:description\" content=\"Test Description\">`,\n\t\t\texpectedProperty: \"og:description\",\n\t\t\texpectedContent:  \"Test Description\",\n\t\t},\n\t\t{\n\t\t\tname:             \"Regular meta tag (name=description, approved by exact match)\", // Updated name for clarity\n\t\t\tnodeHTML:         `<meta name=\"description\" content=\"Test Description\">`,\n\t\t\texpectedProperty: \"description\",\n\t\t\texpectedContent:  \"Test Description\",\n\t\t},\n\t\t{\n\t\t\tname:             \"Regular meta tag (name=keywords, not approved)\",\n\t\t\tnodeHTML:         `<meta name=\"keywords\" content=\"Test Keywords\">`,\n\t\t\texpectedProperty: \"\",\n\t\t\texpectedContent:  \"Test Keywords\",\n\t\t},\n\t\t{\n\t\t\tname:             \"Twitter tag (not approved by default)\",\n\t\t\tnodeHTML:         `<meta name=\"twitter:card\" content=\"summary\">`,\n\t\t\texpectedProperty: \"\",\n\t\t\texpectedContent:  \"summary\",\n\t\t},\n\t\t{\n\t\t\tname:             \"No content (but approved property)\",\n\t\t\tnodeHTML:         `<meta property=\"og:title\">`,\n\t\t\texpectedProperty: \"og:title\",\n\t\t\texpectedContent:  \"\",\n\t\t},\n\t\t{\n\t\t\tname:             \"No property/name attribute\",\n\t\t\tnodeHTML:         `<meta content=\"No property\">`,\n\t\t\texpectedProperty: \"\",\n\t\t\texpectedContent:  \"No property\",\n\t\t},\n\t\t{\n\t\t\tname:             \"Explicitly approved tag with property attribute\",\n\t\t\tnodeHTML:         `<meta property=\"description\" content=\"Approved Description Tag\">`,\n\t\t\texpectedProperty: \"description\", // Approved by exact match in approvedTags\n\t\t\texpectedContent:  \"Approved Description Tag\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tfullHTML := \"<html><head>\" + tt.nodeHTML + \"</head><body></body></html>\"\n\t\t\tdoc, err := html.Parse(strings.NewReader(fullHTML))\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"failed to parse HTML: %v\", err)\n\t\t\t}\n\n\t\t\tvar node *html.Node\n\t\t\tvar findMetaNode func(*html.Node)\n\t\t\tfindMetaNode = func(n *html.Node) {\n\t\t\t\tif node != nil { // Stop searching once found\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tif n.Type == html.ElementNode && n.Data == \"meta\" {\n\t\t\t\t\tnode = n\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tfor c := n.FirstChild; c != nil; c = c.NextSibling {\n\t\t\t\t\tfindMetaNode(c)\n\t\t\t\t}\n\t\t\t}\n\t\t\tfindMetaNode(doc) // Start search from root\n\n\t\t\tif node == nil {\n\t\t\t\t// Handle cases where the input might not actually contain a meta tag, though all test cases do.\n\t\t\t\t// If the test case is *designed* not to have a meta tag, this check should be different.\n\t\t\t\t// But for these tests, failure to find implies an issue with the test setup or parser.\n\t\t\t\tt.Fatalf(\"Could not find meta node in test HTML: %s\", tt.nodeHTML)\n\t\t\t}\n\n\t\t\t// Call extractMetaTagInfo using the test cache instance\n\t\t\tproperty, content := testCache.extractMetaTagInfo(node)\n\n\t\t\tif property != tt.expectedProperty {\n\t\t\t\tt.Errorf(\"expected property '%s', got '%s'\", tt.expectedProperty, property)\n\t\t\t}\n\n\t\t\tif content != tt.expectedContent {\n\t\t\t\tt.Errorf(\"expected content '%s', got '%s'\", tt.expectedContent, content)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "internal/ogtags/sni.go",
    "content": "package ogtags\n\nimport (\n\t\"crypto/tls\"\n\t\"net/http\"\n)\n\n// clientForSNI returns a cached client for the given server name, creating one if needed.\nfunc (c *OGTagCache) clientForSNI(serverName string) *http.Client {\n\tif !c.targetSNIAuto || serverName == \"\" {\n\t\treturn c.client\n\t}\n\n\tc.transportMu.RLock()\n\tcli, ok := c.sniClients[serverName]\n\tc.transportMu.RUnlock()\n\tif ok {\n\t\treturn cli\n\t}\n\n\tc.transportMu.Lock()\n\tdefer c.transportMu.Unlock()\n\tif cli, ok := c.sniClients[serverName]; ok {\n\t\treturn cli\n\t}\n\n\ttr := c.transport.Clone()\n\tif tr.TLSClientConfig == nil {\n\t\ttr.TLSClientConfig = &tls.Config{}\n\t}\n\ttr.TLSClientConfig.ServerName = serverName\n\tif c.insecureSkipVerify {\n\t\ttr.TLSClientConfig.InsecureSkipVerify = true\n\t}\n\n\tcli = &http.Client{\n\t\tTimeout:   httpTimeout,\n\t\tTransport: tr,\n\t}\n\tc.sniClients[serverName] = cli\n\treturn cli\n}\n"
  },
  {
    "path": "internal/test/playwright_test.go",
    "content": "//go:build !windows\n\n// Integration tests for Anubis, using Playwright.\n//\n// These tests require an already running Anubis and Playwright server.\n//\n// Anubis must be configured to redirect to the server started by the test suite.\n// The bind address and the Anubis server can be specified using the flags `-bind` and `-anubis` respectively.\n//\n// Playwright must be started in server mode using `npx playwright@1.50.1 run-server --port 3000`.\n// The version must match the minor used by the playwright-go package.\n//\n// On unsupported systems you may be able to use a container instead: https://playwright.dev/docs/docker#remote-connection\n//\n// 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.\npackage test\n\nimport (\n\t\"flag\"\n\t\"fmt\"\n\t\"net\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"net/url\"\n\t\"os\"\n\t\"os/exec\"\n\t\"strconv\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/TecharoHQ/anubis\"\n\tlibanubis \"github.com/TecharoHQ/anubis/lib\"\n\t\"github.com/playwright-community/playwright-go\"\n)\n\nvar (\n\tplaywrightPort        = flag.Int(\"playwright-port\", 9001, \"Playwright port\")\n\tplaywrightServer      = flag.String(\"playwright\", \"ws://localhost:9001\", \"Playwright server URL\")\n\tplaywrightMaxTime     = flag.Duration(\"playwright-max-time\", 5*time.Second, \"maximum time for Playwright requests\")\n\tplaywrightMaxHardTime = flag.Duration(\"playwright-max-hard-time\", 5*time.Minute, \"maximum time for hard Playwright requests\")\n\tplaywrightRunner      = flag.String(\"playwright-runner\", \"npx\", \"how to start Playwright, can be: none,npx,docker,podman\")\n\n\ttestCases = []testCase{\n\t\t{\n\t\t\tname:      \"firefox\",\n\t\t\taction:    actionChallenge,\n\t\t\trealIP:    placeholderIP,\n\t\t\tuserAgent: \"Mozilla/5.0 (X11; Linux x86_64; rv:136.0) Gecko/20100101 Firefox/136.0\",\n\t\t},\n\t\t{\n\t\t\tname:      \"headlessChrome\",\n\t\t\taction:    actionDeny,\n\t\t\trealIP:    placeholderIP,\n\t\t\tuserAgent: \"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/120.0.6099.28 Safari/537.36\",\n\t\t},\n\t\t{\n\t\t\tname:      \"Amazonbot\",\n\t\t\taction:    actionDeny,\n\t\t\trealIP:    placeholderIP,\n\t\t\tuserAgent: \"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)\",\n\t\t},\n\t\t{\n\t\t\tname:      \"Amazonbot\",\n\t\t\taction:    actionDeny,\n\t\t\trealIP:    placeholderIP,\n\t\t\tuserAgent: \"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)\",\n\t\t},\n\t\t{\n\t\t\tname:      \"PerplexityAI\",\n\t\t\taction:    actionDeny,\n\t\t\trealIP:    placeholderIP,\n\t\t\tuserAgent: \"Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; PerplexityBot/1.0; +https://perplexity.ai/perplexitybot)\",\n\t\t},\n\t\t{\n\t\t\tname:      \"kagiBadIP\",\n\t\t\taction:    actionChallenge,\n\t\t\tisHard:    true,\n\t\t\trealIP:    placeholderIP,\n\t\t\tuserAgent: \"Mozilla/5.0 (compatible; Kagibot/1.0; +https://kagi.com/bot)\",\n\t\t},\n\t\t{\n\t\t\tname:      \"kagiGoodIP\",\n\t\t\taction:    actionAllow,\n\t\t\trealIP:    \"216.18.205.234\",\n\t\t\tuserAgent: \"Mozilla/5.0 (compatible; Kagibot/1.0; +https://kagi.com/bot)\",\n\t\t},\n\t\t{\n\t\t\tname:      \"unknownAgent\",\n\t\t\taction:    actionAllow,\n\t\t\trealIP:    placeholderIP,\n\t\t\tuserAgent: \"AnubisTest/0\",\n\t\t},\n\t}\n)\n\nconst (\n\tactionAllow     action = \"ALLOW\"\n\tactionDeny      action = \"DENY\"\n\tactionChallenge action = \"CHALLENGE\"\n\n\tplaceholderIP     = \"fd11:5ee:bad:c0de::\"\n\tplaywrightVersion = \"1.52.0\"\n)\n\ntype action string\n\ntype testCase struct {\n\tname      string\n\taction    action\n\trealIP    string\n\tuserAgent string\n\tisHard    bool\n}\n\nfunc doesCommandExist(t *testing.T, command string) {\n\tt.Helper()\n\n\tif _, err := exec.LookPath(command); err != nil {\n\t\tt.Skipf(\"%s not found in PATH, skipping integration smoke testing: %v\", command, err)\n\t}\n}\n\nfunc run(t *testing.T, command string) string {\n\tif testing.Short() {\n\t\tt.Skip(\"skipping integration smoke testing in short mode\")\n\t}\n\tt.Helper()\n\n\tshPath, err := exec.LookPath(\"sh\")\n\tif err != nil {\n\t\tt.Fatalf(\"[unexpected] %v\", err)\n\t}\n\n\tt.Logf(\"running command: %s\", command)\n\n\tcmd := exec.Command(shPath, \"-c\", command)\n\tcmd.Stdin = nil\n\tcmd.Stderr = os.Stderr\n\toutput, err := cmd.Output()\n\tif err != nil {\n\t\tt.Fatalf(\"can't run command: %v\", err)\n\t}\n\n\treturn string(output)\n}\n\nfunc daemonize(t *testing.T, command string) {\n\tt.Helper()\n\n\tshPath, err := exec.LookPath(\"sh\")\n\tif err != nil {\n\t\tt.Fatalf(\"[unexpected] %v\", err)\n\t}\n\n\tt.Logf(\"daemonizing command: %s\", command)\n\n\tcmd := exec.Command(shPath, \"-c\", command)\n\tcmd.Stdin = nil\n\tcmd.Stderr = os.Stderr\n\tcmd.Stdout = os.Stdout\n\n\tif err := cmd.Start(); err != nil {\n\t\tt.Fatalf(\"can't daemonize command: %v\", err)\n\t}\n\n\tt.Cleanup(func() {\n\t\tcmd.Process.Kill()\n\t})\n}\n\nfunc startPlaywright(t *testing.T) {\n\tt.Helper()\n\n\tif *playwrightRunner == \"npx\" {\n\t\tdoesCommandExist(t, \"npx\")\n\n\t\tif os.Getenv(\"CI\") == \"true\" {\n\t\t\trun(t, fmt.Sprintf(\"npx --yes playwright@%s install --with-deps\", playwrightVersion))\n\t\t} else {\n\t\t\trun(t, fmt.Sprintf(\"npx --yes playwright@%s install\", playwrightVersion))\n\t\t}\n\n\t\tdaemonize(t, fmt.Sprintf(\"npx --yes playwright@%s run-server --port %d\", playwrightVersion, *playwrightPort))\n\t} else if *playwrightRunner == \"docker\" || *playwrightRunner == \"podman\" {\n\t\tdoesCommandExist(t, *playwrightRunner)\n\n\t\t// docs: https://playwright.dev/docs/docker\n\t\tpwcmd := fmt.Sprintf(\"npx -y playwright@%s run-server --port %d --host 0.0.0.0\", playwrightVersion, *playwrightPort)\n\t\tcontainer := 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))\n\t\tt.Cleanup(func() {\n\t\t\trun(t, fmt.Sprintf(\"%s rm --force %s\", *playwrightRunner, container))\n\t\t})\n\t} else if *playwrightRunner == \"none\" {\n\t\tt.Log(\"not starting Playwright, assuming it is already running\")\n\t} else {\n\t\tt.Skipf(\"unknown runner: %s, skipping\", *playwrightRunner)\n\t}\n\n\tfor {\n\t\tif _, err := http.Get(fmt.Sprintf(\"http://localhost:%d\", *playwrightPort)); err != nil {\n\t\t\ttime.Sleep(500 * time.Millisecond)\n\t\t\tcontinue\n\t\t}\n\t\tbreak\n\t}\n\n\t//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.\n\ttime.Sleep(2 * time.Second)\n}\n\nfunc TestPlaywrightBrowser(t *testing.T) {\n\tif os.Getenv(\"DONT_USE_NETWORK\") != \"\" {\n\t\tt.Skip(\"test requires network egress\")\n\t\treturn\n\t}\n\n\tif os.Getenv(\"SKIP_INTEGRATION\") != \"\" {\n\t\tt.Skip(\"SKIP_INTEGRATION was set\")\n\t\treturn\n\t}\n\n\tstartPlaywright(t)\n\n\tpw := setupPlaywright(t)\n\tanubisURL := spawnAnubis(t)\n\n\tbrowsers := []playwright.BrowserType{pw.Chromium, pw.Firefox, pw.WebKit}\n\n\tfor _, typ := range browsers {\n\t\tt.Run(typ.Name()+\"/warmup\", func(t *testing.T) {\n\t\t\tbrowser, err := typ.Connect(buildBrowserConnect(typ.Name()), playwright.BrowserTypeConnectOptions{\n\t\t\t\tExposeNetwork: playwright.String(\"<loopback>\"),\n\t\t\t})\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"could not connect to remote browser: %v\", err)\n\t\t\t}\n\t\t\tdefer browser.Close()\n\n\t\t\tctx, err := browser.NewContext(playwright.BrowserNewContextOptions{\n\t\t\t\tAcceptDownloads: playwright.Bool(false),\n\t\t\t\tExtraHttpHeaders: map[string]string{\n\t\t\t\t\t\"X-Real-Ip\": \"127.0.0.1\",\n\t\t\t\t},\n\t\t\t\tUserAgent: playwright.String(\"Sephiroth\"),\n\t\t\t})\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"could not create context: %v\", err)\n\t\t\t}\n\t\t\tdefer ctx.Close()\n\n\t\t\tpage, err := ctx.NewPage()\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"could not create page: %v\", err)\n\t\t\t}\n\t\t\tdefer page.Close()\n\n\t\t\ttimeout := 2.0\n\t\t\tpage.Goto(anubisURL, playwright.PageGotoOptions{\n\t\t\t\tTimeout: &timeout,\n\t\t\t})\n\t\t})\n\n\t\tfor _, tc := range testCases {\n\t\t\tname := fmt.Sprintf(\"%s/%s\", typ.Name(), tc.name)\n\t\t\tt.Run(name, func(t *testing.T) {\n\t\t\t\t_, hasDeadline := t.Deadline()\n\t\t\t\tif tc.isHard && hasDeadline {\n\t\t\t\t\tt.Skip(\"skipping hard challenge with deadline\")\n\t\t\t\t}\n\n\t\t\t\tvar performedAction action\n\t\t\t\tvar err error\n\t\t\t\tfor i := range 5 {\n\t\t\t\t\tperformedAction, err = executeTestCase(t, tc, typ, anubisURL)\n\t\t\t\t\tif performedAction == tc.action {\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\t\t\t\t\ttime.Sleep(time.Duration(i+1) * 250 * time.Millisecond)\n\t\t\t\t}\n\t\t\t\tif performedAction != tc.action {\n\t\t\t\t\tt.Errorf(\"unexpected test result, expected %s, got %s\", tc.action, performedAction)\n\t\t\t\t}\n\t\t\t\tif err != nil {\n\t\t\t\t\tt.Fatalf(\"test error: %v\", err)\n\t\t\t\t}\n\t\t\t})\n\t\t}\n\t}\n}\n\nfunc TestPlaywrightWithBasePrefix(t *testing.T) {\n\tif os.Getenv(\"DONT_USE_NETWORK\") != \"\" {\n\t\tt.Skip(\"test requires network egress\")\n\t\treturn\n\t}\n\n\tif os.Getenv(\"SKIP_INTEGRATION\") != \"\" {\n\t\tt.Skip(\"SKIP_INTEGRATION was set\")\n\t\treturn\n\t}\n\n\tt.Skip(\"NOTE(Xe)\\\\ these tests require HTTPS support in #364\")\n\n\tstartPlaywright(t)\n\n\tpw := setupPlaywright(t)\n\tbasePrefix := \"/myapp\"\n\tanubisURL := spawnAnubisWithOptions(t, basePrefix)\n\n\t// Reset BasePrefix after test\n\tt.Cleanup(func() {\n\t\tanubis.BasePrefix = \"\"\n\t})\n\n\tbrowsers := []playwright.BrowserType{pw.Chromium}\n\n\tfor _, typ := range browsers {\n\t\tt.Run(typ.Name()+\"/basePrefix\", func(t *testing.T) {\n\t\t\tbrowser, err := typ.Connect(buildBrowserConnect(typ.Name()), playwright.BrowserTypeConnectOptions{\n\t\t\t\tExposeNetwork: playwright.String(\"<loopback>\"),\n\t\t\t})\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"could not connect to remote browser: %v\", err)\n\t\t\t}\n\t\t\tdefer browser.Close()\n\n\t\t\tctx, err := browser.NewContext(playwright.BrowserNewContextOptions{\n\t\t\t\tAcceptDownloads: playwright.Bool(false),\n\t\t\t\tExtraHttpHeaders: map[string]string{\n\t\t\t\t\t\"X-Real-Ip\": \"127.0.0.1\",\n\t\t\t\t},\n\t\t\t\tUserAgent: playwright.String(\"Mozilla/5.0 (X11; Linux x86_64; rv:136.0) Gecko/20100101 Firefox/136.0\"),\n\t\t\t})\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"could not create context: %v\", err)\n\t\t\t}\n\t\t\tdefer ctx.Close()\n\n\t\t\tpage, err := ctx.NewPage()\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"could not create page: %v\", err)\n\t\t\t}\n\t\t\tdefer page.Close()\n\n\t\t\t// Test accessing the base URL with prefix\n\t\t\t_, err = page.Goto(anubisURL+basePrefix, playwright.PageGotoOptions{\n\t\t\t\tTimeout: pwTimeout(testCases[0], time.Now().Add(5*time.Second)),\n\t\t\t})\n\t\t\tif err != nil {\n\t\t\t\tpwFail(t, page, \"could not navigate to test server with base prefix: %v\", err)\n\t\t\t}\n\n\t\t\t// Check if challenge page is displayed\n\t\t\timage := page.Locator(\"#image[src*=pensive], #image[src*=happy]\")\n\t\t\terr = image.WaitFor(playwright.LocatorWaitForOptions{\n\t\t\t\tTimeout: pwTimeout(testCases[0], time.Now().Add(5*time.Second)),\n\t\t\t})\n\t\t\tif err != nil {\n\t\t\t\tpwFail(t, page, \"could not wait for challenge image: %v\", err)\n\t\t\t}\n\n\t\t\tisVisible, err := image.IsVisible()\n\t\t\tif err != nil {\n\t\t\t\tpwFail(t, page, \"could not check if challenge image is visible: %v\", err)\n\t\t\t}\n\t\t\tif !isVisible {\n\t\t\t\tpwFail(t, page, \"challenge image not visible\")\n\t\t\t}\n\n\t\t\t// Complete the challenge\n\t\t\t// Wait for the challenge to be solved\n\t\t\tanubisTest := page.Locator(\"#anubis-test\")\n\t\t\terr = anubisTest.WaitFor(playwright.LocatorWaitForOptions{\n\t\t\t\tTimeout: pwTimeout(testCases[0], time.Now().Add(30*time.Second)),\n\t\t\t})\n\t\t\tif err != nil {\n\t\t\t\tpwFail(t, page, \"could not wait for challenge to be solved: %v\", err)\n\t\t\t}\n\n\t\t\t// Verify the challenge was solved\n\t\t\tcontent, err := anubisTest.TextContent(playwright.LocatorTextContentOptions{})\n\t\t\tif err != nil {\n\t\t\t\tpwFail(t, page, \"could not get text content: %v\", err)\n\t\t\t}\n\n\t\t\tvar tm int64\n\t\t\tif _, err := fmt.Sscanf(content, \"%d\", &tm); err != nil {\n\t\t\t\tpwFail(t, page, \"unexpected output: %s\", content)\n\t\t\t}\n\n\t\t\t// Check if the timestamp is reasonable\n\t\t\tnow := time.Now().Unix()\n\t\t\tif tm < now-60 || tm > now+60 {\n\t\t\t\tpwFail(t, page, \"unexpected timestamp in output: %d not in range %d±60\", tm, now)\n\t\t\t}\n\n\t\t\t// Check if cookie has the correct path\n\t\t\tcookies, err := ctx.Cookies()\n\t\t\tif err != nil {\n\t\t\t\tpwFail(t, page, \"could not get cookies: %v\", err)\n\t\t\t}\n\n\t\t\tvar found bool\n\t\t\tfor _, cookie := range cookies {\n\t\t\t\tif cookie.Name == anubis.CookieName {\n\t\t\t\t\tfound = true\n\t\t\t\t\tif cookie.Path != basePrefix+\"/\" {\n\t\t\t\t\t\tt.Errorf(\"cookie path is wrong, wanted %s, got: %s\", basePrefix+\"/\", cookie.Path)\n\t\t\t\t\t}\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif !found {\n\t\t\t\tt.Errorf(\"Cookie %q not found\", anubis.CookieName)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc buildBrowserConnect(name string) string {\n\tu, _ := url.Parse(*playwrightServer)\n\n\tq := u.Query()\n\tq.Set(\"browser\", name)\n\tu.RawQuery = q.Encode()\n\n\treturn u.String()\n}\n\nfunc executeTestCase(t *testing.T, tc testCase, typ playwright.BrowserType, anubisURL string) (action, error) {\n\tdeadline, _ := t.Deadline()\n\n\tbrowser, err := typ.Connect(buildBrowserConnect(typ.Name()), playwright.BrowserTypeConnectOptions{\n\t\tExposeNetwork: playwright.String(\"<loopback>\"),\n\t})\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"could not connect to remote browser: %w\", err)\n\t}\n\tdefer browser.Close()\n\n\tctx, err := browser.NewContext(playwright.BrowserNewContextOptions{\n\t\tAcceptDownloads: playwright.Bool(false),\n\t\tExtraHttpHeaders: map[string]string{\n\t\t\t\"X-Real-Ip\": tc.realIP,\n\t\t},\n\t\tUserAgent: playwright.String(tc.userAgent),\n\t})\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"could not create context: %w\", err)\n\t}\n\tdefer ctx.Close()\n\n\tpage, err := ctx.NewPage()\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"could not create page: %w\", err)\n\t}\n\tdefer page.Close()\n\n\t// Attempt challenge.\n\n\tstart := time.Now()\n\t_, err = page.Goto(anubisURL, playwright.PageGotoOptions{\n\t\tTimeout: pwTimeout(tc, deadline),\n\t})\n\tif err != nil {\n\t\treturn \"\", pwFail(t, page, \"could not navigate to test server: %v\", err)\n\t}\n\n\thadChallenge := false\n\tswitch tc.action {\n\tcase actionChallenge:\n\t\t// FIXME: This could race if challenge is completed too quickly.\n\t\tcheckImage(t, tc, deadline, page, \"#image[src*=pensive], #image[src*=happy]\")\n\t\thadChallenge = true\n\tcase actionDeny:\n\t\tcheckImage(t, tc, deadline, page, \"#image[src*=sad]\")\n\t\treturn actionDeny, nil\n\t}\n\n\t// Ensure protected resource was provided.\n\n\tres, err := page.Locator(\"#anubis-test\").TextContent(playwright.LocatorTextContentOptions{\n\t\tTimeout: pwTimeout(tc, deadline),\n\t})\n\tend := time.Now()\n\tif err != nil {\n\t\tpwFail(t, page, \"could not get text content: %v\", err)\n\t}\n\n\tvar tm int64\n\tif _, err := fmt.Sscanf(res, \"%d\", &tm); err != nil {\n\t\tpwFail(t, page, \"unexpected output: %s\", res)\n\t}\n\n\tif tm < start.Unix() || end.Unix() < tm {\n\t\tpwFail(t, page, \"unexpected timestamp in output: %d not in range %d..%d\", tm, start.Unix(), end.Unix())\n\t}\n\n\tif hadChallenge {\n\t\treturn actionChallenge, nil\n\t} else {\n\t\treturn actionAllow, nil\n\t}\n}\n\nfunc checkImage(t *testing.T, tc testCase, deadline time.Time, page playwright.Page, locator string) {\n\timage := page.Locator(locator)\n\terr := image.WaitFor(playwright.LocatorWaitForOptions{\n\t\tTimeout: pwTimeout(tc, deadline),\n\t})\n\tif err != nil {\n\t\tpwFail(t, page, \"could not wait for result: %v\", err)\n\t}\n\n\tfailIsVisible, err := image.IsVisible()\n\tif err != nil {\n\t\tpwFail(t, page, \"could not check result image: %v\", err)\n\t}\n\n\tif !failIsVisible {\n\t\tpwFail(t, page, \"expected result image not visible\")\n\t}\n}\n\nfunc pwFail(t *testing.T, page playwright.Page, format string, args ...any) error {\n\tt.Helper()\n\n\tsaveScreenshot(t, page)\n\treturn fmt.Errorf(format, args...)\n}\n\nfunc pwTimeout(tc testCase, deadline time.Time) *float64 {\n\tmaxTime := *playwrightMaxTime\n\tif tc.isHard {\n\t\tmaxTime = *playwrightMaxHardTime\n\t}\n\n\td := time.Until(deadline)\n\tif d <= 0 || d > maxTime {\n\t\treturn playwright.Float(float64(maxTime.Milliseconds()))\n\t}\n\treturn playwright.Float(float64(d.Milliseconds()))\n}\n\nfunc saveScreenshot(t *testing.T, page playwright.Page) {\n\tt.Helper()\n\n\tdata, err := page.Screenshot()\n\tif err != nil {\n\t\tt.Logf(\"could not take screenshot: %v\", err)\n\t\treturn\n\t}\n\n\tf, err := os.CreateTemp(\"\", \"anubis-test-fail-*.png\")\n\tif err != nil {\n\t\tt.Logf(\"could not create temporary file: %v\", err)\n\t\treturn\n\t}\n\tdefer f.Close()\n\n\t_, err = f.Write(data)\n\tif err != nil {\n\t\tt.Logf(\"could not write screenshot: %v\", err)\n\t\treturn\n\t}\n\n\tt.Logf(\"screenshot saved to %s\", f.Name())\n}\n\nfunc setupPlaywright(t *testing.T) *playwright.Playwright {\n\terr := playwright.Install(&playwright.RunOptions{\n\t\tSkipInstallBrowsers: true,\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"could not install Playwright: %v\", err)\n\t}\n\n\tpw, err := playwright.Run()\n\tif err != nil {\n\t\tt.Fatalf(\"could not start Playwright: %v\", err)\n\t}\n\treturn pw\n}\n\nfunc spawnAnubis(t *testing.T) string {\n\treturn spawnAnubisWithOptions(t, \"\")\n}\n\nfunc spawnAnubisWithOptions(t *testing.T, basePrefix string) string {\n\tt.Helper()\n\n\th := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.Header().Add(\"Content-Type\", \"text/html\")\n\t\tfmt.Fprintf(w, \"<html><body><span id=anubis-test>%d</span></body></html>\", time.Now().Unix())\n\t})\n\n\tpolicy, err := libanubis.LoadPoliciesOrDefault(t.Context(), \"\", anubis.DefaultDifficulty, \"info\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tlistener, err := net.Listen(\"tcp\", \":0\")\n\tif err != nil {\n\t\tt.Fatalf(\"can't listen on random port: %v\", err)\n\t}\n\n\taddr := listener.Addr().(*net.TCPAddr)\n\thost := \"localhost\"\n\tport := strconv.Itoa(addr.Port)\n\n\ts, err := libanubis.New(libanubis.Options{\n\t\tNext:           h,\n\t\tPolicy:         policy,\n\t\tServeRobotsTXT: true,\n\t\tTarget:         \"http://\" + host + \":\" + port,\n\t\tBasePrefix:     basePrefix,\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"can't construct libanubis.Server: %v\", err)\n\t}\n\n\tts := &httptest.Server{\n\t\tListener: listener,\n\t\tConfig:   &http.Server{Handler: s},\n\t}\n\tts.Start()\n\tt.Log(ts.URL)\n\n\tt.Cleanup(func() {\n\t\tts.Close()\n\t})\n\n\treturn ts.URL\n}\n"
  },
  {
    "path": "internal/test/var/.gitignore",
    "content": "*.png\n*.txt\n*.html"
  },
  {
    "path": "internal/unbreakdocker.go",
    "content": "package internal\n\nimport (\n\t\"os\"\n\t\"os/exec\"\n)\n\nfunc UnbreakDocker() {\n\t// XXX(Xe): This is bad code. Do not do this.\n\t//\n\t// I have to do this because I'm running from inside the context of a dev\n\t// container. This dev container runs in a different docker network than\n\t// the valkey test container runs in. In order to let my dev container\n\t// connect to the test container, they need to share a network in common.\n\t// The easiest network to use for this is the default \"bridge\" network.\n\t//\n\t// This is a horrifying monstrosity, but the part that scares me the most\n\t// is the fact that it works.\n\tif hostname, err := os.Hostname(); err == nil {\n\t\texec.Command(\"docker\", \"network\", \"connect\", \"bridge\", hostname).Run()\n\t}\n}\n"
  },
  {
    "path": "internal/xff_test.go",
    "content": "package internal\n\nimport (\n\t\"errors\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n)\n\nfunc TestXForwardedForUpdateIgnoreUnix(t *testing.T) {\n\tvar remoteAddr = \"\"\n\tvar xff = \"\"\n\n\th := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tremoteAddr = r.RemoteAddr\n\t\txff = r.Header.Get(\"X-Forwarded-For\")\n\t\tw.WriteHeader(http.StatusOK)\n\t})\n\n\tr := httptest.NewRequest(http.MethodGet, \"/\", nil)\n\n\tr.RemoteAddr = \"@\"\n\n\tw := httptest.NewRecorder()\n\n\tXForwardedForUpdate(true, h).ServeHTTP(w, r)\n\n\tif r.RemoteAddr != remoteAddr {\n\t\tt.Errorf(\"wanted remoteAddr to be %s, got: %s\", r.RemoteAddr, remoteAddr)\n\t}\n\n\tif xff != \"\" {\n\t\tt.Error(\"handler added X-Forwarded-For when it should not have\")\n\t}\n}\n\nfunc TestXForwardedForUpdateAddToChain(t *testing.T) {\n\tvar xff = \"\"\n\tconst expected = \"1.1.1.1\"\n\n\th := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\txff = r.Header.Get(\"X-Forwarded-For\")\n\t\tw.WriteHeader(http.StatusOK)\n\t})\n\n\tsrv := httptest.NewServer(XForwardedForUpdate(true, h))\n\n\tr, err := http.NewRequest(http.MethodGet, srv.URL, nil)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tr.Header.Set(\"X-Forwarded-For\", \"1.1.1.1,10.20.30.40\")\n\n\tif _, err := srv.Client().Do(r); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif xff != expected {\n\t\tt.Logf(\"expected: %s\", expected)\n\t\tt.Logf(\"got:      %s\", xff)\n\t\tt.Error(\"X-Forwarded-For header was not what was expected\")\n\t}\n}\n\nfunc TestComputeXFFHeader(t *testing.T) {\n\tfor _, tt := range []struct {\n\t\terr           error\n\t\tname          string\n\t\tremoteAddr    string\n\t\torigXFFHeader string\n\t\tresult        string\n\t\tpref          XFFComputePreferences\n\t}{\n\t\t{\n\t\t\tname:          \"StripPrivate\",\n\t\t\tremoteAddr:    \"127.0.0.1:80\",\n\t\t\torigXFFHeader: \"1.1.1.1,10.0.0.1\",\n\t\t\tpref: XFFComputePreferences{\n\t\t\t\tStripPrivate: true,\n\t\t\t},\n\t\t\tresult: \"1.1.1.1,127.0.0.1\",\n\t\t},\n\t\t{\n\t\t\tname:          \"StripPrivate\",\n\t\t\tremoteAddr:    \"127.0.0.1:80\",\n\t\t\torigXFFHeader: \"1.1.1.1,10.0.0.1\",\n\t\t\tpref: XFFComputePreferences{\n\t\t\t\tStripPrivate: false,\n\t\t\t},\n\t\t\tresult: \"1.1.1.1,10.0.0.1,127.0.0.1\",\n\t\t},\n\t\t{\n\t\t\tname:          \"StripLoopback\",\n\t\t\tremoteAddr:    \"127.0.0.1:80\",\n\t\t\torigXFFHeader: \"1.1.1.1,10.0.0.1,127.0.0.1\",\n\t\t\tpref: XFFComputePreferences{\n\t\t\t\tStripLoopback: true,\n\t\t\t},\n\t\t\tresult: \"1.1.1.1,10.0.0.1\",\n\t\t},\n\t\t{\n\t\t\tname:          \"StripCGNAT\",\n\t\t\tremoteAddr:    \"100.64.0.1:80\",\n\t\t\torigXFFHeader: \"1.1.1.1,10.0.0.1,100.64.0.1\",\n\t\t\tpref: XFFComputePreferences{\n\t\t\t\tStripCGNAT: true,\n\t\t\t},\n\t\t\tresult: \"1.1.1.1,10.0.0.1\",\n\t\t},\n\t\t{\n\t\t\tname:          \"StripLinkLocalUnicastIPv4\",\n\t\t\tremoteAddr:    \"169.254.0.1:80\",\n\t\t\torigXFFHeader: \"1.1.1.1,10.0.0.1,169.254.0.1\",\n\t\t\tpref: XFFComputePreferences{\n\t\t\t\tStripLLU: true,\n\t\t\t},\n\t\t\tresult: \"1.1.1.1,10.0.0.1\",\n\t\t},\n\t\t{\n\t\t\tname:          \"StripLinkLocalUnicastIPv6\",\n\t\t\tremoteAddr:    \"169.254.0.1:80\",\n\t\t\torigXFFHeader: \"1.1.1.1,10.0.0.1,fe80::\",\n\t\t\tpref: XFFComputePreferences{\n\t\t\t\tStripLLU: true,\n\t\t\t},\n\t\t\tresult: \"1.1.1.1,10.0.0.1\",\n\t\t},\n\t\t{\n\t\t\tname:          \"Flatten\",\n\t\t\tremoteAddr:    \"127.0.0.1:80\",\n\t\t\torigXFFHeader: \"1.1.1.1,10.0.0.1,fe80::,100.64.0.1,169.254.0.1\",\n\t\t\tpref: XFFComputePreferences{\n\t\t\t\tStripPrivate:  true,\n\t\t\t\tStripLoopback: true,\n\t\t\t\tStripCGNAT:    true,\n\t\t\t\tStripLLU:      true,\n\t\t\t\tFlatten:       true,\n\t\t\t},\n\t\t\tresult: \"1.1.1.1\",\n\t\t},\n\t\t{\n\t\t\tname:          \"TrimSpaces\",\n\t\t\tremoteAddr:    \"127.0.0.1:80\",\n\t\t\torigXFFHeader: \"1.1.1.1, 10.0.0.1, fe80::, 100.64.0.1, 169.254.0.1\",\n\t\t\tpref: XFFComputePreferences{\n\t\t\t\tStripPrivate:  true,\n\t\t\t\tStripLoopback: true,\n\t\t\t\tStripCGNAT:    true,\n\t\t\t\tStripLLU:      true,\n\t\t\t\tFlatten:       true,\n\t\t\t},\n\t\t\tresult: \"1.1.1.1\",\n\t\t},\n\t\t{\n\t\t\tname:       \"invalid-ip-port\",\n\t\t\tremoteAddr: \"fe80::\",\n\t\t\terr:        ErrCantSplitHostParse,\n\t\t},\n\t\t{\n\t\t\tname:       \"invalid-remote-ip\",\n\t\t\tremoteAddr: \"anubis:80\",\n\t\t\terr:        ErrCantParseRemoteIP,\n\t\t},\n\t\t{\n\t\t\tname:       \"no-xff-dont-panic\",\n\t\t\tremoteAddr: \"127.0.0.1:80\",\n\t\t\tpref: XFFComputePreferences{\n\t\t\t\tStripPrivate:  true,\n\t\t\t\tStripLoopback: true,\n\t\t\t\tStripCGNAT:    true,\n\t\t\t\tStripLLU:      true,\n\t\t\t\tFlatten:       true,\n\t\t\t},\n\t\t},\n\t} {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult, err := computeXFFHeader(tt.remoteAddr, tt.origXFFHeader, tt.pref)\n\t\t\tif err != nil && !errors.Is(err, tt.err) {\n\t\t\t\tt.Errorf(\"computeXFFHeader got the wrong error, wanted %v but got: %v\", tt.err, err)\n\t\t\t}\n\n\t\t\tif result != tt.result {\n\t\t\t\tt.Errorf(\"computeXFFHeader returned the wrong result, wanted %q but got: %q\", tt.result, result)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "lib/anubis.go",
    "content": "package lib\n\nimport (\n\t\"context\"\n\t\"crypto/ed25519\"\n\t\"crypto/rand\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"log/slog\"\n\t\"net\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/golang-jwt/jwt/v5\"\n\t\"github.com/google/cel-go/common/types\"\n\t\"github.com/google/uuid\"\n\n\t\"github.com/prometheus/client_golang/prometheus\"\n\t\"github.com/prometheus/client_golang/prometheus/promauto\"\n\n\t\"github.com/TecharoHQ/anubis\"\n\t\"github.com/TecharoHQ/anubis/decaymap\"\n\t\"github.com/TecharoHQ/anubis/internal\"\n\t\"github.com/TecharoHQ/anubis/internal/dnsbl\"\n\t\"github.com/TecharoHQ/anubis/internal/ogtags\"\n\t\"github.com/TecharoHQ/anubis/lib/challenge\"\n\t\"github.com/TecharoHQ/anubis/lib/config\"\n\t\"github.com/TecharoHQ/anubis/lib/localization\"\n\t\"github.com/TecharoHQ/anubis/lib/policy\"\n\t\"github.com/TecharoHQ/anubis/lib/policy/checker\"\n\t\"github.com/TecharoHQ/anubis/lib/store\"\n\n\t// challenge implementations\n\t_ \"github.com/TecharoHQ/anubis/lib/challenge/metarefresh\"\n\t_ \"github.com/TecharoHQ/anubis/lib/challenge/preact\"\n\t_ \"github.com/TecharoHQ/anubis/lib/challenge/proofofwork\"\n)\n\nvar (\n\tchallengesIssued = promauto.NewCounterVec(prometheus.CounterOpts{\n\t\tName: \"anubis_challenges_issued\",\n\t\tHelp: \"The total number of challenges issued\",\n\t}, []string{\"method\"})\n\n\tchallengesValidated = promauto.NewCounterVec(prometheus.CounterOpts{\n\t\tName: \"anubis_challenges_validated\",\n\t\tHelp: \"The total number of challenges validated\",\n\t}, []string{\"method\"})\n\n\tdroneBLHits = promauto.NewCounterVec(prometheus.CounterOpts{\n\t\tName: \"anubis_dronebl_hits\",\n\t\tHelp: \"The total number of hits from DroneBL\",\n\t}, []string{\"status\"})\n\n\tfailedValidations = promauto.NewCounterVec(prometheus.CounterOpts{\n\t\tName: \"anubis_failed_validations\",\n\t\tHelp: \"The total number of failed validations\",\n\t}, []string{\"method\"})\n\n\trequestsProxied = promauto.NewCounterVec(prometheus.CounterOpts{\n\t\tName: \"anubis_proxied_requests_total\",\n\t\tHelp: \"Number of requests proxied through Anubis to upstream targets\",\n\t}, []string{\"host\"})\n)\n\ntype Server struct {\n\tnext        http.Handler\n\tstore       store.Interface\n\tmux         *http.ServeMux\n\tpolicy      *policy.ParsedConfig\n\tOGTags      *ogtags.OGTagCache\n\tlogger      *slog.Logger\n\topts        Options\n\ted25519Priv ed25519.PrivateKey\n\ths512Secret []byte\n}\n\nfunc (s *Server) getTokenKeyfunc() jwt.Keyfunc {\n\t// return ED25519 key if HS512 is not set\n\tif len(s.hs512Secret) == 0 {\n\t\treturn func(token *jwt.Token) (any, error) {\n\t\t\treturn s.ed25519Priv.Public().(ed25519.PublicKey), nil\n\t\t}\n\t} else {\n\t\treturn func(token *jwt.Token) (any, error) {\n\t\t\treturn s.hs512Secret, nil\n\t\t}\n\t}\n}\n\nfunc (s *Server) getChallenge(r *http.Request) (*challenge.Challenge, error) {\n\tid := r.FormValue(\"id\")\n\tj := store.JSON[challenge.Challenge]{Underlying: s.store}\n\n\tchall, err := j.Get(r.Context(), \"challenge:\"+id)\n\n\treturn &chall, err\n}\n\nfunc (s *Server) issueChallenge(ctx context.Context, r *http.Request, lg *slog.Logger, cr policy.CheckResult, rule *policy.Bot) (*challenge.Challenge, error) {\n\tif cr.Rule != config.RuleChallenge {\n\t\tslog.Error(\"this should be impossible, asked to issue a challenge but the rule is not a challenge rule\", \"cr\", cr, \"rule\", rule)\n\t\t//return nil, errors.New(\"[unexpected] this codepath should be impossible, asked to issue a challenge for a non-challenge rule\")\n\t}\n\n\tif rule.Challenge == nil {\n\t\trule.Challenge = &config.ChallengeRules{\n\t\t\tDifficulty: s.policy.DefaultDifficulty,\n\t\t\tAlgorithm:  config.DefaultAlgorithm,\n\t\t}\n\t}\n\n\tid, err := uuid.NewV7()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar randomData = make([]byte, 64)\n\tif _, err := rand.Read(randomData); err != nil {\n\t\treturn nil, err\n\t}\n\n\tchall := challenge.Challenge{\n\t\tID:             id.String(),\n\t\tMethod:         rule.Challenge.Algorithm,\n\t\tRandomData:     fmt.Sprintf(\"%x\", randomData),\n\t\tIssuedAt:       time.Now(),\n\t\tDifficulty:     rule.Challenge.Difficulty,\n\t\tPolicyRuleHash: rule.Hash(),\n\t\tMetadata: map[string]string{\n\t\t\t\"User-Agent\": r.Header.Get(\"User-Agent\"),\n\t\t\t\"X-Real-Ip\":  r.Header.Get(\"X-Real-Ip\"),\n\t\t},\n\t}\n\n\tj := store.JSON[challenge.Challenge]{Underlying: s.store}\n\tif err := j.Set(ctx, \"challenge:\"+id.String(), chall, 30*time.Minute); err != nil {\n\t\treturn nil, err\n\t}\n\n\tlg.Info(\"new challenge issued\", \"challenge\", id.String())\n\n\treturn &chall, err\n}\n\nfunc (s *Server) hydrateChallengeRule(rule *policy.Bot, chall *challenge.Challenge, lg *slog.Logger) *policy.Bot {\n\tif chall == nil {\n\t\treturn rule\n\t}\n\n\tif rule == nil {\n\t\trule = &policy.Bot{\n\t\t\tRules: &checker.List{},\n\t\t}\n\t}\n\n\tif chall.Difficulty == 0 {\n\t\t// fall back to whatever the policy currently says or the global default\n\t\tif rule.Challenge != nil && rule.Challenge.Difficulty != 0 {\n\t\t\tchall.Difficulty = rule.Challenge.Difficulty\n\t\t} else {\n\t\t\tchall.Difficulty = s.policy.DefaultDifficulty\n\t\t}\n\t}\n\n\tif rule.Challenge == nil {\n\t\tlg.Warn(\"rule missing challenge configuration; using stored challenge metadata\", \"rule\", rule.Name)\n\t\trule.Challenge = &config.ChallengeRules{}\n\t}\n\n\tif rule.Challenge.Difficulty == 0 {\n\t\trule.Challenge.Difficulty = chall.Difficulty\n\t}\n\tif rule.Challenge.ReportAs != 0 {\n\t\ts.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)\n\t}\n\tif rule.Challenge.Algorithm == \"\" {\n\t\trule.Challenge.Algorithm = chall.Method\n\t}\n\n\treturn rule\n}\n\nfunc (s *Server) maybeReverseProxyHttpStatusOnly(w http.ResponseWriter, r *http.Request) {\n\ts.maybeReverseProxy(w, r, true)\n}\n\nfunc (s *Server) maybeReverseProxyOrPage(w http.ResponseWriter, r *http.Request) {\n\ts.maybeReverseProxy(w, r, false)\n}\n\nfunc (s *Server) maybeReverseProxy(w http.ResponseWriter, r *http.Request, httpStatusOnly bool) {\n\tlg := internal.GetRequestLogger(s.logger, r)\n\n\tif val, _ := s.store.Get(r.Context(), fmt.Sprintf(\"ogtags:allow:%s%s\", r.Host, r.URL.String())); val != nil {\n\t\tlg.Debug(\"serving opengraph tag asset\")\n\t\ts.ServeHTTPNext(w, r)\n\t\treturn\n\t}\n\n\t// Adjust cookie path if base prefix is not empty\n\tcookiePath := \"/\"\n\tif anubis.BasePrefix != \"\" {\n\t\tcookiePath = strings.TrimSuffix(anubis.BasePrefix, \"/\") + \"/\"\n\t}\n\n\tcr, rule, err := s.check(r, lg)\n\tif err != nil {\n\t\tlg.Error(\"check failed\", \"err\", err)\n\t\tlocalizer := localization.GetLocalizer(r)\n\t\ts.respondWithError(w, r, fmt.Sprintf(\"%s \\\"maybeReverseProxy\\\"\", localizer.T(\"internal_server_error\")), makeCode(err))\n\t\treturn\n\t}\n\n\tr.Header.Add(\"X-Anubis-Rule\", cr.Name)\n\tr.Header.Add(\"X-Anubis-Action\", string(cr.Rule))\n\tlg = lg.With(\"check_result\", cr)\n\tpolicy.Applications.WithLabelValues(cr.Name, string(cr.Rule)).Add(1)\n\n\tip := r.Header.Get(\"X-Real-Ip\")\n\n\tif s.handleDNSBL(w, r, ip, lg) {\n\t\treturn\n\t}\n\n\tif s.checkRules(w, r, cr, lg, rule) {\n\t\treturn\n\t}\n\n\tckie, err := r.Cookie(anubis.CookieName)\n\tif err != nil {\n\t\tlg.Debug(\"cookie not found\", \"path\", r.URL.Path)\n\t\ts.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host})\n\t\ts.RenderIndex(w, r, cr, rule, httpStatusOnly)\n\t\treturn\n\t}\n\n\tif err := ckie.Valid(); err != nil {\n\t\tlg.Debug(\"cookie is invalid\", \"err\", err)\n\t\ts.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host})\n\t\ts.RenderIndex(w, r, cr, rule, httpStatusOnly)\n\t\treturn\n\t}\n\n\tif time.Now().After(ckie.Expires) && !ckie.Expires.IsZero() {\n\t\tlg.Debug(\"cookie expired\", \"path\", r.URL.Path)\n\t\ts.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host})\n\t\ts.RenderIndex(w, r, cr, rule, httpStatusOnly)\n\t\treturn\n\t}\n\n\ttoken, err := jwt.ParseWithClaims(ckie.Value, jwt.MapClaims{}, s.getTokenKeyfunc(), jwt.WithExpirationRequired(), jwt.WithStrictDecoding())\n\n\tif err != nil || !token.Valid {\n\t\tlg.Debug(\"invalid token\", \"path\", r.URL.Path, \"err\", err)\n\t\ts.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host})\n\t\ts.RenderIndex(w, r, cr, rule, httpStatusOnly)\n\t\treturn\n\t}\n\n\tclaims, ok := token.Claims.(jwt.MapClaims)\n\tif !ok {\n\t\tlg.Debug(\"invalid token claims type\", \"path\", r.URL.Path)\n\t\ts.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host})\n\t\ts.RenderIndex(w, r, cr, rule, httpStatusOnly)\n\t\treturn\n\t}\n\n\tpolicyRule, ok := claims[\"policyRule\"].(string)\n\tif !ok {\n\t\tlg.Debug(\"policyRule claim is not a string\")\n\t\ts.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host})\n\t\ts.RenderIndex(w, r, cr, rule, httpStatusOnly)\n\t\treturn\n\t}\n\n\tif policyRule != rule.Hash() {\n\t\tlg.Debug(\"user originally passed with a different rule, issuing new challenge\", \"old\", policyRule, \"new\", rule.Name)\n\t\ts.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host})\n\t\ts.RenderIndex(w, r, cr, rule, httpStatusOnly)\n\t\treturn\n\t}\n\n\tif s.opts.JWTRestrictionHeader != \"\" && claims[\"restriction\"] != internal.SHA256sum(r.Header.Get(s.opts.JWTRestrictionHeader)) {\n\t\tlg.Debug(\"JWT restriction header is invalid\")\n\t\ts.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host})\n\t\ts.RenderIndex(w, r, cr, rule, httpStatusOnly)\n\t\treturn\n\t}\n\n\tr.Header.Add(\"X-Anubis-Status\", \"PASS\")\n\ts.ServeHTTPNext(w, r)\n}\n\nfunc (s *Server) checkRules(w http.ResponseWriter, r *http.Request, cr policy.CheckResult, lg *slog.Logger, rule *policy.Bot) bool {\n\t// Adjust cookie path if base prefix is not empty\n\tcookiePath := \"/\"\n\tif anubis.BasePrefix != \"\" {\n\t\tcookiePath = strings.TrimSuffix(anubis.BasePrefix, \"/\") + \"/\"\n\t}\n\n\tlocalizer := localization.GetLocalizer(r)\n\n\tswitch cr.Rule {\n\tcase config.RuleAllow:\n\t\tlg.Debug(\"allowing traffic to origin (explicit)\")\n\t\ts.ServeHTTPNext(w, r)\n\t\treturn true\n\tcase config.RuleDeny:\n\t\ts.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host})\n\t\tlg.Info(\"explicit deny\")\n\t\tif rule == nil {\n\t\t\tlg.Error(\"rule is nil, cannot calculate checksum\")\n\t\t\ts.respondWithError(w, r, fmt.Sprintf(\"%s \\\"maybeReverseProxy.RuleDeny\\\"\", localizer.T(\"internal_server_error\")), makeCode(ErrActualAnubisBug))\n\t\t\treturn true\n\t\t}\n\t\thash := rule.Hash()\n\n\t\tlg.Debug(\"rule hash\", \"hash\", hash)\n\t\ts.respondWithStatus(w, r, fmt.Sprintf(\"%s %s\", localizer.T(\"access_denied\"), hash), \"\", s.policy.StatusCodes.Deny)\n\t\treturn true\n\tcase config.RuleChallenge:\n\t\tlg.Debug(\"challenge requested\")\n\tcase config.RuleBenchmark:\n\t\tlg.Debug(\"serving benchmark page\")\n\t\ts.RenderBench(w, r)\n\t\treturn true\n\tdefault:\n\t\ts.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host})\n\t\tlg.Error(\"CONFIG ERROR: unknown rule\", \"rule\", cr.Rule)\n\t\ts.respondWithError(w, r, fmt.Sprintf(\"%s \\\"maybeReverseProxy.Rules\\\"\", localizer.T(\"internal_server_error\")), makeCode(ErrActualAnubisBug))\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc (s *Server) handleDNSBL(w http.ResponseWriter, r *http.Request, ip string, lg *slog.Logger) bool {\n\tdb := &store.JSON[dnsbl.DroneBLResponse]{Underlying: s.store, Prefix: \"dronebl:\"}\n\tif s.policy.DNSBL && ip != \"\" {\n\t\tresp, err := db.Get(r.Context(), ip)\n\t\tif err != nil {\n\t\t\tlg.Debug(\"looking up ip in dnsbl\")\n\t\t\tresp, err := dnsbl.Lookup(ip)\n\t\t\tif err != nil {\n\t\t\t\tlg.Error(\"can't look up ip in dnsbl\", \"err\", err)\n\t\t\t}\n\t\t\tdb.Set(r.Context(), ip, resp, 24*time.Hour)\n\t\t\tdroneBLHits.WithLabelValues(resp.String()).Inc()\n\t\t}\n\n\t\tif resp != dnsbl.AllGood {\n\t\t\tlg.Info(\"DNSBL hit\", \"status\", resp.String())\n\t\t\tlocalizer := localization.GetLocalizer(r)\n\t\t\ts.respondWithStatus(w, r, fmt.Sprintf(\"%s: %s, %s https://dronebl.org/lookup?ip=%s\",\n\t\t\t\tlocalizer.T(\"dronebl_entry\"),\n\t\t\t\tresp.String(),\n\t\t\t\tlocalizer.T(\"see_dronebl_lookup\"),\n\t\t\t\tip), \"\", s.policy.StatusCodes.Deny)\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc (s *Server) MakeChallenge(w http.ResponseWriter, r *http.Request) {\n\tlg := internal.GetRequestLogger(s.logger, r)\n\tlocalizer := localization.GetLocalizer(r)\n\n\tredir := r.FormValue(\"redir\")\n\tif redir == \"\" {\n\t\tw.WriteHeader(http.StatusBadRequest)\n\t\tencoder := json.NewEncoder(w)\n\t\tlg.Error(\"invalid invocation of MakeChallenge\", \"redir\", redir)\n\t\tencoder.Encode(struct {\n\t\t\tError string `json:\"error\"`\n\t\t}{\n\t\t\tError: localizer.T(\"invalid_invocation\"),\n\t\t})\n\t\treturn\n\t}\n\n\tr.URL.Path = redir\n\n\tencoder := json.NewEncoder(w)\n\tcr, rule, err := s.check(r, lg)\n\tif err != nil {\n\t\tlg.Error(\"check failed\", \"err\", err)\n\t\tw.WriteHeader(http.StatusInternalServerError)\n\t\terr := encoder.Encode(struct {\n\t\t\tError string `json:\"error\"`\n\t\t}{\n\t\t\tError: fmt.Sprintf(\"%s \\\"makeChallenge\\\"\", localizer.T(\"internal_server_error\")),\n\t\t})\n\t\tif err != nil {\n\t\t\tlg.Error(\"failed to encode error response\", \"err\", err)\n\t\t\tw.WriteHeader(http.StatusInternalServerError)\n\t\t}\n\t\treturn\n\t}\n\tlg = lg.With(\"check_result\", cr)\n\n\tchall, err := s.issueChallenge(r.Context(), r, lg, cr, rule)\n\tif err != nil {\n\t\tlg.Error(\"failed to fetch or issue challenge\", \"err\", err)\n\t\tw.WriteHeader(http.StatusInternalServerError)\n\t\terr := encoder.Encode(struct {\n\t\t\tError string `json:\"error\"`\n\t\t}{\n\t\t\tError: fmt.Sprintf(\"%s \\\"makeChallenge\\\"\", localizer.T(\"internal_server_error\")),\n\t\t})\n\t\tif err != nil {\n\t\t\tlg.Error(\"failed to encode error response\", \"err\", err)\n\t\t\tw.WriteHeader(http.StatusInternalServerError)\n\t\t}\n\t\treturn\n\t}\n\n\ts.SetCookie(w, CookieOpts{Host: r.Host, Name: anubis.TestCookieName, Value: chall.ID})\n\n\terr = encoder.Encode(struct {\n\t\tRules     *config.ChallengeRules `json:\"rules\"`\n\t\tChallenge string                 `json:\"challenge\"`\n\t\tID        string                 `json:\"id\"`\n\t}{\n\t\tRules:     rule.Challenge,\n\t\tChallenge: chall.RandomData,\n\t\tID:        chall.ID,\n\t})\n\tif err != nil {\n\t\tlg.Error(\"failed to encode challenge\", \"err\", err)\n\t\tw.WriteHeader(http.StatusInternalServerError)\n\t\treturn\n\t}\n\tlg.Debug(\"made challenge\", \"challenge\", chall, \"rules\", rule.Challenge, \"cr\", cr)\n\tchallengesIssued.WithLabelValues(\"api\").Inc()\n}\n\nfunc (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) {\n\tlg := internal.GetRequestLogger(s.logger, r)\n\tlocalizer := localization.GetLocalizer(r)\n\n\tredir := r.FormValue(\"redir\")\n\tredirURL, err := url.ParseRequestURI(redir)\n\tif err != nil {\n\t\tlg.Error(\"invalid redirect\", \"err\", err)\n\t\ts.respondWithStatus(w, r, localizer.T(\"invalid_redirect\"), makeCode(err), http.StatusBadRequest)\n\t\treturn\n\t}\n\n\tswitch redirURL.Scheme {\n\tcase \"\", \"http\", \"https\":\n\t\t// allowed\n\tdefault:\n\t\tlg.Error(\"XSS attempt blocked, invalid redirect scheme\", \"scheme\", redirURL.Scheme)\n\t\ts.respondWithStatus(w, r, localizer.T(\"invalid_redirect\"), \"\", http.StatusBadRequest)\n\t\treturn\n\t}\n\n\t// Adjust cookie path if base prefix is not empty\n\tcookiePath := \"/\"\n\tif anubis.BasePrefix != \"\" {\n\t\tcookiePath = strings.TrimSuffix(anubis.BasePrefix, \"/\") + \"/\"\n\t}\n\n\tif _, err := r.Cookie(anubis.TestCookieName); errors.Is(err, http.ErrNoCookie) {\n\t\ts.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host})\n\t\ts.ClearCookie(w, CookieOpts{Name: anubis.TestCookieName, Host: r.Host})\n\t\tlg.Warn(\"user has cookies disabled, this is not an anubis bug\")\n\t\ts.respondWithError(w, r, localizer.T(\"cookies_disabled\"), \"\")\n\t\treturn\n\t}\n\n\t// used by the path checker rule\n\tr.URL = redirURL\n\n\turlParsed, err := r.URL.Parse(redir)\n\tif err != nil {\n\t\ts.respondWithError(w, r, localizer.T(\"redirect_not_parseable\"), makeCode(err))\n\t\treturn\n\t}\n\tif (len(urlParsed.Host) > 0 && len(s.opts.RedirectDomains) != 0 && !matchRedirectDomain(s.opts.RedirectDomains, urlParsed.Host)) || urlParsed.Host != r.URL.Host {\n\t\tlg.Debug(\"domain not allowed\", \"domain\", urlParsed.Host)\n\t\ts.respondWithError(w, r, localizer.T(\"redirect_domain_not_allowed\"), \"\")\n\t\treturn\n\t}\n\n\tcr, rule, err := s.check(r, lg)\n\tif err != nil {\n\t\tlg.Error(\"check failed\", \"err\", err)\n\t\ts.respondWithError(w, r, fmt.Sprintf(\"%s \\\"passChallenge\\\"\", localizer.T(\"internal_server_error\")), makeCode(err))\n\t\treturn\n\t}\n\tlg = lg.With(\"check_result\", cr)\n\n\tchall, err := s.getChallenge(r)\n\tif err != nil {\n\t\tlg.Error(\"getChallenge failed\", \"err\", err)\n\t\talgorithm := \"unknown\"\n\t\tif rule.Challenge != nil {\n\t\t\talgorithm = rule.Challenge.Algorithm\n\t\t}\n\t\ts.respondWithError(w, r, fmt.Sprintf(\"%s: %s\", localizer.T(\"internal_server_error\"), algorithm), makeCode(err))\n\t\treturn\n\t}\n\n\tif chall.Spent {\n\t\tlg.Error(\"double spend prevented\", \"reason\", \"double_spend\")\n\t\ts.respondWithError(w, r, fmt.Sprintf(\"%s: %s\", localizer.T(\"internal_server_error\"), \"double_spend\"), \"\")\n\t\treturn\n\t}\n\n\trule = s.hydrateChallengeRule(rule, chall, lg)\n\n\timpl, ok := challenge.Get(chall.Method)\n\tif !ok {\n\t\tlg.Error(\"check failed\", \"err\", err)\n\t\ts.respondWithError(w, r, fmt.Sprintf(\"%s: %s\", localizer.T(\"internal_server_error\"), rule.Challenge.Algorithm), makeCode(ErrActualAnubisBug))\n\t\treturn\n\t}\n\n\tlg = lg.With(\"challenge\", chall.ID)\n\n\tin := &challenge.ValidateInput{\n\t\tChallenge: chall,\n\t\tRule:      rule,\n\t\tStore:     s.store,\n\t}\n\n\tif err := impl.Validate(r, lg, in); err != nil {\n\t\tfailedValidations.WithLabelValues(rule.Challenge.Algorithm).Inc()\n\t\tvar cerr *challenge.Error\n\t\ts.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host})\n\t\tlg.Debug(\"challenge validate call failed\", \"err\", err)\n\n\t\tswitch {\n\t\tcase errors.As(err, &cerr):\n\t\t\tswitch {\n\t\t\tcase errors.Is(err, challenge.ErrFailed):\n\t\t\t\tlg.Error(\"challenge failed\", \"err\", err)\n\t\t\t\ts.respondWithStatus(w, r, cerr.PublicReason, makeCode(err), cerr.StatusCode)\n\t\t\t\treturn\n\t\t\tcase errors.Is(err, challenge.ErrInvalidFormat), errors.Is(err, challenge.ErrMissingField):\n\t\t\t\tlg.Error(\"invalid challenge format\", \"err\", err)\n\t\t\t\ts.respondWithError(w, r, cerr.PublicReason, makeCode(err))\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t}\n\n\t// generate JWT cookie\n\tvar tokenString string\n\n\t// check if JWTRestrictionHeader is set and header is in request\n\tclaims := jwt.MapClaims{\n\t\t\"challenge\":  chall.ID,\n\t\t\"method\":     rule.Challenge.Algorithm,\n\t\t\"policyRule\": rule.Hash(),\n\t\t\"action\":     string(cr.Rule),\n\t}\n\tif s.opts.JWTRestrictionHeader != \"\" {\n\t\tif r.Header.Get(s.opts.JWTRestrictionHeader) == \"\" {\n\t\t\tlg.Error(\"JWTRestrictionHeader is set in config but not found in request, please check your reverse proxy config.\")\n\t\t\ts.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host})\n\t\t\ts.respondWithError(w, r, \"failed to sign JWT\", makeCode(err))\n\t\t\treturn\n\t\t} else {\n\t\t\tclaims[\"restriction\"] = internal.SHA256sum(r.Header.Get(s.opts.JWTRestrictionHeader))\n\t\t}\n\t}\n\tif s.opts.DifficultyInJWT {\n\t\tclaims[\"difficulty\"] = rule.Challenge.Difficulty\n\t}\n\ttokenString, err = s.signJWT(claims)\n\n\tif err != nil {\n\t\tlg.Error(\"failed to sign JWT\", \"err\", err)\n\t\ts.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host})\n\t\ts.respondWithError(w, r, localizer.T(\"failed_to_sign_jwt\"), makeCode(err))\n\t\treturn\n\t}\n\n\ts.SetCookie(w, CookieOpts{Path: cookiePath, Host: r.Host, Value: tokenString})\n\n\tchall.Spent = true\n\tj := store.JSON[challenge.Challenge]{Underlying: s.store}\n\tif err := j.Set(r.Context(), \"challenge:\"+chall.ID, *chall, 30*time.Minute); err != nil {\n\t\tlg.Debug(\"can't update information about challenge\", \"err\", err)\n\t}\n\n\tchallengesValidated.WithLabelValues(rule.Challenge.Algorithm).Inc()\n\tlg.Debug(\"challenge passed, redirecting to app\")\n\thttp.Redirect(w, r, redir, http.StatusFound)\n}\n\nfunc cr(name string, rule config.Rule, weight int) policy.CheckResult {\n\treturn policy.CheckResult{\n\t\tName:   name,\n\t\tRule:   rule,\n\t\tWeight: weight,\n\t}\n}\n\n// Check evaluates the list of rules, and returns the result\nfunc (s *Server) check(r *http.Request, lg *slog.Logger) (policy.CheckResult, *policy.Bot, error) {\n\thost := r.Header.Get(\"X-Real-Ip\")\n\tif host == \"\" {\n\t\treturn decaymap.Zilch[policy.CheckResult](), nil, fmt.Errorf(\"[misconfiguration] X-Real-Ip header is not set\")\n\t}\n\n\taddr := net.ParseIP(host)\n\tif addr == nil {\n\t\treturn decaymap.Zilch[policy.CheckResult](), nil, fmt.Errorf(\"[misconfiguration] %q is not an IP address\", host)\n\t}\n\n\tweight := 0\n\n\tfor _, b := range s.policy.Bots {\n\t\tmatch, err := b.Rules.Check(r)\n\t\tif err != nil {\n\t\t\treturn decaymap.Zilch[policy.CheckResult](), nil, fmt.Errorf(\"can't run check %s: %w\", b.Name, err)\n\t\t}\n\n\t\tif match {\n\t\t\tswitch b.Action {\n\t\t\tcase config.RuleDeny, config.RuleAllow, config.RuleBenchmark, config.RuleChallenge:\n\t\t\t\treturn cr(\"bot/\"+b.Name, b.Action, weight), &b, nil\n\t\t\tcase config.RuleWeigh:\n\t\t\t\tlg.Debug(\"adjusting weight\", \"name\", b.Name, \"delta\", b.Weight.Adjust)\n\t\t\t\tpolicy.Applications.WithLabelValues(\"bot/\"+b.Name, \"WEIGH\").Add(1)\n\t\t\t\tweight += b.Weight.Adjust\n\t\t\t}\n\t\t}\n\t}\n\n\tfor _, t := range s.policy.Thresholds {\n\t\tresult, _, err := t.Program.ContextEval(r.Context(), &policy.ThresholdRequest{Weight: weight})\n\t\tif err != nil {\n\t\t\tlg.Error(\"error when evaluating threshold expression\", \"expression\", t.Expression.String(), \"err\", err)\n\t\t\tcontinue\n\t\t}\n\n\t\tvar matches bool\n\n\t\tif val, ok := result.(types.Bool); ok {\n\t\t\tmatches = bool(val)\n\t\t}\n\n\t\tif matches {\n\t\t\tchallRules := t.Challenge\n\t\t\tif challRules == nil {\n\t\t\t\t// Non-CHALLENGE thresholds (ALLOW/DENY) don't have challenge config.\n\t\t\t\t// Use an empty struct so hydrateChallengeRule can fill from stored\n\t\t\t\t// challenge data during validation, rather than baking in defaults\n\t\t\t\t// that could mismatch the difficulty the client actually solved for.\n\t\t\t\tchallRules = &config.ChallengeRules{}\n\t\t\t}\n\t\t\treturn cr(\"threshold/\"+t.Name, t.Action, weight), &policy.Bot{\n\t\t\t\tChallenge: challRules,\n\t\t\t\tRules:     &checker.List{},\n\t\t\t}, nil\n\t\t}\n\t}\n\n\treturn cr(\"default/allow\", config.RuleAllow, weight), &policy.Bot{\n\t\tChallenge: &config.ChallengeRules{\n\t\t\tDifficulty: s.policy.DefaultDifficulty,\n\t\t\tAlgorithm:  config.DefaultAlgorithm,\n\t\t},\n\t\tRules: &checker.List{},\n\t}, nil\n}\n"
  },
  {
    "path": "lib/anubis_test.go",
    "content": "package lib\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"log/slog\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"net/url\"\n\t\"os\"\n\t\"strings\"\n\t\"sync\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/TecharoHQ/anubis\"\n\t\"github.com/TecharoHQ/anubis/data\"\n\t\"github.com/TecharoHQ/anubis/internal\"\n\t\"github.com/TecharoHQ/anubis/lib/challenge\"\n\t\"github.com/TecharoHQ/anubis/lib/config\"\n\t\"github.com/TecharoHQ/anubis/lib/policy\"\n\t\"github.com/TecharoHQ/anubis/lib/store\"\n\t\"github.com/TecharoHQ/anubis/lib/thoth/thothmock\"\n)\n\n// TLogWriter implements io.Writer by logging each line to t.Log.\ntype TLogWriter struct {\n\tt *testing.T\n}\n\n// NewTLogWriter returns an io.Writer that sends output to t.Log.\nfunc NewTLogWriter(t *testing.T) io.Writer {\n\treturn &TLogWriter{t: t}\n}\n\n// Write splits input on newlines and logs each line separately.\nfunc (w *TLogWriter) Write(p []byte) (n int, err error) {\n\tlines := strings.SplitSeq(string(p), \"\\n\")\n\tfor line := range lines {\n\t\tif line != \"\" {\n\t\t\tw.t.Log(line)\n\t\t}\n\t}\n\treturn len(p), nil\n}\n\nfunc loadPolicies(t *testing.T, fname string, difficulty int) *policy.ParsedConfig {\n\tt.Helper()\n\n\tctx := thothmock.WithMockThoth(t)\n\n\tif fname == \"\" {\n\t\tfname = \"./testdata/test_config.yaml\"\n\t}\n\n\tt.Logf(\"loading policy file: %s\", fname)\n\n\tanubisPolicy, err := LoadPoliciesOrDefault(ctx, fname, difficulty, \"info\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\treturn anubisPolicy\n}\n\nfunc spawnAnubis(t *testing.T, opts Options) *Server {\n\tt.Helper()\n\n\tif opts.Policy == nil {\n\t\topts.Policy = loadPolicies(t, \"\", 4)\n\t}\n\n\ts, err := New(opts)\n\tif err != nil {\n\t\tt.Fatalf(\"can't construct libanubis.Server: %v\", err)\n\t}\n\n\ts.logger = slog.New(slog.NewJSONHandler(&TLogWriter{t: t}, &slog.HandlerOptions{\n\t\tAddSource: true,\n\t\tLevel:     slog.LevelDebug,\n\t}))\n\n\treturn s\n}\n\ntype challengeResp struct {\n\tID        string `json:\"id\"`\n\tChallenge string `json:\"challenge\"`\n}\n\nfunc makeChallenge(t *testing.T, ts *httptest.Server, cli *http.Client) challengeResp {\n\tt.Helper()\n\n\treq, err := http.NewRequest(http.MethodPost, ts.URL+\"/.within.website/x/cmd/anubis/api/make-challenge\", nil)\n\tif err != nil {\n\t\tt.Fatalf(\"can't make request: %v\", err)\n\t}\n\n\tq := req.URL.Query()\n\tq.Set(\"redir\", \"/\")\n\treq.URL.RawQuery = q.Encode()\n\n\tresp, err := cli.Do(req)\n\tif err != nil {\n\t\tt.Fatalf(\"can't request challenge: %v\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tvar chall challengeResp\n\tif err := json.NewDecoder(resp.Body).Decode(&chall); err != nil {\n\t\tt.Fatalf(\"can't read challenge response body: %v\", err)\n\t}\n\n\treturn chall\n}\n\nfunc handleChallengeZeroDifficulty(t *testing.T, ts *httptest.Server, cli *http.Client, chall challengeResp) *http.Response {\n\tt.Helper()\n\n\tt.Logf(\"%#v\", chall)\n\n\tnonce := 0\n\telapsedTime := 420\n\tredir := \"/\"\n\tcalculated := \"\"\n\tcalcString := fmt.Sprintf(\"%s%d\", chall.Challenge, nonce)\n\tcalculated = internal.SHA256sum(calcString)\n\n\treq, err := http.NewRequest(http.MethodGet, ts.URL+\"/.within.website/x/cmd/anubis/api/pass-challenge\", nil)\n\tif err != nil {\n\t\tt.Fatalf(\"can't make request: %v\", err)\n\t}\n\n\tq := req.URL.Query()\n\tq.Set(\"response\", calculated)\n\tq.Set(\"nonce\", fmt.Sprint(nonce))\n\tq.Set(\"redir\", redir)\n\tq.Set(\"elapsedTime\", fmt.Sprint(elapsedTime))\n\tq.Set(\"id\", chall.ID)\n\treq.URL.RawQuery = q.Encode()\n\n\tt.Log(q.Encode())\n\n\tresp, err := cli.Do(req)\n\tif err != nil {\n\t\tt.Fatalf(\"can't do request: %v\", err)\n\t}\n\n\treturn resp\n}\n\nfunc handleChallengeInvalidProof(t *testing.T, ts *httptest.Server, cli *http.Client, chall challengeResp) *http.Response {\n\tt.Helper()\n\n\treq, err := http.NewRequest(http.MethodGet, ts.URL+\"/.within.website/x/cmd/anubis/api/pass-challenge\", nil)\n\tif err != nil {\n\t\tt.Fatalf(\"can't make request: %v\", err)\n\t}\n\n\tq := req.URL.Query()\n\tq.Set(\"response\", strings.Repeat(\"f\", 64)) // \"hash\" that never starts with the nonce\n\tq.Set(\"nonce\", \"0\")\n\tq.Set(\"redir\", \"/\")\n\tq.Set(\"elapsedTime\", \"0\")\n\tq.Set(\"id\", chall.ID)\n\treq.URL.RawQuery = q.Encode()\n\n\tresp, err := cli.Do(req)\n\tif err != nil {\n\t\tt.Fatalf(\"can't do request: %v\", err)\n\t}\n\n\treturn resp\n}\n\ntype loggingCookieJar struct {\n\tt       *testing.T\n\tcookies map[string][]*http.Cookie\n\tlock    sync.Mutex\n}\n\nfunc (lcj *loggingCookieJar) Cookies(u *url.URL) []*http.Cookie {\n\tlcj.lock.Lock()\n\tdefer lcj.lock.Unlock()\n\n\t// XXX(Xe): This is not RFC compliant in the slightest.\n\tresult, ok := lcj.cookies[u.Host]\n\tif !ok {\n\t\treturn nil\n\t}\n\n\tlcj.t.Logf(\"requested cookies for %s\", u)\n\n\tfor _, ckie := range result {\n\t\tlcj.t.Logf(\"get cookie: <- %s\", ckie)\n\t}\n\n\treturn result\n}\n\nfunc (lcj *loggingCookieJar) SetCookies(u *url.URL, cookies []*http.Cookie) {\n\tlcj.lock.Lock()\n\tdefer lcj.lock.Unlock()\n\n\tfor _, ckie := range cookies {\n\t\tlcj.t.Logf(\"set cookie: %s -> %s\", u, ckie)\n\t}\n\n\t// XXX(Xe): This is not RFC compliant in the slightest.\n\tlcj.cookies[u.Host] = append(lcj.cookies[u.Host], cookies...)\n}\n\ntype userAgentRoundTripper struct {\n\trt http.RoundTripper\n}\n\nfunc (u *userAgentRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {\n\t// Only set if not already present\n\treq = req.Clone(req.Context()) // avoid mutating original request\n\treq.Header.Set(\"User-Agent\", \"Mozilla/5.0\")\n\treq.Header.Set(\"Accept-Encoding\", \"gzip\")\n\treturn u.rt.RoundTrip(req)\n}\n\nfunc httpClient(t *testing.T) *http.Client {\n\tt.Helper()\n\n\tcli := &http.Client{\n\t\tJar: &loggingCookieJar{t: t, cookies: map[string][]*http.Cookie{}},\n\t\tCheckRedirect: func(req *http.Request, via []*http.Request) error {\n\t\t\treturn http.ErrUseLastResponse\n\t\t},\n\t\tTransport: &userAgentRoundTripper{\n\t\t\trt: http.DefaultTransport,\n\t\t},\n\t}\n\n\treturn cli\n}\n\nfunc TestLoadPolicies(t *testing.T) {\n\tfor _, fname := range []string{\"botPolicies.yaml\"} {\n\t\tt.Run(fname, func(t *testing.T) {\n\t\t\tfin, err := data.BotPolicies.Open(fname)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatal(err)\n\t\t\t}\n\t\t\tdefer fin.Close()\n\n\t\t\tif _, err := policy.ParseConfig(t.Context(), fin, fname, 4, \"info\"); err != nil {\n\t\t\t\tt.Fatal(err)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// Regression test for CVE-2025-24369\nfunc TestCVE2025_24369(t *testing.T) {\n\tpol := loadPolicies(t, \"\", anubis.DefaultDifficulty)\n\n\tsrv := spawnAnubis(t, Options{\n\t\tNext:   http.NewServeMux(),\n\t\tPolicy: pol,\n\t})\n\n\tts := httptest.NewServer(internal.RemoteXRealIP(true, \"tcp\", srv))\n\tdefer ts.Close()\n\n\tcli := httpClient(t)\n\tchall := makeChallenge(t, ts, cli)\n\tresp := handleChallengeInvalidProof(t, ts, cli, chall)\n\n\tif resp.StatusCode == http.StatusFound {\n\t\tt.Log(\"Regression on CVE-2025-24369\")\n\t\tt.Errorf(\"wanted HTTP status %d, got: %d\", http.StatusForbidden, resp.StatusCode)\n\t}\n}\n\nfunc TestCookieCustomExpiration(t *testing.T) {\n\tpol := loadPolicies(t, \"testdata/zero_difficulty.yaml\", 0)\n\tckieExpiration := 10 * time.Minute\n\n\tsrv := spawnAnubis(t, Options{\n\t\tNext:   http.NewServeMux(),\n\t\tPolicy: pol,\n\n\t\tCookieExpiration: ckieExpiration,\n\t})\n\n\tts := httptest.NewServer(internal.RemoteXRealIP(true, \"tcp\", srv))\n\tdefer ts.Close()\n\n\tcli := httpClient(t)\n\tchall := makeChallenge(t, ts, cli)\n\n\tresp := handleChallengeZeroDifficulty(t, ts, cli, chall)\n\n\tif resp.StatusCode != http.StatusFound {\n\t\tresp.Write(os.Stderr)\n\t\tt.Errorf(\"wanted %d, got: %d\", http.StatusFound, resp.StatusCode)\n\t}\n\n\tvar ckie *http.Cookie\n\tfor _, cookie := range resp.Cookies() {\n\t\tt.Logf(\"%#v\", cookie)\n\t\tif cookie.Name == anubis.CookieName {\n\t\t\tckie = cookie\n\t\t\tbreak\n\t\t}\n\t}\n\tif ckie == nil {\n\t\tt.Errorf(\"Cookie %q not found\", anubis.CookieName)\n\t\treturn\n\t}\n}\n\nfunc TestCookieSettings(t *testing.T) {\n\tpol := loadPolicies(t, \"testdata/zero_difficulty.yaml\", 0)\n\n\tsrv := spawnAnubis(t, Options{\n\t\tNext:   http.NewServeMux(),\n\t\tPolicy: pol,\n\n\t\tCookieDomain:      \"127.0.0.1\",\n\t\tCookiePartitioned: true,\n\t\tCookieSecure:      true,\n\t\tCookieSameSite:    http.SameSiteNoneMode,\n\t\tCookieExpiration:  anubis.CookieDefaultExpirationTime,\n\t})\n\n\tts := httptest.NewServer(internal.RemoteXRealIP(true, \"tcp\", srv))\n\tdefer ts.Close()\n\n\tcli := httpClient(t)\n\tchall := makeChallenge(t, ts, cli)\n\n\tresp := handleChallengeZeroDifficulty(t, ts, cli, chall)\n\n\tif resp.StatusCode != http.StatusFound {\n\t\tresp.Write(os.Stderr)\n\t\tt.Errorf(\"wanted %d, got: %d\", http.StatusFound, resp.StatusCode)\n\t}\n\n\tvar ckie *http.Cookie\n\tfor _, cookie := range resp.Cookies() {\n\t\tt.Logf(\"%#v\", cookie)\n\t\tif cookie.Name == anubis.CookieName {\n\t\t\tckie = cookie\n\t\t\tbreak\n\t\t}\n\t}\n\tif ckie == nil {\n\t\tt.Errorf(\"Cookie %q not found\", anubis.CookieName)\n\t\treturn\n\t}\n\n\tif ckie.Domain != \"127.0.0.1\" {\n\t\tt.Errorf(\"cookie domain is wrong, wanted 127.0.0.1, got: %s\", ckie.Domain)\n\t}\n\n\tif ckie.Partitioned != srv.opts.CookiePartitioned {\n\t\tt.Errorf(\"wanted partitioned flag %v, got: %v\", srv.opts.CookiePartitioned, ckie.Partitioned)\n\t}\n\n\tif ckie.Secure != srv.opts.CookieSecure {\n\t\tt.Errorf(\"wanted secure flag %v, got: %v\", srv.opts.CookieSecure, ckie.Secure)\n\t}\n\tif ckie.SameSite != srv.opts.CookieSameSite {\n\t\tt.Errorf(\"wanted same site option %v, got: %v\", srv.opts.CookieSameSite, ckie.SameSite)\n\t}\n}\n\nfunc TestCookieSettingsSameSiteNoneModeDowngradedToLaxWhenUnsecure(t *testing.T) {\n\tpol := loadPolicies(t, \"testdata/zero_difficulty.yaml\", 0)\n\n\tsrv := spawnAnubis(t, Options{\n\t\tNext:   http.NewServeMux(),\n\t\tPolicy: pol,\n\n\t\tCookieDomain:      \"127.0.0.1\",\n\t\tCookiePartitioned: true,\n\t\tCookieSecure:      false,\n\t\tCookieSameSite:    http.SameSiteNoneMode,\n\t\tCookieExpiration:  anubis.CookieDefaultExpirationTime,\n\t})\n\n\tts := httptest.NewServer(internal.RemoteXRealIP(true, \"tcp\", srv))\n\tdefer ts.Close()\n\n\tcli := httpClient(t)\n\tchall := makeChallenge(t, ts, cli)\n\n\tresp := handleChallengeZeroDifficulty(t, ts, cli, chall)\n\n\tif resp.StatusCode != http.StatusFound {\n\t\tresp.Write(os.Stderr)\n\t\tt.Errorf(\"wanted %d, got: %d\", http.StatusFound, resp.StatusCode)\n\t}\n\n\tvar ckie *http.Cookie\n\tfor _, cookie := range resp.Cookies() {\n\t\tt.Logf(\"%#v\", cookie)\n\t\tif cookie.Name == anubis.CookieName {\n\t\t\tckie = cookie\n\t\t\tbreak\n\t\t}\n\t}\n\tif ckie == nil {\n\t\tt.Errorf(\"Cookie %q not found\", anubis.CookieName)\n\t\treturn\n\t}\n\n\tif ckie.Domain != \"127.0.0.1\" {\n\t\tt.Errorf(\"cookie domain is wrong, wanted 127.0.0.1, got: %s\", ckie.Domain)\n\t}\n\n\tif ckie.Partitioned != srv.opts.CookiePartitioned {\n\t\tt.Errorf(\"wanted partitioned flag %v, got: %v\", srv.opts.CookiePartitioned, ckie.Partitioned)\n\t}\n\n\tif ckie.Secure != srv.opts.CookieSecure {\n\t\tt.Errorf(\"wanted secure flag %v, got: %v\", srv.opts.CookieSecure, ckie.Secure)\n\t}\n\tif ckie.SameSite != http.SameSiteLaxMode {\n\t\tt.Errorf(\"wanted same site Lax option %v, got: %v\", http.SameSiteLaxMode, ckie.SameSite)\n\t}\n}\n\nfunc TestCheckDefaultDifficultyMatchesPolicy(t *testing.T) {\n\th := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tfmt.Fprintln(w, \"OK\")\n\t})\n\n\tfor i := 1; i < 10; i++ {\n\t\tt.Run(fmt.Sprint(i), func(t *testing.T) {\n\t\t\tanubisPolicy := loadPolicies(t, \"testdata/test_config_no_thresholds.yaml\", i)\n\n\t\t\ts, err := New(Options{\n\t\t\t\tNext:           h,\n\t\t\t\tPolicy:         anubisPolicy,\n\t\t\t\tServeRobotsTXT: true,\n\t\t\t})\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"can't construct libanubis.Server: %v\", err)\n\t\t\t}\n\n\t\t\treq, err := http.NewRequest(http.MethodGet, \"/\", nil)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatal(err)\n\t\t\t}\n\n\t\t\treq.Header.Add(\"X-Real-Ip\", \"127.0.0.1\")\n\n\t\t\tcr, bot, err := s.check(req, s.logger)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatal(err)\n\t\t\t}\n\n\t\t\tt.Log(cr.Name)\n\n\t\t\tif bot.Challenge.Difficulty != i {\n\t\t\t\tt.Errorf(\"Challenge.Difficulty is wrong, wanted %d, got: %d\", i, bot.Challenge.Difficulty)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestBasePrefix(t *testing.T) {\n\th := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tfmt.Fprintln(w, \"OK\")\n\t})\n\n\ttestCases := []struct {\n\t\tname       string\n\t\tbasePrefix string\n\t\tpath       string\n\t\texpected   string\n\t}{\n\t\t{\n\t\t\tname:       \"no prefix\",\n\t\t\tbasePrefix: \"\",\n\t\t\tpath:       \"/.within.website/x/cmd/anubis/api/make-challenge\",\n\t\t\texpected:   \"/.within.website/x/cmd/anubis/api/make-challenge\",\n\t\t},\n\t\t{\n\t\t\tname:       \"with prefix\",\n\t\t\tbasePrefix: \"/myapp\",\n\t\t\tpath:       \"/myapp/.within.website/x/cmd/anubis/api/make-challenge\",\n\t\t\texpected:   \"/myapp/.within.website/x/cmd/anubis/api/make-challenge\",\n\t\t},\n\t\t{\n\t\t\tname:       \"with prefix and trailing slash\",\n\t\t\tbasePrefix: \"/myapp/\",\n\t\t\tpath:       \"/myapp/.within.website/x/cmd/anubis/api/make-challenge\",\n\t\t\texpected:   \"/myapp/.within.website/x/cmd/anubis/api/make-challenge\",\n\t\t},\n\t}\n\n\tfor _, tc := range testCases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\t// Reset the global BasePrefix before each test\n\t\t\tanubis.BasePrefix = \"\"\n\n\t\t\tpol := loadPolicies(t, \"\", 4)\n\n\t\t\tsrv := spawnAnubis(t, Options{\n\t\t\t\tNext:       h,\n\t\t\t\tPolicy:     pol,\n\t\t\t\tBasePrefix: tc.basePrefix,\n\t\t\t})\n\n\t\t\tts := httptest.NewServer(internal.RemoteXRealIP(true, \"tcp\", srv))\n\t\t\tdefer ts.Close()\n\n\t\t\tcli := httpClient(t)\n\n\t\t\treq, err := http.NewRequest(http.MethodPost, ts.URL+tc.path, nil)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatal(err)\n\t\t\t}\n\n\t\t\tq := req.URL.Query()\n\t\t\tredir := tc.basePrefix\n\t\t\tif tc.basePrefix == \"\" {\n\t\t\t\tredir = \"/\"\n\t\t\t}\n\t\t\tq.Set(\"redir\", redir)\n\t\t\treq.URL.RawQuery = q.Encode()\n\n\t\t\tt.Log(req.URL.String())\n\n\t\t\t// Test API endpoint with prefix\n\t\t\tresp, err := cli.Do(req)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"can't request challenge: %v\", err)\n\t\t\t}\n\t\t\tdefer resp.Body.Close()\n\n\t\t\tif resp.StatusCode != http.StatusOK {\n\t\t\t\tt.Errorf(\"expected status code %d, got: %d\", http.StatusOK, resp.StatusCode)\n\t\t\t}\n\n\t\t\tdata, err := io.ReadAll(resp.Body)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"can't read body: %v\", err)\n\t\t\t}\n\n\t\t\tt.Log(string(data))\n\n\t\t\tvar chall challengeResp\n\t\t\tif err := json.NewDecoder(bytes.NewBuffer(data)).Decode(&chall); err != nil {\n\t\t\t\tt.Fatalf(\"can't read challenge response body: %v\", err)\n\t\t\t}\n\n\t\t\tif chall.Challenge == \"\" {\n\t\t\t\tt.Errorf(\"expected non-empty challenge\")\n\t\t\t}\n\n\t\t\t// Test cookie path when passing challenge\n\t\t\t// Find a nonce that produces a hash with the required number of leading zeros\n\t\t\tnonce := 0\n\t\t\tvar calculated string\n\t\t\tfor {\n\t\t\t\tcalcString := fmt.Sprintf(\"%s%d\", chall.Challenge, nonce)\n\t\t\t\tcalculated = internal.SHA256sum(calcString)\n\t\t\t\tif strings.HasPrefix(calculated, strings.Repeat(\"0\", pol.DefaultDifficulty)) {\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t\tnonce++\n\t\t\t}\n\t\t\telapsedTime := 420\n\t\t\tredir = \"/\"\n\n\t\t\tcli.CheckRedirect = func(req *http.Request, via []*http.Request) error {\n\t\t\t\treturn http.ErrUseLastResponse\n\t\t\t}\n\n\t\t\t// Construct the correct path for pass-challenge\n\t\t\tpassChallengePath := tc.path\n\t\t\tpassChallengePath = passChallengePath[:strings.LastIndex(passChallengePath, \"/\")+1] + \"pass-challenge\"\n\n\t\t\treq, err = http.NewRequest(http.MethodGet, ts.URL+passChallengePath, nil)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"can't make request: %v\", err)\n\t\t\t}\n\n\t\t\tfor _, ckie := range resp.Cookies() {\n\t\t\t\treq.AddCookie(ckie)\n\t\t\t}\n\n\t\t\tq = req.URL.Query()\n\t\t\tq.Set(\"response\", calculated)\n\t\t\tq.Set(\"nonce\", fmt.Sprint(nonce))\n\t\t\tq.Set(\"redir\", redir)\n\t\t\tq.Set(\"elapsedTime\", fmt.Sprint(elapsedTime))\n\t\t\tq.Set(\"id\", chall.ID)\n\t\t\treq.URL.RawQuery = q.Encode()\n\n\t\t\tt.Log(req.URL.String())\n\n\t\t\tresp, err = cli.Do(req)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"can't do challenge passing: %v\", err)\n\t\t\t}\n\n\t\t\tif resp.StatusCode != http.StatusFound {\n\t\t\t\tt.Errorf(\"wanted %d, got: %d\", http.StatusFound, resp.StatusCode)\n\t\t\t}\n\n\t\t\t// Check cookie path\n\t\t\tvar ckie *http.Cookie\n\t\t\tfor _, cookie := range resp.Cookies() {\n\t\t\t\tif cookie.Name == anubis.CookieName {\n\t\t\t\t\tckie = cookie\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t\tif ckie == nil {\n\t\t\t\tt.Errorf(\"Cookie %q not found\", anubis.CookieName)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\texpectedPath := \"/\"\n\t\t\tif tc.basePrefix != \"\" {\n\t\t\t\texpectedPath = strings.TrimSuffix(tc.basePrefix, \"/\") + \"/\"\n\t\t\t}\n\n\t\t\tif ckie.Path != expectedPath {\n\t\t\t\tt.Errorf(\"cookie path is wrong, wanted %s, got: %s\", expectedPath, ckie.Path)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestCustomStatusCodes(t *testing.T) {\n\th := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tt.Log(r.UserAgent())\n\t\tw.WriteHeader(http.StatusOK)\n\t\tfmt.Fprintln(w, \"OK\")\n\t})\n\n\tstatusMap := map[string]int{\n\t\t\"ALLOW\":     200,\n\t\t\"CHALLENGE\": 401,\n\t\t\"DENY\":      403,\n\t}\n\n\tpol := loadPolicies(t, \"./testdata/aggressive_403.yaml\", 4)\n\n\tsrv := spawnAnubis(t, Options{\n\t\tNext:   h,\n\t\tPolicy: pol,\n\t})\n\n\tts := httptest.NewServer(internal.RemoteXRealIP(true, \"tcp\", srv))\n\tdefer ts.Close()\n\n\tfor userAgent, statusCode := range statusMap {\n\t\tt.Run(userAgent, func(t *testing.T) {\n\t\t\treq, err := http.NewRequestWithContext(t.Context(), http.MethodGet, ts.URL, nil)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatal(err)\n\t\t\t}\n\n\t\t\treq.Header.Set(\"User-Agent\", userAgent)\n\n\t\t\tresp, err := ts.Client().Do(req)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatal(err)\n\t\t\t}\n\n\t\t\tif resp.StatusCode != statusCode {\n\t\t\t\tt.Errorf(\"wanted status code %d but got: %d\", statusCode, resp.StatusCode)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestCloudflareWorkersRule(t *testing.T) {\n\tfor _, variant := range []string{\"cel\", \"header\"} {\n\t\tt.Run(variant, func(t *testing.T) {\n\t\t\tpol := loadPolicies(t, \"./testdata/cloudflare-workers-\"+variant+\".yaml\", 0)\n\n\t\t\th := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tfmt.Fprintln(w, \"OK\")\n\t\t\t})\n\n\t\t\ts, err := New(Options{\n\t\t\t\tNext:           h,\n\t\t\t\tPolicy:         pol,\n\t\t\t\tServeRobotsTXT: true,\n\t\t\t})\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"can't construct libanubis.Server: %v\", err)\n\t\t\t}\n\n\t\t\tt.Run(\"with-cf-worker-header\", func(t *testing.T) {\n\t\t\t\treq, err := http.NewRequest(http.MethodGet, \"/\", nil)\n\t\t\t\tif err != nil {\n\t\t\t\t\tt.Fatal(err)\n\t\t\t\t}\n\n\t\t\t\treq.Header.Add(\"X-Real-Ip\", \"127.0.0.1\")\n\t\t\t\treq.Header.Add(\"Cf-Worker\", \"true\")\n\n\t\t\t\tcr, _, err := s.check(req, s.logger)\n\t\t\t\tif err != nil {\n\t\t\t\t\tt.Fatal(err)\n\t\t\t\t}\n\n\t\t\t\tif cr.Rule != config.RuleDeny {\n\t\t\t\t\tt.Errorf(\"rule is wrong, wanted %s, got: %s\", config.RuleDeny, cr.Rule)\n\t\t\t\t}\n\t\t\t})\n\n\t\t\tt.Run(\"no-cf-worker-header\", func(t *testing.T) {\n\t\t\t\treq, err := http.NewRequest(http.MethodGet, \"/\", nil)\n\t\t\t\tif err != nil {\n\t\t\t\t\tt.Fatal(err)\n\t\t\t\t}\n\n\t\t\t\treq.Header.Add(\"X-Real-Ip\", \"127.0.0.1\")\n\n\t\t\t\tcr, _, err := s.check(req, s.logger)\n\t\t\t\tif err != nil {\n\t\t\t\t\tt.Fatal(err)\n\t\t\t\t}\n\n\t\t\t\tif cr.Rule != config.RuleAllow {\n\t\t\t\t\tt.Errorf(\"rule is wrong, wanted %s, got: %s\", config.RuleAllow, cr.Rule)\n\t\t\t\t}\n\t\t\t})\n\t\t})\n\t}\n}\n\nfunc TestRuleChange(t *testing.T) {\n\tpol := loadPolicies(t, \"testdata/rule_change.yaml\", 0)\n\tckieExpiration := 10 * time.Minute\n\n\tsrv := spawnAnubis(t, Options{\n\t\tNext:   http.NewServeMux(),\n\t\tPolicy: pol,\n\n\t\tCookieDomain:     \"127.0.0.1\",\n\t\tCookieExpiration: ckieExpiration,\n\t})\n\n\tts := httptest.NewServer(internal.RemoteXRealIP(true, \"tcp\", srv))\n\tdefer ts.Close()\n\n\tcli := httpClient(t)\n\n\tchall := makeChallenge(t, ts, cli)\n\tresp := handleChallengeZeroDifficulty(t, ts, cli, chall)\n\n\tif resp.StatusCode != http.StatusFound {\n\t\tresp.Write(os.Stderr)\n\t\tt.Errorf(\"wanted %d, got: %d\", http.StatusFound, resp.StatusCode)\n\t}\n}\n\nfunc TestStripBasePrefixFromRequest(t *testing.T) {\n\ttestCases := []struct {\n\t\tname            string\n\t\tbasePrefix      string\n\t\trequestPath     string\n\t\texpectedPath    string\n\t\tstripBasePrefix bool\n\t}{\n\t\t{\n\t\t\tname:            \"strip disabled - no change\",\n\t\t\tbasePrefix:      \"/foo\",\n\t\t\tstripBasePrefix: false,\n\t\t\trequestPath:     \"/foo/bar\",\n\t\t\texpectedPath:    \"/foo/bar\",\n\t\t},\n\t\t{\n\t\t\tname:            \"strip enabled - removes prefix\",\n\t\t\tbasePrefix:      \"/foo\",\n\t\t\tstripBasePrefix: true,\n\t\t\trequestPath:     \"/foo/bar\",\n\t\t\texpectedPath:    \"/bar\",\n\t\t},\n\t\t{\n\t\t\tname:            \"strip enabled - root becomes slash\",\n\t\t\tbasePrefix:      \"/foo\",\n\t\t\tstripBasePrefix: true,\n\t\t\trequestPath:     \"/foo\",\n\t\t\texpectedPath:    \"/\",\n\t\t},\n\t\t{\n\t\t\tname:            \"strip enabled - trailing slash on base prefix\",\n\t\t\tbasePrefix:      \"/foo/\",\n\t\t\tstripBasePrefix: true,\n\t\t\trequestPath:     \"/foo/bar\",\n\t\t\texpectedPath:    \"/bar\",\n\t\t},\n\t\t{\n\t\t\tname:            \"strip enabled - no prefix match\",\n\t\t\tbasePrefix:      \"/foo\",\n\t\t\tstripBasePrefix: true,\n\t\t\trequestPath:     \"/other/bar\",\n\t\t\texpectedPath:    \"/other/bar\",\n\t\t},\n\t\t{\n\t\t\tname:            \"strip enabled - empty base prefix\",\n\t\t\tbasePrefix:      \"\",\n\t\t\tstripBasePrefix: true,\n\t\t\trequestPath:     \"/foo/bar\",\n\t\t\texpectedPath:    \"/foo/bar\",\n\t\t},\n\t\t{\n\t\t\tname:            \"strip enabled - nested path\",\n\t\t\tbasePrefix:      \"/app\",\n\t\t\tstripBasePrefix: true,\n\t\t\trequestPath:     \"/app/api/v1/users\",\n\t\t\texpectedPath:    \"/api/v1/users\",\n\t\t},\n\t\t{\n\t\t\tname:            \"strip enabled - exact match becomes root\",\n\t\t\tbasePrefix:      \"/myapp\",\n\t\t\tstripBasePrefix: true,\n\t\t\trequestPath:     \"/myapp/\",\n\t\t\texpectedPath:    \"/\",\n\t\t},\n\t}\n\n\tfor _, tc := range testCases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tsrv := &Server{\n\t\t\t\topts: Options{\n\t\t\t\t\tBasePrefix:      tc.basePrefix,\n\t\t\t\t\tStripBasePrefix: tc.stripBasePrefix,\n\t\t\t\t},\n\t\t\t}\n\n\t\t\treq := httptest.NewRequest(http.MethodGet, tc.requestPath, nil)\n\t\t\toriginalPath := req.URL.Path\n\n\t\t\tresult := srv.stripBasePrefixFromRequest(req)\n\n\t\t\tif result.URL.Path != tc.expectedPath {\n\t\t\t\tt.Errorf(\"expected path %q, got %q\", tc.expectedPath, result.URL.Path)\n\t\t\t}\n\n\t\t\t// Ensure original request is not modified when no stripping should occur\n\t\t\tif !tc.stripBasePrefix || tc.basePrefix == \"\" || !strings.HasPrefix(tc.requestPath, strings.TrimSuffix(tc.basePrefix, \"/\")) {\n\t\t\t\tif result != req {\n\t\t\t\t\tt.Error(\"expected same request object when no modification needed\")\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// Ensure original request is not modified when stripping occurs\n\t\t\t\tif req.URL.Path != originalPath {\n\t\t\t\t\tt.Error(\"original request was modified\")\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestChallengeFor_ErrNotFound makes sure that users with invalid challenge IDs\n// in the test cookie don't get rejected by the database lookup failing.\nfunc TestChallengeFor_ErrNotFound(t *testing.T) {\n\tpol := loadPolicies(t, \"testdata/aggressive_403.yaml\", 0)\n\tckieExpiration := 10 * time.Minute\n\tconst wrongCookie = \"wrong cookie\"\n\n\tsrv := spawnAnubis(t, Options{\n\t\tNext:   http.NewServeMux(),\n\t\tPolicy: pol,\n\n\t\tCookieDomain:     \"127.0.0.1\",\n\t\tCookieExpiration: ckieExpiration,\n\t})\n\n\treq := httptest.NewRequest(\"GET\", \"http://example.com/\", nil)\n\treq.Header.Set(\"X-Real-IP\", \"127.0.0.1\")\n\treq.Header.Set(\"User-Agent\", \"CHALLENGE\")\n\treq.AddCookie(&http.Cookie{Name: anubis.TestCookieName, Value: wrongCookie})\n\n\tw := httptest.NewRecorder()\n\tsrv.maybeReverseProxyOrPage(w, req)\n\n\tresp := w.Result()\n\tdefer resp.Body.Close()\n\n\tbody := new(strings.Builder)\n\t_, err := io.Copy(body, resp.Body)\n\tif err != nil {\n\t\tt.Fatalf(\"reading body should not fail: %v\", err)\n\t}\n\n\tt.Run(\"make sure challenge page is issued\", func(t *testing.T) {\n\t\tif !strings.Contains(body.String(), \"anubis_challenge\") {\n\t\t\tt.Error(\"should get a challenge page\")\n\t\t}\n\n\t\tif resp.StatusCode != http.StatusUnauthorized {\n\t\t\tt.Errorf(\"should get a 401 Unauthorized, got: %d\", resp.StatusCode)\n\t\t}\n\t})\n\n\tt.Run(\"make sure that the body is not an error page\", func(t *testing.T) {\n\t\tif strings.Contains(body.String(), \"reject.webp\") {\n\t\t\tt.Error(\"should not get an internal server error\")\n\t\t}\n\t})\n\n\tt.Run(\"make sure new test cookie is issued\", func(t *testing.T) {\n\t\tfound := false\n\t\tfor _, cookie := range resp.Cookies() {\n\t\t\tif cookie.Name == anubis.TestCookieName {\n\t\t\t\tif cookie.Value == wrongCookie {\n\t\t\t\t\tt.Error(\"a new challenge cookie should be issued\")\n\t\t\t\t}\n\t\t\t\tfound = true\n\t\t\t}\n\t\t}\n\t\tif !found {\n\t\t\tt.Error(\"a new test cookie should be set\")\n\t\t}\n\t})\n}\n\nfunc TestPassChallengeXSS(t *testing.T) {\n\tpol := loadPolicies(t, \"\", anubis.DefaultDifficulty)\n\n\tsrv := spawnAnubis(t, Options{\n\t\tNext:   http.NewServeMux(),\n\t\tPolicy: pol,\n\t})\n\n\tts := httptest.NewServer(internal.RemoteXRealIP(true, \"tcp\", srv))\n\tdefer ts.Close()\n\n\tcli := httpClient(t)\n\tchall := makeChallenge(t, ts, cli)\n\n\ttestCases := []struct {\n\t\tname  string\n\t\tredir string\n\t}{\n\t\t{\n\t\t\tname:  \"javascript alert\",\n\t\t\tredir: \"javascript:alert('xss')\",\n\t\t},\n\t\t{\n\t\t\tname:  \"vbscript\",\n\t\t\tredir: \"vbscript:msgbox(\\\"XSS\\\")\",\n\t\t},\n\t\t{\n\t\t\tname:  \"data url\",\n\t\t\tredir: \"data:text/html;base64,PHNjcmlwdD5hbGVydCgneHNzJyk8L3NjcmlwdD4=\",\n\t\t},\n\t}\n\n\tt.Run(\"with test cookie\", func(t *testing.T) {\n\t\tfor _, tc := range testCases {\n\t\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\t\tnonce := 0\n\t\t\t\telapsedTime := 420\n\t\t\t\tcalculated := \"\"\n\t\t\t\tcalcString := fmt.Sprintf(\"%s%d\", chall.Challenge, nonce)\n\t\t\t\tcalculated = internal.SHA256sum(calcString)\n\n\t\t\t\treq, err := http.NewRequest(http.MethodGet, ts.URL+\"/.within.website/x/cmd/anubis/api/pass-challenge\", nil)\n\t\t\t\tif err != nil {\n\t\t\t\t\tt.Fatalf(\"can't make request: %v\", err)\n\t\t\t\t}\n\n\t\t\t\tq := req.URL.Query()\n\t\t\t\tq.Set(\"response\", calculated)\n\t\t\t\tq.Set(\"nonce\", fmt.Sprint(nonce))\n\t\t\t\tq.Set(\"redir\", tc.redir)\n\t\t\t\tq.Set(\"elapsedTime\", fmt.Sprint(elapsedTime))\n\t\t\t\treq.URL.RawQuery = q.Encode()\n\n\t\t\t\tu, err := url.Parse(ts.URL)\n\t\t\t\tif err != nil {\n\t\t\t\t\tt.Fatal(err)\n\t\t\t\t}\n\n\t\t\t\tfor _, ckie := range cli.Jar.Cookies(u) {\n\t\t\t\t\tif ckie.Name == anubis.TestCookieName {\n\t\t\t\t\t\treq.AddCookie(ckie)\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tresp, err := cli.Do(req)\n\t\t\t\tif err != nil {\n\t\t\t\t\tt.Fatalf(\"can't do request: %v\", err)\n\t\t\t\t}\n\n\t\t\t\tbody, _ := io.ReadAll(resp.Body)\n\n\t\t\t\tif bytes.Contains(body, []byte(tc.redir)) {\n\t\t\t\t\tt.Log(string(body))\n\t\t\t\t\tt.Error(\"found XSS in HTML body\")\n\t\t\t\t}\n\n\t\t\t\tif resp.StatusCode != http.StatusBadRequest {\n\t\t\t\t\tt.Errorf(\"wanted status %d, got %d. body: %s\", http.StatusBadRequest, resp.StatusCode, body)\n\t\t\t\t}\n\t\t\t})\n\t\t}\n\t})\n\n\tt.Run(\"no test cookie\", func(t *testing.T) {\n\t\tfor _, tc := range testCases {\n\t\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\t\tnonce := 0\n\t\t\t\telapsedTime := 420\n\t\t\t\tcalculated := \"\"\n\t\t\t\tcalcString := fmt.Sprintf(\"%s%d\", chall.Challenge, nonce)\n\t\t\t\tcalculated = internal.SHA256sum(calcString)\n\n\t\t\t\treq, err := http.NewRequest(http.MethodGet, ts.URL+\"/.within.website/x/cmd/anubis/api/pass-challenge\", nil)\n\t\t\t\tif err != nil {\n\t\t\t\t\tt.Fatalf(\"can't make request: %v\", err)\n\t\t\t\t}\n\n\t\t\t\tq := req.URL.Query()\n\t\t\t\tq.Set(\"response\", calculated)\n\t\t\t\tq.Set(\"nonce\", fmt.Sprint(nonce))\n\t\t\t\tq.Set(\"redir\", tc.redir)\n\t\t\t\tq.Set(\"elapsedTime\", fmt.Sprint(elapsedTime))\n\t\t\t\treq.URL.RawQuery = q.Encode()\n\n\t\t\t\tresp, err := cli.Do(req)\n\t\t\t\tif err != nil {\n\t\t\t\t\tt.Fatalf(\"can't do request: %v\", err)\n\t\t\t\t}\n\n\t\t\t\tbody, _ := io.ReadAll(resp.Body)\n\n\t\t\t\tif bytes.Contains(body, []byte(tc.redir)) {\n\t\t\t\t\tt.Log(string(body))\n\t\t\t\t\tt.Error(\"found XSS in HTML body\")\n\t\t\t\t}\n\n\t\t\t\tif resp.StatusCode != http.StatusBadRequest {\n\t\t\t\t\tt.Errorf(\"wanted status %d, got %d. body: %s\", http.StatusBadRequest, resp.StatusCode, body)\n\t\t\t\t}\n\t\t\t})\n\t\t}\n\t})\n}\n\nfunc TestPassChallengeNilRuleChallengeFallback(t *testing.T) {\n\tpol := loadPolicies(t, \"testdata/zero_difficulty.yaml\", 0)\n\n\tsrv := spawnAnubis(t, Options{\n\t\tNext:   http.NewServeMux(),\n\t\tPolicy: pol,\n\t})\n\n\tallowThreshold, err := policy.ParsedThresholdFromConfig(config.Threshold{\n\t\tName: \"allow-all\",\n\t\tExpression: &config.ExpressionOrList{\n\t\t\tExpression: \"true\",\n\t\t},\n\t\tAction: config.RuleAllow,\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"can't compile test threshold: %v\", err)\n\t}\n\tsrv.policy.Thresholds = []*policy.Threshold{allowThreshold}\n\tsrv.policy.Bots = nil\n\n\tchall := challenge.Challenge{\n\t\tID:         \"test-challenge\",\n\t\tMethod:     \"metarefresh\",\n\t\tRandomData: \"apple cider\",\n\t\tIssuedAt:   time.Now().Add(-5 * time.Second),\n\t\tDifficulty: 1,\n\t}\n\n\tj := store.JSON[challenge.Challenge]{Underlying: srv.store}\n\tif err := j.Set(context.Background(), \"challenge:\"+chall.ID, chall, time.Minute); err != nil {\n\t\tt.Fatalf(\"can't insert challenge into store: %v\", err)\n\t}\n\n\treq := httptest.NewRequest(http.MethodGet, \"https://example.com\"+anubis.APIPrefix+\"pass-challenge\", nil)\n\tq := req.URL.Query()\n\tq.Set(\"redir\", \"/\")\n\tq.Set(\"id\", chall.ID)\n\tq.Set(\"challenge\", chall.RandomData)\n\treq.URL.RawQuery = q.Encode()\n\treq.Header.Set(\"X-Real-Ip\", \"203.0.113.4\")\n\treq.Header.Set(\"User-Agent\", \"NilChallengeTester/1.0\")\n\treq.AddCookie(&http.Cookie{Name: anubis.TestCookieName, Value: chall.ID})\n\n\trr := httptest.NewRecorder()\n\n\tsrv.PassChallenge(rr, req)\n\n\tif rr.Code != http.StatusFound {\n\t\tt.Fatalf(\"expected redirect when validating challenge, got %d\", rr.Code)\n\t}\n}\n\nfunc TestXForwardedForNoDoubleComma(t *testing.T) {\n\tvar h http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.Header().Set(\"X-Forwarded-For\", r.Header.Get(\"X-Forwarded-For\"))\n\t\tfmt.Fprintln(w, \"OK\")\n\t})\n\n\th = internal.XForwardedForToXRealIP(h)\n\th = internal.XForwardedForUpdate(false, h)\n\n\tpol := loadPolicies(t, \"testdata/permissive.yaml\", 4)\n\n\tsrv := spawnAnubis(t, Options{\n\t\tNext:   h,\n\t\tPolicy: pol,\n\t})\n\tts := httptest.NewServer(srv)\n\tt.Cleanup(ts.Close)\n\n\treq, err := http.NewRequest(http.MethodGet, ts.URL, nil)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\treq.Header.Set(\"X-Real-Ip\", \"10.0.0.1\")\n\n\tresp, err := ts.Client().Do(req)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif resp.StatusCode != http.StatusOK {\n\t\tt.Errorf(\"response status is wrong, wanted %d but got: %s\", http.StatusOK, resp.Status)\n\t}\n\n\tif xff := resp.Header.Get(\"X-Forwarded-For\"); strings.HasPrefix(xff, \",,\") {\n\t\tt.Errorf(\"X-Forwarded-For has two leading commas: %q\", xff)\n\t}\n}\n"
  },
  {
    "path": "lib/challenge/challenge.go",
    "content": "package challenge\n\nimport \"time\"\n\n// Challenge is the metadata about a single challenge issuance.\ntype Challenge struct {\n\tIssuedAt       time.Time         `json:\"issuedAt\"`                 // When the challenge was issued\n\tMetadata       map[string]string `json:\"metadata\"`                 // Challenge metadata such as IP address and user agent\n\tID             string            `json:\"id\"`                       // UUID identifying the challenge\n\tMethod         string            `json:\"method\"`                   // Challenge method\n\tRandomData     string            `json:\"randomData\"`               // The random data the client processes\n\tPolicyRuleHash string            `json:\"policyRuleHash,omitempty\"` // Hash of the policy rule that issued this challenge\n\tDifficulty     int               `json:\"difficulty,omitempty\"`     // Difficulty that was in effect when issued\n\tSpent          bool              `json:\"spent\"`                    // Has the challenge already been solved?\n}\n"
  },
  {
    "path": "lib/challenge/challengetest/challengetest.go",
    "content": "package challengetest\n\nimport (\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/TecharoHQ/anubis\"\n\t\"github.com/TecharoHQ/anubis/internal\"\n\t\"github.com/TecharoHQ/anubis/lib/challenge\"\n\t\"github.com/google/uuid\"\n)\n\nfunc New(t *testing.T) *challenge.Challenge {\n\tt.Helper()\n\n\tid := uuid.Must(uuid.NewV7())\n\trandomData := internal.SHA256sum(time.Now().String())\n\n\treturn &challenge.Challenge{\n\t\tID:         id.String(),\n\t\tRandomData: randomData,\n\t\tIssuedAt:   time.Now(),\n\t\tDifficulty: anubis.DefaultDifficulty,\n\t}\n}\n"
  },
  {
    "path": "lib/challenge/challengetest/challengetest_test.go",
    "content": "package challengetest\n\nimport \"testing\"\n\nfunc TestNew(t *testing.T) {\n\t_ = New(t)\n}\n"
  },
  {
    "path": "lib/challenge/error.go",
    "content": "package challenge\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n)\n\nvar (\n\tErrFailed        = errors.New(\"challenge: user failed challenge\")\n\tErrMissingField  = errors.New(\"challenge: missing field\")\n\tErrInvalidFormat = errors.New(\"challenge: field has invalid format\")\n\tErrInvalidInput  = errors.New(\"challenge: input is nil or missing required fields\")\n)\n\nfunc NewError(verb, publicReason string, privateReason error) *Error {\n\treturn &Error{\n\t\tVerb:          verb,\n\t\tPublicReason:  publicReason,\n\t\tPrivateReason: privateReason,\n\t\tStatusCode:    http.StatusForbidden,\n\t}\n}\n\ntype Error struct {\n\tPrivateReason error\n\tVerb          string\n\tPublicReason  string\n\tStatusCode    int\n}\n\nfunc (e *Error) Error() string {\n\treturn fmt.Sprintf(\"challenge: error when processing challenge: %s: %v\", e.Verb, e.PrivateReason)\n}\n\nfunc (e *Error) Unwrap() error {\n\treturn e.PrivateReason\n}\n"
  },
  {
    "path": "lib/challenge/interface.go",
    "content": "package challenge\n\nimport (\n\t\"fmt\"\n\t\"log/slog\"\n\t\"net/http\"\n\t\"sort\"\n\t\"sync\"\n\n\t\"github.com/TecharoHQ/anubis/lib/config\"\n\t\"github.com/TecharoHQ/anubis/lib/policy\"\n\t\"github.com/TecharoHQ/anubis/lib/store\"\n\t\"github.com/a-h/templ\"\n)\n\nvar (\n\tregistry map[string]Impl = map[string]Impl{}\n\tregLock  sync.RWMutex\n)\n\nfunc Register(name string, impl Impl) {\n\tregLock.Lock()\n\tdefer regLock.Unlock()\n\n\tregistry[name] = impl\n}\n\nfunc Get(name string) (Impl, bool) {\n\tregLock.RLock()\n\tdefer regLock.RUnlock()\n\tresult, ok := registry[name]\n\treturn result, ok\n}\n\nfunc Methods() []string {\n\tregLock.RLock()\n\tdefer regLock.RUnlock()\n\tvar result []string\n\tfor method := range registry {\n\t\tresult = append(result, method)\n\t}\n\tsort.Strings(result)\n\treturn result\n}\n\ntype IssueInput struct {\n\tImpressum *config.Impressum\n\tRule      *policy.Bot\n\tChallenge *Challenge\n\tOGTags    map[string]string\n\tStore     store.Interface\n}\n\nfunc (in *IssueInput) Valid() error {\n\tif in == nil {\n\t\treturn fmt.Errorf(\"%w: IssueInput is nil\", ErrInvalidInput)\n\t}\n\tif in.Rule == nil {\n\t\treturn fmt.Errorf(\"%w: Rule is nil\", ErrInvalidInput)\n\t}\n\tif in.Rule.Challenge == nil {\n\t\treturn fmt.Errorf(\"%w: Rule.Challenge is nil\", ErrInvalidInput)\n\t}\n\tif in.Challenge == nil {\n\t\treturn fmt.Errorf(\"%w: Challenge is nil\", ErrInvalidInput)\n\t}\n\treturn nil\n}\n\ntype ValidateInput struct {\n\tRule      *policy.Bot\n\tChallenge *Challenge\n\tStore     store.Interface\n}\n\nfunc (in *ValidateInput) Valid() error {\n\tif in == nil {\n\t\treturn fmt.Errorf(\"%w: ValidateInput is nil\", ErrInvalidInput)\n\t}\n\tif in.Rule == nil {\n\t\treturn fmt.Errorf(\"%w: Rule is nil\", ErrInvalidInput)\n\t}\n\tif in.Rule.Challenge == nil {\n\t\treturn fmt.Errorf(\"%w: Rule.Challenge is nil\", ErrInvalidInput)\n\t}\n\tif in.Challenge == nil {\n\t\treturn fmt.Errorf(\"%w: Challenge is nil\", ErrInvalidInput)\n\t}\n\treturn nil\n}\n\ntype Impl interface {\n\t// Setup registers any additional routes with the Impl for assets or API routes.\n\tSetup(mux *http.ServeMux)\n\n\t// Issue a new challenge to the user, called by the Anubis.\n\tIssue(w http.ResponseWriter, r *http.Request, lg *slog.Logger, in *IssueInput) (templ.Component, error)\n\n\t// Validate a challenge, making sure that it passes muster.\n\tValidate(r *http.Request, lg *slog.Logger, in *ValidateInput) error\n}\n"
  },
  {
    "path": "lib/challenge/metarefresh/metarefresh.go",
    "content": "package metarefresh\n\nimport (\n\t\"crypto/subtle\"\n\t\"fmt\"\n\t\"log/slog\"\n\t\"net/http\"\n\t\"time\"\n\n\t\"github.com/TecharoHQ/anubis\"\n\t\"github.com/TecharoHQ/anubis/lib/challenge\"\n\t\"github.com/TecharoHQ/anubis/lib/localization\"\n\t\"github.com/a-h/templ\"\n)\n\n//go:generate go tool github.com/a-h/templ/cmd/templ generate\n\nfunc init() {\n\tchallenge.Register(\"metarefresh\", &Impl{})\n}\n\ntype Impl struct{}\n\nfunc (i *Impl) Setup(mux *http.ServeMux) {}\n\nfunc (i *Impl) Issue(w http.ResponseWriter, r *http.Request, lg *slog.Logger, in *challenge.IssueInput) (templ.Component, error) {\n\tif err := in.Valid(); err != nil {\n\t\treturn nil, err\n\t}\n\n\tu, err := r.URL.Parse(anubis.BasePrefix + \"/.within.website/x/cmd/anubis/api/pass-challenge\")\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"can't render page: %w\", err)\n\t}\n\n\tq := u.Query()\n\tq.Set(\"redir\", r.URL.String())\n\tq.Set(\"challenge\", in.Challenge.RandomData)\n\tq.Set(\"id\", in.Challenge.ID)\n\tu.RawQuery = q.Encode()\n\n\tshowMeta := in.Challenge.RandomData[0]%2 == 0\n\n\tif !showMeta {\n\t\tw.Header().Add(\"Refresh\", fmt.Sprintf(\"%d; url=%s\", in.Rule.Challenge.Difficulty+1, u.String()))\n\t}\n\n\tloc := localization.GetLocalizer(r)\n\n\tresult := page(u.String(), in.Rule.Challenge.Difficulty, showMeta, loc)\n\n\treturn result, nil\n}\n\nfunc (i *Impl) Validate(r *http.Request, lg *slog.Logger, in *challenge.ValidateInput) error {\n\tif err := in.Valid(); err != nil {\n\t\treturn challenge.NewError(\"validate\", \"invalid input\", err)\n\t}\n\n\twantTime := in.Challenge.IssuedAt.Add(time.Duration(in.Rule.Challenge.Difficulty) * 800 * time.Millisecond)\n\n\tif time.Now().Before(wantTime) {\n\t\treturn challenge.NewError(\"validate\", \"insufficient time\", fmt.Errorf(\"%w: wanted user to wait until at least %s\", challenge.ErrFailed, wantTime.Format(time.RFC3339)))\n\t}\n\n\tgotChallenge := r.FormValue(\"challenge\")\n\n\tif subtle.ConstantTimeCompare([]byte(in.Challenge.RandomData), []byte(gotChallenge)) != 1 {\n\t\treturn challenge.NewError(\"validate\", \"invalid response\", fmt.Errorf(\"%w: wanted response %s but got %s\", challenge.ErrFailed, in.Challenge.RandomData, gotChallenge))\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "lib/challenge/metarefresh/metarefresh.templ",
    "content": "package metarefresh\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/TecharoHQ/anubis\"\n\t\"github.com/TecharoHQ/anubis/lib/localization\"\n)\n\ntempl page(redir string, difficulty int, showMeta bool, loc *localization.SimpleLocalizer) {\n\t<div class=\"centered-div\">\n\t\t<img id=\"image\" style=\"width:100%;max-width:256px;\" src={ anubis.BasePrefix + \"/.within.website/x/cmd/anubis/static/img/pensive.webp?cacheBuster=\" + anubis.Version }/>\n\t\t<img style=\"display:none;\" style=\"width:100%;max-width:256px;\" src={ anubis.BasePrefix + \"/.within.website/x/cmd/anubis/static/img/happy.webp?cacheBuster=\" + anubis.Version }/>\n\t\t<p id=\"status\">{ loc.T(\"loading\") }</p>\n\t\t<p>{ loc.T(\"connection_security\") }</p>\n\t\tif showMeta {\n\t\t\t<meta http-equiv=\"refresh\" content={ fmt.Sprintf(\"%d; url=%s\", difficulty+1, redir) }/>\n\t\t}\n\t</div>\n}\n"
  },
  {
    "path": "lib/challenge/metarefresh/metarefresh_templ.go",
    "content": "// Code generated by templ - DO NOT EDIT.\n\n// templ: version: v0.3.960\npackage metarefresh\n\n//lint:file-ignore SA4006 This context is only used if a nested component is present.\n\nimport \"github.com/a-h/templ\"\nimport templruntime \"github.com/a-h/templ/runtime\"\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/TecharoHQ/anubis\"\n\t\"github.com/TecharoHQ/anubis/lib/localization\"\n)\n\nfunc page(redir string, difficulty int, showMeta bool, loc *localization.SimpleLocalizer) templ.Component {\n\treturn templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {\n\t\ttempl_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context\n\t\tif templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {\n\t\t\treturn templ_7745c5c3_CtxErr\n\t\t}\n\t\ttempl_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)\n\t\tif !templ_7745c5c3_IsBuffer {\n\t\t\tdefer func() {\n\t\t\t\ttempl_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)\n\t\t\t\tif templ_7745c5c3_Err == nil {\n\t\t\t\t\ttempl_7745c5c3_Err = templ_7745c5c3_BufErr\n\t\t\t\t}\n\t\t\t}()\n\t\t}\n\t\tctx = templ.InitializeContext(ctx)\n\t\ttempl_7745c5c3_Var1 := templ.GetChildren(ctx)\n\t\tif templ_7745c5c3_Var1 == nil {\n\t\t\ttempl_7745c5c3_Var1 = templ.NopComponent\n\t\t}\n\t\tctx = templ.ClearChildren(ctx)\n\t\ttempl_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, \"<div class=\\\"centered-div\\\"><img id=\\\"image\\\" style=\\\"width:100%;max-width:256px;\\\" src=\\\"\")\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\tvar templ_7745c5c3_Var2 string\n\t\ttempl_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(anubis.BasePrefix + \"/.within.website/x/cmd/anubis/static/img/pensive.webp?cacheBuster=\" + anubis.Version)\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ.Error{Err: templ_7745c5c3_Err, FileName: `metarefresh.templ`, Line: 12, Col: 165}\n\t\t}\n\t\t_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\ttempl_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, \"\\\"> <img style=\\\"display:none;\\\" style=\\\"width:100%;max-width:256px;\\\" src=\\\"\")\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\tvar templ_7745c5c3_Var3 string\n\t\ttempl_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(anubis.BasePrefix + \"/.within.website/x/cmd/anubis/static/img/happy.webp?cacheBuster=\" + anubis.Version)\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ.Error{Err: templ_7745c5c3_Err, FileName: `metarefresh.templ`, Line: 13, Col: 174}\n\t\t}\n\t\t_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\ttempl_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, \"\\\"><p id=\\\"status\\\">\")\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\tvar templ_7745c5c3_Var4 string\n\t\ttempl_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(loc.T(\"loading\"))\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ.Error{Err: templ_7745c5c3_Err, FileName: `metarefresh.templ`, Line: 14, Col: 35}\n\t\t}\n\t\t_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\ttempl_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, \"</p><p>\")\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\tvar templ_7745c5c3_Var5 string\n\t\ttempl_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(loc.T(\"connection_security\"))\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ.Error{Err: templ_7745c5c3_Err, FileName: `metarefresh.templ`, Line: 15, Col: 35}\n\t\t}\n\t\t_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\ttempl_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, \"</p>\")\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\tif showMeta {\n\t\t\ttempl_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, \"<meta http-equiv=\\\"refresh\\\" content=\\\"\")\n\t\t\tif templ_7745c5c3_Err != nil {\n\t\t\t\treturn templ_7745c5c3_Err\n\t\t\t}\n\t\t\tvar templ_7745c5c3_Var6 string\n\t\t\ttempl_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf(\"%d; url=%s\", difficulty+1, redir))\n\t\t\tif templ_7745c5c3_Err != nil {\n\t\t\t\treturn templ.Error{Err: templ_7745c5c3_Err, FileName: `metarefresh.templ`, Line: 17, Col: 86}\n\t\t\t}\n\t\t\t_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))\n\t\t\tif templ_7745c5c3_Err != nil {\n\t\t\t\treturn templ_7745c5c3_Err\n\t\t\t}\n\t\t\ttempl_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, \"\\\">\")\n\t\t\tif templ_7745c5c3_Err != nil {\n\t\t\t\treturn templ_7745c5c3_Err\n\t\t\t}\n\t\t}\n\t\ttempl_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, \"</div>\")\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\treturn nil\n\t})\n}\n\nvar _ = templruntime.GeneratedTemplate\n"
  },
  {
    "path": "lib/challenge/metrics.go",
    "content": "package challenge\n\nimport (\n\t\"math\"\n\n\t\"github.com/prometheus/client_golang/prometheus\"\n\t\"github.com/prometheus/client_golang/prometheus/promauto\"\n)\n\nvar TimeTaken = promauto.NewHistogramVec(prometheus.HistogramOpts{\n\tName:    \"anubis_time_taken\",\n\tHelp:    \"The time taken for a browser to generate a response (milliseconds)\",\n\tBuckets: prometheus.ExponentialBucketsRange(1, math.Pow(2, 20), 20),\n}, []string{\"method\"})\n"
  },
  {
    "path": "lib/challenge/preact/build.sh",
    "content": "#!/usr/bin/env bash\n\nset -euo pipefail\n\ncd \"$(dirname \"$0\")\"\n\nLICENSE='/*\n@licstart  The following is the entire license notice for the\nJavaScript code in this page.\n\nCopyright (c) 2025 Xe Iaso <xe.iaso@techaro.lol>\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in\nall copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\nTHE SOFTWARE.\n\nIncludes code from https://www.npmjs.com/package/preact which is used under\nthe terms of the MIT license.\n\nIncludes code from https://github.com/aws/aws-sdk-js-crypto-helpers which is\nused under the terms of the Apache 2 license.\n\n@licend  The above is the entire license notice\nfor the JavaScript code in this page.\n*/'\n\nmkdir -p static/js\n\nfor file in js/*.tsx; do\n  filename=\"${file##*/}\"       # Extracts \"app.jsx\" from \"./js/app.jsx\"\n  output=\"${filename%.tsx}.js\"  # Changes \"app.jsx\" to \"app.js\"\n  echo $output\n\n  esbuild \"${file}\" --minify --bundle --outfile=static/\"${output}\" --banner:js=\"${LICENSE}\"\ndone"
  },
  {
    "path": "lib/challenge/preact/js/app.tsx",
    "content": "import { render, h, Fragment } from \"preact\";\nimport { useState, useEffect } from \"preact/hooks\";\nimport { g, j, r, u, x } from \"./xeact.js\";\nimport { Sha256 } from \"@aws-crypto/sha256-js\";\n\n/** @jsx h */\n/** @jsxFrag Fragment */\n\nfunction toHexString(arr: Uint8Array) {\n  return Array.from(arr)\n    .map((c) => c.toString(16).padStart(2, \"0\"))\n    .join(\"\");\n}\n\ninterface PreactInfo {\n  redir: string;\n  challenge: string;\n  difficulty: number;\n  connection_security_message: string;\n  loading_message: string;\n  pensive_url: string;\n}\n\nconst App = () => {\n  const [state, setState] = useState<PreactInfo>();\n  const [imageURL, setImageURL] = useState<string | null>(null);\n  const [passed, setPassed] = useState<boolean>(false);\n  const [challenge, setChallenge] = useState<string | null>(null);\n\n  useEffect(() => {\n    setState(j(\"preact_info\"));\n  });\n\n  useEffect(() => {\n    if (state === undefined) {\n      return;\n    }\n\n    setImageURL(state?.pensive_url);\n    const hash = new Sha256(\"\");\n    hash.update(state.challenge);\n    setChallenge(toHexString(hash.digestSync()));\n  }, [state]);\n\n  useEffect(() => {\n    if (state === undefined) {\n      return;\n    }\n\n    const timer = setTimeout(() => {\n      setPassed(true);\n    }, state?.difficulty * 125);\n\n    return () => clearTimeout(timer);\n  }, [challenge]);\n\n  useEffect(() => {\n    if (state === undefined) {\n      return;\n    }\n\n    if (challenge === null) {\n      return;\n    }\n\n    window.location.href = u(state.redir, {\n      result: challenge,\n    });\n  }, [passed]);\n\n  return (\n    <>\n      {imageURL !== null && (\n        <img src={imageURL} style={{ width: \"100%\", maxWidth: \"256px\" }} />\n      )}\n      {state !== undefined && (\n        <>\n          <p id=\"status\">{state.loading_message}</p>\n          <p>{state.connection_security_message}</p>\n        </>\n      )}\n    </>\n  );\n};\n\nx(g(\"app\"));\nrender(<App />, g(\"app\"));\n"
  },
  {
    "path": "lib/challenge/preact/js/xeact.js",
    "content": "/**\n * Creates a DOM element, assigns the properties of `data` to it, and appends all `children`.\n *\n * @type{function(string|Function, Object=, Node|Array.<Node|string>=)}\n */\nconst h = (name, data = {}, children = []) => {\n  const result =\n    typeof name == \"function\"\n      ? name(data)\n      : Object.assign(document.createElement(name), data);\n  if (!Array.isArray(children)) {\n    children = [children];\n  }\n  result.append(...children);\n  return result;\n};\n\n/**\n * Create a text node.\n *\n * Equivalent to `document.createTextNode(text)`\n *\n * @type{function(string): Text}\n */\nconst t = (text) => document.createTextNode(text);\n\n/**\n * Remove all child nodes from a DOM element.\n *\n * @type{function(Node)}\n */\nconst x = (elem) => {\n  while (elem.lastChild) {\n    elem.removeChild(elem.lastChild);\n  }\n};\n\n/**\n * Get all elements with the given ID.\n *\n * Equivalent to `document.getElementById(name)`\n *\n * @type{function(string): HTMLElement}\n */\nconst g = (name) => document.getElementById(name);\n\n/**\n * Get all elements with the given class name.\n *\n * Equivalent to `document.getElementsByClassName(name)`\n *\n * @type{function(string): HTMLCollectionOf.<Element>}\n */\nconst c = (name) => document.getElementsByClassName(name);\n\n/** @type{function(string): HTMLCollectionOf.<Element>} */\nconst n = (name) => document.getElementsByName(name);\n\n/**\n * Get all elements matching the given HTML selector.\n *\n * Matches selectors with `document.querySelectorAll(selector)`\n *\n * @type{function(string): Array.<HTMLElement>}\n */\nconst s = (selector) => Array.from(document.querySelectorAll(selector));\n\n/**\n * Generate a relative URL from `url`, appending all key-value pairs from `params` as URL-encoded parameters.\n *\n * @type{function(string=, Object=): string}\n */\nconst u = (url = \"\", params = {}) => {\n  let result = new URL(url, window.location.href);\n  Object.entries(params).forEach((kv) => {\n    let [k, v] = kv;\n    result.searchParams.set(k, v);\n  });\n  return result.toString();\n};\n\n/**\n * Takes a callback to run when all DOM content is loaded.\n *\n * Equivalent to `window.addEventListener('DOMContentLoaded', callback)`\n *\n * @type{function(function())}\n */\nconst r = (callback) => window.addEventListener(\"DOMContentLoaded\", callback);\n\n/**\n * Allows a stateful value to be tracked by consumers.\n *\n * This is the Xeact version of the React useState hook.\n *\n * @type{function(any): [function(): any, function(any): void]}\n */\nconst useState = (value = undefined) => {\n  return [\n    () => value,\n    (x) => {\n      value = x;\n    },\n  ];\n};\n\n/**\n * Debounce an action for up to ms milliseconds.\n *\n * @type{function(number): function(function(any): void)}\n */\nconst d = (ms) => {\n  let debounceTimer = null;\n  return (f) => {\n    clearTimeout(debounceTimer);\n    debounceTimer = setTimeout(f, ms);\n  };\n};\n\n/**\n * Parse the contents of a given HTML page element as JSON and\n * return the results.\n *\n * This is useful when using templ to pass complicated data from\n * the server to the client via HTML[1].\n *\n * [1]: https://templ.guide/syntax-and-usage/script-templates/#pass-server-side-data-to-the-client-in-a-html-attribute\n */\nconst j = (id) => JSON.parse(g(id).textContent);\n\nexport { h, t, x, g, j, c, n, u, s, r, useState, d };\n"
  },
  {
    "path": "lib/challenge/preact/preact.go",
    "content": "package preact\n\nimport (\n\t\"context\"\n\t\"crypto/subtle\"\n\t_ \"embed\"\n\t\"fmt\"\n\t\"io\"\n\t\"log/slog\"\n\t\"net/http\"\n\t\"time\"\n\n\t\"github.com/TecharoHQ/anubis\"\n\t\"github.com/TecharoHQ/anubis/internal\"\n\t\"github.com/TecharoHQ/anubis/lib/challenge\"\n\t\"github.com/TecharoHQ/anubis/lib/localization\"\n\t\"github.com/a-h/templ\"\n)\n\n//go:generate ./build.sh\n//go:generate go tool github.com/a-h/templ/cmd/templ generate\n\n//go:embed static/app.js\nvar appJS []byte\n\nfunc renderAppJS(ctx context.Context, out io.Writer) error {\n\tfmt.Fprint(out, `<script type=\"module\">`)\n\tout.Write(appJS)\n\tfmt.Fprint(out, \"</script>\")\n\treturn nil\n}\n\nfunc init() {\n\tchallenge.Register(\"preact\", &impl{})\n}\n\ntype impl struct{}\n\nfunc (i *impl) Setup(mux *http.ServeMux) {}\n\nfunc (i *impl) Issue(w http.ResponseWriter, r *http.Request, lg *slog.Logger, in *challenge.IssueInput) (templ.Component, error) {\n\tif err := in.Valid(); err != nil {\n\t\treturn nil, err\n\t}\n\n\tu, err := r.URL.Parse(anubis.BasePrefix + \"/.within.website/x/cmd/anubis/api/pass-challenge\")\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"can't render page: %w\", err)\n\t}\n\n\tq := u.Query()\n\tq.Set(\"redir\", r.URL.String())\n\tq.Set(\"id\", in.Challenge.ID)\n\tu.RawQuery = q.Encode()\n\n\tloc := localization.GetLocalizer(r)\n\n\tresult := page(u.String(), in.Challenge.RandomData, in.Rule.Challenge.Difficulty, loc)\n\n\treturn result, nil\n}\n\nfunc (i *impl) Validate(r *http.Request, lg *slog.Logger, in *challenge.ValidateInput) error {\n\tif err := in.Valid(); err != nil {\n\t\treturn challenge.NewError(\"validate\", \"invalid input\", err)\n\t}\n\n\twantTime := in.Challenge.IssuedAt.Add(time.Duration(in.Rule.Challenge.Difficulty) * 80 * time.Millisecond)\n\n\tif time.Now().Before(wantTime) {\n\t\treturn challenge.NewError(\"validate\", \"insufficient time\", fmt.Errorf(\"%w: wanted user to wait until at least %s\", challenge.ErrFailed, wantTime.Format(time.RFC3339)))\n\t}\n\n\tgot := r.FormValue(\"result\")\n\twant := internal.SHA256sum(in.Challenge.RandomData)\n\n\tif subtle.ConstantTimeCompare([]byte(want), []byte(got)) != 1 {\n\t\treturn challenge.NewError(\"validate\", \"invalid response\", fmt.Errorf(\"%w: wanted response %s but got %s\", challenge.ErrFailed, want, got))\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "lib/challenge/preact/preact.templ",
    "content": "package preact\n\nimport (\n\t\"github.com/TecharoHQ/anubis\"\n\t\"github.com/TecharoHQ/anubis/lib/localization\"\n)\n\ntempl page(redir, challenge string, difficulty int, loc *localization.SimpleLocalizer) {\n\t<div class=\"centered-div\">\n\t\t<div id=\"app\">\n\t\t\t<img id=\"image\" style=\"width:100%;max-width:256px;\" src={ anubis.BasePrefix + \"/.within.website/x/cmd/anubis/static/img/pensive.webp?cacheBuster=\" + anubis.Version }/>\n\t\t\t<p id=\"status\">{ loc.T(\"loading\") }</p>\n\t\t\t<p>{ loc.T(\"connection_security\") }</p>\n\t\t</div>\n\t\t@templ.JSONScript(\"preact_info\", map[string]any{\n\t\t\t\"redir\":                       redir,\n\t\t\t\"challenge\":                   challenge,\n\t\t\t\"difficulty\":                  difficulty,\n\t\t\t\"connection_security_message\": loc.T(\"connection_security\"),\n\t\t\t\"loading_message\":             loc.T(\"loading\"),\n\t\t\t\"pensive_url\":                 anubis.BasePrefix + \"/.within.website/x/cmd/anubis/static/img/pensive.webp?cacheBuster=\" + anubis.Version,\n\t\t})\n\t\t@templ.ComponentFunc(renderAppJS)\n\t\t<noscript>\n\t\t\t{ loc.T(\"javascript_required\") }\n\t\t</noscript>\n\t</div>\n}\n"
  },
  {
    "path": "lib/challenge/preact/preact_templ.go",
    "content": "// Code generated by templ - DO NOT EDIT.\n\n// templ: version: v0.3.960\npackage preact\n\n//lint:file-ignore SA4006 This context is only used if a nested component is present.\n\nimport \"github.com/a-h/templ\"\nimport templruntime \"github.com/a-h/templ/runtime\"\n\nimport (\n\t\"github.com/TecharoHQ/anubis\"\n\t\"github.com/TecharoHQ/anubis/lib/localization\"\n)\n\nfunc page(redir, challenge string, difficulty int, loc *localization.SimpleLocalizer) templ.Component {\n\treturn templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {\n\t\ttempl_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context\n\t\tif templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {\n\t\t\treturn templ_7745c5c3_CtxErr\n\t\t}\n\t\ttempl_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)\n\t\tif !templ_7745c5c3_IsBuffer {\n\t\t\tdefer func() {\n\t\t\t\ttempl_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)\n\t\t\t\tif templ_7745c5c3_Err == nil {\n\t\t\t\t\ttempl_7745c5c3_Err = templ_7745c5c3_BufErr\n\t\t\t\t}\n\t\t\t}()\n\t\t}\n\t\tctx = templ.InitializeContext(ctx)\n\t\ttempl_7745c5c3_Var1 := templ.GetChildren(ctx)\n\t\tif templ_7745c5c3_Var1 == nil {\n\t\t\ttempl_7745c5c3_Var1 = templ.NopComponent\n\t\t}\n\t\tctx = templ.ClearChildren(ctx)\n\t\ttempl_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, \"<div class=\\\"centered-div\\\"><div id=\\\"app\\\"><img id=\\\"image\\\" style=\\\"width:100%;max-width:256px;\\\" src=\\\"\")\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\tvar templ_7745c5c3_Var2 string\n\t\ttempl_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(anubis.BasePrefix + \"/.within.website/x/cmd/anubis/static/img/pensive.webp?cacheBuster=\" + anubis.Version)\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ.Error{Err: templ_7745c5c3_Err, FileName: `preact.templ`, Line: 11, Col: 166}\n\t\t}\n\t\t_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\ttempl_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, \"\\\"><p id=\\\"status\\\">\")\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\tvar templ_7745c5c3_Var3 string\n\t\ttempl_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(loc.T(\"loading\"))\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ.Error{Err: templ_7745c5c3_Err, FileName: `preact.templ`, Line: 12, Col: 36}\n\t\t}\n\t\t_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\ttempl_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, \"</p><p>\")\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\tvar templ_7745c5c3_Var4 string\n\t\ttempl_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(loc.T(\"connection_security\"))\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ.Error{Err: templ_7745c5c3_Err, FileName: `preact.templ`, Line: 13, Col: 36}\n\t\t}\n\t\t_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\ttempl_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, \"</p></div>\")\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\ttempl_7745c5c3_Err = templ.JSONScript(\"preact_info\", map[string]any{\n\t\t\t\"redir\":                       redir,\n\t\t\t\"challenge\":                   challenge,\n\t\t\t\"difficulty\":                  difficulty,\n\t\t\t\"connection_security_message\": loc.T(\"connection_security\"),\n\t\t\t\"loading_message\":             loc.T(\"loading\"),\n\t\t\t\"pensive_url\":                 anubis.BasePrefix + \"/.within.website/x/cmd/anubis/static/img/pensive.webp?cacheBuster=\" + anubis.Version,\n\t\t}).Render(ctx, templ_7745c5c3_Buffer)\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\ttempl_7745c5c3_Err = templ.ComponentFunc(renderAppJS).Render(ctx, templ_7745c5c3_Buffer)\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\ttempl_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, \"<noscript>\")\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\tvar templ_7745c5c3_Var5 string\n\t\ttempl_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(loc.T(\"javascript_required\"))\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ.Error{Err: templ_7745c5c3_Err, FileName: `preact.templ`, Line: 25, Col: 33}\n\t\t}\n\t\t_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\ttempl_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, \"</noscript></div>\")\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\treturn nil\n\t})\n}\n\nvar _ = templruntime.GeneratedTemplate\n"
  },
  {
    "path": "lib/challenge/preact/static/.gitignore",
    "content": "app.js"
  },
  {
    "path": "lib/challenge/proofofwork/proofofwork.go",
    "content": "package proofofwork\n\nimport (\n\t\"crypto/subtle\"\n\t\"fmt\"\n\t\"log/slog\"\n\t\"net/http\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/TecharoHQ/anubis/internal\"\n\tchall \"github.com/TecharoHQ/anubis/lib/challenge\"\n\t\"github.com/TecharoHQ/anubis/lib/localization\"\n\t\"github.com/a-h/templ\"\n)\n\n//go:generate go tool github.com/a-h/templ/cmd/templ generate\n\nfunc init() {\n\tchall.Register(\"fast\", &Impl{Algorithm: \"fast\"})\n\tchall.Register(\"slow\", &Impl{Algorithm: \"slow\"})\n}\n\ntype Impl struct {\n\tAlgorithm string\n}\n\nfunc (i *Impl) Setup(mux *http.ServeMux) {}\n\nfunc (i *Impl) Issue(w http.ResponseWriter, r *http.Request, lg *slog.Logger, in *chall.IssueInput) (templ.Component, error) {\n\tloc := localization.GetLocalizer(r)\n\treturn page(loc), nil\n}\n\nfunc (i *Impl) Validate(r *http.Request, lg *slog.Logger, in *chall.ValidateInput) error {\n\tif err := in.Valid(); err != nil {\n\t\treturn chall.NewError(\"validate\", \"invalid input\", err)\n\t}\n\n\trule := in.Rule\n\tchallenge := in.Challenge.RandomData\n\n\tnonceStr := r.FormValue(\"nonce\")\n\tif nonceStr == \"\" {\n\t\treturn chall.NewError(\"validate\", \"invalid response\", fmt.Errorf(\"%w nonce\", chall.ErrMissingField))\n\t}\n\n\tnonce, err := strconv.Atoi(nonceStr)\n\tif err != nil {\n\t\treturn chall.NewError(\"validate\", \"invalid response\", fmt.Errorf(\"%w: nonce: %w\", chall.ErrInvalidFormat, err))\n\n\t}\n\n\telapsedTimeStr := r.FormValue(\"elapsedTime\")\n\tif elapsedTimeStr == \"\" {\n\t\treturn chall.NewError(\"validate\", \"invalid response\", fmt.Errorf(\"%w elapsedTime\", chall.ErrMissingField))\n\t}\n\n\telapsedTime, err := strconv.ParseFloat(elapsedTimeStr, 64)\n\tif err != nil {\n\t\treturn chall.NewError(\"validate\", \"invalid response\", fmt.Errorf(\"%w: elapsedTime: %w\", chall.ErrInvalidFormat, err))\n\t}\n\n\tresponse := r.FormValue(\"response\")\n\tif response == \"\" {\n\t\treturn chall.NewError(\"validate\", \"invalid response\", fmt.Errorf(\"%w response\", chall.ErrMissingField))\n\t}\n\n\tcalcString := fmt.Sprintf(\"%s%d\", challenge, nonce)\n\tcalculated := internal.SHA256sum(calcString)\n\n\tif subtle.ConstantTimeCompare([]byte(response), []byte(calculated)) != 1 {\n\t\treturn chall.NewError(\"validate\", \"invalid response\", fmt.Errorf(\"%w: wanted response %s but got %s\", chall.ErrFailed, calculated, response))\n\t}\n\n\t// compare the leading zeroes\n\tif !strings.HasPrefix(response, strings.Repeat(\"0\", rule.Challenge.Difficulty)) {\n\t\treturn chall.NewError(\"validate\", \"invalid response\", fmt.Errorf(\"%w: wanted %d leading zeros but got %s\", chall.ErrFailed, rule.Challenge.Difficulty, response))\n\t}\n\n\tlg.Debug(\"challenge took\", \"elapsedTime\", elapsedTime)\n\tchall.TimeTaken.WithLabelValues(i.Algorithm).Observe(elapsedTime)\n\n\treturn nil\n}\n"
  },
  {
    "path": "lib/challenge/proofofwork/proofofwork.templ",
    "content": "package proofofwork\n\nimport (\n\t\"github.com/TecharoHQ/anubis\"\n\t\"github.com/TecharoHQ/anubis/lib/localization\"\n)\n\ntempl page(localizer *localization.SimpleLocalizer) {\n\t<div class=\"centered-div\">\n\t\t<img id=\"image\" style=\"width:100%;max-width:256px;\" src={ anubis.BasePrefix + \"/.within.website/x/cmd/anubis/static/img/pensive.webp?cacheBuster=\" + anubis.Version }/>\n\t\t<img style=\"display:none;\" style=\"width:100%;max-width:256px;\" src={ anubis.BasePrefix + \"/.within.website/x/cmd/anubis/static/img/happy.webp?cacheBuster=\" + anubis.Version }/>\n\t\t<p id=\"status\">{ localizer.T(\"loading\") }</p>\n\t\t<script async type=\"module\" src={ anubis.BasePrefix + \"/.within.website/x/cmd/anubis/static/js/main.mjs?cacheBuster=\" + anubis.Version }></script>\n\t\t<div id=\"progress\" role=\"progressbar\" aria-labelledby=\"status\">\n\t\t\t<div class=\"bar-inner\"></div>\n\t\t</div>\n\t\t<details>\n\t\t\tif anubis.UseSimplifiedExplanation {\n\t\t\t\t<p>\n\t\t\t\t\t{ localizer.T(\"simplified_explanation\") }\n\t\t\t\t</p>\n\t\t\t} else {\n\t\t\t\t<p>\n\t\t\t\t\t{ localizer.T(\"ai_companies_explanation\") }\n\t\t\t\t</p>\n\t\t\t\t<p>\n\t\t\t\t\t{ localizer.T(\"anubis_compromise\") }\n\t\t\t\t</p>\n\t\t\t\t<p>\n\t\t\t\t\t{ localizer.T(\"hack_purpose\") }\n\t\t\t\t</p>\n\t\t\t\t<p>\n\t\t\t\t\t{ localizer.T(\"jshelter_note\") }\n\t\t\t\t</p>\n\t\t\t}\n\t\t</details>\n\t\t<noscript>\n\t\t\t<p>\n\t\t\t\t{ localizer.T(\"javascript_required\") }\n\t\t\t</p>\n\t\t</noscript>\n\t\t<div id=\"testarea\"></div>\n\t</div>\n}\n"
  },
  {
    "path": "lib/challenge/proofofwork/proofofwork_templ.go",
    "content": "// Code generated by templ - DO NOT EDIT.\n\n// templ: version: v0.3.960\npackage proofofwork\n\n//lint:file-ignore SA4006 This context is only used if a nested component is present.\n\nimport \"github.com/a-h/templ\"\nimport templruntime \"github.com/a-h/templ/runtime\"\n\nimport (\n\t\"github.com/TecharoHQ/anubis\"\n\t\"github.com/TecharoHQ/anubis/lib/localization\"\n)\n\nfunc page(localizer *localization.SimpleLocalizer) templ.Component {\n\treturn templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {\n\t\ttempl_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context\n\t\tif templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {\n\t\t\treturn templ_7745c5c3_CtxErr\n\t\t}\n\t\ttempl_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)\n\t\tif !templ_7745c5c3_IsBuffer {\n\t\t\tdefer func() {\n\t\t\t\ttempl_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)\n\t\t\t\tif templ_7745c5c3_Err == nil {\n\t\t\t\t\ttempl_7745c5c3_Err = templ_7745c5c3_BufErr\n\t\t\t\t}\n\t\t\t}()\n\t\t}\n\t\tctx = templ.InitializeContext(ctx)\n\t\ttempl_7745c5c3_Var1 := templ.GetChildren(ctx)\n\t\tif templ_7745c5c3_Var1 == nil {\n\t\t\ttempl_7745c5c3_Var1 = templ.NopComponent\n\t\t}\n\t\tctx = templ.ClearChildren(ctx)\n\t\ttempl_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, \"<div class=\\\"centered-div\\\"><img id=\\\"image\\\" style=\\\"width:100%;max-width:256px;\\\" src=\\\"\")\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\tvar templ_7745c5c3_Var2 string\n\t\ttempl_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(anubis.BasePrefix + \"/.within.website/x/cmd/anubis/static/img/pensive.webp?cacheBuster=\" + anubis.Version)\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 10, Col: 165}\n\t\t}\n\t\t_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\ttempl_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, \"\\\"> <img style=\\\"display:none;\\\" style=\\\"width:100%;max-width:256px;\\\" src=\\\"\")\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\tvar templ_7745c5c3_Var3 string\n\t\ttempl_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(anubis.BasePrefix + \"/.within.website/x/cmd/anubis/static/img/happy.webp?cacheBuster=\" + anubis.Version)\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 11, Col: 174}\n\t\t}\n\t\t_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\ttempl_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, \"\\\"><p id=\\\"status\\\">\")\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\tvar templ_7745c5c3_Var4 string\n\t\ttempl_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T(\"loading\"))\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 12, Col: 41}\n\t\t}\n\t\t_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\ttempl_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, \"</p><script async type=\\\"module\\\" src=\\\"\")\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\tvar templ_7745c5c3_Var5 string\n\t\ttempl_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(anubis.BasePrefix + \"/.within.website/x/cmd/anubis/static/js/main.mjs?cacheBuster=\" + anubis.Version)\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 13, Col: 136}\n\t\t}\n\t\t_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\ttempl_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, \"\\\"></script><div id=\\\"progress\\\" role=\\\"progressbar\\\" aria-labelledby=\\\"status\\\"><div class=\\\"bar-inner\\\"></div></div><details>\")\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\tif anubis.UseSimplifiedExplanation {\n\t\t\ttempl_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, \"<p>\")\n\t\t\tif templ_7745c5c3_Err != nil {\n\t\t\t\treturn templ_7745c5c3_Err\n\t\t\t}\n\t\t\tvar templ_7745c5c3_Var6 string\n\t\t\ttempl_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T(\"simplified_explanation\"))\n\t\t\tif templ_7745c5c3_Err != nil {\n\t\t\t\treturn templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 20, Col: 44}\n\t\t\t}\n\t\t\t_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))\n\t\t\tif templ_7745c5c3_Err != nil {\n\t\t\t\treturn templ_7745c5c3_Err\n\t\t\t}\n\t\t\ttempl_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, \"</p>\")\n\t\t\tif templ_7745c5c3_Err != nil {\n\t\t\t\treturn templ_7745c5c3_Err\n\t\t\t}\n\t\t} else {\n\t\t\ttempl_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, \"<p>\")\n\t\t\tif templ_7745c5c3_Err != nil {\n\t\t\t\treturn templ_7745c5c3_Err\n\t\t\t}\n\t\t\tvar templ_7745c5c3_Var7 string\n\t\t\ttempl_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T(\"ai_companies_explanation\"))\n\t\t\tif templ_7745c5c3_Err != nil {\n\t\t\t\treturn templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 24, Col: 46}\n\t\t\t}\n\t\t\t_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))\n\t\t\tif templ_7745c5c3_Err != nil {\n\t\t\t\treturn templ_7745c5c3_Err\n\t\t\t}\n\t\t\ttempl_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, \"</p><p>\")\n\t\t\tif templ_7745c5c3_Err != nil {\n\t\t\t\treturn templ_7745c5c3_Err\n\t\t\t}\n\t\t\tvar templ_7745c5c3_Var8 string\n\t\t\ttempl_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T(\"anubis_compromise\"))\n\t\t\tif templ_7745c5c3_Err != nil {\n\t\t\t\treturn templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 27, Col: 39}\n\t\t\t}\n\t\t\t_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))\n\t\t\tif templ_7745c5c3_Err != nil {\n\t\t\t\treturn templ_7745c5c3_Err\n\t\t\t}\n\t\t\ttempl_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, \"</p><p>\")\n\t\t\tif templ_7745c5c3_Err != nil {\n\t\t\t\treturn templ_7745c5c3_Err\n\t\t\t}\n\t\t\tvar templ_7745c5c3_Var9 string\n\t\t\ttempl_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T(\"hack_purpose\"))\n\t\t\tif templ_7745c5c3_Err != nil {\n\t\t\t\treturn templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 30, Col: 34}\n\t\t\t}\n\t\t\t_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))\n\t\t\tif templ_7745c5c3_Err != nil {\n\t\t\t\treturn templ_7745c5c3_Err\n\t\t\t}\n\t\t\ttempl_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, \"</p><p>\")\n\t\t\tif templ_7745c5c3_Err != nil {\n\t\t\t\treturn templ_7745c5c3_Err\n\t\t\t}\n\t\t\tvar templ_7745c5c3_Var10 string\n\t\t\ttempl_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T(\"jshelter_note\"))\n\t\t\tif templ_7745c5c3_Err != nil {\n\t\t\t\treturn templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 33, Col: 35}\n\t\t\t}\n\t\t\t_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))\n\t\t\tif templ_7745c5c3_Err != nil {\n\t\t\t\treturn templ_7745c5c3_Err\n\t\t\t}\n\t\t\ttempl_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, \"</p>\")\n\t\t\tif templ_7745c5c3_Err != nil {\n\t\t\t\treturn templ_7745c5c3_Err\n\t\t\t}\n\t\t}\n\t\ttempl_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, \"</details><noscript><p>\")\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\tvar templ_7745c5c3_Var11 string\n\t\ttempl_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T(\"javascript_required\"))\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 39, Col: 40}\n\t\t}\n\t\t_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\ttempl_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, \"</p></noscript><div id=\\\"testarea\\\"></div></div>\")\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\treturn nil\n\t})\n}\n\nvar _ = templruntime.GeneratedTemplate\n"
  },
  {
    "path": "lib/challenge/proofofwork/proofofwork_test.go",
    "content": "package proofofwork\n\nimport (\n\t\"errors\"\n\t\"log/slog\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\n\t\"github.com/TecharoHQ/anubis/lib/challenge\"\n\t\"github.com/TecharoHQ/anubis/lib/config\"\n\t\"github.com/TecharoHQ/anubis/lib/policy\"\n)\n\nfunc mkRequest(t *testing.T, values map[string]string) *http.Request {\n\tt.Helper()\n\treq, err := http.NewRequestWithContext(t.Context(), http.MethodGet, \"/\", nil)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tq := req.URL.Query()\n\n\tfor k, v := range values {\n\t\tq.Set(k, v)\n\t}\n\n\treq.URL.RawQuery = q.Encode()\n\n\treturn req\n}\n\n// TestValidateNilRuleChallenge reproduces the panic from\n// https://github.com/TecharoHQ/anubis/issues/1463\n//\n// When a threshold rule matches during PassChallenge, check() can return\n// a policy.Bot with Challenge == nil. After hydrateChallengeRule fails to\n// run (or the error path hits before it), Validate dereferences\n// rule.Challenge.Difficulty and panics.\nfunc TestValidateNilRuleChallenge(t *testing.T) {\n\ti := &Impl{Algorithm: \"fast\"}\n\tlg := slog.With()\n\n\t// This is the exact response for SHA256(\"hunter\" + \"0\") with 0 leading zeros required.\n\tconst challengeStr = \"hunter\"\n\tconst response = \"2652bdba8fb4d2ab39ef28d8534d7694c557a4ae146c1e9237bd8d950280500e\"\n\n\treq := mkRequest(t, map[string]string{\n\t\t\"nonce\":       \"0\",\n\t\t\"elapsedTime\": \"69\",\n\t\t\"response\":    response,\n\t})\n\n\tfor _, tc := range []struct {\n\t\tname  string\n\t\tinput *challenge.ValidateInput\n\t}{\n\t\t{\n\t\t\tname: \"nil-rule-challenge\",\n\t\t\tinput: &challenge.ValidateInput{\n\t\t\t\tRule:      &policy.Bot{},\n\t\t\t\tChallenge: &challenge.Challenge{RandomData: challengeStr},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"nil-rule\",\n\t\t\tinput: &challenge.ValidateInput{\n\t\t\t\tChallenge: &challenge.Challenge{RandomData: challengeStr},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:  \"nil-challenge\",\n\t\t\tinput: &challenge.ValidateInput{Rule: &policy.Bot{Challenge: &config.ChallengeRules{Algorithm: \"fast\"}}},\n\t\t},\n\t\t{\n\t\t\tname:  \"nil-input\",\n\t\t\tinput: nil,\n\t\t},\n\t} {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\terr := i.Validate(req, lg, tc.input)\n\t\t\tif !errors.Is(err, challenge.ErrInvalidInput) {\n\t\t\t\tt.Fatalf(\"expected ErrInvalidInput, got: %v\", err)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestBasic(t *testing.T) {\n\ti := &Impl{Algorithm: \"fast\"}\n\tbot := &policy.Bot{\n\t\tChallenge: &config.ChallengeRules{\n\t\t\tAlgorithm:  \"fast\",\n\t\t\tDifficulty: 0,\n\t\t},\n\t}\n\tconst challengeStr = \"hunter\"\n\tconst response = \"2652bdba8fb4d2ab39ef28d8534d7694c557a4ae146c1e9237bd8d950280500e\"\n\n\tfor _, cs := range []struct {\n\t\tname         string\n\t\treq          *http.Request\n\t\terr          error\n\t\tchallengeStr string\n\t}{\n\t\t{\n\t\t\tname: \"allgood\",\n\t\t\treq: mkRequest(t, map[string]string{\n\t\t\t\t\"nonce\":       \"0\",\n\t\t\t\t\"elapsedTime\": \"69\",\n\t\t\t\t\"response\":    response,\n\t\t\t}),\n\t\t\terr:          nil,\n\t\t\tchallengeStr: challengeStr,\n\t\t},\n\t\t{\n\t\t\tname:         \"no-params\",\n\t\t\treq:          mkRequest(t, map[string]string{}),\n\t\t\terr:          challenge.ErrMissingField,\n\t\t\tchallengeStr: challengeStr,\n\t\t},\n\t\t{\n\t\t\tname: \"missing-nonce\",\n\t\t\treq: mkRequest(t, map[string]string{\n\t\t\t\t\"elapsedTime\": \"69\",\n\t\t\t\t\"response\":    response,\n\t\t\t}),\n\t\t\terr:          challenge.ErrMissingField,\n\t\t\tchallengeStr: challengeStr,\n\t\t},\n\t\t{\n\t\t\tname: \"missing-elapsedTime\",\n\t\t\treq: mkRequest(t, map[string]string{\n\t\t\t\t\"nonce\":    \"0\",\n\t\t\t\t\"response\": response,\n\t\t\t}),\n\t\t\terr:          challenge.ErrMissingField,\n\t\t\tchallengeStr: challengeStr,\n\t\t},\n\t\t{\n\t\t\tname: \"missing-response\",\n\t\t\treq: mkRequest(t, map[string]string{\n\t\t\t\t\"nonce\":       \"0\",\n\t\t\t\t\"elapsedTime\": \"69\",\n\t\t\t}),\n\t\t\terr:          challenge.ErrMissingField,\n\t\t\tchallengeStr: challengeStr,\n\t\t},\n\t\t{\n\t\t\tname: \"wrong-nonce-format\",\n\t\t\treq: mkRequest(t, map[string]string{\n\t\t\t\t\"nonce\":       \"taco\",\n\t\t\t\t\"elapsedTime\": \"69\",\n\t\t\t\t\"response\":    response,\n\t\t\t}),\n\t\t\terr:          challenge.ErrInvalidFormat,\n\t\t\tchallengeStr: challengeStr,\n\t\t},\n\t\t{\n\t\t\tname: \"wrong-elapsedTime-format\",\n\t\t\treq: mkRequest(t, map[string]string{\n\t\t\t\t\"nonce\":       \"0\",\n\t\t\t\t\"elapsedTime\": \"taco\",\n\t\t\t\t\"response\":    response,\n\t\t\t}),\n\t\t\terr:          challenge.ErrInvalidFormat,\n\t\t\tchallengeStr: challengeStr,\n\t\t},\n\t\t{\n\t\t\tname: \"invalid-response\",\n\t\t\treq: mkRequest(t, map[string]string{\n\t\t\t\t\"nonce\":       \"0\",\n\t\t\t\t\"elapsedTime\": \"69\",\n\t\t\t\t\"response\":    response,\n\t\t\t}),\n\t\t\terr:          challenge.ErrFailed,\n\t\t\tchallengeStr: \"Tacos are tasty\",\n\t\t},\n\t} {\n\t\tt.Run(cs.name, func(t *testing.T) {\n\t\t\tlg := slog.With()\n\n\t\t\ti.Setup(http.NewServeMux())\n\n\t\t\tinp := &challenge.IssueInput{\n\t\t\t\tRule: bot,\n\t\t\t\tChallenge: &challenge.Challenge{\n\t\t\t\t\tRandomData: cs.challengeStr,\n\t\t\t\t},\n\t\t\t}\n\n\t\t\tif _, err := i.Issue(httptest.NewRecorder(), cs.req, lg, inp); err != nil {\n\t\t\t\tt.Errorf(\"can't issue challenge: %v\", err)\n\t\t\t}\n\n\t\t\tif err := i.Validate(cs.req, lg, &challenge.ValidateInput{\n\t\t\t\tRule: bot,\n\t\t\t\tChallenge: &challenge.Challenge{\n\t\t\t\t\tRandomData: cs.challengeStr,\n\t\t\t\t},\n\t\t\t}); !errors.Is(err, cs.err) {\n\t\t\t\tt.Errorf(\"got wrong error from Validate, got %v but wanted %v\", err, cs.err)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "lib/config/asn.go",
    "content": "package config\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n)\n\nvar (\n\tErrPrivateASN = errors.New(\"bot.ASNs: you have specified a private use ASN\")\n)\n\ntype ASNs struct {\n\tMatch []uint32 `json:\"match\"`\n}\n\nfunc (a *ASNs) Valid() error {\n\tvar errs []error\n\n\tfor _, asn := range a.Match {\n\t\tif isPrivateASN(asn) {\n\t\t\terrs = append(errs, fmt.Errorf(\"%w: %d is private (see RFC 6996)\", ErrPrivateASN, asn))\n\t\t}\n\t}\n\n\tif len(errs) != 0 {\n\t\treturn fmt.Errorf(\"bot.ASNs: invalid ASN settings: %w\", errors.Join(errs...))\n\t}\n\n\treturn nil\n}\n\n// isPrivateASN checks if an ASN is in the private use area.\n//\n// Based on RFC 6996 and IANA allocations.\nfunc isPrivateASN(asn uint32) bool {\n\tswitch {\n\tcase asn >= 64512 && asn <= 65534:\n\t\treturn true\n\tcase asn >= 4200000000 && asn <= 4294967294:\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n"
  },
  {
    "path": "lib/config/asn_test.go",
    "content": "package config\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"testing\"\n)\n\nfunc TestASNsValid(t *testing.T) {\n\tfor _, tt := range []struct {\n\t\terr   error\n\t\tinput *ASNs\n\t\tname  string\n\t}{\n\t\t{\n\t\t\tname: \"basic valid\",\n\t\t\tinput: &ASNs{\n\t\t\t\tMatch: []uint32{13335}, // Cloudflare\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"private ASN\",\n\t\t\tinput: &ASNs{\n\t\t\t\tMatch: []uint32{64513, 4206942069}, // 16 and 32 bit private ASN\n\t\t\t},\n\t\t\terr: ErrPrivateASN,\n\t\t},\n\t} {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tif err := tt.input.Valid(); !errors.Is(err, tt.err) {\n\t\t\t\tt.Logf(\"want: %v\", tt.err)\n\t\t\t\tt.Logf(\"got:  %v\", err)\n\t\t\t\tt.Error(\"got wrong validation error\")\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestIsPrivateASN(t *testing.T) {\n\tfor _, tt := range []struct {\n\t\tinput  uint32\n\t\toutput bool\n\t}{\n\t\t{13335, false},     // Cloudflare\n\t\t{64513, true},      // 16 bit private ASN\n\t\t{4206942069, true}, // 32 bit private ASN\n\t} {\n\t\tt.Run(fmt.Sprint(tt.input, \"->\", tt.output), func(t *testing.T) {\n\t\t\tresult := isPrivateASN(tt.input)\n\t\t\tif result != tt.output {\n\t\t\t\tt.Errorf(\"wanted isPrivateASN(%d) == %v, got: %v\", tt.input, tt.output, result)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "lib/config/check.go",
    "content": "//go:build ignore\n\npackage config\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\n\t\"github.com/TecharoHQ/anubis/lib/checker\"\n)\n\nvar (\n\tErrUnknownCheckType = errors.New(\"config.Bot.Check: unknown check type\")\n)\n\ntype AllChecks struct {\n\tAll []Check `json:\"all\"`\n}\n\ntype AnyChecks struct {\n\tAll []Check `json:\"any\"`\n}\n\ntype Check struct {\n\tType string          `json:\"type\"`\n\tArgs json.RawMessage `json:\"args\"`\n}\n\nfunc (c *Check) Valid(ctx context.Context) error {\n\tvar errs []error\n\n\tif len(c.Type) == 0 {\n\t\terrs = append(errs, ErrNoStoreBackend)\n\t}\n\n\tfac, ok := checker.Get(c.Type)\n\tswitch ok {\n\tcase true:\n\t\tif err := fac.Valid(ctx, c.Args); err != nil {\n\t\t\terrs = append(errs, err)\n\t\t}\n\tcase false:\n\t\terrs = append(errs, fmt.Errorf(\"%w: %q\", ErrUnknownCheckType, c.Type))\n\t}\n\n\tif len(errs) != 0 {\n\t\treturn errors.Join(errs...)\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "lib/config/config.go",
    "content": "package config\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"io/fs\"\n\t\"net\"\n\t\"net/http\"\n\t\"os\"\n\t\"regexp\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/TecharoHQ/anubis/data\"\n\t\"k8s.io/apimachinery/pkg/util/yaml\"\n)\n\nvar (\n\tErrNoBotRulesDefined                 = errors.New(\"config: must define at least one (1) bot rule\")\n\tErrBotMustHaveName                   = errors.New(\"config.Bot: must set name\")\n\tErrBotMustHaveUserAgentOrPath        = errors.New(\"config.Bot: must set either user_agent_regex, path_regex, headers_regex, or remote_addresses\")\n\tErrBotMustHaveUserAgentOrPathNotBoth = errors.New(\"config.Bot: must set either user_agent_regex, path_regex, and not both\")\n\tErrUnknownAction                     = errors.New(\"config.Bot: unknown action\")\n\tErrInvalidUserAgentRegex             = errors.New(\"config.Bot: invalid user agent regex\")\n\tErrInvalidPathRegex                  = errors.New(\"config.Bot: invalid path regex\")\n\tErrInvalidHeadersRegex               = errors.New(\"config.Bot: invalid headers regex\")\n\tErrInvalidCIDR                       = errors.New(\"config.Bot: invalid CIDR\")\n\tErrRegexEndsWithNewline              = errors.New(\"config.Bot: regular expression ends with newline (try >- instead of > in yaml)\")\n\tErrInvalidImportStatement            = errors.New(\"config.ImportStatement: invalid source file\")\n\tErrCantSetBotAndImportValuesAtOnce   = errors.New(\"config.BotOrImport: can't set bot rules and import values at the same time\")\n\tErrMustSetBotOrImportRules           = errors.New(\"config.BotOrImport: rule definition is invalid, you must set either bot rules or an import statement, not both\")\n\tErrStatusCodeNotValid                = errors.New(\"config.StatusCode: status code not valid, must be between 100 and 599\")\n)\n\ntype Rule string\n\nconst (\n\tRuleUnknown   Rule = \"\"\n\tRuleAllow     Rule = \"ALLOW\"\n\tRuleDeny      Rule = \"DENY\"\n\tRuleChallenge Rule = \"CHALLENGE\"\n\tRuleWeigh     Rule = \"WEIGH\"\n\tRuleBenchmark Rule = \"DEBUG_BENCHMARK\"\n)\n\nfunc (r Rule) Valid() error {\n\tswitch r {\n\tcase RuleAllow, RuleDeny, RuleChallenge, RuleWeigh, RuleBenchmark:\n\t\treturn nil\n\tdefault:\n\t\treturn ErrUnknownAction\n\t}\n}\n\nconst DefaultAlgorithm = \"fast\"\n\ntype BotConfig struct {\n\tUserAgentRegex *string           `json:\"user_agent_regex,omitempty\" yaml:\"user_agent_regex,omitempty\"`\n\tPathRegex      *string           `json:\"path_regex,omitempty\" yaml:\"path_regex,omitempty\"`\n\tHeadersRegex   map[string]string `json:\"headers_regex,omitempty\" yaml:\"headers_regex,omitempty\"`\n\tExpression     *ExpressionOrList `json:\"expression,omitempty\" yaml:\"expression,omitempty\"`\n\tChallenge      *ChallengeRules   `json:\"challenge,omitempty\" yaml:\"challenge,omitempty\"`\n\tWeight         *Weight           `json:\"weight,omitempty\" yaml:\"weight,omitempty\"`\n\n\t// Thoth features\n\tGeoIP *GeoIP `json:\"geoip,omitempty\"`\n\tASNs  *ASNs  `json:\"asns,omitempty\"`\n\n\tName       string   `json:\"name\" yaml:\"name\"`\n\tAction     Rule     `json:\"action\" yaml:\"action\"`\n\tRemoteAddr []string `json:\"remote_addresses,omitempty\" yaml:\"remote_addresses,omitempty\"`\n}\n\nfunc (b BotConfig) Zero() bool {\n\tfor _, cond := range []bool{\n\t\tb.Name != \"\",\n\t\tb.UserAgentRegex != nil,\n\t\tb.PathRegex != nil,\n\t\tlen(b.HeadersRegex) != 0,\n\t\tb.Action != \"\",\n\t\tlen(b.RemoteAddr) != 0,\n\t\tb.Challenge != nil,\n\t\tb.GeoIP != nil,\n\t\tb.ASNs != nil,\n\t} {\n\t\tif cond {\n\t\t\treturn false\n\t\t}\n\t}\n\n\treturn true\n}\n\nfunc (b *BotConfig) Valid() error {\n\tvar errs []error\n\n\tif b.Name == \"\" {\n\t\terrs = append(errs, ErrBotMustHaveName)\n\t}\n\n\tallFieldsEmpty := b.UserAgentRegex == nil &&\n\t\tb.PathRegex == nil &&\n\t\tlen(b.RemoteAddr) == 0 &&\n\t\tlen(b.HeadersRegex) == 0 &&\n\t\tb.ASNs == nil &&\n\t\tb.GeoIP == nil\n\n\tif allFieldsEmpty && b.Expression == nil {\n\t\terrs = append(errs, ErrBotMustHaveUserAgentOrPath)\n\t}\n\n\tif b.UserAgentRegex != nil && b.PathRegex != nil {\n\t\terrs = append(errs, ErrBotMustHaveUserAgentOrPathNotBoth)\n\t}\n\n\tif b.UserAgentRegex != nil {\n\t\tif strings.HasSuffix(*b.UserAgentRegex, \"\\n\") {\n\t\t\terrs = append(errs, fmt.Errorf(\"%w: user agent regex: %q\", ErrRegexEndsWithNewline, *b.UserAgentRegex))\n\t\t}\n\n\t\tif _, err := regexp.Compile(*b.UserAgentRegex); err != nil {\n\t\t\terrs = append(errs, ErrInvalidUserAgentRegex, err)\n\t\t}\n\t}\n\n\tif b.PathRegex != nil {\n\t\tif strings.HasSuffix(*b.PathRegex, \"\\n\") {\n\t\t\terrs = append(errs, fmt.Errorf(\"%w: path regex: %q\", ErrRegexEndsWithNewline, *b.PathRegex))\n\t\t}\n\n\t\tif _, err := regexp.Compile(*b.PathRegex); err != nil {\n\t\t\terrs = append(errs, ErrInvalidPathRegex, err)\n\t\t}\n\t}\n\n\tif len(b.HeadersRegex) > 0 {\n\t\tfor name, expr := range b.HeadersRegex {\n\t\t\tif name == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tif strings.HasSuffix(expr, \"\\n\") {\n\t\t\t\terrs = append(errs, fmt.Errorf(\"%w: header %s regex: %q\", ErrRegexEndsWithNewline, name, expr))\n\t\t\t}\n\n\t\t\tif _, err := regexp.Compile(expr); err != nil {\n\t\t\t\terrs = append(errs, ErrInvalidHeadersRegex, err)\n\t\t\t}\n\t\t}\n\t}\n\n\tif len(b.RemoteAddr) > 0 {\n\t\tfor _, cidr := range b.RemoteAddr {\n\t\t\tif _, _, err := net.ParseCIDR(cidr); err != nil {\n\t\t\t\terrs = append(errs, ErrInvalidCIDR, err)\n\t\t\t}\n\t\t}\n\t}\n\n\tif b.Expression != nil {\n\t\tif err := b.Expression.Valid(); err != nil {\n\t\t\terrs = append(errs, err)\n\t\t}\n\t}\n\n\tswitch b.Action {\n\tcase RuleAllow, RuleBenchmark, RuleChallenge, RuleDeny, RuleWeigh:\n\t\t// okay\n\tdefault:\n\t\terrs = append(errs, fmt.Errorf(\"%w: %q\", ErrUnknownAction, b.Action))\n\t}\n\n\tif b.Action == RuleChallenge && b.Challenge != nil {\n\t\tif err := b.Challenge.Valid(); err != nil {\n\t\t\terrs = append(errs, err)\n\t\t}\n\t}\n\n\tif b.Action == RuleWeigh && b.Weight == nil {\n\t\tb.Weight = &Weight{Adjust: 5}\n\t}\n\n\tif len(errs) != 0 {\n\t\treturn fmt.Errorf(\"config: bot entry for %q is not valid:\\n%w\", b.Name, errors.Join(errs...))\n\t}\n\n\treturn nil\n}\n\ntype ChallengeRules struct {\n\tAlgorithm  string `json:\"algorithm,omitempty\" yaml:\"algorithm,omitempty\"`\n\tDifficulty int    `json:\"difficulty,omitempty\" yaml:\"difficulty,omitempty\"`\n\tReportAs   int    `json:\"report_as,omitempty\" yaml:\"report_as,omitempty\"`\n}\n\nvar (\n\tErrChallengeDifficultyTooLow  = errors.New(\"config.ChallengeRules: difficulty is too low (must be >= 0)\")\n\tErrChallengeDifficultyTooHigh = errors.New(\"config.ChallengeRules: difficulty is too high (must be <= 64)\")\n\tErrChallengeMustHaveAlgorithm = errors.New(\"config.ChallengeRules: must have algorithm name set\")\n)\n\nfunc (cr ChallengeRules) Valid() error {\n\tvar errs []error\n\n\tif cr.Algorithm == \"\" {\n\t\terrs = append(errs, ErrChallengeMustHaveAlgorithm)\n\t}\n\n\tif cr.Difficulty < 0 {\n\t\terrs = append(errs, fmt.Errorf(\"%w, got: %d\", ErrChallengeDifficultyTooLow, cr.Difficulty))\n\t}\n\n\tif cr.Difficulty > 64 {\n\t\terrs = append(errs, fmt.Errorf(\"%w, got: %d\", ErrChallengeDifficultyTooHigh, cr.Difficulty))\n\t}\n\n\tif len(errs) != 0 {\n\t\treturn fmt.Errorf(\"config: challenge rules entry is not valid:\\n%w\", errors.Join(errs...))\n\t}\n\n\treturn nil\n}\n\ntype ImportStatement struct {\n\tImport string `json:\"import\"`\n\tBots   []BotConfig\n}\n\nfunc (is *ImportStatement) open() (fs.File, error) {\n\tif after, ok := strings.CutPrefix(is.Import, \"(data)/\"); ok {\n\t\tfname := after\n\t\tfin, err := data.BotPolicies.Open(fname)\n\t\treturn fin, err\n\t}\n\n\treturn os.Open(is.Import)\n}\n\nfunc (is *ImportStatement) load() error {\n\tfin, err := is.open()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"%w: %s: %w\", ErrInvalidImportStatement, is.Import, err)\n\t}\n\tdefer fin.Close()\n\n\tvar imported []BotOrImport\n\tvar result []BotConfig\n\n\tif err := yaml.NewYAMLToJSONDecoder(fin).Decode(&imported); err != nil {\n\t\treturn fmt.Errorf(\"can't parse %s: %w\", is.Import, err)\n\t}\n\n\tvar errs []error\n\n\tfor _, b := range imported {\n\t\tif err := b.Valid(); err != nil {\n\t\t\terrs = append(errs, err)\n\t\t}\n\n\t\tif b.ImportStatement != nil {\n\t\t\tresult = append(result, b.ImportStatement.Bots...)\n\t\t}\n\n\t\tif b.BotConfig != nil {\n\t\t\tresult = append(result, *b.BotConfig)\n\t\t}\n\t}\n\n\tif len(errs) != 0 {\n\t\treturn fmt.Errorf(\"config %s is not valid:\\n%w\", is.Import, errors.Join(errs...))\n\t}\n\n\tis.Bots = result\n\n\treturn nil\n}\n\nfunc (is *ImportStatement) Valid() error {\n\treturn is.load()\n}\n\ntype BotOrImport struct {\n\t*BotConfig       `json:\",inline\"`\n\t*ImportStatement `json:\",inline\"`\n}\n\nfunc (boi *BotOrImport) Valid() error {\n\tif boi.BotConfig != nil && boi.ImportStatement != nil {\n\t\treturn ErrCantSetBotAndImportValuesAtOnce\n\t}\n\n\tif boi.BotConfig != nil {\n\t\treturn boi.BotConfig.Valid()\n\t}\n\n\tif boi.ImportStatement != nil {\n\t\treturn boi.ImportStatement.Valid()\n\t}\n\n\treturn ErrMustSetBotOrImportRules\n}\n\ntype StatusCodes struct {\n\tChallenge int `json:\"CHALLENGE\"`\n\tDeny      int `json:\"DENY\"`\n}\n\nfunc (sc StatusCodes) Valid() error {\n\tvar errs []error\n\n\tif sc.Challenge == 0 || (sc.Challenge < 100 && sc.Challenge >= 599) {\n\t\terrs = append(errs, fmt.Errorf(\"%w: challenge is %d\", ErrStatusCodeNotValid, sc.Challenge))\n\t}\n\n\tif sc.Deny == 0 || (sc.Deny < 100 && sc.Deny >= 599) {\n\t\terrs = append(errs, fmt.Errorf(\"%w: deny is %d\", ErrStatusCodeNotValid, sc.Deny))\n\t}\n\n\tif len(errs) != 0 {\n\t\treturn fmt.Errorf(\"status codes not valid:\\n%w\", errors.Join(errs...))\n\t}\n\n\treturn nil\n}\n\ntype fileConfig struct {\n\tOpenGraph   openGraphFileConfig `json:\"openGraph\"`\n\tImpressum   *Impressum          `json:\"impressum,omitempty\"`\n\tStore       *Store              `json:\"store\"`\n\tBots        []BotOrImport       `json:\"bots\"`\n\tThresholds  []Threshold         `json:\"thresholds\"`\n\tStatusCodes StatusCodes         `json:\"status_codes\"`\n\tDNSBL       bool                `json:\"dnsbl\"`\n\tDNSTTL      DnsTTL              `json:\"dns_ttl\"`\n\tLogging     *Logging            `json:\"logging\"`\n}\n\nfunc (c *fileConfig) Valid() error {\n\tvar errs []error\n\n\tif len(c.Bots) == 0 {\n\t\terrs = append(errs, ErrNoBotRulesDefined)\n\t}\n\n\tfor i, b := range c.Bots {\n\t\tif err := b.Valid(); err != nil {\n\t\t\terrs = append(errs, fmt.Errorf(\"bot %d: %w\", i, err))\n\t\t}\n\t}\n\n\tif c.OpenGraph.Enabled {\n\t\tif err := c.OpenGraph.Valid(); err != nil {\n\t\t\terrs = append(errs, err)\n\t\t}\n\t}\n\n\tif err := c.StatusCodes.Valid(); err != nil {\n\t\terrs = append(errs, err)\n\t}\n\n\tfor i, t := range c.Thresholds {\n\t\tif err := t.Valid(); err != nil {\n\t\t\terrs = append(errs, fmt.Errorf(\"threshold %d: %w\", i, err))\n\t\t}\n\t}\n\n\tif err := c.Logging.Valid(); err != nil {\n\t\terrs = append(errs, err)\n\t}\n\n\tif c.Store != nil {\n\t\tif err := c.Store.Valid(); err != nil {\n\t\t\terrs = append(errs, err)\n\t\t}\n\t}\n\n\tif len(errs) != 0 {\n\t\treturn fmt.Errorf(\"config is not valid:\\n%w\", errors.Join(errs...))\n\t}\n\n\treturn nil\n}\n\nfunc Load(fin io.Reader, fname string) (*Config, error) {\n\tc := &fileConfig{\n\t\tStatusCodes: StatusCodes{\n\t\t\tChallenge: http.StatusOK,\n\t\t\tDeny:      http.StatusOK,\n\t\t},\n\t\tDNSTTL: DnsTTL{\n\t\t\tForward: 300,\n\t\t\tReverse: 300,\n\t\t},\n\t\tStore: &Store{\n\t\t\tBackend: \"memory\",\n\t\t},\n\t\tLogging: (Logging{}).Default(),\n\t}\n\n\tif err := yaml.NewYAMLToJSONDecoder(fin).Decode(&c); err != nil {\n\t\treturn nil, fmt.Errorf(\"can't parse policy config YAML %s: %w\", fname, err)\n\t}\n\n\tif err := c.Valid(); err != nil {\n\t\treturn nil, err\n\t}\n\n\tresult := &Config{\n\t\tDNSBL:  c.DNSBL,\n\t\tDNSTTL: c.DNSTTL,\n\t\tOpenGraph: OpenGraph{\n\t\t\tEnabled:      c.OpenGraph.Enabled,\n\t\t\tConsiderHost: c.OpenGraph.ConsiderHost,\n\t\t\tOverride:     c.OpenGraph.Override,\n\t\t},\n\t\tStatusCodes: c.StatusCodes,\n\t\tStore:       c.Store,\n\t\tLogging:     c.Logging,\n\t}\n\n\tif c.OpenGraph.TimeToLive != \"\" {\n\t\t// XXX(Xe): already validated in Valid()\n\t\togTTL, _ := time.ParseDuration(c.OpenGraph.TimeToLive)\n\t\tresult.OpenGraph.TimeToLive = ogTTL\n\t}\n\n\tvar validationErrs []error\n\n\tfor _, boi := range c.Bots {\n\t\tif boi.ImportStatement != nil {\n\t\t\tif err := boi.load(); err != nil {\n\t\t\t\tvalidationErrs = append(validationErrs, err)\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tresult.Bots = append(result.Bots, boi.ImportStatement.Bots...)\n\t\t}\n\n\t\tif boi.BotConfig != nil {\n\t\t\tif err := boi.BotConfig.Valid(); err != nil {\n\t\t\t\tvalidationErrs = append(validationErrs, err)\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tresult.Bots = append(result.Bots, *boi.BotConfig)\n\t\t}\n\t}\n\n\tif c.Impressum != nil {\n\t\tif err := c.Impressum.Valid(); err != nil {\n\t\t\tvalidationErrs = append(validationErrs, err)\n\t\t}\n\n\t\tresult.Impressum = c.Impressum\n\t}\n\n\tif len(c.Thresholds) == 0 {\n\t\tc.Thresholds = DefaultThresholds\n\t}\n\n\tfor _, t := range c.Thresholds {\n\t\tif err := t.Valid(); err != nil {\n\t\t\tvalidationErrs = append(validationErrs, err)\n\t\t\tcontinue\n\t\t}\n\n\t\tresult.Thresholds = append(result.Thresholds, t)\n\t}\n\n\tif len(validationErrs) > 0 {\n\t\treturn nil, fmt.Errorf(\"errors validating policy config %s: %w\", fname, errors.Join(validationErrs...))\n\t}\n\n\treturn result, nil\n}\n\ntype DnsTTL struct {\n\tForward int `json:\"forward\"`\n\tReverse int `json:\"reverse\"`\n}\n\nfunc (sc DnsTTL) Valid() error {\n\tvar errs []error\n\n\tif sc.Forward < 0 {\n\t\terrs = append(errs, fmt.Errorf(\"%w: forward TTL is %d\", ErrStatusCodeNotValid, sc.Forward))\n\t}\n\n\tif sc.Reverse < 0 {\n\t\terrs = append(errs, fmt.Errorf(\"%w: reverse TTL is %d\", ErrStatusCodeNotValid, sc.Reverse))\n\t}\n\n\tif len(errs) != 0 {\n\t\treturn fmt.Errorf(\"dns TTL values not valid:\\n%w\", errors.Join(errs...))\n\t}\n\n\treturn nil\n}\n\ntype Config struct {\n\tImpressum   *Impressum\n\tStore       *Store\n\tOpenGraph   OpenGraph\n\tBots        []BotConfig\n\tThresholds  []Threshold\n\tStatusCodes StatusCodes\n\tLogging     *Logging\n\tDNSBL       bool\n\tDNSTTL      DnsTTL\n}\n\nfunc (c Config) Valid() error {\n\tvar errs []error\n\n\tif len(c.Bots) == 0 {\n\t\terrs = append(errs, ErrNoBotRulesDefined)\n\t}\n\n\tfor _, b := range c.Bots {\n\t\tif err := b.Valid(); err != nil {\n\t\t\terrs = append(errs, err)\n\t\t}\n\t}\n\n\tif len(errs) != 0 {\n\t\treturn fmt.Errorf(\"config is not valid:\\n%w\", errors.Join(errs...))\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "lib/config/config_test.go",
    "content": "package config_test\n\nimport (\n\t\"errors\"\n\t\"io/fs\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n\n\t\"github.com/TecharoHQ/anubis/data\"\n\t. \"github.com/TecharoHQ/anubis/lib/config\"\n)\n\nfunc p[V any](v V) *V { return &v }\n\nfunc TestBotValid(t *testing.T) {\n\tvar tests = []struct {\n\t\tbot  BotConfig\n\t\terr  error\n\t\tname string\n\t}{\n\t\t{\n\t\t\tname: \"simple user agent\",\n\t\t\tbot: BotConfig{\n\t\t\t\tName:           \"mozilla-ua\",\n\t\t\t\tAction:         RuleChallenge,\n\t\t\t\tUserAgentRegex: p(\"Mozilla\"),\n\t\t\t},\n\t\t\terr: nil,\n\t\t},\n\t\t{\n\t\t\tname: \"simple path\",\n\t\t\tbot: BotConfig{\n\t\t\t\tName:      \"well-known-path\",\n\t\t\t\tAction:    RuleAllow,\n\t\t\t\tPathRegex: p(\"^/.well-known/.*$\"),\n\t\t\t},\n\t\t\terr: nil,\n\t\t},\n\t\t{\n\t\t\tname: \"no rule name\",\n\t\t\tbot: BotConfig{\n\t\t\t\tAction:         RuleChallenge,\n\t\t\t\tUserAgentRegex: p(\"Mozilla\"),\n\t\t\t},\n\t\t\terr: ErrBotMustHaveName,\n\t\t},\n\t\t{\n\t\t\tname: \"no rule matcher\",\n\t\t\tbot: BotConfig{\n\t\t\t\tName:   \"broken-rule\",\n\t\t\t\tAction: RuleAllow,\n\t\t\t},\n\t\t\terr: ErrBotMustHaveUserAgentOrPath,\n\t\t},\n\t\t{\n\t\t\tname: \"both user-agent and path\",\n\t\t\tbot: BotConfig{\n\t\t\t\tName:           \"path-and-user-agent\",\n\t\t\t\tAction:         RuleDeny,\n\t\t\t\tUserAgentRegex: p(\"Mozilla\"),\n\t\t\t\tPathRegex:      p(\"^/.secret-place/.*$\"),\n\t\t\t},\n\t\t\terr: ErrBotMustHaveUserAgentOrPathNotBoth,\n\t\t},\n\t\t{\n\t\t\tname: \"unknown action\",\n\t\t\tbot: BotConfig{\n\t\t\t\tName:           \"Unknown action\",\n\t\t\t\tAction:         RuleUnknown,\n\t\t\t\tUserAgentRegex: p(\"Mozilla\"),\n\t\t\t},\n\t\t\terr: ErrUnknownAction,\n\t\t},\n\t\t{\n\t\t\tname: \"invalid user agent regex\",\n\t\t\tbot: BotConfig{\n\t\t\t\tName:           \"mozilla-ua\",\n\t\t\t\tAction:         RuleChallenge,\n\t\t\t\tUserAgentRegex: p(\"a(b\"),\n\t\t\t},\n\t\t\terr: ErrInvalidUserAgentRegex,\n\t\t},\n\t\t{\n\t\t\tname: \"invalid path regex\",\n\t\t\tbot: BotConfig{\n\t\t\t\tName:      \"mozilla-ua\",\n\t\t\t\tAction:    RuleChallenge,\n\t\t\t\tPathRegex: p(\"a(b\"),\n\t\t\t},\n\t\t\terr: ErrInvalidPathRegex,\n\t\t},\n\t\t{\n\t\t\tname: \"invalid headers regex\",\n\t\t\tbot: BotConfig{\n\t\t\t\tName:   \"mozilla-ua\",\n\t\t\t\tAction: RuleChallenge,\n\t\t\t\tHeadersRegex: map[string]string{\n\t\t\t\t\t\"Content-Type\": \"a(b\",\n\t\t\t\t},\n\t\t\t\tPathRegex: p(\"a(b\"),\n\t\t\t},\n\t\t\terr: ErrInvalidHeadersRegex,\n\t\t},\n\t\t{\n\t\t\tname: \"challenge difficulty too low\",\n\t\t\tbot: BotConfig{\n\t\t\t\tName:      \"mozilla-ua\",\n\t\t\t\tAction:    RuleChallenge,\n\t\t\t\tPathRegex: p(\"Mozilla\"),\n\t\t\t\tChallenge: &ChallengeRules{\n\t\t\t\t\tDifficulty: -1,\n\t\t\t\t\tAlgorithm:  \"fast\",\n\t\t\t\t},\n\t\t\t},\n\t\t\terr: ErrChallengeDifficultyTooLow,\n\t\t},\n\t\t{\n\t\t\tname: \"challenge difficulty too high\",\n\t\t\tbot: BotConfig{\n\t\t\t\tName:      \"mozilla-ua\",\n\t\t\t\tAction:    RuleChallenge,\n\t\t\t\tPathRegex: p(\"Mozilla\"),\n\t\t\t\tChallenge: &ChallengeRules{\n\t\t\t\t\tDifficulty: 420,\n\t\t\t\t\tAlgorithm:  \"fast\",\n\t\t\t\t},\n\t\t\t},\n\t\t\terr: ErrChallengeDifficultyTooHigh,\n\t\t},\n\t\t{\n\t\t\tname: \"invalid cidr range\",\n\t\t\tbot: BotConfig{\n\t\t\t\tName:       \"mozilla-ua\",\n\t\t\t\tAction:     RuleAllow,\n\t\t\t\tRemoteAddr: []string{\"0.0.0.0/33\"},\n\t\t\t},\n\t\t\terr: ErrInvalidCIDR,\n\t\t},\n\t\t{\n\t\t\tname: \"only filter by IP range\",\n\t\t\tbot: BotConfig{\n\t\t\t\tName:       \"mozilla-ua\",\n\t\t\t\tAction:     RuleAllow,\n\t\t\t\tRemoteAddr: []string{\"0.0.0.0/0\"},\n\t\t\t},\n\t\t\terr: nil,\n\t\t},\n\t\t{\n\t\t\tname: \"filter by user agent and IP range\",\n\t\t\tbot: BotConfig{\n\t\t\t\tName:           \"mozilla-ua\",\n\t\t\t\tAction:         RuleAllow,\n\t\t\t\tUserAgentRegex: p(\"Mozilla\"),\n\t\t\t\tRemoteAddr:     []string{\"0.0.0.0/0\"},\n\t\t\t},\n\t\t\terr: nil,\n\t\t},\n\t\t{\n\t\t\tname: \"filter by path and IP range\",\n\t\t\tbot: BotConfig{\n\t\t\t\tName:       \"mozilla-ua\",\n\t\t\t\tAction:     RuleAllow,\n\t\t\t\tPathRegex:  p(\"^.*$\"),\n\t\t\t\tRemoteAddr: []string{\"0.0.0.0/0\"},\n\t\t\t},\n\t\t\terr: nil,\n\t\t},\n\t\t{\n\t\t\tname: \"weight rule without weight\",\n\t\t\tbot: BotConfig{\n\t\t\t\tName:           \"weight-adjust-if-mozilla\",\n\t\t\t\tAction:         RuleWeigh,\n\t\t\t\tUserAgentRegex: p(\"Mozilla\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"weight rule with weight adjust\",\n\t\t\tbot: BotConfig{\n\t\t\t\tName:           \"weight-adjust-if-mozilla\",\n\t\t\t\tAction:         RuleWeigh,\n\t\t\t\tUserAgentRegex: p(\"Mozilla\"),\n\t\t\t\tWeight: &Weight{\n\t\t\t\t\tAdjust: 5,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, cs := range tests {\n\t\tt.Run(cs.name, func(t *testing.T) {\n\t\t\terr := cs.bot.Valid()\n\t\t\tif err == nil && cs.err == nil {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif err == nil && cs.err != nil {\n\t\t\t\tt.Errorf(\"didn't get an error, but wanted: %v\", cs.err)\n\t\t\t}\n\n\t\t\tif !errors.Is(err, cs.err) {\n\t\t\t\tt.Logf(\"got wrong error from Valid()\")\n\t\t\t\tt.Logf(\"wanted: %v\", cs.err)\n\t\t\t\tt.Logf(\"got:    %v\", err)\n\t\t\t\tt.Errorf(\"got invalid error from check\")\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestConfigValidKnownGood(t *testing.T) {\n\tfinfos, err := os.ReadDir(\"testdata/good\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tfor _, st := range finfos {\n\t\tt.Run(st.Name(), func(t *testing.T) {\n\t\t\tfin, err := os.Open(filepath.Join(\"testdata\", \"good\", st.Name()))\n\t\t\tif err != nil {\n\t\t\t\tt.Fatal(err)\n\t\t\t}\n\t\t\tdefer fin.Close()\n\n\t\t\tc, err := Load(fin, st.Name())\n\t\t\tif err != nil {\n\t\t\t\tt.Fatal(err)\n\t\t\t}\n\n\t\t\tif err := c.Valid(); err != nil {\n\t\t\t\tt.Error(err)\n\t\t\t}\n\n\t\t\tif len(c.Bots) == 0 {\n\t\t\t\tt.Error(\"wanted more than 0 bots, got zero\")\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestImportStatement(t *testing.T) {\n\ttype testCase struct {\n\t\terr        error\n\t\tname       string\n\t\timportPath string\n\t}\n\n\tvar tests []testCase\n\n\tfor _, folderName := range []string{\n\t\t\"apps\",\n\t\t\"bots\",\n\t\t\"common\",\n\t\t\"crawlers\",\n\t\t\"meta\",\n\t} {\n\t\tif err := fs.WalkDir(data.BotPolicies, folderName, func(path string, d fs.DirEntry, err error) error {\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tif d.IsDir() {\n\t\t\t\treturn nil\n\t\t\t}\n\t\t\tif d.Name() == \"README.md\" {\n\t\t\t\treturn nil\n\t\t\t}\n\n\t\t\ttests = append(tests, testCase{\n\t\t\t\tname:       \"(data)/\" + path,\n\t\t\t\timportPath: \"(data)/\" + path,\n\t\t\t\terr:        nil,\n\t\t\t})\n\n\t\t\treturn nil\n\t\t}); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tis := &ImportStatement{\n\t\t\t\tImport: tt.importPath,\n\t\t\t}\n\n\t\t\tif err := is.Valid(); err != nil {\n\t\t\t\tt.Errorf(\"validation error: %v\", err)\n\t\t\t}\n\n\t\t\tif len(is.Bots) == 0 {\n\t\t\t\tt.Error(\"wanted bot definitions, but got none\")\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestConfigValidBad(t *testing.T) {\n\tfinfos, err := os.ReadDir(\"testdata/bad\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tfor _, st := range finfos {\n\t\tt.Run(st.Name(), func(t *testing.T) {\n\t\t\tfin, err := os.Open(filepath.Join(\"testdata\", \"bad\", st.Name()))\n\t\t\tif err != nil {\n\t\t\t\tt.Fatal(err)\n\t\t\t}\n\t\t\tdefer fin.Close()\n\n\t\t\t_, err = Load(fin, filepath.Join(\"testdata\", \"bad\", st.Name()))\n\t\t\tif err == nil {\n\t\t\t\tt.Fatal(\"validation should have failed but didn't somehow\")\n\t\t\t} else {\n\t\t\t\tt.Log(err)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestBotConfigZero(t *testing.T) {\n\tvar b BotConfig\n\tif !b.Zero() {\n\t\tt.Error(\"zero value config.BotConfig is not zero value\")\n\t}\n\n\tb.Name = \"hi\"\n\tif b.Zero() {\n\t\tt.Error(\"config.BotConfig with name is zero value\")\n\t}\n\n\tb.UserAgentRegex = p(\".*\")\n\tif b.Zero() {\n\t\tt.Error(\"config.BotConfig with user agent regex is zero value\")\n\t}\n\n\tb.PathRegex = p(\".*\")\n\tif b.Zero() {\n\t\tt.Error(\"config.BotConfig with path regex is zero value\")\n\t}\n\n\tb.HeadersRegex = map[string]string{\"hi\": \"there\"}\n\tif b.Zero() {\n\t\tt.Error(\"config.BotConfig with headers regex is zero value\")\n\t}\n\n\tb.Action = RuleAllow\n\tif b.Zero() {\n\t\tt.Error(\"config.BotConfig with action is zero value\")\n\t}\n\n\tb.RemoteAddr = []string{\"::/0\"}\n\tif b.Zero() {\n\t\tt.Error(\"config.BotConfig with remote addresses is zero value\")\n\t}\n\n\tb.Challenge = &ChallengeRules{\n\t\tDifficulty: 4,\n\t\tAlgorithm:  DefaultAlgorithm,\n\t}\n\tif b.Zero() {\n\t\tt.Error(\"config.BotConfig with challenge rules is zero value\")\n\t}\n}\n"
  },
  {
    "path": "lib/config/expressionorlist.go",
    "content": "package config\n\nimport (\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"slices\"\n\t\"strings\"\n)\n\nvar (\n\tErrExpressionOrListMustBeStringOrObject = errors.New(\"config: this must be a string or an object\")\n\tErrExpressionEmpty                      = errors.New(\"config: this expression is empty\")\n\tErrExpressionCantHaveBoth               = errors.New(\"config: expression block can't contain multiple expression types\")\n)\n\ntype ExpressionOrList struct {\n\tExpression string   `json:\"-\" yaml:\"-\"`\n\tAll        []string `json:\"all,omitempty\" yaml:\"all,omitempty\"`\n\tAny        []string `json:\"any,omitempty\" yaml:\"any,omitempty\"`\n}\n\nfunc (eol ExpressionOrList) String() string {\n\tswitch {\n\tcase len(eol.Expression) != 0:\n\t\treturn eol.Expression\n\tcase len(eol.All) != 0:\n\t\tvar sb strings.Builder\n\t\tfor i, pred := range eol.All {\n\t\t\tif i != 0 {\n\t\t\t\tfmt.Fprintf(&sb, \" && \")\n\t\t\t}\n\t\t\tfmt.Fprintf(&sb, \"( %s )\", pred)\n\t\t}\n\t\treturn sb.String()\n\tcase len(eol.Any) != 0:\n\t\tvar sb strings.Builder\n\t\tfor i, pred := range eol.Any {\n\t\t\tif i != 0 {\n\t\t\t\tfmt.Fprintf(&sb, \" || \")\n\t\t\t}\n\t\t\tfmt.Fprintf(&sb, \"( %s )\", pred)\n\t\t}\n\t\treturn sb.String()\n\t}\n\tpanic(\"this should not happen\")\n}\n\nfunc (eol ExpressionOrList) Equal(rhs *ExpressionOrList) bool {\n\tif eol.Expression != rhs.Expression {\n\t\treturn false\n\t}\n\n\tif !slices.Equal(eol.All, rhs.All) {\n\t\treturn false\n\t}\n\n\tif !slices.Equal(eol.Any, rhs.Any) {\n\t\treturn false\n\t}\n\n\treturn true\n}\n\nfunc (eol *ExpressionOrList) MarshalYAML() (any, error) {\n\tswitch {\n\tcase len(eol.All) == 1 && len(eol.Any) == 0:\n\t\teol.Expression = eol.All[0]\n\t\teol.All = nil\n\tcase len(eol.Any) == 1 && len(eol.All) == 0:\n\t\teol.Expression = eol.Any[0]\n\t\teol.Any = nil\n\t}\n\n\tif eol.Expression != \"\" {\n\t\treturn eol.Expression, nil\n\t}\n\n\ttype RawExpressionOrList ExpressionOrList\n\treturn RawExpressionOrList(*eol), nil\n}\n\nfunc (eol *ExpressionOrList) MarshalJSON() ([]byte, error) {\n\tswitch {\n\tcase len(eol.All) == 1 && len(eol.Any) == 0:\n\t\teol.Expression = eol.All[0]\n\t\teol.All = nil\n\tcase len(eol.Any) == 1 && len(eol.All) == 0:\n\t\teol.Expression = eol.Any[0]\n\t\teol.Any = nil\n\t}\n\n\tif eol.Expression != \"\" {\n\t\treturn json.Marshal(string(eol.Expression))\n\t}\n\n\ttype RawExpressionOrList ExpressionOrList\n\tval := RawExpressionOrList(*eol)\n\treturn json.Marshal(val)\n}\n\nfunc (eol *ExpressionOrList) UnmarshalJSON(data []byte) error {\n\tswitch string(data[0]) {\n\tcase `\"`: // string\n\t\treturn json.Unmarshal(data, &eol.Expression)\n\tcase \"{\": // object\n\t\ttype RawExpressionOrList ExpressionOrList\n\t\tvar val RawExpressionOrList\n\t\tif err := json.Unmarshal(data, &val); err != nil {\n\t\t\treturn err\n\t\t}\n\t\teol.All = val.All\n\t\teol.Any = val.Any\n\n\t\treturn nil\n\t}\n\n\treturn ErrExpressionOrListMustBeStringOrObject\n}\n\nfunc (eol *ExpressionOrList) Valid() error {\n\tif eol.Expression == \"\" && len(eol.All) == 0 && len(eol.Any) == 0 {\n\t\treturn ErrExpressionEmpty\n\t}\n\tif len(eol.All) != 0 && len(eol.Any) != 0 {\n\t\treturn ErrExpressionCantHaveBoth\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "lib/config/expressionorlist_test.go",
    "content": "package config\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"testing\"\n\n\tyaml \"sigs.k8s.io/yaml/goyaml.v3\"\n)\n\nfunc TestExpressionOrListMarshalJSON(t *testing.T) {\n\tfor _, tt := range []struct {\n\t\terr    error\n\t\tinput  *ExpressionOrList\n\t\tname   string\n\t\toutput []byte\n\t}{\n\t\t{\n\t\t\tname: \"single expression\",\n\t\t\tinput: &ExpressionOrList{\n\t\t\t\tExpression: \"true\",\n\t\t\t},\n\t\t\toutput: []byte(`\"true\"`),\n\t\t\terr:    nil,\n\t\t},\n\t\t{\n\t\t\tname: \"all\",\n\t\t\tinput: &ExpressionOrList{\n\t\t\t\tAll: []string{\"true\", \"true\"},\n\t\t\t},\n\t\t\toutput: []byte(`{\"all\":[\"true\",\"true\"]}`),\n\t\t\terr:    nil,\n\t\t},\n\t\t{\n\t\t\tname: \"all one\",\n\t\t\tinput: &ExpressionOrList{\n\t\t\t\tAll: []string{\"true\"},\n\t\t\t},\n\t\t\toutput: []byte(`\"true\"`),\n\t\t\terr:    nil,\n\t\t},\n\t\t{\n\t\t\tname: \"any\",\n\t\t\tinput: &ExpressionOrList{\n\t\t\t\tAny: []string{\"true\", \"false\"},\n\t\t\t},\n\t\t\toutput: []byte(`{\"any\":[\"true\",\"false\"]}`),\n\t\t\terr:    nil,\n\t\t},\n\t\t{\n\t\t\tname: \"any one\",\n\t\t\tinput: &ExpressionOrList{\n\t\t\t\tAny: []string{\"true\"},\n\t\t\t},\n\t\t\toutput: []byte(`\"true\"`),\n\t\t\terr:    nil,\n\t\t},\n\t} {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult, err := json.Marshal(tt.input)\n\t\t\tif !errors.Is(err, tt.err) {\n\t\t\t\tt.Errorf(\"wanted marshal error: %v but got: %v\", tt.err, err)\n\t\t\t}\n\n\t\t\tif !bytes.Equal(result, tt.output) {\n\t\t\t\tt.Logf(\"wanted: %s\", string(tt.output))\n\t\t\t\tt.Logf(\"got:    %s\", string(result))\n\t\t\t\tt.Error(\"mismatched output\")\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestExpressionOrListMarshalYAML(t *testing.T) {\n\tfor _, tt := range []struct {\n\t\terr    error\n\t\tinput  *ExpressionOrList\n\t\tname   string\n\t\toutput []byte\n\t}{\n\t\t{\n\t\t\tname: \"single expression\",\n\t\t\tinput: &ExpressionOrList{\n\t\t\t\tExpression: \"true\",\n\t\t\t},\n\t\t\toutput: []byte(`\"true\"`),\n\t\t\terr:    nil,\n\t\t},\n\t\t{\n\t\t\tname: \"all\",\n\t\t\tinput: &ExpressionOrList{\n\t\t\t\tAll: []string{\"true\", \"true\"},\n\t\t\t},\n\t\t\toutput: []byte(`all:\n    - \"true\"\n    - \"true\"`),\n\t\t\terr: nil,\n\t\t},\n\t\t{\n\t\t\tname: \"all one\",\n\t\t\tinput: &ExpressionOrList{\n\t\t\t\tAll: []string{\"true\"},\n\t\t\t},\n\t\t\toutput: []byte(`\"true\"`),\n\t\t\terr:    nil,\n\t\t},\n\t\t{\n\t\t\tname: \"any\",\n\t\t\tinput: &ExpressionOrList{\n\t\t\t\tAny: []string{\"true\", \"false\"},\n\t\t\t},\n\t\t\toutput: []byte(`any:\n    - \"true\"\n    - \"false\"`),\n\t\t\terr: nil,\n\t\t},\n\t\t{\n\t\t\tname: \"any one\",\n\t\t\tinput: &ExpressionOrList{\n\t\t\t\tAny: []string{\"true\"},\n\t\t\t},\n\t\t\toutput: []byte(`\"true\"`),\n\t\t\terr:    nil,\n\t\t},\n\t} {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult, err := yaml.Marshal(tt.input)\n\t\t\tif !errors.Is(err, tt.err) {\n\t\t\t\tt.Errorf(\"wanted marshal error: %v but got: %v\", tt.err, err)\n\t\t\t}\n\n\t\t\tresult = bytes.TrimSpace(result)\n\n\t\t\tif !bytes.Equal(result, tt.output) {\n\t\t\t\tt.Logf(\"wanted: %q\", string(tt.output))\n\t\t\t\tt.Logf(\"got:    %q\", string(result))\n\t\t\t\tt.Error(\"mismatched output\")\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestExpressionOrListUnmarshalJSON(t *testing.T) {\n\tfor _, tt := range []struct {\n\t\terr      error\n\t\tvalidErr error\n\t\tresult   *ExpressionOrList\n\t\tname     string\n\t\tinp      string\n\t}{\n\t\t{\n\t\t\tname: \"simple\",\n\t\t\tinp:  `\"\\\"User-Agent\\\" in headers\"`,\n\t\t\tresult: &ExpressionOrList{\n\t\t\t\tExpression: `\"User-Agent\" in headers`,\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"object-and\",\n\t\t\tinp: `{\n\t\t\t\"all\": [\"\\\"User-Agent\\\" in headers\"]\n\t\t\t}`,\n\t\t\tresult: &ExpressionOrList{\n\t\t\t\tAll: []string{\n\t\t\t\t\t`\"User-Agent\" in headers`,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"object-or\",\n\t\t\tinp: `{\n\t\t\t\"any\": [\"\\\"User-Agent\\\" in headers\"]\n\t\t\t}`,\n\t\t\tresult: &ExpressionOrList{\n\t\t\t\tAny: []string{\n\t\t\t\t\t`\"User-Agent\" in headers`,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"both-or-and\",\n\t\t\tinp: `{\n\t\t\t\"all\": [\"\\\"User-Agent\\\" in headers\"],\n\t\t\t\"any\": [\"\\\"User-Agent\\\" in headers\"]\n\t\t\t}`,\n\t\t\tvalidErr: ErrExpressionCantHaveBoth,\n\t\t},\n\t\t{\n\t\t\tname: \"expression-empty\",\n\t\t\tinp: `{\n\t\t\t\"any\": []\n\t\t\t}`,\n\t\t\tvalidErr: ErrExpressionEmpty,\n\t\t},\n\t} {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tvar eol ExpressionOrList\n\n\t\t\tif err := json.Unmarshal([]byte(tt.inp), &eol); !errors.Is(err, tt.err) {\n\t\t\t\tt.Errorf(\"wanted unmarshal error: %v but got: %v\", tt.err, err)\n\t\t\t}\n\n\t\t\tif tt.result != nil && !eol.Equal(tt.result) {\n\t\t\t\tt.Logf(\"wanted: %#v\", tt.result)\n\t\t\t\tt.Logf(\"got:    %#v\", &eol)\n\t\t\t\tt.Fatal(\"parsed expression is not what was expected\")\n\t\t\t}\n\n\t\t\tif err := eol.Valid(); !errors.Is(err, tt.validErr) {\n\t\t\t\tt.Errorf(\"wanted validation error: %v but got: %v\", tt.err, err)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestExpressionOrListString(t *testing.T) {\n\tfor _, tt := range []struct {\n\t\tname string\n\t\tout  string\n\t\tin   ExpressionOrList\n\t}{\n\t\t{\n\t\t\tname: \"single expression\",\n\t\t\tin: ExpressionOrList{\n\t\t\t\tExpression: \"true\",\n\t\t\t},\n\t\t\tout: \"true\",\n\t\t},\n\t\t{\n\t\t\tname: \"all\",\n\t\t\tin: ExpressionOrList{\n\t\t\t\tAll: []string{\"true\"},\n\t\t\t},\n\t\t\tout: \"( true )\",\n\t\t},\n\t\t{\n\t\t\tname: \"all with &&\",\n\t\t\tin: ExpressionOrList{\n\t\t\t\tAll: []string{\"true\", \"true\"},\n\t\t\t},\n\t\t\tout: \"( true ) && ( true )\",\n\t\t},\n\t\t{\n\t\t\tname: \"any\",\n\t\t\tin: ExpressionOrList{\n\t\t\t\tAll: []string{\"true\"},\n\t\t\t},\n\t\t\tout: \"( true )\",\n\t\t},\n\t\t{\n\t\t\tname: \"any with ||\",\n\t\t\tin: ExpressionOrList{\n\t\t\t\tAny: []string{\"true\", \"true\"},\n\t\t\t},\n\t\t\tout: \"( true ) || ( true )\",\n\t\t},\n\t} {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult := tt.in.String()\n\t\t\tif result != tt.out {\n\t\t\t\tt.Errorf(\"wanted %q, got: %q\", tt.out, result)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "lib/config/geoip.go",
    "content": "package config\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"regexp\"\n\t\"strings\"\n)\n\nvar (\n\tcountryCodeRegexp = regexp.MustCompile(`^[a-zA-Z]{2}$`)\n\n\tErrNotCountryCode = errors.New(\"config.Bot: invalid country code\")\n)\n\ntype GeoIP struct {\n\tCountries []string `json:\"countries\"`\n}\n\nfunc (g *GeoIP) Valid() error {\n\tvar errs []error\n\n\tfor i, cc := range g.Countries {\n\t\tif !countryCodeRegexp.MatchString(cc) {\n\t\t\terrs = append(errs, fmt.Errorf(\"%w: %s\", ErrNotCountryCode, cc))\n\t\t}\n\n\t\tg.Countries[i] = strings.ToLower(cc)\n\t}\n\n\tif len(errs) != 0 {\n\t\treturn fmt.Errorf(\"bot.GeoIP: invalid GeoIP settings: %w\", errors.Join(errs...))\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "lib/config/geoip_test.go",
    "content": "package config\n\nimport (\n\t\"errors\"\n\t\"testing\"\n)\n\nfunc TestGeoIPValid(t *testing.T) {\n\tfor _, tt := range []struct {\n\t\terr   error\n\t\tinput *GeoIP\n\t\tname  string\n\t}{\n\t\t{\n\t\t\tname: \"basic valid\",\n\t\t\tinput: &GeoIP{\n\t\t\t\tCountries: []string{\"CA\"},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"invalid country\",\n\t\t\tinput: &GeoIP{\n\t\t\t\tCountries: []string{\"XOB\"},\n\t\t\t},\n\t\t\terr: ErrNotCountryCode,\n\t\t},\n\t} {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tif err := tt.input.Valid(); !errors.Is(err, tt.err) {\n\t\t\t\tt.Logf(\"want: %v\", tt.err)\n\t\t\t\tt.Logf(\"got:  %v\", err)\n\t\t\t\tt.Error(\"got wrong validation error\")\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "lib/config/impressum.go",
    "content": "package config\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n)\n\nvar ErrMissingValue = errors.New(\"config: missing value\")\n\ntype Impressum struct {\n\tFooter string        `json:\"footer\" yaml:\"footer\"`\n\tPage   ImpressumPage `json:\"page\" yaml:\"page\"`\n}\n\nfunc (i Impressum) Render(_ context.Context, w io.Writer) error {\n\tif _, err := fmt.Fprint(w, i.Footer); err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\nfunc (i Impressum) Valid() error {\n\tvar errs []error\n\n\tif len(i.Footer) == 0 {\n\t\terrs = append(errs, fmt.Errorf(\"%w: impressum footer must be defined\", ErrMissingValue))\n\t}\n\n\tif err := i.Page.Valid(); err != nil {\n\t\terrs = append(errs, err)\n\t}\n\n\tif len(errs) != 0 {\n\t\treturn errors.Join(errs...)\n\t}\n\n\treturn nil\n}\n\ntype ImpressumPage struct {\n\tTitle string `json:\"title\" yaml:\"title\"`\n\tBody  string `json:\"body\" yaml:\"body\"`\n}\n\nfunc (ip ImpressumPage) Render(_ context.Context, w io.Writer) error {\n\tif _, err := fmt.Fprint(w, ip.Body); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc (ip ImpressumPage) Valid() error {\n\tvar errs []error\n\n\tif len(ip.Title) == 0 {\n\t\terrs = append(errs, fmt.Errorf(\"%w: impressum page title must be defined\", ErrMissingValue))\n\t}\n\n\tif len(ip.Body) == 0 {\n\t\terrs = append(errs, fmt.Errorf(\"%w: impressum body title must be defined\", ErrMissingValue))\n\t}\n\n\tif len(errs) != 0 {\n\t\treturn errors.Join(errs...)\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "lib/config/impressum_test.go",
    "content": "package config\n\nimport (\n\t\"bytes\"\n\t\"errors\"\n\t\"testing\"\n)\n\nfunc TestImpressumValid(t *testing.T) {\n\tfor _, cs := range []struct {\n\t\terr  error\n\t\tinp  Impressum\n\t\tname string\n\t}{\n\t\t{\n\t\t\tname: \"basic happy path\",\n\t\t\tinp: Impressum{\n\t\t\t\tFooter: \"<p>Website hosted by Techaro.<p>\",\n\t\t\t\tPage: ImpressumPage{\n\t\t\t\t\tTitle: \"Techaro Imprint\",\n\t\t\t\t\tBody:  \"<p>This is an imprint page.</p>\",\n\t\t\t\t},\n\t\t\t},\n\t\t\terr: nil,\n\t\t},\n\t\t{\n\t\t\tname: \"no footer\",\n\t\t\tinp: Impressum{\n\t\t\t\tFooter: \"\",\n\t\t\t\tPage: ImpressumPage{\n\t\t\t\t\tTitle: \"Techaro Imprint\",\n\t\t\t\t\tBody:  \"<p>This is an imprint page.</p>\",\n\t\t\t\t},\n\t\t\t},\n\t\t\terr: ErrMissingValue,\n\t\t},\n\t\t{\n\t\t\tname: \"page not valid\",\n\t\t\tinp: Impressum{\n\t\t\t\tFooter: \"test page please ignore\",\n\t\t\t},\n\t\t\terr: ErrMissingValue,\n\t\t},\n\t} {\n\t\tt.Run(cs.name, func(t *testing.T) {\n\t\t\tif err := cs.inp.Valid(); !errors.Is(err, cs.err) {\n\t\t\t\tt.Logf(\"want: %v\", cs.err)\n\t\t\t\tt.Logf(\"got:  %v\", err)\n\t\t\t\tt.Error(\"validation failed\")\n\t\t\t}\n\n\t\t\tvar buf bytes.Buffer\n\t\t\tif err := cs.inp.Render(t.Context(), &buf); err != nil {\n\t\t\t\tt.Errorf(\"can't render footer: %v\", err)\n\t\t\t}\n\n\t\t\tif err := cs.inp.Page.Render(t.Context(), &buf); err != nil {\n\t\t\t\tt.Errorf(\"can't render page: %v\", err)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "lib/config/logging.go",
    "content": "package config\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"log/slog\"\n)\n\nvar (\n\tErrMissingLoggingFileConfig = errors.New(\"config.Logging: missing value parameters in logging block\")\n\tErrInvalidLoggingSink       = errors.New(\"config.Logging: invalid sink\")\n\tErrInvalidLoggingFileConfig = errors.New(\"config.LoggingFileConfig: invalid parameters\")\n\tErrOutOfRange               = errors.New(\"config: error out of range\")\n)\n\ntype Logging struct {\n\tSink       string             `json:\"sink\"`       // Logging sink, either \"stdio\" or \"file\"\n\tLevel      *slog.Level        `json:\"level\"`      // Log level, if set supersedes the level in flags\n\tParameters *LoggingFileConfig `json:\"parameters\"` // Logging parameters, to be dynamic in the future\n}\n\nconst (\n\tLogSinkStdio = \"stdio\"\n\tLogSinkFile  = \"file\"\n)\n\nfunc (l *Logging) Valid() error {\n\tvar errs []error\n\n\tswitch l.Sink {\n\tcase LogSinkStdio:\n\t\t// no validation needed\n\tcase LogSinkFile:\n\t\tif l.Parameters == nil {\n\t\t\terrs = append(errs, ErrMissingLoggingFileConfig)\n\t\t}\n\n\t\tif err := l.Parameters.Valid(); err != nil {\n\t\t\terrs = append(errs, err)\n\t\t}\n\tdefault:\n\t\terrs = append(errs, fmt.Errorf(\"%w: sink %s is unknown to me\", ErrInvalidLoggingSink, l.Sink))\n\t}\n\n\tif len(errs) != 0 {\n\t\treturn errors.Join(errs...)\n\t}\n\n\treturn nil\n}\n\nfunc (Logging) Default() *Logging {\n\treturn &Logging{\n\t\tSink: \"stdio\",\n\t}\n}\n\ntype LoggingFileConfig struct {\n\tFilename     string `json:\"file\"`\n\tMaxBackups   int    `json:\"maxBackups\"`\n\tMaxBytes     int64  `json:\"maxBytes\"`\n\tMaxAge       int    `json:\"maxAge\"`\n\tCompress     bool   `json:\"compress\"`\n\tUseLocalTime bool   `json:\"useLocalTime\"`\n}\n\nfunc (lfc *LoggingFileConfig) Valid() error {\n\tif lfc == nil {\n\t\treturn fmt.Errorf(\"logging file config is nil, why are you calling this?\")\n\t}\n\n\tvar errs []error\n\n\tif lfc.Zero() {\n\t\terrs = append(errs, ErrMissingValue)\n\t}\n\n\tif lfc.Filename == \"\" {\n\t\terrs = append(errs, fmt.Errorf(\"%w: filename\", ErrMissingValue))\n\t}\n\n\tif lfc.MaxBackups < 0 {\n\t\terrs = append(errs, fmt.Errorf(\"%w: max backup count %d is not greater than or equal to zero\", ErrOutOfRange, lfc.MaxBackups))\n\t}\n\n\tif lfc.MaxAge < 0 {\n\t\terrs = append(errs, fmt.Errorf(\"%w: max backup count %d is not greater than or equal to zero\", ErrOutOfRange, lfc.MaxAge))\n\t}\n\n\tif len(errs) != 0 {\n\t\terrs = append([]error{ErrInvalidLoggingFileConfig}, errs...)\n\t\treturn errors.Join(errs...)\n\t}\n\n\treturn nil\n}\n\nfunc (lfc LoggingFileConfig) Zero() bool {\n\tfor _, cond := range []bool{\n\t\tlfc.Filename != \"\",\n\t\tlfc.MaxBackups != 0,\n\t\tlfc.MaxBytes != 0,\n\t\tlfc.MaxAge != 0,\n\t\tlfc.Compress,\n\t\tlfc.UseLocalTime,\n\t} {\n\t\tif cond {\n\t\t\treturn false\n\t\t}\n\t}\n\n\treturn true\n}\n\nfunc (LoggingFileConfig) Default() *LoggingFileConfig {\n\treturn &LoggingFileConfig{\n\t\tFilename:     \"./var/anubis.log\",\n\t\tMaxBackups:   3,\n\t\tMaxBytes:     104857600, // 100 Mi\n\t\tMaxAge:       7,         // 7 days\n\t\tCompress:     true,\n\t\tUseLocalTime: false,\n\t}\n}\n"
  },
  {
    "path": "lib/config/logging_test.go",
    "content": "package config\n\nimport (\n\t\"errors\"\n\t\"testing\"\n)\n\nfunc TestLoggingValid(t *testing.T) {\n\tfor _, tt := range []struct {\n\t\tname  string\n\t\tinput *Logging\n\t\twant  error\n\t}{\n\t\t{\n\t\t\tname:  \"simple happy\",\n\t\t\tinput: (Logging{}).Default(),\n\t\t},\n\t\t{\n\t\t\tname: \"default file config\",\n\t\t\tinput: &Logging{\n\t\t\t\tSink:       LogSinkFile,\n\t\t\t\tParameters: (&LoggingFileConfig{}).Default(),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"invalid sink\",\n\t\t\tinput: &Logging{\n\t\t\t\tSink: \"taco invalid\",\n\t\t\t},\n\t\t\twant: ErrInvalidLoggingSink,\n\t\t},\n\t\t{\n\t\t\tname: \"missing parameters\",\n\t\t\tinput: &Logging{\n\t\t\t\tSink: LogSinkFile,\n\t\t\t},\n\t\t\twant: ErrMissingLoggingFileConfig,\n\t\t},\n\t\t{\n\t\t\tname: \"invalid parameters\",\n\t\t\tinput: &Logging{\n\t\t\t\tSink:       LogSinkFile,\n\t\t\t\tParameters: &LoggingFileConfig{},\n\t\t\t},\n\t\t\twant: ErrInvalidLoggingFileConfig,\n\t\t},\n\t\t{\n\t\t\tname: \"file sink with no filename\",\n\t\t\tinput: &Logging{\n\t\t\t\tSink: LogSinkFile,\n\t\t\t\tParameters: &LoggingFileConfig{\n\t\t\t\t\tFilename:     \"\",\n\t\t\t\t\tMaxBackups:   3,\n\t\t\t\t\tMaxBytes:     104857600, // 100 Mi\n\t\t\t\t\tMaxAge:       7,         // 7 days\n\t\t\t\t\tCompress:     true,\n\t\t\t\t\tUseLocalTime: false,\n\t\t\t\t},\n\t\t\t},\n\t\t\twant: ErrMissingValue,\n\t\t},\n\t\t{\n\t\t\tname: \"file sink with negative max backups\",\n\t\t\tinput: &Logging{\n\t\t\t\tSink: LogSinkFile,\n\t\t\t\tParameters: &LoggingFileConfig{\n\t\t\t\t\tFilename:     \"./var/anubis.log\",\n\t\t\t\t\tMaxBackups:   -3,\n\t\t\t\t\tMaxBytes:     104857600, // 100 Mi\n\t\t\t\t\tMaxAge:       7,         // 7 days\n\t\t\t\t\tCompress:     true,\n\t\t\t\t\tUseLocalTime: false,\n\t\t\t\t},\n\t\t\t},\n\t\t\twant: ErrOutOfRange,\n\t\t},\n\t\t{\n\t\t\tname: \"file sink with negative max age\",\n\t\t\tinput: &Logging{\n\t\t\t\tSink: LogSinkFile,\n\t\t\t\tParameters: &LoggingFileConfig{\n\t\t\t\t\tFilename:     \"./var/anubis.log\",\n\t\t\t\t\tMaxBackups:   3,\n\t\t\t\t\tMaxBytes:     104857600, // 100 Mi\n\t\t\t\t\tMaxAge:       -7,        // 7 days\n\t\t\t\t\tCompress:     true,\n\t\t\t\t\tUseLocalTime: false,\n\t\t\t\t},\n\t\t\t},\n\t\t\twant: ErrOutOfRange,\n\t\t},\n\t} {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\terr := tt.input.Valid()\n\n\t\t\tif !errors.Is(err, tt.want) {\n\t\t\t\tt.Logf(\"wanted error: %v\", tt.want)\n\t\t\t\tt.Logf(\"   got error: %v\", err)\n\t\t\t\tt.Fatal(\"got wrong error\")\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "lib/config/opengraph.go",
    "content": "package config\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"time\"\n)\n\nvar (\n\tErrInvalidOpenGraphConfig   = errors.New(\"config.OpenGraph: invalid OpenGraph configuration\")\n\tErrOpenGraphTTLDoesNotParse = 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)\")\n\tErrOpenGraphMissingProperty = errors.New(\"config.OpenGraph: default opengraph tags missing a property\")\n)\n\ntype openGraphFileConfig struct {\n\tOverride     map[string]string `json:\"override,omitempty\" yaml:\"override,omitempty\"`\n\tTimeToLive   string            `json:\"ttl\" yaml:\"ttl\"`\n\tEnabled      bool              `json:\"enabled\" yaml:\"enabled\"`\n\tConsiderHost bool              `json:\"considerHost\" yaml:\"enabled\"`\n}\n\ntype OpenGraph struct {\n\tOverride     map[string]string `json:\"override,omitempty\" yaml:\"override,omitempty\"`\n\tTimeToLive   time.Duration     `json:\"ttl\" yaml:\"ttl\"`\n\tEnabled      bool              `json:\"enabled\" yaml:\"enabled\"`\n\tConsiderHost bool              `json:\"considerHost\" yaml:\"enabled\"`\n}\n\nfunc (og *openGraphFileConfig) Valid() error {\n\tvar errs []error\n\n\tif _, err := time.ParseDuration(og.TimeToLive); err != nil {\n\t\terrs = append(errs, fmt.Errorf(\"%w: ParseDuration(%q) returned: %w\", ErrOpenGraphTTLDoesNotParse, og.TimeToLive, err))\n\t}\n\n\tif len(og.Override) != 0 {\n\t\tfor _, tag := range []string{\n\t\t\t\"og:title\",\n\t\t} {\n\t\t\tif _, ok := og.Override[tag]; !ok {\n\t\t\t\terrs = append(errs, fmt.Errorf(\"%w: %s\", ErrOpenGraphMissingProperty, tag))\n\t\t\t}\n\t\t}\n\t}\n\n\tif len(errs) != 0 {\n\t\treturn errors.Join(ErrInvalidOpenGraphConfig, errors.Join(errs...))\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "lib/config/opengraph_test.go",
    "content": "package config\n\nimport (\n\t\"errors\"\n\t\"testing\"\n)\n\nfunc TestOpenGraphFileConfigValid(t *testing.T) {\n\tfor _, tt := range []struct {\n\t\terr   error\n\t\tinput *openGraphFileConfig\n\t\tname  string\n\t}{\n\t\t{\n\t\t\tname: \"basic happy path\",\n\t\t\tinput: &openGraphFileConfig{\n\t\t\t\tEnabled:      true,\n\t\t\t\tConsiderHost: false,\n\t\t\t\tTimeToLive:   \"1h\",\n\t\t\t\tOverride:     map[string]string{},\n\t\t\t},\n\t\t\terr: nil,\n\t\t},\n\t\t{\n\t\t\tname: \"basic happy path with default\",\n\t\t\tinput: &openGraphFileConfig{\n\t\t\t\tEnabled:      true,\n\t\t\t\tConsiderHost: false,\n\t\t\t\tTimeToLive:   \"1h\",\n\t\t\t\tOverride: map[string]string{\n\t\t\t\t\t\"og:title\": \"foobar\",\n\t\t\t\t},\n\t\t\t},\n\t\t\terr: nil,\n\t\t},\n\t\t{\n\t\t\tname: \"invalid time duration\",\n\t\t\tinput: &openGraphFileConfig{\n\t\t\t\tEnabled:      true,\n\t\t\t\tConsiderHost: false,\n\t\t\t\tTimeToLive:   \"taco\",\n\t\t\t\tOverride:     map[string]string{},\n\t\t\t},\n\t\t\terr: ErrOpenGraphTTLDoesNotParse,\n\t\t},\n\t\t{\n\t\t\tname: \"missing og:title in defaults\",\n\t\t\tinput: &openGraphFileConfig{\n\t\t\t\tEnabled:      true,\n\t\t\t\tConsiderHost: false,\n\t\t\t\tTimeToLive:   \"1h\",\n\t\t\t\tOverride: map[string]string{\n\t\t\t\t\t\"description\": \"foobar\",\n\t\t\t\t},\n\t\t\t},\n\t\t\terr: ErrOpenGraphMissingProperty,\n\t\t},\n\t} {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tif err := tt.input.Valid(); !errors.Is(err, tt.err) {\n\t\t\t\tt.Logf(\"wanted error: %v\", tt.err)\n\t\t\t\tt.Logf(\"got error:    %v\", err)\n\t\t\t\tt.Error(\"validation failed\")\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "lib/config/store.go",
    "content": "package config\n\nimport (\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\n\t\"github.com/TecharoHQ/anubis/lib/store\"\n\t_ \"github.com/TecharoHQ/anubis/lib/store/all\"\n)\n\nvar (\n\tErrNoStoreBackend      = errors.New(\"config.Store: no backend defined\")\n\tErrUnknownStoreBackend = errors.New(\"config.Store: unknown backend\")\n)\n\ntype Store struct {\n\tBackend    string          `json:\"backend\"`\n\tParameters json.RawMessage `json:\"parameters\"`\n}\n\nfunc (s *Store) Valid() error {\n\tvar errs []error\n\n\tif len(s.Backend) == 0 {\n\t\terrs = append(errs, ErrNoStoreBackend)\n\t}\n\n\tfac, ok := store.Get(s.Backend)\n\tswitch ok {\n\tcase true:\n\t\tif err := fac.Valid(s.Parameters); err != nil {\n\t\t\terrs = append(errs, err)\n\t\t}\n\tcase false:\n\t\terrs = append(errs, fmt.Errorf(\"%w: %q\", ErrUnknownStoreBackend, s.Backend))\n\t}\n\n\tif len(errs) != 0 {\n\t\treturn errors.Join(errs...)\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "lib/config/store_test.go",
    "content": "package config_test\n\nimport (\n\t\"encoding/json\"\n\t\"errors\"\n\t\"testing\"\n\n\t\"github.com/TecharoHQ/anubis/lib/config\"\n\t\"github.com/TecharoHQ/anubis/lib/store/bbolt\"\n\t\"github.com/TecharoHQ/anubis/lib/store/valkey\"\n)\n\nfunc TestStoreValid(t *testing.T) {\n\tfor _, tt := range []struct {\n\t\terr   error\n\t\tname  string\n\t\tinput config.Store\n\t}{\n\t\t{\n\t\t\tname:  \"no backend\",\n\t\t\tinput: config.Store{},\n\t\t\terr:   config.ErrNoStoreBackend,\n\t\t},\n\t\t{\n\t\t\tname: \"in-memory backend\",\n\t\t\tinput: config.Store{\n\t\t\t\tBackend: \"memory\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"bbolt backend\",\n\t\t\tinput: config.Store{\n\t\t\t\tBackend:    \"bbolt\",\n\t\t\t\tParameters: json.RawMessage(`{\"path\": \"/tmp/foo\", \"bucket\": \"bar\"}`),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"valkey backend\",\n\t\t\tinput: config.Store{\n\t\t\t\tBackend:    \"valkey\",\n\t\t\t\tParameters: json.RawMessage(`{\"url\": \"redis://valkey:6379/0\"}`),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"valkey backend no URL\",\n\t\t\tinput: config.Store{\n\t\t\t\tBackend:    \"valkey\",\n\t\t\t\tParameters: json.RawMessage(`{}`),\n\t\t\t},\n\t\t\terr: valkey.ErrNoURL,\n\t\t},\n\t\t{\n\t\t\tname: \"valkey backend bad URL\",\n\t\t\tinput: config.Store{\n\t\t\t\tBackend:    \"valkey\",\n\t\t\t\tParameters: json.RawMessage(`{\"url\": \"http://anubis.techaro.lol\"}`),\n\t\t\t},\n\t\t\terr: valkey.ErrBadURL,\n\t\t},\n\t\t{\n\t\t\tname: \"bbolt backend no path\",\n\t\t\tinput: config.Store{\n\t\t\t\tBackend:    \"bbolt\",\n\t\t\t\tParameters: json.RawMessage(`{\"path\": \"\", \"bucket\": \"bar\"}`),\n\t\t\t},\n\t\t\terr: bbolt.ErrMissingPath,\n\t\t},\n\t\t{\n\t\t\tname: \"unknown backend\",\n\t\t\tinput: config.Store{\n\t\t\t\tBackend: \"taco salad\",\n\t\t\t},\n\t\t\terr: config.ErrUnknownStoreBackend,\n\t\t},\n\t} {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tif err := tt.input.Valid(); !errors.Is(err, tt.err) {\n\t\t\t\tt.Logf(\"want: %v\", tt.err)\n\t\t\t\tt.Logf(\"got:  %v\", err)\n\t\t\t\tt.Error(\"invalid error returned\")\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "lib/config/testdata/bad/badregexes.json",
    "content": "{\n  \"bots\": [\n    {\n      \"name\": \"path-bad\",\n      \"path_regex\": \"a(b\",\n      \"action\": \"DENY\"\n    },\n    {\n      \"name\": \"user-agent-bad\",\n      \"user_agent_regex\": \"a(b\",\n      \"action\": \"DENY\"\n    },\n    {\n      \"name\": \"headers-bad\",\n      \"headers\": {\n        \"Accept-Encoding\": \"a(b\"\n      },\n      \"action\": \"DENY\"\n    }\n  ]\n}\n"
  },
  {
    "path": "lib/config/testdata/bad/badregexes.yaml",
    "content": "bots:\n  - name: path-bad\n    path_regex: \"a(b\"\n    action: DENY\n  - name: user-agent-bad\n    user_agent_regex: \"a(b\"\n    action: DENY\n"
  },
  {
    "path": "lib/config/testdata/bad/dns-ttl-custom.yaml",
    "content": "dns_ttl:\n  forward: 60.0\n  reverse: \"600\"\n\nbots:\n  - name: \"test\"\n    user_agent_regex: \".*\"\n    action: \"DENY\"\n"
  },
  {
    "path": "lib/config/testdata/bad/import_and_bot.json",
    "content": "{\n  \"bots\": [\n    {\n      \"import\": \"(data)/bots/ai-catchall.yaml\",\n      \"name\": \"generic-browser\",\n      \"user_agent_regex\": \"Mozilla|Opera\\n\",\n      \"action\": \"CHALLENGE\"\n    }\n  ]\n}\n"
  },
  {
    "path": "lib/config/testdata/bad/import_and_bot.yaml",
    "content": "bots:\n  - import: (data)/bots/ai-catchall.yaml\n    name: generic-browser\n    user_agent_regex: >\n      Mozilla|Opera\n    action: CHALLENGE\n"
  },
  {
    "path": "lib/config/testdata/bad/import_invalid_file.json",
    "content": "{\n  \"bots\": [\n    {\n      \"import\": \"(data)/does-not-exist-fake-file.yaml\"\n    }\n  ]\n}\n"
  },
  {
    "path": "lib/config/testdata/bad/import_invalid_file.yaml",
    "content": "bots:\n  - import: (data)/does-not-exist-fake-file.yaml\n"
  },
  {
    "path": "lib/config/testdata/bad/impressum-no-footer.yaml",
    "content": "bots:\n  - name: simple-weight-adjust\n    action: WEIGH\n    user_agent_regex: Mozilla\n    weight:\n      adjust: 5\n\nimpressum:\n  page:\n    title: Test\n    body: <p>This is a test</p>\n"
  },
  {
    "path": "lib/config/testdata/bad/impressum-no-page-contents.yaml",
    "content": "bots:\n  - name: simple-weight-adjust\n    action: WEIGH\n    user_agent_regex: Mozilla\n    weight:\n      adjust: 5\n\nimpressum:\n  footer: \"Hi there these are WORDS on the INTERNET.\"\n  page: {}\n"
  },
  {
    "path": "lib/config/testdata/bad/invalid.json",
    "content": "{\n  \"bots\": [{}]\n}\n"
  },
  {
    "path": "lib/config/testdata/bad/invalid.yaml",
    "content": "bots: []\n"
  },
  {
    "path": "lib/config/testdata/bad/logging-invalid-sink.yaml",
    "content": "logging:\n  sink: \"nope\"\n"
  },
  {
    "path": "lib/config/testdata/bad/logging-no-parameters.yaml",
    "content": "logging:\n  sink: \"file\"\n"
  },
  {
    "path": "lib/config/testdata/bad/multiple_expression_types.json",
    "content": "{\n  \"bots\": [\n    {\n      \"name\": \"multiple-expression-types\",\n      \"action\": \"ALLOW\",\n      \"expression\": {\n        \"all\": [\n          \"userAgent.startsWith(\\\"git/\\\") || userAgent.contains(\\\"libgit\\\")\",\n          \"\\\"Git-Protocol\\\" in headers && headers[\\\"Git-Protocol\\\"] == \\\"version=2\\\"\\n\"\n        ],\n        \"any\": [\"userAgent.startsWith(\\\"evilbot/\\\")\"]\n      }\n    }\n  ]\n}\n"
  },
  {
    "path": "lib/config/testdata/bad/multiple_expression_types.yaml",
    "content": "bots:\n  - name: multiple-expression-types\n    action: ALLOW\n    expression:\n      all:\n        - userAgent.startsWith(\"git/\") || userAgent.contains(\"libgit\")\n        - >\n          \"Git-Protocol\" in headers && headers[\"Git-Protocol\"] == \"version=2\"\n      any:\n        - userAgent.startsWith(\"evilbot/\")\n"
  },
  {
    "path": "lib/config/testdata/bad/nobots.json",
    "content": "{}\n"
  },
  {
    "path": "lib/config/testdata/bad/nobots.yaml",
    "content": "{}\n"
  },
  {
    "path": "lib/config/testdata/bad/opengraph_bad_ttl.yaml",
    "content": "bots:\n  - name: everything\n    user_agent_regex: .*\n    action: DENY\n\nopenGraph:\n  enabled: true\n  considerHost: false\n  ttl: taco\n  default:\n    \"og:title\": \"Xe's magic land of fun\"\n    \"og:description\": \"We're no strangers to love, you know the rules and so do I\"\n"
  },
  {
    "path": "lib/config/testdata/bad/regex_ends_newline.json",
    "content": "{\n  \"bots\": [\n    {\n      \"name\": \"user-agent-ends-newline\",\n      \"user_agent_regex\": \"Mozilla\\n\",\n      \"action\": \"CHALLENGE\"\n    },\n    {\n      \"name\": \"path-ends-newline\",\n      \"path_regex\": \"^/evil/.*$\\n\",\n      \"action\": \"CHALLENGE\"\n    },\n    {\n      \"name\": \"headers-ends-newline\",\n      \"headers_regex\": {\n        \"CF-Worker\": \".*\\n\"\n      },\n      \"action\": \"CHALLENGE\"\n    }\n  ]\n}\n"
  },
  {
    "path": "lib/config/testdata/bad/regex_ends_newline.yaml",
    "content": "bots:\n  - name: user-agent-ends-newline\n    # Subtle bug: this ends with a newline\n    user_agent_regex: >\n      Mozilla\n    action: CHALLENGE\n  - name: path-ends-newline\n    # Subtle bug: this ends with a newline\n    path_regex: >\n      ^/evil/.*$\n    action: CHALLENGE\n  - name: headers-ends-newline\n    # Subtle bug: this ends with a newline\n    headers_regex:\n      CF-Worker: >\n        .*\n    action: CHALLENGE\n"
  },
  {
    "path": "lib/config/testdata/bad/status-codes-0.json",
    "content": "{\n  \"bots\": [\n    {\n      \"name\": \"everything\",\n      \"user_agent_regex\": \".*\",\n      \"action\": \"DENY\"\n    }\n  ],\n  \"status_codes\": {\n    \"CHALLENGE\": 0,\n    \"DENY\": 0\n  }\n}\n"
  },
  {
    "path": "lib/config/testdata/bad/status-codes-0.yaml",
    "content": "bots:\n  - name: everything\n    user_agent_regex: .*\n    action: DENY\n\nstatus_codes:\n  CHALLENGE: 0\n  DENY: 0\n"
  },
  {
    "path": "lib/config/testdata/bad/threshold-challenge-without-challenge.yaml",
    "content": "bots:\n  - name: simple-weight-adjust\n    action: WEIGH\n    user_agent_regex: Mozilla\n    weight:\n      adjust: 5\n\nthresholds:\n  - name: extreme-suspicion\n    expression: \"true\"\n    action: WEIGH\n"
  },
  {
    "path": "lib/config/testdata/bad/thresholds.yaml",
    "content": "bots:\n  - name: simple-weight-adjust\n    action: WEIGH\n    user_agent_regex: Mozilla\n    weight:\n      adjust: 5\n\nthresholds:\n  - name: extreme-suspicion\n    expression: \"true\"\n    action: WEIGH\n    challenge:\n      algorithm: fast\n      difficulty: 4\n      report_as: 4\n"
  },
  {
    "path": "lib/config/testdata/bad/unparseable.json",
    "content": "}"
  },
  {
    "path": "lib/config/testdata/bad/unparseable.yaml",
    "content": "}\n"
  },
  {
    "path": "lib/config/testdata/good/allow_everyone.json",
    "content": "{\n  \"bots\": [\n    {\n      \"name\": \"everyones-invited\",\n      \"remote_addresses\": [\"0.0.0.0/0\", \"::/0\"],\n      \"action\": \"ALLOW\"\n    }\n  ]\n}\n"
  },
  {
    "path": "lib/config/testdata/good/allow_everyone.yaml",
    "content": "bots:\n  - name: everyones-invited\n    remote_addresses:\n      - \"0.0.0.0/0\"\n      - \"::/0\"\n    action: ALLOW\n"
  },
  {
    "path": "lib/config/testdata/good/block_cf_workers.json",
    "content": "{\n  \"bots\": [\n    {\n      \"name\": \"Cloudflare Workers\",\n      \"headers_regex\": {\n        \"CF-Worker\": \".*\"\n      },\n      \"action\": \"DENY\"\n    }\n  ],\n  \"dnsbl\": false\n}\n"
  },
  {
    "path": "lib/config/testdata/good/block_cf_workers.yaml",
    "content": "bots:\n  - name: cloudflare-workers\n    headers_regex:\n      CF-Worker: .*\n    action: DENY\n"
  },
  {
    "path": "lib/config/testdata/good/challenge_cloudflare.yaml",
    "content": "bots:\n  - name: challenge-cloudflare\n    action: CHALLENGE\n    asns:\n      match:\n        - 13335 # Cloudflare\n"
  },
  {
    "path": "lib/config/testdata/good/challengemozilla.json",
    "content": "{\n  \"bots\": [\n    {\n      \"name\": \"generic-browser\",\n      \"user_agent_regex\": \"Mozilla\",\n      \"action\": \"CHALLENGE\"\n    }\n  ]\n}\n"
  },
  {
    "path": "lib/config/testdata/good/challengemozilla.yaml",
    "content": "bots:\n  - name: generic-browser\n    user_agent_regex: Mozilla\n    action: CHALLENGE\n"
  },
  {
    "path": "lib/config/testdata/good/dns-ttl-custom.yaml",
    "content": "dns_ttl:\n  forward: 600\n  reverse: 600\n\nbots:\n  - name: \"test\"\n    user_agent_regex: \".*\"\n    action: \"DENY\"\n"
  },
  {
    "path": "lib/config/testdata/good/entropy.yaml",
    "content": "bots:\n  - name: total-randomness\n    action: ALLOW\n    expression:\n      all:\n        - '\"Accept\" in headers'\n        - headers[\"Accept\"].contains(\"text/html\")\n        - randInt(1) == 0\n"
  },
  {
    "path": "lib/config/testdata/good/everything_blocked.json",
    "content": "{\n  \"bots\": [\n    {\n      \"name\": \"everything\",\n      \"user_agent_regex\": \".*\",\n      \"action\": \"DENY\"\n    }\n  ],\n  \"dnsbl\": false\n}\n"
  },
  {
    "path": "lib/config/testdata/good/everything_blocked.yaml",
    "content": "bots:\n  - name: everything\n    user_agent_regex: .*\n    action: DENY\n"
  },
  {
    "path": "lib/config/testdata/good/geoip_us.yaml",
    "content": "bots:\n  - name: compute-tarrif-us\n    action: CHALLENGE\n    geoip:\n      countries:\n        - US\n"
  },
  {
    "path": "lib/config/testdata/good/git_client.json",
    "content": "{\n  \"bots\": [\n    {\n      \"name\": \"allow-git-clients\",\n      \"action\": \"ALLOW\",\n      \"expression\": {\n        \"all\": [\n          \"userAgent.startsWith(\\\"git/\\\") || userAgent.contains(\\\"libgit\\\")\",\n          \"\\\"Git-Protocol\\\" in headers && headers[\\\"Git-Protocol\\\"] == \\\"version=2\\\"\"\n        ]\n      }\n    }\n  ]\n}\n"
  },
  {
    "path": "lib/config/testdata/good/git_client.yaml",
    "content": "bots:\n  - name: allow-git-clients\n    action: ALLOW\n    expression:\n      all:\n        - userAgent.startsWith(\"git/\") || userAgent.contains(\"libgit\")\n        - >\n          \"Git-Protocol\" in headers && headers[\"Git-Protocol\"] == \"version=2\"\n"
  },
  {
    "path": "lib/config/testdata/good/import_filesystem.json",
    "content": "{\n  \"bots\": [\n    {\n      \"import\": \"./testdata/hack-test.json\"\n    }\n  ]\n}\n"
  },
  {
    "path": "lib/config/testdata/good/import_filesystem.yaml",
    "content": "bots:\n  - import: ./testdata/hack-test.yaml\n"
  },
  {
    "path": "lib/config/testdata/good/import_keep_internet_working.json",
    "content": "{\n  \"bots\": [\n    {\n      \"import\": \"(data)/common/keep-internet-working.yaml\"\n    }\n  ]\n}\n"
  },
  {
    "path": "lib/config/testdata/good/import_keep_internet_working.yaml",
    "content": "bots:\n  - import: (data)/common/keep-internet-working.yaml\n"
  },
  {
    "path": "lib/config/testdata/good/impressum.yaml",
    "content": "bots:\n  - name: simple\n    action: CHALLENGE\n    user_agent_regex: Mozilla\n\nimpressum:\n  footer: \"Hi these are WORDS on the INTERNET.\"\n  page:\n    title: Test\n    body: <p>This is a test</p>\n"
  },
  {
    "path": "lib/config/testdata/good/logging-file.yaml",
    "content": "bots:\n  - name: simple\n    action: CHALLENGE\n    user_agent_regex: Mozilla\n\nlogs:\n  sink: \"file\"\n  parameters:\n    file: \"/var/log/botstopper/default.log\"\n    maxBackups: 3 # keep at least 3 old copies\n    maxBytes: 67108864 # each file can have up to 64 MB of logs\n    maxAge: 7 # rotate files out every n days\n    oldFileTimeFormat: 2006-01-02T15-04-05 # RFC 3339-ish\n    compress: true\n    useLocalTime: false # timezone for rotated files is UTC\n"
  },
  {
    "path": "lib/config/testdata/good/logging-stdio.yaml",
    "content": "bots:\n  - name: simple\n    action: CHALLENGE\n    user_agent_regex: Mozilla\n\nlogging:\n  sink: \"stdio\"\n"
  },
  {
    "path": "lib/config/testdata/good/no-thresholds.yaml",
    "content": "bots:\n  - name: simple-weight-adjust\n    action: WEIGH\n    user_agent_regex: Mozilla\n    weight:\n      adjust: 5\n\nthresholds: []\n"
  },
  {
    "path": "lib/config/testdata/good/old_xesite.json",
    "content": "{\n  \"bots\": [\n    {\n      \"name\": \"amazonbot\",\n      \"user_agent_regex\": \"Amazonbot\",\n      \"action\": \"DENY\"\n    },\n    {\n      \"name\": \"googlebot\",\n      \"user_agent_regex\": \"\\\\+http\\\\:\\\\/\\\\/www\\\\.google\\\\.com/bot\\\\.html\",\n      \"action\": \"ALLOW\"\n    },\n    {\n      \"name\": \"bingbot\",\n      \"user_agent_regex\": \"\\\\+http\\\\:\\\\/\\\\/www\\\\.bing\\\\.com/bingbot\\\\.htm\",\n      \"action\": \"ALLOW\"\n    },\n    {\n      \"name\": \"qwantbot\",\n      \"user_agent_regex\": \"\\\\+https\\\\:\\\\/\\\\/help\\\\.qwant\\\\.com/bot/\",\n      \"action\": \"ALLOW\"\n    },\n    {\n      \"name\": \"discordbot\",\n      \"user_agent_regex\": \"Discordbot/2\\\\.0; \\\\+https\\\\:\\\\/\\\\/discordapp\\\\.com\",\n      \"action\": \"ALLOW\"\n    },\n    {\n      \"name\": \"blueskybot\",\n      \"user_agent_regex\": \"Bluesky Cardyb\",\n      \"action\": \"ALLOW\"\n    },\n    {\n      \"name\": \"us-artificial-intelligence-scraper\",\n      \"user_agent_regex\": \"\\\\+https\\\\:\\\\/\\\\/github\\\\.com\\\\/US-Artificial-Intelligence\\\\/scraper\",\n      \"action\": \"DENY\"\n    },\n    {\n      \"name\": \"well-known\",\n      \"path_regex\": \"^/.well-known/.*$\",\n      \"action\": \"ALLOW\"\n    },\n    {\n      \"name\": \"favicon\",\n      \"path_regex\": \"^/favicon.ico$\",\n      \"action\": \"ALLOW\"\n    },\n    {\n      \"name\": \"robots-txt\",\n      \"path_regex\": \"^/robots.txt$\",\n      \"action\": \"ALLOW\"\n    },\n    {\n      \"name\": \"rss-readers\",\n      \"path_regex\": \".*\\\\.(rss|xml|atom|json)$\",\n      \"action\": \"ALLOW\"\n    },\n    {\n      \"name\": \"lightpanda\",\n      \"user_agent_regex\": \"^Lightpanda/.*$\",\n      \"action\": \"DENY\"\n    },\n    {\n      \"name\": \"headless-chrome\",\n      \"user_agent_regex\": \"HeadlessChrome\",\n      \"action\": \"DENY\"\n    },\n    {\n      \"name\": \"headless-chromium\",\n      \"user_agent_regex\": \"HeadlessChromium\",\n      \"action\": \"DENY\"\n    },\n    {\n      \"name\": \"generic-browser\",\n      \"user_agent_regex\": \"Mozilla\",\n      \"action\": \"CHALLENGE\"\n    }\n  ]\n}\n"
  },
  {
    "path": "lib/config/testdata/good/opengraph_all_good.yaml",
    "content": "bots:\n  - name: everything\n    user_agent_regex: .*\n    action: DENY\n\nopenGraph:\n  enabled: true\n  considerHost: false\n  ttl: 1h\n  default:\n    \"og:title\": \"Xe's magic land of fun\"\n    \"og:description\": \"We're no strangers to love, you know the rules and so do I\"\n"
  },
  {
    "path": "lib/config/testdata/good/simple-weight.yaml",
    "content": "bots:\n  - name: simple-weight-adjust\n    action: WEIGH\n    user_agent_regex: Mozilla\n    weight:\n      adjust: 5\n"
  },
  {
    "path": "lib/config/testdata/good/status-codes-paranoid.json",
    "content": "{\n  \"bots\": [\n    {\n      \"name\": \"everything\",\n      \"user_agent_regex\": \".*\",\n      \"action\": \"DENY\"\n    }\n  ],\n  \"status_codes\": {\n    \"CHALLENGE\": 200,\n    \"DENY\": 200\n  }\n}\n"
  },
  {
    "path": "lib/config/testdata/good/status-codes-paranoid.yaml",
    "content": "bots:\n  - name: everything\n    user_agent_regex: .*\n    action: DENY\n\nstatus_codes:\n  CHALLENGE: 200\n  DENY: 200\n"
  },
  {
    "path": "lib/config/testdata/good/status-codes-rfc.json",
    "content": "{\n  \"bots\": [\n    {\n      \"name\": \"everything\",\n      \"user_agent_regex\": \".*\",\n      \"action\": \"DENY\"\n    }\n  ],\n  \"status_codes\": {\n    \"CHALLENGE\": 403,\n    \"DENY\": 403\n  }\n}\n"
  },
  {
    "path": "lib/config/testdata/good/status-codes-rfc.yaml",
    "content": "bots:\n  - name: everything\n    user_agent_regex: .*\n    action: DENY\n\nstatus_codes:\n  CHALLENGE: 403\n  DENY: 403\n"
  },
  {
    "path": "lib/config/testdata/good/thresholds.yaml",
    "content": "bots:\n  - name: simple-weight-adjust\n    action: WEIGH\n    user_agent_regex: Mozilla\n    weight:\n      adjust: 5\n\nthresholds:\n  - name: minimal-suspicion\n    expression: weight < 0\n    action: ALLOW\n  - name: mild-suspicion\n    expression:\n      all:\n        - weight >= 0\n        - weight < 10\n    action: CHALLENGE\n    challenge:\n      algorithm: metarefresh\n      difficulty: 1\n  - name: moderate-suspicion\n    expression:\n      all:\n        - weight >= 10\n        - weight < 20\n    action: CHALLENGE\n    challenge:\n      algorithm: fast\n      difficulty: 2\n  - name: extreme-suspicion\n    expression: weight >= 20\n    action: CHALLENGE\n    challenge:\n      algorithm: fast\n      difficulty: 4\n"
  },
  {
    "path": "lib/config/testdata/good/weight-no-weight.yaml",
    "content": "bots:\n  - name: weight\n    action: WEIGH\n    user_agent_regex: Mozilla\n"
  },
  {
    "path": "lib/config/testdata/hack-test.json",
    "content": "[\n  {\n    \"name\": \"ipv6-ula\",\n    \"action\": \"ALLOW\",\n    \"remote_addresses\": [\"fc00::/7\"]\n  }\n]\n"
  },
  {
    "path": "lib/config/testdata/hack-test.yaml",
    "content": "- name: well-known\n  path_regex: ^/.well-known/.*$\n  action: ALLOW\n"
  },
  {
    "path": "lib/config/threshold.go",
    "content": "package config\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\n\t\"github.com/TecharoHQ/anubis\"\n)\n\nvar (\n\tErrNoThresholdRulesDefined             = errors.New(\"config: no thresholds defined\")\n\tErrThresholdMustHaveName               = errors.New(\"config.Threshold: must set name\")\n\tErrThresholdMustHaveExpression         = errors.New(\"config.Threshold: must set expression\")\n\tErrThresholdChallengeMustHaveChallenge = errors.New(\"config.Threshold: a threshold with the CHALLENGE action must have challenge set\")\n\tErrThresholdCannotHaveWeighAction      = errors.New(\"config.Threshold: a threshold cannot have the WEIGH action\")\n\n\tDefaultThresholds = []Threshold{\n\t\t{\n\t\t\tName: \"legacy-anubis-behaviour\",\n\t\t\tExpression: &ExpressionOrList{\n\t\t\t\tExpression: \"weight > 0\",\n\t\t\t},\n\t\t\tAction: RuleChallenge,\n\t\t\tChallenge: &ChallengeRules{\n\t\t\t\tAlgorithm:  \"fast\",\n\t\t\t\tDifficulty: anubis.DefaultDifficulty,\n\t\t\t},\n\t\t},\n\t}\n)\n\ntype Threshold struct {\n\tExpression *ExpressionOrList `json:\"expression\" yaml:\"expression\"`\n\tChallenge  *ChallengeRules   `json:\"challenge\" yaml:\"challenge\"`\n\tName       string            `json:\"name\" yaml:\"name\"`\n\tAction     Rule              `json:\"action\" yaml:\"action\"`\n}\n\nfunc (t Threshold) Valid() error {\n\tvar errs []error\n\n\tif len(t.Name) == 0 {\n\t\terrs = append(errs, ErrThresholdMustHaveName)\n\t}\n\n\tif t.Expression == nil {\n\t\terrs = append(errs, ErrThresholdMustHaveExpression)\n\t}\n\n\tif t.Expression != nil {\n\t\tif err := t.Expression.Valid(); err != nil {\n\t\t\terrs = append(errs, err)\n\t\t}\n\t}\n\n\tif err := t.Action.Valid(); err != nil {\n\t\terrs = append(errs, err)\n\t}\n\n\tif t.Action == RuleWeigh {\n\t\terrs = append(errs, ErrThresholdCannotHaveWeighAction)\n\t}\n\n\tif t.Action == RuleChallenge && t.Challenge == nil {\n\t\terrs = append(errs, ErrThresholdChallengeMustHaveChallenge)\n\t}\n\n\tif t.Challenge != nil {\n\t\tif err := t.Challenge.Valid(); err != nil {\n\t\t\terrs = append(errs, err)\n\t\t}\n\t}\n\n\tif len(errs) != 0 {\n\t\treturn fmt.Errorf(\"config: threshold entry for %q is not valid:\\n%w\", t.Name, errors.Join(errs...))\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "lib/config/threshold_test.go",
    "content": "package config\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n)\n\nfunc TestThresholdValid(t *testing.T) {\n\tfor _, tt := range []struct {\n\t\terr   error\n\t\tinput *Threshold\n\t\tname  string\n\t}{\n\t\t{\n\t\t\tname: \"basic allow\",\n\t\t\tinput: &Threshold{\n\t\t\t\tName:       \"basic-allow\",\n\t\t\t\tExpression: &ExpressionOrList{Expression: \"true\"},\n\t\t\t\tAction:     RuleAllow,\n\t\t\t},\n\t\t\terr: nil,\n\t\t},\n\t\t{\n\t\t\tname: \"basic challenge\",\n\t\t\tinput: &Threshold{\n\t\t\t\tName:       \"basic-challenge\",\n\t\t\t\tExpression: &ExpressionOrList{Expression: \"true\"},\n\t\t\t\tAction:     RuleChallenge,\n\t\t\t\tChallenge: &ChallengeRules{\n\t\t\t\t\tAlgorithm:  \"fast\",\n\t\t\t\t\tDifficulty: 1,\n\t\t\t\t},\n\t\t\t},\n\t\t\terr: nil,\n\t\t},\n\t\t{\n\t\t\tname:  \"no name\",\n\t\t\tinput: &Threshold{},\n\t\t\terr:   ErrThresholdMustHaveName,\n\t\t},\n\t\t{\n\t\t\tname:  \"no expression\",\n\t\t\tinput: &Threshold{},\n\t\t\terr:   ErrThresholdMustHaveName,\n\t\t},\n\t\t{\n\t\t\tname: \"invalid expression\",\n\t\t\tinput: &Threshold{\n\t\t\t\tExpression: &ExpressionOrList{},\n\t\t\t},\n\t\t\terr: ErrExpressionEmpty,\n\t\t},\n\t\t{\n\t\t\tname:  \"invalid action\",\n\t\t\tinput: &Threshold{},\n\t\t\terr:   ErrUnknownAction,\n\t\t},\n\t\t{\n\t\t\tname: \"challenge action but no challenge\",\n\t\t\tinput: &Threshold{\n\t\t\t\tAction: RuleChallenge,\n\t\t\t},\n\t\t\terr: ErrThresholdChallengeMustHaveChallenge,\n\t\t},\n\t\t{\n\t\t\tname: \"challenge invalid\",\n\t\t\tinput: &Threshold{\n\t\t\t\tAction:    RuleChallenge,\n\t\t\t\tChallenge: &ChallengeRules{Difficulty: -1, ReportAs: -1},\n\t\t\t},\n\t\t\terr: ErrChallengeDifficultyTooLow,\n\t\t},\n\t} {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tif err := tt.input.Valid(); !errors.Is(err, tt.err) {\n\t\t\t\tt.Errorf(\"threshold is invalid: %v\", err)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestDefaultThresholdsValid(t *testing.T) {\n\tfor i, th := range DefaultThresholds {\n\t\tt.Run(fmt.Sprintf(\"%d %s\", i, th.Name), func(t *testing.T) {\n\t\t\tif err := th.Valid(); err != nil {\n\t\t\t\tt.Errorf(\"threshold invalid: %v\", err)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestLoadActuallyLoadsThresholds(t *testing.T) {\n\tfin, err := os.Open(filepath.Join(\".\", \"testdata\", \"good\", \"thresholds.yaml\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer fin.Close()\n\n\tc, err := Load(fin, fin.Name())\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif len(c.Thresholds) != 4 {\n\t\tt.Errorf(\"wanted 4 thresholds, got %d thresholds\", len(c.Thresholds))\n\t}\n}\n"
  },
  {
    "path": "lib/config/weight.go",
    "content": "package config\n\ntype Weight struct {\n\tAdjust int `json:\"adjust\" yaml:\"adjust\"`\n}\n"
  },
  {
    "path": "lib/config.go",
    "content": "package lib\n\nimport (\n\t\"context\"\n\t\"crypto/ed25519\"\n\t\"crypto/rand\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"log/slog\"\n\t\"net/http\"\n\t\"os\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/TecharoHQ/anubis\"\n\t\"github.com/TecharoHQ/anubis/data\"\n\t\"github.com/TecharoHQ/anubis/internal\"\n\t\"github.com/TecharoHQ/anubis/internal/honeypot/naive\"\n\t\"github.com/TecharoHQ/anubis/internal/ogtags\"\n\t\"github.com/TecharoHQ/anubis/lib/challenge\"\n\t\"github.com/TecharoHQ/anubis/lib/config\"\n\t\"github.com/TecharoHQ/anubis/lib/localization\"\n\t\"github.com/TecharoHQ/anubis/lib/policy\"\n\t\"github.com/TecharoHQ/anubis/web\"\n\t\"github.com/TecharoHQ/anubis/xess\"\n\t\"github.com/a-h/templ\"\n)\n\ntype Options struct {\n\tNext                     http.Handler\n\tPolicy                   *policy.ParsedConfig\n\tTarget                   string\n\tTargetHost               string\n\tTargetSNI                string\n\tTargetInsecureSkipVerify bool\n\tCookieDynamicDomain      bool\n\tCookieDomain             string\n\tCookieExpiration         time.Duration\n\tCookiePartitioned        bool\n\tBasePrefix               string\n\tWebmasterEmail           string\n\tRedirectDomains          []string\n\tED25519PrivateKey        ed25519.PrivateKey\n\tHS512Secret              []byte\n\tStripBasePrefix          bool\n\tOpenGraph                config.OpenGraph\n\tServeRobotsTXT           bool\n\tCookieSecure             bool\n\tCookieSameSite           http.SameSite\n\tLogger                   *slog.Logger\n\tLogLevel                 string\n\tPublicUrl                string\n\tJWTRestrictionHeader     string\n\tDifficultyInJWT          bool\n}\n\nfunc LoadPoliciesOrDefault(ctx context.Context, fname string, defaultDifficulty int, logLevel string) (*policy.ParsedConfig, error) {\n\tvar fin io.ReadCloser\n\tvar err error\n\n\tif fname != \"\" {\n\t\tfin, err = os.Open(fname)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"can't parse policy file %s: %w\", fname, err)\n\t\t}\n\t} else {\n\t\tfname = \"(data)/botPolicies.yaml\"\n\t\tfin, err = data.BotPolicies.Open(\"botPolicies.yaml\")\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"[unexpected] can't parse builtin policy file %s: %w\", fname, err)\n\t\t}\n\t}\n\n\tdefer func(fin io.ReadCloser) {\n\t\terr := fin.Close()\n\t\tif err != nil {\n\t\t\tslog.Error(\"failed to close policy file\", \"file\", fname, \"err\", err)\n\t\t}\n\t}(fin)\n\n\tanubisPolicy, err := policy.ParseConfig(ctx, fin, fname, defaultDifficulty, logLevel)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"can't parse policy file %s: %w\", fname, err)\n\t}\n\tvar validationErrs []error\n\n\tfor _, b := range anubisPolicy.Bots {\n\t\tif _, ok := challenge.Get(b.Challenge.Algorithm); !ok {\n\t\t\tvalidationErrs = append(validationErrs, fmt.Errorf(\"%w %s\", policy.ErrChallengeRuleHasWrongAlgorithm, b.Challenge.Algorithm))\n\t\t}\n\t}\n\n\tif len(validationErrs) != 0 {\n\t\treturn nil, fmt.Errorf(\"can't do final validation of Anubis config: %w\", errors.Join(validationErrs...))\n\t}\n\n\treturn anubisPolicy, err\n}\n\nfunc New(opts Options) (*Server, error) {\n\tif opts.Logger == nil {\n\t\topts.Logger = slog.With(\"subsystem\", \"anubis\")\n\t}\n\n\tif opts.ED25519PrivateKey == nil && opts.HS512Secret == nil {\n\t\topts.Logger.Debug(\"opts.PrivateKey not set, generating a new one\")\n\t\t_, priv, err := ed25519.GenerateKey(rand.Reader)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"lib: can't generate private key: %v\", err)\n\t\t}\n\t\topts.ED25519PrivateKey = priv\n\t}\n\n\tanubis.BasePrefix = strings.TrimRight(opts.BasePrefix, \"/\")\n\tanubis.PublicUrl = opts.PublicUrl\n\n\tresult := &Server{\n\t\tnext:        opts.Next,\n\t\ted25519Priv: opts.ED25519PrivateKey,\n\t\ths512Secret: opts.HS512Secret,\n\t\tpolicy:      opts.Policy,\n\t\topts:        opts,\n\t\tOGTags: ogtags.NewOGTagCache(opts.Target, opts.Policy.OpenGraph, opts.Policy.Store, ogtags.TargetOptions{\n\t\t\tHost:               opts.TargetHost,\n\t\t\tSNI:                opts.TargetSNI,\n\t\t\tInsecureSkipVerify: opts.TargetInsecureSkipVerify,\n\t\t}),\n\t\tstore:  opts.Policy.Store,\n\t\tlogger: opts.Logger,\n\t}\n\n\tmux := http.NewServeMux()\n\txess.Mount(mux)\n\n\t// Helper to add global prefix\n\tregisterWithPrefix := func(pattern string, handler http.Handler, method string) {\n\t\tif method != \"\" {\n\t\t\tmethod = method + \" \" // methods must end with a space to register with them\n\t\t}\n\n\t\t// Ensure there's no double slash when concatenating BasePrefix and pattern\n\t\tbasePrefix := strings.TrimSuffix(anubis.BasePrefix, \"/\")\n\t\tprefix := method + basePrefix\n\n\t\t// If pattern doesn't start with a slash, add one\n\t\tif !strings.HasPrefix(pattern, \"/\") {\n\t\t\tpattern = \"/\" + pattern\n\t\t}\n\n\t\tmux.Handle(prefix+pattern, handler)\n\t}\n\n\t// Ensure there's no double slash when concatenating BasePrefix and StaticPath\n\tstripPrefix := strings.TrimSuffix(anubis.BasePrefix, \"/\") + anubis.StaticPath\n\tregisterWithPrefix(anubis.StaticPath, internal.UnchangingCache(internal.NoBrowsing(http.StripPrefix(stripPrefix, http.FileServerFS(web.Static)))), \"\")\n\n\tif opts.ServeRobotsTXT {\n\t\tregisterWithPrefix(\"/robots.txt\", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\thttp.ServeFileFS(w, r, web.Static, \"static/robots.txt\")\n\t\t}), \"GET\")\n\t\tregisterWithPrefix(\"/.well-known/robots.txt\", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\thttp.ServeFileFS(w, r, web.Static, \"static/robots.txt\")\n\t\t}), \"GET\")\n\t}\n\n\tif opts.Policy.Impressum != nil {\n\t\tregisterWithPrefix(anubis.APIPrefix+\"imprint\", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\ttempl.Handler(\n\t\t\t\tweb.Base(opts.Policy.Impressum.Page.Title, opts.Policy.Impressum.Page, opts.Policy.Impressum, localization.GetLocalizer(r)),\n\t\t\t).ServeHTTP(w, r)\n\t\t}), \"GET\")\n\t}\n\n\tregisterWithPrefix(anubis.APIPrefix+\"pass-challenge\", http.HandlerFunc(result.PassChallenge), \"GET\")\n\tregisterWithPrefix(anubis.APIPrefix+\"check\", http.HandlerFunc(result.maybeReverseProxyHttpStatusOnly), \"\")\n\tregisterWithPrefix(\"/\", http.HandlerFunc(result.maybeReverseProxyOrPage), \"\")\n\n\tmazeGen, err := naive.New(result.store, result.logger)\n\tif err == nil {\n\t\tregisterWithPrefix(anubis.APIPrefix+\"honeypot/{id}/{stage}\", mazeGen, http.MethodGet)\n\n\t\topts.Policy.Bots = append(\n\t\t\topts.Policy.Bots,\n\t\t\tpolicy.Bot{\n\t\t\t\tRules:  mazeGen.CheckNetwork(),\n\t\t\t\tAction: config.RuleWeigh,\n\t\t\t\tWeight: &config.Weight{\n\t\t\t\t\tAdjust: 30,\n\t\t\t\t},\n\t\t\t\tName: \"honeypot/network\",\n\t\t\t},\n\t\t\tpolicy.Bot{\n\t\t\t\tRules:  mazeGen.CheckUA(),\n\t\t\t\tAction: config.RuleWeigh,\n\t\t\t\tWeight: &config.Weight{\n\t\t\t\t\tAdjust: 30,\n\t\t\t\t},\n\t\t\t\tName: \"honeypot/user-agent\",\n\t\t\t},\n\t\t)\n\t} else {\n\t\tresult.logger.Error(\"can't init honeypot subsystem\", \"err\", err)\n\t}\n\n\t//goland:noinspection GoBoolExpressions\n\tif anubis.Version == \"devel\" {\n\t\t// make-challenge is only used in tests. Only enable while version is devel\n\t\tregisterWithPrefix(anubis.APIPrefix+\"make-challenge\", http.HandlerFunc(result.MakeChallenge), \"POST\")\n\t}\n\n\tfor _, implKind := range challenge.Methods() {\n\t\timpl, _ := challenge.Get(implKind)\n\t\timpl.Setup(mux)\n\t}\n\n\tresult.mux = mux\n\n\treturn result, nil\n}\n"
  },
  {
    "path": "lib/config_test.go",
    "content": "package lib\n\nimport (\n\t\"errors\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n\n\t\"github.com/TecharoHQ/anubis\"\n\t\"github.com/TecharoHQ/anubis/lib/policy\"\n\t\"github.com/TecharoHQ/anubis/lib/thoth/thothmock\"\n)\n\nfunc TestInvalidChallengeMethod(t *testing.T) {\n\tif _, err := LoadPoliciesOrDefault(t.Context(), \"testdata/invalid-challenge-method.yaml\", 4, \"info\"); !errors.Is(err, policy.ErrChallengeRuleHasWrongAlgorithm) {\n\t\tt.Fatalf(\"wanted error %v but got %v\", policy.ErrChallengeRuleHasWrongAlgorithm, err)\n\t}\n}\n\nfunc TestBadConfigs(t *testing.T) {\n\tfinfos, err := os.ReadDir(\"config/testdata/bad\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tfor _, st := range finfos {\n\t\tt.Run(st.Name(), func(t *testing.T) {\n\t\t\tif _, err := LoadPoliciesOrDefault(t.Context(), filepath.Join(\"config\", \"testdata\", \"bad\", st.Name()), anubis.DefaultDifficulty, \"info\"); err == nil {\n\t\t\t\tt.Fatal(err)\n\t\t\t} else {\n\t\t\t\tt.Log(err)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestGoodConfigs(t *testing.T) {\n\tfinfos, err := os.ReadDir(\"config/testdata/good\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tfor _, st := range finfos {\n\t\tt.Run(st.Name(), func(t *testing.T) {\n\t\t\tt.Run(\"with-thoth\", func(t *testing.T) {\n\t\t\t\tctx := thothmock.WithMockThoth(t)\n\t\t\t\tif _, err := LoadPoliciesOrDefault(ctx, filepath.Join(\"config\", \"testdata\", \"good\", st.Name()), anubis.DefaultDifficulty, \"info\"); err != nil {\n\t\t\t\t\tt.Fatal(err)\n\t\t\t\t}\n\t\t\t})\n\n\t\t\tt.Run(\"without-thoth\", func(t *testing.T) {\n\t\t\t\tif _, err := LoadPoliciesOrDefault(t.Context(), filepath.Join(\"config\", \"testdata\", \"good\", st.Name()), anubis.DefaultDifficulty, \"info\"); err != nil {\n\t\t\t\t\tt.Fatal(err)\n\t\t\t\t}\n\t\t\t})\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "lib/http.go",
    "content": "package lib\n\nimport (\n\t\"bytes\"\n\t\"compress/gzip\"\n\t\"encoding/base64\"\n\t\"errors\"\n\t\"fmt\"\n\t\"math/rand\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"regexp\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/TecharoHQ/anubis\"\n\t\"github.com/TecharoHQ/anubis/internal\"\n\t\"github.com/TecharoHQ/anubis/internal/glob\"\n\t\"github.com/TecharoHQ/anubis/lib/challenge\"\n\t\"github.com/TecharoHQ/anubis/lib/localization\"\n\t\"github.com/TecharoHQ/anubis/lib/policy\"\n\t\"github.com/TecharoHQ/anubis/web\"\n\t\"github.com/TecharoHQ/anubis/xess\"\n\t\"github.com/a-h/templ\"\n\t\"github.com/golang-jwt/jwt/v5\"\n\t\"golang.org/x/net/publicsuffix\"\n)\n\nvar domainMatchRegexp = regexp.MustCompile(`^((xn--)?[a-z0-9]+(-[a-z0-9]+)*\\.)+[a-z]{2,}$`)\n\nvar (\n\tErrActualAnubisBug = errors.New(\"this is an actual bug in Anubis, please file an issue with the magic string 'taco bell'\")\n)\n\n// matchRedirectDomain returns true if host matches any of the allowed redirect\n// domain patterns. Patterns may contain '*' which are matched using the\n// internal glob matcher. Matching is case-insensitive on hostnames.\nfunc matchRedirectDomain(allowed []string, host string) bool {\n\th := strings.ToLower(strings.TrimSpace(host))\n\tfor _, pat := range allowed {\n\t\tp := strings.ToLower(strings.TrimSpace(pat))\n\t\tif strings.Contains(p, glob.GLOB) {\n\t\t\tif glob.Glob(p, h) {\n\t\t\t\treturn true\n\t\t\t}\n\t\t\tcontinue\n\t\t}\n\t\tif p == h {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\ntype CookieOpts struct {\n\tValue  string\n\tHost   string\n\tPath   string\n\tName   string\n\tExpiry time.Duration\n}\n\nfunc (s *Server) SetCookie(w http.ResponseWriter, cookieOpts CookieOpts) {\n\tvar domain = s.opts.CookieDomain\n\tvar name = anubis.CookieName\n\tvar path = \"/\"\n\tvar sameSite = s.opts.CookieSameSite\n\n\tif cookieOpts.Name != \"\" {\n\t\tname = cookieOpts.Name\n\t}\n\tif cookieOpts.Path != \"\" {\n\t\tpath = cookieOpts.Path\n\t}\n\tif s.opts.CookieDynamicDomain && domainMatchRegexp.MatchString(cookieOpts.Host) {\n\t\tif etld, err := publicsuffix.EffectiveTLDPlusOne(cookieOpts.Host); err == nil {\n\t\t\tdomain = etld\n\t\t}\n\t}\n\n\tif cookieOpts.Expiry == 0 {\n\t\tcookieOpts.Expiry = s.opts.CookieExpiration\n\t}\n\n\tif s.opts.CookieSameSite == http.SameSiteNoneMode && !s.opts.CookieSecure {\n\t\tsameSite = http.SameSiteLaxMode\n\t}\n\n\thttp.SetCookie(w, &http.Cookie{\n\t\tName:        name,\n\t\tValue:       cookieOpts.Value,\n\t\tExpires:     time.Now().Add(cookieOpts.Expiry),\n\t\tSameSite:    sameSite,\n\t\tDomain:      domain,\n\t\tSecure:      s.opts.CookieSecure,\n\t\tPartitioned: s.opts.CookiePartitioned,\n\t\tPath:        path,\n\t})\n}\n\nfunc (s *Server) ClearCookie(w http.ResponseWriter, cookieOpts CookieOpts) {\n\tvar domain = s.opts.CookieDomain\n\tvar name = anubis.CookieName\n\tvar path = \"/\"\n\tvar sameSite = s.opts.CookieSameSite\n\n\tif cookieOpts.Name != \"\" {\n\t\tname = cookieOpts.Name\n\t}\n\tif cookieOpts.Path != \"\" {\n\t\tpath = cookieOpts.Path\n\t}\n\tif s.opts.CookieDynamicDomain && domainMatchRegexp.MatchString(cookieOpts.Host) {\n\t\tif etld, err := publicsuffix.EffectiveTLDPlusOne(cookieOpts.Host); err == nil {\n\t\t\tdomain = etld\n\t\t}\n\t}\n\tif s.opts.CookieSameSite == http.SameSiteNoneMode && !s.opts.CookieSecure {\n\t\tsameSite = http.SameSiteLaxMode\n\t}\n\n\thttp.SetCookie(w, &http.Cookie{\n\t\tName:        name,\n\t\tValue:       \"\",\n\t\tMaxAge:      -1,\n\t\tExpires:     time.Now().Add(-1 * time.Minute),\n\t\tSameSite:    sameSite,\n\t\tPartitioned: s.opts.CookiePartitioned,\n\t\tDomain:      domain,\n\t\tSecure:      s.opts.CookieSecure,\n\t\tPath:        path,\n\t})\n}\n\n// https://github.com/oauth2-proxy/oauth2-proxy/blob/master/pkg/upstream/http.go#L124\ntype UnixRoundTripper struct {\n\tTransport *http.Transport\n}\n\n// set bare minimum stuff\nfunc (t UnixRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {\n\treq = req.Clone(req.Context())\n\tif req.Host == \"\" {\n\t\treq.Host = \"localhost\"\n\t}\n\treq.URL.Host = req.Host // proxy error: no Host in request URL\n\treq.URL.Scheme = \"http\" // make http.Transport happy and avoid an infinite recursion\n\treturn t.Transport.RoundTrip(req)\n}\n\nfunc randomChance(n int) bool {\n\treturn rand.Intn(n) == 0\n}\n\n// XXX(Xe): generated by ChatGPT\nfunc rot13(s string) string {\n\trotated := make([]rune, len(s))\n\tfor i, c := range s {\n\t\tswitch {\n\t\tcase c >= 'A' && c <= 'Z':\n\t\t\trotated[i] = 'A' + ((c - 'A' + 13) % 26)\n\t\tcase c >= 'a' && c <= 'z':\n\t\t\trotated[i] = 'a' + ((c - 'a' + 13) % 26)\n\t\tdefault:\n\t\t\trotated[i] = c\n\t\t}\n\t}\n\treturn string(rotated)\n}\n\nfunc makeCode(err error) string {\n\tvar buf bytes.Buffer\n\tgzw := gzip.NewWriter(&buf)\n\terrStr := fmt.Sprintf(\"internal error: %v\", err)\n\n\tfmt.Fprintln(gzw, rot13(errStr))\n\tif err := gzw.Close(); err != nil {\n\t\tpanic(\"can't write to gzip in ram buffer\")\n\t}\n\tconst width = 16\n\n\tenc := base64.StdEncoding.EncodeToString(buf.Bytes())\n\tvar builder strings.Builder\n\tfor i := 0; i < len(enc); i += width {\n\t\tend := min(i+width, len(enc))\n\t\tbuilder.WriteString(enc[i:end])\n\t\tbuilder.WriteByte('\\n')\n\t}\n\treturn builder.String()\n}\n\nfunc (s *Server) RenderIndex(w http.ResponseWriter, r *http.Request, cr policy.CheckResult, rule *policy.Bot, returnHTTPStatusOnly bool) {\n\tlocalizer := localization.GetLocalizer(r)\n\n\tif returnHTTPStatusOnly {\n\t\tif s.opts.PublicUrl == \"\" {\n\t\t\tw.WriteHeader(http.StatusUnauthorized)\n\t\t\tw.Write([]byte(localizer.T(\"authorization_required\")))\n\t\t} else {\n\t\t\tredirectURL, err := s.constructRedirectURL(r)\n\t\t\tif err != nil {\n\t\t\t\ts.respondWithStatus(w, r, err.Error(), \"\", http.StatusBadRequest)\n\t\t\t\treturn\n\t\t\t}\n\t\t\thttp.Redirect(w, r, redirectURL, http.StatusTemporaryRedirect)\n\t\t}\n\t\treturn\n\t}\n\n\tlg := internal.GetRequestLogger(s.logger, r)\n\n\tif !strings.Contains(r.Header.Get(\"Accept-Encoding\"), \"gzip\") && randomChance(64) {\n\t\tlg.Error(\"client was given a challenge but does not in fact support gzip compression\")\n\t\ts.respondWithError(w, r, localizer.T(\"client_error_browser\"), \"\")\n\t\treturn\n\t}\n\n\tchallengesIssued.WithLabelValues(\"embedded\").Add(1)\n\tchall, err := s.issueChallenge(r.Context(), r, lg, cr, rule)\n\tif err != nil {\n\t\tlg.Error(\"can't get challenge\", \"err\", err)\n\t\talgorithm := \"unknown\"\n\t\tif rule.Challenge != nil {\n\t\t\talgorithm = rule.Challenge.Algorithm\n\t\t}\n\t\ts.ClearCookie(w, CookieOpts{Name: anubis.TestCookieName, Host: r.Host})\n\t\ts.respondWithError(w, r, fmt.Sprintf(\"%s: %s\", localizer.T(\"internal_server_error\"), algorithm), makeCode(err))\n\t\treturn\n\t}\n\n\tlg = lg.With(\"challenge\", chall.ID)\n\n\tvar ogTags map[string]string = nil\n\tif s.opts.OpenGraph.Enabled {\n\t\tvar err error\n\t\togTags, err = s.OGTags.GetOGTags(r.Context(), r.URL, r.Host)\n\t\tif err != nil {\n\t\t\tlg.Error(\"failed to get OG tags\", \"err\", err)\n\t\t}\n\t}\n\n\ts.SetCookie(w, CookieOpts{\n\t\tValue:  chall.ID,\n\t\tHost:   r.Host,\n\t\tPath:   \"/\",\n\t\tName:   anubis.TestCookieName,\n\t\tExpiry: 30 * time.Minute,\n\t})\n\n\timpl, ok := challenge.Get(chall.Method)\n\tif !ok {\n\t\talgorithm := \"unknown\"\n\t\tif rule.Challenge != nil {\n\t\t\talgorithm = rule.Challenge.Algorithm\n\t\t}\n\t\tlg.Error(\"check failed\", \"err\", \"can't get algorithm\", \"algorithm\", algorithm)\n\t\ts.ClearCookie(w, CookieOpts{Name: anubis.TestCookieName, Host: r.Host})\n\t\ts.respondWithError(w, r, fmt.Sprintf(\"%s: %s\", localizer.T(\"internal_server_error\"), algorithm), makeCode(err))\n\t\treturn\n\t}\n\n\tin := &challenge.IssueInput{\n\t\tImpressum: s.policy.Impressum,\n\t\tRule:      rule,\n\t\tChallenge: chall,\n\t\tOGTags:    ogTags,\n\t\tStore:     s.store,\n\t}\n\n\tcomponent, err := impl.Issue(w, r, lg, in)\n\tif err != nil {\n\t\tlg.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.\n\t\ts.respondWithError(w, r, fmt.Sprintf(\"%s \\\"RenderIndex\\\"\", localizer.T(\"internal_server_error\")), makeCode(err))\n\t\treturn\n\t}\n\n\tpage := web.BaseWithChallengeAndOGTags(\n\t\tlocalizer.T(\"making_sure_not_bot\"),\n\t\tcomponent,\n\t\ts.policy.Impressum,\n\t\tchall,\n\t\tin.Rule.Challenge,\n\t\tin.OGTags,\n\t\tlocalizer,\n\t)\n\n\thandler := internal.GzipMiddleware(1, internal.NoStoreCache(templ.Handler(\n\t\tpage,\n\t\ttempl.WithStatus(s.opts.Policy.StatusCodes.Challenge),\n\t)))\n\thandler.ServeHTTP(w, r)\n}\n\nfunc (s *Server) constructRedirectURL(r *http.Request) (string, error) {\n\tproto := r.Header.Get(\"X-Forwarded-Proto\")\n\thost := r.Header.Get(\"X-Forwarded-Host\")\n\turi := r.Header.Get(\"X-Forwarded-Uri\")\n\n\tlocalizer := localization.GetLocalizer(r)\n\n\tif proto == \"\" || host == \"\" || uri == \"\" {\n\t\treturn \"\", errors.New(localizer.T(\"missing_required_forwarded_headers\"))\n\t}\n\n\tswitch proto {\n\tcase \"http\", \"https\":\n\t\t// allowed\n\tdefault:\n\t\tlg := internal.GetRequestLogger(s.logger, r)\n\t\tlg.Warn(\"invalid protocol in X-Forwarded-Proto\", \"proto\", proto)\n\t\treturn \"\", errors.New(localizer.T(\"invalid_redirect\"))\n\t}\n\n\t// Check if host is allowed in RedirectDomains (supports '*' via glob)\n\tif len(s.opts.RedirectDomains) > 0 && !matchRedirectDomain(s.opts.RedirectDomains, host) {\n\t\tlg := internal.GetRequestLogger(s.logger, r)\n\t\tlg.Debug(\"domain not allowed\", \"domain\", host)\n\t\treturn \"\", errors.New(localizer.T(\"redirect_domain_not_allowed\"))\n\t}\n\n\tredir := proto + \"://\" + host + uri\n\tescapedURL := url.QueryEscape(redir)\n\treturn fmt.Sprintf(\"%s/.within.website/?redir=%s\", s.opts.PublicUrl, escapedURL), nil\n}\n\nfunc (s *Server) RenderBench(w http.ResponseWriter, r *http.Request) {\n\tlocalizer := localization.GetLocalizer(r)\n\n\ttempl.Handler(\n\t\tweb.Base(localizer.T(\"benchmarking_anubis\"), web.Bench(localizer), s.policy.Impressum, localizer),\n\t).ServeHTTP(w, r)\n}\n\nfunc (s *Server) respondWithError(w http.ResponseWriter, r *http.Request, message, code string) {\n\ts.respondWithStatus(w, r, message, code, http.StatusInternalServerError)\n}\n\nfunc (s *Server) respondWithStatus(w http.ResponseWriter, r *http.Request, msg, code string, status int) {\n\tlocalizer := localization.GetLocalizer(r)\n\n\tcomponent := web.Base(\n\t\tlocalizer.T(\"oh_noes\"),\n\t\tweb.ErrorPage(msg, s.opts.WebmasterEmail, code, localizer),\n\t\ts.policy.Impressum,\n\t\tlocalizer,\n\t)\n\thandler := internal.NoStoreCache(templ.Handler(component, templ.WithStatus(status)))\n\thandler.ServeHTTP(w, r)\n}\n\nfunc (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {\n\tif strings.HasPrefix(r.URL.Path, anubis.BasePrefix+anubis.StaticPath) {\n\t\ts.mux.ServeHTTP(w, r)\n\t\treturn\n\t} else if strings.HasPrefix(r.URL.Path, anubis.BasePrefix+xess.BasePrefix) {\n\t\ts.mux.ServeHTTP(w, r)\n\t\treturn\n\t}\n\n\t// Forward robots.txt requests to mux when ServeRobotsTXT is enabled\n\tif s.opts.ServeRobotsTXT {\n\t\tpath := strings.TrimPrefix(r.URL.Path, anubis.BasePrefix)\n\t\tif path == \"/robots.txt\" || path == \"/.well-known/robots.txt\" {\n\t\t\ts.mux.ServeHTTP(w, r)\n\t\t\treturn\n\t\t}\n\t}\n\n\ts.maybeReverseProxyOrPage(w, r)\n}\n\nfunc (s *Server) stripBasePrefixFromRequest(r *http.Request) *http.Request {\n\tif !s.opts.StripBasePrefix || s.opts.BasePrefix == \"\" {\n\t\treturn r\n\t}\n\n\tbasePrefix := strings.TrimSuffix(s.opts.BasePrefix, \"/\")\n\tpath := r.URL.Path\n\n\tif !strings.HasPrefix(path, basePrefix) {\n\t\treturn r\n\t}\n\n\ttrimmedPath := strings.TrimPrefix(path, basePrefix)\n\tif trimmedPath == \"\" {\n\t\ttrimmedPath = \"/\"\n\t}\n\n\t// Clone the request and URL\n\treqCopy := r.Clone(r.Context())\n\turlCopy := *r.URL\n\turlCopy.Path = trimmedPath\n\treqCopy.URL = &urlCopy\n\n\treturn reqCopy\n}\n\nfunc (s *Server) ServeHTTPNext(w http.ResponseWriter, r *http.Request) {\n\tif s.next == nil {\n\t\tlocalizer := localization.GetLocalizer(r)\n\n\t\tredir := r.FormValue(\"redir\")\n\t\turlParsed, err := url.ParseRequestURI(redir)\n\t\tif err != nil {\n\t\t\t// if ParseRequestURI fails, try as relative URL\n\t\t\turlParsed, err = r.URL.Parse(redir)\n\t\t\tif err != nil {\n\t\t\t\ts.respondWithStatus(w, r, localizer.T(\"redirect_not_parseable\"), makeCode(err), http.StatusBadRequest)\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\n\t\t// validate URL scheme to prevent javascript:, data:, file:, tel:, etc.\n\t\tswitch urlParsed.Scheme {\n\t\tcase \"\", \"http\", \"https\":\n\t\t\t// allowed: empty scheme means relative URL\n\t\tdefault:\n\t\t\tlg := internal.GetRequestLogger(s.logger, r)\n\t\t\tlg.Warn(\"XSS attempt blocked, invalid redirect scheme\", \"scheme\", urlParsed.Scheme, \"redir\", redir)\n\t\t\ts.respondWithStatus(w, r, localizer.T(\"invalid_redirect\"), \"\", http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\n\t\thostNotAllowed := len(urlParsed.Host) > 0 &&\n\t\t\tlen(s.opts.RedirectDomains) != 0 &&\n\t\t\t!matchRedirectDomain(s.opts.RedirectDomains, urlParsed.Host)\n\t\thostMismatch := r.URL.Host != \"\" && urlParsed.Host != \"\" && urlParsed.Host != r.URL.Host\n\n\t\tif hostNotAllowed || hostMismatch {\n\t\t\tlg := internal.GetRequestLogger(s.logger, r)\n\t\t\tlg.Debug(\"domain not allowed\", \"domain\", urlParsed.Host)\n\t\t\ts.respondWithStatus(w, r, localizer.T(\"redirect_domain_not_allowed\"), makeCode(err), http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\n\t\tif redir != \"\" {\n\t\t\thttp.Redirect(w, r, redir, http.StatusFound)\n\t\t\treturn\n\t\t}\n\n\t\ttempl.Handler(\n\t\t\tweb.Base(localizer.T(\"you_are_not_a_bot\"), web.StaticHappy(localizer), s.policy.Impressum, localizer),\n\t\t).ServeHTTP(w, r)\n\t} else {\n\t\trequestsProxied.WithLabelValues(r.Host).Inc()\n\t\tr = s.stripBasePrefixFromRequest(r)\n\t\ts.next.ServeHTTP(w, r)\n\t}\n}\n\nfunc (s *Server) signJWT(claims jwt.MapClaims) (string, error) {\n\tclaims[\"iat\"] = time.Now().Unix()\n\tclaims[\"nbf\"] = time.Now().Add(-1 * time.Minute).Unix()\n\tclaims[\"exp\"] = time.Now().Add(s.opts.CookieExpiration).Unix()\n\n\tif len(s.hs512Secret) == 0 {\n\t\treturn jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims).SignedString(s.ed25519Priv)\n\t} else {\n\t\treturn jwt.NewWithClaims(jwt.SigningMethodHS512, claims).SignedString(s.hs512Secret)\n\t}\n}\n"
  },
  {
    "path": "lib/http_test.go",
    "content": "package lib\n\nimport (\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"net/url\"\n\t\"testing\"\n\n\t\"github.com/TecharoHQ/anubis\"\n\t\"github.com/TecharoHQ/anubis/internal\"\n\t\"github.com/TecharoHQ/anubis/lib/policy\"\n)\n\nfunc TestSetCookie(t *testing.T) {\n\tfor _, tt := range []struct {\n\t\tname       string\n\t\thost       string\n\t\tcookieName string\n\t\toptions    Options\n\t}{\n\t\t{\n\t\t\tname:       \"basic\",\n\t\t\toptions:    Options{},\n\t\t\thost:       \"\",\n\t\t\tcookieName: anubis.CookieName,\n\t\t},\n\t\t{\n\t\t\tname:       \"domain techaro.lol\",\n\t\t\toptions:    Options{CookieDomain: \"techaro.lol\"},\n\t\t\thost:       \"\",\n\t\t\tcookieName: anubis.CookieName,\n\t\t},\n\t\t{\n\t\t\tname:       \"dynamic cookie domain\",\n\t\t\toptions:    Options{CookieDynamicDomain: true},\n\t\t\thost:       \"techaro.lol\",\n\t\t\tcookieName: anubis.CookieName,\n\t\t},\n\t} {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tsrv := spawnAnubis(t, tt.options)\n\t\t\trw := httptest.NewRecorder()\n\n\t\t\tsrv.SetCookie(rw, CookieOpts{Value: \"test\", Host: tt.host})\n\n\t\t\tresp := rw.Result()\n\t\t\tcookies := resp.Cookies()\n\n\t\t\tckie := cookies[0]\n\n\t\t\tif ckie.Name != tt.cookieName {\n\t\t\t\tt.Errorf(\"wanted cookie named %q, got cookie named %q\", tt.cookieName, ckie.Name)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestClearCookie(t *testing.T) {\n\tsrv := spawnAnubis(t, Options{})\n\trw := httptest.NewRecorder()\n\n\tsrv.ClearCookie(rw, CookieOpts{Host: \"localhost\"})\n\n\tresp := rw.Result()\n\n\tcookies := resp.Cookies()\n\n\tif len(cookies) != 1 {\n\t\tt.Errorf(\"wanted 1 cookie, got %d cookies\", len(cookies))\n\t}\n\n\tckie := cookies[0]\n\n\tif ckie.Name != anubis.CookieName {\n\t\tt.Errorf(\"wanted cookie named %q, got cookie named %q\", anubis.CookieName, ckie.Name)\n\t}\n\n\tif ckie.MaxAge != -1 {\n\t\tt.Errorf(\"wanted cookie max age of -1, got: %d\", ckie.MaxAge)\n\t}\n}\n\nfunc TestClearCookieWithDomain(t *testing.T) {\n\tsrv := spawnAnubis(t, Options{CookieDomain: \"techaro.lol\"})\n\trw := httptest.NewRecorder()\n\n\tsrv.ClearCookie(rw, CookieOpts{Host: \"localhost\"})\n\n\tresp := rw.Result()\n\n\tcookies := resp.Cookies()\n\n\tif len(cookies) != 1 {\n\t\tt.Errorf(\"wanted 1 cookie, got %d cookies\", len(cookies))\n\t}\n\n\tckie := cookies[0]\n\n\tif ckie.Name != anubis.CookieName {\n\t\tt.Errorf(\"wanted cookie named %q, got cookie named %q\", anubis.CookieName, ckie.Name)\n\t}\n\n\tif ckie.MaxAge != -1 {\n\t\tt.Errorf(\"wanted cookie max age of -1, got: %d\", ckie.MaxAge)\n\t}\n}\n\nfunc TestClearCookieWithDynamicDomain(t *testing.T) {\n\tsrv := spawnAnubis(t, Options{CookieDynamicDomain: true})\n\trw := httptest.NewRecorder()\n\n\tsrv.ClearCookie(rw, CookieOpts{Host: \"subdomain.xeiaso.net\"})\n\n\tresp := rw.Result()\n\n\tcookies := resp.Cookies()\n\n\tif len(cookies) != 1 {\n\t\tt.Errorf(\"wanted 1 cookie, got %d cookies\", len(cookies))\n\t}\n\n\tckie := cookies[0]\n\n\tif ckie.Name != anubis.CookieName {\n\t\tt.Errorf(\"wanted cookie named %q, got cookie named %q\", anubis.CookieName, ckie.Name)\n\t}\n\n\tif ckie.Domain != \"xeiaso.net\" {\n\t\tt.Errorf(\"wanted cookie domain %q, got cookie domain %q\", \"xeiaso.net\", ckie.Domain)\n\t}\n\n\tif ckie.MaxAge != -1 {\n\t\tt.Errorf(\"wanted cookie max age of -1, got: %d\", ckie.MaxAge)\n\t}\n}\n\nfunc TestRenderIndexRedirect(t *testing.T) {\n\ts := &Server{\n\t\topts: Options{\n\t\t\tPublicUrl: \"https://anubis.example.com\",\n\t\t},\n\t}\n\treq := httptest.NewRequest(\"GET\", \"/\", nil)\n\treq.Header.Set(\"X-Forwarded-Proto\", \"https\")\n\treq.Header.Set(\"X-Forwarded-Host\", \"example.com\")\n\treq.Header.Set(\"X-Forwarded-Uri\", \"/foo\")\n\n\trr := httptest.NewRecorder()\n\ts.RenderIndex(rr, req, policy.CheckResult{}, nil, true)\n\n\tif rr.Code != http.StatusTemporaryRedirect {\n\t\tt.Errorf(\"expected status %d, got %d\", http.StatusTemporaryRedirect, rr.Code)\n\t}\n\tlocation := rr.Header().Get(\"Location\")\n\tparsedURL, err := url.Parse(location)\n\tif err != nil {\n\t\tt.Fatalf(\"failed to parse location URL %q: %v\", location, err)\n\t}\n\n\tscheme := \"https\"\n\tif parsedURL.Scheme != scheme {\n\t\tt.Errorf(\"expected scheme to be %q, got %q\", scheme, parsedURL.Scheme)\n\t}\n\n\thost := \"anubis.example.com\"\n\tif parsedURL.Host != host {\n\t\tt.Errorf(\"expected url to be %q, got %q\", host, parsedURL.Host)\n\t}\n\n\tredir := parsedURL.Query().Get(\"redir\")\n\texpectedRedir := \"https://example.com/foo\"\n\tif redir != expectedRedir {\n\t\tt.Errorf(\"expected redir param to be %q, got %q\", expectedRedir, redir)\n\t}\n}\n\nfunc TestRenderIndexUnauthorized(t *testing.T) {\n\ts := &Server{\n\t\topts: Options{\n\t\t\tPublicUrl: \"\",\n\t\t},\n\t}\n\treq := httptest.NewRequest(\"GET\", \"/\", nil)\n\trr := httptest.NewRecorder()\n\n\ts.RenderIndex(rr, req, policy.CheckResult{}, nil, true)\n\n\tif rr.Code != http.StatusUnauthorized {\n\t\tt.Errorf(\"expected status %d, got %d\", http.StatusUnauthorized, rr.Code)\n\t}\n\tif body := rr.Body.String(); body != \"Authorization required\" {\n\t\tt.Errorf(\"expected body %q, got %q\", \"Authorization required\", body)\n\t}\n}\n\nfunc TestNoCacheOnError(t *testing.T) {\n\tpol := loadPolicies(t, \"testdata/useragent.yaml\", 0)\n\tsrv := spawnAnubis(t, Options{Policy: pol})\n\tts := httptest.NewServer(internal.RemoteXRealIP(true, \"tcp\", srv))\n\tdefer ts.Close()\n\n\tfor userAgent, expectedCacheControl := range map[string]string{\n\t\t\"DENY\":      \"no-store\",\n\t\t\"CHALLENGE\": \"no-store\",\n\t\t\"ALLOW\":     \"\",\n\t} {\n\t\tt.Run(userAgent, func(t *testing.T) {\n\t\t\treq, err := http.NewRequest(http.MethodGet, ts.URL, nil)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatal(err)\n\t\t\t}\n\n\t\t\treq.Header.Set(\"User-Agent\", userAgent)\n\n\t\t\tresp, err := ts.Client().Do(req)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatal(err)\n\t\t\t}\n\n\t\t\tif resp.Header.Get(\"Cache-Control\") != expectedCacheControl {\n\t\t\t\tt.Errorf(\"wanted Cache-Control header %q, got %q\", expectedCacheControl, resp.Header.Get(\"Cache-Control\"))\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "lib/localization/locales/cs.json",
    "content": "{\n  \"loading\": \"Načítám...\",\n  \"why_am_i_seeing\": \"Proč to vidím?\",\n  \"protected_by\": \"Chráněno pomocí\",\n  \"protected_from\": \"Od\",\n  \"made_with\": \"Vytvořeno s ❤️ v 🇨🇦\",\n  \"mascot_design\": \"Design maskota od\",\n  \"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.\",\n  \"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.\",\n  \"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í.\",\n  \"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á <a href=\\\"https://en.wikipedia.org/wiki/Proof_of_work\\\">Proof of Work</a>. Ú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.\",\n  \"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.\",\n  \"version_info\": \"Tato webová stránka běží na Anubis verzi\",\n  \"try_again\": \"Zkusit znovu\",\n  \"go_home\": \"Přejít na úvodní stránku\",\n  \"contact_webmaster\": \"nebo pokud si myslíte, že byste neměli být blokováni, kontaktujte správce na\",\n  \"connection_security\": \"Prosím počkejte chvilku, zatímco zajišťujeme bezpečnost vašeho připojení.\",\n  \"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.\",\n  \"benchmark_requires_js\": \"Spuštění testovacího nástroje vyžaduje povolení JavaScriptu.\",\n  \"difficulty\": \"Obtížnost:\",\n  \"algorithm\": \"Algoritmus:\",\n  \"compare\": \"Porovnat:\",\n  \"time\": \"Čas\",\n  \"iters\": \"Iterace\",\n  \"time_a\": \"Čas A\",\n  \"iters_a\": \"Iterace A\",\n  \"time_b\": \"Čas B\",\n  \"iters_b\": \"Iterace B\",\n  \"static_check_endpoint\": \"Toto je pouze kontrolní koncový bod pro přístup na tuto stránku.\",\n  \"authorization_required\": \"Vyžadována autorizace\",\n  \"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\",\n  \"access_denied\": \"Přístup zamítnut: kód chyby\",\n  \"dronebl_entry\": \"DroneBL nahlásil záznam\",\n  \"see_dronebl_lookup\": \"viz\",\n  \"internal_server_error\": \"Interní chyba serveru: správce špatně nakonfiguroval Anubis. Kontaktujte správce a požádejte ho, aby zkontroloval systémové záznamy.\",\n  \"invalid_redirect\": \"Neplatné přesměrování\",\n  \"redirect_not_parseable\": \"URL přesměrování nelze analyzovat\",\n  \"redirect_domain_not_allowed\": \"Doména přesměrování není povolena\",\n  \"failed_to_sign_jwt\": \"nepodařilo se podepsat JWT\",\n  \"invalid_invocation\": \"Neplatné vyvolání MakeChallenge\",\n  \"client_error_browser\": \"Chyba prohlížeče: Ujistěte se, že váš prohlížeč je aktuální a zkuste to později.\",\n  \"oh_noes\": \"Jejda!\",\n  \"benchmarking_anubis\": \"Testování Anubise!\",\n  \"you_are_not_a_bot\": \"Nejste robot!\",\n  \"making_sure_not_bot\": \"Ujišťujeme se, že nejste robot!\",\n  \"celphase\": \"CELPHASE\",\n  \"js_web_crypto_error\": \"Váš prohlížeč nepodporuje funkci web.crypto. Používáte zabezpečené připojení?\",\n  \"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é?\",\n  \"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.\",\n  \"js_context_not_secure\": \"Vaše připojení není bezpečné!\",\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 <a href=\\\"https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts#when_is_a_context_considered_secure\\\">MDN</a>.\",\n  \"js_calculating\": \"Počítám...\",\n  \"js_missing_feature\": \"Chybějící funkce\",\n  \"js_challenge_error\": \"Chyba výzvy!\",\n  \"js_challenge_error_msg\": \"Nepodařilo se vyřešit kontrolní algoritmus. Možná budete chtít obnovit stránku.\",\n  \"js_calculating_difficulty\": \"Počítám...<br/>Obtížnost:\",\n  \"js_speed\": \"Rychlost:\",\n  \"js_verification_longer\": \"Ověřování trvá déle, než se očekávalo. Prosím neobnovujte stránku.\",\n  \"js_success\": \"Úspěch!\",\n  \"js_done_took\": \"Hotovo! Trvalo to\",\n  \"js_iterations\": \"iterací\",\n  \"js_finished_reading\": \"Čtení dokončeno, pokračovat →\",\n  \"js_calculation_error\": \"Chyba výpočtu!\",\n  \"js_calculation_error_msg\": \"Nepodařilo se vypočítat výzvu:\",\n  \"missing_required_forwarded_headers\": \"Chybějící požadované hlavičky X-Forwarded-*\"\n}\n"
  },
  {
    "path": "lib/localization/locales/de.json",
    "content": "{\n  \"loading\": \"Ladevorgang...\",\n  \"why_am_i_seeing\": \"Warum sehe ich diese Seite?\",\n  \"protected_by\": \"Geschützt durch\",\n  \"protected_from\": \"Von\",\n  \"made_with\": \"Mit ❤️ entwickelt in 🇨🇦\",\n  \"mascot_design\": \"Maskottchen erstellt von\",\n  \"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.\",\n  \"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.\",\n  \"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.\",\n  \"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 <a href=\\\"https://en.wikipedia.org/wiki/Proof_of_work\\\">Proof of Work</a>. Die Aufgabe wird in wenigen Sekunden berechnet und du erhältst Zugriff auf die Website. Danke für deine Geduld.\",\n  \"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.\",\n  \"version_info\": \"Diese Website läuft mit Anubis-Version\",\n  \"try_again\": \"Erneut versuchen\",\n  \"go_home\": \"Zur Startseite\",\n  \"contact_webmaster\": \"Falls du glaubst, dass es sich um einen Fehler handelt, kontaktiere bitte den Administrator unter\",\n  \"connection_security\": \"Bitte warte einen Moment, während wir die Sicherheit deiner Verbindung prüfen.\",\n  \"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.\",\n  \"benchmark_requires_js\": \"Für die Nutzung des Benchmark-Tools muss JavaScript aktiviert sein.\",\n  \"difficulty\": \"Schwierigkeit:\",\n  \"algorithm\": \"Algorithmus:\",\n  \"compare\": \"Vergleich:\",\n  \"time\": \"Zeit\",\n  \"iters\": \"Iterationen\",\n  \"time_a\": \"Zeit A\",\n  \"iters_a\": \"Iterationen A\",\n  \"time_b\": \"Zeit B\",\n  \"iters_b\": \"Iterationen B\",\n  \"static_check_endpoint\": \"Dies ist ein Endpunkt zur Prüfung durch einen Reverse-Proxy.\",\n  \"authorization_required\": \"Autorisierung erforderlich\",\n  \"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.\",\n  \"access_denied\": \"Zugriff verweigert – Fehlercode\",\n  \"dronebl_entry\": \"Eintrag in DroneBL\",\n  \"see_dronebl_lookup\": \"anzeigen\",\n  \"internal_server_error\": \"Interner Serverfehler: Der Administrator hat Anubis fehlerhaft konfiguriert. Bitte kontaktiere den Administrator und bitte ihn, die Logs zu prüfen.\",\n  \"invalid_redirect\": \"Ungültige Weiterleitung\",\n  \"redirect_not_parseable\": \"Weiterleitungs-URL kann nicht verarbeitet werden\",\n  \"redirect_domain_not_allowed\": \"Weiterleitungs-Domain nicht erlaubt\",\n  \"missing_required_forwarded_headers\": \"Erforderliche X-Forwarded-*-Header fehlen\",\n  \"failed_to_sign_jwt\": \"JWT konnte nicht signiert werden\",\n  \"invalid_invocation\": \"Ungültiger Aufruf von MakeChallenge\",\n  \"client_error_browser\": \"Client-Fehler: Bitte stelle sicher, dass dein Browser aktuell ist, und versuche es später erneut.\",\n  \"oh_noes\": \"Oh nein!\",\n  \"benchmarking_anubis\": \"Benchmark wird durchgeführt!\",\n  \"you_are_not_a_bot\": \"Du bist kein Bot!\",\n  \"making_sure_not_bot\": \"Dein Browser wird geprüft!\",\n  \"celphase\": \"CELPHASE\",\n  \"js_web_crypto_error\": \"Dein Browser verfügt nicht über ein funktionierendes web.crypto-Element. Wird eine sichere Verbindung verwendet?\",\n  \"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?\",\n  \"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.\",\n  \"js_context_not_secure\": \"Diese Verbindung ist nicht sicher!\",\n  \"js_context_not_secure_msg\": \"Bitte versuche, dich über HTTPS zu verbinden, oder weise den Administrator darauf hin, HTTPS einzurichten. Mehr Informationen: <a href=\\\"https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts#when_is_a_context_considered_secure\\\">MDN</a>.\",\n  \"js_calculating\": \"Berechnung läuft...\",\n  \"js_missing_feature\": \"Fehlendes Feature\",\n  \"js_challenge_error\": \"Prüfung fehlgeschlagen!\",\n  \"js_challenge_error_msg\": \"Der Prüf-Algorithmus konnte nicht geladen werden. Bitte lade die Seite neu.\",\n  \"js_calculating_difficulty\": \"Berechnung läuft...<br/>Schwierigkeit:\",\n  \"js_speed\": \"Geschwindigkeit:\",\n  \"js_verification_longer\": \"Die Prüfung dauert länger als erwartet. Bitte warte und lade die Seite nicht neu.\",\n  \"js_success\": \"Erfolgreich!\",\n  \"js_done_took\": \"Fertig! Dauer:\",\n  \"js_iterations\": \"Iterationen\",\n  \"js_finished_reading\": \"Fertig gelesen – weiter zur Seite →\",\n  \"js_calculation_error\": \"Berechnungsfehler!\",\n  \"js_calculation_error_msg\": \"Fehler bei der Berechnung der Prüfung:\"\n}\n"
  },
  {
    "path": "lib/localization/locales/en.json",
    "content": "{\n  \"loading\": \"Loading...\",\n  \"why_am_i_seeing\": \"Why am I seeing this?\",\n  \"protected_by\": \"Protected by\",\n  \"protected_from\": \"From\",\n  \"made_with\": \"Made with ❤️ in 🇨🇦\",\n  \"mascot_design\": \"Mascot design by\",\n  \"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.\",\n  \"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.\",\n  \"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.\",\n  \"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 <a href=\\\"https://en.wikipedia.org/wiki/Proof_of_work\\\">Proof of Work</a>. The task is calculated in a few seconds and you are granted access to the website. Thank you for your understanding and patience.\",\n  \"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.\",\n  \"version_info\": \"This website is running Anubis version\",\n  \"try_again\": \"Try again\",\n  \"go_home\": \"Go home\",\n  \"contact_webmaster\": \"or if you believe you should not be blocked, please contact the webmaster at\",\n  \"connection_security\": \"Please wait a moment while we ensure the security of your connection.\",\n  \"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.\",\n  \"benchmark_requires_js\": \"Running the benchmark tool requires JavaScript to be enabled.\",\n  \"difficulty\": \"Difficulty:\",\n  \"algorithm\": \"Algorithm:\",\n  \"compare\": \"Compare:\",\n  \"time\": \"Time\",\n  \"iters\": \"Iters\",\n  \"time_a\": \"Time A\",\n  \"iters_a\": \"Iters A\",\n  \"time_b\": \"Time B\",\n  \"iters_b\": \"Iters B\",\n  \"static_check_endpoint\": \"This is just a check endpoint for your reverse proxy to use.\",\n  \"authorization_required\": \"Authorization required\",\n  \"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\",\n  \"access_denied\": \"Access Denied: error code\",\n  \"dronebl_entry\": \"DroneBL reported an entry\",\n  \"see_dronebl_lookup\": \"see\",\n  \"internal_server_error\": \"Internal Server Error: administrator has misconfigured Anubis. Please contact the administrator and ask them to look for the logs around\",\n  \"invalid_redirect\": \"Invalid redirect\",\n  \"redirect_not_parseable\": \"Redirect URL not parseable\",\n  \"redirect_domain_not_allowed\": \"Redirect domain not allowed\",\n  \"missing_required_forwarded_headers\": \"Missing required X-Forwarded-* headers\",\n  \"failed_to_sign_jwt\": \"failed to sign JWT\",\n  \"invalid_invocation\": \"Invalid invocation of MakeChallenge\",\n  \"client_error_browser\": \"Client Error: Please ensure your browser is up to date and try again later.\",\n  \"oh_noes\": \"Oh noes!\",\n  \"benchmarking_anubis\": \"Benchmarking Anubis!\",\n  \"you_are_not_a_bot\": \"You are not a bot!\",\n  \"making_sure_not_bot\": \"Making sure you're not a bot!\",\n  \"celphase\": \"CELPHASE\",\n  \"js_web_crypto_error\": \"Your browser doesn't have a functioning web.crypto element. Are you viewing this over a secure context?\",\n  \"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?\",\n  \"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.\",\n  \"js_context_not_secure\": \"Your context is not secure!\",\n  \"js_context_not_secure_msg\": \"Try connecting over HTTPS or let the admin know to set up HTTPS. For more information, see <a href=\\\"https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts#when_is_a_context_considered_secure\\\">MDN</a>.\",\n  \"js_calculating\": \"Calculating...\",\n  \"js_missing_feature\": \"Missing feature\",\n  \"js_challenge_error\": \"Challenge error!\",\n  \"js_challenge_error_msg\": \"Failed to resolve check algorithm. You may want to reload the page.\",\n  \"js_calculating_difficulty\": \"Calculating...<br/>Difficulty:\",\n  \"js_speed\": \"Speed:\",\n  \"js_verification_longer\": \"Verification is taking longer than expected. Please do not refresh the page.\",\n  \"js_success\": \"Success!\",\n  \"js_done_took\": \"Done! Took\",\n  \"js_iterations\": \"iterations\",\n  \"js_finished_reading\": \"I've finished reading, continue →\",\n  \"js_calculation_error\": \"Calculation error!\",\n  \"js_calculation_error_msg\": \"Failed to calculate challenge:\"\n}\n"
  },
  {
    "path": "lib/localization/locales/es.json",
    "content": "{\n  \"loading\": \"Cargando...\",\n  \"why_am_i_seeing\": \"¿Por qué veo esto?\",\n  \"protected_by\": \"Protegido por\",\n  \"protected_from\": \"From\",\n  \"made_with\": \"Hecho con ❤️ en 🇨🇦\",\n  \"mascot_design\": \"Diseño de la mascota por\",\n  \"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.\",\n  \"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.\",\n  \"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.\",\n  \"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.\",\n  \"version_info\": \"Este sitio web utiliza Anubis versión\",\n  \"try_again\": \"Intentar de nuevo\",\n  \"go_home\": \"Inicio\",\n  \"contact_webmaster\": \"o si crees que no deberías estar bloqueado, por favor contacta al webmaster en\",\n  \"connection_security\": \"Espere un momento mientras garantizamos la seguridad de su conexión.\",\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.\",\n  \"benchmark_requires_js\": \"Ejecutar la herramienta de benchmark requiere que JavaScript esté habilitado.\",\n  \"difficulty\": \"Dificultad:\",\n  \"algorithm\": \"Algoritmo:\",\n  \"compare\": \"Comparar:\",\n  \"time\": \"Tiempo\",\n  \"iters\": \"Iteraciones\",\n  \"time_a\": \"Tiempo A\",\n  \"iters_a\": \"Iter. A\",\n  \"time_b\": \"Tiempo B\",\n  \"iters_b\": \"Iter. B\",\n  \"static_check_endpoint\": \"Este es solo un endpoint de verificación para que tu proxy inverso lo use.\",\n  \"authorization_required\": \"Autorización requerida\",\n  \"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\",\n  \"access_denied\": \"Acceso denegado: código de error\",\n  \"dronebl_entry\": \"DroneBL reportó una entrada\",\n  \"see_dronebl_lookup\": \"ver\",\n  \"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\",\n  \"invalid_redirect\": \"Redirección inválida\",\n  \"redirect_not_parseable\": \"URL de redirección no analizable\",\n  \"redirect_domain_not_allowed\": \"Dominio de redirección no permitido\",\n  \"failed_to_sign_jwt\": \"falló al firmar JWT\",\n  \"invalid_invocation\": \"Invocación inválida de MakeChallenge\",\n  \"client_error_browser\": \"Error del cliente: Por favor asegúrate de que tu navegador esté actualizado e inténtalo de nuevo más tarde.\",\n  \"oh_noes\": \"¡Oh no!\",\n  \"benchmarking_anubis\": \"¡Benchmarking de Anubis!\",\n  \"you_are_not_a_bot\": \"¡No eres un robot!\",\n  \"making_sure_not_bot\": \"¡Asegurándonos de que no eres un robot!\",\n  \"celphase\": \"CELPHASE\",\n  \"js_web_crypto_error\": \"Tu navegador no tiene un elemento web.crypto funcional. ¿Estás viendo esta página en un contexto seguro?\",\n  \"js_web_workers_error\": \"Tu navegador no soporta web workers (Anubis los usa para evitar bloquear tu navegador). ¿Tienes un plugin como JShelter instalado?\",\n  \"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.\",\n  \"js_context_not_secure\": \"¡Tu contexto no es seguro!\",\n  \"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 <a href=\\\"https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts#when_is_a_context_considered_secure\\\">MDN</a>.\",\n  \"js_calculating\": \"Calculando...\",\n  \"js_missing_feature\": \"Característica faltante\",\n  \"js_challenge_error\": \"¡Error de desafío!\",\n  \"js_challenge_error_msg\": \"Falló al resolver el algoritmo de verificación. Puedes intentar recargar la página.\",\n  \"js_calculating_difficulty\": \"Calculando...<br/>Dificultad:\",\n  \"js_speed\": \"Velocidad:\",\n  \"js_verification_longer\": \"La verificación está tomando más tiempo del esperado. Por favor no actualices la página.\",\n  \"js_success\": \"¡Éxito!\",\n  \"js_done_took\": \"¡Terminado! Tomó\",\n  \"js_iterations\": \"iteraciones\",\n  \"js_finished_reading\": \"He terminado de leer, continuar →\",\n  \"js_calculation_error\": \"¡Error de cálculo!\",\n  \"js_calculation_error_msg\": \"Falló al calcular el desafío:\",\n  \"missing_required_forwarded_headers\": \"Faltan los encabezados X-Forwarded-* requeridos\",\n  \"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 <a href=\\\"https://en.wikipedia.org/wiki/Proof_of_work\\\">Prueba de trabajo</a>. La tarea se calcula en unos segundos y se le concede acceso al sitio web. Gracias por su comprensión y paciencia.\"\n}\n"
  },
  {
    "path": "lib/localization/locales/et.json",
    "content": "{\n  \"loading\": \"Laadin...\",\n  \"why_am_i_seeing\": \"Miks ma pean seda nägema?\",\n  \"protected_by\": \"Kaitseb\",\n  \"protected_from\": \"From\",\n  \"made_with\": \"Tehtud ❤️ga 🇨🇦s\",\n  \"mascot_design\": \"Maskoti disainis\",\n  \"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.\",\n  \"anubis_compromise\": \"Anubis on kompromisslahendus. Anubis kasutab nö. töötõendi skeemi, mille sarnane oli <em>Hashcash</em>, 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.\",\n  \"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.\",\n  \"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.\",\n  \"version_info\": \"Sellel lehel jookseb Anubis, versioon\",\n  \"try_again\": \"Proovi uuesti\",\n  \"go_home\": \"Mine koju\",\n  \"contact_webmaster\": \"või kui sa arvad, et sa ei peaks olema blokeeritud, võta ühendust veebimeistriga aadressil\",\n  \"connection_security\": \"Oota korraks, me kontrollime ühenduse turvalisust.\",\n  \"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.\",\n  \"benchmark_requires_js\": \"Kiirustesti jaoks on vajalik JavaScript sisse lülitada.\",\n  \"difficulty\": \"Raskus:\",\n  \"algorithm\": \"Algoritm:\",\n  \"compare\": \"Võrdle:\",\n  \"time\": \"Aega\",\n  \"iters\": \"Korda\",\n  \"time_a\": \"A aega\",\n  \"iters_a\": \"A korda\",\n  \"time_b\": \"B aega\",\n  \"iters_b\": \"B korda\",\n  \"static_check_endpoint\": \"Seda lehte vaatab ainult sinu vaheserver.\",\n  \"authorization_required\": \"Ligipääs puudub\",\n  \"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\",\n  \"access_denied\": \"Ligipääs keelatud: veakood\",\n  \"dronebl_entry\": \"DroneBL tagastas sissekande\",\n  \"see_dronebl_lookup\": \"vaata\",\n  \"internal_server_error\": \"Programmi sisemine viga: administraator on Anubise valesti seadistanud. Võta temaga ühendust ja palu tal otsida logidest märksõna\",\n  \"invalid_redirect\": \"Vigane ümbersuunamine\",\n  \"redirect_not_parseable\": \"Ümbersuunamise URL on vigane\",\n  \"redirect_domain_not_allowed\": \"Ümbersuunamise domeen pole lubatud\",\n  \"failed_to_sign_jwt\": \"JWT allkirjastamine ebaõnnestus\",\n  \"invalid_invocation\": \"MakeChallenge väljakutsumine on vigane\",\n  \"client_error_browser\": \"Kliendipoolne viga: palun kontrolli, et su brauser oleks uuendatud ja proovi uuesti.\",\n  \"oh_noes\": \"Oi ei!\",\n  \"benchmarking_anubis\": \"Anubise kiirustest!\",\n  \"you_are_not_a_bot\": \"Sina ei ole bott!\",\n  \"making_sure_not_bot\": \"Kontrollime, et sa ei ole bott!\",\n  \"celphase\": \"CELPHASE\",\n  \"js_web_crypto_error\": \"Sinu brauseris ei ole töötavat web.crypto elementi. Kas sa avasid selle turvakontekstis?\",\n  \"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?\",\n  \"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.\",\n  \"js_context_not_secure\": \"Sinu brauserikontekst ei ole turvaline!\",\n  \"js_context_not_secure_msg\": \"Proovi ühendada HTTPS aadressiga või anna administraatorile teada, et HTTPS on vajalik seadistada. Lisainfot vaata <a href=\\\"https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts#when_is_a_context_considered_secure\\\">MDNist</a>.\",\n  \"js_calculating\": \"Arvutan...\",\n  \"js_missing_feature\": \"Puuduv brauseri omadus\",\n  \"js_challenge_error\": \"Kontrolli viga!\",\n  \"js_challenge_error_msg\": \"Ei suutnud tuvastada kontrollalgoritmi. Võiksid proovida lehe uuesti laadida.\",\n  \"js_calculating_difficulty\": \"Arvutan...<br/>Raskus:\",\n  \"js_speed\": \"Kiirus:\",\n  \"js_verification_longer\": \"Kontrollimine võtab kauem kui tavaliselt. Palun ära lae lehte uuesti.\",\n  \"js_success\": \"Õnnestus!\",\n  \"js_done_took\": \"Tehtud! Võttis\",\n  \"js_iterations\": \"kordust\",\n  \"js_finished_reading\": \"Lugesin ära, edasi →\",\n  \"js_calculation_error\": \"Arvutamise viga!\",\n  \"js_calculation_error_msg\": \"Ei suutnud kontrolli arvutada:\",\n  \"missing_required_forwarded_headers\": \"Puuduvad nõutud X-Forwarded-* päised\",\n  \"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 <a href=\\\"https://en.wikipedia.org/wiki/Proof_of_work\\\">Töötõendiks</a>. Ülesanne arvutatakse mõne sekundiga ja teile antakse juurdepääs veebisaidile. Täname teid mõistva suhtumise ja kannatlikkuse eest.\"\n}\n"
  },
  {
    "path": "lib/localization/locales/fi.json",
    "content": "{\n  \"loading\": \"Ladataan...\",\n  \"why_am_i_seeing\": \"Miksi näen tämän?\",\n  \"protected_by\": \"Suojan tarjoaa\",\n  \"protected_from\": \"tekijänä\",\n  \"made_with\": \"❤️ tehty 🇨🇦:ssa\",\n  \"mascot_design\": \"Maskotin suunnitellut\",\n  \"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.\",\n  \"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.\",\n  \"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.\",\n  \"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.\",\n  \"version_info\": \"Sivusto käyttää Anubis versiota\",\n  \"try_again\": \"Yritä uudelleen\",\n  \"go_home\": \"Poistu\",\n  \"contact_webmaster\": \"tai jos uskot ettei sinua tulisi estää, ota yhteyttä ylläpitäjään\",\n  \"connection_security\": \"Odota hetki. Varmistamme yhteytesi tietoturvan.\",\n  \"javascript_required\": \"Valitettavasti JavaScript on oltava käytössä tämän haasteen suorittamiseksi. Vaihtoehtoinen ratkaisu on työn alla.\",\n  \"benchmark_requires_js\": \"JavaScript on oltava käytössä suorituskykytestin ajamiseksi.\",\n  \"difficulty\": \"Vaikeus:\",\n  \"algorithm\": \"Kaava:\",\n  \"compare\": \"Vertailu:\",\n  \"time\": \"Aika\",\n  \"iters\": \"Toisto\",\n  \"time_a\": \"Aika A\",\n  \"iters_a\": \"Toisto A\",\n  \"time_b\": \"Aika B\",\n  \"iters_b\": \"Toisto B\",\n  \"static_check_endpoint\": \"Tämä päätepiste on käyttämääsi käänteistä välityspalvelinta varten.\",\n  \"authorization_required\": \"Valtuutus vaadittu\",\n  \"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\",\n  \"access_denied\": \"Pääsy estetty: virhekoodi\",\n  \"dronebl_entry\": \"DroneBL ilmoitti merkinnän\",\n  \"see_dronebl_lookup\": \"katso\",\n  \"internal_server_error\": \"Palvelinvirhe: Anubis on väärin määritetty. Pyydä ylläpitäjää tarkistamaan lokit\",\n  \"invalid_redirect\": \"Virheellinen pyyntö\",\n  \"redirect_not_parseable\": \"Uudellenohjauksen URL ei voitu jäsentää\",\n  \"redirect_domain_not_allowed\": \"Uudelleenohjauksen verkkotunnus ei ole sallittu\",\n  \"failed_to_sign_jwt\": \"JWT ei voitu allekirjoittaa\",\n  \"invalid_invocation\": \"Virheellinen MakeChallenge-kaava\",\n  \"client_error_browser\": \"Käyttäjävirhe: Varmista ettei selaimesi ole vanhentunut ja yritä uudelleen.\",\n  \"oh_noes\": \"Voi ei!\",\n  \"benchmarking_anubis\": \"Testataan Anubis!\",\n  \"you_are_not_a_bot\": \"Et ole robotti!\",\n  \"making_sure_not_bot\": \"Varmistetaan ettet ole robotti!\",\n  \"celphase\": \"CELPHASE\",\n  \"js_web_crypto_error\": \"Selaimesi web.crypto elementti ei toimi. Onko yhteytesi suojattu?\",\n  \"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ä?\",\n  \"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.\",\n  \"js_context_not_secure\": \"Yhteytesi ei ole suojattu!\",\n  \"js_context_not_secure_msg\": \"Yhdistä käyttäen HTTPS tai pyydä ylläpitäjää määrittämään HTTPS. Saadaksesi lisätietoja, katso <a href=\\\"https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts#when_is_a_context_considered_secure\\\">MDN</a>.\",\n  \"js_calculating\": \"Lasketaan...\",\n  \"js_missing_feature\": \"Puuttuva ominaisuus\",\n  \"js_challenge_error\": \"Haastevirhe!\",\n  \"js_challenge_error_msg\": \"Tarkistuskaavaa ei voitu ratkaista. Voit yrittää ladata sivua uudelleen.\",\n  \"js_calculating_difficulty\": \"Lasketaan...<br/>Vaikeus:\",\n  \"js_speed\": \"Nopeus:\",\n  \"js_verification_longer\": \"Vahvistus kestää odotettua pitempään. Ethän lataa sivua uudelleen.\",\n  \"js_success\": \"Onnistui!\",\n  \"js_done_took\": \"Valmis! Kesti\",\n  \"js_iterations\": \"toistot\",\n  \"js_finished_reading\": \"Luettu, jatka →\",\n  \"js_calculation_error\": \"Laskentavirhe!\",\n  \"js_calculation_error_msg\": \"Haasteen laskenta ei onnistunut:\",\n  \"missing_required_forwarded_headers\": \"Puuttuvat vaaditut X-Forwarded-* otsikot\",\n  \"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ä <a href=\\\"https://en.wikipedia.org/wiki/Proof_of_work\\\">Työtodistus</a>. Tehtävä lasketaan muutamassa sekunnissa ja saat pääsyn verkkosivustolle. Kiitos ymmärryksestäsi ja kärsivällisyydestäsi.\"\n}\n"
  },
  {
    "path": "lib/localization/locales/fil.json",
    "content": "{\n  \"loading\": \"Naglo-load...\",\n  \"why_am_i_seeing\": \"Bakit nakikita ko ito?\",\n  \"protected_by\": \"Pinoprotekta ng\",\n  \"protected_from\": \"mula sa\",\n  \"made_with\": \"Ginawa na may ❤️ sa 🇨🇦\",\n  \"mascot_design\": \"Disenyo ng Maskot ni/ng\",\n  \"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.\",\n  \"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.\",\n  \"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.\",\n  \"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.\",\n  \"version_info\": \"Ang website na ito ay tumatakbo ng Anubis bersyon\",\n  \"try_again\": \"Subukan muli\",\n  \"go_home\": \"Bumalik sa panimula\",\n  \"contact_webmaster\": \"o kung naniniwala ka na hindi ka dapat na-block, mangyaring makipag-ugnayan sa mga webmaster sa\",\n  \"connection_security\": \"Mangyaring maghintay nang ilang sandali habang sinisigurado namin ang seguridad ng iyong koneksyon.\",\n  \"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.\",\n  \"benchmark_requires_js\": \"Kinakailangang naka-enable ang JavaScript upang patakbuhin ang benchmark tool.\",\n  \"difficulty\": \"Kahirapan:\",\n  \"algorithm\": \"Algoritmo:\",\n  \"compare\": \"Kumpara:\",\n  \"time\": \"Oras\",\n  \"iters\": \"Mga Iterasyon\",\n  \"time_a\": \"Time A\",\n  \"iters_a\": \"Iters A\",\n  \"time_b\": \"Time B\",\n  \"iters_b\": \"Iters B\",\n  \"static_check_endpoint\": \"Isa lang itong check endpoint para magamit ng iyong reverse proxy.\",\n  \"authorization_required\": \"Kinakailangan ang pagpapatunay\",\n  \"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\",\n  \"access_denied\": \"Tinanggihan ang Access: error code\",\n  \"dronebl_entry\": \"Nag-ulat ang DroneBL ng entry\",\n  \"see_dronebl_lookup\": \"tignan ang\",\n  \"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\",\n  \"invalid_redirect\": \"Hindi wastong redirect\",\n  \"redirect_not_parseable\": \"Hindi ma-parse ang redirect URL\",\n  \"redirect_domain_not_allowed\": \"Hindi pinapayagan ang redirect domain\",\n  \"failed_to_sign_jwt\": \"nabigong ilagda ang JWT\",\n  \"invalid_invocation\": \"Hindi wastong panawagan para sa MakeChallenge\",\n  \"client_error_browser\": \"Error sa Kliyente: Pakitiyak na napapanahon ang iyong browser at subukang muli sa ibang pagkakataon.\",\n  \"oh_noes\": \"Ay, naku!\",\n  \"benchmarking_anubis\": \"Binebenchmark ang Anubis!\",\n  \"you_are_not_a_bot\": \"Hindi ka isang bot!\",\n  \"making_sure_not_bot\": \"Sinisigurado na hindi ka isang bot!\",\n  \"celphase\": \"CELPHASE\",\n  \"js_web_crypto_error\": \"Ang iyong browser ay walang gumaganang web.crypto element. Tinitingnan mo ba ito sa isang secure na konteksto?\",\n  \"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?\",\n  \"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.\",\n  \"js_context_not_secure\": \"Hindi secure ang iyong konteksto!\",\n  \"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 <a href=\\\"https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts#when_is_a_context_considered_secure\\\">MDN</a>.\",\n  \"js_calculating\": \"Kinakalkula...\",\n  \"js_missing_feature\": \"Nawawalang feature\",\n  \"js_challenge_error\": \"Error sa hamon!\",\n  \"js_challenge_error_msg\": \"Nabigong iresolba ang algoritmo ng pagsusuri. Baka gusto mong i-reload ang pahina.\",\n  \"js_calculating_difficulty\": \"Kinakalkula...<br/>Kahirapan:\",\n  \"js_speed\": \"Bilis:\",\n  \"js_verification_longer\": \"Mas tumatagal ang pag-verify kaysa sa inaasahan. Mangyaring huwag i-refresh ang pahina.\",\n  \"js_success\": \"Matagumpay!\",\n  \"js_done_took\": \"Tapos na! Nagtagal nang\",\n  \"js_iterations\": \"mga iterasyon\",\n  \"js_finished_reading\": \"Tapos na akong magbasa, magpatuloy →\",\n  \"js_calculation_error\": \"Error sa pagkalkula!\",\n  \"js_calculation_error_msg\": \"Nabigong ikalkula ang hamon:\",\n  \"missing_required_forwarded_headers\": \"Nawawala ang kinakailangang X-Forwarded-* na mga header\",\n  \"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 <a href=\\\"https://en.wikipedia.org/wiki/Proof_of_work\\\">Proof of Work</a>. Ang gawain ay kinakalkula sa loob ng ilang segundo at binibigyan ka ng access sa website. Salamat sa iyong pag-unawa at pasensya.\"\n}\n"
  },
  {
    "path": "lib/localization/locales/fr.json",
    "content": "{\n  \"loading\": \"Chargement...\",\n  \"why_am_i_seeing\": \"Comment suis-je arrivé·e ici ?\",\n  \"protected_by\": \"Protégé par\",\n  \"protected_from\": \"de\",\n  \"made_with\": \"Fait avec ❤️ au 🇨🇦\",\n  \"mascot_design\": \"Design de la mascotte par\",\n  \"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.\",\n  \"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.\",\n  \"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.\",\n  \"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.\",\n  \"version_info\": \"Ce site Web utilise Anubis version\",\n  \"try_again\": \"Réessayer\",\n  \"go_home\": \"Accueil\",\n  \"contact_webmaster\": \"ou si vous pensez que vous ne devriez pas être bloqué, veuillez contacter le webmaster à l'adresse\",\n  \"connection_security\": \"Veuillez patienter un instant pendant que nous assurons la sécurité de votre connexion.\",\n  \"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.\",\n  \"benchmark_requires_js\": \"L'exécution de l'outil de benchmark nécessite l'activation de JavaScript.\",\n  \"difficulty\": \"Difficulté :\",\n  \"algorithm\": \"Algorithme :\",\n  \"compare\": \"Comparer :\",\n  \"time\": \"Temps\",\n  \"iters\": \"Itérations\",\n  \"time_a\": \"Temps A\",\n  \"iters_a\": \"Itér. A\",\n  \"time_b\": \"Temps B\",\n  \"iters_b\": \"Itér. B\",\n  \"static_check_endpoint\": \"Ceci est juste un point de terminaison de vérification à utiliser par votre proxy inverse.\",\n  \"authorization_required\": \"Autorisation requise\",\n  \"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.\",\n  \"access_denied\": \"Accès refusé : code d'erreur\",\n  \"dronebl_entry\": \"DroneBL a rapporté une entrée\",\n  \"see_dronebl_lookup\": \"voir\",\n  \"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\",\n  \"invalid_redirect\": \"Redirection invalide\",\n  \"redirect_not_parseable\": \"URL de redirection non analysable\",\n  \"redirect_domain_not_allowed\": \"Domaine de redirection non autorisé\",\n  \"failed_to_sign_jwt\": \"échec de la signature du JWT\",\n  \"invalid_invocation\": \"Invocation invalide de MakeChallenge\",\n  \"client_error_browser\": \"Erreur client : Veuillez vous assurer que votre navigateur est à jour et réessayez plus tard.\",\n  \"oh_noes\": \"Oh non !\",\n  \"benchmarking_anubis\": \"Je vérifie les performances d'Anubis !\",\n  \"you_are_not_a_bot\": \"Vous n'êtes pas un robot !\",\n  \"making_sure_not_bot\": \"Je m'assure que vous n'êtes pas un robot !\",\n  \"celphase\": \"CELPHASE\",\n  \"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é ?\",\n  \"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 ?\",\n  \"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.\",\n  \"js_context_not_secure\": \"Votre contexte n'est pas sécurisé !\",\n  \"js_context_not_secure_msg\": \"Essayez de vous connecter via HTTPS ou demandez à l'administrateur·rice de configurer HTTPS. Pour plus d'informations, consultez <a href=\\\"https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts#when_is_a_context_considered_secure\\\">MDN</a>.\",\n  \"js_calculating\": \"Calcul en cours...\",\n  \"js_missing_feature\": \"Fonctionnalité manquante\",\n  \"js_challenge_error\": \"Erreur de défi !\",\n  \"js_challenge_error_msg\": \"Échec de la résolution de l'algorithme de vérification. Vous pouvez essayer de recharger la page.\",\n  \"js_calculating_difficulty\": \"Calcul en cours...<br/>Difficulté :\",\n  \"js_speed\": \"Vitesse :\",\n  \"js_verification_longer\": \"La vérification prend plus de temps que prévu. Veuillez ne pas actualiser la page.\",\n  \"js_success\": \"Vérification réussie !\",\n  \"js_done_took\": \"Terminé ! Cela aura nécessité\",\n  \"js_iterations\": \"itérations\",\n  \"js_finished_reading\": \"J'ai fini de lire, continuer →\",\n  \"js_calculation_error\": \"Erreur de calcul !\",\n  \"js_calculation_error_msg\": \"Échec du calcul du défi :\",\n  \"missing_required_forwarded_headers\": \"En-têtes X-Forwarded-* manquants\",\n  \"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é <a href=\\\"https://en.wikipedia.org/wiki/Proof_of_work\\\">Preuve de travail</a>. La tâche s'effectue en quelques secondes, puis vous avez accès au site Web. Merci pour votre compréhension et votre patience.\"\n}\n"
  },
  {
    "path": "lib/localization/locales/is.json",
    "content": "{\n  \"loading\": \"Hleður...\",\n  \"why_am_i_seeing\": \"Af hverju er ég að sjá þetta?\",\n  \"protected_by\": \"Verndað með\",\n  \"protected_from\": \"Frá\",\n  \"made_with\": \"Gert í 🇨🇦 með ❤️\",\n  \"mascot_design\": \"Lukkudýrið hannað af\",\n  \"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.\",\n  \"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.\",\n  \"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.\",\n  \"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.\",\n  \"version_info\": \"Þetta vefsvæði er að keyra Anubis útgáfu\",\n  \"try_again\": \"Prófaðu aftur\",\n  \"go_home\": \"Farðu aftur heim til þín\",\n  \"contact_webmaster\": \"eða ef þú heldur að ekki ætti að loka á þig, þá ættirðu að hafa samband við vefstjórann á\",\n  \"connection_security\": \"Hinkraðu augnablik á meðan við tryggjum öryggi tengingarinnar þinnar.\",\n  \"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.\",\n  \"benchmark_requires_js\": \"JavaScript þarf að vera virkt til að keyra afkastaprófunarkerfið.\",\n  \"difficulty\": \"Erfiðleikastig:\",\n  \"algorithm\": \"Reiknirit:\",\n  \"compare\": \"Bera saman:\",\n  \"time\": \"Tími\",\n  \"iters\": \"Umferðir\",\n  \"time_a\": \"Tími A\",\n  \"iters_a\": \"Umferðir A\",\n  \"time_b\": \"Tími B\",\n  \"iters_b\": \"Umferðir B\",\n  \"static_check_endpoint\": \"Þetta er bara endapunktur prófunar til notkunar fyrir öfuga milliþjóninn (reverse proxy) þinn.\",\n  \"authorization_required\": \"Auðkenning nauðsynleg\",\n  \"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\",\n  \"access_denied\": \"Aðgangi hafnað: villukóði\",\n  \"dronebl_entry\": \"DroneBL tilkynnti færslu\",\n  \"see_dronebl_lookup\": \"skoðaðu\",\n  \"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\",\n  \"invalid_redirect\": \"Ógild endurbeining\",\n  \"redirect_not_parseable\": \"Slóð endurbeiningar er ekki túlkanleg\",\n  \"redirect_domain_not_allowed\": \"Lén endurbeiningar er ekki leyft\",\n  \"failed_to_sign_jwt\": \"mistókst að undirrita JWT\",\n  \"invalid_invocation\": \"Ógild kvaðning á MakeChallenge\",\n  \"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.\",\n  \"oh_noes\": \"Æi nei!\",\n  \"benchmarking_anubis\": \"Afkastaprófun Anubis!\",\n  \"you_are_not_a_bot\": \"Þú ert ekki botti!\",\n  \"making_sure_not_bot\": \"Geng úr skugga um að þú sért ekki botti!\",\n  \"celphase\": \"CELPHASE\",\n  \"js_web_crypto_error\": \"Vafrinn þinn er ekki með web.crypto einindi sem virkar. Ertu að skoða þetta í gegnum öruggt umhverfi?\",\n  \"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?\",\n  \"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.\",\n  \"js_context_not_secure\": \"Umhverfið þitt er ekki öruggt!\",\n  \"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 <a href=\\\"https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts#when_is_a_context_considered_secure\\\">MDN</a>.\",\n  \"js_calculating\": \"Reikna...\",\n  \"js_missing_feature\": \"Eiginleika vantar\",\n  \"js_challenge_error\": \"Villa í áskorun!\",\n  \"js_challenge_error_msg\": \"Mistókst að leysa reiknirit á prófunar. Þú gætir viljað endurlesa síðuna.\",\n  \"js_calculating_difficulty\": \"Reikna...<br/>Erfiðleikastig:\",\n  \"js_speed\": \"Hraði:\",\n  \"js_verification_longer\": \"Sannvottun tók lengri tíma en búast má við. Ekki endurlesa síðuna.\",\n  \"js_success\": \"Tókst!\",\n  \"js_done_took\": \"Klárt! Tók\",\n  \"js_iterations\": \"umferðir\",\n  \"js_finished_reading\": \"Ég hef lokið lestrinum, höldum áfram →\",\n  \"js_calculation_error\": \"Reiknivilla!\",\n  \"js_calculation_error_msg\": \"Mistókst að reikna áskorun:\",\n  \"missing_required_forwarded_headers\": \"Vantar nauðsynleg X-Forwarded-* hausar\",\n  \"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ð <a href=\\\"https://en.wikipedia.org/wiki/Proof_of_work\\\">Sönnun-á-vinnu</a>. Verkefnið er reiknað á nokkrum sekúndum og þú færð aðgang að vefsíðunni. Takk fyrir skilninginn og þolinmæðina.\"\n}\n"
  },
  {
    "path": "lib/localization/locales/it.json",
    "content": "{\n  \"loading\": \"Caricamento...\",\n  \"why_am_i_seeing\": \"Perché vedo questa schermata?\",\n  \"protected_by\": \"Protetto da\",\n  \"protected_from\": \"From\",\n  \"made_with\": \"Realizzato con ❤️ in 🇨🇦\",\n  \"mascot_design\": \"Mascotte disegnata da\",\n  \"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.\",\n  \"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.\",\n  \"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.\",\n  \"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.\",\n  \"version_info\": \"Questo sito sta usando Anubis versione\",\n  \"try_again\": \"Riprova\",\n  \"go_home\": \"Vai alla home\",\n  \"contact_webmaster\": \"o, se pensi di non dover essere bloccato, contatta l'amministratore a\",\n  \"connection_security\": \"Un momento: stiamo controllando la sicurezza della tua connessione.\",\n  \"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.\",\n  \"benchmark_requires_js\": \"Per eseguire lo strumento di test, è necessario abilitare Javascript.\",\n  \"difficulty\": \"Difficoltà:\",\n  \"algorithm\": \"Algoritmo:\",\n  \"compare\": \"Test:\",\n  \"time\": \"Tempo\",\n  \"iters\": \"Iterazioni\",\n  \"time_a\": \"Tempo A\",\n  \"iters_a\": \"Iterazioni A\",\n  \"time_b\": \"Tempo B\",\n  \"iters_b\": \"Iterazioni B\",\n  \"static_check_endpoint\": \"Questo è solo un endpoint di test da utilizzare col reverse proxy.\",\n  \"authorization_required\": \"Autorizzazione necessaria\",\n  \"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.\",\n  \"access_denied\": \"Accesso negato: errore\",\n  \"dronebl_entry\": \"DroneBL ha riportato un record\",\n  \"see_dronebl_lookup\": \"vedi\",\n  \"internal_server_error\": \"Internal Server Error: Anubis non è configurato correttamente. Contattare l'amministratore e chiedergli di controllare i log attorno a\",\n  \"invalid_redirect\": \"Reindirizzamento non valido\",\n  \"redirect_not_parseable\": \"Errore di sintassi nel reindirizzamento\",\n  \"redirect_domain_not_allowed\": \"Dominio non permesso per il reindirizzamento\",\n  \"failed_to_sign_jwt\": \"Impossibile firmare JWT\",\n  \"invalid_invocation\": \"Chiamata non valida a MakeChallenge\",\n  \"client_error_browser\": \"Client Error: assicurati che il tuo browser sia aggiornato e riprova.\",\n  \"oh_noes\": \"Oh no!\",\n  \"benchmarking_anubis\": \"Testando Anubis!\",\n  \"you_are_not_a_bot\": \"Non sei un robot!\",\n  \"making_sure_not_bot\": \"Controllo se sei un robot...\",\n  \"celphase\": \"CELPHASE\",\n  \"js_web_crypto_error\": \"Il tuo browser non ha un elemento web.crypto funzionante. Stai utilizzando una connessione sicura?\",\n  \"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?\",\n  \"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.\",\n  \"js_context_not_secure\": \"La tua connessione non è sicura!\",\n  \"js_context_not_secure_msg\": \"Prova a connetterti tramite HTTPS, o fallo abilitare dall'amministratore del sito. Per maggiori informazioni, vedi <a href=\\\"https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts#when_is_a_context_considered_secure\\\">MDN</a>.\",\n  \"js_calculating\": \"Calcolo in corso...\",\n  \"js_missing_feature\": \"Funzionalità mancante\",\n  \"js_challenge_error\": \"Errore nel test!\",\n  \"js_challenge_error_msg\": \"Impossibile trovare l'algoritmo di controllo. Ricarica la pagina.\",\n  \"js_calculating_difficulty\": \"Calcolo in corso...<br/>Difficoltà:\",\n  \"js_speed\": \"Velocità:\",\n  \"js_verification_longer\": \"La verifica sta richiedendo più tempo del previsto. Non aggiornare la pagina: attendere.\",\n  \"js_success\": \"Successo!\",\n  \"js_done_took\": \"Fatto! Sono state necessarie\",\n  \"js_iterations\": \"iterazioni.\",\n  \"js_finished_reading\": \"Ho finito di leggere, continua →\",\n  \"js_calculation_error\": \"Errore nel calcolo!\",\n  \"js_calculation_error_msg\": \"Impossibile superare il test:\",\n  \"missing_required_forwarded_headers\": \"Mancano gli header X-Forwarded-* richiesti\",\n  \"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 <a href=\\\"https://en.wikipedia.org/wiki/Proof_of_work\\\">Proof of Work</a>. Il compito viene calcolato in pochi secondi e ti viene concesso l'accesso al sito web. Grazie per la tua comprensione e pazienza.\"\n}\n"
  },
  {
    "path": "lib/localization/locales/ja.json",
    "content": "{\n  \"loading\": \"ロード中...\",\n  \"why_am_i_seeing\": \"なぜこれが表示されるのですか？\",\n  \"protected_by\": \"Protected by\",\n  \"protected_from\": \"From\",\n  \"made_with\": \"Made with ❤️ 🇨🇦\",\n  \"mascot_design\": \"Mascot design by\",\n  \"ai_companies_explanation\": \"このメッセージが表示されているのは、このウェブサイトの管理者が、AI企業による過剰なウェブスクレイピングからサーバーを守るためにAnubisを導入しているためです。これにより、ウェブサイトがダウンし、すべての利用者がリソースにアクセスできなくなる事態が発生することがあります。\",\n  \"anubis_compromise\": \"Anubisは妥協策です。AnubisはHashcashのようなProof-of-Work方式を採用しており、これは元々メールスパムを減らすために提案された仕組みです。個人レベルでは追加の負荷は無視できる程度ですが、大規模なスクレイピングでは負荷が積み重なり、スクレイピングのコストが大幅に増加します。\",\n  \"hack_purpose\": \"最終的に、これはヘッドレスブラウザのフィンガープリントと識別に時間を費やすためのプレースホルダーソリューションです（例：フォントレンダリングの方法による）。これにより、正当なユーザーにはチャレンジのプルーフオブワークページを提示する必要がなくなります。\",\n  \"jshelter_note\": \"Anubisは、JShelterのようなプラグインが無効化する最新のJavaScript機能を必要とします。このドメインではJShelterや同様のプラグインを無効にしてください。\",\n  \"version_info\": \"このウェブサイトはAnubisバージョンで動作しています\",\n  \"try_again\": \"再試行\",\n  \"go_home\": \"ホームに戻る\",\n  \"contact_webmaster\": \"もしブロックされるべきでないと思われる場合は、ウェブマスターにご連絡ください：\",\n  \"connection_security\": \"接続の安全性を確認しています。しばらくお待ちください。\",\n  \"javascript_required\": \"申し訳ありませんが、このチャレンジを通過するにはJavaScriptを有効にする必要があります。これはAI企業がウェブホスティングの社会的契約を変えてしまったためです。JavaScriptなしの解決策は現在開発中です。\",\n  \"benchmark_requires_js\": \"ベンチマークツールを実行するにはJavaScriptを有効にする必要があります。\",\n  \"difficulty\": \"難易度:\",\n  \"algorithm\": \"アルゴリズム:\",\n  \"compare\": \"比較:\",\n  \"time\": \"時間\",\n  \"iters\": \"イテレーション数\",\n  \"time_a\": \"時間A\",\n  \"iters_a\": \"イテレーションA\",\n  \"time_b\": \"時間B\",\n  \"iters_b\": \"イテレーションB\",\n  \"static_check_endpoint\": \"これはリバースプロキシ用のチェックエンドポイントです。\",\n  \"authorization_required\": \"認証が必要です\",\n  \"cookies_disabled\": \"お使いのブラウザはCookieを無効にしています。Anubisは、あなたが正当なクライアントであることを確認するためにCookieを必要とします。このドメインでCookieを有効にしてください。\",\n  \"access_denied\": \"アクセス拒否: エラーコード\",\n  \"dronebl_entry\": \"DroneBLにエントリーが報告されました\",\n  \"see_dronebl_lookup\": \"参照\",\n  \"internal_server_error\": \"内部サーバーエラー: 管理者がAnubisの設定を誤っています。管理者に連絡し、次のログを確認するよう依頼してください:\",\n  \"invalid_redirect\": \"無効なリダイレクト\",\n  \"redirect_not_parseable\": \"リダイレクトURLを解析できません\",\n  \"redirect_domain_not_allowed\": \"リダイレクトドメインは許可されていません\",\n  \"failed_to_sign_jwt\": \"JWTの署名に失敗しました\",\n  \"invalid_invocation\": \"MakeChallengeの無効な呼び出し\",\n  \"client_error_browser\": \"クライアントエラー: ブラウザが最新であることを確認し、後でもう一度お試しください。\",\n  \"oh_noes\": \"Oh noes!\",\n  \"benchmarking_anubis\": \"Anubisのベンチマーク中！\",\n  \"you_are_not_a_bot\": \"あなたはボットではありません！\",\n  \"making_sure_not_bot\": \"あなたがボットでないことを確認しています！\",\n  \"celphase\": \"CELPHASE\",\n  \"js_web_crypto_error\": \"お使いのブラウザには正常に動作するweb.crypto要素がありません。安全なコンテキストで閲覧していますか？\",\n  \"js_web_workers_error\": \"お使いのブラウザはWebワーカーをサポートしていません（Anubisはこれでブラウザのフリーズを防ぎます）。JShelterのようなプラグインを使用していませんか？\",\n  \"js_cookies_error\": \"お使いのブラウザはCookieを保存しません。Anubisは、チャレンジを通過したクライアントを判別するために署名付きトークンをCookieに保存します。このドメインでCookieの保存を有効にしてください。Anubisが保存するCookie名は予告なく変更される場合があります。Cookie名や値は公開APIの一部ではありません。\",\n  \"js_context_not_secure\": \"お使いのコンテキストは安全ではありません！\",\n  \"js_context_not_secure_msg\": \"HTTPSで接続するか、管理者にHTTPSの設定を依頼してください。詳細は<a href=\\\"https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts#when_is_a_context_considered_secure\\\">MDN</a>をご覧ください。\",\n  \"js_calculating\": \"計算中...\",\n  \"js_missing_feature\": \"機能がありません\",\n  \"js_challenge_error\": \"チャレンジエラー！\",\n  \"js_challenge_error_msg\": \"チェックアルゴリズムの解決に失敗しました。ページを再読み込みしてください。\",\n  \"js_calculating_difficulty\": \"計算中...<br/>難易度:\",\n  \"js_speed\": \"速度:\",\n  \"js_verification_longer\": \"検証に予想以上の時間がかかっています。ページをリフレッシュしないでください。\",\n  \"js_success\": \"成功！\",\n  \"js_done_took\": \"完了！所要時間\",\n  \"js_iterations\": \"イテレーション数\",\n  \"js_finished_reading\": \"読み終わりました。続行 →\",\n  \"js_calculation_error\": \"計算エラー！\",\n  \"js_calculation_error_msg\": \"チャレンジの計算に失敗しました:\",\n  \"missing_required_forwarded_headers\": \"必要な X-Forwarded-* ヘッダーがありません\",\n  \"simplified_explanation\": \"これは、CAPTCHAと同様の、ボットや悪意のあるリクエストに対する対策です。ただし、自分で作業する代わりに、ブラウザに計算タスクが与えられ、それを解決して有効なクライアントであることを確認する必要があります。この概念は<a href=\\\"https://en.wikipedia.org/wiki/Proof_of_work\\\">Proof of Work</a>と呼ばれます。タスクは数秒で計算され、ウェブサイトへのアクセスが許可されます。ご理解とご協力をお願いいたします。\"\n}\n"
  },
  {
    "path": "lib/localization/locales/lt.json",
    "content": "{\n  \"loading\": \"Įkeliama...\",\n  \"why_am_i_seeing\": \"Kodėl tai matau?\",\n  \"protected_by\": \"Saugo\",\n  \"protected_from\": \"iš\",\n  \"made_with\": \"Sukurta 🇨🇦 su ❤️\",\n  \"mascot_design\": \"Talismao dizainą sukūrė\",\n  \"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.\",\n  \"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ą.\",\n  \"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.\",\n  \"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. <a href=\\\"https://en.wikipedia.org/wiki/Proof_of_work\\\">„Proof-of-work“</a>). Naršyklė atsakymą paprastai apskaičiuoja per kelias sekundes, o tuomet jums suteikiama prieiga prie svetainės. Dėkojame jums už supratingumą ir kantrybę.\",\n  \"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.\",\n  \"version_info\": \"Šioje svetainėje veikia „Anubis“ versija\",\n  \"try_again\": \"Bandyti dar kartą\",\n  \"go_home\": \"Grįžkite į pradžią\",\n  \"contact_webmaster\": \"arba, jei manote, jog esate blokuojami per klaidą, kreipkitės į svetainės administratorių adresu\",\n  \"connection_security\": \"Prašom luktelėti, kol patikrinsime jūsų ryšio saugumą.\",\n  \"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.\",\n  \"benchmark_requires_js\": \"Įvertinimo įrankiui būtina, kad naršyklėje būtų įjungtas „JavaScript“ palaikymas.\",\n  \"difficulty\": \"Sudėtingumas:\",\n  \"algorithm\": \"Algoritmas:\",\n  \"compare\": \"Palyginti:\",\n  \"time\": \"Laikas\",\n  \"iters\": \"Iteracijos\",\n  \"time_a\": \"Laikas A\",\n  \"iters_a\": \"Iteracijos A\",\n  \"time_b\": \"Laikas B\",\n  \"iters_b\": \"Iteracijos B\",\n  \"static_check_endpoint\": \"Tai – tik būsenos patikrinimo adresas, kurį gali naudoti jūsų atvirkštinis įgaliotasis serveris.\",\n  \"authorization_required\": \"Būtinas leidimas\",\n  \"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\",\n  \"access_denied\": \"Prieiga uždrausta: klaidos kodas\",\n  \"dronebl_entry\": \"„DroneBL“ pranešė apie įrašą\",\n  \"see_dronebl_lookup\": \"parodyti\",\n  \"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\",\n  \"invalid_redirect\": \"Netinkamas nukreipimas\",\n  \"redirect_not_parseable\": \"Nukreipimo adreso nepavyko išanalizuoti\",\n  \"redirect_domain_not_allowed\": \"Nukreipimo domenas neleistinas\",\n  \"missing_required_forwarded_headers\": \"Trūksta būtinų „X-Forwarded-*“ antraščių\",\n  \"failed_to_sign_jwt\": \"nepavyko pasirašyti JWT\",\n  \"invalid_invocation\": \"Netinkamas kreipinys į „MakeChallenge“\",\n  \"client_error_browser\": \"Problema klientinėje dalyje: įsitikinkite, jog jūsų naršyklė nepasenusi ir bandykite dar kartą.\",\n  \"oh_noes\": \"O, ne!\",\n  \"benchmarking_anubis\": \"Vertinama „Anubis“ sparta!\",\n  \"you_are_not_a_bot\": \"Jūs nesate robotas!\",\n  \"making_sure_not_bot\": \"Stengiamasi užtikrinti, jog jūs nesate robotas!\",\n  \"celphase\": \"CELPHASE\",\n  \"js_web_crypto_error\": \"Jūsų naršyklėje nėra funkcionalaus „web.crypto“ elemento. Ar jūs šį tinklalapį žiūrite iš saugaus konteksto?\",\n  \"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į?\",\n  \"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.\",\n  \"js_context_not_secure\": \"Jūsų kontekstas nėra saugus!\",\n  \"js_context_not_secure_msg\": \"Pabandykite prisijungti per HTTPS arba praneškite svetainės administratoriui, kad sukonfigūruotų HTTPS. Išsamesnės informacijos rasite <a href=\\\"https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts#when_is_a_context_considered_secure\\\">MDN</a> (anglų k.).\",\n  \"js_calculating\": \"Skaičiuojama...\",\n  \"js_missing_feature\": \"Trūksta funkcionalumo\",\n  \"js_challenge_error\": \"Iššūkio klaida!\",\n  \"js_challenge_error_msg\": \"Nepavyko nustatyti patikros algoritmo. Pamėginkite įkelti tinklalapį iš naujo.\",\n  \"js_calculating_difficulty\": \"Skaičiuojama...<br/>Sudėtingumas:\",\n  \"js_speed\": \"Sparta:\",\n  \"js_verification_longer\": \"Patikra užtrunka ilgiau nei įprasta. Neskubėkite įkelti šio tinklalapio iš naujo.\",\n  \"js_success\": \"Sėkmė!\",\n  \"js_done_took\": \"Baigta! Prireikė\",\n  \"js_iterations\": \"iteracijų\",\n  \"js_finished_reading\": \"Viską perskaičiau, tęskime →\",\n  \"js_calculation_error\": \"Skaičiavimo klaida!\",\n  \"js_calculation_error_msg\": \"Nepavyko įveikti iššūkio:\",\n  \"missing_required_forwarded_headers\": \"Trūksta privalomų X-Forwarded-* antraščių\"\n}\n"
  },
  {
    "path": "lib/localization/locales/manifest.json",
    "content": "{\n  \"supportedLanguages\": [\n    \"cs\",\n    \"de\",\n    \"en\",\n    \"es\",\n    \"et\",\n    \"fi\",\n    \"fil\",\n    \"fr\",\n    \"is\",\n    \"it\",\n    \"ja\",\n    \"lt\",\n    \"nb\",\n    \"nl\",\n    \"nn\",\n    \"pl\",\n    \"pt-BR\",\n    \"ru\",\n    \"tr\",\n    \"uk\",\n    \"vi\",\n    \"zh-CN\",\n    \"zh-TW\",\n    \"sv\"\n  ]\n}\n"
  },
  {
    "path": "lib/localization/locales/nb.json",
    "content": "{\n  \"loading\": \"Laster inn...\",\n  \"why_am_i_seeing\": \"Hvorfor ser jeg dette?\",\n  \"protected_by\": \"Beskyttet av\",\n  \"protected_from\": \"fra\",\n  \"made_with\": \"Laget med ❤️ i 🇨🇦\",\n  \"mascot_design\": \"Maskotdesign av\",\n  \"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.\",\n  \"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.\",\n  \"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.\",\n  \"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.\",\n  \"version_info\": \"Dette nettstedet kjører Anubis-utgave\",\n  \"try_again\": \"Prøv igjen\",\n  \"go_home\": \"Gå hjem\",\n  \"contact_webmaster\": \"eller om du synes at du ikke burde være blokkert, vennligst ta kontakt med administratoren på\",\n  \"connection_security\": \"Vennligst vent mens vi bekrefter tryggheten av tilkoblingen din.\",\n  \"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.\",\n  \"benchmark_requires_js\": \"JavaScript må være påslått for å kjøre sammenligningsverktøyet.\",\n  \"difficulty\": \"Vanskelighetsnivå:\",\n  \"algorithm\": \"Algoritme:\",\n  \"compare\": \"Jevnfør:\",\n  \"time\": \"Tid\",\n  \"iters\": \"Gjentakelser\",\n  \"time_a\": \"Tid A\",\n  \"iters_a\": \"Gjentakelser A\",\n  \"time_b\": \"Tid B\",\n  \"iters_b\": \"Gjentakelser B\",\n  \"static_check_endpoint\": \"Dette er bare et sjekkeendepunkt for din omvendte proxy å bruke.\",\n  \"authorization_required\": \"Legitimasjon kreves\",\n  \"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.\",\n  \"access_denied\": \"Adgang nektet: feilkode\",\n  \"dronebl_entry\": \"DroneBL rapporterte em oppføring.\",\n  \"see_dronebl_lookup\": \"se\",\n  \"internal_server_error\": \"Intern serverfeil: administratoren har feilkonfigurert Anubis. Vennligst ta kontakt med hen og spør hen om å se gjennom loggene om\",\n  \"invalid_redirect\": \"Ugyldig omdirigering\",\n  \"redirect_not_parseable\": \"Omdirigerings-URL-en kunne ikkj tolkes\",\n  \"redirect_domain_not_allowed\": \"Omdirigeringsdomenet er ikke tillatt\",\n  \"failed_to_sign_jwt\": \"mislyktes i å signere JWT\",\n  \"invalid_invocation\": \"Ugyldig fremkalling av MakeChallenge\",\n  \"client_error_browser\": \"Klientfeil: Vennligst sørg for at at nettleseren din er oppdatert og prøv igjen senere.\",\n  \"oh_noes\": \"Å nei!\",\n  \"benchmarking_anubis\": \"Sammenligner Anubis!\",\n  \"you_are_not_a_bot\": \"Du er ikke en bot!\",\n  \"making_sure_not_bot\": \"Bekrefter at du ikke er en bot!\",\n  \"celphase\": \"CELPHASE\",\n  \"js_web_crypto_error\": \"Nettleseren din har ikke et fungerande web.crypto-element. Ser du dette med ei sikker tilkopling?\",\n  \"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?\",\n  \"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.\",\n  \"js_context_not_secure\": \"Du bruker ikke en sikker tilkobling!\",\n  \"js_context_not_secure_msg\": \"Prøv å koble til over HTTPS eller fortell administratoren å opprette HTTPS. Se <a hreflang=\\\"en\\\" href=\\\"https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts#when_is_a_context_considered_secure\\\">MDN</a> for mer informasjon.\",\n  \"js_calculating\": \"Beregner…\",\n  \"js_missing_feature\": \"Mangler funksjon\",\n  \"js_challenge_error\": \"Utfordringsfeil!\",\n  \"js_challenge_error_msg\": \"Mislyktes i å tolke sjekkalgoritmen. Du burde laste inn denne siden på nytt.\",\n  \"js_calculating_difficulty\": \"Beregner…<br/>Vanskelighetsnivå:\",\n  \"js_speed\": \"hastighet:\",\n  \"js_verification_longer\": \"Verifisering tar lengre enn forventet. Vennligst ikke last inn denne siden på nytt.\",\n  \"js_success\": \"Vellykket!\",\n  \"js_done_took\": \"Ferdig! Tok\",\n  \"js_iterations\": \"gjentakelser\",\n  \"js_finished_reading\": \"Jeg har sluttet å lese, fortsett →\",\n  \"js_calculation_error\": \"Beregningsfeil!\",\n  \"js_calculation_error_msg\": \"Mislyktes i å beregne utfordring:\",\n  \"missing_required_forwarded_headers\": \"Mangler nødvendige X-Forwarded-* header\",\n  \"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 <a href=\\\"https://en.wikipedia.org/wiki/Proof_of_work\\\">Proof of Work</a>. Oppgaven beregnes på noen få sekunder, og du får tilgang til nettstedet. Takk for din forståelse og tålmodighet.\"\n}\n"
  },
  {
    "path": "lib/localization/locales/nl.json",
    "content": "{\n  \"loading\": \"Laden...\",\n  \"why_am_i_seeing\": \"Waarom zie ik dit?\",\n  \"protected_by\": \"Beschermd door\",\n  \"protected_from\": \"Van\",\n  \"made_with\": \"Gemaakt met ❤️ in 🇨🇦\",\n  \"mascot_design\": \"Mascotte-ontwerp door\",\n  \"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.\",\n  \"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.\",\n  \"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.\",\n  \"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.\",\n  \"version_info\": \"Deze website draait op de Anubis-versie\",\n  \"try_again\": \"Probeer opnieuw\",\n  \"go_home\": \"Naar de hoofdpagine\",\n  \"contact_webmaster\": \"of als je denkt dat je niet geblokkeerd had moeten worden, neem contact op met de webmaster op\",\n  \"connection_security\": \"Wacht even terwijl we de veiligheid van je verbinding waarborgen.\",\n  \"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.\",\n  \"benchmark_requires_js\": \"Voor het uitvoeren van de check moet JavaScript zijn ingeschakeld.\",\n  \"difficulty\": \"Moeilijkheidsgraad:\",\n  \"algorithm\": \"Algoritme:\",\n  \"compare\": \"Vergelijken:\",\n  \"time\": \"Tijd\",\n  \"iters\": \"Iters\",\n  \"time_a\": \"Tijd A\",\n  \"iters_a\": \"Iters A\",\n  \"time_b\": \"Tijd B\",\n  \"iters_b\": \"Iters B\",\n  \"static_check_endpoint\": \"Dit is gewoon een controle-eindpunt voor je reverse proxy om te gebruiken.\",\n  \"authorization_required\": \"Autorisatie vereist\",\n  \"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\",\n  \"access_denied\": \"Toegang geweigerd: foutcode\",\n  \"dronebl_entry\": \"DroneBL meldde een item\",\n  \"see_dronebl_lookup\": \"zie\",\n  \"internal_server_error\": \"Interne Serverfout: beheerder heeft Anubis verkeerd geconfigureerd. Vraag de beheerder om de logs te bekijken.\",\n  \"invalid_redirect\": \"Ongeldige omleiding\",\n  \"redirect_not_parseable\": \"Redirect-URL kan niet verwerkt worden\",\n  \"redirect_domain_not_allowed\": \"Redirect-domein niet toegestaan\",\n  \"failed_to_sign_jwt\": \"JWT niet ondertekend\",\n  \"invalid_invocation\": \"Ongeldige aanroep van MakeChallenge\",\n  \"client_error_browser\": \"Fout bij client: Controleer of je browser bijgewerkt is en probeer het later opnieuw.\",\n  \"oh_noes\": \"Oh nee-tjes!\",\n  \"benchmarking_anubis\": \"Anubis benchmarken!\",\n  \"you_are_not_a_bot\": \"Je bent geen bot!\",\n  \"making_sure_not_bot\": \"Even checken of je een bot bent!\",\n  \"celphase\": \"CELPHASE\",\n  \"js_web_crypto_error\": \"Je browser heeft geen werkend web.crypto-element. Bekijkt u dit via een beveiligde context?\",\n  \"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?\",\n  \"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.\",\n  \"js_context_not_secure\": \"Je context is niet veilig!\",\n  \"js_context_not_secure_msg\": \"Probeer verbinding te maken via HTTPS of laat de beheerder weten dat HTTPS moet worden ingesteld. Zie <a href=\\\"https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts#when_is_a_context_considered_secure\\\">MDN</a> voor meer informatie.\",\n  \"js_calculating\": \"Berekenen...\",\n  \"js_missing_feature\": \"Ontbrekende functie\",\n  \"js_challenge_error\": \"Uitdagingsfout!\",\n  \"js_challenge_error_msg\": \"De check is gefaald. Misschien wil je de pagina opnieuw laden.\",\n  \"js_calculating_difficulty\": \"Rekenen...<br/>Moeilijkheidsgraad:\",\n  \"js_speed\": \"Snelheid:\",\n  \"js_verification_longer\": \"Verificatie duurt langer dan verwacht. Ververs de pagina niet.\",\n  \"js_success\": \"Gelukt!\",\n  \"js_done_took\": \"Klaar! Nam\",\n  \"js_iterations\": \"iteraties\",\n  \"js_finished_reading\": \"Ik ben klaar met lezen, ga verder →\",\n  \"js_calculation_error\": \"Rekenfout!\",\n  \"js_calculation_error_msg\": \"Uitdaging niet berekend:\",\n  \"missing_required_forwarded_headers\": \"Ontbrekende vereiste X-Forwarded-* headers\",\n  \"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 <a href=\\\"https://en.wikipedia.org/wiki/Proof_of_work\\\">Proof of Work</a> genoemd. De taak wordt in een paar seconden berekend en u krijgt toegang tot de website. Bedankt voor je begrip en geduld.\"\n}\n"
  },
  {
    "path": "lib/localization/locales/nn.json",
    "content": "{\n  \"loading\": \"Lastar inn...\",\n  \"why_am_i_seeing\": \"Kvifor ser eg dette?\",\n  \"protected_by\": \"Verna av\",\n  \"protected_from\": \"frå\",\n  \"made_with\": \"Laga med ❤️ i 🇨🇦\",\n  \"mascot_design\": \"Maskotdesign av\",\n  \"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.\",\n  \"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.\",\n  \"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.\",\n  \"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.\",\n  \"version_info\": \"Denne netstaden køyrer Anubis-utgåve\",\n  \"try_again\": \"Prøv att\",\n  \"go_home\": \"Far heim\",\n  \"contact_webmaster\": \"eller om du tykkjer at du ikkje burde vera blokkert, venlegast tak kontakt med administratoren på\",\n  \"connection_security\": \"Venlegast venta medan vi stadfester tryggleiken av tilkoplinga di.\",\n  \"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.\",\n  \"benchmark_requires_js\": \"JavaScript må vera slegen på for å køyra samanlikningsverktøyet.\",\n  \"difficulty\": \"Vanskenivå:\",\n  \"algorithm\": \"Algoritme:\",\n  \"compare\": \"Jamfør:\",\n  \"time\": \"Tid\",\n  \"iters\": \"Oppattakingar\",\n  \"time_a\": \"Tid A\",\n  \"iters_a\": \"Oppattakingar A\",\n  \"time_b\": \"Tid B\",\n  \"iters_b\": \"Oppattakingar B\",\n  \"static_check_endpoint\": \"Dette er berre eit sjekkeendepunkt for din omvende proxy å nøyta.\",\n  \"authorization_required\": \"Legitimering krevst\",\n  \"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.\",\n  \"access_denied\": \"Tilgang nekta: feilkode\",\n  \"dronebl_entry\": \"DroneBL rapporterte ei oppføring.\",\n  \"see_dronebl_lookup\": \"sjå\",\n  \"internal_server_error\": \"Intern serverfeil: administratoren har feilkonfigurert Anubis. Venlegast tak kontakt med hen og spør hen om å sjå gjennom loggane om\",\n  \"invalid_redirect\": \"Ugyldig omdirigering\",\n  \"redirect_not_parseable\": \"Omdirigerings-URL-en kunne ikkje tolkast\",\n  \"redirect_domain_not_allowed\": \"Omdirigeringsdomenet er ikkje tillate\",\n  \"failed_to_sign_jwt\": \"mislukkast i å signera JWT\",\n  \"invalid_invocation\": \"Ugyldig framkalling av MakeChallenge\",\n  \"client_error_browser\": \"Klientfeil: Venlegast stadfest at netlesaren din er oppdatert og prøv att seinare.\",\n  \"oh_noes\": \"Å nei!\",\n  \"benchmarking_anubis\": \"Samanliknar Anubis!\",\n  \"you_are_not_a_bot\": \"Du er ikkje ein bot!\",\n  \"making_sure_not_bot\": \"Stadfester at du ikkje er ein bot!\",\n  \"celphase\": \"CELPHASE\",\n  \"js_web_crypto_error\": \"Netlesaren din har ikkje eit fungerande web.crypto-element. Ser du dette med ei sikker tilkopling?\",\n  \"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?\",\n  \"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.\",\n  \"js_context_not_secure\": \"Du nøyter ikkje ei sikker tilkopling!\",\n  \"js_context_not_secure_msg\": \"Prøv å kopla til over HTTPS eller fortel administratoren å oppretta HTTPS. Sjå <a hreflang=\\\"en\\\" href=\\\"https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts#when_is_a_context_considered_secure\\\">MDN</a> for fleire opplysingar.\",\n  \"js_calculating\": \"Reknar…\",\n  \"js_missing_feature\": \"Manglar funksjon\",\n  \"js_challenge_error\": \"Utfordringsfeil!\",\n  \"js_challenge_error_msg\": \"Mislukkast i å tolka sjekkalgoritmen. Du burde lasta inn denne sida på nytt.\",\n  \"js_calculating_difficulty\": \"Reknar…<br/>Vanskenivå:\",\n  \"js_speed\": \"fart:\",\n  \"js_verification_longer\": \"Verifisering tek lenger enn venta. Venlegast ikkje last inn denne sida på nytt.\",\n  \"js_success\": \"Vellukka!\",\n  \"js_done_took\": \"Ferdig! Tok\",\n  \"js_iterations\": \"oppattakingar\",\n  \"js_finished_reading\": \"Eg har slutta å lesa, hald fram →\",\n  \"js_calculation_error\": \"Rekningsfeil!\",\n  \"js_calculation_error_msg\": \"Mislukkast i å rekna utfordring:\",\n  \"missing_required_forwarded_headers\": \"Vantande naudsynte «X-Forwarded-*»-overskrifter\",\n  \"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 <a href=\\\"https://en.wikipedia.org/wiki/Proof_of_work\\\">arbeidsstadfesting</a>. Oppgåva vert rekna ut på nokre få sekund, og du får tilgang til nettstaden. Takk for forståinga di og tolmodet ditt.\"\n}\n"
  },
  {
    "path": "lib/localization/locales/pl.json",
    "content": "{\n  \"loading\": \"Ładowanie...\",\n  \"why_am_i_seeing\": \"Dlaczego to widzę?\",\n  \"protected_by\": \"Chronione przez\",\n  \"protected_from\": \"Przed\",\n  \"made_with\": \"Stworzone z ❤️ w 🇨🇦\",\n  \"mascot_design\": \"Projekt maskotki:\",\n  \"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.\",\n  \"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.\",\n  \"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.\",\n  \"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 <a href=\\\"https://en.wikipedia.org/wiki/Proof_of_work\\\">Proof of Work</a>. Zadanie trwa kilka sekund i uzyskujesz dostęp do strony. Dziękujemy za cierpliwość.\",\n  \"jshelter_note\": \"Uwaga: Anubis wymaga nowoczesnych funkcji JavaScript, które wtyczki typu JShelter mogą blokować. Wyłącz JShelter lub podobne dodatki dla tej domeny.\",\n  \"version_info\": \"Ta strona działa na Anubis w wersji\",\n  \"try_again\": \"Spróbuj ponownie\",\n  \"go_home\": \"Wróć na stronę główną\",\n  \"contact_webmaster\": \"lub jeśli uważasz, że nie powinieneś być blokowany, skontaktuj się z administratorem pod adresem\",\n  \"connection_security\": \"Poczekaj chwilę, sprawdzamy bezpieczeństwo Twojego połączenia.\",\n  \"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.\",\n  \"benchmark_requires_js\": \"Uruchomienie narzędzia testowego wymaga włączonego JavaScript.\",\n  \"difficulty\": \"Trudność:\",\n  \"algorithm\": \"Algorytm:\",\n  \"compare\": \"Porównaj:\",\n  \"time\": \"Czas\",\n  \"iters\": \"Iteracje\",\n  \"time_a\": \"Czas A\",\n  \"iters_a\": \"Iteracje A\",\n  \"time_b\": \"Czas B\",\n  \"iters_b\": \"Iteracje B\",\n  \"static_check_endpoint\": \"To jedynie punkt kontrolny do użytku przez Twój reverse proxy.\",\n  \"authorization_required\": \"Wymagane uwierzytelnienie\",\n  \"cookies_disabled\": \"Twoja przeglądarka blokuje ciasteczka. Anubis wymaga ich, aby potwierdzić, że jesteś prawidłowym klientem. Włącz ciasteczka dla tej domeny.\",\n  \"access_denied\": \"Brak dostępu: kod błędu\",\n  \"dronebl_entry\": \"DroneBL zgłosił wpis\",\n  \"see_dronebl_lookup\": \"zobacz\",\n  \"internal_server_error\": \"Błąd wewnętrzny serwera: administrator błędnie skonfigurował Anubis. Skontaktuj się z administratorem i poproś o sprawdzenie logów\",\n  \"invalid_redirect\": \"Nieprawidłowe przekierowanie\",\n  \"redirect_not_parseable\": \"Nie można odczytać adresu przekierowania\",\n  \"redirect_domain_not_allowed\": \"Domena przekierowania niedozwolona\",\n  \"missing_required_forwarded_headers\": \"Brak wymaganych nagłówków X-Forwarded-*\",\n  \"failed_to_sign_jwt\": \"Nie udało się podpisać JWT\",\n  \"invalid_invocation\": \"Nieprawidłowe wywołanie MakeChallenge\",\n  \"client_error_browser\": \"Błąd klienta: upewnij się, że Twoja przeglądarka jest aktualna i spróbuj ponownie później.\",\n  \"oh_noes\": \"O nie!\",\n  \"benchmarking_anubis\": \"Testowanie wydajności Anubis!\",\n  \"you_are_not_a_bot\": \"Nie jesteś botem!\",\n  \"making_sure_not_bot\": \"Sprawdzamy, czy nie jesteś botem!\",\n  \"celphase\": \"CELPHASE\",\n  \"js_web_crypto_error\": \"Twoja przeglądarka nie obsługuje web.crypto. Czy korzystasz z bezpiecznego połączenia?\",\n  \"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?\",\n  \"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.\",\n  \"js_context_not_secure\": \"Kontekst nie jest bezpieczny!\",\n  \"js_context_not_secure_msg\": \"Spróbuj połączyć się przez HTTPS lub poinformuj administratora, by skonfigurował HTTPS. Więcej informacji na <a href=\\\"https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts#when_is_a_context_considered_secure\\\">MDN</a>.\",\n  \"js_calculating\": \"Obliczanie...\",\n  \"js_missing_feature\": \"Brakująca funkcja\",\n  \"js_challenge_error\": \"Błąd wyzwania!\",\n  \"js_challenge_error_msg\": \"Nie udało się ustalić algorytmu sprawdzającego. Możesz spróbować odświeżyć stronę.\",\n  \"js_calculating_difficulty\": \"Obliczanie...<br/>Trudność:\",\n  \"js_speed\": \"Prędkość:\",\n  \"js_verification_longer\": \"Weryfikacja trwa dłużej niż zwykle. Proszę nie odświeżać strony.\",\n  \"js_success\": \"Sukces!\",\n  \"js_done_took\": \"Gotowe! Zajęło to\",\n  \"js_iterations\": \"iteracji\",\n  \"js_finished_reading\": \"Skończyłem czytać, kontynuuj →\",\n  \"js_calculation_error\": \"Błąd obliczeń!\",\n  \"js_calculation_error_msg\": \"Nie udało się obliczyć zadania:\"\n}\n"
  },
  {
    "path": "lib/localization/locales/pt-BR.json",
    "content": "{\n  \"loading\": \"Carregando...\",\n  \"why_am_i_seeing\": \"Por que estou vendo isso?\",\n  \"protected_by\": \"Protegido por\",\n  \"protected_from\": \"de\",\n  \"made_with\": \"Feito com ❤️ no Canadá\",\n  \"mascot_design\": \"Design do mascote por\",\n  \"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.\",\n  \"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.\",\n  \"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.\",\n  \"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.\",\n  \"version_info\": \"Este site está usando o Anubis versão\",\n  \"try_again\": \"Tente novamente\",\n  \"go_home\": \"Início\",\n  \"contact_webmaster\": \"ou se você acredita que não deveria estar bloqueado, contate o administrador em\",\n  \"connection_security\": \"Por favor, aguarde um momento enquanto nós garantimos a segurança de sua conexão.\",\n  \"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.\",\n  \"benchmark_requires_js\": \"Para executar a ferramenta de benchmark, é necessário que o JavaScript esteja habilitado.\",\n  \"difficulty\": \"Dificuldade:\",\n  \"algorithm\": \"Algoritmo:\",\n  \"compare\": \"Comparar:\",\n  \"time\": \"Tempo\",\n  \"iters\": \"Iteração\",\n  \"time_a\": \"Tempo A\",\n  \"iters_a\": \"Iteração A\",\n  \"time_b\": \"Tempo B\",\n  \"iters_b\": \"Iteração B\",\n  \"static_check_endpoint\": \"Este é apenas um ponto de verificação para seu proxy reverso usar.\",\n  \"authorization_required\": \"Autorização necessária\",\n  \"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.\",\n  \"access_denied\": \"Acesso negado: código de erro\",\n  \"dronebl_entry\": \"DroneBL relatou uma entrada\",\n  \"see_dronebl_lookup\": \"consulte\",\n  \"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.\",\n  \"invalid_redirect\": \"Redirecionamento inválido\",\n  \"redirect_not_parseable\": \"URL de redirecionamento não analisável\",\n  \"redirect_domain_not_allowed\": \"Domínio de redirecionamento não permitido\",\n  \"failed_to_sign_jwt\": \"falha ao assinar JWT\",\n  \"invalid_invocation\": \"Invocação de MakeChallenge inválida\",\n  \"client_error_browser\": \"Erro do cliente: verifique se seu navegador está atualizado e tente novamente mais tarde.\",\n  \"oh_noes\": \"Ah, não!\",\n  \"benchmarking_anubis\": \"Fazendo benchmark do Anubis!\",\n  \"you_are_not_a_bot\": \"Você não é um bot!\",\n  \"making_sure_not_bot\": \"Certificando de que você não é um bot!\",\n  \"celphase\": \"CELPHASE\",\n  \"js_web_crypto_error\": \"Seu navegador não possui um elemento web.crypto funcional. Você está visualizando isso em um contexto seguro?\",\n  \"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?\",\n  \"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.\",\n  \"js_context_not_secure\": \"Seu contexto não é seguro!\",\n  \"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 <a href=\\\"https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts#when_is_a_context_considered_secure\\\">MDN</a>.\",\n  \"js_calculating\": \"Calculando...\",\n  \"js_missing_feature\": \"Recurso não disponível\",\n  \"js_challenge_error\": \"Erro na validação!\",\n  \"js_challenge_error_msg\": \"Falha ao resolver o algoritmo de verificação. Talvez seja necessário recarregar a página.\",\n  \"js_calculating_difficulty\": \"Calculando...<br/>Dificuldade:\",\n  \"js_speed\": \"Velocidade:\",\n  \"js_verification_longer\": \"A verificação está demorando mais do que o esperado. Não atualize a página.\",\n  \"js_success\": \"Sucesso!\",\n  \"js_done_took\": \"Feito! Levou\",\n  \"js_iterations\": \"iterações\",\n  \"js_finished_reading\": \"Terminei de ler, continue →\",\n  \"js_calculation_error\": \"Erro de cálculo!\",\n  \"js_calculation_error_msg\": \"Falha ao calcular a validação:\",\n  \"missing_required_forwarded_headers\": \"Faltam os cabeçalhos X-Forwarded-* obrigatórios\",\n  \"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 <a href=\\\"https://en.wikipedia.org/wiki/Proof_of_work\\\">Prova de Trabalho</a>. A tarefa é calculada em poucos segundos e você tem acesso ao site. Obrigado pela sua compreensão e paciência.\"\n}\n"
  },
  {
    "path": "lib/localization/locales/ru.json",
    "content": "{\n  \"loading\": \"Загрузка...\",\n  \"why_am_i_seeing\": \"Почему я вижу это?\",\n  \"protected_by\": \"Защищено\",\n  \"protected_from\": \"От\",\n  \"made_with\": \"Сделано с ❤️ из 🇨🇦\",\n  \"mascot_design\": \"Дизайн маскота от\",\n  \"ai_companies_explanation\": \"Вы это видите, потому что администратор этого сайта настроил Anubis для защиты сервера от атак, использующих ИИ, которые агрессивно копируют данные с сайтов. Это может привести к зависанию сайтов и делает их ресурсы недоступными для всех.\",\n  \"anubis_compromise\": \"Anubis - это компромисс. Anubis использует Proof-of-Work, похожую на Hashcash, для борьбы со спамом в электронной почте. Идея в том, что на отдельных уровнях дополнительная нагрузка не влиятельна, но на уровне массового парсинга она накапливается и значительно удорожает сбор данных.\",\n  \"hack_purpose\": \"В конечном итоге, это временное решение, чтобы можно было уделить больше времени снятию отпечатков и идентификации безголовых браузеров (например, по тому, как они отрисовывают шрифты), чтобы страница с доказательством работы не требовалась для пользователей, которые с гораздо большей вероятностью являются легитимными.\",\n  \"jshelter_note\": \"Anubis требует использования современных функций JavaScript, которые плагины, по типу JShelter, отключают. Пожалуйста, отключите JShelter и другие подобные плагины для этого домена.\",\n  \"version_info\": \"На сайте запущен Anubis версии\",\n  \"try_again\": \"Попробуйте снова\",\n  \"go_home\": \"Перейти на домашнюю\",\n  \"contact_webmaster\": \"если вы уверены, что это ошибка, свяжитесь с владельцем сайта через\",\n  \"connection_security\": \"Пожалуйста, подождите, пока мы проверим безопасность вашего соединения.\",\n  \"javascript_required\": \"К сожалению, для решения этой проверки необходимо включить JavaScript. Это необходимо, поскольку компании, занимающиеся разработкой ИИ, изменили моральные правила, касающийся хостинга веб-сайтов. Решение без использования JavaScript находится в стадии разработки.\",\n  \"benchmark_requires_js\": \"Для работы тестирования необходимо включить JavaScript.\",\n  \"difficulty\": \"Сложность:\",\n  \"algorithm\": \"Алгоритм:\",\n  \"compare\": \"Сравнить:\",\n  \"time\": \"Время\",\n  \"iters\": \"Итерации\",\n  \"time_a\": \"Время A\",\n  \"iters_a\": \"Итерации A\",\n  \"time_b\": \"Время B\",\n  \"iters_b\": \"Итерации B\",\n  \"static_check_endpoint\": \"Это всего лишь точка проверки, которую может использовать ваш обратный прокси-сервер.\",\n  \"authorization_required\": \"Требуется авторизация.\",\n  \"cookies_disabled\": \"В вашем браузере отключены cookie файлы. Anubis требует их для подтверждения того, что вы являетесь настоящим человеком. Пожалуйста, включите файлы cookie для этого домена\",\n  \"access_denied\": \"Доступ запрещён: код ошибки\",\n  \"dronebl_entry\": \"DroneBL сообщил о записи\",\n  \"see_dronebl_lookup\": \"см.\",\n  \"internal_server_error\": \"Внутренняя ошибка сервера: администратор неправильно настроил Anubis. Обратитесь к администратору и попросите его просмотреть логи\",\n  \"invalid_redirect\": \"Неверное перенаправление\",\n  \"redirect_not_parseable\": \"URL-адрес перенаправления не может быть анализирован\",\n  \"redirect_domain_not_allowed\": \"Перенаправление домена запрещено\",\n  \"failed_to_sign_jwt\": \"не смог подписать JWT\",\n  \"invalid_invocation\": \"Неверный вызов MakeChallenge\",\n  \"client_error_browser\": \"Ошибка клиента: убедитесь, что у вас браузер новейшей версии, и повторите попытку позже.\",\n  \"oh_noes\": \"О нет!\",\n  \"benchmarking_anubis\": \"Анализ Анубиса!\",\n  \"you_are_not_a_bot\": \"Вы не бот!\",\n  \"making_sure_not_bot\": \"Проверяем, что вы не бот!\",\n  \"celphase\": \"CELPHASE\",\n  \"js_web_crypto_error\": \"В вашем браузере отсутствует функция web.crypto. Вы просматриваете страницу через защищённый контекст?\",\n  \"js_web_workers_error\": \"Ваш браузер не поддерживает web worker (Anubis использует его, чтобы избежать зависания браузера). У вас установлен плагин типа JShelter?\",\n  \"js_cookies_error\": \"Ваш браузер не сохраняет cookie файлы. Anubis использует их для определения клиентов, прошедших проверку, сохраняя подписанный токен в файле cookie. Включите сохранение файлов cookie для этого домена. Имена файлов cookie, хранимых Anubis, могут изменяться без предварительного уведомления. Имена и значения cookie файлов не являются частью общедоступного API.\",\n  \"js_context_not_secure\": \"Ваш контекст небезопасен!\",\n  \"js_context_not_secure_msg\": \"Попробуйте подключиться по HTTPS или попросите администратора, чтобы он настроил HTTPS. Подробнее см. <a href=\\\"https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts#when_is_a_context_considered_secure\\\">MDN</a>.\",\n  \"js_calculating\": \"Расчёт...\",\n  \"js_missing_feature\": \"Отсутствует функция\",\n  \"js_challenge_error\": \"Ошибка проверки!\",\n  \"js_challenge_error_msg\": \"Не удалось определить алгоритм проверки. Возможно, нужно перезагрузить страницу..\",\n  \"js_calculating_difficulty\": \"Расчёт...<br/>Сложность:\",\n  \"js_speed\": \"Скорость:\",\n  \"js_verification_longer\": \"Проверка занимает больше времени, чем ожидалось. Пожалуйста, не обновляйте страницу.\",\n  \"js_success\": \"Успех!\",\n  \"js_done_took\": \"Получилось! Заняло\",\n  \"js_iterations\": \"итераций\",\n  \"js_finished_reading\": \"Я дочитал, продолжить →\",\n  \"js_calculation_error\": \"Ошибка расчёта!\",\n  \"js_calculation_error_msg\": \"Не удалось рассчитать задачу:\",\n  \"missing_required_forwarded_headers\": \"Отсутствуют требуемые заголовки X-Forwarded-*\",\n  \"simplified_explanation\": \"Это мера против ботов и вредоносных запросов, аналогичная CAPTCHA. Однако вместо того, чтобы вам приходилось работать самостоятельно, вашему браузеру дается задача вычисления, которую он должен решить, чтобы убедиться, что он является действительным клиентом. Эта концепция называется <a href=\\\"https://en.wikipedia.org/wiki/Proof_of_work\\\">Доказательство выполнения работы</a>. Задача рассчитывается за несколько секунд, и вам предоставляется доступ к веб-сайту. Спасибо за понимание и терпение.\"\n}\n"
  },
  {
    "path": "lib/localization/locales/sv.json",
    "content": "{\n  \"loading\": \"Laddar...\",\n  \"why_am_i_seeing\": \"Varför ser jag detta?\",\n  \"protected_by\": \"Skyddat av\",\n  \"protected_from\": \"Från\",\n  \"made_with\": \"Gjort med ❤️ i 🇨🇦\",\n  \"mascot_design\": \"Maskotdesign av\",\n  \"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.\",\n  \"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.\",\n  \"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.\",\n  \"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.\",\n  \"version_info\": \"Den här webbsidan kör Anubis version\",\n  \"try_again\": \"Försök igen\",\n  \"go_home\": \"Gå hem\",\n  \"contact_webmaster\": \"eller om du tycker att du inte borde bli blockerad, kontakta den webbansvarige på\",\n  \"connection_security\": \"Var vänlig och vänta en stund medan vi säkerställer din anslutnings säkerhet.\",\n  \"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.\",\n  \"benchmark_requires_js\": \"För att köra prestandamätningsverktyget krävs det att JavaScript är igång.\",\n  \"difficulty\": \"Svårighetsgrad:\",\n  \"algorithm\": \"Algoritm:\",\n  \"compare\": \"Jämför:\",\n  \"time\": \"Tid\",\n  \"iters\": \"Iterationer\",\n  \"time_a\": \"Tid A\",\n  \"iters_a\": \"Iterationer A\",\n  \"time_b\": \"Tid B\",\n  \"iters_b\": \"Iterationer B\",\n  \"static_check_endpoint\": \"Detta är bara en kontrollendpunkt för användning av din reverse-proxy.\",\n  \"authorization_required\": \"Tillstånd krävs\",\n  \"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\",\n  \"access_denied\": \"Tillstånd nekat: felkod\",\n  \"dronebl_entry\": \"DroneBL rapporterade en post\",\n  \"see_dronebl_lookup\": \"visa\",\n  \"internal_server_error\": \"Internt serverfel: administratören har felkonfigurerat Anubis. Kontakta administratören och be dem att leta efter loggarna.\",\n  \"invalid_redirect\": \"Ogiltig omdirigering\",\n  \"redirect_not_parseable\": \"Omdirigeringsurl icke tolkbar\",\n  \"redirect_domain_not_allowed\": \"Omdirigeringsdomän icke tillåten\",\n  \"failed_to_sign_jwt\": \"misslyckades att signera JWT\",\n  \"invalid_invocation\": \"Ogiltigt anrop av MakeChallenge\",\n  \"client_error_browser\": \"Klientfel: Dubbelkolla att din webbläsare är uppdaterad och försök igen senare.\",\n  \"oh_noes\": \"Aj då!\",\n  \"benchmarking_anubis\": \"Prestandamäter Anubis!\",\n  \"you_are_not_a_bot\": \"Du är inte en bot!\",\n  \"making_sure_not_bot\": \"Kollar så att du inte är en bot!\",\n  \"celphase\": \"CELPHASE\",\n  \"js_web_crypto_error\": \"Din webbläsare har inte ett fungerande web.crypto-element. Ser du denna sida över en säker webbläsarkontext?\",\n  \"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?\",\n  \"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.\",\n  \"js_context_not_secure\": \"Din webbläsarkontext är ej säker!\",\n  \"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 <a href=\\\"https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts#when_is_a_context_considered_secure\\\">MDN</a>.\",\n  \"js_calculating\": \"Beräknar...\",\n  \"js_missing_feature\": \"Funktion saknas\",\n  \"js_challenge_error\": \"Utmaningsfel!\",\n  \"js_challenge_error_msg\": \"Misslyckades att lösa kontrollalgoritm. Du bör ladda om sidan.\",\n  \"js_calculating_difficulty\": \"Beräknar...<br/>Svårighetsgrad:\",\n  \"js_speed\": \"Hastighet:\",\n  \"js_verification_longer\": \"Verifikation tar längre än förväntat. Ladda ej om sidan.\",\n  \"js_success\": \"Lyckades!\",\n  \"js_done_took\": \"Klart! tog\",\n  \"js_iterations\": \"iterationer\",\n  \"js_finished_reading\": \"Jag har läst klart, fortsätt →\",\n  \"js_calculation_error\": \"Beräkningsfel!\",\n  \"js_calculation_error_msg\": \"Misslyckades att kalkylera utmaning:\",\n  \"missing_required_forwarded_headers\": \"Saknar nödvändiga X-Forwarded-* headers\",\n  \"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 <a href=\\\"https://en.wikipedia.org/wiki/Proof_of_work\\\">Arbetsbevis</a>. 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.\"\n}\n"
  },
  {
    "path": "lib/localization/locales/th.json",
    "content": "{\n  \"loading\": \"กำลังโหลด...\",\n  \"why_am_i_seeing\": \"ทำไมถึงเห็นสิ่งนี้?\",\n  \"protected_by\": \"ปกป้องโดย\",\n  \"protected_from\": \"จาก\",\n  \"made_with\": \"สร้างด้วย ❤️ ใน 🇨🇦\",\n  \"mascot_design\": \"ออกแบบมาสค็อตโดย\",\n  \"ai_companies_explanation\": \"คุณเห็นสิ่งนี้เพราะผู้ดูแลเว็บไซต์ได้ตั้งค่า Anubis เพื่อป้องกันเซิร์ฟเวอร์จากบริษัท AI ที่ทำการขูดข้อมูลเว็บไซต์อย่างก้าวร้าว ซึ่งสามารถทำให้เว็บไซต์ล่ม และทำให้ทรัพยากรของเว็บไซต์ไม่สามารถเข้าถึงได้สำหรับทุกคน\",\n  \"anubis_compromise\": \"Anubis คือการประนีประนอม โดยใช้ระบบ Proof-of-Work คล้ายกับ Hashcash ซึ่งเป็นแนวคิดสำหรับลดสแปมอีเมล แนวคิดคือ การโหลดเพิ่มเติมในระดับผู้ใช้รายบุคคลสามารถละเลยได้ แต่ในระดับการขูดข้อมูลจำนวนมาก มันจะสะสมและทำให้การขูดแพงขึ้น\",\n  \"hack_purpose\": \"ท้ายที่สุดแล้ว นี่คือการแฮ็กที่มีวัตถุประสงค์หลักเพื่อเป็นโซลูชันชั่วคราวที่ 'เพียงพอ' เพื่อให้มีเวลาในการสร้างการตรวจจับตัวตนของเบราว์เซอร์แบบไม่มีกล่องข้อความ (เช่น ผ่านการเรนเดอร์ฟอนต์) เพื่อไม่ต้องแสดงหน้า Proof-of-Work แก่ผู้ใช้ที่มีแนวโน้มว่าจะเป็นผู้ใช้จริง\",\n  \"jshelter_note\": \"โปรดทราบว่า Anubis ต้องการใช้คุณสมบัติ JavaScript สมัยใหม่ที่ปลั๊กอินอย่าง JShelter จะปิดใช้งาน กรุณาปิด JShelter หรือปลั๊กอินลักษณะคล้ายกันสำหรับโดเมนนี้\",\n  \"version_info\": \"เว็บไซต์นี้กำลังใช้ Anubis เวอร์ชัน\",\n  \"try_again\": \"ลองอีกครั้ง\",\n  \"go_home\": \"กลับหน้าหลัก\",\n  \"contact_webmaster\": \"หากคุณเชื่อว่าไม่ควรถูกบล็อก กรุณาติดต่อผู้ดูแลเว็บไซต์ที่\",\n  \"connection_security\": \"กรุณารอสักครู่ในขณะที่เราตรวจสอบความปลอดภัยของการเชื่อมต่อของคุณ\",\n  \"javascript_required\": \"น่าเสียดายที่คุณต้องเปิดใช้ JavaScript เพื่อผ่านการทดสอบนี้ เนื่องจากบริษัท AI ได้เปลี่ยนข้อตกลงทางสังคมเกี่ยวกับการโฮสต์เว็บไซต์ ทางเลือกแบบ 'ไม่มี JS' กำลังอยู่ระหว่างการพัฒนา\",\n  \"benchmark_requires_js\": \"เครื่องมือวัดประสิทธิภาพต้องใช้ JavaScript\",\n  \"difficulty\": \"ความยาก:\",\n  \"algorithm\": \"อัลกอริธึม:\",\n  \"compare\": \"เปรียบเทียบ:\",\n  \"time\": \"เวลา\",\n  \"iters\": \"จำนวนรอบ\",\n  \"time_a\": \"เวลา A\",\n  \"iters_a\": \"รอบ A\",\n  \"time_b\": \"เวลา B\",\n  \"iters_b\": \"รอบ B\",\n  \"static_check_endpoint\": \"นี่เป็นเพียง endpoint ตรวจสอบสำหรับ reverse proxy ของคุณ\",\n  \"authorization_required\": \"ต้องมีการยืนยันตัวตน\",\n  \"cookies_disabled\": \"เบราว์เซอร์ของคุณปิดการใช้งานคุกกี้ Anubis ต้องใช้คุกกี้เพื่อตรวจสอบว่าเป็นผู้ใช้ที่แท้จริง กรุณาเปิดใช้งานคุกกี้สำหรับโดเมนนี้\",\n  \"access_denied\": \"การเข้าถึงถูกปฏิเสธ: รหัสข้อผิดพลาด\",\n  \"dronebl_entry\": \"DroneBL รายงานรายการนี้\",\n  \"see_dronebl_lookup\": \"ดู\",\n  \"internal_server_error\": \"เกิดข้อผิดพลาดในเซิร์ฟเวอร์: ผู้ดูแลระบบได้กำหนดค่า Anubis อย่างไม่ถูกต้อง กรุณาติดต่อผู้ดูแลระบบและให้เขาตรวจสอบบันทึกใกล้กับ\",\n  \"invalid_redirect\": \"การเปลี่ยนเส้นทางไม่ถูกต้อง\",\n  \"redirect_not_parseable\": \"ไม่สามารถแยกวิเคราะห์ URL สำหรับเปลี่ยนเส้นทาง\",\n  \"redirect_domain_not_allowed\": \"ไม่อนุญาตให้เปลี่ยนเส้นทางไปยังโดเมนนี้\",\n  \"failed_to_sign_jwt\": \"ไม่สามารถเซ็น JWT ได้\",\n  \"invalid_invocation\": \"เรียกใช้ MakeChallenge อย่างไม่ถูกต้อง\",\n  \"client_error_browser\": \"ข้อผิดพลาดของไคลเอนต์: กรุณาตรวจสอบว่าเบราว์เซอร์ของคุณเป็นเวอร์ชันล่าสุด และลองใหม่ในภายหลัง\",\n  \"oh_noes\": \"โอ้ ไม่!\",\n  \"benchmarking_anubis\": \"กำลังวัดประสิทธิภาพ Anubis!\",\n  \"you_are_not_a_bot\": \"คุณไม่ใช่บอท!\",\n  \"making_sure_not_bot\": \"ตรวจสอบให้แน่ใจว่าคุณไม่ใช่บอท!\",\n  \"celphase\": \"CELPHASE\",\n  \"js_web_crypto_error\": \"เบราว์เซอร์ของคุณไม่มีฟีเจอร์ web.crypto ที่ใช้งานได้ คุณกำลังดูผ่านบริบทที่ปลอดภัยหรือไม่?\",\n  \"js_web_workers_error\": \"เบราว์เซอร์ของคุณไม่รองรับ web workers (Anubis ใช้เพื่อลดการค้างของเบราว์เซอร์) คุณใช้ปลั๊กอินเช่น JShelter หรือไม่?\",\n  \"js_cookies_error\": \"เบราว์เซอร์ของคุณไม่เก็บคุกกี้ Anubis ใช้คุกกี้เพื่อเก็บโทเค็นที่เซ็นแล้วสำหรับไคลเอนต์ที่ผ่านการท้าทาย กรุณาเปิดใช้งานการเก็บคุกกี้สำหรับโดเมนนี้ ชื่อคุกกี้อาจเปลี่ยนแปลงได้โดยไม่แจ้งล่วงหน้า\",\n  \"js_context_not_secure\": \"บริบทของคุณไม่ปลอดภัย!\",\n  \"js_context_not_secure_msg\": \"ลองเชื่อมต่อผ่าน HTTPS หรือแจ้งผู้ดูแลระบบให้ตั้งค่า HTTPS สำหรับข้อมูลเพิ่มเติมดูที่ <a href=\\\"https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts#when_is_a_context_considered_secure\\\">MDN</a>\",\n  \"js_calculating\": \"กำลังคำนวณ...\",\n  \"js_missing_feature\": \"ไม่มีคุณลักษณะนี้\",\n  \"js_challenge_error\": \"เกิดข้อผิดพลาดในการท้าทาย!\",\n  \"js_challenge_error_msg\": \"ไม่สามารถแก้ไขอัลกอริธึมการตรวจสอบ อาจต้องโหลดหน้าใหม่\",\n  \"js_calculating_difficulty\": \"กำลังคำนวณ...<br/>ความยาก:\",\n  \"js_speed\": \"ความเร็ว:\",\n  \"js_verification_longer\": \"การตรวจสอบใช้เวลานานกว่าที่คาดไว้ กรุณาอย่ารีเฟรชหน้านี้\",\n  \"js_success\": \"สำเร็จ!\",\n  \"js_done_took\": \"เสร็จแล้ว! ใช้เวลา\",\n  \"js_iterations\": \"รอบ\",\n  \"js_finished_reading\": \"อ่านจบแล้ว ดำเนินการต่อ →\",\n  \"js_calculation_error\": \"เกิดข้อผิดพลาดในการคำนวณ!\",\n  \"js_calculation_error_msg\": \"ไม่สามารถคำนวณการท้าทายได้:\"\n}\n"
  },
  {
    "path": "lib/localization/locales/tr.json",
    "content": "{\n  \"loading\": \"Yükleniyor...\",\n  \"why_am_i_seeing\": \"Bunu neden görüyorum?\",\n  \"protected_by\": \"Koruma sağlayan:\",\n  \"protected_from\": \"Yapan:\",\n  \"made_with\": \"🇨🇦’da ❤️ ile yapıldı\",\n  \"mascot_design\": \"Maskot tasarımı:\",\n  \"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.\",\n  \"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.\",\n  \"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.\",\n  \"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.\",\n  \"version_info\": \"Bu web sitesi şu Anubis sürümünü çalıştırıyor:\",\n  \"try_again\": \"Tekrar dene\",\n  \"go_home\": \"Ana sayfaya dön\",\n  \"contact_webmaster\": \"veya engellenmemeniz gerektiğini düşünüyorsanız lütfen şu adrese e-posta gönderin:\",\n  \"connection_security\": \"Bağlantınızın güvenliği sağlanırken lütfen bekleyin.\",\n  \"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.\",\n  \"benchmark_requires_js\": \"Kıyaslama aracının çalıştırılması için JavaScript’in etkin olması gereklidir.\",\n  \"difficulty\": \"Zorluk:\",\n  \"algorithm\": \"Algoritma:\",\n  \"compare\": \"Karşılaştır:\",\n  \"time\": \"Süre\",\n  \"iters\": \"Tekrar\",\n  \"time_a\": \"Süre A\",\n  \"iters_a\": \"Tekrar A\",\n  \"time_b\": \"Süre B\",\n  \"iters_b\": \"Tekrar B\",\n  \"static_check_endpoint\": \"Bu sadece, ters vekil sunucunuzun kullanması için bir kontrol adresidir.\",\n  \"authorization_required\": \"Yetkilendirme gerekli\",\n  \"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.\",\n  \"access_denied\": \"Erişim reddedildi: Hata kodu\",\n  \"dronebl_entry\": \"DroneBL bir giriş bildirdi\",\n  \"see_dronebl_lookup\": \"bakınız\",\n  \"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:\",\n  \"invalid_redirect\": \"Geçersiz yönlendirme\",\n  \"redirect_not_parseable\": \"Yönlendirme URL’si çözümlenemiyor\",\n  \"redirect_domain_not_allowed\": \"Yönlendirme alan adına izin verilmiyor\",\n  \"failed_to_sign_jwt\": \"JWT imzalanamadı\",\n  \"invalid_invocation\": \"Geçersiz MakeChallenge çağrısı\",\n  \"client_error_browser\": \"İstemci hatası: Lütfen tarayıcınızın güncel olduğundan emin olun ve daha sonra tekrar deneyin.\",\n  \"oh_noes\": \"Ah hayır!\",\n  \"benchmarking_anubis\": \"Anubis kıyaslanıyor!\",\n  \"you_are_not_a_bot\": \"Bot değilsiniz!\",\n  \"making_sure_not_bot\": \"Bot olmadığınızdan emin oluyoruz!\",\n  \"celphase\": \"CELPHASE\",\n  \"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?\",\n  \"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?\",\n  \"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.\",\n  \"js_context_not_secure\": \"Bağlantınız güvenli değil!\",\n  \"js_context_not_secure_msg\": \"HTTPS üzerinden bağlanmayı deneyin veya yöneticiden HTTPS kurulumu yapmasını isteyin. Daha fazla bilgi için bkz. <a href=\\\"https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts#when_is_a_context_considered_secure\\\">MDN</a>.\",\n  \"js_calculating\": \"Hesaplanıyor...\",\n  \"js_missing_feature\": \"Eksik özellik\",\n  \"js_challenge_error\": \"Hesaplama hatası!\",\n  \"js_challenge_error_msg\": \"Algoritma çözümlemesi başarısız oldu. Sayfayı yeniden yüklemeyi deneyebilirsiniz.\",\n  \"js_calculating_difficulty\": \"Hesaplanıyor...<br/>Zorluk:\",\n  \"js_speed\": \"Hız:\",\n  \"js_verification_longer\": \"Doğrulama beklenenden uzun sürüyor. Lütfen sayfayı yenilemeyin.\",\n  \"js_success\": \"Başarılı!\",\n  \"js_done_took\": \"Tamamlandı! Süre:\",\n  \"js_iterations\": \"tekrar\",\n  \"js_finished_reading\": \"Okumayı bitirdim, devam et →\",\n  \"js_calculation_error\": \"Hesaplama hatası!\",\n  \"js_calculation_error_msg\": \"Zorluk hesaplaması başarısız oldu:\",\n  \"missing_required_forwarded_headers\": \"Gerekli X-Forwarded-* başlıkları eksik\",\n  \"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 <a href=\\\"https://en.wikipedia.org/wiki/Proof_of_work\\\">İş Kanıtı</a> 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.\"\n}\n"
  },
  {
    "path": "lib/localization/locales/uk.json",
    "content": "{\n  \"loading\": \"Завантаження...\",\n  \"why_am_i_seeing\": \"Чому я це бачу?\",\n  \"protected_by\": \"Захищено засобами\",\n  \"protected_from\": \"за авторством\",\n  \"made_with\": \"Зроблено з ❤️ у 🇨🇦\",\n  \"mascot_design\": \"Дизайн персонажа від\",\n  \"ai_companies_explanation\": \"Ви це бачите, оскільки адміністрація сайту налаштувала Anubis, щоб захистити сервер від тиску ШІ-компаній, які агресивно сканують вебсайти. Їхня діяльність спричиняє перебої в роботі вебсайтів, що робить матеріали недоступними для всіх.\",\n  \"anubis_compromise\": \"Anubis — це компроміс. Anubis втілює схему доказу виконаної роботи подібно до Hashcash — засобу боротьби зі спамом. По ідеї, додаткове навантаження не обтяжує справжню людину, котра робить небагато запитів, а от масове сканування таким чином стає суттєво дорожчим.\",\n  \"hack_purpose\": \"Це тимчасове рішення, котре дозволяє приділити більше часу розпізнанню й виокремленню автоматизованих браузерів (наприклад, за тим, як вони промальовують шрифти), щоб сторінку перевірки доказу виконаної роботи не доводилося показувати ймовірно справжнім користувачам.\",\n  \"simplified_explanation\": \"Це засіб боротьби з ботами й зловмисними запитами, подібний до капчі. Проте замість того, щоб просити вас щось зробити, він пропонує вашому браузеру розв'язати обчислювальне завдання. Ця концепція називається <a href=\\\"https://en.wikipedia.org/wiki/Proof_of_work\\\">доказом виконаної роботи</a>. Завдання обчислюється кілька секунд, після чого вам надається доступ до сайту. Дякуємо за розуміння й терплячість.\",\n  \"jshelter_note\": \"Зауважте, Anubis потребує сучасного JavaScript-функціоналу, котрий може бути недоступним при використанні розширень на зразок JShelter. Будь ласка, вимкніть JShelter чи інші подібні розширення для цього домену.\",\n  \"version_info\": \"Цей вебсайт застосовує Anubis версії\",\n  \"try_again\": \"Повторіть спробу\",\n  \"go_home\": \"Перейдіть на головну сторінку\",\n  \"contact_webmaster\": \"або, якщо ви певні в помилковості блокування, сконтактуйте з адміністрацією за адресою\",\n  \"connection_security\": \"Зачекайте хвилинку, поки ми перевіримо безпеку вашого з'єднання.\",\n  \"javascript_required\": \"На жаль, вам потрібно ввімкнути JavaScript, щоб пройти цю перевірку. Це необхідно, оскільки ШІ-компанії нехтують суспільним договором, завдяки якому можливо утримувати вебсайти. Робота над рішенням без використання JS триває.\",\n  \"benchmark_requires_js\": \"Щоб запустити тестування продуктивності, ввімкніть JavaScript.\",\n  \"difficulty\": \"Складність:\",\n  \"algorithm\": \"Алгоритм:\",\n  \"compare\": \"Порівняти:\",\n  \"time\": \"Час\",\n  \"iters\": \"Ітерації\",\n  \"time_a\": \"Час A\",\n  \"iters_a\": \"Ітерації A\",\n  \"time_b\": \"Час B\",\n  \"iters_b\": \"Ітерації B\",\n  \"static_check_endpoint\": \"Це просто сторінка перевірки для вашого зворотного проксі.\",\n  \"authorization_required\": \"Необхідно авторизуватися\",\n  \"cookies_disabled\": \"У вашому браузері вимкнено кукі. Anubis використовує кукі, щоб упевнитись, що ви дійсно людина. Це законний інтерес. Будь ласка, ввімкніть кукі для цього домену\",\n  \"access_denied\": \"Доступ заборонено: код помилки\",\n  \"dronebl_entry\": \"DroneBL містить пункт\",\n  \"see_dronebl_lookup\": \"див.\",\n  \"internal_server_error\": \"Внутрішня помилка сервера: адміністрація хибно налаштувала Anubis. Будь ласка, сконтактуйте з адміністрацією й попросіть глянути логи довкола\",\n  \"invalid_redirect\": \"Хибне переспрямування\",\n  \"redirect_not_parseable\": \"Не вдається розпізнати URL-адресу переспрямування\",\n  \"redirect_domain_not_allowed\": \"Заборонений домен переспрямування\",\n  \"missing_required_forwarded_headers\": \"Бракує обов'язкових заголовків X-Forwarded-*\",\n  \"failed_to_sign_jwt\": \"не вдається підписати JWT\",\n  \"invalid_invocation\": \"Хибний виклик MakeChallenge\",\n  \"client_error_browser\": \"Помилка клієнта: переконайтесь, що використовуєте браузер актуальної версії, й повторіть спробу.\",\n  \"oh_noes\": \"Йой!\",\n  \"benchmarking_anubis\": \"Тестування продуктивності Anubis!\",\n  \"you_are_not_a_bot\": \"Ви не бот!\",\n  \"making_sure_not_bot\": \"Перевірка, чи ви не бот!\",\n  \"celphase\": \"CELPHASE\",\n  \"js_web_crypto_error\": \"Ваш браузер не надає web.crypto. Ви певні, що дивитесь це через захищений контекст?\",\n  \"js_web_workers_error\": \"Ваш браузер не підтримує Web Workers (Anubis використовує їх, щоб ваш браузер не зависав на час перевірки). Можливо, у вас встановлено розширення на зразок JShelter?\",\n  \"js_cookies_error\": \"Ваш браузер не зберігає кукі. Anubis записує підписаний токен до кукі, щоб занотувати, що клієнт пройшов перевірку. Будь ласка, ввімкніть збереження кукі для цього домену. Назви кукі, які записує Anubis, можуть змінюватися без попередження. Назви й значення кукі не є частиною публічного API.\",\n  \"js_context_not_secure\": \"Ваш контекст незахищений!\",\n  \"js_context_not_secure_msg\": \"Спробуйте з'єднатися через HTTPS або попросіть адміністрацію налаштувати HTTPS. Докладніше — в <a href=\\\"https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts#when_is_a_context_considered_secure\\\">MDN</a>.\",\n  \"js_calculating\": \"Обчислення...\",\n  \"js_missing_feature\": \"Бракує функціоналу\",\n  \"js_challenge_error\": \"Помилка перевірки!\",\n  \"js_challenge_error_msg\": \"Не вдалося визначити алгоритм перевірки. Спробуйте оновити сторінку.\",\n  \"js_calculating_difficulty\": \"Обчислення...<br/>Складність:\",\n  \"js_speed\": \"Швидкість:\",\n  \"js_verification_longer\": \"Перевірка триває довше, ніж очікувалося. Будь ласка, не оновлюйте сторінку.\",\n  \"js_success\": \"Успіх!\",\n  \"js_done_took\": \"Готово! Знадобилося\",\n  \"js_iterations\": \"ітерацій\",\n  \"js_finished_reading\": \"Читання завершено, продовжити →\",\n  \"js_calculation_error\": \"Помилка обчислення!\",\n  \"js_calculation_error_msg\": \"Не вдалося обчислити перевірку:\"\n}\n"
  },
  {
    "path": "lib/localization/locales/vi.json",
    "content": "{\n  \"loading\": \"Đang nạp...\",\n  \"why_am_i_seeing\": \"Tại sao tôi đang thấy trang này?\",\n  \"protected_by\": \"Bảo vệ bởi\",\n  \"protected_from\": \"từ\",\n  \"made_with\": \"Tạo ra bằng ❤️ tại 🇨🇦\",\n  \"mascot_design\": \"Thiết kế mascot bởi\",\n  \"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.\",\n  \"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.\",\n  \"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.\",\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à <a href=\\\"https://en.wikipedia.org/wiki/Proof_of_work\\\">Bằng chứng Công việc</a>. 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.\",\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.\",\n  \"version_info\": \"Trang web này đang chạy Anubis phiên bản\",\n  \"try_again\": \"Thử lại\",\n  \"go_home\": \"Về trang chủ\",\n  \"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\",\n  \"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.\",\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.\",\n  \"benchmark_requires_js\": \"Bắt buộc phải bật JavaScript để chạy công cụ benchmark.\",\n  \"difficulty\": \"Độ khó:\",\n  \"algorithm\": \"Thuật toán:\",\n  \"compare\": \"So sánh:\",\n  \"time\": \"Thời gian\",\n  \"iters\": \"Lặp lại\",\n  \"time_a\": \"Thời gian A\",\n  \"iters_a\": \"Lặp lại kiểu A\",\n  \"time_b\": \"Thời gian B\",\n  \"iters_b\": \"Lặp lại kiểu B\",\n  \"static_check_endpoint\": \"Đây là điểm cuối cho reverse proxy của bạn sử dụng.\",\n  \"authorization_required\": \"Bắt buộc xác thực\",\n  \"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\",\n  \"access_denied\": \"Truy cập bị từ chối: mã lỗi\",\n  \"dronebl_entry\": \"DroneBL báo cáo truy cập\",\n  \"see_dronebl_lookup\": \"xem\",\n  \"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\",\n  \"invalid_redirect\": \"Điều hướng không hợp lệ\",\n  \"redirect_not_parseable\": \"Liên kết điều hướng không thể xử lý\",\n  \"redirect_domain_not_allowed\": \"Tên miền điều hướng không được phép\",\n  \"missing_required_forwarded_headers\": \"Thiếu các tiêu đề X-Forwarded-* bắt buộc\",\n  \"failed_to_sign_jwt\": \"không thể ký JWT\",\n  \"invalid_invocation\": \"Gọi hàm MakeChallenge không hợp lệ\",\n  \"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.\",\n  \"oh_noes\": \"Ôi không!\",\n  \"benchmarking_anubis\": \"Đang benchmark Anubis!\",\n  \"you_are_not_a_bot\": \"Bạn không phải là bot!\",\n  \"making_sure_not_bot\": \"Đang kiểm tra bạn không phải là bot!\",\n  \"celphase\": \"CELPHASE\",\n  \"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?\",\n  \"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?\",\n  \"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.\",\n  \"js_context_not_secure\": \"Kết nối này không bảo mật!\",\n  \"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 <a href=\\\"https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts#when_is_a_context_considered_secure\\\">MDN</a>.\",\n  \"js_calculating\": \"Đang tính toán...\",\n  \"js_missing_feature\": \"Thiếu tính năng\",\n  \"js_challenge_error\": \"Lỗi thử thách!\",\n  \"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.\",\n  \"js_calculating_difficulty\": \"Đang tính...<br/>Độ khó:\",\n  \"js_speed\": \"Tốc độ:\",\n  \"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.\",\n  \"js_success\": \"Thành công!\",\n  \"js_done_took\": \"Hoàn tất! Mất\",\n  \"js_iterations\": \"lần lặp lại\",\n  \"js_finished_reading\": \"Tôi đã đọc xong, tiếp tục →\",\n  \"js_calculation_error\": \"Lỗi tính toán!\",\n  \"js_calculation_error_msg\": \"Không thể tính toán thử thách:\"\n}\n"
  },
  {
    "path": "lib/localization/locales/zh-CN.json",
    "content": "{\n  \"loading\": \"加载中...\",\n  \"why_am_i_seeing\": \"为什么我会看到这个？\",\n  \"protected_by\": \"本网站由\",\n  \"protected_from\": \"保护，来自\",\n  \"made_with\": \"在 🇨🇦 用 ❤️ 制作\",\n  \"mascot_design\": \"吉祥物由\",\n  \"ai_companies_explanation\": \"您会看到这个画面，是因为网站管理员启用了 Anubis 来保护服务器，避免 AI 公司大量爬取网站内容。这类行为会导致网站崩溃，让所有用户都无法正常访问资源。\",\n  \"anubis_compromise\": \"Anubis 是一种折中做法。它采用了类似 Hashcash 的工作量证明机制（Proof-of-Work），该机制最初是为了减少垃圾邮件而提出。其核心概念是：对个别用户而言，额外的计算负担可以忽略，但对大规模爬虫来说，累积起来的成本将大幅增加，从而让爬取行为变得更困难。\",\n  \"hack_purpose\": \"最终，这是一个占位符解决方案，以便将更多时间用于指纹识别和识别无头浏览器（例如：通过它们如何进行字体渲染），从而无需向更可能是合法用户的用户呈现挑战工作量证明页面。\",\n  \"jshelter_note\": \"请注意，Anubis 需要使用现代 JavaScript 功能，而像 JShelter 这类插件可能会阻挡这些功能。请为此域名停用 JShelter 或类似的插件。\",\n  \"version_info\": \"这个网站正在运行的 Anubis 版本为\",\n  \"try_again\": \"再试一次\",\n  \"go_home\": \"返回首页\",\n  \"contact_webmaster\": \"或者您觉得您不应该被封锁，请联系网站管理员于\",\n  \"connection_security\": \"请稍等，我们需要在继续之前检查您的连接安全性。\",\n  \"javascript_required\": \"很遗憾，您必须启用 JavaScript 才能通过这项验证。这是因为 AI 公司已经改变了网站托管的社会契约，因此我们必须采取这样的保护机制。无需 JavaScript 的解决方案仍在开发中。\",\n  \"benchmark_requires_js\": \"运行基准测试工具需要启用 JavaScript。\",\n  \"difficulty\": \"难度：\",\n  \"algorithm\": \"算法：\",\n  \"compare\": \"比较：\",\n  \"time\": \"时间\",\n  \"iters\": \"迭代\",\n  \"time_a\": \"时间 A\",\n  \"iters_a\": \"迭代 A\",\n  \"time_b\": \"时间 B\",\n  \"iters_b\": \"迭代 B\",\n  \"static_check_endpoint\": \"这是提供给您的反向代理服务器使用的检查端点。\",\n  \"authorization_required\": \"需要认证\",\n  \"cookies_disabled\": \"您的浏览器目前已禁用 Cookie，为了确认您是合法用户，Anubis 需要启用 Cookie。 请您为此域名启用 Cookie\",\n  \"access_denied\": \"拒绝访问：错误代码\",\n  \"dronebl_entry\": \"DroneBL 报告了一条记录\",\n  \"see_dronebl_lookup\": \"见\",\n  \"internal_server_error\": \"内部服务器错误：管理员错误地配置了 Anubis。 请联系管理员要求他们检查日志\",\n  \"invalid_redirect\": \"无效的重定向\",\n  \"redirect_not_parseable\": \"重定向 URL 无法解析\",\n  \"redirect_domain_not_allowed\": \"重定向的域名并不允许\",\n  \"failed_to_sign_jwt\": \"签署 JWT 失败\",\n  \"invalid_invocation\": \"无效的 MakeChallenge 调用\",\n  \"client_error_browser\": \"客户端错误：请确保您的浏览器是最新版本并稍候再试。\",\n  \"oh_noes\": \"哎呀糟糕了！\",\n  \"benchmarking_anubis\": \"正在进行 Anubis 性能测试！\",\n  \"you_are_not_a_bot\": \"你不是机器人！\",\n  \"making_sure_not_bot\": \"正在确认你是不是机器人！\",\n  \"celphase\": \"CELPHASE 设计\",\n  \"js_web_crypto_error\": \"您的浏览器无法正常使用 web.crypto 组件。您是否通过安全连接（HTTPS）查看此网站？\",\n  \"js_web_workers_error\": \"您的浏览器并不支持 Web workers （Anubis 使用这个来避免冻结您的浏览器 ）您有安装像是 JShelter 之类的插件吗？\",\n  \"js_cookies_error\": \"您的浏览器无法存储 Cookie。 Anubis 会使用 Cookie 存储签署的凭证，以判断用户是否已通过验证。请为此域名启用 Cookie 存储功能。 请注意，Anubis 存储的 Cookie 名称可能会变动，且其名称与内容不属于公开 API 的一部分。\",\n  \"js_context_not_secure\": \"您的内容并不安全\",\n  \"js_context_not_secure_msg\": \"请尝试使用 HTTPS 连接，或联系网站管理员设置 HTTPS。更多信息请参见 <a href=\\\"https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts#when_is_a_context_considered_secure\\\">MDN</a>。\",\n  \"js_calculating\": \"计算中...\",\n  \"js_missing_feature\": \"缺少功能\",\n  \"js_challenge_error\": \"挑战错误！\",\n  \"js_challenge_error_msg\": \"解决检查算法失败。 您可能会想要刷新页面。\",\n  \"js_calculating_difficulty\": \"计算中...<br/>难度：\",\n  \"js_speed\": \"速度：\",\n  \"js_verification_longer\": \"验证所花的时间高于预期。 请不要刷新页面。\",\n  \"js_success\": \"成功！\",\n  \"js_done_took\": \"完成！ 花费\",\n  \"js_iterations\": \"迭代\",\n  \"js_finished_reading\": \"我读完了，继续 →\",\n  \"js_calculation_error\": \"计算错误！\",\n  \"js_calculation_error_msg\": \"计算挑战失败：\",\n  \"missing_required_forwarded_headers\": \"缺少必要的 X-Forwarded-* 头\",\n  \"simplified_explanation\": \"这是一种类似于验证码的措施，用于防止机器人和恶意请求。但是，您无需自己动手，您的浏览器会收到一个计算任务，必须解决该任务以确保它是有效的客户端。这个概念称为<a href=\\\"https://en.wikipedia.org/wiki/Proof_of_work\\\">工作量证明</a>。该任务在几秒钟内计算完毕，您将被授予访问网站的权限。感谢您的理解和耐心。\"\n}\n"
  },
  {
    "path": "lib/localization/locales/zh-TW.json",
    "content": "{\n  \"loading\": \"載入中...\",\n  \"why_am_i_seeing\": \"為什麼我看到這個？\",\n  \"protected_by\": \"本網站由\",\n  \"protected_from\": \"保護，來自\",\n  \"made_with\": \"在 🇨🇦 用 ❤️ 製作\",\n  \"mascot_design\": \"吉祥物由\",\n  \"ai_companies_explanation\": \"您會看到這個畫面，是因為網站管理員啟用了 Anubis 來保護伺服器，避免 AI 公司大量爬取網站內容。這類行為會導致網站當機，讓所有使用者都無法正常存取資源。\",\n  \"anubis_compromise\": \"Anubis 是一種折衷做法。它採用了類似 Hashcash 的工作量證明機制（Proof-of-Work），該機制最初是為了減少垃圾郵件而提出。其核心概念是：對個別使用者而言，額外的運算負擔可以忽略，但對大規模爬蟲來說，累積起來的成本將大幅增加，從而讓爬取行為變得更困難。\",\n  \"hack_purpose\": \"最終，這是一個佔位符解決方案，以便將更多時間用於指紋識別和識別無頭瀏覽器（例如：透過它們如何進行字體渲染），從而無需向更可能是合法用戶的用戶呈現挑戰工作量證明頁面。\",\n  \"jshelter_note\": \"請注意，Anubis 需要使用現代 JavaScript 功能，而像 JShelter 這類外掛可能會阻擋這些功能。請為此網域停用 JShelter 或類似的插件。\",\n  \"version_info\": \"這個網站正在運行 Anubis 版本\",\n  \"try_again\": \"再試一次\",\n  \"go_home\": \"回首頁\",\n  \"contact_webmaster\": \"或者您覺得您不應該被封鎖，請聯絡站點管理員於\",\n  \"connection_security\": \"請稍等，我們需要在繼續之前檢閱您的連線安全性。\",\n  \"javascript_required\": \"很遺憾，您必須啟用 JavaScript 才能通過這項驗證。這是因為 AI 公司已經改變了網站託管的社會契約，因此我們必須採取這樣的保護機制。無需 JavaScript 的解法仍在開發中。\",\n  \"benchmark_requires_js\": \"執行基準測試工具需要啟用 JavaScript。\",\n  \"difficulty\": \"難度：\",\n  \"algorithm\": \"演算法：\",\n  \"compare\": \"比較：\",\n  \"time\": \"時間\",\n  \"iters\": \"迭代\",\n  \"time_a\": \"時間 A\",\n  \"iters_a\": \"迭代 A\",\n  \"time_b\": \"時間 B\",\n  \"iters_b\": \"迭代 B\",\n  \"static_check_endpoint\": \"這是提供給您的反向代理伺服器使用的檢查端點。\",\n  \"authorization_required\": \"需要認證\",\n  \"cookies_disabled\": \"您的瀏覽器目前已停用 Cookie，為了確認您是合法使用者，Anubis 需要啟用 Cookie。 請為此網域啟用 Cookie\",\n  \"access_denied\": \"拒絕存取：錯誤代碼\",\n  \"dronebl_entry\": \"DroneBL 回報了一筆紀錄\",\n  \"see_dronebl_lookup\": \"見\",\n  \"internal_server_error\": \"內部伺服器錯誤：管理員錯誤地配置了 Anubis。 請聯絡管理員要求他們檢閱日誌\",\n  \"invalid_redirect\": \"無效的重新導向\",\n  \"redirect_not_parseable\": \"重新導向 URL 無法解析\",\n  \"redirect_domain_not_allowed\": \"重新導向的網域並不允許\",\n  \"failed_to_sign_jwt\": \"簽署 JWT 失敗\",\n  \"invalid_invocation\": \"無效的 MakeChallenge 呼叫\",\n  \"client_error_browser\": \"客戶端錯誤：請確保您的瀏覽器是最新版本並稍候再試。\",\n  \"oh_noes\": \"哎呀糟糕了！\",\n  \"benchmarking_anubis\": \"正在進行 Anubis 效能測試！\",\n  \"you_are_not_a_bot\": \"你不是機器人！\",\n  \"making_sure_not_bot\": \"正在確認你是不是機器人！\",\n  \"celphase\": \"CELPHASE 設計\",\n  \"js_web_crypto_error\": \"您的瀏覽器無法正常使用 web.crypto 元件。您是否透過安全連線（HTTPS）檢視此網站？\",\n  \"js_web_workers_error\": \"您的瀏覽器並不支援 Web workers （Anubis 使用這個來避免凍結您的瀏覽器 ）您有安裝像是 JShelter 之類的插件嗎？\",\n  \"js_cookies_error\": \"您的瀏覽器無法儲存 Cookie。 Anubis 會使用 Cookie 儲存簽署的憑證，以判斷使用者是否已通過驗證。請為此網域啟用 Cookie 儲存功能。 請注意，Anubis 儲存的 Cookie 名稱可能會變動，且其名稱與內容不屬於公開 API 的一部分。\",\n  \"js_context_not_secure\": \"您的內容並不安全\",\n  \"js_context_not_secure_msg\": \"請嘗試使用 HTTPS 連線，或聯繫網站管理員設定 HTTPS。更多資訊請參見 <a href=\\\"https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts#when_is_a_context_considered_secure\\\">MDN</a>。\",\n  \"js_calculating\": \"計算中...\",\n  \"js_missing_feature\": \"缺少功能\",\n  \"js_challenge_error\": \"挑戰錯誤！\",\n  \"js_challenge_error_msg\": \"解決檢查演算法失敗。 您可能會想要重整頁面。\",\n  \"js_calculating_difficulty\": \"計算中...<br/>難度：\",\n  \"js_speed\": \"速度：\",\n  \"js_verification_longer\": \"驗證所花的時間高於預期。 請不要重整頁面。\",\n  \"js_success\": \"成功！\",\n  \"js_done_took\": \"完成！ 花費\",\n  \"js_iterations\": \"迭代\",\n  \"js_finished_reading\": \"我讀完了，繼續 →\",\n  \"js_calculation_error\": \"計算錯誤！\",\n  \"js_calculation_error_msg\": \"計算挑戰失敗：\",\n  \"missing_required_forwarded_headers\": \"缺少必要的 X-Forwarded-* 標頭\",\n  \"simplified_explanation\": \"這是一種類似於驗證碼的措施，用於防止機器人和惡意請求。但是，您無需自己動手，您的瀏覽器會收到一個計算任務，必須解決該任務以確保它是有效的客戶端。這個概念稱為<a href=\\\"https://en.wikipedia.org/wiki/Proof_of_work\\\">工作量證明</a>。該任務在幾秒鐘內計算完畢，您將被授予訪問網站的權限。感謝您的理解和耐心。\"\n}\n"
  },
  {
    "path": "lib/localization/localization.go",
    "content": "package localization\n\nimport (\n\t\"embed\"\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"strings\"\n\t\"sync\"\n\n\t\"github.com/TecharoHQ/anubis\"\n\t\"github.com/nicksnyder/go-i18n/v2/i18n\"\n\t\"golang.org/x/text/language\"\n)\n\n//go:embed locales/*.json\nvar localeFS embed.FS\n\ntype LocalizationService struct {\n\tbundle *i18n.Bundle\n}\n\nvar (\n\tglobalService *LocalizationService\n\tonce          sync.Once\n)\n\nfunc NewLocalizationService() *LocalizationService {\n\tonce.Do(func() {\n\t\tbundle := i18n.NewBundle(language.English)\n\t\tbundle.RegisterUnmarshalFunc(\"json\", json.Unmarshal)\n\n\t\t// Read all JSON files from the locales directory\n\t\tentries, err := localeFS.ReadDir(\"locales\")\n\t\tif err != nil {\n\t\t\t// Try fallback - create a minimal service with default messages\n\t\t\tglobalService = &LocalizationService{bundle: bundle}\n\t\t\treturn\n\t\t}\n\n\t\tloadedAny := false\n\t\tfor _, entry := range entries {\n\t\t\tif !entry.IsDir() && strings.HasSuffix(entry.Name(), \".json\") {\n\t\t\t\tfilePath := \"locales/\" + entry.Name()\n\t\t\t\t_, err := bundle.LoadMessageFileFS(localeFS, filePath)\n\t\t\t\tif err != nil {\n\t\t\t\t\t// Log error but continue with other files\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tloadedAny = true\n\t\t\t}\n\t\t}\n\n\t\tif !loadedAny {\n\t\t\t// If no files were loaded successfully, create minimal service\n\t\t\tglobalService = &LocalizationService{bundle: bundle}\n\t\t\treturn\n\t\t}\n\n\t\tglobalService = &LocalizationService{bundle: bundle}\n\t})\n\n\t// Safety check - if globalService is still nil, create a minimal one\n\tif globalService == nil {\n\t\tbundle := i18n.NewBundle(language.English)\n\t\tbundle.RegisterUnmarshalFunc(\"json\", json.Unmarshal)\n\t\tglobalService = &LocalizationService{bundle: bundle}\n\t}\n\n\treturn globalService\n}\n\nfunc (ls *LocalizationService) GetLocalizer(lang string) *i18n.Localizer {\n\treturn i18n.NewLocalizer(ls.bundle, lang)\n}\n\nfunc (ls *LocalizationService) GetLocalizerFromRequest(r *http.Request) *i18n.Localizer {\n\tif ls == nil || ls.bundle == nil {\n\t\t// Fallback to a basic bundle if service is not properly initialized\n\t\tbundle := i18n.NewBundle(language.English)\n\t\tbundle.RegisterUnmarshalFunc(\"json\", json.Unmarshal)\n\t\treturn i18n.NewLocalizer(bundle, \"en\")\n\t}\n\tacceptLanguage := r.Header.Get(\"Accept-Language\")\n\n\t// Parse Accept-Language header to properly handle quality factors\n\t// The language.ParseAcceptLanguage function returns tags sorted by quality\n\ttags, _, err := language.ParseAcceptLanguage(acceptLanguage)\n\tif err != nil || len(tags) == 0 {\n\t\treturn i18n.NewLocalizer(ls.bundle, \"en\")\n\t}\n\n\t// Convert parsed tags to strings for the localizer\n\t// We include both the full tag and base language to ensure proper matching\n\tlangs := make([]string, 0, len(tags)*2+1)\n\tfor _, tag := range tags {\n\t\tlangs = append(langs, tag.String())\n\t\t// Also add base language (e.g., \"en\" for \"en-GB\") to help matching\n\t\tbase, _ := tag.Base()\n\t\tif base.String() != tag.String() {\n\t\t\tlangs = append(langs, base.String())\n\t\t}\n\t}\n\tlangs = append(langs, \"en\") // Always include English as fallback\n\n\treturn i18n.NewLocalizer(ls.bundle, langs...)\n}\n\n// SimpleLocalizer wraps i18n.Localizer with a more convenient API\ntype SimpleLocalizer struct {\n\tLocalizer *i18n.Localizer\n}\n\n// T provides a concise way to localize messages\nfunc (sl *SimpleLocalizer) T(messageID string) string {\n\treturn sl.Localizer.MustLocalize(&i18n.LocalizeConfig{MessageID: messageID})\n}\n\n// Get the language that is used by the localizer by retrieving a well-known string that is required to be present\nfunc (sl *SimpleLocalizer) GetLang() string {\n\t_, tag, err := sl.Localizer.LocalizeWithTag(&i18n.LocalizeConfig{MessageID: \"loading\"})\n\tif err != nil {\n\t\treturn \"en\"\n\t}\n\treturn tag.String()\n}\n\n// GetLocalizer creates a localizer based on the request's Accept-Language header or forcedLanguage option\nfunc GetLocalizer(r *http.Request) *SimpleLocalizer {\n\tvar localizer *i18n.Localizer\n\tif anubis.ForcedLanguage == \"\" {\n\t\tlocalizer = NewLocalizationService().GetLocalizerFromRequest(r)\n\t} else {\n\t\tlocalizer = NewLocalizationService().GetLocalizer(anubis.ForcedLanguage)\n\t}\n\treturn &SimpleLocalizer{Localizer: localizer}\n}\n"
  },
  {
    "path": "lib/localization/localization_test.go",
    "content": "package localization\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http/httptest\"\n\t\"sort\"\n\t\"testing\"\n\n\t\"github.com/nicksnyder/go-i18n/v2/i18n\"\n)\n\nfunc TestLocalizationService(t *testing.T) {\n\tservice := NewLocalizationService()\n\n\tloadingStrMap := map[string]string{\n\t\t\"de\":    \"Ladevorgang...\",\n\t\t\"en\":    \"Loading...\",\n\t\t\"es\":    \"Cargando...\",\n\t\t\"et\":    \"Laadin...\",\n\t\t\"fil\":   \"Naglo-load...\",\n\t\t\"fr\":    \"Chargement...\",\n\t\t\"ja\":    \"ロード中...\",\n\t\t\"is\":    \"Hleður...\",\n\t\t\"nb\":    \"Laster inn...\",\n\t\t\"nl\":    \"Laden...\",\n\t\t\"nn\":    \"Lastar inn...\",\n\t\t\"pl\":    \"Ładowanie...\",\n\t\t\"pt-BR\": \"Carregando...\",\n\t\t\"tr\":    \"Yükleniyor...\",\n\t\t\"ru\":    \"Загрузка...\",\n\t\t\"uk\":    \"Завантаження...\",\n\t\t\"vi\":    \"Đang nạp...\",\n\t\t\"zh-CN\": \"加载中...\",\n\t\t\"zh-TW\": \"載入中...\",\n\t\t\"sv\":    \"Laddar...\",\n\t}\n\n\tvar keys []string\n\n\tfor lang := range loadingStrMap {\n\t\tkeys = append(keys, lang)\n\t}\n\n\tsort.Strings(keys)\n\n\tfor _, lang := range keys {\n\t\texpected := loadingStrMap[lang]\n\t\tt.Run(fmt.Sprintf(\"%s localization\", lang), func(t *testing.T) {\n\t\t\tlocalizer := service.GetLocalizer(lang)\n\t\t\tresult := localizer.MustLocalize(&i18n.LocalizeConfig{MessageID: \"loading\"})\n\t\t\tif result != expected {\n\t\t\t\tt.Errorf(\"Expected '%s', got '%s'\", expected, result)\n\t\t\t}\n\t\t})\n\t}\n\n\t// Test for requiredKeys localization\n\trequiredKeys := []string{\n\t\t\"loading\", \"why_am_i_seeing\", \"protected_by\", \"protected_from\", \"made_with\",\n\t\t\"mascot_design\", \"try_again\", \"go_home\", \"javascript_required\",\n\t}\n\n\tfor _, lang := range keys {\n\t\tt.Run(fmt.Sprintf(\"All required keys exist in %s\", lang), func(t *testing.T) {\n\t\t\tloc := service.GetLocalizer(lang)\n\t\t\tfor _, key := range requiredKeys {\n\t\t\t\tresult := loc.MustLocalize(&i18n.LocalizeConfig{MessageID: key})\n\t\t\t\tif result == \"\" {\n\t\t\t\t\tt.Errorf(\"Key '%s' returned empty string\", key)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\ntype manifest struct {\n\tSupportedLanguages []string `json:\"supportedLanguages\"`\n}\n\nfunc loadManifest(t *testing.T) manifest {\n\tt.Helper()\n\n\tfin, err := localeFS.Open(\"locales/manifest.json\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer fin.Close()\n\n\tvar result manifest\n\tif err := json.NewDecoder(fin).Decode(&result); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\treturn result\n}\n\nfunc TestComprehensiveTranslations(t *testing.T) {\n\tservice := NewLocalizationService()\n\n\tvar translations = map[string]any{}\n\tfin, err := localeFS.Open(\"locales/en.json\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer fin.Close()\n\n\tif err := json.NewDecoder(fin).Decode(&translations); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tvar keys []string\n\tfor k := range translations {\n\t\tkeys = append(keys, k)\n\t}\n\n\tsort.Strings(keys)\n\n\tmanifest := loadManifest(t)\n\tif len(manifest.SupportedLanguages) == 0 {\n\t\tt.Fatal(\"no languages loaded\")\n\t}\n\n\tfor _, lang := range loadManifest(t).SupportedLanguages {\n\t\tt.Run(lang, func(t *testing.T) {\n\t\t\tloc := service.GetLocalizer(lang)\n\t\t\tsl := SimpleLocalizer{Localizer: loc}\n\t\t\tservice_lang := sl.GetLang()\n\t\t\tif service_lang != lang {\n\t\t\t\tt.Error(\"Localizer language not same as specified\")\n\t\t\t}\n\t\t\tfor _, key := range keys {\n\t\t\t\tt.Run(key, func(t *testing.T) {\n\t\t\t\t\tif result := sl.T(key); result == \"\" {\n\t\t\t\t\t\tt.Error(\"key not defined\")\n\t\t\t\t\t}\n\t\t\t\t})\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAcceptLanguageQualityFactors(t *testing.T) {\n\tservice := NewLocalizationService()\n\n\ttestCases := []struct {\n\t\tname           string\n\t\tacceptLanguage string\n\t\texpectedLang   string\n\t}{\n\t\t{\"simple_en\", \"en\", \"en\"},\n\t\t{\"simple_de\", \"de\", \"de\"},\n\t\t{\"en_GB_with_lower_priority_de\", \"en-GB,de-DE;q=0.5\", \"en\"},\n\t\t{\"en_GB_only\", \"en-GB\", \"en\"},\n\t\t{\"de_with_lower_priority_en\", \"de,en;q=0.5\", \"de\"},\n\t\t{\"de_DE_with_lower_priority_en\", \"de-DE,en;q=0.5\", \"de\"},\n\t\t{\"fr_with_lower_priority_de\", \"fr,de;q=0.5\", \"fr\"},\n\t\t{\"zh_CN_regional\", \"zh-CN\", \"zh-CN\"},\n\t\t{\"zh_TW_regional\", \"zh-TW\", \"zh-TW\"},\n\t\t{\"pt_BR_regional\", \"pt-BR\", \"pt-BR\"},\n\t\t{\"complex_header\", \"fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7,de;q=0.5\", \"fr\"},\n\t}\n\n\tfor _, tc := range testCases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\treq := httptest.NewRequest(\"GET\", \"/\", nil)\n\t\t\treq.Header.Set(\"Accept-Language\", tc.acceptLanguage)\n\n\t\t\tlocalizer := service.GetLocalizerFromRequest(req)\n\t\t\tsl := &SimpleLocalizer{Localizer: localizer}\n\n\t\t\tgotLang := sl.GetLang()\n\t\t\tif gotLang != tc.expectedLang {\n\t\t\t\tt.Errorf(\"Accept-Language %q: expected %s, got %s\", tc.acceptLanguage, tc.expectedLang, gotLang)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "lib/policy/bot.go",
    "content": "package policy\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/TecharoHQ/anubis/internal\"\n\t\"github.com/TecharoHQ/anubis/lib/config\"\n\t\"github.com/TecharoHQ/anubis/lib/policy/checker\"\n)\n\ntype Bot struct {\n\tRules     checker.Impl\n\tChallenge *config.ChallengeRules\n\tWeight    *config.Weight\n\tName      string\n\tAction    config.Rule\n}\n\nfunc (b Bot) Hash() string {\n\treturn internal.FastHash(fmt.Sprintf(\"%s::%s\", b.Name, b.Rules.Hash()))\n}\n"
  },
  {
    "path": "lib/policy/celchecker.go",
    "content": "package policy\n\nimport (\n\t\"fmt\"\n\t\"net/http\"\n\n\t\"github.com/TecharoHQ/anubis/internal\"\n\t\"github.com/TecharoHQ/anubis/internal/dns\"\n\t\"github.com/TecharoHQ/anubis/lib/config\"\n\t\"github.com/TecharoHQ/anubis/lib/policy/expressions\"\n\t\"github.com/google/cel-go/cel\"\n\t\"github.com/google/cel-go/common/types\"\n)\n\ntype CELChecker struct {\n\tprogram cel.Program\n\tsrc     string\n}\n\nfunc NewCELChecker(cfg *config.ExpressionOrList, dnsObj *dns.Dns) (*CELChecker, error) {\n\tenv, err := expressions.BotEnvironment(dnsObj)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tprogram, err := expressions.Compile(env, cfg.String())\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"can't compile CEL program: %w\", err)\n\t}\n\n\treturn &CELChecker{\n\t\tsrc:     cfg.String(),\n\t\tprogram: program,\n\t}, nil\n}\n\nfunc (cc *CELChecker) Hash() string {\n\treturn internal.FastHash(cc.src)\n}\n\nfunc (cc *CELChecker) Check(r *http.Request) (bool, error) {\n\tresult, _, err := cc.program.ContextEval(r.Context(), &CELRequest{r})\n\n\tif err != nil {\n\t\treturn false, err\n\t}\n\n\tif val, ok := result.(types.Bool); ok {\n\t\treturn bool(val), nil\n\t}\n\n\treturn false, nil\n}\n\ntype CELRequest struct {\n\t*http.Request\n}\n\nfunc (cr *CELRequest) Parent() cel.Activation { return nil }\n\nfunc (cr *CELRequest) ResolveName(name string) (any, bool) {\n\tswitch name {\n\tcase \"remoteAddress\":\n\t\treturn cr.Header.Get(\"X-Real-Ip\"), true\n\tcase \"contentLength\":\n\t\treturn cr.ContentLength, true\n\tcase \"host\":\n\t\treturn cr.Host, true\n\tcase \"method\":\n\t\treturn cr.Method, true\n\tcase \"userAgent\":\n\t\treturn cr.UserAgent(), true\n\tcase \"path\":\n\t\treturn cr.URL.Path, true\n\tcase \"query\":\n\t\treturn expressions.URLValues{Values: cr.URL.Query()}, true\n\tcase \"headers\":\n\t\treturn expressions.HTTPHeaders{Header: cr.Header}, true\n\tcase \"load_1m\":\n\t\treturn expressions.Load1(), true\n\tcase \"load_5m\":\n\t\treturn expressions.Load5(), true\n\tcase \"load_15m\":\n\t\treturn expressions.Load15(), true\n\tdefault:\n\t\treturn nil, false\n\t}\n}\n"
  },
  {
    "path": "lib/policy/checker/checker.go",
    "content": "// Package checker defines the Checker interface and a helper utility to avoid import cycles.\npackage checker\n\nimport (\n\t\"fmt\"\n\t\"net/http\"\n\t\"strings\"\n\n\t\"github.com/TecharoHQ/anubis/internal\"\n)\n\ntype Impl interface {\n\tCheck(*http.Request) (bool, error)\n\tHash() string\n}\n\ntype Func func(*http.Request) (bool, error)\n\nfunc (f Func) Check(r *http.Request) (bool, error) {\n\treturn f(r)\n}\n\nfunc (f Func) Hash() string { return internal.FastHash(fmt.Sprintf(\"%#v\", f)) }\n\ntype List []Impl\n\n// Check runs each checker in the list against the request.\n// It returns true only if *all* checkers return true (AND semantics).\n// If any checker returns an error, the function returns false and the error.\nfunc (l List) Check(r *http.Request) (bool, error) {\n\tfor _, c := range l {\n\t\tok, err := c.Check(r)\n\t\tif err != nil {\n\t\t\t// Propagate the error; overall result is false.\n\t\t\treturn false, err\n\t\t}\n\t\tif !ok {\n\t\t\t// One false means the combined result is false. Short-circuit\n\t\t\t// so we don't waste time.\n\t\t\treturn false, err\n\t\t}\n\t}\n\t// Assume success until a checker says otherwise.\n\treturn true, nil\n}\n\nfunc (l List) Hash() string {\n\tvar sb strings.Builder\n\n\tfor _, c := range l {\n\t\tfmt.Fprintln(&sb, c.Hash())\n\t}\n\n\treturn internal.FastHash(sb.String())\n}\n"
  },
  {
    "path": "lib/policy/checker/checker_test.go",
    "content": "package checker\n\nimport (\n\t\"errors\"\n\t\"net/http\"\n\t\"testing\"\n)\n\n// Mock implements the Impl interface for testing.\ntype Mock struct {\n\tresult bool\n\terr    error\n\thash   string\n}\n\nfunc (m Mock) Check(r *http.Request) (bool, error) { return m.result, m.err }\nfunc (m Mock) Hash() string                        { return m.hash }\n\nfunc TestListCheck_AndSemantics(t *testing.T) {\n\treq, _ := http.NewRequest(http.MethodGet, \"http://example.com\", nil)\n\n\ttests := []struct {\n\t\tname    string\n\t\tlist    List\n\t\twant    bool\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\tname: \"all true\",\n\t\t\tlist: List{Mock{true, nil, \"a\"}, Mock{true, nil, \"b\"}},\n\t\t\twant: true,\n\t\t},\n\t\t{\n\t\t\tname: \"one false\",\n\t\t\tlist: List{Mock{true, nil, \"a\"}, Mock{false, nil, \"b\"}},\n\t\t\twant: false,\n\t\t},\n\t\t{\n\t\t\tname:    \"error propagates\",\n\t\t\tlist:    List{Mock{true, nil, \"a\"}, Mock{true, errors.New(\"boom\"), \"b\"}},\n\t\t\twant:    false,\n\t\t\twantErr: true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot, err := tt.list.Check(req)\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Fatalf(\"unexpected error state: %v\", err)\n\t\t\t}\n\t\t\tif got != tt.want {\n\t\t\t\tt.Fatalf(\"expected %v, got %v\", tt.want, got)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "lib/policy/checker.go",
    "content": "package policy\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/netip\"\n\t\"regexp\"\n\t\"strings\"\n\n\t\"github.com/TecharoHQ/anubis/internal\"\n\t\"github.com/TecharoHQ/anubis/lib/policy/checker\"\n\t\"github.com/gaissmai/bart\"\n)\n\nvar (\n\tErrMisconfiguration = errors.New(\"[unexpected] policy: administrator misconfiguration\")\n)\n\ntype RemoteAddrChecker struct {\n\tprefixTable *bart.Lite\n\thash        string\n}\n\nfunc NewRemoteAddrChecker(cidrs []string) (checker.Impl, error) {\n\ttable := new(bart.Lite)\n\n\tfor _, cidr := range cidrs {\n\t\tprefix, err := netip.ParsePrefix(cidr)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"%w: range %s not parsing: %w\", ErrMisconfiguration, cidr, err)\n\t\t}\n\n\t\ttable.Insert(prefix)\n\t}\n\n\treturn &RemoteAddrChecker{\n\t\tprefixTable: table,\n\t\thash:        internal.FastHash(strings.Join(cidrs, \",\")),\n\t}, nil\n}\n\nfunc (rac *RemoteAddrChecker) Check(r *http.Request) (bool, error) {\n\thost := r.Header.Get(\"X-Real-Ip\")\n\tif host == \"\" {\n\t\treturn false, fmt.Errorf(\"%w: header X-Real-Ip is not set\", ErrMisconfiguration)\n\t}\n\n\taddr, err := netip.ParseAddr(host)\n\tif err != nil {\n\t\treturn false, fmt.Errorf(\"%w: %s is not an IP address: %w\", ErrMisconfiguration, host, err)\n\t}\n\n\t// Convert IPv4-mapped IPv6 addresses to IPv4\n\tif addr.Is6() && addr.Is4In6() {\n\t\taddr = addr.Unmap()\n\t}\n\n\treturn rac.prefixTable.Contains(addr), nil\n}\n\nfunc (rac *RemoteAddrChecker) Hash() string {\n\treturn rac.hash\n}\n\ntype HeaderMatchesChecker struct {\n\theader string\n\tregexp *regexp.Regexp\n\thash   string\n}\n\nfunc NewUserAgentChecker(rexStr string) (checker.Impl, error) {\n\treturn NewHeaderMatchesChecker(\"User-Agent\", rexStr)\n}\n\nfunc NewHeaderMatchesChecker(header, rexStr string) (checker.Impl, error) {\n\trex, err := regexp.Compile(strings.TrimSpace(rexStr))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"%w: regex %s failed parse: %w\", ErrMisconfiguration, rexStr, err)\n\t}\n\treturn &HeaderMatchesChecker{strings.TrimSpace(header), rex, internal.FastHash(header + \": \" + rexStr)}, nil\n}\n\nfunc (hmc *HeaderMatchesChecker) Check(r *http.Request) (bool, error) {\n\tif hmc.regexp.MatchString(r.Header.Get(hmc.header)) {\n\t\treturn true, nil\n\t}\n\n\treturn false, nil\n}\n\nfunc (hmc *HeaderMatchesChecker) Hash() string {\n\treturn hmc.hash\n}\n\ntype PathChecker struct {\n\tregexp *regexp.Regexp\n\thash   string\n}\n\nfunc NewPathChecker(rexStr string) (checker.Impl, error) {\n\trex, err := regexp.Compile(strings.TrimSpace(rexStr))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"%w: regex %s failed parse: %w\", ErrMisconfiguration, rexStr, err)\n\t}\n\treturn &PathChecker{rex, internal.FastHash(rexStr)}, nil\n}\n\nfunc (pc *PathChecker) Check(r *http.Request) (bool, error) {\n\toriginalUrl := r.Header.Get(\"X-Original-URI\")\n\tif originalUrl != \"\" {\n\t\tif pc.regexp.MatchString(originalUrl) {\n\t\t\treturn true, nil\n\t\t}\n\t}\n\n\tif pc.regexp.MatchString(r.URL.Path) {\n\t\treturn true, nil\n\t}\n\n\treturn false, nil\n}\n\nfunc (pc *PathChecker) Hash() string {\n\treturn pc.hash\n}\n\nfunc NewHeaderExistsChecker(key string) checker.Impl {\n\treturn headerExistsChecker{strings.TrimSpace(key)}\n}\n\ntype headerExistsChecker struct {\n\theader string\n}\n\nfunc (hec headerExistsChecker) Check(r *http.Request) (bool, error) {\n\tif r.Header.Get(hec.header) != \"\" {\n\t\treturn true, nil\n\t}\n\n\treturn false, nil\n}\n\nfunc (hec headerExistsChecker) Hash() string {\n\treturn internal.FastHash(hec.header)\n}\n\nfunc NewHeadersChecker(headermap map[string]string) (checker.Impl, error) {\n\tvar result checker.List\n\tvar errs []error\n\n\tfor key, rexStr := range headermap {\n\t\tif rexStr == \".*\" {\n\t\t\tresult = append(result, headerExistsChecker{strings.TrimSpace(key)})\n\t\t\tcontinue\n\t\t}\n\n\t\trex, err := regexp.Compile(strings.TrimSpace(rexStr))\n\t\tif err != nil {\n\t\t\terrs = append(errs, fmt.Errorf(\"while compiling header %s regex %s: %w\", key, rexStr, err))\n\t\t\tcontinue\n\t\t}\n\n\t\tresult = append(result, &HeaderMatchesChecker{key, rex, internal.FastHash(key + \": \" + rexStr)})\n\t}\n\n\tif len(errs) != 0 {\n\t\treturn nil, errors.Join(errs...)\n\t}\n\n\treturn result, nil\n}\n"
  },
  {
    "path": "lib/policy/checker_test.go",
    "content": "package policy\n\nimport (\n\t\"errors\"\n\t\"net/http\"\n\t\"testing\"\n)\n\nfunc TestRemoteAddrChecker(t *testing.T) {\n\tfor _, tt := range []struct {\n\t\terr   error\n\t\tname  string\n\t\tip    string\n\t\tcidrs []string\n\t\tok    bool\n\t}{\n\t\t{\n\t\t\tname:  \"match_ipv4\",\n\t\t\tcidrs: []string{\"0.0.0.0/0\"},\n\t\t\tip:    \"1.1.1.1\",\n\t\t\tok:    true,\n\t\t\terr:   nil,\n\t\t},\n\t\t{\n\t\t\tname:  \"match_ipv4_in_ipv6\",\n\t\t\tcidrs: []string{\"0.0.0.0/0\"},\n\t\t\tip:    \"::ffff:1.1.1.1\",\n\t\t\tok:    true,\n\t\t\terr:   nil,\n\t\t},\n\t\t{\n\t\t\tname:  \"match_ipv4_in_ipv6_hex\",\n\t\t\tcidrs: []string{\"0.0.0.0/0\"},\n\t\t\tip:    \"::ffff:101:101\",\n\t\t\tok:    true,\n\t\t\terr:   nil,\n\t\t},\n\t\t{\n\t\t\tname:  \"match_ipv6\",\n\t\t\tcidrs: []string{\"::/0\"},\n\t\t\tip:    \"cafe:babe::\",\n\t\t\tok:    true,\n\t\t\terr:   nil,\n\t\t},\n\t\t{\n\t\t\tname:  \"not_match_ipv4\",\n\t\t\tcidrs: []string{\"1.1.1.1/32\"},\n\t\t\tip:    \"1.1.1.2\",\n\t\t\tok:    false,\n\t\t\terr:   nil,\n\t\t},\n\t\t{\n\t\t\tname:  \"not_match_ipv6\",\n\t\t\tcidrs: []string{\"cafe:babe::/128\"},\n\t\t\tip:    \"cafe:babe:4::/128\",\n\t\t\tok:    false,\n\t\t\terr:   nil,\n\t\t},\n\t\t{\n\t\t\tname:  \"no_ip_set\",\n\t\t\tcidrs: []string{\"::/0\"},\n\t\t\tok:    false,\n\t\t\terr:   ErrMisconfiguration,\n\t\t},\n\t\t{\n\t\t\tname:  \"invalid_ip\",\n\t\t\tcidrs: []string{\"::/0\"},\n\t\t\tip:    \"According to all natural laws of aviation\",\n\t\t\tok:    false,\n\t\t\terr:   ErrMisconfiguration,\n\t\t},\n\t} {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\trac, err := NewRemoteAddrChecker(tt.cidrs)\n\t\t\tif err != nil && !errors.Is(err, tt.err) {\n\t\t\t\tt.Fatalf(\"creating RemoteAddrChecker failed: %v\", err)\n\t\t\t}\n\n\t\t\tr, err := http.NewRequest(http.MethodGet, \"/\", nil)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"can't make request: %v\", err)\n\t\t\t}\n\n\t\t\tif tt.ip != \"\" {\n\t\t\t\tr.Header.Add(\"X-Real-Ip\", tt.ip)\n\t\t\t}\n\n\t\t\tok, err := rac.Check(r)\n\n\t\t\tif tt.ok != ok {\n\t\t\t\tt.Errorf(\"ok: %v, wanted: %v\", ok, tt.ok)\n\t\t\t}\n\n\t\t\tif err != nil && tt.err != nil && !errors.Is(err, tt.err) {\n\t\t\t\tt.Errorf(\"err: %v, wanted: %v\", err, tt.err)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestHeaderMatchesChecker(t *testing.T) {\n\tfor _, tt := range []struct {\n\t\terr            error\n\t\tname           string\n\t\theader         string\n\t\trexStr         string\n\t\treqHeaderKey   string\n\t\treqHeaderValue string\n\t\tok             bool\n\t}{\n\t\t{\n\t\t\tname:           \"match\",\n\t\t\theader:         \"Cf-Worker\",\n\t\t\trexStr:         \".*\",\n\t\t\treqHeaderKey:   \"Cf-Worker\",\n\t\t\treqHeaderValue: \"true\",\n\t\t\tok:             true,\n\t\t\terr:            nil,\n\t\t},\n\t\t{\n\t\t\tname:           \"not_match\",\n\t\t\theader:         \"Cf-Worker\",\n\t\t\trexStr:         \"false\",\n\t\t\treqHeaderKey:   \"Cf-Worker\",\n\t\t\treqHeaderValue: \"true\",\n\t\t\tok:             false,\n\t\t\terr:            nil,\n\t\t},\n\t\t{\n\t\t\tname:           \"not_present\",\n\t\t\theader:         \"Cf-Worker\",\n\t\t\trexStr:         \"foobar\",\n\t\t\treqHeaderKey:   \"Something-Else\",\n\t\t\treqHeaderValue: \"true\",\n\t\t\tok:             false,\n\t\t\terr:            nil,\n\t\t},\n\t\t{\n\t\t\tname:   \"invalid_regex\",\n\t\t\trexStr: \"a(b\",\n\t\t\terr:    ErrMisconfiguration,\n\t\t},\n\t} {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\thmc, err := NewHeaderMatchesChecker(tt.header, tt.rexStr)\n\t\t\tif err != nil && !errors.Is(err, tt.err) {\n\t\t\t\tt.Fatalf(\"creating HeaderMatchesChecker failed\")\n\t\t\t}\n\n\t\t\tif tt.err != nil && hmc == nil {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tr, err := http.NewRequest(http.MethodGet, \"/\", nil)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"can't make request: %v\", err)\n\t\t\t}\n\n\t\t\tr.Header.Set(tt.reqHeaderKey, tt.reqHeaderValue)\n\n\t\t\tok, err := hmc.Check(r)\n\n\t\t\tif tt.ok != ok {\n\t\t\t\tt.Errorf(\"ok: %v, wanted: %v\", ok, tt.ok)\n\t\t\t}\n\n\t\t\tif err != nil && tt.err != nil && !errors.Is(err, tt.err) {\n\t\t\t\tt.Errorf(\"err: %v, wanted: %v\", err, tt.err)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestHeaderExistsChecker(t *testing.T) {\n\tfor _, tt := range []struct {\n\t\tname      string\n\t\theader    string\n\t\treqHeader string\n\t\tok        bool\n\t}{\n\t\t{\n\t\t\tname:      \"match\",\n\t\t\theader:    \"Authorization\",\n\t\t\treqHeader: \"Authorization\",\n\t\t\tok:        true,\n\t\t},\n\t\t{\n\t\t\tname:      \"not_match\",\n\t\t\theader:    \"Authorization\",\n\t\t\treqHeader: \"Authentication\",\n\t\t},\n\t} {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\thec := headerExistsChecker{tt.header}\n\n\t\t\tr, err := http.NewRequest(http.MethodGet, \"/\", nil)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"can't make request: %v\", err)\n\t\t\t}\n\n\t\t\tr.Header.Set(tt.reqHeader, \"hunter2\")\n\n\t\t\tok, err := hec.Check(r)\n\n\t\t\tif tt.ok != ok {\n\t\t\t\tt.Errorf(\"ok: %v, wanted: %v\", ok, tt.ok)\n\t\t\t}\n\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"err: %v\", err)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestPathChecker_XOriginalURI(t *testing.T) {\n\ttests := []struct {\n\t\tname          string\n\t\tregex         string\n\t\txOriginalURI  string\n\t\turlPath       string\n\t\theaderKey     string\n\t\texpectedMatch bool\n\t\texpectError   bool\n\t}{\n\t\t{\n\t\t\tname:          \"X-Original-URI matches regex (with trailing space - current typo)\",\n\t\t\tregex:         \"^/api/.*\",\n\t\t\txOriginalURI:  \"/api/users\",\n\t\t\turlPath:       \"/different/path\",\n\t\t\theaderKey:     \"X-Original-URI\",\n\t\t\texpectedMatch: true,\n\t\t\texpectError:   false,\n\t\t},\n\t\t{\n\t\t\tname:          \"X-Original-URI doesn't match, falls back to URL.Path\",\n\t\t\tregex:         \"^/admin/.*\",\n\t\t\txOriginalURI:  \"/api/users\",\n\t\t\turlPath:       \"/admin/dashboard\",\n\t\t\theaderKey:     \"X-Original-URI\",\n\t\t\texpectedMatch: true,\n\t\t\texpectError:   false,\n\t\t},\n\t\t{\n\t\t\tname:          \"Neither X-Original-URI nor URL.Path match\",\n\t\t\tregex:         \"^/admin/.*\",\n\t\t\txOriginalURI:  \"/api/users\",\n\t\t\turlPath:       \"/public/info\",\n\t\t\theaderKey:     \"X-Original-URI \",\n\t\t\texpectedMatch: false,\n\t\t\texpectError:   false,\n\t\t},\n\t\t{\n\t\t\tname:          \"Empty X-Original-URI, URL.Path matches\",\n\t\t\tregex:         \"^/static/.*\",\n\t\t\txOriginalURI:  \"\",\n\t\t\turlPath:       \"/static/css/style.css\",\n\t\t\theaderKey:     \"X-Original-URI\",\n\t\t\texpectedMatch: true,\n\t\t\texpectError:   false,\n\t\t},\n\t\t{\n\t\t\tname:          \"Complex regex matching X-Original-URI\",\n\t\t\tregex:         `^/api/v[0-9]+/(users|posts)/[0-9]+$`,\n\t\t\txOriginalURI:  \"/api/v1/users/123\",\n\t\t\turlPath:       \"/different\",\n\t\t\theaderKey:     \"X-Original-URI\",\n\t\t\texpectedMatch: true,\n\t\t\texpectError:   false,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\t// Create the PathChecker\n\t\t\tpc, err := NewPathChecker(tt.regex)\n\t\t\tif err != nil {\n\t\t\t\tif !tt.expectError {\n\t\t\t\t\tt.Fatalf(\"NewPathChecker() unexpected error: %v\", err)\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif tt.expectError {\n\t\t\t\tt.Fatal(\"NewPathChecker() expected error but got none\")\n\t\t\t}\n\n\t\t\treq, err := http.NewRequest(\"GET\", \"http://example.com\"+tt.urlPath, nil)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"Failed to create request: %v\", err)\n\t\t\t}\n\n\t\t\tif tt.xOriginalURI != \"\" {\n\t\t\t\treq.Header.Set(tt.headerKey, tt.xOriginalURI)\n\t\t\t}\n\n\t\t\tmatch, err := pc.Check(req)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"Check() unexpected error: %v\", err)\n\t\t\t}\n\n\t\t\tif match != tt.expectedMatch {\n\t\t\t\tt.Errorf(\"Check() = %v, want %v\", match, tt.expectedMatch)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "lib/policy/checkresult.go",
    "content": "package policy\n\nimport (\n\t\"log/slog\"\n\n\t\"github.com/TecharoHQ/anubis/lib/config\"\n)\n\ntype CheckResult struct {\n\tName   string\n\tRule   config.Rule\n\tWeight int\n}\n\nfunc (cr CheckResult) LogValue() slog.Value {\n\treturn slog.GroupValue(\n\t\tslog.String(\"name\", cr.Name),\n\t\tslog.String(\"rule\", string(cr.Rule)),\n\t\tslog.Int(\"weight\", cr.Weight),\n\t)\n}\n"
  },
  {
    "path": "lib/policy/expressions/README.md",
    "content": "# Expressions support\n\nThe 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.\n"
  },
  {
    "path": "lib/policy/expressions/environment.go",
    "content": "package expressions\n\nimport (\n\t\"math/rand/v2\"\n\t\"strings\"\n\n\t\"github.com/TecharoHQ/anubis/internal/dns\"\n\t\"github.com/google/cel-go/cel\"\n\t\"github.com/google/cel-go/common/types\"\n\t\"github.com/google/cel-go/common/types/ref\"\n\t\"github.com/google/cel-go/common/types/traits\"\n\t\"github.com/google/cel-go/ext\"\n)\n\n// BotEnvironment creates a new CEL environment, this is the set of\n// variables and functions that are passed into the CEL scope so that\n// Anubis can fail loudly and early when something is invalid instead\n// of blowing up at runtime.\nfunc BotEnvironment(dnsObj *dns.Dns) (*cel.Env, error) {\n\treturn New(\n\t\t// Variables exposed to CEL programs:\n\t\tcel.Variable(\"remoteAddress\", cel.StringType),\n\t\tcel.Variable(\"contentLength\", cel.IntType),\n\t\tcel.Variable(\"host\", cel.StringType),\n\t\tcel.Variable(\"method\", cel.StringType),\n\t\tcel.Variable(\"userAgent\", cel.StringType),\n\t\tcel.Variable(\"path\", cel.StringType),\n\t\tcel.Variable(\"query\", cel.MapType(cel.StringType, cel.StringType)),\n\t\tcel.Variable(\"headers\", cel.MapType(cel.StringType, cel.StringType)),\n\t\tcel.Variable(\"load_1m\", cel.DoubleType),\n\t\tcel.Variable(\"load_5m\", cel.DoubleType),\n\t\tcel.Variable(\"load_15m\", cel.DoubleType),\n\n\t\t// Bot-specific functions:\n\t\tcel.Function(\"missingHeader\",\n\t\t\tcel.Overload(\"missingHeader_map_string_string_string\",\n\t\t\t\t[]*cel.Type{cel.MapType(cel.StringType, cel.StringType), cel.StringType},\n\t\t\t\tcel.BoolType,\n\t\t\t\tcel.BinaryBinding(func(headers, key ref.Val) ref.Val {\n\t\t\t\t\t// Convert headers to a trait that supports Find\n\t\t\t\t\theadersMap, ok := headers.(traits.Indexer)\n\t\t\t\t\tif !ok {\n\t\t\t\t\t\treturn types.ValOrErr(headers, \"headers is not a map, but is %T\", headers)\n\t\t\t\t\t}\n\n\t\t\t\t\tkeyStr, ok := key.(types.String)\n\t\t\t\t\tif !ok {\n\t\t\t\t\t\treturn types.ValOrErr(key, \"key is not a string, but is %T\", key)\n\t\t\t\t\t}\n\n\t\t\t\t\tval := headersMap.Get(keyStr)\n\t\t\t\t\t// Check if the key is missing by testing for an error\n\t\t\t\t\tif types.IsError(val) {\n\t\t\t\t\t\treturn types.Bool(true) // header is missing\n\t\t\t\t\t}\n\t\t\t\t\treturn types.Bool(false) // header is present\n\t\t\t\t}),\n\t\t\t),\n\t\t),\n\n\t\tcel.Function(\"reverseDNS\",\n\t\t\tcel.Overload(\"reverseDNS_string_list_string\",\n\t\t\t\t[]*cel.Type{cel.StringType},\n\t\t\t\tcel.ListType(cel.StringType),\n\t\t\t\tcel.UnaryBinding(func(addr ref.Val) ref.Val {\n\t\t\t\t\taddrStr, ok := addr.(types.String)\n\t\t\t\t\tif !ok {\n\t\t\t\t\t\treturn types.ValOrErr(addr, \"addr is not a string, but is %T\", addr)\n\t\t\t\t\t}\n\n\t\t\t\t\tnames, err := dnsObj.ReverseDNS(string(addrStr))\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn types.NewStringList(types.DefaultTypeAdapter, []string{})\n\t\t\t\t\t}\n\t\t\t\t\treturn types.NewStringList(types.DefaultTypeAdapter, names)\n\t\t\t\t}),\n\t\t\t),\n\t\t),\n\n\t\tcel.Function(\"lookupHost\",\n\t\t\tcel.Overload(\"lookupHost_string_list_string\",\n\t\t\t\t[]*cel.Type{cel.StringType},\n\t\t\t\tcel.ListType(cel.StringType),\n\t\t\t\tcel.UnaryBinding(func(host ref.Val) ref.Val {\n\t\t\t\t\thostStr, ok := host.(types.String)\n\t\t\t\t\tif !ok {\n\t\t\t\t\t\treturn types.ValOrErr(host, \"host is not a string, but is %T\", host)\n\t\t\t\t\t}\n\n\t\t\t\t\taddrs, err := dnsObj.LookupHost(string(hostStr))\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn types.NewStringList(types.DefaultTypeAdapter, []string{})\n\t\t\t\t\t}\n\t\t\t\t\treturn types.NewStringList(types.DefaultTypeAdapter, addrs)\n\t\t\t\t}),\n\t\t\t),\n\t\t),\n\n\t\tcel.Function(\"verifyFCrDNS\",\n\t\t\tcel.Overload(\"verifyFCrDNS_string_bool\",\n\t\t\t\t[]*cel.Type{cel.StringType},\n\t\t\t\tcel.BoolType,\n\t\t\t\tcel.UnaryBinding(func(addr ref.Val) ref.Val {\n\t\t\t\t\taddrStr, ok := addr.(types.String)\n\t\t\t\t\tif !ok {\n\t\t\t\t\t\treturn types.ValOrErr(addr, \"addr is not a string\")\n\t\t\t\t\t}\n\t\t\t\t\treturn types.Bool(dnsObj.VerifyFCrDNS(string(addrStr), nil))\n\t\t\t\t}),\n\t\t\t),\n\t\t\tcel.Overload(\"verifyFCrDNS_string_string_bool\",\n\t\t\t\t[]*cel.Type{cel.StringType, cel.StringType},\n\t\t\t\tcel.BoolType,\n\t\t\t\tcel.BinaryBinding(func(addr, pattern ref.Val) ref.Val {\n\t\t\t\t\taddrStr, ok := addr.(types.String)\n\t\t\t\t\tif !ok {\n\t\t\t\t\t\treturn types.ValOrErr(addr, \"addr is not a string\")\n\t\t\t\t\t}\n\t\t\t\t\tpatternStr, ok := pattern.(types.String)\n\t\t\t\t\tif !ok {\n\t\t\t\t\t\treturn types.ValOrErr(pattern, \"pattern is not a string\")\n\t\t\t\t\t}\n\t\t\t\t\tp := string(patternStr)\n\t\t\t\t\treturn types.Bool(dnsObj.VerifyFCrDNS(string(addrStr), &p))\n\t\t\t\t}),\n\t\t\t),\n\t\t),\n\n\t\t// arpaReverseIP transforms ip into arpa reverse notation like this\n\t\t// 1.2.3.4\t\t->\t4.3.2.1\n\t\t// 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\n\t\tcel.Function(\"arpaReverseIP\",\n\t\t\tcel.Overload(\"arpaReverseIP_string_string\",\n\t\t\t\t[]*cel.Type{cel.StringType},\n\t\t\t\tcel.StringType,\n\t\t\t\tcel.UnaryBinding(func(addr ref.Val) ref.Val {\n\t\t\t\t\ts, ok := addr.(types.String)\n\t\t\t\t\tif !ok {\n\t\t\t\t\t\treturn types.ValOrErr(addr, \"addr is not a string\")\n\t\t\t\t\t}\n\n\t\t\t\t\treversedIp, err := dnsObj.ArpaReverseIP(string(s))\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn types.ValOrErr(addr, \"%s\", err.Error())\n\t\t\t\t\t}\n\t\t\t\t\treturn types.String(reversedIp)\n\t\t\t\t}),\n\t\t\t),\n\t\t),\n\n\t\t// regexSafe escapes a string for insertion into a regular expression\n\t\tcel.Function(\"regexSafe\",\n\t\t\tcel.Overload(\"regexSafe_string_string\",\n\t\t\t\t[]*cel.Type{cel.StringType},\n\t\t\t\tcel.StringType,\n\t\t\t\tcel.UnaryBinding(func(str ref.Val) ref.Val {\n\t\t\t\t\ts, ok := str.(types.String)\n\t\t\t\t\tif !ok {\n\t\t\t\t\t\treturn types.ValOrErr(str, \"addr is not a string\")\n\t\t\t\t\t}\n\n\t\t\t\t\tescapes := []string{\"\\\\\", \".\", \":\", \"*\", \"?\", \"-\", \"[\", \"]\", \"(\", \")\", \"+\", \"{\", \"}\", \"|\", \"^\", \"$\"}\n\t\t\t\t\tr := string(s)\n\n\t\t\t\t\tfor _, escape := range escapes {\n\t\t\t\t\t\tr = strings.ReplaceAll(r, escape, \"\\\\\"+escape)\n\t\t\t\t\t}\n\t\t\t\t\treturn types.String(r)\n\t\t\t\t}),\n\t\t\t),\n\t\t),\n\n\t\tcel.Function(\"segments\",\n\t\t\tcel.Overload(\"segments_string_list_string\",\n\t\t\t\t[]*cel.Type{cel.StringType},\n\t\t\t\tcel.ListType(cel.StringType),\n\t\t\t\tcel.UnaryBinding(func(path ref.Val) ref.Val {\n\t\t\t\t\tpathStrType, ok := path.(types.String)\n\t\t\t\t\tif !ok {\n\t\t\t\t\t\treturn types.ValOrErr(path, \"path is not a string, but is %T\", path)\n\t\t\t\t\t}\n\n\t\t\t\t\tpathStr := string(pathStrType)\n\t\t\t\t\tif !strings.HasPrefix(pathStr, \"/\") {\n\t\t\t\t\t\treturn types.ValOrErr(path, \"path does not start with /\")\n\t\t\t\t\t}\n\n\t\t\t\t\tpathList := strings.Split(string(pathStr), \"/\")[1:]\n\n\t\t\t\t\treturn types.NewStringList(types.DefaultTypeAdapter, pathList)\n\t\t\t\t}),\n\t\t\t),\n\t\t),\n\t)\n}\n\n// NewThreshold creates a new CEL environment for threshold checking.\nfunc ThresholdEnvironment() (*cel.Env, error) {\n\treturn New(\n\t\tcel.Variable(\"weight\", cel.IntType),\n\t)\n}\n\nfunc New(opts ...cel.EnvOption) (*cel.Env, error) {\n\targs := []cel.EnvOption{\n\t\text.Strings(\n\t\t\text.StringsLocale(\"en_US\"),\n\t\t\text.StringsValidateFormatCalls(true),\n\t\t),\n\n\t\t// default all timestamps to UTC\n\t\tcel.DefaultUTCTimeZone(true),\n\n\t\t// Functions exposed to all CEL programs:\n\t\tcel.Function(\"randInt\",\n\t\t\tcel.Overload(\"randInt_int\",\n\t\t\t\t[]*cel.Type{cel.IntType},\n\t\t\t\tcel.IntType,\n\t\t\t\tcel.UnaryBinding(func(val ref.Val) ref.Val {\n\t\t\t\t\tn, ok := val.(types.Int)\n\t\t\t\t\tif !ok {\n\t\t\t\t\t\treturn types.ValOrErr(val, \"value is not an integer, but is %T\", val)\n\t\t\t\t\t}\n\n\t\t\t\t\treturn types.Int(rand.IntN(int(n)))\n\t\t\t\t}),\n\t\t\t),\n\t\t),\n\t}\n\n\targs = append(args, opts...)\n\treturn cel.NewEnv(args...)\n}\n\n// Compile takes CEL environment and syntax tree then emits an optimized\n// Program for execution.\nfunc Compile(env *cel.Env, src string) (cel.Program, error) {\n\tintermediate, iss := env.Compile(src)\n\tif iss != nil {\n\t\treturn nil, iss.Err()\n\t}\n\n\tast, iss := env.Check(intermediate)\n\tif iss != nil {\n\t\treturn nil, iss.Err()\n\t}\n\n\treturn env.Program(\n\t\tast,\n\t\tcel.EvalOptions(\n\t\t\t// optimize regular expressions right now instead of on the fly\n\t\t\tcel.OptOptimize,\n\t\t),\n\t)\n}\n"
  },
  {
    "path": "lib/policy/expressions/environment_test.go",
    "content": "package expressions\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"net\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/TecharoHQ/anubis/internal/dns\"\n\t\"github.com/TecharoHQ/anubis/lib/store/memory\"\n\t\"github.com/google/cel-go/common/types\"\n\t\"github.com/google/cel-go/common/types/ref\"\n)\n\n// newTestDNS is a helper function to create a new Dns object with an in-memory cache for testing.\nfunc newTestDNS(forwardTTL int, reverseTTL int) *dns.Dns {\n\tctx := context.Background()\n\tmemStore := memory.New(ctx)\n\tcache := dns.NewDNSCache(forwardTTL, reverseTTL, memStore)\n\treturn dns.New(ctx, cache)\n}\n\nfunc TestBotEnvironment(t *testing.T) {\n\tdnsObj := newTestDNS(300, 300)\n\tenv, err := BotEnvironment(dnsObj)\n\tif err != nil {\n\t\tt.Fatalf(\"failed to create bot environment: %v\", err)\n\t}\n\n\tt.Run(\"missingHeader\", func(t *testing.T) {\n\t\ttests := []struct {\n\t\t\theaders     map[string]string\n\t\t\tname        string\n\t\t\texpression  string\n\t\t\tdescription string\n\t\t\texpected    types.Bool\n\t\t}{\n\t\t\t{\n\t\t\t\tname:       \"missing-header\",\n\t\t\t\texpression: `missingHeader(headers, \"Missing-Header\")`,\n\t\t\t\theaders: map[string]string{\n\t\t\t\t\t\"User-Agent\":   \"test-agent\",\n\t\t\t\t\t\"Content-Type\": \"application/json\",\n\t\t\t\t},\n\t\t\t\texpected:    types.Bool(true),\n\t\t\t\tdescription: \"should return true when header is missing\",\n\t\t\t},\n\t\t\t{\n\t\t\t\tname:       \"existing-header\",\n\t\t\t\texpression: `missingHeader(headers, \"User-Agent\")`,\n\t\t\t\theaders: map[string]string{\n\t\t\t\t\t\"User-Agent\":   \"test-agent\",\n\t\t\t\t\t\"Content-Type\": \"application/json\",\n\t\t\t\t},\n\t\t\t\texpected:    types.Bool(false),\n\t\t\t\tdescription: \"should return false when header exists\",\n\t\t\t},\n\t\t\t{\n\t\t\t\tname:       \"case-sensitive\",\n\t\t\t\texpression: `missingHeader(headers, \"user-agent\")`,\n\t\t\t\theaders: map[string]string{\n\t\t\t\t\t\"User-Agent\": \"test-agent\",\n\t\t\t\t},\n\t\t\t\texpected:    types.Bool(true),\n\t\t\t\tdescription: \"should be case-sensitive (user-agent != User-Agent)\",\n\t\t\t},\n\t\t\t{\n\t\t\t\tname:        \"empty-headers\",\n\t\t\t\texpression:  `missingHeader(headers, \"Any-Header\")`,\n\t\t\t\theaders:     map[string]string{},\n\t\t\t\texpected:    types.Bool(true),\n\t\t\t\tdescription: \"should return true for any header when map is empty\",\n\t\t\t},\n\t\t\t{\n\t\t\t\tname:       \"real-world-sec-ch-ua\",\n\t\t\t\texpression: `missingHeader(headers, \"Sec-Ch-Ua\")`,\n\t\t\t\theaders: map[string]string{\n\t\t\t\t\t\"User-Agent\": \"curl/7.68.0\",\n\t\t\t\t\t\"Accept\":     \"*/*\",\n\t\t\t\t\t\"Host\":       \"example.com\",\n\t\t\t\t},\n\t\t\t\texpected:    types.Bool(true),\n\t\t\t\tdescription: \"should detect missing browser-specific headers from bots\",\n\t\t\t},\n\t\t\t{\n\t\t\t\tname:       \"browser-with-sec-ch-ua\",\n\t\t\t\texpression: `missingHeader(headers, \"Sec-Ch-Ua\")`,\n\t\t\t\theaders: map[string]string{\n\t\t\t\t\t\"User-Agent\": \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36\",\n\t\t\t\t\t\"Sec-Ch-Ua\":  `\"Chrome\"; v=\"91\", \"Not A Brand\"; v=\"99\"`,\n\t\t\t\t\t\"Accept\":     \"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8\",\n\t\t\t\t},\n\t\t\t\texpected:    types.Bool(false),\n\t\t\t\tdescription: \"should return false when browser sends Sec-Ch-Ua header\",\n\t\t\t},\n\t\t}\n\n\t\tfor _, tt := range tests {\n\t\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\t\tprog, err := Compile(env, tt.expression)\n\t\t\t\tif err != nil {\n\t\t\t\t\tt.Fatalf(\"failed to compile expression %q: %v\", tt.expression, err)\n\t\t\t\t}\n\n\t\t\t\tresult, _, err := prog.Eval(map[string]any{\n\t\t\t\t\t\"headers\": tt.headers,\n\t\t\t\t})\n\t\t\t\tif err != nil {\n\t\t\t\t\tt.Fatalf(\"failed to evaluate expression %q: %v\", tt.expression, err)\n\t\t\t\t}\n\n\t\t\t\tif result != tt.expected {\n\t\t\t\t\tt.Errorf(\"%s: expected %v, got %v\", tt.description, tt.expected, result)\n\t\t\t\t}\n\t\t\t})\n\t\t}\n\n\t\tt.Run(\"function-compilation\", func(t *testing.T) {\n\t\t\tsrc := `missingHeader(headers, \"Test-Header\")`\n\t\t\t_, err := Compile(env, src)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"failed to compile missingHeader expression: %v\", err)\n\t\t\t}\n\t\t})\n\t})\n\n\tt.Run(\"segments\", func(t *testing.T) {\n\t\tfor _, tt := range []struct {\n\t\t\tname        string\n\t\t\tdescription string\n\t\t\texpression  string\n\t\t\tpath        string\n\t\t\texpected    types.Bool\n\t\t}{\n\t\t\t{\n\t\t\t\tname:        \"simple\",\n\t\t\t\tdescription: \"/ should have one path segment\",\n\t\t\t\texpression:  `size(segments(path)) == 1`,\n\t\t\t\tpath:        \"/\",\n\t\t\t\texpected:    types.Bool(true),\n\t\t\t},\n\t\t\t{\n\t\t\t\tname:        \"two segments without trailing slash\",\n\t\t\t\tdescription: \"/user/foo should have two segments\",\n\t\t\t\texpression:  `size(segments(path)) == 2`,\n\t\t\t\tpath:        \"/user/foo\",\n\t\t\t\texpected:    types.Bool(true),\n\t\t\t},\n\t\t\t{\n\t\t\t\tname:        \"at least two segments\",\n\t\t\t\tdescription: \"/foo/bar/ should have at least two path segments\",\n\t\t\t\texpression:  `size(segments(path)) >= 2`,\n\t\t\t\tpath:        \"/foo/bar/\",\n\t\t\t\texpected:    types.Bool(true),\n\t\t\t},\n\t\t\t{\n\t\t\t\tname:        \"at most two segments\",\n\t\t\t\tdescription: \"/foo/bar/ does not have less than two path segments\",\n\t\t\t\texpression:  `size(segments(path)) < 2`,\n\t\t\t\tpath:        \"/foo/bar/\",\n\t\t\t\texpected:    types.Bool(false),\n\t\t\t},\n\t\t} {\n\t\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\t\tprog, err := Compile(env, tt.expression)\n\t\t\t\tif err != nil {\n\t\t\t\t\tt.Fatalf(\"failed to compile expression %q: %v\", tt.expression, err)\n\t\t\t\t}\n\n\t\t\t\tresult, _, err := prog.Eval(map[string]any{\n\t\t\t\t\t\"path\": tt.path,\n\t\t\t\t})\n\t\t\t\tif err != nil {\n\t\t\t\t\tt.Fatalf(\"failed to evaluate expression %q: %v\", tt.expression, err)\n\t\t\t\t}\n\n\t\t\t\tif result != tt.expected {\n\t\t\t\t\tt.Errorf(\"%s: expected %v, got %v\", tt.description, tt.expected, result)\n\t\t\t\t}\n\t\t\t})\n\t\t}\n\n\t\tt.Run(\"invalid\", func(t *testing.T) {\n\t\t\tfor _, tt := range []struct {\n\t\t\t\tenv             any\n\t\t\t\tname            string\n\t\t\t\tdescription     string\n\t\t\t\texpression      string\n\t\t\t\twantFailCompile bool\n\t\t\t\twantFailEval    bool\n\t\t\t}{\n\t\t\t\t{\n\t\t\t\t\tname:        \"segments of headers\",\n\t\t\t\t\tdescription: \"headers are not a path list\",\n\t\t\t\t\texpression:  `segments(headers)`,\n\t\t\t\t\tenv: map[string]any{\n\t\t\t\t\t\t\"headers\": map[string]string{\n\t\t\t\t\t\t\t\"foo\": \"bar\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\twantFailCompile: true,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname:        \"invalid path type\",\n\t\t\t\t\tdescription: \"a path should be a sting\",\n\t\t\t\t\texpression:  `size(segments(path)) != 0`,\n\t\t\t\t\tenv: map[string]any{\n\t\t\t\t\t\t\"path\": 4,\n\t\t\t\t\t},\n\t\t\t\t\twantFailEval: true,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname:        \"invalid path\",\n\t\t\t\t\tdescription: \"a path should start with a leading slash\",\n\t\t\t\t\texpression:  `size(segments(path)) != 0`,\n\t\t\t\t\tenv: map[string]any{\n\t\t\t\t\t\t\"path\": \"foo\",\n\t\t\t\t\t},\n\t\t\t\t\twantFailEval: true,\n\t\t\t\t},\n\t\t\t} {\n\t\t\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\t\t\tprog, err := Compile(env, tt.expression)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\tif !tt.wantFailCompile {\n\t\t\t\t\t\t\tt.Log(tt.description)\n\t\t\t\t\t\t\tt.Fatalf(\"failed to compile expression %q: %v\", tt.expression, err)\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\treturn\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\n\t\t\t\t\t_, _, err = prog.Eval(tt.env)\n\n\t\t\t\t\tif err == nil {\n\t\t\t\t\t\tt.Log(tt.description)\n\t\t\t\t\t\tt.Fatal(\"wanted an error but got none\")\n\t\t\t\t\t}\n\n\t\t\t\t\tt.Log(err)\n\t\t\t\t})\n\t\t\t}\n\t\t})\n\n\t\tt.Run(\"function-compilation\", func(t *testing.T) {\n\t\t\tsrc := `size(segments(path)) <= 2`\n\t\t\t_, err := Compile(env, src)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"failed to compile missingHeader expression: %v\", err)\n\t\t\t}\n\t\t})\n\t})\n\n\tt.Run(\"regexSafe\", func(t *testing.T) {\n\t\ttests := []struct {\n\t\t\tname        string\n\t\t\texpression  string\n\t\t\texpected    types.String\n\t\t\tdescription string\n\t\t}{\n\t\t\t{\n\t\t\t\tname:        \"complex-test\",\n\t\t\t\texpression:  `regexSafe(\"^(test1|test2|)[a-z]+$\")`,\n\t\t\t\texpected:    types.String(\"\\\\^\\\\(test1\\\\|test2\\\\|\\\\)\\\\[a\\\\-z\\\\]\\\\+\\\\$\"),\n\t\t\t\tdescription: \"should escape all reserved regex characters\",\n\t\t\t},\n\t\t\t{\n\t\t\t\tname:        \"backslash-test\",\n\t\t\t\texpression:  `regexSafe(\"use \\\\\\\\ for special characters escaping\\t, one/\\\"\\\\\\\"/for/cel and one/for/regex\")`,\n\t\t\t\texpected:    types.String(\"use \\\\\\\\\\\\\\\\ for special characters escaping\\t, one/\\\"\\\\\\\\\\\"/for/cel and one/for/regex\"),\n\t\t\t\tdescription: \"should escape double-backslashes as double-double-backslashes and ignore cel escaping and forward slashes\",\n\t\t\t},\n\t\t}\n\n\t\tfor _, tt := range tests {\n\t\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\t\tprog, err := Compile(env, tt.expression)\n\t\t\t\tif err != nil {\n\t\t\t\t\tt.Fatalf(\"failed to compile expression %q: %v\", tt.expression, err)\n\t\t\t\t}\n\n\t\t\t\tresult, _, err := prog.Eval(map[string]any{})\n\t\t\t\tif err != nil {\n\t\t\t\t\tt.Fatalf(\"failed to evaluate expression %q: %v\", tt.expression, err)\n\t\t\t\t}\n\n\t\t\t\tif result != tt.expected {\n\t\t\t\t\tt.Errorf(\"%s: expected %v, got %v\", tt.description, tt.expected, result)\n\t\t\t\t}\n\t\t\t})\n\t\t}\n\n\t\tt.Run(\"function-compilation\", func(t *testing.T) {\n\t\t\tsrc := `regexSafe(\".*\")`\n\t\t\t_, err := Compile(env, src)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"failed to compile regexSafe expression: %v\", err)\n\t\t\t}\n\t\t})\n\t})\n\n\tt.Run(\"dnsFunctions\", func(t *testing.T) {\n\t\toriginalDNSLookupAddr := dns.DNSLookupAddr\n\t\toriginalDNSLookupHost := dns.DNSLookupHost\n\t\tdefer func() {\n\t\t\tdns.DNSLookupAddr = originalDNSLookupAddr\n\t\t\tdns.DNSLookupHost = originalDNSLookupHost\n\t\t}()\n\n\t\tt.Run(\"reverseDNS\", func(t *testing.T) {\n\t\t\ttests := []struct {\n\t\t\t\tname        string\n\t\t\t\taddr        string\n\t\t\t\tmockReturn  []string\n\t\t\t\tmockError   error\n\t\t\t\texpression  string\n\t\t\t\texpected    ref.Val\n\t\t\t\tdescription string\n\t\t\t}{\n\t\t\t\t{\n\t\t\t\t\tname:        \"success\",\n\t\t\t\t\taddr:        \"8.8.8.8\",\n\t\t\t\t\tmockReturn:  []string{\"dns.google.\"},\n\t\t\t\t\texpression:  `reverseDNS(\"8.8.8.8\")`,\n\t\t\t\t\texpected:    types.NewStringList(types.DefaultTypeAdapter, []string{\"dns.google\"}),\n\t\t\t\t\tdescription: \"should return domain names for an IP\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname:        \"not-found\",\n\t\t\t\t\taddr:        \"127.0.0.1\",\n\t\t\t\t\tmockReturn:  []string{},\n\t\t\t\t\tmockError:   &net.DNSError{IsNotFound: true},\n\t\t\t\t\texpression:  `reverseDNS(\"127.0.0.1\")`,\n\t\t\t\t\texpected:    types.NewStringList(types.DefaultTypeAdapter, []string{}),\n\t\t\t\t\tdescription: \"should return an empty list when not found\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname:        \"error\",\n\t\t\t\t\taddr:        \"error-addr\",\n\t\t\t\t\tmockError:   errors.New(\"some dns error\"),\n\t\t\t\t\texpression:  `reverseDNS(\"error-addr\")`,\n\t\t\t\t\texpected:    types.NewStringList(types.DefaultTypeAdapter, []string{}),\n\t\t\t\t\tdescription: \"should return empty list on error\",\n\t\t\t\t},\n\t\t\t}\n\n\t\t\tfor _, tt := range tests {\n\t\t\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\t\t\tdns.DNSLookupAddr = func(addr string) ([]string, error) {\n\t\t\t\t\t\tif addr == tt.addr {\n\t\t\t\t\t\t\treturn tt.mockReturn, tt.mockError\n\t\t\t\t\t\t}\n\t\t\t\t\t\treturn nil, errors.New(\"unexpected address for reverse lookup\")\n\t\t\t\t\t}\n\n\t\t\t\t\tprog, err := Compile(env, tt.expression)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\tt.Fatalf(\"failed to compile expression %q: %v\", tt.expression, err)\n\t\t\t\t\t}\n\n\t\t\t\t\tresult, _, err := prog.Eval(map[string]any{})\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\tt.Fatalf(\"failed to evaluate expression %q: %v\", tt.expression, err)\n\t\t\t\t\t}\n\t\t\t\t\tif result.Equal(tt.expected) != types.True {\n\t\t\t\t\t\tt.Errorf(\"%s: expected %v, got %v\", tt.description, tt.expected, result)\n\t\t\t\t\t}\n\t\t\t\t})\n\t\t\t}\n\t\t})\n\n\t\tt.Run(\"lookupHost\", func(t *testing.T) {\n\t\t\ttests := []struct {\n\t\t\t\tname        string\n\t\t\t\thost        string\n\t\t\t\tmockReturn  []string\n\t\t\t\tmockError   error\n\t\t\t\texpression  string\n\t\t\t\texpected    ref.Val\n\t\t\t\tdescription string\n\t\t\t}{\n\t\t\t\t{\n\t\t\t\t\tname:        \"success\",\n\t\t\t\t\thost:        \"dns.google\",\n\t\t\t\t\tmockReturn:  []string{\"8.8.8.8\", \"8.8.4.4\"},\n\t\t\t\t\texpression:  `lookupHost(\"dns.google\")`,\n\t\t\t\t\texpected:    types.NewStringList(types.DefaultTypeAdapter, []string{\"8.8.8.8\", \"8.8.4.4\"}),\n\t\t\t\t\tdescription: \"should return IPs for a domain name\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname:        \"not-found\",\n\t\t\t\t\thost:        \"nonexistent.domain.example.com\",\n\t\t\t\t\tmockReturn:  []string{},\n\t\t\t\t\tmockError:   &net.DNSError{IsNotFound: true},\n\t\t\t\t\texpression:  `lookupHost(\"nonexistent.domain.example.com\")`,\n\t\t\t\t\texpected:    types.NewStringList(types.DefaultTypeAdapter, []string{}),\n\t\t\t\t\tdescription: \"should return an empty list when not found\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname:        \"error\",\n\t\t\t\t\thost:        \"error-host\",\n\t\t\t\t\tmockError:   errors.New(\"some dns error\"),\n\t\t\t\t\texpression:  `lookupHost(\"error-host\")`,\n\t\t\t\t\texpected:    types.NewStringList(types.DefaultTypeAdapter, []string{}),\n\t\t\t\t\tdescription: \"should return empty list on error\",\n\t\t\t\t},\n\t\t\t}\n\n\t\t\tfor _, tt := range tests {\n\t\t\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\t\t\tdns.DNSLookupHost = func(host string) ([]string, error) {\n\t\t\t\t\t\tif host == tt.host {\n\t\t\t\t\t\t\treturn tt.mockReturn, tt.mockError\n\t\t\t\t\t\t}\n\t\t\t\t\t\treturn nil, errors.New(\"unexpected host for forward lookup\")\n\t\t\t\t\t}\n\n\t\t\t\t\tprog, err := Compile(env, tt.expression)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\tt.Fatalf(\"failed to compile expression %q: %v\", tt.expression, err)\n\t\t\t\t\t}\n\n\t\t\t\t\tresult, _, err := prog.Eval(map[string]any{})\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\tt.Fatalf(\"failed to evaluate expression %q: %v\", tt.expression, err)\n\t\t\t\t\t}\n\t\t\t\t\tif result.Equal(tt.expected) != types.True {\n\t\t\t\t\t\tt.Errorf(\"%s: expected %v, got %v\", tt.description, tt.expected, result)\n\t\t\t\t\t}\n\t\t\t\t})\n\t\t\t}\n\t\t})\n\n\t\tt.Run(\"verifyFCrDNS\", func(t *testing.T) {\n\t\t\ttests := []struct {\n\t\t\t\tname              string\n\t\t\t\taddr              string\n\t\t\t\treverseMockReturn []string\n\t\t\t\treverseMockError  error\n\t\t\t\tforwardMockReturn map[string][]string // name -> ips\n\t\t\t\tforwardMockError  map[string]error\n\t\t\t\texpression        string\n\t\t\t\texpected          types.Bool\n\t\t\t\tdescription       string\n\t\t\t}{\n\t\t\t\t{\n\t\t\t\t\tname:              \"success\",\n\t\t\t\t\taddr:              \"8.8.8.8\",\n\t\t\t\t\treverseMockReturn: []string{\"dns.google.\"},\n\t\t\t\t\tforwardMockReturn: map[string][]string{\"dns.google\": {\"8.8.8.8\", \"8.8.4.4\"}},\n\t\t\t\t\texpression:        `verifyFCrDNS(\"8.8.8.8\")`,\n\t\t\t\t\texpected:          types.Bool(true),\n\t\t\t\t\tdescription:       \"should return true for valid FCrDNS\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname:              \"failure\",\n\t\t\t\t\taddr:              \"1.2.3.4\",\n\t\t\t\t\treverseMockReturn: []string{\"spoofed.example.com.\"},\n\t\t\t\t\tforwardMockReturn: map[string][]string{\"spoofed.example.com\": {\"5.6.7.8\"}},\n\t\t\t\t\texpression:        `verifyFCrDNS(\"1.2.3.4\")`,\n\t\t\t\t\texpected:          types.Bool(false),\n\t\t\t\t\tdescription:       \"should return false for invalid FCrDNS\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname:             \"reverse-lookup-fails\",\n\t\t\t\t\taddr:             \"1.1.1.1\",\n\t\t\t\t\treverseMockError: errors.New(\"reverse lookup failed\"),\n\t\t\t\t\texpression:       `verifyFCrDNS(\"1.1.1.1\")`,\n\t\t\t\t\texpected:         types.Bool(false),\n\t\t\t\t\tdescription:      \"should return false if reverse lookup fails\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname:              \"success-with-pattern\",\n\t\t\t\t\taddr:              \"8.8.8.8\",\n\t\t\t\t\treverseMockReturn: []string{\"dns.google.\"},\n\t\t\t\t\tforwardMockReturn: map[string][]string{\"dns.google\": {\"8.8.8.8\"}},\n\t\t\t\t\texpression:        `verifyFCrDNS(\"8.8.8.8\", \"dns.google\")`,\n\t\t\t\t\texpected:          types.Bool(true),\n\t\t\t\t\tdescription:       \"should return true for valid FCrDNS with matching pattern\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname:              \"failure-with-pattern\",\n\t\t\t\t\taddr:              \"8.8.8.8\",\n\t\t\t\t\treverseMockReturn: []string{\"dns.google.\"},\n\t\t\t\t\tforwardMockReturn: map[string][]string{\"dns.google\": {\"8.8.8.8\"}},\n\t\t\t\t\texpression:        `verifyFCrDNS(\"8.8.8.8\", \"wrong.pattern\")`,\n\t\t\t\t\texpected:          types.Bool(false),\n\t\t\t\t\tdescription:       \"should return false for FCrDNS with non-matching pattern\",\n\t\t\t\t},\n\t\t\t}\n\n\t\t\tfor _, tt := range tests {\n\t\t\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\t\t\tdns.DNSLookupAddr = func(addr string) ([]string, error) {\n\t\t\t\t\t\tif addr == tt.addr {\n\t\t\t\t\t\t\treturn tt.reverseMockReturn, tt.reverseMockError\n\t\t\t\t\t\t}\n\t\t\t\t\t\treturn nil, errors.New(\"unexpected address for reverse lookup\")\n\t\t\t\t\t}\n\t\t\t\t\tdns.DNSLookupHost = func(host string) ([]string, error) {\n\t\t\t\t\t\thost = strings.TrimSuffix(host, \".\")\n\t\t\t\t\t\tif ips, ok := tt.forwardMockReturn[host]; ok {\n\t\t\t\t\t\t\treturn ips, nil\n\t\t\t\t\t\t}\n\t\t\t\t\t\tif err, ok := tt.forwardMockError[host]; ok {\n\t\t\t\t\t\t\treturn nil, err\n\t\t\t\t\t\t}\n\t\t\t\t\t\treturn nil, &net.DNSError{IsNotFound: true}\n\t\t\t\t\t}\n\n\t\t\t\t\tprog, err := Compile(env, tt.expression)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\tt.Fatalf(\"failed to compile expression %q: %v\", tt.expression, err)\n\t\t\t\t\t}\n\n\t\t\t\t\tresult, _, err := prog.Eval(map[string]any{})\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\tt.Fatalf(\"failed to evaluate expression %q: %v\", tt.expression, err)\n\t\t\t\t\t}\n\t\t\t\t\tif result.Equal(tt.expected) != types.True {\n\t\t\t\t\t\tt.Errorf(\"%s: expected %v, got %v\", tt.description, tt.expected, result)\n\t\t\t\t\t}\n\t\t\t\t})\n\t\t\t}\n\t\t})\n\n\t\tt.Run(\"arpaReverseIP\", func(t *testing.T) {\n\t\t\ttests := []struct {\n\t\t\t\tname        string\n\t\t\t\texpression  string\n\t\t\t\texpected    types.String\n\t\t\t\tdescription string\n\t\t\t\tevalError   bool\n\t\t\t}{\n\t\t\t\t{\n\t\t\t\t\tname:        \"ipv4\",\n\t\t\t\t\texpression:  `arpaReverseIP(\"1.2.3.4\")`,\n\t\t\t\t\texpected:    types.String(\"4.3.2.1\"),\n\t\t\t\t\tdescription: \"should correctly reverse an IPv4 address\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname:        \"ipv6\",\n\t\t\t\t\texpression:  `arpaReverseIP(\"2001:db8::1\")`,\n\t\t\t\t\texpected:    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\"),\n\t\t\t\t\tdescription: \"should correctly reverse an IPv6 address\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname:        \"ipv6-full\",\n\t\t\t\t\texpression:  `arpaReverseIP(\"2001:0db8:85a3:0000:0000:8a2e:0370:7334\")`,\n\t\t\t\t\texpected:    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\"),\n\t\t\t\t\tdescription: \"should correctly reverse a fully expanded IPv6 address\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname:        \"ipv6-loopback\",\n\t\t\t\t\texpression:  `arpaReverseIP(\"::1\")`,\n\t\t\t\t\texpected:    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\"),\n\t\t\t\t\tdescription: \"should correctly reverse the IPv6 loopback address\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname:        \"invalid-ip\",\n\t\t\t\t\texpression:  `arpaReverseIP(\"not-an-ip\")`,\n\t\t\t\t\tevalError:   true,\n\t\t\t\t\tdescription: \"should error on an invalid IP\",\n\t\t\t\t},\n\t\t\t}\n\n\t\t\tfor _, tt := range tests {\n\t\t\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\t\t\tprog, err := Compile(env, tt.expression)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\tt.Fatalf(\"failed to compile expression %q: %v\", tt.expression, err)\n\t\t\t\t\t}\n\n\t\t\t\t\tresult, _, err := prog.Eval(map[string]any{})\n\t\t\t\t\tif tt.evalError {\n\t\t\t\t\t\tif err == nil {\n\t\t\t\t\t\t\tt.Errorf(\"%s: expected an evaluation error, but got none\", tt.description)\n\t\t\t\t\t\t}\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\tt.Fatalf(\"failed to evaluate expression %q: %v\", tt.expression, err)\n\t\t\t\t\t}\n\t\t\t\t\tif result.Equal(tt.expected) != types.True {\n\t\t\t\t\t\tt.Errorf(\"%s: expected %v, got %v\", tt.description, tt.expected, result)\n\t\t\t\t\t}\n\t\t\t\t})\n\t\t\t}\n\t\t})\n\t})\n}\n\nfunc TestThresholdEnvironment(t *testing.T) {\n\tenv, err := ThresholdEnvironment()\n\tif err != nil {\n\t\tt.Fatalf(\"failed to create threshold environment: %v\", err)\n\t}\n\n\ttests := []struct {\n\t\tvariables     map[string]any\n\t\tname          string\n\t\texpression    string\n\t\tdescription   string\n\t\texpected      types.Bool\n\t\tshouldCompile bool\n\t}{\n\t\t{\n\t\t\tname:          \"weight-variable-available\",\n\t\t\texpression:    `weight > 100`,\n\t\t\tvariables:     map[string]any{\"weight\": 150},\n\t\t\texpected:      types.Bool(true),\n\t\t\tdescription:   \"should support weight variable in expressions\",\n\t\t\tshouldCompile: true,\n\t\t},\n\t\t{\n\t\t\tname:          \"weight-variable-false-case\",\n\t\t\texpression:    `weight > 100`,\n\t\t\tvariables:     map[string]any{\"weight\": 50},\n\t\t\texpected:      types.Bool(false),\n\t\t\tdescription:   \"should correctly evaluate weight comparisons\",\n\t\t\tshouldCompile: true,\n\t\t},\n\t\t{\n\t\t\tname:          \"missingHeader-not-available\",\n\t\t\texpression:    `missingHeader(headers, \"Test\")`,\n\t\t\tvariables:     map[string]any{},\n\t\t\texpected:      types.Bool(false), // not used\n\t\t\tdescription:   \"should not have missingHeader function available\",\n\t\t\tshouldCompile: false,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tprog, err := Compile(env, tt.expression)\n\n\t\t\tif !tt.shouldCompile {\n\t\t\t\tif err == nil {\n\t\t\t\t\tt.Fatalf(\"%s: expected compilation to fail but it succeeded\", tt.description)\n\t\t\t\t}\n\t\t\t\treturn // Test passed - compilation failed as expected\n\t\t\t}\n\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"failed to compile expression %q: %v\", tt.expression, err)\n\t\t\t}\n\n\t\t\tresult, _, err := prog.Eval(tt.variables)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"failed to evaluate expression %q: %v\", tt.expression, err)\n\t\t\t}\n\n\t\t\tif result != tt.expected {\n\t\t\t\tt.Errorf(\"%s: expected %v, got %v\", tt.description, tt.expected, result)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestNewEnvironment(t *testing.T) {\n\tenv, err := New()\n\tif err != nil {\n\t\tt.Fatalf(\"failed to create new environment: %v\", err)\n\t}\n\n\ttests := []struct {\n\t\tname          string\n\t\texpression    string\n\t\tvariables     map[string]any\n\t\texpectBool    *bool // nil if we just want to test compilation or non-bool result\n\t\tdescription   string\n\t\tshouldCompile bool\n\t}{\n\t\t{\n\t\t\tname:          \"randInt-function-compilation\",\n\t\t\texpression:    `randInt(10)`,\n\t\t\tvariables:     map[string]any{},\n\t\t\texpectBool:    nil, // Don't check result, just compilation\n\t\t\tdescription:   \"should compile randInt function\",\n\t\t\tshouldCompile: true,\n\t\t},\n\t\t{\n\t\t\tname:          \"randInt-range-validation\",\n\t\t\texpression:    `randInt(10) >= 0 && randInt(10) < 10`,\n\t\t\tvariables:     map[string]any{},\n\t\t\texpectBool:    boolPtr(true),\n\t\t\tdescription:   \"should return values in correct range\",\n\t\t\tshouldCompile: true,\n\t\t},\n\t\t{\n\t\t\tname:          \"strings-extension-size\",\n\t\t\texpression:    `\"hello\".size() == 5`,\n\t\t\tvariables:     map[string]any{},\n\t\t\texpectBool:    boolPtr(true),\n\t\t\tdescription:   \"should support string extension functions\",\n\t\t\tshouldCompile: true,\n\t\t},\n\t\t{\n\t\t\tname:          \"strings-extension-contains\",\n\t\t\texpression:    `\"hello world\".contains(\"world\")`,\n\t\t\tvariables:     map[string]any{},\n\t\t\texpectBool:    boolPtr(true),\n\t\t\tdescription:   \"should support string contains function\",\n\t\t\tshouldCompile: true,\n\t\t},\n\t\t{\n\t\t\tname:          \"strings-extension-startsWith\",\n\t\t\texpression:    `\"hello world\".startsWith(\"hello\")`,\n\t\t\tvariables:     map[string]any{},\n\t\t\texpectBool:    boolPtr(true),\n\t\t\tdescription:   \"should support string startsWith function\",\n\t\t\tshouldCompile: true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tprog, err := Compile(env, tt.expression)\n\n\t\t\tif !tt.shouldCompile {\n\t\t\t\tif err == nil {\n\t\t\t\t\tt.Fatalf(\"%s: expected compilation to fail but it succeeded\", tt.description)\n\t\t\t\t}\n\t\t\t\treturn // Test passed - compilation failed as expected\n\t\t\t}\n\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"failed to compile expression %q: %v\", tt.expression, err)\n\t\t\t}\n\n\t\t\t// If we only want to test compilation, skip evaluation\n\t\t\tif tt.expectBool == nil {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tresult, _, err := prog.Eval(tt.variables)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"failed to evaluate expression %q: %v\", tt.expression, err)\n\t\t\t}\n\n\t\t\tif result != types.Bool(*tt.expectBool) {\n\t\t\t\tt.Errorf(\"%s: expected %v, got %v\", tt.description, *tt.expectBool, result)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// Helper function to create bool pointers\nfunc boolPtr(b bool) *bool {\n\treturn &b\n}\n"
  },
  {
    "path": "lib/policy/expressions/http_headers.go",
    "content": "package expressions\n\nimport (\n\t\"net/http\"\n\t\"reflect\"\n\t\"strings\"\n\n\t\"github.com/google/cel-go/common/types\"\n\t\"github.com/google/cel-go/common/types/ref\"\n\t\"github.com/google/cel-go/common/types/traits\"\n)\n\n// HTTPHeaders is a type wrapper to expose HTTP headers into CEL programs.\ntype HTTPHeaders struct {\n\thttp.Header\n}\n\nfunc (h HTTPHeaders) ConvertToNative(typeDesc reflect.Type) (any, error) {\n\treturn nil, ErrNotImplemented\n}\n\nfunc (h HTTPHeaders) ConvertToType(typeVal ref.Type) ref.Val {\n\tswitch typeVal {\n\tcase types.MapType:\n\t\treturn h\n\tcase types.TypeType:\n\t\treturn types.MapType\n\t}\n\n\treturn types.NewErr(\"can't convert from %q to %q\", types.MapType, typeVal)\n}\n\nfunc (h HTTPHeaders) Equal(other ref.Val) ref.Val {\n\treturn types.Bool(false) // We don't want to compare header maps\n}\n\nfunc (h HTTPHeaders) Type() ref.Type {\n\treturn types.MapType\n}\n\nfunc (h HTTPHeaders) Value() any { return h }\n\nfunc (h HTTPHeaders) Find(key ref.Val) (ref.Val, bool) {\n\tk, ok := key.(types.String)\n\tif !ok {\n\t\treturn nil, false\n\t}\n\n\tif _, ok := h.Header[string(k)]; !ok {\n\t\treturn nil, false\n\t}\n\n\treturn types.String(strings.Join(h.Header.Values(string(k)), \",\")), true\n}\n\nfunc (h HTTPHeaders) Contains(key ref.Val) ref.Val {\n\t_, ok := h.Find(key)\n\treturn types.Bool(ok)\n}\n\nfunc (h HTTPHeaders) Get(key ref.Val) ref.Val {\n\tresult, ok := h.Find(key)\n\tif !ok {\n\t\treturn types.ValOrErr(result, \"no such key: %v\", key)\n\t}\n\treturn result\n}\n\nfunc (h HTTPHeaders) Iterator() traits.Iterator { panic(\"TODO(Xe): implement me\") }\n\nfunc (h HTTPHeaders) IsZeroValue() bool {\n\treturn len(h.Header) == 0\n}\n\nfunc (h HTTPHeaders) Size() ref.Val { return types.Int(len(h.Header)) }\n"
  },
  {
    "path": "lib/policy/expressions/http_headers_test.go",
    "content": "package expressions\n\nimport (\n\t\"net/http\"\n\t\"testing\"\n\n\t\"github.com/google/cel-go/common/types\"\n)\n\nfunc TestHTTPHeaders(t *testing.T) {\n\theaders := HTTPHeaders{\n\t\tHeader: http.Header{\n\t\t\t\"Content-Type\": {\"application/json\"},\n\t\t\t\"Cf-Worker\":    {\"true\"},\n\t\t\t\"User-Agent\":   {\"Go-http-client/2\"},\n\t\t},\n\t}\n\n\tt.Run(\"contains-existing-header\", func(t *testing.T) {\n\t\tresp := headers.Contains(types.String(\"User-Agent\"))\n\t\tif !resp.(types.Bool) {\n\t\t\tt.Fatal(\"headers does not contain User-Agent\")\n\t\t}\n\t})\n\n\tt.Run(\"not-contains-missing-header\", func(t *testing.T) {\n\t\tresp := headers.Contains(types.String(\"Xxx-Random-Header\"))\n\t\tif resp.(types.Bool) {\n\t\t\tt.Fatal(\"headers does not contain User-Agent\")\n\t\t}\n\t})\n\n\tt.Run(\"get-existing-header\", func(t *testing.T) {\n\t\tval := headers.Get(types.String(\"User-Agent\"))\n\t\tswitch val.(type) {\n\t\tcase types.String:\n\t\t\t// ok\n\t\tdefault:\n\t\t\tt.Fatalf(\"result was wrong type %T\", val)\n\t\t}\n\t})\n\n\tt.Run(\"not-get-missing-header\", func(t *testing.T) {\n\t\tval := headers.Get(types.String(\"Xxx-Random-Header\"))\n\t\tswitch val.(type) {\n\t\tcase *types.Err:\n\t\t\t// ok\n\t\tdefault:\n\t\t\tt.Fatalf(\"result was wrong type %T\", val)\n\t\t}\n\t})\n}\n"
  },
  {
    "path": "lib/policy/expressions/loadavg.go",
    "content": "package expressions\n\nimport (\n\t\"context\"\n\t\"log/slog\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/shirou/gopsutil/v4/load\"\n)\n\ntype loadAvg struct {\n\tdata *load.AvgStat\n\tlock sync.RWMutex\n}\n\nfunc (l *loadAvg) updateThread(ctx context.Context) {\n\tticker := time.NewTicker(15 * time.Second)\n\tdefer ticker.Stop()\n\n\tl.update()\n\n\tfor {\n\t\tselect {\n\t\tcase <-ticker.C:\n\t\t\tl.update()\n\t\tcase <-ctx.Done():\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc (l *loadAvg) update() {\n\tl.lock.Lock()\n\tdefer l.lock.Unlock()\n\n\tvar err error\n\tl.data, err = load.Avg()\n\tif err != nil {\n\t\tslog.Debug(\"can't get load average\", \"err\", err)\n\t}\n}\n\nvar (\n\tglobalLoadAvg *loadAvg\n)\n\nfunc init() {\n\tglobalLoadAvg = &loadAvg{}\n\tgo globalLoadAvg.updateThread(context.Background())\n}\n\nfunc Load1() float64 {\n\tglobalLoadAvg.lock.RLock()\n\tdefer globalLoadAvg.lock.RUnlock()\n\treturn globalLoadAvg.data.Load1\n}\n\nfunc Load5() float64 {\n\tglobalLoadAvg.lock.RLock()\n\tdefer globalLoadAvg.lock.RUnlock()\n\treturn globalLoadAvg.data.Load5\n}\n\nfunc Load15() float64 {\n\tglobalLoadAvg.lock.RLock()\n\tdefer globalLoadAvg.lock.RUnlock()\n\treturn globalLoadAvg.data.Load15\n}\n"
  },
  {
    "path": "lib/policy/expressions/url_values.go",
    "content": "package expressions\n\nimport (\n\t\"errors\"\n\t\"net/url\"\n\t\"reflect\"\n\t\"strings\"\n\n\t\"github.com/google/cel-go/common/types\"\n\t\"github.com/google/cel-go/common/types/ref\"\n\t\"github.com/google/cel-go/common/types/traits\"\n)\n\nvar ErrNotImplemented = errors.New(\"expressions: not implemented\")\n\n// URLValues is a type wrapper to expose url.Values into CEL programs.\ntype URLValues struct {\n\turl.Values\n}\n\nfunc (u URLValues) ConvertToNative(typeDesc reflect.Type) (any, error) {\n\treturn nil, ErrNotImplemented\n}\n\nfunc (u URLValues) ConvertToType(typeVal ref.Type) ref.Val {\n\tswitch typeVal {\n\tcase types.MapType:\n\t\treturn u\n\tcase types.TypeType:\n\t\treturn types.MapType\n\t}\n\n\treturn types.NewErr(\"can't convert from %q to %q\", types.MapType, typeVal)\n}\n\nfunc (u URLValues) Equal(other ref.Val) ref.Val {\n\treturn types.Bool(false) // We don't want to compare header maps\n}\n\nfunc (u URLValues) Type() ref.Type {\n\treturn types.MapType\n}\n\nfunc (u URLValues) Value() any { return u }\n\nfunc (u URLValues) Find(key ref.Val) (ref.Val, bool) {\n\tk, ok := key.(types.String)\n\tif !ok {\n\t\treturn nil, false\n\t}\n\n\tif _, ok := u.Values[string(k)]; !ok {\n\t\treturn nil, false\n\t}\n\n\treturn types.String(strings.Join(u.Values[string(k)], \",\")), true\n}\n\nfunc (u URLValues) Contains(key ref.Val) ref.Val {\n\t_, ok := u.Find(key)\n\treturn types.Bool(ok)\n}\n\nfunc (u URLValues) Get(key ref.Val) ref.Val {\n\tresult, ok := u.Find(key)\n\tif !ok {\n\t\treturn types.ValOrErr(result, \"no such key: %v\", key)\n\t}\n\treturn result\n}\n\nfunc (u URLValues) Iterator() traits.Iterator { panic(\"TODO(Xe): implement me\") }\n\nfunc (u URLValues) IsZeroValue() bool {\n\treturn len(u.Values) == 0\n}\n\nfunc (u URLValues) Size() ref.Val { return types.Int(len(u.Values)) }\n"
  },
  {
    "path": "lib/policy/expressions/url_values_test.go",
    "content": "package expressions\n\nimport (\n\t\"net/url\"\n\t\"testing\"\n\n\t\"github.com/google/cel-go/common/types\"\n)\n\nfunc TestURLValues(t *testing.T) {\n\theaders := URLValues{\n\t\tValues: url.Values{\n\t\t\t\"format\": {\"json\"},\n\t\t},\n\t}\n\n\tt.Run(\"contains-existing-key\", func(t *testing.T) {\n\t\tresp := headers.Contains(types.String(\"format\"))\n\t\tif !resp.(types.Bool) {\n\t\t\tt.Fatal(\"headers does not contain User-Agent\")\n\t\t}\n\t})\n\n\tt.Run(\"not-contains-missing-key\", func(t *testing.T) {\n\t\tresp := headers.Contains(types.String(\"not-there\"))\n\t\tif resp.(types.Bool) {\n\t\t\tt.Fatal(\"headers does not contain User-Agent\")\n\t\t}\n\t})\n\n\tt.Run(\"get-existing-key\", func(t *testing.T) {\n\t\tval := headers.Get(types.String(\"format\"))\n\t\tswitch val.(type) {\n\t\tcase types.String:\n\t\t\t// ok\n\t\tdefault:\n\t\t\tt.Fatalf(\"result was wrong type %T\", val)\n\t\t}\n\t})\n\n\tt.Run(\"not-get-missing-key\", func(t *testing.T) {\n\t\tval := headers.Get(types.String(\"not-there\"))\n\t\tswitch val.(type) {\n\t\tcase *types.Err:\n\t\t\t// ok\n\t\tdefault:\n\t\t\tt.Fatalf(\"result was wrong type %T\", val)\n\t\t}\n\t})\n}\n"
  },
  {
    "path": "lib/policy/policy.go",
    "content": "package policy\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"log/slog\"\n\t\"os\"\n\t\"sync/atomic\"\n\t\"time\"\n\n\t\"github.com/TecharoHQ/anubis/internal\"\n\t\"github.com/TecharoHQ/anubis/internal/dns\"\n\t\"github.com/TecharoHQ/anubis/lib/config\"\n\t\"github.com/TecharoHQ/anubis/lib/policy/checker\"\n\t\"github.com/TecharoHQ/anubis/lib/store\"\n\t\"github.com/TecharoHQ/anubis/lib/thoth\"\n\t\"github.com/fahedouch/go-logrotate\"\n\t\"github.com/prometheus/client_golang/prometheus\"\n\t\"github.com/prometheus/client_golang/prometheus/promauto\"\n\n\t_ \"github.com/TecharoHQ/anubis/lib/store/all\"\n)\n\nvar (\n\tApplications = promauto.NewCounterVec(prometheus.CounterOpts{\n\t\tName: \"anubis_policy_results\",\n\t\tHelp: \"The results of each policy rule\",\n\t}, []string{\"rule\", \"action\"})\n\n\tErrChallengeRuleHasWrongAlgorithm = errors.New(\"config.Bot.ChallengeRules: algorithm is invalid\")\n\twarnedAboutThresholds             = &atomic.Bool{}\n)\n\ntype ParsedConfig struct {\n\tStore             store.Interface\n\torig              *config.Config\n\tImpressum         *config.Impressum\n\tOpenGraph         config.OpenGraph\n\tBots              []Bot\n\tThresholds        []*Threshold\n\tStatusCodes       config.StatusCodes\n\tDefaultDifficulty int\n\tDNSBL             bool\n\tDnsCache          *dns.DnsCache\n\tDns               *dns.Dns\n\tLogger            *slog.Logger\n}\n\nfunc newParsedConfig(orig *config.Config) *ParsedConfig {\n\treturn &ParsedConfig{\n\t\torig:        orig,\n\t\tOpenGraph:   orig.OpenGraph,\n\t\tStatusCodes: orig.StatusCodes,\n\t}\n}\n\nfunc ParseConfig(ctx context.Context, fin io.Reader, fname string, defaultDifficulty int, logLevel string) (*ParsedConfig, error) {\n\tc, err := config.Load(fin, fname)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar validationErrs []error\n\n\ttc, hasThothClient := thoth.FromContext(ctx)\n\n\tresult := newParsedConfig(c)\n\tresult.DefaultDifficulty = defaultDifficulty\n\n\tif c.Logging.Level != nil {\n\t\tlogLevel = c.Logging.Level.String()\n\t}\n\n\tswitch c.Logging.Sink {\n\tcase config.LogSinkStdio:\n\t\tresult.Logger = internal.InitSlog(logLevel, os.Stderr)\n\tcase config.LogSinkFile:\n\t\tout := &logrotate.Logger{\n\t\t\tFilename:           c.Logging.Parameters.Filename,\n\t\t\tFilenameTimeFormat: time.RFC3339,\n\t\t\tMaxBytes:           c.Logging.Parameters.MaxBytes,\n\t\t\tMaxAge:             c.Logging.Parameters.MaxAge,\n\t\t\tMaxBackups:         c.Logging.Parameters.MaxBackups,\n\t\t\tLocalTime:          c.Logging.Parameters.UseLocalTime,\n\t\t\tCompress:           c.Logging.Parameters.Compress,\n\t\t}\n\n\t\tresult.Logger = internal.InitSlog(logLevel, out)\n\t}\n\n\tlg := result.Logger.With(\"at\", \"config-validate\")\n\n\tstFac, ok := store.Get(c.Store.Backend)\n\tswitch ok {\n\tcase true:\n\t\tstore, err := stFac.Build(ctx, c.Store.Parameters)\n\t\tif err != nil {\n\t\t\tvalidationErrs = append(validationErrs, err)\n\t\t} else {\n\t\t\tresult.Store = store\n\t\t}\n\tcase false:\n\t\tvalidationErrs = append(validationErrs, config.ErrUnknownStoreBackend)\n\t}\n\n\tresult.DnsCache = dns.NewDNSCache(result.orig.DNSTTL.Forward, result.orig.DNSTTL.Reverse, result.Store)\n\tresult.Dns = dns.New(ctx, result.DnsCache)\n\n\tfor _, b := range c.Bots {\n\t\tif berr := b.Valid(); berr != nil {\n\t\t\tvalidationErrs = append(validationErrs, berr)\n\t\t\tcontinue\n\t\t}\n\n\t\tparsedBot := Bot{\n\t\t\tName:   b.Name,\n\t\t\tAction: b.Action,\n\t\t}\n\n\t\tcl := checker.List{}\n\n\t\tif len(b.RemoteAddr) > 0 {\n\t\t\tc, err := NewRemoteAddrChecker(b.RemoteAddr)\n\t\t\tif err != nil {\n\t\t\t\tvalidationErrs = append(validationErrs, fmt.Errorf(\"while processing rule %s remote addr set: %w\", b.Name, err))\n\t\t\t} else {\n\t\t\t\tcl = append(cl, c)\n\t\t\t}\n\t\t}\n\n\t\tif b.UserAgentRegex != nil {\n\t\t\tc, err := NewUserAgentChecker(*b.UserAgentRegex)\n\t\t\tif err != nil {\n\t\t\t\tvalidationErrs = append(validationErrs, fmt.Errorf(\"while processing rule %s user agent regex: %w\", b.Name, err))\n\t\t\t} else {\n\t\t\t\tcl = append(cl, c)\n\t\t\t}\n\t\t}\n\n\t\tif b.PathRegex != nil {\n\t\t\tc, err := NewPathChecker(*b.PathRegex)\n\t\t\tif err != nil {\n\t\t\t\tvalidationErrs = append(validationErrs, fmt.Errorf(\"while processing rule %s path regex: %w\", b.Name, err))\n\t\t\t} else {\n\t\t\t\tcl = append(cl, c)\n\t\t\t}\n\t\t}\n\n\t\tif len(b.HeadersRegex) > 0 {\n\t\t\tc, err := NewHeadersChecker(b.HeadersRegex)\n\t\t\tif err != nil {\n\t\t\t\tvalidationErrs = append(validationErrs, fmt.Errorf(\"while processing rule %s headers regex map: %w\", b.Name, err))\n\t\t\t} else {\n\t\t\t\tcl = append(cl, c)\n\t\t\t}\n\t\t}\n\n\t\tif b.Expression != nil {\n\t\t\tc, err := NewCELChecker(b.Expression, result.Dns)\n\t\t\tif err != nil {\n\t\t\t\tvalidationErrs = append(validationErrs, fmt.Errorf(\"while processing rule %s expressions: %w\", b.Name, err))\n\t\t\t} else {\n\t\t\t\tcl = append(cl, c)\n\t\t\t}\n\t\t}\n\n\t\tif b.ASNs != nil {\n\t\t\tif !hasThothClient {\n\t\t\t\tlg.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)\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tcl = append(cl, tc.ASNCheckerFor(b.ASNs.Match))\n\t\t}\n\n\t\tif b.GeoIP != nil {\n\t\t\tif !hasThothClient {\n\t\t\t\tlg.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)\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tcl = append(cl, tc.GeoIPCheckerFor(b.GeoIP.Countries))\n\t\t}\n\n\t\tif b.Challenge == nil {\n\t\t\tparsedBot.Challenge = &config.ChallengeRules{\n\t\t\t\tDifficulty: defaultDifficulty,\n\t\t\t\tAlgorithm:  \"fast\",\n\t\t\t}\n\t\t} else {\n\t\t\tparsedBot.Challenge = b.Challenge\n\t\t\tif parsedBot.Challenge.Algorithm == \"\" {\n\t\t\t\tparsedBot.Challenge.Algorithm = config.DefaultAlgorithm\n\t\t\t}\n\n\t\t\tif parsedBot.Challenge.Algorithm == \"slow\" {\n\t\t\t\tlg.Warn(\"use of deprecated algorithm \\\"slow\\\" detected, please update this to \\\"fast\\\" when possible\", \"name\", parsedBot.Name)\n\t\t\t}\n\t\t}\n\n\t\tif b.Weight != nil {\n\t\t\tparsedBot.Weight = b.Weight\n\t\t}\n\n\t\tresult.Impressum = c.Impressum\n\n\t\tparsedBot.Rules = cl\n\n\t\tresult.Bots = append(result.Bots, parsedBot)\n\t}\n\n\tfor _, t := range c.Thresholds {\n\t\tif t.Challenge != nil && t.Challenge.Algorithm == \"slow\" {\n\t\t\tlg.Warn(\"use of deprecated algorithm \\\"slow\\\" detected, please update this to \\\"fast\\\" when possible\", \"name\", t.Name)\n\t\t}\n\n\t\tif t.Challenge != nil && t.Challenge.ReportAs != 0 {\n\t\t\tlg.Warn(\"use of deprecated report_as setting detected, please remove this from your policy file when possible\", \"name\", t.Name)\n\t\t}\n\n\t\tif t.Name == \"legacy-anubis-behaviour\" && t.Expression.String() == \"true\" {\n\t\t\tif !warnedAboutThresholds.Load() {\n\t\t\t\tlg.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/\")\n\t\t\t\twarnedAboutThresholds.Store(true)\n\t\t\t}\n\n\t\t\tt.Challenge.Difficulty = defaultDifficulty\n\t\t}\n\n\t\tthreshold, err := ParsedThresholdFromConfig(t)\n\t\tif err != nil {\n\t\t\tvalidationErrs = append(validationErrs, fmt.Errorf(\"can't compile threshold config for %s: %w\", t.Name, err))\n\t\t\tcontinue\n\t\t}\n\n\t\tresult.Thresholds = append(result.Thresholds, threshold)\n\t}\n\n\tif len(validationErrs) > 0 {\n\t\treturn nil, fmt.Errorf(\"errors validating policy config JSON %s: %w\", fname, errors.Join(validationErrs...))\n\t}\n\n\tresult.DNSBL = c.DNSBL\n\n\treturn result, nil\n}\n"
  },
  {
    "path": "lib/policy/policy_test.go",
    "content": "package policy\n\nimport (\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n\n\t\"github.com/TecharoHQ/anubis\"\n\t\"github.com/TecharoHQ/anubis/data\"\n\t\"github.com/TecharoHQ/anubis/lib/thoth/thothmock\"\n)\n\nfunc TestDefaultPolicyMustParse(t *testing.T) {\n\tctx := thothmock.WithMockThoth(t)\n\n\tfin, err := data.BotPolicies.Open(\"botPolicies.yaml\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer fin.Close()\n\n\tif _, err := ParseConfig(ctx, fin, \"botPolicies.yaml\", anubis.DefaultDifficulty, \"info\"); err != nil {\n\t\tt.Fatalf(\"can't parse config: %v\", err)\n\t}\n}\n\nfunc TestGoodConfigs(t *testing.T) {\n\n\tfinfos, err := os.ReadDir(\"../config/testdata/good\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tfor _, st := range finfos {\n\t\tt.Run(st.Name(), func(t *testing.T) {\n\t\t\tt.Run(\"with-thoth\", func(t *testing.T) {\n\t\t\t\tfin, err := os.Open(filepath.Join(\"..\", \"config\", \"testdata\", \"good\", st.Name()))\n\t\t\t\tif err != nil {\n\t\t\t\t\tt.Fatal(err)\n\t\t\t\t}\n\t\t\t\tdefer fin.Close()\n\n\t\t\t\tctx := thothmock.WithMockThoth(t)\n\t\t\t\tif _, err := ParseConfig(ctx, fin, fin.Name(), anubis.DefaultDifficulty, \"info\"); err != nil {\n\t\t\t\t\tt.Fatal(err)\n\t\t\t\t}\n\t\t\t})\n\n\t\t\tt.Run(\"without-thoth\", func(t *testing.T) {\n\t\t\t\tfin, err := os.Open(filepath.Join(\"..\", \"config\", \"testdata\", \"good\", st.Name()))\n\t\t\t\tif err != nil {\n\t\t\t\t\tt.Fatal(err)\n\t\t\t\t}\n\t\t\t\tdefer fin.Close()\n\n\t\t\t\tif _, err := ParseConfig(t.Context(), fin, fin.Name(), anubis.DefaultDifficulty, \"info\"); err != nil {\n\t\t\t\t\tt.Fatal(err)\n\t\t\t\t}\n\t\t\t})\n\t\t})\n\t}\n}\n\nfunc TestBadConfigs(t *testing.T) {\n\tctx := thothmock.WithMockThoth(t)\n\n\tfinfos, err := os.ReadDir(\"../config/testdata/bad\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tfor _, st := range finfos {\n\t\tt.Run(st.Name(), func(t *testing.T) {\n\t\t\tfin, err := os.Open(filepath.Join(\"..\", \"config\", \"testdata\", \"bad\", st.Name()))\n\t\t\tif err != nil {\n\t\t\t\tt.Fatal(err)\n\t\t\t}\n\t\t\tdefer fin.Close()\n\n\t\t\tif _, err := ParseConfig(ctx, fin, fin.Name(), anubis.DefaultDifficulty, \"info\"); err == nil {\n\t\t\t\tt.Fatal(err)\n\t\t\t} else {\n\t\t\t\tt.Log(err)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "lib/policy/testdata/hack-test.json",
    "content": "[\n  {\n    \"name\": \"ipv6-ula\",\n    \"action\": \"ALLOW\",\n    \"remote_addresses\": [\"fc00::/7\"]\n  }\n]\n"
  },
  {
    "path": "lib/policy/testdata/hack-test.yaml",
    "content": "- name: well-known\n  path_regex: ^/.well-known/.*$\n  action: ALLOW\n"
  },
  {
    "path": "lib/policy/thresholds.go",
    "content": "package policy\n\nimport (\n\t\"github.com/TecharoHQ/anubis/lib/config\"\n\t\"github.com/TecharoHQ/anubis/lib/policy/expressions\"\n\t\"github.com/google/cel-go/cel\"\n)\n\ntype Threshold struct {\n\tconfig.Threshold\n\tProgram cel.Program\n}\n\nfunc ParsedThresholdFromConfig(t config.Threshold) (*Threshold, error) {\n\tresult := &Threshold{\n\t\tThreshold: t,\n\t}\n\n\tenv, err := expressions.ThresholdEnvironment()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tprogram, err := expressions.Compile(env, t.Expression.String())\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tresult.Program = program\n\n\treturn result, nil\n}\n\ntype ThresholdRequest struct {\n\tWeight int\n}\n\nfunc (tr *ThresholdRequest) Parent() cel.Activation { return nil }\n\nfunc (tr *ThresholdRequest) ResolveName(name string) (any, bool) {\n\tswitch name {\n\tcase \"weight\":\n\t\treturn tr.Weight, true\n\tdefault:\n\t\treturn nil, false\n\t}\n}\n"
  },
  {
    "path": "lib/redirect_security_test.go",
    "content": "package lib\n\nimport (\n\t\"log/slog\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"net/url\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/TecharoHQ/anubis/lib/policy\"\n)\n\nfunc TestRedirectSecurity(t *testing.T) {\n\ttests := []struct {\n\t\treqHost  string\n\t\ttestType string // \"constructRedirectURL\", \"serveHTTPNext\", \"renderIndex\"\n\n\t\t// For constructRedirectURL tests\n\t\txForwardedProto string\n\t\txForwardedHost  string\n\t\txForwardedUri   string\n\n\t\t// For serveHTTPNext tests\n\t\tredirParam string\n\t\tname       string\n\n\t\terrorContains  string\n\t\texpectedStatus int\n\n\t\t// For renderIndex tests\n\t\treturnHTTPStatusOnly bool\n\t\tshouldError          bool\n\t\tshouldNotRedirect    bool\n\t\tshouldBlock          bool\n\t}{\n\t\t// constructRedirectURL tests - X-Forwarded-Proto validation\n\t\t{\n\t\t\tname:            \"constructRedirectURL: javascript protocol should be rejected\",\n\t\t\ttestType:        \"constructRedirectURL\",\n\t\t\txForwardedProto: \"javascript\",\n\t\t\txForwardedHost:  \"example.com\",\n\t\t\txForwardedUri:   \"alert(1)\",\n\t\t\tshouldError:     true,\n\t\t\terrorContains:   \"invalid\",\n\t\t},\n\t\t{\n\t\t\tname:            \"constructRedirectURL: data protocol should be rejected\",\n\t\t\ttestType:        \"constructRedirectURL\",\n\t\t\txForwardedProto: \"data\",\n\t\t\txForwardedHost:  \"text/html\",\n\t\t\txForwardedUri:   \",<script>alert(1)</script>\",\n\t\t\tshouldError:     true,\n\t\t\terrorContains:   \"invalid\",\n\t\t},\n\t\t{\n\t\t\tname:            \"constructRedirectURL: file protocol should be rejected\",\n\t\t\ttestType:        \"constructRedirectURL\",\n\t\t\txForwardedProto: \"file\",\n\t\t\txForwardedHost:  \"\",\n\t\t\txForwardedUri:   \"/etc/passwd\",\n\t\t\tshouldError:     true,\n\t\t\terrorContains:   \"invalid\",\n\t\t},\n\t\t{\n\t\t\tname:            \"constructRedirectURL: ftp protocol should be rejected\",\n\t\t\ttestType:        \"constructRedirectURL\",\n\t\t\txForwardedProto: \"ftp\",\n\t\t\txForwardedHost:  \"example.com\",\n\t\t\txForwardedUri:   \"/file.txt\",\n\t\t\tshouldError:     true,\n\t\t\terrorContains:   \"invalid\",\n\t\t},\n\t\t{\n\t\t\tname:            \"constructRedirectURL: https protocol should be allowed\",\n\t\t\ttestType:        \"constructRedirectURL\",\n\t\t\txForwardedProto: \"https\",\n\t\t\txForwardedHost:  \"example.com\",\n\t\t\txForwardedUri:   \"/foo\",\n\t\t\tshouldError:     false,\n\t\t},\n\t\t{\n\t\t\tname:            \"constructRedirectURL: http protocol should be allowed\",\n\t\t\ttestType:        \"constructRedirectURL\",\n\t\t\txForwardedProto: \"http\",\n\t\t\txForwardedHost:  \"example.com\",\n\t\t\txForwardedUri:   \"/bar\",\n\t\t\tshouldError:     false,\n\t\t},\n\n\t\t// serveHTTPNext tests - redir parameter validation\n\t\t{\n\t\t\tname:              \"serveHTTPNext: javascript: URL should be rejected\",\n\t\t\ttestType:          \"serveHTTPNext\",\n\t\t\tredirParam:        \"javascript:alert(1)\",\n\t\t\treqHost:           \"example.com\",\n\t\t\texpectedStatus:    http.StatusBadRequest,\n\t\t\tshouldNotRedirect: true,\n\t\t},\n\t\t{\n\t\t\tname:              \"serveHTTPNext: data: URL should be rejected\",\n\t\t\ttestType:          \"serveHTTPNext\",\n\t\t\tredirParam:        \"data:text/html,<script>alert(1)</script>\",\n\t\t\treqHost:           \"example.com\",\n\t\t\texpectedStatus:    http.StatusBadRequest,\n\t\t\tshouldNotRedirect: true,\n\t\t},\n\t\t{\n\t\t\tname:              \"serveHTTPNext: file: URL should be rejected\",\n\t\t\ttestType:          \"serveHTTPNext\",\n\t\t\tredirParam:        \"file:///etc/passwd\",\n\t\t\treqHost:           \"example.com\",\n\t\t\texpectedStatus:    http.StatusBadRequest,\n\t\t\tshouldNotRedirect: true,\n\t\t},\n\t\t{\n\t\t\tname:              \"serveHTTPNext: vbscript: URL should be rejected\",\n\t\t\ttestType:          \"serveHTTPNext\",\n\t\t\tredirParam:        \"vbscript:msgbox(1)\",\n\t\t\treqHost:           \"example.com\",\n\t\t\texpectedStatus:    http.StatusBadRequest,\n\t\t\tshouldNotRedirect: true,\n\t\t},\n\t\t{\n\t\t\tname:           \"serveHTTPNext: valid https URL should work\",\n\t\t\ttestType:       \"serveHTTPNext\",\n\t\t\tredirParam:     \"https://example.com/foo\",\n\t\t\treqHost:        \"example.com\",\n\t\t\texpectedStatus: http.StatusFound,\n\t\t},\n\t\t{\n\t\t\tname:           \"serveHTTPNext: valid relative URL should work\",\n\t\t\ttestType:       \"serveHTTPNext\",\n\t\t\tredirParam:     \"/foo/bar\",\n\t\t\treqHost:        \"example.com\",\n\t\t\texpectedStatus: http.StatusFound,\n\t\t},\n\t\t{\n\t\t\tname:           \"serveHTTPNext: external domain should be blocked\",\n\t\t\ttestType:       \"serveHTTPNext\",\n\t\t\tredirParam:     \"https://evil.com/phishing\",\n\t\t\treqHost:        \"example.com\",\n\t\t\texpectedStatus: http.StatusBadRequest,\n\t\t\tshouldBlock:    true,\n\t\t},\n\t\t{\n\t\t\tname:           \"serveHTTPNext: relative path should work\",\n\t\t\ttestType:       \"serveHTTPNext\",\n\t\t\tredirParam:     \"/safe/path\",\n\t\t\treqHost:        \"example.com\",\n\t\t\texpectedStatus: http.StatusFound,\n\t\t},\n\t\t{\n\t\t\tname:           \"serveHTTPNext: empty redir should show success page\",\n\t\t\ttestType:       \"serveHTTPNext\",\n\t\t\tredirParam:     \"\",\n\t\t\treqHost:        \"example.com\",\n\t\t\texpectedStatus: http.StatusOK,\n\t\t},\n\n\t\t// renderIndex tests - full subrequest auth flow\n\t\t{\n\t\t\tname:                 \"renderIndex: javascript protocol in X-Forwarded-Proto\",\n\t\t\ttestType:             \"renderIndex\",\n\t\t\txForwardedProto:      \"javascript\",\n\t\t\txForwardedHost:       \"example.com\",\n\t\t\txForwardedUri:        \"alert(1)\",\n\t\t\treturnHTTPStatusOnly: true,\n\t\t\texpectedStatus:       http.StatusBadRequest,\n\t\t},\n\t\t{\n\t\t\tname:                 \"renderIndex: data protocol in X-Forwarded-Proto\",\n\t\t\ttestType:             \"renderIndex\",\n\t\t\txForwardedProto:      \"data\",\n\t\t\txForwardedHost:       \"example.com\",\n\t\t\txForwardedUri:        \"text/html,<script>alert(1)</script>\",\n\t\t\treturnHTTPStatusOnly: true,\n\t\t\texpectedStatus:       http.StatusBadRequest,\n\t\t},\n\t\t{\n\t\t\tname:                 \"renderIndex: valid https redirect\",\n\t\t\ttestType:             \"renderIndex\",\n\t\t\txForwardedProto:      \"https\",\n\t\t\txForwardedHost:       \"example.com\",\n\t\t\txForwardedUri:        \"/protected/page\",\n\t\t\treturnHTTPStatusOnly: true,\n\t\t\texpectedStatus:       http.StatusTemporaryRedirect,\n\t\t},\n\t}\n\n\ts := &Server{\n\t\topts: Options{\n\t\t\tPublicUrl:       \"https://anubis.example.com\",\n\t\t\tRedirectDomains: []string{},\n\t\t},\n\t\tlogger: slog.Default(),\n\t\tpolicy: &policy.ParsedConfig{},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tswitch tt.testType {\n\t\t\tcase \"constructRedirectURL\":\n\t\t\t\treq := httptest.NewRequest(\"GET\", \"/\", nil)\n\t\t\t\treq.Header.Set(\"X-Forwarded-Proto\", tt.xForwardedProto)\n\t\t\t\treq.Header.Set(\"X-Forwarded-Host\", tt.xForwardedHost)\n\t\t\t\treq.Header.Set(\"X-Forwarded-Uri\", tt.xForwardedUri)\n\n\t\t\t\tredirectURL, err := s.constructRedirectURL(req)\n\n\t\t\t\tif tt.shouldError {\n\t\t\t\t\tif err == nil {\n\t\t\t\t\t\tt.Errorf(\"expected error containing %q, got nil\", tt.errorContains)\n\t\t\t\t\t\tt.Logf(\"got redirect URL: %s\", redirectURL)\n\t\t\t\t\t} else if tt.errorContains != \"\" && !strings.Contains(err.Error(), tt.errorContains) {\n\t\t\t\t\t\tt.Logf(\"expected error containing %q, got: %v\", tt.errorContains, err)\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\tt.Errorf(\"expected no error, got: %v\", err)\n\t\t\t\t\t}\n\t\t\t\t\t// Verify the redirect URL is safe\n\t\t\t\t\tif redirectURL != \"\" {\n\t\t\t\t\t\tparsed, err := url.Parse(redirectURL)\n\t\t\t\t\t\tif err != nil {\n\t\t\t\t\t\t\tt.Errorf(\"failed to parse redirect URL: %v\", err)\n\t\t\t\t\t\t}\n\t\t\t\t\t\tredirParam := parsed.Query().Get(\"redir\")\n\t\t\t\t\t\tif redirParam != \"\" {\n\t\t\t\t\t\t\tredirParsed, err := url.Parse(redirParam)\n\t\t\t\t\t\t\tif err != nil {\n\t\t\t\t\t\t\t\tt.Errorf(\"failed to parse redir parameter: %v\", err)\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tif redirParsed.Scheme != \"http\" && redirParsed.Scheme != \"https\" {\n\t\t\t\t\t\t\t\tt.Errorf(\"redir parameter has unsafe scheme: %s\", redirParsed.Scheme)\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\tcase \"serveHTTPNext\":\n\t\t\t\treq := httptest.NewRequest(\"GET\", \"/.within.website/?redir=\"+url.QueryEscape(tt.redirParam), nil)\n\t\t\t\treq.Host = tt.reqHost\n\t\t\t\treq.URL.Host = tt.reqHost\n\t\t\t\trr := httptest.NewRecorder()\n\n\t\t\t\ts.ServeHTTPNext(rr, req)\n\n\t\t\t\tif rr.Code != tt.expectedStatus {\n\t\t\t\t\tt.Errorf(\"expected status %d, got %d\", tt.expectedStatus, rr.Code)\n\t\t\t\t\tt.Logf(\"body: %s\", rr.Body.String())\n\t\t\t\t}\n\n\t\t\t\tif tt.shouldNotRedirect {\n\t\t\t\t\tlocation := rr.Header().Get(\"Location\")\n\t\t\t\t\tif location != \"\" {\n\t\t\t\t\t\tt.Errorf(\"expected no redirect, but got Location header: %s\", location)\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tif tt.shouldBlock {\n\t\t\t\t\tlocation := rr.Header().Get(\"Location\")\n\t\t\t\t\tif location != \"\" && strings.Contains(location, \"evil.com\") {\n\t\t\t\t\t\tt.Errorf(\"redirect to evil.com was not blocked: %s\", location)\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\tcase \"renderIndex\":\n\t\t\t\treq := httptest.NewRequest(\"GET\", \"/\", nil)\n\t\t\t\treq.Header.Set(\"X-Forwarded-Proto\", tt.xForwardedProto)\n\t\t\t\treq.Header.Set(\"X-Forwarded-Host\", tt.xForwardedHost)\n\t\t\t\treq.Header.Set(\"X-Forwarded-Uri\", tt.xForwardedUri)\n\n\t\t\t\trr := httptest.NewRecorder()\n\t\t\t\ts.RenderIndex(rr, req, policy.CheckResult{}, nil, tt.returnHTTPStatusOnly)\n\n\t\t\t\tif rr.Code != tt.expectedStatus {\n\t\t\t\t\tt.Errorf(\"expected status %d, got %d\", tt.expectedStatus, rr.Code)\n\t\t\t\t}\n\n\t\t\t\tif tt.expectedStatus == http.StatusTemporaryRedirect {\n\t\t\t\t\tlocation := rr.Header().Get(\"Location\")\n\t\t\t\t\tif location == \"\" {\n\t\t\t\t\t\tt.Error(\"expected Location header, got none\")\n\t\t\t\t\t} else {\n\t\t\t\t\t\t// Verify the location doesn't contain javascript:\n\t\t\t\t\t\tif strings.Contains(location, \"javascript\") {\n\t\t\t\t\t\t\tt.Errorf(\"Location header contains 'javascript': %s\", location)\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "lib/store/actorifiedstore.go",
    "content": "package store\n\nimport (\n\t\"context\"\n\t\"time\"\n\n\t\"github.com/TecharoHQ/anubis/internal/actorify\"\n)\n\ntype unit struct{}\n\ntype ActorifiedStore struct {\n\tInterface\n\n\tdeleteActor *actorify.Actor[string, unit]\n\tgetActor    *actorify.Actor[string, []byte]\n\tsetActor    *actorify.Actor[*actorSetReq, unit]\n\tcancel      context.CancelFunc\n}\n\ntype actorSetReq struct {\n\tkey    string\n\tvalue  []byte\n\texpiry time.Duration\n}\n\nfunc NewActorifiedStore(backend Interface) *ActorifiedStore {\n\tctx, cancel := context.WithCancel(context.Background())\n\n\tresult := &ActorifiedStore{\n\t\tInterface: backend,\n\t\tcancel:    cancel,\n\t}\n\n\tresult.deleteActor = actorify.New(ctx, result.actorDelete)\n\tresult.getActor = actorify.New(ctx, backend.Get)\n\tresult.setActor = actorify.New(ctx, result.actorSet)\n\n\treturn result\n}\n\nfunc (a *ActorifiedStore) Close() { a.cancel() }\n\nfunc (a *ActorifiedStore) Delete(ctx context.Context, key string) error {\n\tif _, err := a.deleteActor.Call(ctx, key); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc (a *ActorifiedStore) Get(ctx context.Context, key string) ([]byte, error) {\n\treturn a.getActor.Call(ctx, key)\n}\n\nfunc (a *ActorifiedStore) Set(ctx context.Context, key string, value []byte, expiry time.Duration) error {\n\tif _, err := a.setActor.Call(ctx, &actorSetReq{\n\t\tkey:    key,\n\t\tvalue:  value,\n\t\texpiry: expiry,\n\t}); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc (a *ActorifiedStore) actorDelete(ctx context.Context, key string) (unit, error) {\n\tif err := a.Interface.Delete(ctx, key); err != nil {\n\t\treturn unit{}, err\n\t}\n\n\treturn unit{}, nil\n}\n\nfunc (a *ActorifiedStore) actorSet(ctx context.Context, req *actorSetReq) (unit, error) {\n\tif err := a.Interface.Set(ctx, req.key, req.value, req.expiry); err != nil {\n\t\treturn unit{}, err\n\t}\n\n\treturn unit{}, nil\n}\n"
  },
  {
    "path": "lib/store/all/all.go",
    "content": "// Package all is a meta-package that imports all store implementations.\n//\n// This is a HACK to make tests work consistently.\npackage all\n\nimport (\n\t_ \"github.com/TecharoHQ/anubis/lib/store/bbolt\"\n\t_ \"github.com/TecharoHQ/anubis/lib/store/memory\"\n\t_ \"github.com/TecharoHQ/anubis/lib/store/s3api\"\n\t_ \"github.com/TecharoHQ/anubis/lib/store/valkey\"\n)\n"
  },
  {
    "path": "lib/store/bbolt/bbolt.go",
    "content": "package bbolt\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"log/slog\"\n\t\"time\"\n\n\t\"github.com/TecharoHQ/anubis/lib/store\"\n\t\"go.etcd.io/bbolt\"\n)\n\n// Sentinel error value used for testing and in admin-visible error messages.\nvar (\n\tErrNotExists = errors.New(\"bbolt: value does not exist in store\")\n)\n\n// Store implements store.Interface backed by bbolt[1].\n//\n// In essence, bbolt is a hierarchical key/value store with a twist: every value\n// needs to belong to a bucket. Buckets can contain an infinite number of\n// buckets. As such, Anubis nests values in buckets. Each value in the store\n// is given its own bucket with two keys:\n//\n// 1. data - The raw data, usually in JSON\n// 2. expiry - The expiry time formatted as a time.RFC3339Nano timestamp string\n//\n// When Anubis stores a new bit of data, it creates a new bucket for that value.\n// This allows the cleanup phase to iterate over every bucket in the database and\n// only scan the expiry times without having to decode the entire record.\n//\n// bbolt is not suitable for environments where multiple instance of Anubis need\n// to read from and write to the same backend store. For that, use the valkey\n// storage backend.\n//\n// [1]: https://github.com/etcd-io/bbolt\ntype Store struct {\n\tbdb *bbolt.DB\n}\n\n// Delete a key from the datastore. If the key does not exist, return an error.\nfunc (s *Store) Delete(ctx context.Context, key string) error {\n\treturn s.bdb.Update(func(tx *bbolt.Tx) error {\n\t\tif tx.Bucket([]byte(key)) == nil {\n\t\t\treturn fmt.Errorf(\"%w: %q\", ErrNotExists, key)\n\t\t}\n\n\t\treturn tx.DeleteBucket([]byte(key))\n\t})\n}\n\n// Get a value from the datastore.\n//\n// Because each value is stored in its own bucket with data and expiry keys,\n// two get operations are required:\n//\n// 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.\n// 2. Get the data key, copy into the result byteslice, return it.\nfunc (s *Store) Get(ctx context.Context, key string) ([]byte, error) {\n\tvar result []byte\n\n\tif err := s.bdb.View(func(tx *bbolt.Tx) error {\n\t\titemBucket := tx.Bucket([]byte(key))\n\t\tif itemBucket == nil {\n\t\t\treturn fmt.Errorf(\"%w: %q\", store.ErrNotFound, key)\n\t\t}\n\n\t\texpiryStr := itemBucket.Get([]byte(\"expiry\"))\n\t\tif expiryStr == nil {\n\t\t\treturn fmt.Errorf(\"[unexpected] %w: %q (expiry is nil)\", store.ErrNotFound, key)\n\t\t}\n\n\t\texpiry, err := time.Parse(time.RFC3339Nano, string(expiryStr))\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"[unexpected] %w: %w\", store.ErrCantDecode, err)\n\t\t}\n\n\t\tif time.Now().After(expiry) {\n\t\t\tgo s.Delete(context.Background(), key)\n\t\t\treturn fmt.Errorf(\"%w: %q\", store.ErrNotFound, key)\n\t\t}\n\n\t\tdataStr := itemBucket.Get([]byte(\"data\"))\n\t\tif dataStr == nil {\n\t\t\treturn fmt.Errorf(\"[unexpected] %w: %q (data is nil)\", store.ErrNotFound, key)\n\t\t}\n\n\t\tresult = make([]byte, len(dataStr))\n\t\tif n := copy(result, dataStr); n != len(dataStr) {\n\t\t\treturn fmt.Errorf(\"[unexpected] %w: %d bytes copied of %d\", store.ErrCantDecode, n, len(dataStr))\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn result, nil\n}\n\n// Set a value into the store with a given expiry.\nfunc (s *Store) Set(ctx context.Context, key string, value []byte, expiry time.Duration) error {\n\texpires := time.Now().Add(expiry)\n\n\treturn s.bdb.Update(func(tx *bbolt.Tx) error {\n\t\tvalueBkt, err := tx.CreateBucketIfNotExists([]byte(key))\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"%w: %w: %q (create bucket)\", store.ErrCantEncode, err, key)\n\t\t}\n\n\t\tif err := valueBkt.Put([]byte(\"expiry\"), []byte(expires.Format(time.RFC3339Nano))); err != nil {\n\t\t\treturn fmt.Errorf(\"%w: %q (expiry)\", store.ErrCantEncode, key)\n\t\t}\n\n\t\tif err := valueBkt.Put([]byte(\"data\"), value); err != nil {\n\t\t\treturn fmt.Errorf(\"%w: %q (data)\", store.ErrCantEncode, key)\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc (s *Store) cleanup(ctx context.Context) error {\n\tnow := time.Now()\n\n\treturn s.bdb.Update(func(tx *bbolt.Tx) error {\n\t\treturn tx.ForEach(func(key []byte, valueBkt *bbolt.Bucket) error {\n\t\t\tvar expiry time.Time\n\t\t\tvar err error\n\n\t\t\texpiryStr := valueBkt.Get([]byte(\"expiry\"))\n\t\t\tif expiryStr == nil {\n\t\t\t\tslog.Warn(\"while running cleanup, expiry is not set somehow, file a bug?\", \"key\", string(key))\n\t\t\t\treturn nil\n\t\t\t}\n\n\t\t\texpiry, err = time.Parse(time.RFC3339Nano, string(expiryStr))\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"[unexpected] %w in bucket %q: %w\", store.ErrCantDecode, string(key), err)\n\t\t\t}\n\n\t\t\tif now.After(expiry) {\n\t\t\t\treturn tx.DeleteBucket(key)\n\t\t\t}\n\n\t\t\treturn nil\n\t\t})\n\t})\n}\n\nfunc (s *Store) IsPersistent() bool {\n\treturn true\n}\n\nfunc (s *Store) cleanupThread(ctx context.Context) {\n\tt := time.NewTicker(time.Hour)\n\tdefer t.Stop()\n\n\tfor {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn\n\t\tcase <-t.C:\n\t\t\tif err := s.cleanup(ctx); err != nil {\n\t\t\t\tslog.Error(\"error during bbolt cleanup\", \"err\", err)\n\t\t\t}\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "lib/store/bbolt/bbolt_test.go",
    "content": "package bbolt\n\nimport (\n\t\"encoding/json\"\n\t\"path/filepath\"\n\t\"testing\"\n\n\t\"github.com/TecharoHQ/anubis/lib/store/storetest\"\n)\n\nfunc TestImpl(t *testing.T) {\n\tpath := filepath.Join(t.TempDir(), \"db\")\n\tt.Log(path)\n\tdata, err := json.Marshal(Config{\n\t\tPath: path,\n\t})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tstoretest.Common(t, Factory{}, json.RawMessage(data))\n}\n"
  },
  {
    "path": "lib/store/bbolt/factory.go",
    "content": "package bbolt\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\n\t\"github.com/TecharoHQ/anubis/lib/store\"\n\t\"go.etcd.io/bbolt\"\n)\n\nvar (\n\tErrMissingPath     = errors.New(\"bbolt: path is missing from config\")\n\tErrCantWriteToPath = errors.New(\"bbolt: can't write to path\")\n)\n\nfunc init() {\n\tstore.Register(\"bbolt\", Factory{})\n}\n\n// Factory builds new instances of the bbolt storage backend according to\n// configuration passed via a json.RawMessage.\ntype Factory struct{}\n\n// Build parses and validates the bbolt storage backend Config and creates\n// a new instance of it.\nfunc (Factory) Build(ctx context.Context, data json.RawMessage) (store.Interface, error) {\n\tvar config Config\n\tif err := json.Unmarshal([]byte(data), &config); err != nil {\n\t\treturn nil, fmt.Errorf(\"%w: %w\", store.ErrBadConfig, err)\n\t}\n\n\tif err := config.Valid(); err != nil {\n\t\treturn nil, fmt.Errorf(\"%w: %w\", store.ErrBadConfig, err)\n\t}\n\n\tbdb, err := bbolt.Open(config.Path, 0600, nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"can't open bbolt database %s: %w\", config.Path, err)\n\t}\n\n\tresult := &Store{\n\t\tbdb: bdb,\n\t}\n\n\tgo result.cleanupThread(ctx)\n\n\treturn result, nil\n}\n\n// Valid parses and validates the bbolt store Config or returns\n// an error.\nfunc (Factory) Valid(data json.RawMessage) error {\n\tvar config Config\n\tif err := json.Unmarshal([]byte(data), &config); err != nil {\n\t\treturn fmt.Errorf(\"%w: %w\", store.ErrBadConfig, err)\n\t}\n\n\tif err := config.Valid(); err != nil {\n\t\treturn fmt.Errorf(\"%w: %w\", store.ErrBadConfig, err)\n\t}\n\n\treturn nil\n}\n\n// Config is the bbolt storage backend configuration.\ntype Config struct {\n\t// Path is the filesystem path of the database. The folder must be writable to Anubis.\n\tPath string `json:\"path\"`\n}\n\n// Valid validates the configuration including checking if its containing folder is writable.\nfunc (c Config) Valid() error {\n\tvar errs []error\n\n\tif c.Path == \"\" {\n\t\terrs = append(errs, ErrMissingPath)\n\t} else {\n\t\tdir := filepath.Dir(c.Path)\n\t\tif err := os.WriteFile(filepath.Join(dir, \".test-file\"), []byte(\"\"), 0600); err != nil {\n\t\t\terrs = append(errs, ErrCantWriteToPath)\n\t\t}\n\t\tos.Remove(filepath.Join(dir, \".test-file\"))\n\t}\n\n\tif len(errs) != 0 {\n\t\treturn errors.Join(errs...)\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "lib/store/bbolt/factory_test.go",
    "content": "package bbolt\n\nimport (\n\t\"encoding/json\"\n\t\"errors\"\n\t\"testing\"\n)\n\nfunc TestFactoryValid(t *testing.T) {\n\tf := Factory{}\n\n\tt.Run(\"bad config\", func(t *testing.T) {\n\t\tif err := f.Valid(json.RawMessage(`}`)); err == nil {\n\t\t\tt.Error(\"wanted parsing failure but got a successful result\")\n\t\t}\n\t})\n\n\tt.Run(\"invalid config\", func(t *testing.T) {\n\t\tfor _, tt := range []struct {\n\t\t\terr  error\n\t\t\tname string\n\t\t\tcfg  Config\n\t\t}{\n\t\t\t{\n\t\t\t\tname: \"missing path\",\n\t\t\t\tcfg:  Config{},\n\t\t\t\terr:  ErrMissingPath,\n\t\t\t},\n\t\t} {\n\t\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\t\tdata, err := json.Marshal(tt.cfg)\n\t\t\t\tif err != nil {\n\t\t\t\t\tt.Fatal(err)\n\t\t\t\t}\n\n\t\t\t\tif err := f.Valid(json.RawMessage(data)); !errors.Is(err, tt.err) {\n\t\t\t\t\tt.Error(err)\n\t\t\t\t}\n\t\t\t})\n\t\t}\n\t})\n}\n"
  },
  {
    "path": "lib/store/interface.go",
    "content": "package store\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"time\"\n)\n\nvar (\n\t// ErrNotFound is returned when the store implementation cannot find the value\n\t// for a given key.\n\tErrNotFound = errors.New(\"store: key not found\")\n\n\t// ErrCantDecode is returned when a store adaptor cannot decode the store format\n\t// to a value used by the code.\n\tErrCantDecode = errors.New(\"store: can't decode value\")\n\n\t// ErrCantEncode is returned when a store adaptor cannot encode the value into\n\t// the format that the store uses.\n\tErrCantEncode = errors.New(\"store: can't encode value\")\n\n\t// ErrBadConfig is returned when a store adaptor's configuration is invalid.\n\tErrBadConfig = errors.New(\"store: configuration is invalid\")\n)\n\n// Interface defines the calls that Anubis uses for storage in a local or remote\n// datastore. This can be implemented with an in-memory, on-disk, or in-database\n// storage backend.\ntype Interface interface {\n\t// Delete removes a value from the store by key.\n\tDelete(ctx context.Context, key string) error\n\n\t// Get returns the value of a key assuming that value exists and has not expired.\n\tGet(ctx context.Context, key string) ([]byte, error)\n\n\t// Set puts a value into the store that expires according to its expiry.\n\tSet(ctx context.Context, key string, value []byte, expiry time.Duration) error\n\n\t// IsPersistent returns true if this storage backend persists data across\n\t// service restarts (e.g., bbolt, valkey). Returns false for volatile storage\n\t// like in-memory backends.\n\tIsPersistent() bool\n}\n\nfunc z[T any]() T { return *new(T) }\n\ntype JSON[T any] struct {\n\tUnderlying Interface\n\tPrefix     string\n}\n\nfunc (j *JSON[T]) Delete(ctx context.Context, key string) error {\n\tif j.Prefix != \"\" {\n\t\tkey = j.Prefix + key\n\t}\n\n\treturn j.Underlying.Delete(ctx, key)\n}\n\nfunc (j *JSON[T]) Get(ctx context.Context, key string) (T, error) {\n\tif j.Prefix != \"\" {\n\t\tkey = j.Prefix + key\n\t}\n\n\tdata, err := j.Underlying.Get(ctx, key)\n\tif err != nil {\n\t\treturn z[T](), err\n\t}\n\n\tvar result T\n\tif err := json.Unmarshal(data, &result); err != nil {\n\t\treturn z[T](), fmt.Errorf(\"%w: %w\", ErrCantDecode, err)\n\t}\n\n\treturn result, nil\n}\n\nfunc (j *JSON[T]) Set(ctx context.Context, key string, value T, expiry time.Duration) error {\n\tif j.Prefix != \"\" {\n\t\tkey = j.Prefix + key\n\t}\n\n\tdata, err := json.Marshal(value)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"%w: %w\", ErrCantEncode, err)\n\t}\n\n\tif err := j.Underlying.Set(ctx, key, data, expiry); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc (j *JSON[T]) IsPersistent() bool {\n\treturn j.Underlying.IsPersistent()\n}\n"
  },
  {
    "path": "lib/store/json_test.go",
    "content": "package store_test\n\nimport (\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/TecharoHQ/anubis/lib/store\"\n\t\"github.com/TecharoHQ/anubis/lib/store/memory\"\n)\n\nfunc TestJSON(t *testing.T) {\n\ttype data struct {\n\t\tID string `json:\"id\"`\n\t}\n\n\tst := memory.New(t.Context())\n\tdb := store.JSON[data]{\n\t\tUnderlying: st,\n\t\tPrefix:     \"foo:\",\n\t}\n\n\tif err := db.Set(t.Context(), \"test\", data{ID: t.Name()}, time.Minute); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tgot, err := db.Get(t.Context(), \"test\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif got.ID != t.Name() {\n\t\tt.Fatalf(\"got wrong data for key \\\"test\\\", wanted %q but got: %q\", t.Name(), got.ID)\n\t}\n\n\tif err := db.Delete(t.Context(), \"test\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif _, err := db.Get(t.Context(), \"test\"); err == nil {\n\t\tt.Fatal(\"wanted invalid get to fail, it did not\")\n\t}\n\n\tif err := st.Set(t.Context(), \"foo:test\", []byte(\"}\"), time.Minute); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif _, err := db.Get(t.Context(), \"test\"); err == nil {\n\t\tt.Fatal(\"wanted invalid get to fail, it did not\")\n\t}\n}\n"
  },
  {
    "path": "lib/store/memory/memory.go",
    "content": "package memory\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"time\"\n\n\t\"github.com/TecharoHQ/anubis/decaymap\"\n\t\"github.com/TecharoHQ/anubis/lib/store\"\n)\n\ntype factory struct{}\n\nfunc (factory) Build(ctx context.Context, _ json.RawMessage) (store.Interface, error) {\n\treturn New(ctx), nil\n}\n\nfunc (factory) Valid(json.RawMessage) error { return nil }\n\nfunc init() {\n\tstore.Register(\"memory\", factory{})\n}\n\ntype impl struct {\n\tstore *decaymap.Impl[string, []byte]\n}\n\nfunc (i *impl) Delete(_ context.Context, key string) error {\n\tif !i.store.Delete(key) {\n\t\treturn fmt.Errorf(\"%w: %q\", store.ErrNotFound, key)\n\t}\n\n\treturn nil\n}\n\nfunc (i *impl) Get(_ context.Context, key string) ([]byte, error) {\n\tresult, ok := i.store.Get(key)\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"%w: %q\", store.ErrNotFound, key)\n\t}\n\n\treturn result, nil\n}\n\nfunc (i *impl) Set(_ context.Context, key string, value []byte, expiry time.Duration) error {\n\ti.store.Set(key, value, expiry)\n\treturn nil\n}\n\nfunc (i *impl) IsPersistent() bool {\n\treturn false\n}\n\nfunc (i *impl) cleanupThread(ctx context.Context) {\n\tt := time.NewTicker(5 * time.Minute)\n\tdefer t.Stop()\n\n\tfor {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn\n\t\tcase <-t.C:\n\t\t\ti.store.Cleanup()\n\t\t}\n\t}\n}\n\n// New creates a simple in-memory store. This will not scale to multiple Anubis instances.\nfunc New(ctx context.Context) store.Interface {\n\tresult := &impl{\n\t\tstore: decaymap.New[string, []byte](),\n\t}\n\n\tgo result.cleanupThread(ctx)\n\n\treturn result\n}\n"
  },
  {
    "path": "lib/store/memory/memory_test.go",
    "content": "package memory\n\nimport (\n\t\"testing\"\n\n\t\"github.com/TecharoHQ/anubis/lib/store/storetest\"\n)\n\nfunc TestImpl(t *testing.T) {\n\tstoretest.Common(t, factory{}, nil)\n}\n"
  },
  {
    "path": "lib/store/registry.go",
    "content": "package store\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"sort\"\n\t\"sync\"\n)\n\nvar (\n\tregistry map[string]Factory = map[string]Factory{}\n\tregLock  sync.RWMutex\n)\n\ntype Factory interface {\n\tBuild(ctx context.Context, config json.RawMessage) (Interface, error)\n\tValid(config json.RawMessage) error\n}\n\nfunc Register(name string, impl Factory) {\n\tregLock.Lock()\n\tdefer regLock.Unlock()\n\n\tregistry[name] = impl\n}\n\nfunc Get(name string) (Factory, bool) {\n\tregLock.RLock()\n\tdefer regLock.RUnlock()\n\tresult, ok := registry[name]\n\treturn result, ok\n}\n\nfunc Methods() []string {\n\tregLock.RLock()\n\tdefer regLock.RUnlock()\n\tvar result []string\n\tfor method := range registry {\n\t\tresult = append(result, method)\n\t}\n\tsort.Strings(result)\n\treturn result\n}\n"
  },
  {
    "path": "lib/store/s3api/factory.go",
    "content": "package s3api\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\n\t\"github.com/TecharoHQ/anubis/lib/store\"\n\tawsConfig \"github.com/aws/aws-sdk-go-v2/config\"\n\t\"github.com/aws/aws-sdk-go-v2/service/s3\"\n)\n\nvar (\n\tErrNoRegion          = errors.New(\"s3api.Config: no region env var name defined\")\n\tErrNoAccessKeyID     = errors.New(\"s3api.Config: no access key id env var name defined\")\n\tErrNoSecretAccessKey = errors.New(\"s3api.Config: no secret access key env var name defined\")\n\tErrNoBucketName      = errors.New(\"s3api.Config: no bucket name env var name defined\")\n)\n\nfunc init() {\n\tstore.Register(\"s3api\", Factory{})\n}\n\n// S3API is the subset of the AWS S3 client used by this store. It enables mocking in tests.\ntype S3API interface {\n\tPutObject(ctx context.Context, params *s3.PutObjectInput, optFns ...func(*s3.Options)) (*s3.PutObjectOutput, error)\n\tGetObject(ctx context.Context, params *s3.GetObjectInput, optFns ...func(*s3.Options)) (*s3.GetObjectOutput, error)\n\tDeleteObject(ctx context.Context, params *s3.DeleteObjectInput, optFns ...func(*s3.Options)) (*s3.DeleteObjectOutput, error)\n\tHeadObject(ctx context.Context, params *s3.HeadObjectInput, optFns ...func(*s3.Options)) (*s3.HeadObjectOutput, error)\n}\n\n// Factory builds an S3-backed store. Tests can inject a Mock via Client.\n// Factory can optionally carry a preconstructed S3 client (e.g., a mock in tests).\ntype Factory struct {\n\tClient S3API\n}\n\nfunc (f Factory) Build(ctx context.Context, data json.RawMessage) (store.Interface, error) {\n\tvar config Config\n\n\tif err := json.Unmarshal([]byte(data), &config); err != nil {\n\t\treturn nil, fmt.Errorf(\"%w: %w\", store.ErrBadConfig, err)\n\t}\n\n\tif err := config.Valid(); err != nil {\n\t\treturn nil, fmt.Errorf(\"%w: %w\", store.ErrBadConfig, err)\n\t}\n\n\tif config.BucketName == \"\" {\n\t\treturn nil, fmt.Errorf(\"%w: %s\", store.ErrBadConfig, ErrNoBucketName)\n\t}\n\n\t// If a client was injected (e.g., tests), use it directly.\n\tif f.Client != nil {\n\t\treturn &Store{\n\t\t\ts3:     f.Client,\n\t\t\tbucket: config.BucketName,\n\t\t}, nil\n\t}\n\n\tcfg, err := awsConfig.LoadDefaultConfig(ctx)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"can't load AWS config from environment: %w\", err)\n\t}\n\n\tclient := s3.NewFromConfig(cfg, func(o *s3.Options) {\n\t\to.UsePathStyle = config.PathStyle\n\t})\n\n\treturn &Store{\n\t\ts3:     client,\n\t\tbucket: config.BucketName,\n\t}, nil\n}\n\nfunc (Factory) Valid(data json.RawMessage) error {\n\tvar config Config\n\tif err := json.Unmarshal([]byte(data), &config); err != nil {\n\t\treturn fmt.Errorf(\"%w: %w\", store.ErrBadConfig, err)\n\t}\n\n\tif err := config.Valid(); err != nil {\n\t\treturn fmt.Errorf(\"%w: %w\", store.ErrBadConfig, err)\n\t}\n\n\treturn nil\n}\n\ntype Config struct {\n\tBucketName string `json:\"bucketName\"`\n\tPathStyle  bool   `json:\"pathStyle\"`\n}\n\nfunc (c Config) Valid() error {\n\tvar errs []error\n\n\tif c.BucketName == \"\" {\n\t\terrs = append(errs, ErrNoBucketName)\n\t}\n\n\tif len(errs) != 0 {\n\t\treturn fmt.Errorf(\"s3api.Config: invalid config: %w\", errors.Join(errs...))\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "lib/store/s3api/s3api.go",
    "content": "package s3api\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/TecharoHQ/anubis/lib/store\"\n\t\"github.com/aws/aws-sdk-go-v2/service/s3\"\n)\n\ntype Store struct {\n\ts3     S3API\n\tbucket string\n}\n\nfunc (s *Store) Delete(ctx context.Context, key string) error {\n\tnormKey := strings.ReplaceAll(key, \":\", \"/\")\n\t// Emulate not found by probing first.\n\tif _, err := s.s3.HeadObject(ctx, &s3.HeadObjectInput{Bucket: &s.bucket, Key: &normKey}); err != nil {\n\t\treturn fmt.Errorf(\"%w: %w\", store.ErrNotFound, err)\n\t}\n\tif _, err := s.s3.DeleteObject(ctx, &s3.DeleteObjectInput{Bucket: &s.bucket, Key: &normKey}); err != nil {\n\t\treturn fmt.Errorf(\"can't delete from s3: %w\", err)\n\t}\n\treturn nil\n}\n\nfunc (s *Store) Get(ctx context.Context, key string) ([]byte, error) {\n\tnormKey := strings.ReplaceAll(key, \":\", \"/\")\n\tout, err := s.s3.GetObject(ctx, &s3.GetObjectInput{\n\t\tBucket: &s.bucket,\n\t\tKey:    &normKey,\n\t})\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"%w: %w\", store.ErrNotFound, err)\n\t}\n\tdefer out.Body.Close()\n\tif msStr, ok := out.Metadata[\"x-anubis-expiry-ms\"]; ok && msStr != \"\" {\n\t\tif ms, err := strconv.ParseInt(msStr, 10, 64); err == nil {\n\t\t\tif time.Now().UnixMilli() >= ms {\n\t\t\t\t_, _ = s.s3.DeleteObject(ctx, &s3.DeleteObjectInput{Bucket: &s.bucket, Key: &normKey})\n\t\t\t\treturn nil, store.ErrNotFound\n\t\t\t}\n\t\t}\n\t}\n\tb, err := io.ReadAll(out.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"can't read s3 object: %w\", err)\n\t}\n\treturn b, nil\n}\n\nfunc (s *Store) Set(ctx context.Context, key string, value []byte, expiry time.Duration) error {\n\tnormKey := strings.ReplaceAll(key, \":\", \"/\")\n\t// S3 has no native TTL; we store object with metadata X-Anubis-Expiry as epoch seconds.\n\tvar meta map[string]string\n\tif expiry > 0 {\n\t\texp := time.Now().Add(expiry).UnixMilli()\n\t\tmeta = map[string]string{\"x-anubis-expiry-ms\": fmt.Sprintf(\"%d\", exp)}\n\t}\n\t_, err := s.s3.PutObject(ctx, &s3.PutObjectInput{\n\t\tBucket:   &s.bucket,\n\t\tKey:      &normKey,\n\t\tBody:     bytes.NewReader(value),\n\t\tMetadata: meta,\n\t})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"can't put s3 object: %w\", err)\n\t}\n\treturn nil\n}\n\nfunc (Store) IsPersistent() bool { return true }\n"
  },
  {
    "path": "lib/store/s3api/s3api_test.go",
    "content": "package s3api\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"maps\"\n\t\"sync\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/TecharoHQ/anubis/lib/store/storetest\"\n\t\"github.com/aws/aws-sdk-go-v2/aws\"\n\t\"github.com/aws/aws-sdk-go-v2/service/s3\"\n)\n\n// mockS3 is an in-memory mock of the methods we use.\ntype mockS3 struct {\n\tdata   map[string][]byte\n\tmeta   map[string]map[string]string\n\tbucket string\n\tmu     sync.RWMutex\n}\n\nfunc (m *mockS3) PutObject(ctx context.Context, in *s3.PutObjectInput, _ ...func(*s3.Options)) (*s3.PutObjectOutput, error) {\n\tm.mu.Lock()\n\tdefer m.mu.Unlock()\n\tif m.data == nil {\n\t\tm.data = map[string][]byte{}\n\t}\n\tif m.meta == nil {\n\t\tm.meta = map[string]map[string]string{}\n\t}\n\tb, _ := io.ReadAll(in.Body)\n\tm.data[aws.ToString(in.Key)] = bytes.Clone(b)\n\tif in.Metadata != nil {\n\t\tm.meta[aws.ToString(in.Key)] = map[string]string{}\n\t\tmaps.Copy(m.meta[aws.ToString(in.Key)], in.Metadata)\n\t}\n\tm.bucket = aws.ToString(in.Bucket)\n\treturn &s3.PutObjectOutput{}, nil\n}\n\nfunc (m *mockS3) GetObject(ctx context.Context, in *s3.GetObjectInput, _ ...func(*s3.Options)) (*s3.GetObjectOutput, error) {\n\tm.mu.RLock()\n\tdefer m.mu.RUnlock()\n\tb, ok := m.data[aws.ToString(in.Key)]\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"not found\")\n\t}\n\tout := &s3.GetObjectOutput{Body: io.NopCloser(bytes.NewReader(b))}\n\tif md, ok := m.meta[aws.ToString(in.Key)]; ok {\n\t\tout.Metadata = md\n\t}\n\treturn out, nil\n}\n\nfunc (m *mockS3) DeleteObject(ctx context.Context, in *s3.DeleteObjectInput, _ ...func(*s3.Options)) (*s3.DeleteObjectOutput, error) {\n\tm.mu.Lock()\n\tdefer m.mu.Unlock()\n\tdelete(m.data, aws.ToString(in.Key))\n\tdelete(m.meta, aws.ToString(in.Key))\n\treturn &s3.DeleteObjectOutput{}, nil\n}\n\nfunc (m *mockS3) HeadObject(ctx context.Context, in *s3.HeadObjectInput, _ ...func(*s3.Options)) (*s3.HeadObjectOutput, error) {\n\tm.mu.RLock()\n\tdefer m.mu.RUnlock()\n\tif _, ok := m.data[aws.ToString(in.Key)]; !ok {\n\t\treturn nil, fmt.Errorf(\"not found\")\n\t}\n\treturn &s3.HeadObjectOutput{}, nil\n}\n\nfunc TestImpl(t *testing.T) {\n\tmock := &mockS3{}\n\tf := Factory{Client: mock}\n\n\tdata, _ := json.Marshal(Config{\n\t\tBucketName: \"bucket\",\n\t})\n\n\tstoretest.Common(t, f, json.RawMessage(data))\n}\n\nfunc TestKeyNormalization(t *testing.T) {\n\tmock := &mockS3{}\n\tf := Factory{Client: mock}\n\n\tdata, _ := json.Marshal(Config{\n\t\tBucketName: \"anubis\",\n\t})\n\n\ts, err := f.Build(t.Context(), json.RawMessage(data))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tkey := \"a:b:c\"\n\tval := []byte(\"value\")\n\tif err := s.Set(t.Context(), key, val, 0); err != nil {\n\t\tt.Fatalf(\"Set failed: %v\", err)\n\t}\n\t// Ensure mock saw normalized key\n\tmock.mu.RLock()\n\t_, hasRaw := mock.data[\"a:b:c\"]\n\tgot, hasNorm := mock.data[\"a/b/c\"]\n\tmock.mu.RUnlock()\n\tif hasRaw {\n\t\tt.Fatalf(\"mock contains raw key with colon; normalization failed\")\n\t}\n\tif !hasNorm || !bytes.Equal(got, val) {\n\t\tt.Fatalf(\"normalized key missing or wrong value: got=%q\", string(got))\n\t}\n\n\t// Get using colon key should work\n\tout, err := s.Get(t.Context(), key)\n\tif err != nil {\n\t\tt.Fatalf(\"Get failed: %v\", err)\n\t}\n\tif !bytes.Equal(out, val) {\n\t\tt.Fatalf(\"Get returned wrong value: got=%q\", string(out))\n\t}\n\n\t// Delete using colon key should delete normalized object\n\tif err := s.Delete(t.Context(), key); err != nil {\n\t\tt.Fatalf(\"Delete failed: %v\", err)\n\t}\n\t// Give any async cleanup in tests a tick (not needed for mock, but harmless)\n\ttime.Sleep(1 * time.Millisecond)\n\tmock.mu.RLock()\n\t_, exists := mock.data[\"a/b/c\"]\n\tmock.mu.RUnlock()\n\tif exists {\n\t\tt.Fatalf(\"normalized key still exists after Delete\")\n\t}\n}\n"
  },
  {
    "path": "lib/store/storetest/storetest.go",
    "content": "package storetest\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/TecharoHQ/anubis/lib/store\"\n)\n\nfunc Common(t *testing.T, f store.Factory, config json.RawMessage) {\n\tif err := f.Valid(config); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\ts, err := f.Build(t.Context(), config)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tfor _, tt := range []struct {\n\t\terr  error\n\t\tdoer func(t *testing.T, s store.Interface) error\n\t\tname string\n\t}{\n\t\t{\n\t\t\tname: \"basic get set delete\",\n\t\t\tdoer: func(t *testing.T, s store.Interface) error {\n\t\t\t\tif _, err := s.Get(t.Context(), t.Name()); !errors.Is(err, store.ErrNotFound) {\n\t\t\t\t\tt.Errorf(\"wanted %s to not exist in store but it exists anyways\", t.Name())\n\t\t\t\t}\n\n\t\t\t\tif err := s.Set(t.Context(), t.Name(), []byte(t.Name()), 5*time.Minute); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\n\t\t\t\tval, err := s.Get(t.Context(), t.Name())\n\t\t\t\tif errors.Is(err, store.ErrNotFound) {\n\t\t\t\t\tt.Errorf(\"wanted %s to exist in store but it does not: %v\", t.Name(), err)\n\t\t\t\t} else if err != nil {\n\t\t\t\t\tt.Error(err)\n\t\t\t\t}\n\n\t\t\t\tif !bytes.Equal(val, []byte(t.Name())) {\n\t\t\t\t\tt.Logf(\"want: %q\", t.Name())\n\t\t\t\t\tt.Logf(\"got:  %q\", string(val))\n\t\t\t\t\tt.Error(\"wrong value returned\")\n\t\t\t\t}\n\n\t\t\t\tif err := s.Delete(t.Context(), t.Name()); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\n\t\t\t\tif _, err := s.Get(t.Context(), t.Name()); !errors.Is(err, store.ErrNotFound) {\n\t\t\t\t\tt.Error(\"wanted test to not exist in store but it exists anyways\")\n\t\t\t\t}\n\n\t\t\t\tif err := s.Delete(t.Context(), t.Name()); err == nil {\n\t\t\t\t\tt.Errorf(\"key %q does not exist and Delete did not return non-nil\", t.Name())\n\t\t\t\t}\n\n\t\t\t\treturn nil\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"expires\",\n\t\t\tdoer: func(t *testing.T, s store.Interface) error {\n\t\t\t\tif err := s.Set(t.Context(), t.Name(), []byte(t.Name()), 150*time.Millisecond); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\n\t\t\t\t//nosleep:bypass XXX(Xe): use Go's time faking thing in Go 1.25 when that is released.\n\t\t\t\ttime.Sleep(155 * time.Millisecond)\n\n\t\t\t\tif _, err := s.Get(t.Context(), t.Name()); !errors.Is(err, store.ErrNotFound) {\n\t\t\t\t\tt.Errorf(\"wanted %s to not exist in store but it exists anyways\", t.Name())\n\t\t\t\t}\n\n\t\t\t\treturn nil\n\t\t\t},\n\t\t},\n\t} {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tt.Parallel()\n\t\t\tif err := tt.doer(t, s); !errors.Is(err, tt.err) {\n\t\t\t\tt.Logf(\"want: %v\", tt.err)\n\t\t\t\tt.Logf(\"got:  %v\", err)\n\t\t\t\tt.Error(\"wrong error\")\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "lib/store/valkey/factory.go",
    "content": "package valkey\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"time\"\n\n\t\"github.com/TecharoHQ/anubis/internal\"\n\t\"github.com/TecharoHQ/anubis/lib/store\"\n\tvalkey \"github.com/redis/go-redis/v9\"\n\t\"github.com/redis/go-redis/v9/maintnotifications\"\n)\n\nfunc init() {\n\tstore.Register(\"valkey\", Factory{})\n}\n\nvar (\n\tErrNoURL  = errors.New(\"valkey.Config: no URL defined\")\n\tErrBadURL = errors.New(\"valkey.Config: URL is invalid\")\n\n\t// Sentinel validation errors\n\tErrSentinelMasterNameRequired = errors.New(\"valkey.Sentinel: masterName is required\")\n\tErrSentinelAddrRequired       = errors.New(\"valkey.Sentinel: addr is required\")\n\tErrSentinelAddrEmpty          = errors.New(\"valkey.Sentinel: addr cannot be empty\")\n)\n\n// Config is what Anubis unmarshals from the \"parameters\" JSON.\ntype Config struct {\n\tURL     string `json:\"url\"`\n\tCluster bool   `json:\"cluster,omitempty\"`\n\n\tSentinel *Sentinel `json:\"sentinel,omitempty\"`\n}\n\nfunc (c Config) Valid() error {\n\tvar errs []error\n\n\tif c.URL == \"\" && c.Sentinel == nil {\n\t\terrs = append(errs, ErrNoURL)\n\t}\n\n\t// Validate URL only if provided\n\tif c.URL != \"\" {\n\t\tif _, err := valkey.ParseURL(c.URL); err != nil {\n\t\t\terrs = append(errs, fmt.Errorf(\"%w: %v\", ErrBadURL, err))\n\t\t}\n\t}\n\n\tif c.Sentinel != nil {\n\t\tif err := c.Sentinel.Valid(); err != nil {\n\t\t\terrs = append(errs, err)\n\t\t}\n\t}\n\n\tif len(errs) > 0 {\n\t\treturn errors.Join(errs...)\n\t}\n\n\treturn nil\n}\n\ntype Sentinel struct {\n\tMasterName string                  `json:\"masterName\"`\n\tAddr       internal.ListOr[string] `json:\"addr\"`\n\tClientName string                  `json:\"clientName,omitempty\"`\n\tUsername   string                  `json:\"username,omitempty\"`\n\tPassword   string                  `json:\"password,omitempty\"`\n}\n\nfunc (s Sentinel) Valid() error {\n\tvar errs []error\n\n\tif s.MasterName == \"\" {\n\t\terrs = append(errs, ErrSentinelMasterNameRequired)\n\t}\n\n\tif len(s.Addr) == 0 {\n\t\terrs = append(errs, ErrSentinelAddrRequired)\n\t} else {\n\t\t// Check if all addresses in the list are empty\n\t\tallEmpty := true\n\t\tfor _, addr := range s.Addr {\n\t\t\tif addr != \"\" {\n\t\t\t\tallEmpty = false\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif allEmpty {\n\t\t\terrs = append(errs, ErrSentinelAddrEmpty)\n\t\t}\n\t}\n\n\tif len(errs) > 0 {\n\t\treturn errors.Join(errs...)\n\t}\n\n\treturn nil\n}\n\n// redisClient is satisfied by *valkey.Client and *valkey.ClusterClient.\ntype redisClient interface {\n\tGet(ctx context.Context, key string) *valkey.StringCmd\n\tSet(ctx context.Context, key string, value any, expiration time.Duration) *valkey.StatusCmd\n\tDel(ctx context.Context, keys ...string) *valkey.IntCmd\n\tPing(ctx context.Context) *valkey.StatusCmd\n}\n\ntype Factory struct{}\n\nfunc (Factory) Valid(data json.RawMessage) error {\n\tvar cfg Config\n\tif err := json.Unmarshal(data, &cfg); err != nil {\n\t\treturn err\n\t}\n\treturn cfg.Valid()\n}\n\nfunc (Factory) Build(ctx context.Context, data json.RawMessage) (store.Interface, error) {\n\tvar cfg Config\n\tif err := json.Unmarshal(data, &cfg); err != nil {\n\t\treturn nil, err\n\t}\n\tif err := cfg.Valid(); err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar client redisClient\n\n\tswitch {\n\tcase cfg.Cluster:\n\t\topts, err := valkey.ParseURL(cfg.URL)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"valkey.Factory: %w\", err)\n\t\t}\n\n\t\t// Cluster mode: use the parsed Addr as the seed node.\n\t\tclusterOpts := &valkey.ClusterOptions{\n\t\t\tAddrs: []string{opts.Addr},\n\t\t\t// Explicitly disable maintenance notifications\n\t\t\t// This prevents the client from sending CLIENT MAINT_NOTIFICATIONS ON\n\t\t\tMaintNotificationsConfig: &maintnotifications.Config{\n\t\t\t\tMode: maintnotifications.ModeDisabled,\n\t\t\t},\n\t\t}\n\t\tclient = valkey.NewClusterClient(clusterOpts)\n\tcase cfg.Sentinel != nil:\n\t\topts := &valkey.FailoverOptions{\n\t\t\tMasterName:       cfg.Sentinel.MasterName,\n\t\t\tSentinelAddrs:    cfg.Sentinel.Addr,\n\t\t\tSentinelUsername: cfg.Sentinel.Username,\n\t\t\tSentinelPassword: cfg.Sentinel.Password,\n\t\t\tUsername:         cfg.Sentinel.Username,\n\t\t\tPassword:         cfg.Sentinel.Password,\n\t\t\tClientName:       cfg.Sentinel.ClientName,\n\t\t}\n\t\tclient = valkey.NewFailoverClusterClient(opts)\n\tdefault:\n\t\topts, err := valkey.ParseURL(cfg.URL)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"valkey.Factory: %w\", err)\n\t\t}\n\n\t\topts.MaintNotificationsConfig = &maintnotifications.Config{\n\t\t\tMode: maintnotifications.ModeDisabled,\n\t\t}\n\t\tclient = valkey.NewClient(opts)\n\t}\n\n\t// Optional but nice: fail fast if the cluster/single node is unreachable.\n\tif err := client.Ping(ctx).Err(); err != nil {\n\t\treturn nil, fmt.Errorf(\"valkey.Factory: ping failed: %w\", err)\n\t}\n\n\treturn &Store{client: client}, nil\n}\n"
  },
  {
    "path": "lib/store/valkey/valkey.go",
    "content": "package valkey\n\nimport (\n\t\"context\"\n\t\"time\"\n\n\t\"github.com/TecharoHQ/anubis/lib/store\"\n\tvalkey \"github.com/redis/go-redis/v9\"\n)\n\n// Store implements store.Interface on top of Redis/Valkey.\ntype Store struct {\n\tclient redisClient\n}\n\nvar _ store.Interface = (*Store)(nil)\n\nfunc (s *Store) Get(ctx context.Context, key string) ([]byte, error) {\n\tcmd := s.client.Get(ctx, key)\n\tif err := cmd.Err(); err != nil {\n\t\tif err == valkey.Nil {\n\t\t\treturn nil, store.ErrNotFound\n\t\t}\n\t\treturn nil, err\n\t}\n\treturn cmd.Bytes()\n}\n\nfunc (s *Store) Set(ctx context.Context, key string, value []byte, expiry time.Duration) error {\n\treturn s.client.Set(ctx, key, value, expiry).Err()\n}\n\nfunc (s *Store) Delete(ctx context.Context, key string) error {\n\tres := s.client.Del(ctx, key)\n\tif err := res.Err(); err != nil {\n\t\treturn err\n\t}\n\tif n, _ := res.Result(); n == 0 {\n\t\treturn store.ErrNotFound\n\t}\n\treturn nil\n}\n\n// IsPersistent tells Anubis this backend is “real” storage, not in-memory.\nfunc (s *Store) IsPersistent() bool {\n\treturn true\n}\n"
  },
  {
    "path": "lib/store/valkey/valkey_test.go",
    "content": "package valkey\n\nimport (\n\t\"encoding/json\"\n\t\"errors\"\n\t\"os\"\n\t\"testing\"\n\n\t\"github.com/TecharoHQ/anubis/lib/store/storetest\"\n\t\"github.com/testcontainers/testcontainers-go\"\n\t\"github.com/testcontainers/testcontainers-go/wait\"\n)\n\nfunc TestImpl(t *testing.T) {\n\tif os.Getenv(\"DONT_USE_NETWORK\") != \"\" {\n\t\tt.Skip(\"test requires network egress\")\n\t\treturn\n\t}\n\n\ttestcontainers.SkipIfProviderIsNotHealthy(t)\n\n\tvalkeyC, err := testcontainers.Run(\n\t\tt.Context(), \"valkey/valkey:8\",\n\t\ttestcontainers.WithExposedPorts(\"6379/tcp\"),\n\t\ttestcontainers.WithWaitStrategy(\n\t\t\twait.ForListeningPort(\"6379/tcp\"),\n\t\t\twait.ForLog(\"Ready to accept connections\"),\n\t\t),\n\t)\n\ttestcontainers.CleanupContainer(t, valkeyC)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tendpoint, err := valkeyC.PortEndpoint(t.Context(), \"6379/tcp\", \"redis\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tdata, err := json.Marshal(Config{\n\t\tURL: endpoint,\n\t})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tstoretest.Common(t, Factory{}, json.RawMessage(data))\n}\n\nfunc TestFactoryValid(t *testing.T) {\n\ttests := []struct {\n\t\tname        string\n\t\tjsonData    string\n\t\texpectError error\n\t}{\n\t\t{\n\t\t\tname:        \"empty config\",\n\t\t\tjsonData:    `{}`,\n\t\t\texpectError: ErrNoURL,\n\t\t},\n\t\t{\n\t\t\tname:        \"valid URL only\",\n\t\t\tjsonData:    `{\"url\": \"redis://localhost:6379\"}`,\n\t\t\texpectError: nil,\n\t\t},\n\t\t{\n\t\t\tname:        \"invalid URL\",\n\t\t\tjsonData:    `{\"url\": \"invalid-url\"}`,\n\t\t\texpectError: ErrBadURL,\n\t\t},\n\t\t{\n\t\t\tname:        \"valid sentinel config\",\n\t\t\tjsonData:    `{\"sentinel\": {\"masterName\": \"mymaster\", \"addr\": [\"localhost:26379\"], \"password\": \"mypass\"}}`,\n\t\t\texpectError: nil,\n\t\t},\n\t\t{\n\t\t\tname:        \"sentinel missing masterName\",\n\t\t\tjsonData:    `{\"sentinel\": {\"addr\": [\"localhost:26379\"], \"password\": \"mypass\"}}`,\n\t\t\texpectError: ErrSentinelMasterNameRequired,\n\t\t},\n\t\t{\n\t\t\tname:        \"sentinel missing addr\",\n\t\t\tjsonData:    `{\"sentinel\": {\"masterName\": \"mymaster\", \"password\": \"mypass\"}}`,\n\t\t\texpectError: ErrSentinelAddrRequired,\n\t\t},\n\t\t{\n\t\t\tname:        \"sentinel empty addr\",\n\t\t\tjsonData:    `{\"sentinel\": {\"masterName\": \"mymaster\", \"addr\": [\"\"], \"password\": \"mypass\"}}`,\n\t\t\texpectError: ErrSentinelAddrEmpty,\n\t\t},\n\t\t{\n\t\t\tname:        \"sentinel missing password\",\n\t\t\tjsonData:    `{\"sentinel\": {\"masterName\": \"mymaster\", \"addr\": [\"localhost:26379\"]}}`,\n\t\t\texpectError: nil,\n\t\t},\n\t\t{\n\t\t\tname:        \"sentinel with optional fields\",\n\t\t\tjsonData:    `{\"sentinel\": {\"masterName\": \"mymaster\", \"addr\": [\"localhost:26379\"], \"password\": \"mypass\", \"clientName\": \"myclient\", \"username\": \"myuser\"}}`,\n\t\t\texpectError: nil,\n\t\t},\n\t\t{\n\t\t\tname:        \"sentinel single address (not array)\",\n\t\t\tjsonData:    `{\"sentinel\": {\"masterName\": \"mymaster\", \"addr\": \"localhost:26379\", \"password\": \"mypass\"}}`,\n\t\t\texpectError: nil,\n\t\t},\n\t\t{\n\t\t\tname:        \"sentinel mixed empty and valid addresses\",\n\t\t\tjsonData:    `{\"sentinel\": {\"masterName\": \"mymaster\", \"addr\": [\"\", \"localhost:26379\", \"\"], \"password\": \"mypass\"}}`,\n\t\t\texpectError: nil,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tfactory := Factory{}\n\t\t\terr := factory.Valid(json.RawMessage(tt.jsonData))\n\n\t\t\tif tt.expectError == nil {\n\t\t\t\tif err != nil {\n\t\t\t\t\tt.Errorf(\"expected no error, got: %v\", err)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tif err == nil {\n\t\t\t\t\tt.Errorf(\"expected error %v, got nil\", tt.expectError)\n\t\t\t\t} else if !errors.Is(err, tt.expectError) {\n\t\t\t\t\tt.Errorf(\"expected error %v, got: %v\", tt.expectError, err)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "lib/testdata/aggressive_403.yaml",
    "content": "bots:\n  - name: deny\n    user_agent_regex: DENY\n    action: DENY\n\n  - name: challenge\n    user_agent_regex: CHALLENGE\n    action: CHALLENGE\n\nstatus_codes:\n  CHALLENGE: 401\n  DENY: 403\n"
  },
  {
    "path": "lib/testdata/cloudflare-workers-cel.yaml",
    "content": "bots:\n  - name: cloudflare-workers\n    expression: '\"Cf-Worker\" in headers'\n    action: DENY\n\nstatus_codes:\n  CHALLENGE: 401\n  DENY: 403\n"
  },
  {
    "path": "lib/testdata/cloudflare-workers-header.yaml",
    "content": "bots:\n  - name: cloudflare-workers\n    headers_regex:\n      CF-Worker: .*\n    action: DENY\n\nstatus_codes:\n  CHALLENGE: 401\n  DENY: 403\n"
  },
  {
    "path": "lib/testdata/hack-test.json",
    "content": "[\n  {\n    \"name\": \"ipv6-ula\",\n    \"action\": \"ALLOW\",\n    \"remote_addresses\": [\"fc00::/7\"]\n  }\n]\n"
  },
  {
    "path": "lib/testdata/hack-test.yaml",
    "content": "- name: well-known\n  path_regex: ^/.well-known/.*$\n  action: ALLOW\n"
  },
  {
    "path": "lib/testdata/invalid-challenge-method.yaml",
    "content": "bots:\n  - name: generic-bot-catchall\n    user_agent_regex: (?i:bot|crawler)\n    action: CHALLENGE\n    challenge:\n      difficulty: 16\n      algorithm: hunter2 # invalid algorithm\n"
  },
  {
    "path": "lib/testdata/permissive.yaml",
    "content": "bots:\n  - import: (data)/common/allow-private-addresses.yaml\n\ndnsbl: false\n"
  },
  {
    "path": "lib/testdata/rule_change.yaml",
    "content": "bots:\n  - name: old-rule\n    path_regex: ^/old$\n    action: CHALLENGE\n\n  - name: new-rule\n    path_regex: ^/new$\n    action: CHALLENGE\n\nstatus_codes:\n  CHALLENGE: 401\n  DENY: 403\n"
  },
  {
    "path": "lib/testdata/test_config.yaml",
    "content": "bots:\n  - import: (data)/bots/_deny-pathological.yaml\n  - import: (data)/bots/aggressive-brazilian-scrapers.yaml\n  - import: (data)/meta/ai-block-aggressive.yaml\n  - import: (data)/crawlers/_allow-good.yaml\n  - import: (data)/clients/x-firefox-ai.yaml\n  - import: (data)/common/keep-internet-working.yaml\n  - name: countries-with-aggressive-scrapers\n    action: WEIGH\n    geoip:\n      countries:\n        - BR\n        - CN\n    weight:\n      adjust: 10\n  - name: aggressive-asns-without-functional-abuse-contact\n    action: WEIGH\n    asns:\n      match:\n        - 13335 # Cloudflare\n        - 136907 # Huawei Cloud\n        - 45102 # Alibaba Cloud\n    weight:\n      adjust: 10\n  - name: generic-browser\n    user_agent_regex: >-\n      Mozilla|Opera\n    action: WEIGH\n    weight:\n      adjust: 10\n\ndnsbl: false\n\nstatus_codes:\n  CHALLENGE: 200\n  DENY: 200\n\nthresholds:\n  - name: minimal-suspicion\n    expression: \"true\"\n    action: CHALLENGE\n    challenge:\n      algorithm: fast\n      difficulty: 1\n"
  },
  {
    "path": "lib/testdata/test_config_no_thresholds.yaml",
    "content": "bots:\n  - import: (data)/bots/_deny-pathological.yaml\n  - import: (data)/bots/aggressive-brazilian-scrapers.yaml\n  - import: (data)/meta/ai-block-aggressive.yaml\n  - import: (data)/crawlers/_allow-good.yaml\n  - import: (data)/clients/x-firefox-ai.yaml\n  - import: (data)/common/keep-internet-working.yaml\n  - name: countries-with-aggressive-scrapers\n    action: WEIGH\n    geoip:\n      countries:\n        - BR\n        - CN\n    weight:\n      adjust: 10\n  - name: aggressive-asns-without-functional-abuse-contact\n    action: WEIGH\n    asns:\n      match:\n        - 13335 # Cloudflare\n        - 136907 # Huawei Cloud\n        - 45102 # Alibaba Cloud\n    weight:\n      adjust: 10\n  - name: generic-browser\n    user_agent_regex: >-\n      Mozilla|Opera\n    action: WEIGH\n    weight:\n      adjust: 10\n\ndnsbl: false\n\nstatus_codes:\n  CHALLENGE: 200\n  DENY: 200\n\nthresholds: []\n"
  },
  {
    "path": "lib/testdata/useragent.yaml",
    "content": "bots:\n  - name: deny\n    user_agent_regex: DENY\n    action: DENY\n\n  - name: challenge\n    user_agent_regex: CHALLENGE\n    action: CHALLENGE\n\n  - name: allow\n    user_agent_regex: ALLOW\n    action: ALLOW\n"
  },
  {
    "path": "lib/testdata/zero_difficulty.yaml",
    "content": "bots:\n  - import: (data)/bots/_deny-pathological.yaml\n  - import: (data)/bots/aggressive-brazilian-scrapers.yaml\n  - import: (data)/meta/ai-block-aggressive.yaml\n  - import: (data)/crawlers/_allow-good.yaml\n  - import: (data)/clients/x-firefox-ai.yaml\n  - import: (data)/common/keep-internet-working.yaml\n  - name: countries-with-aggressive-scrapers\n    action: WEIGH\n    geoip:\n      countries:\n        - BR\n        - CN\n    weight:\n      adjust: 10\n  - name: aggressive-asns-without-functional-abuse-contact\n    action: WEIGH\n    asns:\n      match:\n        - 13335 # Cloudflare\n        - 136907 # Huawei Cloud\n        - 45102 # Alibaba Cloud\n    weight:\n      adjust: 10\n  - name: generic-browser\n    user_agent_regex: >-\n      Mozilla|Opera\n    action: WEIGH\n    weight:\n      adjust: 10\n\ndnsbl: false\n\nstatus_codes:\n  CHALLENGE: 200\n  DENY: 200\n\nthresholds:\n  - name: minimal-suspicion\n    expression: \"true\"\n    action: CHALLENGE\n    challenge:\n      algorithm: fast\n      difficulty: 0\n"
  },
  {
    "path": "lib/thoth/asnchecker.go",
    "content": "package thoth\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"log/slog\"\n\t\"net/http\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/TecharoHQ/anubis/internal\"\n\t\"github.com/TecharoHQ/anubis/lib/policy/checker\"\n\tiptoasnv1 \"github.com/TecharoHQ/thoth-proto/gen/techaro/thoth/iptoasn/v1\"\n)\n\nfunc (c *Client) ASNCheckerFor(asns []uint32) checker.Impl {\n\tasnMap := map[uint32]struct{}{}\n\tvar sb strings.Builder\n\tfmt.Fprintln(&sb, \"ASNChecker\")\n\tfor _, asn := range asns {\n\t\tasnMap[asn] = struct{}{}\n\t\tfmt.Fprintln(&sb, \"AS\", asn)\n\t}\n\n\treturn &ASNChecker{\n\t\tiptoasn: c.IPToASN,\n\t\tasns:    asnMap,\n\t\thash:    internal.FastHash(sb.String()),\n\t}\n}\n\ntype ASNChecker struct {\n\tiptoasn iptoasnv1.IpToASNServiceClient\n\tasns    map[uint32]struct{}\n\thash    string\n}\n\nfunc (asnc *ASNChecker) Check(r *http.Request) (bool, error) {\n\tctx, cancel := context.WithTimeout(r.Context(), 500*time.Millisecond)\n\tdefer cancel()\n\n\tipInfo, err := asnc.iptoasn.Lookup(ctx, &iptoasnv1.LookupRequest{\n\t\tIpAddress: r.Header.Get(\"X-Real-Ip\"),\n\t})\n\tif err != nil {\n\t\tswitch {\n\t\tcase errors.Is(err, context.DeadlineExceeded):\n\t\t\tslog.Debug(\"error contacting thoth\", \"err\", err, \"actionable\", false)\n\t\t\treturn false, nil\n\t\tdefault:\n\t\t\tslog.Error(\"error contacting thoth, please contact support\", \"err\", err, \"actionable\", true)\n\t\t\treturn false, nil\n\t\t}\n\t}\n\n\t// If IP is not publicly announced, return false\n\tif !ipInfo.GetAnnounced() {\n\t\treturn false, nil\n\t}\n\n\t_, ok := asnc.asns[uint32(ipInfo.GetAsNumber())]\n\n\treturn ok, nil\n}\n\nfunc (asnc *ASNChecker) Hash() string {\n\treturn asnc.hash\n}\n"
  },
  {
    "path": "lib/thoth/asnchecker_test.go",
    "content": "package thoth_test\n\nimport (\n\t\"fmt\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\n\t\"github.com/TecharoHQ/anubis/lib/policy/checker\"\n\t\"github.com/TecharoHQ/anubis/lib/thoth\"\n\tiptoasnv1 \"github.com/TecharoHQ/thoth-proto/gen/techaro/thoth/iptoasn/v1\"\n)\n\nvar _ checker.Impl = &thoth.ASNChecker{}\n\nfunc TestASNChecker(t *testing.T) {\n\tcli := loadSecrets(t)\n\n\tasnc := cli.ASNCheckerFor([]uint32{13335})\n\n\tfor _, cs := range []struct {\n\t\tipAddress string\n\t\twantMatch bool\n\t\twantError bool\n\t}{\n\t\t{\n\t\t\tipAddress: \"1.1.1.1\",\n\t\t\twantMatch: true,\n\t\t\twantError: false,\n\t\t},\n\t\t{\n\t\t\tipAddress: \"2.2.2.2\",\n\t\t\twantMatch: false,\n\t\t\twantError: false,\n\t\t},\n\t\t{\n\t\t\tipAddress: \"taco\",\n\t\t\twantMatch: false,\n\t\t\twantError: false,\n\t\t},\n\t\t{\n\t\t\tipAddress: \"127.0.0.1\",\n\t\t\twantMatch: false,\n\t\t\twantError: false,\n\t\t},\n\t} {\n\t\tt.Run(fmt.Sprintf(\"%v\", cs), func(t *testing.T) {\n\t\t\treq := httptest.NewRequest(\"GET\", \"/\", nil)\n\t\t\treq.Header.Set(\"X-Real-Ip\", cs.ipAddress)\n\n\t\t\tmatch, err := asnc.Check(req)\n\n\t\t\tif match != cs.wantMatch {\n\t\t\t\tt.Errorf(\"Wanted match: %v, got: %v\", cs.wantMatch, match)\n\t\t\t}\n\n\t\t\tswitch {\n\t\t\tcase err != nil && !cs.wantError:\n\t\t\t\tt.Errorf(\"Did not want error but got: %v\", err)\n\t\t\tcase err == nil && cs.wantError:\n\t\t\t\tt.Error(\"Wanted error but got none\")\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc BenchmarkWithCache(b *testing.B) {\n\tcli := loadSecrets(b)\n\treq := &iptoasnv1.LookupRequest{IpAddress: \"1.1.1.1\"}\n\n\t_, err := cli.IPToASN.Lookup(b.Context(), req)\n\tif err != nil {\n\t\tb.Error(err)\n\t}\n\n\tfor b.Loop() {\n\t\t_, err := cli.IPToASN.Lookup(b.Context(), req)\n\t\tif err != nil {\n\t\t\tb.Error(err)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "lib/thoth/auth.go",
    "content": "package thoth\n\nimport (\n\t\"context\"\n\n\t\"google.golang.org/grpc\"\n\t\"google.golang.org/grpc/metadata\"\n)\n\nfunc authUnaryClientInterceptor(token string) grpc.UnaryClientInterceptor {\n\treturn func(\n\t\tctx context.Context,\n\t\tmethod string,\n\t\treq any,\n\t\treply any,\n\t\tcc *grpc.ClientConn,\n\t\tinvoker grpc.UnaryInvoker,\n\t\topts ...grpc.CallOption,\n\t) error {\n\t\tmd := metadata.Pairs(\"authorization\", \"Bearer \"+token)\n\t\tctx = metadata.NewOutgoingContext(ctx, md)\n\t\treturn invoker(ctx, method, req, reply, cc, opts...)\n\t}\n}\n\nfunc authStreamClientInterceptor(token string) grpc.StreamClientInterceptor {\n\treturn func(\n\t\tctx context.Context,\n\t\tdesc *grpc.StreamDesc,\n\t\tcc *grpc.ClientConn,\n\t\tmethod string,\n\t\tstreamer grpc.Streamer,\n\t\topts ...grpc.CallOption,\n\t) (grpc.ClientStream, error) {\n\t\tmd := metadata.Pairs(\"authorization\", \"Bearer \"+token)\n\t\tctx = metadata.NewOutgoingContext(ctx, md)\n\t\treturn streamer(ctx, desc, cc, method, opts...)\n\t}\n}\n"
  },
  {
    "path": "lib/thoth/cachediptoasn.go",
    "content": "package thoth\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"log/slog\"\n\t\"net/netip\"\n\n\tiptoasnv1 \"github.com/TecharoHQ/thoth-proto/gen/techaro/thoth/iptoasn/v1\"\n\t\"github.com/gaissmai/bart\"\n\t\"google.golang.org/grpc\"\n)\n\ntype IPToASNWithCache struct {\n\tnext  iptoasnv1.IpToASNServiceClient\n\ttable *bart.Table[*iptoasnv1.LookupResponse]\n}\n\nfunc NewIpToASNWithCache(next iptoasnv1.IpToASNServiceClient) *IPToASNWithCache {\n\tresult := &IPToASNWithCache{\n\t\tnext:  next,\n\t\ttable: &bart.Table[*iptoasnv1.LookupResponse]{},\n\t}\n\n\tfor _, pfx := range []netip.Prefix{\n\t\tnetip.MustParsePrefix(\"10.0.0.0/8\"),         // RFC 1918\n\t\tnetip.MustParsePrefix(\"172.16.0.0/12\"),      // RFC 1918\n\t\tnetip.MustParsePrefix(\"192.168.0.0/16\"),     // RFC 1918\n\t\tnetip.MustParsePrefix(\"127.0.0.0/8\"),        // Loopback\n\t\tnetip.MustParsePrefix(\"169.254.0.0/16\"),     // Link-local\n\t\tnetip.MustParsePrefix(\"100.64.0.0/10\"),      // CGNAT\n\t\tnetip.MustParsePrefix(\"192.0.0.0/24\"),       // Protocol assignments\n\t\tnetip.MustParsePrefix(\"192.0.2.0/24\"),       // TEST-NET-1\n\t\tnetip.MustParsePrefix(\"198.18.0.0/15\"),      // Benchmarking\n\t\tnetip.MustParsePrefix(\"198.51.100.0/24\"),    // TEST-NET-2\n\t\tnetip.MustParsePrefix(\"203.0.113.0/24\"),     // TEST-NET-3\n\t\tnetip.MustParsePrefix(\"240.0.0.0/4\"),        // Reserved\n\t\tnetip.MustParsePrefix(\"255.255.255.255/32\"), // Broadcast\n\t\tnetip.MustParsePrefix(\"fc00::/7\"),           // Unique local address\n\t\tnetip.MustParsePrefix(\"fe80::/10\"),          // Link-local\n\t\tnetip.MustParsePrefix(\"::1/128\"),            // Loopback\n\t\tnetip.MustParsePrefix(\"::/128\"),             // Unspecified\n\t\tnetip.MustParsePrefix(\"100::/64\"),           // Discard-only\n\t\tnetip.MustParsePrefix(\"2001:db8::/32\"),      // Documentation\n\t} {\n\t\tresult.table.Insert(pfx, &iptoasnv1.LookupResponse{Announced: false})\n\t}\n\n\treturn result\n}\n\nfunc (ip2asn *IPToASNWithCache) Lookup(ctx context.Context, lr *iptoasnv1.LookupRequest, opts ...grpc.CallOption) (*iptoasnv1.LookupResponse, error) {\n\taddr, err := netip.ParseAddr(lr.GetIpAddress())\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"input is not an IP address: %w\", err)\n\t}\n\n\tcachedResponse, ok := ip2asn.table.Lookup(addr)\n\tif ok {\n\t\treturn cachedResponse, nil\n\t}\n\n\tresp, err := ip2asn.next.Lookup(ctx, lr, opts...)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar errs []error\n\tfor _, cidr := range resp.GetCidr() {\n\t\tpfx, err := netip.ParsePrefix(cidr)\n\t\tif err != nil {\n\t\t\terrs = append(errs, err)\n\t\t\tcontinue\n\t\t}\n\t\tip2asn.table.Insert(pfx, resp)\n\t}\n\n\tif len(errs) != 0 {\n\t\tslog.Error(\"errors parsing IP prefixes\", \"err\", errors.Join(errs...))\n\t}\n\n\treturn resp, nil\n}\n"
  },
  {
    "path": "lib/thoth/context.go",
    "content": "package thoth\n\nimport \"context\"\n\ntype ctxKey struct{}\n\nfunc With(ctx context.Context, cli *Client) context.Context {\n\treturn context.WithValue(ctx, ctxKey{}, cli)\n}\n\nfunc FromContext(ctx context.Context) (*Client, bool) {\n\tcli, ok := ctx.Value(ctxKey{}).(*Client)\n\treturn cli, ok\n}\n"
  },
  {
    "path": "lib/thoth/geoipchecker.go",
    "content": "package thoth\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"log/slog\"\n\t\"net/http\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/TecharoHQ/anubis/lib/policy/checker\"\n\tiptoasnv1 \"github.com/TecharoHQ/thoth-proto/gen/techaro/thoth/iptoasn/v1\"\n)\n\nfunc (c *Client) GeoIPCheckerFor(countries []string) checker.Impl {\n\tcountryMap := map[string]struct{}{}\n\tvar sb strings.Builder\n\tfmt.Fprintln(&sb, \"GeoIPChecker\")\n\tfor _, cc := range countries {\n\t\tcountryMap[cc] = struct{}{}\n\t\tfmt.Fprintln(&sb, cc)\n\t}\n\n\treturn &GeoIPChecker{\n\t\tIPToASN:   c.IPToASN,\n\t\tCountries: countryMap,\n\t\thash:      sb.String(),\n\t}\n}\n\ntype GeoIPChecker struct {\n\tIPToASN   iptoasnv1.IpToASNServiceClient\n\tCountries map[string]struct{}\n\thash      string\n}\n\nfunc (gipc *GeoIPChecker) Check(r *http.Request) (bool, error) {\n\tctx, cancel := context.WithTimeout(r.Context(), 500*time.Millisecond)\n\tdefer cancel()\n\n\tipInfo, err := gipc.IPToASN.Lookup(ctx, &iptoasnv1.LookupRequest{\n\t\tIpAddress: r.Header.Get(\"X-Real-Ip\"),\n\t})\n\tif err != nil {\n\t\tswitch {\n\t\tcase errors.Is(err, context.DeadlineExceeded):\n\t\t\tslog.Debug(\"error contacting thoth\", \"err\", err, \"actionable\", false)\n\t\t\treturn false, nil\n\t\tdefault:\n\t\t\tslog.Error(\"error contacting thoth, please contact support\", \"err\", err, \"actionable\", true)\n\t\t\treturn false, nil\n\t\t}\n\t}\n\n\t// If IP is not publicly announced, return false\n\tif !ipInfo.GetAnnounced() {\n\t\treturn false, nil\n\t}\n\n\t_, ok := gipc.Countries[strings.ToLower(ipInfo.GetCountryCode())]\n\n\treturn ok, nil\n}\n\nfunc (gipc *GeoIPChecker) Hash() string {\n\treturn gipc.hash\n}\n"
  },
  {
    "path": "lib/thoth/geoipchecker_test.go",
    "content": "package thoth_test\n\nimport (\n\t\"fmt\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\n\t\"github.com/TecharoHQ/anubis/lib/policy/checker\"\n\t\"github.com/TecharoHQ/anubis/lib/thoth\"\n)\n\nvar _ checker.Impl = &thoth.GeoIPChecker{}\n\nfunc TestGeoIPChecker(t *testing.T) {\n\tcli := loadSecrets(t)\n\n\tasnc := cli.GeoIPCheckerFor([]string{\"us\"})\n\n\tfor _, cs := range []struct {\n\t\tipAddress string\n\t\twantMatch bool\n\t\twantError bool\n\t}{\n\t\t{\n\t\t\tipAddress: \"1.1.1.1\",\n\t\t\twantMatch: true,\n\t\t\twantError: false,\n\t\t},\n\t\t{\n\t\t\tipAddress: \"2.2.2.2\",\n\t\t\twantMatch: false,\n\t\t\twantError: false,\n\t\t},\n\t\t{\n\t\t\tipAddress: \"taco\",\n\t\t\twantMatch: false,\n\t\t\twantError: false,\n\t\t},\n\t\t{\n\t\t\tipAddress: \"127.0.0.1\",\n\t\t\twantMatch: false,\n\t\t\twantError: false,\n\t\t},\n\t} {\n\t\tt.Run(fmt.Sprintf(\"%v\", cs), func(t *testing.T) {\n\t\t\treq := httptest.NewRequest(\"GET\", \"/\", nil)\n\t\t\treq.Header.Set(\"X-Real-Ip\", cs.ipAddress)\n\n\t\t\tmatch, err := asnc.Check(req)\n\n\t\t\tif match != cs.wantMatch {\n\t\t\t\tt.Errorf(\"Wanted match: %v, got: %v\", cs.wantMatch, match)\n\t\t\t}\n\n\t\t\tswitch {\n\t\t\tcase err != nil && !cs.wantError:\n\t\t\t\tt.Errorf(\"Did not want error but got: %v\", err)\n\t\t\tcase err == nil && cs.wantError:\n\t\t\t\tt.Error(\"Wanted error but got none\")\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "lib/thoth/thoth.go",
    "content": "package thoth\n\nimport (\n\t\"context\"\n\t\"crypto/tls\"\n\t\"fmt\"\n\t\"time\"\n\n\t\"github.com/TecharoHQ/anubis\"\n\tiptoasnv1 \"github.com/TecharoHQ/thoth-proto/gen/techaro/thoth/iptoasn/v1\"\n\tgrpcprom \"github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus\"\n\t\"github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/timeout\"\n\t\"github.com/prometheus/client_golang/prometheus\"\n\t\"google.golang.org/grpc\"\n\t\"google.golang.org/grpc/credentials\"\n\t\"google.golang.org/grpc/credentials/insecure\"\n\thealthv1 \"google.golang.org/grpc/health/grpc_health_v1\"\n)\n\ntype Client struct {\n\tconn    *grpc.ClientConn\n\thealth  healthv1.HealthClient\n\tIPToASN iptoasnv1.IpToASNServiceClient\n}\n\nfunc New(ctx context.Context, thothURL, apiToken string, plaintext bool) (*Client, error) {\n\tclMetrics := grpcprom.NewClientMetrics(\n\t\tgrpcprom.WithClientHandlingTimeHistogram(\n\t\t\tgrpcprom.WithHistogramBuckets([]float64{0.001, 0.01, 0.1, 0.3, 0.6, 1, 3, 6, 9, 20, 30, 60, 90, 120}),\n\t\t),\n\t)\n\tprometheus.DefaultRegisterer.Register(clMetrics)\n\n\tdo := []grpc.DialOption{\n\t\tgrpc.WithChainUnaryInterceptor(\n\t\t\ttimeout.UnaryClientInterceptor(500*time.Millisecond),\n\t\t\tclMetrics.UnaryClientInterceptor(),\n\t\t\tauthUnaryClientInterceptor(apiToken),\n\t\t),\n\t\tgrpc.WithChainStreamInterceptor(\n\t\t\tclMetrics.StreamClientInterceptor(),\n\t\t\tauthStreamClientInterceptor(apiToken),\n\t\t),\n\t\tgrpc.WithUserAgent(fmt.Sprint(\"Techaro/anubis:\", anubis.Version)),\n\t}\n\n\tif plaintext {\n\t\tdo = append(do, grpc.WithTransportCredentials(insecure.NewCredentials()))\n\t} else {\n\t\tdo = append(do, grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{})))\n\t}\n\n\tconn, err := grpc.NewClient(\n\t\tthothURL,\n\t\tdo...,\n\t)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"can't dial thoth at %s: %w\", thothURL, err)\n\t}\n\n\thc := healthv1.NewHealthClient(conn)\n\n\treturn &Client{\n\t\tconn:    conn,\n\t\thealth:  hc,\n\t\tIPToASN: NewIpToASNWithCache(iptoasnv1.NewIpToASNServiceClient(conn)),\n\t}, nil\n}\n\nfunc (c *Client) Close() error {\n\tif c.conn != nil {\n\t\treturn c.conn.Close()\n\t}\n\treturn nil\n}\n\nfunc (c *Client) WithIPToASNService(impl iptoasnv1.IpToASNServiceClient) {\n\tc.IPToASN = impl\n}\n"
  },
  {
    "path": "lib/thoth/thoth_test.go",
    "content": "package thoth_test\n\nimport (\n\t\"os\"\n\t\"testing\"\n\n\t\"github.com/TecharoHQ/anubis/lib/thoth\"\n\t\"github.com/TecharoHQ/anubis/lib/thoth/thothmock\"\n\t\"github.com/joho/godotenv\"\n)\n\nfunc loadSecrets(t testing.TB) *thoth.Client {\n\tt.Helper()\n\n\tif err := godotenv.Load(); err != nil {\n\t\tt.Log(\"using mock thoth\")\n\t\tresult := &thoth.Client{}\n\t\tresult.WithIPToASNService(thothmock.MockIpToASNService())\n\t\treturn result\n\t}\n\n\tcli, err := thoth.New(t.Context(), os.Getenv(\"THOTH_URL\"), os.Getenv(\"THOTH_API_KEY\"), false)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\treturn cli\n}\n\nfunc TestNew(t *testing.T) {\n\tcli := loadSecrets(t)\n\n\tif err := cli.Close(); err != nil {\n\t\tt.Fatal(err)\n\t}\n}\n"
  },
  {
    "path": "lib/thoth/thothmock/iptoasn.go",
    "content": "package thothmock\n\nimport (\n\t\"context\"\n\t\"net/netip\"\n\n\tiptoasnv1 \"github.com/TecharoHQ/thoth-proto/gen/techaro/thoth/iptoasn/v1\"\n\t\"google.golang.org/grpc\"\n\t\"google.golang.org/grpc/codes\"\n\t\"google.golang.org/grpc/status\"\n)\n\nfunc MockIpToASNService() *IpToASNService {\n\tresponses := map[string]*iptoasnv1.LookupResponse{\n\t\t\"127.0.0.1\": {Announced: false},\n\t\t\"::1\":       {Announced: false},\n\t\t\"10.10.10.10\": {\n\t\t\tAnnounced:   true,\n\t\t\tAsNumber:    13335,\n\t\t\tCidr:        []string{\"1.1.1.0/24\"},\n\t\t\tCountryCode: \"US\",\n\t\t\tDescription: \"Cloudflare\",\n\t\t},\n\t\t\"2.2.2.2\": {\n\t\t\tAnnounced:   true,\n\t\t\tAsNumber:    420,\n\t\t\tCidr:        []string{\"2.2.2.0/24\"},\n\t\t\tCountryCode: \"CA\",\n\t\t\tDescription: \"test canada\",\n\t\t},\n\t\t\"1.1.1.1\": {\n\t\t\tAnnounced:   true,\n\t\t\tAsNumber:    13335,\n\t\t\tCidr:        []string{\"1.1.1.0/24\"},\n\t\t\tCountryCode: \"US\",\n\t\t\tDescription: \"Cloudflare\",\n\t\t},\n\t}\n\n\treturn &IpToASNService{Responses: responses}\n}\n\ntype IpToASNService struct {\n\tiptoasnv1.UnimplementedIpToASNServiceServer\n\tResponses map[string]*iptoasnv1.LookupResponse\n}\n\nfunc (ip2asn *IpToASNService) Lookup(ctx context.Context, lr *iptoasnv1.LookupRequest, opts ...grpc.CallOption) (*iptoasnv1.LookupResponse, error) {\n\tif _, err := netip.ParseAddr(lr.GetIpAddress()); err != nil {\n\t\treturn nil, err\n\t}\n\n\tresp, ok := ip2asn.Responses[lr.GetIpAddress()]\n\tif !ok {\n\t\treturn nil, status.Error(codes.NotFound, \"IP address not found in mock\")\n\t}\n\n\treturn resp, nil\n}\n"
  },
  {
    "path": "lib/thoth/thothmock/withthothmock.go",
    "content": "package thothmock\n\nimport (\n\t\"context\"\n\t\"testing\"\n\n\t\"github.com/TecharoHQ/anubis/lib/thoth\"\n)\n\nfunc WithMockThoth(t *testing.T) context.Context {\n\tt.Helper()\n\n\tthothCli := &thoth.Client{}\n\tthothCli.WithIPToASNService(MockIpToASNService())\n\tctx := thoth.With(t.Context(), thothCli)\n\treturn ctx\n}\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"@techaro/anubis\",\n  \"version\": \"1.25.0\",\n  \"description\": \"\",\n  \"main\": \"index.js\",\n  \"scripts\": {\n    \"test\": \"npm run assets && SKIP_INTEGRATION=1 go test ./...\",\n    \"test:integration\": \"npm run assets && go test -v ./internal/test\",\n    \"test:integration:podman\": \"npm run assets && go test -v ./internal/test --playwright-runner=podman\",\n    \"test:integration:docker\": \"npm run assets && go test -v ./internal/test --playwright-runner=docker\",\n    \"assets\": \"go generate ./... && ./web/build.sh && ./xess/build.sh\",\n    \"build\": \"npm run assets && go build -o ./var/anubis ./cmd/anubis\",\n    \"dev\": \"npm run assets && go run ./cmd/anubis --use-remote-address --target http://localhost:3000\",\n    \"container\": \"npm run assets && go run ./cmd/containerbuild\",\n    \"package\": \"go tool yeet\",\n    \"lint\": \"make lint\",\n    \"prepare\": \"husky && go mod download\",\n    \"format\": \"prettier -w . 2>&1 >/dev/null && go run goimports -w .\"\n  },\n  \"author\": \"\",\n  \"license\": \"ISC\",\n  \"devDependencies\": {\n    \"@commitlint/cli\": \"^20.4.3\",\n    \"@commitlint/config-conventional\": \"^20.4.3\",\n    \"baseline-browser-mapping\": \"^2.10.0\",\n    \"cssnano\": \"^7.1.3\",\n    \"cssnano-preset-advanced\": \"^7.0.11\",\n    \"esbuild\": \"^0.27.3\",\n    \"husky\": \"^9.1.7\",\n    \"playwright\": \"^1.52.0\",\n    \"postcss-cli\": \"^11.0.1\",\n    \"postcss-import\": \"^16.1.1\",\n    \"postcss-import-url\": \"^7.2.0\",\n    \"postcss-url\": \"^10.1.3\",\n    \"prettier\": \"^3.8.1\"\n  },\n  \"dependencies\": {\n    \"@aws-crypto/sha256-js\": \"^5.2.0\",\n    \"preact\": \"^10.28.4\"\n  },\n  \"commitlint\": {\n    \"extends\": [\n      \"@commitlint/config-conventional\"\n    ],\n    \"rules\": {\n      \"body-max-line-length\": [\n        2,\n        \"always\",\n        99999\n      ],\n      \"footer-max-line-length\": [\n        2,\n        \"always\",\n        99999\n      ],\n      \"signed-off-by\": [\n        2,\n        \"always\"\n      ]\n    }\n  },\n  \"prettier\": {\n    \"singleQuote\": false,\n    \"tabWidth\": 2,\n    \"semi\": true,\n    \"trailingComma\": \"all\",\n    \"printWidth\": 80\n  }\n}"
  },
  {
    "path": "run/anubis.freebsd",
    "content": "#!/bin/sh\n\n# PROVIDE: anubis\n# REQUIRE: DAEMON NETWORKING\n# KEYWORD: shutdown\n\n# Add the following lines to /etc/rc.conf.local or /etc/rc.conf to enable anubis:\n# anubis_enable (bool):        Set to \"NO\" by default.\n#                              Set it to \"YES\" to enable anubis.\n# anubis_user (user):          Set to \"www\" by default.\n#                              User to run anubis as.\n# anubis_group (group):        Set to \"www\" by default.\n#                              Group to run anubis as.\n# anubis_bin (str):            Set to \"/usr/local/bin/anubis\" by default.\n#                              Location of the anubis binary\n# anubis_args (str):           Set to \"\" by default.\n#                              Extra flags passed to anubis.\n# anubis_env (str):            Set to \"\" by default.\n#                              List of environment variables to be set before starting..\n# anubis_env_file (str):       Set to \"/etc/anubis.env\" by default.\n#                              Location of a file containing environment variables.\n#\n# Closely follows the init script from https://cgit.freebsd.org/ports/tree/www/go-anubis/files/anubis.in \n# with a couple of adjustments for more flexible environment variable handling\n\n. /etc/rc.subr\n\nname=anubis\nrcvar=anubis_enable\n\nload_rc_config ${name}\n\n: ${anubis_enable=\"NO\"}\n: ${anubis_user=\"www\"}\n: ${anubis_group=\"www\"}\n: ${anubis_bin=\"/usr/local/bin/anubis\"}\n: ${anubis_args=\"\"}\n: ${anubis_env=\"\"}\n: ${anubis_env_file=\"/etc/anubis.env\"}\n\npidfile=/var/run/${name}.pid\ndaemon_pidfile=/var/run/${name}-daemon.pid\ncommand=/usr/sbin/daemon\nprocname=${anubis_bin}\nlogfile=/var/log/${name}.log\ncommand_args=\"-c -f -R 5 -r -T ${name} -p ${pidfile} -P ${daemon_pidfile} -o ${logfile} ${procname} ${anubis_args}\"\nstart_precmd=anubis_startprecmd\nstop_postcmd=anubis_stoppostcmd\n\nanubis_startprecmd () {\n    if [ ! -e ${logfile} ]; then\n        install -o ${anubis_user} -g ${anubis_group} /dev/null ${logfile}\n    fi\n    if [ ! -e ${daemon_pidfile} ]; then\n        install -o ${anubis_user} -g ${anubis_group} /dev/null ${daemon_pidfile}\n    fi\n    if [ ! -e ${pidfile} ]; then\n        install -o ${anubis_user} -g ${anubis_group} /dev/null ${pidfile}\n    fi\n}\n\nanubis_stoppostcmd() {\n    if [ -f \"${daemon_pidfile}\" ]; then\n        pids=$( pgrep -F ${daemon_pidfile} 2>&1 )\n        _err=$?\n        [ ${_err} -eq 0 ] && kill -9 ${pids}\n    fi\n}\n\n\nrun_rc_command \"$1\"\n"
  },
  {
    "path": "run/anubis@.service",
    "content": "[Unit]\nDescription=\"Anubis HTTP defense proxy (instance %i)\"\n\n[Service]\nExecStart=/usr/bin/anubis\nRestart=always\nRestartSec=30s\nEnvironmentFile=/etc/anubis/%i.env\nLimitNOFILE=infinity\nDynamicUser=yes\nCacheDirectory=anubis/%i\nCacheDirectoryMode=0755\nStateDirectory=anubis/%i\nStateDirectoryMode=0755\nRuntimeDirectory=anubis/%i\nRuntimeDirectoryMode=0755\nReadWritePaths=/run\n\n[Install]\nWantedBy=multi-user.target"
  },
  {
    "path": "run/default.env",
    "content": "BIND=:8923\nDIFFICULTY=4\nMETRICS_BIND=:9090\nSERVE_ROBOTS_TXT=0\nTARGET=http://localhost:3000"
  },
  {
    "path": "run/openrc/anubis.confd",
    "content": "# The URL of the service that Anubis should forward valid requests to. Supports\n# Unix domain sockets.\n#ANUBIS_TARGET=\"http://localhost:3923\"\n#ANUBIS_TARGET=\"unix:///path/to/socket\"\n\n# The network address that Anubis listens on.\n#\n# If unset, listen on /run/anubis_${instance}/anubis.sock Unix socket instead.\n#ANUBIS_BIND_PORT=\":8923\"\n\n# The network address that Anubis serves Prometheus metrics on.\n#\n# If unset, listen on /run/anubis_${instance}/metrix.sock Unix socket instead.\n#ANUBIS_METRICS_BIND_PORT=\":9090\"\n\n# The difficulty of the challenge, or the number of leading zeroes that must be\n# in successful responses.\n#ANUBIS_DIFFICULTY=4\n\n# Additional command-line options for Anubis.\n#ANUBIS_OPTS=\"\"\n\n# Configure the user[:group] Anubis will run as.\n#command_user=\"anubis:anubis\"\n"
  },
  {
    "path": "run/openrc/anubis.initd",
    "content": "#!/sbin/openrc-run\n# shellcheck shell=sh\n\ninstance=${RC_SVCNAME#*.}\n\ndescription=\"Anubis HTTP defense proxy (instance ${instance})\"\nsupervisor=\"supervise-daemon\"\ncommand=\"/usr/bin/anubis\"\ncommand_args=\"\\\n\t-bind ${ANUBIS_BIND_PORT:-/run/anubis_${instance?}/anubis.sock -bind-network unix} \\\n\t-metrics-bind ${ANUBIS_METRICS_BIND_PORT:-/run/anubis_${instance?}/metrics.sock -metrics-bind-network unix} \\\n\t-target ${ANUBIS_TARGET:-http://localhost:3923} \\\n\t-difficulty ${ANUBIS_DIFFICULTY:-4} \\\n\t${ANUBIS_OPTS}\n\"\ncommand_background=1\npidfile=\"/run/anubis_${instance?}/anubis.pid\"\n\n: \"${command_user:=anubis:anubis}\"\n\ndepend() {\n\tuse net firewall\n}\n\nstart_pre() {\n\tif [ \"${instance?}\" = \"${RC_SVCNAME?}\" ]; then\n\t\teerror \"${RC_SVCNAME?} cannot be started directly. You must create\"\n\t\teerror \"symbolic links to it for the services you want to start\"\n\t\teerror \"and add those to the appropriate runlevels.\"\n\t\treturn 1\n\tfi\n\n\trm -rf \"/run/anubis_${instance?}\"\n\tcheckpath -D -o \"${command_user?}\" \"/run/anubis_${instance?}\"\n}\n"
  },
  {
    "path": "test/.gitignore",
    "content": "*.sock\n*.pem\n"
  },
  {
    "path": "test/anubis_configs/aggressive_403.yaml",
    "content": "bots:\n  - name: deny\n    user_agent_regex: DENY\n    action: DENY\n\n  - name: challenge\n    user_agent_regex: CHALLENGE\n    action: CHALLENGE\n\nstatus_codes:\n  CHALLENGE: 401\n  DENY: 403\n"
  },
  {
    "path": "test/caddy/Caddyfile",
    "content": ":80 {\n\treverse_proxy http://anubis:3000 {\n\t\theader_up X-Real-Ip {remote_host}\n\t\theader_up X-Http-Version {http.request.proto}\n\t}\n}\n\n:443 {\n\ttls /etc/techaro/pki/caddy.local.cetacean.club/cert.pem /etc/techaro/pki/caddy.local.cetacean.club/key.pem\n\n\treverse_proxy http://anubis:3000 {\n\t\theader_up X-Real-Ip {remote_host}\n\t\theader_up X-Http-Version {http.request.proto}\n\t\theader_up X-Tls-Version {http.request.tls.version}\n\t}\n}\n"
  },
  {
    "path": "test/caddy/Dockerfile",
    "content": "# FROM caddy:2.10.0-builder AS builder\n\n# RUN xcaddy build \\\n#   --with github.com/lolPants/caddy-requestid\n\nFROM caddy:2.10.0 AS run\n\n# COPY --from=builder /usr/bin/caddy /usr/bin/caddy\nCOPY Caddyfile /etc/caddy/Caddyfile"
  },
  {
    "path": "test/caddy/docker-compose.yaml",
    "content": "services:\n  caddy:\n    image: xxxtest/caddy\n    build: .\n    ports:\n      - 8080:80\n      - 8443:443\n    volumes:\n      - \"../pki/caddy.local.cetacean.club:/etc/techaro/pki/caddy.local.cetacean.club/\"\n\n  anubis:\n    image: ghcr.io/techarohq/anubis:main\n    environment:\n      BIND: \":3000\"\n      TARGET: http://httpdebug:3000\n      POLICY_FNAME: /etc/techaro/anubis/less_paranoid.yaml\n    volumes:\n      - ../anubis_configs:/etc/techaro/anubis\n\n  httpdebug:\n    image: ghcr.io/xe/x/httpdebug\n    pull_policy: always\n"
  },
  {
    "path": "test/caddy/start.sh",
    "content": "#!/usr/bin/env bash\n\n# If the transient local TLS certificate doesn't exist, mint a new one\nif [ ! -f ../pki/caddy.local.cetacean.club/cert.pem ]; then\n  # Subshell to contain the directory change\n  (\n    cd ../pki \\\n    && mkdir -p caddy.local.cetacean.club \\\n    && \\\n    # Try using https://github.com/FiloSottile/mkcert for better DevEx,\n    # but fall back to using https://github.com/jsha/minica in case\n    # you don't have that installed.\n    (\n      mkcert \\\n        --cert-file ./caddy.local.cetacean.club/cert.pem \\\n        --key-file ./caddy.local.cetacean.club/key.pem caddy.local.cetacean.club \\\n      || go tool minica -domains caddy.local.cetacean.club\n    )\n  )\nfi\n\ndocker compose up --build"
  },
  {
    "path": "test/cmd/cipra/internal/containerip.go",
    "content": "package internal\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\t\"github.com/docker/docker/client\"\n)\n\n// GetContainerIPAddress returns the first non-empty IP address of the container with the given name.\n// It returns the IP address as a string or an error.\nfunc GetContainerIPAddress(containerName string) (string, error) {\n\tctx := context.Background()\n\tcli, err := client.NewClientWithOpts(client.FromEnv)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\t// Get container details\n\tcontainerJSON, err := cli.ContainerInspect(ctx, containerName)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\t// Loop through all networks and return the first IP address found\n\tfor _, net := range containerJSON.NetworkSettings.Networks {\n\t\tif net.IPAddress != \"\" {\n\t\t\treturn net.IPAddress, nil\n\t\t}\n\t}\n\n\treturn \"\", fmt.Errorf(\"no IP address found for container %q\", containerName)\n}\n"
  },
  {
    "path": "test/cmd/cipra/internal/getlanip.go",
    "content": "package internal\n\nimport (\n\t\"fmt\"\n\t\"net\"\n)\n\n// GetLANIP returns the first non-loopback IPv4 LAN IP address.\nfunc GetLANIP() (net.IP, error) {\n\tifaces, err := net.Interfaces()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tfor _, iface := range ifaces {\n\t\t// Skip down or loopback interfaces\n\t\tif iface.Flags&(net.FlagUp|net.FlagLoopback) != net.FlagUp {\n\t\t\tcontinue\n\t\t}\n\n\t\taddrs, err := iface.Addrs()\n\t\tif err != nil {\n\t\t\tcontinue // skip interfaces we can't query\n\t\t}\n\n\t\tfor _, addr := range addrs {\n\t\t\tvar ip net.IP\n\n\t\t\tswitch v := addr.(type) {\n\t\t\tcase *net.IPNet:\n\t\t\t\tip = v.IP\n\t\t\tcase *net.IPAddr:\n\t\t\t\tip = v.IP\n\t\t\t}\n\n\t\t\tif ip == nil || ip.IsLoopback() {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tip = ip.To4()\n\t\t\tif ip == nil {\n\t\t\t\tcontinue // not an IPv4 address\n\t\t\t}\n\n\t\t\treturn ip, nil\n\t\t}\n\t}\n\n\treturn nil, fmt.Errorf(\"no connected LAN IPv4 address found\")\n}\n"
  },
  {
    "path": "test/cmd/cipra/internal/unbreakdocker.go",
    "content": "package internal\n\nimport (\n\t\"context\"\n\t\"log\"\n\t\"os\"\n\n\t\"github.com/docker/docker/api/types/network\"\n\t\"github.com/docker/docker/client\"\n)\n\n// UnbreakDocker connects the container named after the current hostname\n// to the specified Docker network.\nfunc UnbreakDocker(networkName string) error {\n\tctx := context.Background()\n\n\tcli, err := client.NewClientWithOpts(client.FromEnv)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\thostname, err := os.Hostname()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\terr = cli.NetworkConnect(ctx, networkName, hostname, &network.EndpointSettings{})\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tlog.Printf(\"Connected container %q to network %q\\n\", hostname, networkName)\n\treturn nil\n}\n"
  },
  {
    "path": "test/cmd/cipra/main.go",
    "content": "package main\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"flag\"\n\t\"fmt\"\n\t\"log\"\n\t\"net/http\"\n\t\"os\"\n\t\"os/exec\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/TecharoHQ/anubis/test/cmd/cipra/internal\"\n\t\"github.com/facebookgo/flagenv\"\n)\n\nvar (\n\tbind                 = flag.String(\"bind\", \":9090\", \"TCP host:port to bind HTTP on\")\n\tbrowserBin           = flag.String(\"browser-bin\", \"palemoon\", \"browser binary name\")\n\tbrowserContainerName = flag.String(\"browser-container-name\", \"palemoon\", \"browser container name\")\n\tcomposeName          = flag.String(\"compose-name\", \"\", \"docker compose base name for resources\")\n\tvncServerContainer   = flag.String(\"vnc-container-name\", \"display\", \"VNC host:port (NOT a display number)\")\n)\n\nfunc main() {\n\tflagenv.Parse()\n\tflag.Parse()\n\n\tctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)\n\tdefer cancel()\n\n\tlanip, err := internal.GetLANIP()\n\tif err != nil {\n\t\tlog.Panic(err)\n\t}\n\n\tos.Setenv(\"TARGET\", fmt.Sprintf(\"%s%s\", lanip.String(), *bind))\n\n\thttp.HandleFunc(\"/{$}\", func(w http.ResponseWriter, r *http.Request) {\n\t\thttp.Error(w, \"OK\", http.StatusOK)\n\t\tlog.Println(\"got termination signal\", r.RequestURI)\n\t\tgo func() {\n\t\t\ttime.Sleep(2 * time.Second)\n\t\t\tcancel()\n\t\t}()\n\t})\n\n\tsrv := &http.Server{\n\t\tHandler: http.DefaultServeMux,\n\t\tAddr:    *bind,\n\t}\n\n\tgo func() {\n\t\tif err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {\n\t\t\tlog.Panic(err)\n\t\t}\n\t}()\n\n\tif err := RunScript(ctx, \"docker\", \"compose\", \"up\", \"-d\"); err != nil {\n\t\tlog.Fatalf(\"can't start project: %v\", err)\n\t}\n\n\tdefer RunScript(ctx, \"docker\", \"compose\", \"down\", \"-t\", \"1\")\n\tdefer RunScript(ctx, \"docker\", \"compose\", \"rm\", \"-f\")\n\n\tinternal.UnbreakDocker(*composeName + \"_default\")\n\n\tif err := RunScript(ctx, \"docker\", \"exec\", fmt.Sprintf(\"%s-%s-1\", *composeName, *browserContainerName), \"bash\", \"/hack/scripts/install-cert.sh\"); err != nil {\n\t\tlog.Panic(err)\n\t}\n\n\tif err := RunScript(ctx, \"docker\", \"exec\", fmt.Sprintf(\"%s-%s-1\", *composeName, *browserContainerName), *browserBin, \"https://relayd\"); err != nil {\n\t\tlog.Panic(err)\n\t}\n\n\t<-ctx.Done()\n\tsrv.Close()\n\ttime.Sleep(2 * time.Second)\n}\n\nfunc RunScript(ctx context.Context, args ...string) error {\n\tvar err error\n\tbackoff := 250 * time.Millisecond\n\n\tfor attempt := 0; attempt < 5; attempt++ {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn nil\n\t\tdefault:\n\t\t}\n\t\tlog.Printf(\"Running command: %s\", strings.Join(args, \" \"))\n\t\tcmd := exec.CommandContext(ctx, args[0], args[1:]...)\n\t\tcmd.Stdout = os.Stdout\n\t\tcmd.Stderr = os.Stderr\n\n\t\terr = cmd.Run()\n\t\tif exitErr, ok := err.(*exec.ExitError); ok {\n\t\t\tlog.Printf(\"attempt=%d code=%d\", attempt, exitErr.ExitCode())\n\t\t}\n\n\t\tif err == nil {\n\t\t\treturn nil\n\t\t}\n\n\t\tlog.Printf(\"Attempt %d failed: %v %T\", attempt+1, err, err)\n\t\tlog.Printf(\"Retrying in %v...\", backoff)\n\t\ttime.Sleep(backoff)\n\t\tbackoff *= 2\n\t}\n\n\treturn fmt.Errorf(\"script failed after 5 attempts: %w\", err)\n}\n"
  },
  {
    "path": "test/cmd/httpdebug/main.go",
    "content": "package main\n\nimport (\n\t\"flag\"\n\t\"fmt\"\n\t\"log\"\n\t\"log/slog\"\n\t\"net/http\"\n)\n\nvar (\n\tbind = flag.String(\"bind\", \":3923\", \"TCP port to bind to\")\n)\n\nfunc main() {\n\tflag.Parse()\n\n\tslog.Info(\"listening\", \"url\", \"http://localhost\"+*bind)\n\tlog.Fatal(http.ListenAndServe(*bind, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tslog.Info(\"got request\", \"method\", r.Method, \"path\", r.RequestURI)\n\n\t\tfmt.Fprintln(w, r.Method, r.RequestURI)\n\t\tr.Header.Write(w)\n\t})))\n}\n"
  },
  {
    "path": "test/cmd/relayd/main.go",
    "content": "package main\n\nimport (\n\t\"context\"\n\t\"flag\"\n\t\"log\"\n\t\"log/slog\"\n\t\"net\"\n\t\"net/http\"\n\t\"net/http/httputil\"\n\t\"net/url\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/TecharoHQ/anubis/internal\"\n\t\"github.com/facebookgo/flagenv\"\n\t\"github.com/google/uuid\"\n)\n\nvar (\n\tbind      = flag.String(\"bind\", \":3004\", \"port to listen on\")\n\tcertDir   = flag.String(\"cert-dir\", \"/xe/pki\", \"where to read mounted certificates from\")\n\tcertFname = flag.String(\"cert-fname\", \"cert.pem\", \"certificate filename\")\n\tkeyFname  = flag.String(\"key-fname\", \"key.pem\", \"key filename\")\n\tproxyTo   = flag.String(\"proxy-to\", \"http://localhost:5000\", \"where to reverse proxy to\")\n\tslogLevel = flag.String(\"slog-level\", \"info\", \"logging level\")\n)\n\nfunc main() {\n\tflagenv.Parse()\n\tflag.Parse()\n\n\tinternal.InitSlog(*slogLevel)\n\n\tslog.Info(\"starting\",\n\t\t\"bind\", *bind,\n\t\t\"cert-dir\", *certDir,\n\t\t\"cert-fname\", *certFname,\n\t\t\"key-fname\", *keyFname,\n\t\t\"proxy-to\", *proxyTo,\n\t)\n\n\tcert := filepath.Join(*certDir, *certFname)\n\tkey := filepath.Join(*certDir, *keyFname)\n\n\tst, err := os.Stat(cert)\n\n\tif err != nil {\n\t\tslog.Error(\"can't stat cert file\", \"certFname\", cert)\n\t\tos.Exit(1)\n\t}\n\n\tlastModified := st.ModTime()\n\n\tgo func(lm time.Time) {\n\t\tt := time.NewTicker(time.Hour)\n\t\tdefer t.Stop()\n\n\t\tfor range t.C {\n\t\t\tst, err := os.Stat(cert)\n\t\t\tif err != nil {\n\t\t\t\tslog.Error(\"can't stat file\", \"fname\", cert, \"err\", err)\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tif st.ModTime().After(lm) {\n\t\t\t\tslog.Info(\"new cert detected\", \"oldTime\", lm.Format(time.RFC3339), \"newTime\", st.ModTime().Format(time.RFC3339))\n\t\t\t\tos.Exit(0)\n\t\t\t}\n\t\t}\n\t}(lastModified)\n\n\tu, err := url.Parse(*proxyTo)\n\tif err != nil {\n\t\tlog.Fatal(err)\n\t}\n\n\th := httputil.NewSingleHostReverseProxy(u)\n\n\tif u.Scheme == \"unix\" {\n\t\tslog.Info(\"using unix socket proxy\")\n\n\t\th = &httputil.ReverseProxy{\n\t\t\tDirector: func(r *http.Request) {\n\t\t\t\tr.URL.Scheme = \"http\"\n\t\t\t\tr.URL.Host = r.Host\n\n\t\t\t\tr.Header.Set(\"X-Forwarded-Proto\", \"https\")\n\t\t\t\tr.Header.Set(\"X-Forwarded-Scheme\", \"https\")\n\t\t\t\tr.Header.Set(\"X-Request-Id\", uuid.NewString())\n\t\t\t\tr.Header.Set(\"X-Scheme\", \"https\")\n\n\t\t\t\tremoteHost, remotePort, err := net.SplitHostPort(r.Host)\n\t\t\t\tif err == nil {\n\t\t\t\t\tr.Header.Set(\"X-Forwarded-Host\", remoteHost)\n\t\t\t\t\tr.Header.Set(\"X-Forwarded-Port\", remotePort)\n\t\t\t\t} else {\n\t\t\t\t\tr.Header.Set(\"X-Forwarded-Host\", r.Host)\n\t\t\t\t}\n\n\t\t\t\thost, _, err := net.SplitHostPort(r.RemoteAddr)\n\t\t\t\tif err == nil {\n\t\t\t\t\tr.Header.Set(\"X-Real-Ip\", host)\n\t\t\t\t}\n\t\t\t},\n\t\t\tTransport: &http.Transport{\n\t\t\t\tDialContext: func(_ context.Context, _, _ string) (net.Conn, error) {\n\t\t\t\t\treturn net.Dial(\"unix\", strings.TrimPrefix(*proxyTo, \"unix://\"))\n\t\t\t\t},\n\t\t\t},\n\t\t}\n\t}\n\n\tlog.Fatal(\n\t\thttp.ListenAndServeTLS(\n\t\t\t*bind,\n\t\t\tcert,\n\t\t\tkey,\n\t\t\th,\n\t\t),\n\t)\n}\n"
  },
  {
    "path": "test/cmd/unixhttpd/main.go",
    "content": "package main\n\nimport (\n\t\"flag\"\n\t\"fmt\"\n\t\"log\"\n\t\"log/slog\"\n\t\"net\"\n\t\"net/http\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\n\t\"github.com/TecharoHQ/anubis/internal\"\n\t\"github.com/facebookgo/flagenv\"\n)\n\nvar (\n\tdir        = flag.String(\"dir\", \".\", \"directory to serve\")\n\tslogLevel  = flag.String(\"slog-level\", \"info\", \"logging level\")\n\tsocketPath = flag.String(\"socket-path\", \"./unixhttpd.sock\", \"unix socket path to use\")\n)\n\nfunc init() {\n\tflag.Usage = func() {\n\t\tfmt.Fprintf(os.Stderr, \"Usage of %s:\\n\", filepath.Base(os.Args[0]))\n\t\tfmt.Fprintf(os.Stderr, \"  %s [--dir=.] [--socket-path=./unixhttpd.sock]\\n\\n\", filepath.Base(os.Args[0]))\n\t\tflag.PrintDefaults()\n\t\tos.Exit(2)\n\t}\n}\n\nfunc main() {\n\tflagenv.Parse()\n\tflag.Parse()\n\n\tinternal.InitSlog(*slogLevel)\n\n\tif *dir == \"\" && *socketPath == \"\" {\n\t\tflag.Usage()\n\t}\n\n\tslog.Info(\"starting up\", \"dir\", *dir, \"socketPath\", *socketPath)\n\n\tos.Remove(*socketPath)\n\n\tmux := http.NewServeMux()\n\n\tmux.HandleFunc(\"/reqmeta\", func(w http.ResponseWriter, r *http.Request) {\n\t\tcontains := strings.Contains(r.Header.Get(\"Accept\"), \"text/html\")\n\n\t\tif contains {\n\t\t\tw.Header().Add(\"Content-Type\", \"text/html\")\n\t\t\tfmt.Fprint(w, \"<pre id=\\\"main\\\"><code>\")\n\t\t}\n\n\t\tr.Write(w)\n\n\t\tif contains {\n\t\t\tfmt.Fprintln(w, \"</pre></code>\")\n\t\t}\n\t})\n\n\tmux.Handle(\"/\", http.FileServer(http.Dir(*dir)))\n\n\tserver := http.Server{\n\t\tHandler: mux,\n\t}\n\n\tunixListener, err := net.Listen(\"unix\", *socketPath)\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\tlog.Fatal(server.Serve(unixListener))\n}\n"
  },
  {
    "path": "test/default-config-macro/compare_bots.py",
    "content": "#!/usr/bin/env python3\n\"\"\"\nScript to verify that the 'bots' field in data/botPolicies.yaml\nhas the same semantic contents as data/meta/default-config.yaml.\n\nCW: generated by AI\n\"\"\"\n\nimport yaml\nimport sys\nimport os\nimport subprocess\nimport difflib\n\ndef load_yaml(file_path):\n    \"\"\"Load YAML file and return the data.\"\"\"\n    try:\n        with open(file_path, 'r') as f:\n            return yaml.safe_load(f)\n    except Exception as e:\n        print(f\"Error loading {file_path}: {e}\")\n        sys.exit(1)\n\ndef normalize_yaml(data):\n    \"\"\"Normalize YAML data by removing comments and standardizing structure.\"\"\"\n    # For lists, just return as is, since YAML comments are stripped by safe_load\n    return data\n\ndef get_repo_root():\n    \"\"\"Get the root directory of the git repository.\"\"\"\n    try:\n        result = subprocess.run(['git', 'rev-parse', '--show-toplevel'], capture_output=True, text=True, check=True)\n        return result.stdout.strip()\n    except subprocess.CalledProcessError:\n        print(\"Error: Not in a git repository\")\n        sys.exit(1)\n\ndef main():\n    # Get the git repository root\n    repo_root = get_repo_root()\n\n    # Paths relative to the repo root\n    bot_policies_path = os.path.join(repo_root, 'data', 'botPolicies.yaml')\n    default_config_path = os.path.join(repo_root, 'data', 'meta', 'default-config.yaml')\n\n    # Load the files\n    bot_policies = load_yaml(bot_policies_path)\n    default_config = load_yaml(default_config_path)\n\n    # Extract the 'bots' field from botPolicies.yaml\n    if 'bots' not in bot_policies:\n        print(\"Error: 'bots' field not found in botPolicies.yaml\")\n        sys.exit(1)\n    bots_field = bot_policies['bots']\n\n    # The default-config.yaml is a list directly\n    default_bots = default_config\n\n    # Normalize both\n    normalized_bots = normalize_yaml(bots_field)\n    normalized_default = normalize_yaml(default_bots)\n\n    # Compare\n    if normalized_bots == normalized_default:\n        print(\"SUCCESS: The 'bots' field in botPolicies.yaml matches the contents of default-config.yaml\")\n        sys.exit(0)\n    else:\n        print(\"FAILURE: The 'bots' field in botPolicies.yaml does not match the contents of default-config.yaml\")\n        print(\"\\nDiff:\")\n        bots_yaml = yaml.dump(normalized_bots, default_flow_style=False)\n        default_yaml = yaml.dump(normalized_default, default_flow_style=False)\n        diff = difflib.unified_diff(\n            bots_yaml.splitlines(keepends=True),\n            default_yaml.splitlines(keepends=True),\n            fromfile='bots field in botPolicies.yaml',\n            tofile='default-config.yaml'\n        )\n        print(''.join(diff))\n        sys.exit(1)\n\nif __name__ == \"__main__\":\n    main()"
  },
  {
    "path": "test/default-config-macro/test.sh",
    "content": "#!/usr/bin/env bash\n\nset -euo pipefail\n\ncd \"$(dirname \"$0\")\"\npython3 -c 'import yaml'\npython3 ./compare_bots.py"
  },
  {
    "path": "test/docker-registry/anubis.yaml",
    "content": "bots:\n  - import: (data)/meta/default-config.yaml\n  - import: (data)/clients/docker-client.yaml\n\nstatus_codes:\n  CHALLENGE: 200\n  DENY: 403\n"
  },
  {
    "path": "test/docker-registry/docker-compose.yaml",
    "content": "services:\n  registry:\n    image: distribution/distribution:edge\n    restart: always\n\n  relayd:\n    image: ghcr.io/xe/x/relayd\n    pull_policy: always\n    environment:\n      CERT_DIR: /etc/techaro/pki/registry.local.cetacean.club\n      CERT_FNAME: cert.pem\n      KEY_FNAME: key.pem\n      PROXY_TO: http://anubis:3000\n    ports:\n      - 3004:3004\n    volumes:\n      - ./pki/registry.local.cetacean.club:/etc/techaro/pki/registry.local.cetacean.club\n\n  anubis:\n    image: ko.local/anubis\n    restart: always\n    environment:\n      BIND: \":3000\"\n      TARGET: http://registry:5000\n      POLICY_FNAME: /etc/techaro/anubis.yaml\n      USE_REMOTE_ADDRESS: \"true\"\n    ports:\n      - 3000\n    volumes:\n      - ./anubis.yaml:/etc/techaro/anubis.yaml\n"
  },
  {
    "path": "test/docker-registry/test.sh",
    "content": "#!/usr/bin/env bash\n\nset -eo pipefail\n\nexport VERSION=${GITHUB_SHA}-test\nexport KO_DOCKER_REPO=ko.local\n\nset -u\n\nsource ../lib/lib.sh\n\nbuild_anubis_ko\n\nfunction cleanup() {\n\tdocker compose down\n}\n\ntrap cleanup EXIT SIGINT\n\nmint_cert registry.local.cetacean.club\n\ndocker compose up -d\n\nbackoff-retry skopeo \\\n\t--insecure-policy \\\n\tcopy \\\n\t--dest-tls-verify=false \\\n\tdocker://hello-world \\\n\tdocker://registry.local.cetacean.club:3004/hello-world\n"
  },
  {
    "path": "test/docker-registry/var/.gitignore",
    "content": "*\n!.gitignore"
  },
  {
    "path": "test/double_slash/anubis.yaml",
    "content": "bots:\n  - name: challenge\n    user_agent_regex: CHALLENGE\n    action: CHALLENGE\n\nstatus_codes:\n  CHALLENGE: 200\n  DENY: 403\n"
  },
  {
    "path": "test/double_slash/input.txt",
    "content": "/wiki//bin\n/wiki//boot\n/wiki//dev\n/wiki//dev/de\n/wiki//dev/en\n/wiki//dev/en-ca\n/wiki//dev/es\n/wiki//dev/fr\n/wiki//dev/hr\n/wiki//dev/hu\n/wiki//dev/it\n/wiki//dev/ja\n/wiki//dev/ko\n/wiki//dev/pl\n/wiki//dev/pt-br\n/wiki//dev/ro\n/wiki//dev/ru\n/wiki//dev/sv\n/wiki//dev/uk\n/wiki//dev/zh-cn\n/wiki//etc\n/wiki//etc/conf.d\n/wiki//etc/env.d\n/wiki//etc/fstab\n/wiki//etc/fstab/de\n/wiki//etc/fstab/en\n/wiki//etc/fstab/es\n/wiki//etc/fstab/fr\n/wiki//etc/fstab/hu\n/wiki//etc/fstab/it\n/wiki//etc/fstab/ja\n/wiki//etc/fstab/ko\n/wiki//etc/fstab/ru\n/wiki//etc/fstab/sv\n/wiki//etc/fstab/uk\n/wiki//etc/fstab/zh-cn\n/wiki//etc/hosts\n/wiki//etc/local.d\n/wiki//etc/make.conf\n/wiki//etc/portage\n/wiki//etc/portage/bashrc\n/wiki//etc/portage/Bashrc\n/wiki//etc/portage/binrepos.conf\n/wiki//etc/portage/binrepos.conf/en\n/wiki//etc/portage/binrepos.conf/hu\n/wiki//etc/portage/binrepos.conf/ja\n/wiki//etc/portage/binrepos.conf/ru\n/wiki//etc/portage/categories\n/wiki//etc/portage/color.map\n/wiki//etc/portage/env\n/wiki//etc/portage/img/ico.png\n/wiki//etc/portage/license_groups\n/wiki//etc/portage/make.conf\n/wiki//etc/portage/make.conf/de\n/wiki//etc/portage/make.conf/de/etc/portage/make.conf\n/wiki//etc/portage/make.conf/en\n/wiki//etc/portage/make.conf/es\n/wiki//etc/portage/make.conf/fr\n/wiki//etc/portage/make.conf/hu\n/wiki//etc/portage/make.conf/it\n/wiki//etc/portage/make.conf/it/var/db/repos/gentoo/licenses\n/wiki//etc/portage/make.conf/ja\n/wiki//etc/portage/make.conf/pl\n/wiki//etc/portage/make.conf/ru\n/wiki//etc/portage/make.conf/uk\n/wiki//etc/portage/make.conf/zh-cn\n/wiki//etc/portage/make.profile\n/wiki//etc/portage/mirrors\n/wiki//etc/portage/modules\n/wiki//etc/portage/package.accept_keywords\n/wiki//etc/portage/package.env\n/wiki//etc/portage/package.license\n/wiki//etc/portage/package.license/en\n/wiki//etc/portage/package.license/es\n/wiki//etc/portage/package.license/hu\n/wiki//etc/portage/package.license/ja\n/wiki//etc/portage/package.mask\n/wiki//etc/portage/package.mask/en\n/wiki//etc/portage/package.mask/hu\n/wiki//etc/portage/package.mask/ja\n/wiki//etc/portage/package.properties\n/wiki//etc/portage/package.unmask\n/wiki//etc/portage/package.use\n/wiki//etc/portage/package.use/de\n/wiki//etc/portage/package.use/en\n/wiki//etc/portage/package.use/es\n/wiki//etc/portage/package.use/fr\n/wiki//etc/portage/package.use/hu\n/wiki//etc/portage/package.use/it\n/wiki//etc/portage/package.use/ja\n/wiki//etc/portage/package.use/ru\n/wiki//etc/portage/package.use/uk\n/wiki//etc/portage/package.use/zh-cn\n/wiki//etc/portage/patches\n/wiki//etc/portage/profile/make.defaults\n/wiki//etc/portage/profile/package.provided\n/wiki//etc/portage/profile/package.provided/etc/portage/profile/package.provided\n/wiki//etc/portage/profile/package.provided/etc/portage/profiles/package.provided\n/wiki//etc/portage/profile/package.use.mask\n/wiki//etc/portage/profiles/package.provided\n/wiki//etc/portage/profiles/package.use.mask\n/wiki//etc/portage/profiles/package.use.mask/etc/portage/profile/package.use.mask\n/wiki//etc/portage/profiles/package.use.mask/etc/portage/profiles/package.use.mask\n/wiki//etc/portage/profiles/use.mask\n/wiki//etc/portage/profile/use.mask\n/wiki//etc/portage/repos.conf\n/wiki//etc/portage/repos.conf/brother-overlay.conf\n/wiki//etc/portage/repos.conf/de\n/wiki//etc/portage/repos.conf/en\n/wiki//etc/portage/repos.conf/es\n/wiki//etc/portage/repos.conf/etc/portage/repos.conf/gentoo.conf\n/wiki//etc/portage/repos.conf/fr\n/wiki//etc/portage/repos.conf/fr/etc/portage/repos.conf/gentoo.conf\n/wiki//etc/portage/repos.conf/gentoo.conf\n/wiki//etc/portage/repos.conf/gentoo.conf/etc/portage/repos.conf/gentoo.conf\n/wiki//etc/portage/repos.conf/hr\n/wiki//etc/portage/repos.conf/hu\n/wiki//etc/portage/repos.conf/it\n/wiki//etc/portage/repos.conf/ja\n/wiki//etc/portage/repos.conf/ko\n/wiki//etc/portage/repos.conf/pl\n/wiki//etc/portage/repos.conf/pt-br\n/wiki//etc/portage/repos.conf/ru\n/wiki//etc/portage/repos.conf/uk\n/wiki//etc/portage/repos.conf/zh-cn\n/wiki//etc/portage/savedconfig\n/wiki//etc/portage/sets\n/wiki//etc/profile\n/wiki//etc/profile.env\n/wiki//etc/sandbox.conf\n/wiki//home\n/wiki//lib\n/wiki//lib64\n/wiki//media\n/wiki//mnt\n/wiki//opt\n/wiki//proc\n/wiki//proc/config.gz\n/wiki//run\n/wiki//sbin\n/wiki//srv\n/wiki//sys\n/wiki//tmp\n/wiki//usr\n/wiki//usr/bin\n/wiki//usr_move\n/wiki//usr/portage\n/wiki//usr/portage/distfiles\n/wiki//usr/portage/licenses\n/wiki//usr/portage/metadata\n/wiki//usr/portage/metadata/md5-cache\n/wiki//usr/portage/metadata/md5-cache/usr/portage/metadata/md5-cache\n/wiki//usr/portage/metadata/md5-cache/var/db/repos/gentoo//metadata/md5-cache\n/wiki//usr/portage/packages\n/wiki//usr/portage/profiles\n/wiki//usr/portage/profiles/license_groups\n/wiki//usr/portage/profiles/license_groups/usr/portage/profiles/license_groups\n/wiki//usr/portage/profiles/license_groups/var/db/repos/gentoo//profiles/license_groups\n/wiki//usr/share/doc/\n/wiki//var/cache/binpkgs\n/wiki//var/cache/distfiles\n/wiki//var/db/pkg\n/wiki//var/db/pkg%22\n/wiki//var/db/repos/gentoo\n/wiki//var/db/repos/gentoo/licenses\n/wiki//var/db/repos/gentoo/licenses/var/db/repos/gentoo//licenses\n/wiki//var/db/repos/gentoo/licenses/var/db/repos/gentoo/licenses\n/wiki//var/db/repos/gentoo/metadata\n/wiki//var/db/repos/gentoo/metadata/md5-cache\n/wiki//var/db/repos/gentoo/metadata/var/db/repos/gentoo//metadata\n/wiki//var/db/repos/gentoo/metadata/var/db/repos/gentoo/metadata\n/wiki//var/db/repos/gentoo/profiles\n/wiki//var/db/repos/gentoo/profiles/license_groups\n/wiki//var/db/repos/gentoo/profiles/package.mask\n/wiki//var/lib/portage\n/wiki//var/lib/portage/world\n/wiki//var/run\n/gcc-bugs/bug-122002-4@http.gcc.gnu.org%2Fbugzilla%2F/T/"
  },
  {
    "path": "test/double_slash/test.mjs",
    "content": "import { createReadStream } from \"fs\";\nimport { createInterface } from \"readline\";\n\nasync function getPage(path) {\n  return fetch(`http://localhost:8923${path}`)\n    .then((resp) => {\n      if (resp.status !== 200) {\n        throw new Error(`wanted status 200, got status: ${resp.status}`);\n      }\n      return resp;\n    })\n    .then((resp) => resp.text());\n}\n\n(async () => {\n  const fin = createReadStream(\"input.txt\");\n  const rl = createInterface({\n    input: fin,\n    crlfDelay: Infinity,\n  });\n\n  const resultSheet = {};\n\n  let failed = false;\n\n  for await (const line of rl) {\n    console.log(line);\n\n    const resp = await getPage(line);\n    resultSheet[line] = {\n      match: resp.includes(`GET ${line}`),\n      line: resp.split(\"\\n\")[0],\n    };\n  }\n\n  for (let [k, v] of Object.entries(resultSheet)) {\n    if (!v.match) {\n      failed = true;\n    }\n\n    console.debug({ path: k, results: v });\n  }\n\n  process.exit(failed ? 1 : 0);\n})();\n"
  },
  {
    "path": "test/double_slash/test.sh",
    "content": "#!/usr/bin/env bash\n\nset -euo pipefail\n\nfunction cleanup() {\n\tpkill -P $$\n}\n\ntrap cleanup EXIT SIGINT\n\n# Build static assets\n(cd ../.. && npm ci && npm run assets)\n\ngo tool anubis --help 2>/dev/null || :\n\ngo run ../cmd/httpdebug &\n\ngo tool anubis \\\n\t--policy-fname ./anubis.yaml \\\n\t--use-remote-address \\\n\t--target=http://localhost:3923 &\n\nbackoff-retry node ./test.mjs\n"
  },
  {
    "path": "test/double_slash/var/.gitignore",
    "content": "*\n!.gitignore"
  },
  {
    "path": "test/forced-language/anubis.yaml",
    "content": "bots:\n  - name: challenge\n    user_agent_regex: CHALLENGE\n    action: CHALLENGE\n\nstatus_codes:\n  CHALLENGE: 200\n  DENY: 403\n"
  },
  {
    "path": "test/forced-language/test.mjs",
    "content": "async function getChallengePage() {\n  return fetch(\"http://localhost:8923/reqmeta\", {\n    headers: {\n      \"Accept-Language\": \"en\",\n      \"User-Agent\": \"CHALLENGE\",\n    },\n  })\n    .then((resp) => {\n      if (resp.status !== 200) {\n        throw new Error(`wanted status 200, got status: ${resp.status}`);\n      }\n      return resp;\n    })\n    .then((resp) => resp.text());\n}\n\n(async () => {\n  const page = await getChallengePage();\n\n  if (!page.includes(`<html lang=\"de\">`)) {\n    console.log(page);\n    throw new Error(\"force language smoke test failed\");\n  }\n\n  console.log(\"FORCED_LANGUAGE=de caused a page to be rendered in german\");\n  process.exit(0);\n})();\n"
  },
  {
    "path": "test/forced-language/test.sh",
    "content": "#!/usr/bin/env bash\n\nset -euo pipefail\n\nfunction cleanup() {\n  pkill -P $$\n}\n\ntrap cleanup EXIT SIGINT\n\n# Build static assets\n(cd ../.. && npm ci && npm run assets)\n\ngo tool anubis --help 2>/dev/null ||:\n\ngo run ../cmd/unixhttpd &\n\nFORCED_LANGUAGE=de go tool anubis \\\n  --policy-fname ./anubis.yaml \\\n  --use-remote-address \\\n  --target=unix://$(pwd)/unixhttpd.sock &\n\nbackoff-retry node ./test.mjs\n"
  },
  {
    "path": "test/forced-language/var/.gitignore",
    "content": "*\n!.gitignore"
  },
  {
    "path": "test/git-clone/docker-compose.yaml",
    "content": "services:\n  cgit:\n    image: joseluisq/alpine-cgit\n    pull_policy: always\n    restart: always\n    environment:\n      CGIT_TITLE: Test git server\n      CGIT_DESC: Test server, please ignore\n    volumes:\n      - ./var/repos:/srv/git\n\n  anubis:\n    image: ko.local/anubis\n    environment:\n      BIND: \":8005\"\n      TARGET: http://cgit:80\n      USE_REMOTE_ADDRESS: \"true\"\n    ports:\n      - 8005:8005\n\nvolumes:\n  cgit-data:\n"
  },
  {
    "path": "test/git-clone/test.sh",
    "content": "#!/usr/bin/env bash\n\nset -eo pipefail\n\nexport VERSION=$GITHUB_COMMIT-test\nexport KO_DOCKER_REPO=ko.local\n\nset -u\n\nsource ../lib/lib.sh\n\nbuild_anubis_ko\n\nrm -rf ./var/repos ./var/clones\nmkdir -p ./var/repos ./var/clones\n\n(cd ./var/repos && git clone --bare https://github.com/TecharoHQ/status.git)\n\ndocker compose up -d\n\nsleep 2\n\n(cd ./var/clones && git clone http://localhost:8005/status.git)\n\nexit 0"
  },
  {
    "path": "test/git-clone/var/.gitignore",
    "content": "*\n!.gitignore"
  },
  {
    "path": "test/git-push/docker-compose.yaml",
    "content": "services:\n  git:\n    image: ghcr.io/kutespaces/simple-git-http-server\n    pull_policy: always\n    restart: always\n    volumes:\n      - ./var/repos:/git\n\n  anubis:\n    image: ko.local/anubis\n    environment:\n      BIND: \":3000\"\n      TARGET: http://git:80\n      USE_REMOTE_ADDRESS: \"true\"\n    ports:\n      - 3000:3000\n"
  },
  {
    "path": "test/git-push/test.sh",
    "content": "#!/usr/bin/env bash\n\nset -eo pipefail\n\nexport VERSION=${GITHUB_SHA}-test\nexport KO_DOCKER_REPO=ko.local\n\nset -u\n\nsource ../lib/lib.sh\n\nbuild_anubis_ko\n\nrm -rf ./var/repos ./var/foo\nmkdir -p ./var/repos\n\n(cd ./var/repos && git init --bare foo.git && cd foo.git && git config http.receivepack true)\n\ndocker compose up -d\n\nsleep 2\n\n(\n\tcd var &&\n\t\tmkdir foo &&\n\t\tcd foo &&\n\t\tgit init &&\n\t\ttouch README &&\n\t\tgit add . &&\n\t\tgit config user.name \"Anubis CI\" &&\n\t\tgit config user.email \"social+anubis-ci@techaro.lol\" &&\n\t\tgit commit -sm \"initial commit\" &&\n\t\tgit push -u http://localhost:3000/git/foo.git master\n)\n\nexit 0\n"
  },
  {
    "path": "test/git-push/var/.gitignore",
    "content": "*\n!.gitignore"
  },
  {
    "path": "test/go.mod",
    "content": "module github.com/TecharoHQ/anubis/test\n\ngo 1.24.5\n\nreplace github.com/TecharoHQ/anubis => ..\n\nrequire (\n\tgithub.com/TecharoHQ/anubis v1.23.1\n\tgithub.com/docker/docker v28.5.2+incompatible\n\tgithub.com/facebookgo/flagenv v0.0.0-20160425205200-fcd59fca7456\n\tgithub.com/google/uuid v1.6.0\n)\n\nrequire (\n\tbuf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20251209175733-2a1774d88802.1 // indirect\n\tcel.dev/expr v0.25.1 // indirect\n\tgithub.com/Microsoft/go-winio v0.6.2 // indirect\n\tgithub.com/TecharoHQ/thoth-proto v0.5.0 // indirect\n\tgithub.com/a-h/templ v0.3.960 // indirect\n\tgithub.com/antlr4-go/antlr/v4 v4.13.1 // indirect\n\tgithub.com/aws/aws-sdk-go-v2 v1.41.0 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/config v1.32.5 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/credentials v1.19.5 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/service/s3 v1.93.2 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/service/signin v1.0.4 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/service/sso v1.30.7 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/service/sts v1.41.5 // indirect\n\tgithub.com/aws/smithy-go v1.24.0 // indirect\n\tgithub.com/beorn7/perks v1.0.1 // indirect\n\tgithub.com/cespare/xxhash/v2 v2.3.0 // indirect\n\tgithub.com/containerd/errdefs v1.0.0 // indirect\n\tgithub.com/containerd/errdefs/pkg v0.3.0 // indirect\n\tgithub.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect\n\tgithub.com/distribution/reference v0.6.0 // indirect\n\tgithub.com/djherbis/times v1.6.0 // indirect\n\tgithub.com/docker/go-connections v0.6.0 // indirect\n\tgithub.com/docker/go-units v0.5.0 // indirect\n\tgithub.com/ebitengine/purego v0.9.1 // indirect\n\tgithub.com/facebookgo/ensure v0.0.0-20200202191622-63f1cf65ac4c // indirect\n\tgithub.com/facebookgo/subset v0.0.0-20200203212716-c811ad88dec4 // indirect\n\tgithub.com/fahedouch/go-logrotate v0.3.0 // indirect\n\tgithub.com/felixge/httpsnoop v1.0.4 // indirect\n\tgithub.com/gaissmai/bart v0.26.0 // indirect\n\tgithub.com/go-logr/logr v1.4.3 // indirect\n\tgithub.com/go-logr/stdr v1.2.2 // indirect\n\tgithub.com/go-ole/go-ole v1.3.0 // indirect\n\tgithub.com/golang-jwt/jwt/v5 v5.3.0 // indirect\n\tgithub.com/google/cel-go v0.26.1 // indirect\n\tgithub.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 // indirect\n\tgithub.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 // indirect\n\tgithub.com/joho/godotenv v1.5.1 // indirect\n\tgithub.com/jsha/minica v1.1.0 // indirect\n\tgithub.com/lum8rjack/go-ja4h v0.0.0-20250828030157-fa5266d50650 // indirect\n\tgithub.com/moby/docker-image-spec v1.3.1 // indirect\n\tgithub.com/moby/sys/atomicwriter v0.1.0 // indirect\n\tgithub.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect\n\tgithub.com/nicksnyder/go-i18n/v2 v2.6.0 // indirect\n\tgithub.com/nikandfor/spintax v0.0.0-20181023094358-fc346b245bb3 // indirect\n\tgithub.com/opencontainers/go-digest v1.0.0 // indirect\n\tgithub.com/opencontainers/image-spec v1.1.1 // indirect\n\tgithub.com/pkg/errors v0.9.1 // indirect\n\tgithub.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect\n\tgithub.com/prometheus/client_golang v1.23.2 // indirect\n\tgithub.com/prometheus/client_model v0.6.2 // indirect\n\tgithub.com/prometheus/common v0.67.4 // indirect\n\tgithub.com/prometheus/procfs v0.19.2 // indirect\n\tgithub.com/redis/go-redis/v9 v9.17.2 // indirect\n\tgithub.com/sebest/xff v0.0.0-20210106013422-671bd2870b3a // indirect\n\tgithub.com/shirou/gopsutil/v4 v4.25.11 // indirect\n\tgithub.com/stoewer/go-strcase v1.3.1 // indirect\n\tgithub.com/yusufpapurcu/wmi v1.2.4 // indirect\n\tgo.etcd.io/bbolt v1.4.3 // indirect\n\tgo.opentelemetry.io/auto/sdk v1.2.1 // indirect\n\tgo.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 // indirect\n\tgo.opentelemetry.io/otel v1.38.0 // indirect\n\tgo.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.37.0 // indirect\n\tgo.opentelemetry.io/otel/metric v1.38.0 // indirect\n\tgo.opentelemetry.io/otel/trace v1.38.0 // indirect\n\tgo.yaml.in/yaml/v2 v2.4.3 // indirect\n\tgolang.org/x/exp v0.0.0-20251209150349-8475f28825e9 // indirect\n\tgolang.org/x/net v0.48.0 // indirect\n\tgolang.org/x/sys v0.39.0 // indirect\n\tgolang.org/x/text v0.32.0 // indirect\n\tgoogle.golang.org/genproto/googleapis/api v0.0.0-20251213004720-97cd9d5aeac2 // indirect\n\tgoogle.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 // indirect\n\tgoogle.golang.org/grpc v1.77.0 // indirect\n\tgoogle.golang.org/protobuf v1.36.11 // indirect\n\tgotest.tools/v3 v3.5.2 // indirect\n\tk8s.io/apimachinery v0.34.3 // indirect\n\tsigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect\n\tsigs.k8s.io/yaml v1.6.0 // indirect\n)\n\ntool (\n\tgithub.com/TecharoHQ/anubis/cmd/anubis\n\tgithub.com/jsha/minica\n)\n"
  },
  {
    "path": "test/go.sum",
    "content": "buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20251209175733-2a1774d88802.1 h1:j9yeqTWEFrtimt8Nng2MIeRrpoCvQzM9/g25XTvqUGg=\nbuf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20251209175733-2a1774d88802.1/go.mod h1:tvtbpgaVXZX4g6Pn+AnzFycuRK3MOz5HJfEGeEllXYM=\ncel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4=\ncel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=\ndario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=\ndario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=\ngithub.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=\ngithub.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=\ngithub.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=\ngithub.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=\ngithub.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=\ngithub.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=\ngithub.com/TecharoHQ/thoth-proto v0.5.0 h1:Fa663s4soYiURSU8MfW9tZ2wF+LsCRSaYmjUSyagfBM=\ngithub.com/TecharoHQ/thoth-proto v0.5.0/go.mod h1:C/U7FqTxpVn4V/qebC/GcW32I0h9xzsmWehF27KFOJs=\ngithub.com/a-h/templ v0.3.960 h1:trshEpGa8clF5cdI39iY4ZrZG8Z/QixyzEyUnA7feTM=\ngithub.com/a-h/templ v0.3.960/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo=\ngithub.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ=\ngithub.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw=\ngithub.com/aws/aws-sdk-go-v2 v1.41.0 h1:tNvqh1s+v0vFYdA1xq0aOJH+Y5cRyZ5upu6roPgPKd4=\ngithub.com/aws/aws-sdk-go-v2 v1.41.0/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0=\ngithub.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU=\ngithub.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4=\ngithub.com/aws/aws-sdk-go-v2/config v1.32.5 h1:pz3duhAfUgnxbtVhIK39PGF/AHYyrzGEyRD9Og0QrE8=\ngithub.com/aws/aws-sdk-go-v2/config v1.32.5/go.mod h1:xmDjzSUs/d0BB7ClzYPAZMmgQdrodNjPPhd6bGASwoE=\ngithub.com/aws/aws-sdk-go-v2/credentials v1.19.5 h1:xMo63RlqP3ZZydpJDMBsH9uJ10hgHYfQFIk1cHDXrR4=\ngithub.com/aws/aws-sdk-go-v2/credentials v1.19.5/go.mod h1:hhbH6oRcou+LpXfA/0vPElh/e0M3aFeOblE1sssAAEk=\ngithub.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 h1:80+uETIWS1BqjnN9uJ0dBUaETh+P1XwFy5vwHwK5r9k=\ngithub.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16/go.mod h1:wOOsYuxYuB/7FlnVtzeBYRcjSRtQpAW0hCP7tIULMwo=\ngithub.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 h1:rgGwPzb82iBYSvHMHXc8h9mRoOUBZIGFgKb9qniaZZc=\ngithub.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16/go.mod h1:L/UxsGeKpGoIj6DxfhOWHWQ/kGKcd4I1VncE4++IyKA=\ngithub.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 h1:1jtGzuV7c82xnqOVfx2F0xmJcOw5374L7N6juGW6x6U=\ngithub.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16/go.mod h1:M2E5OQf+XLe+SZGmmpaI2yy+J326aFf6/+54PoxSANc=\ngithub.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=\ngithub.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=\ngithub.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16 h1:CjMzUs78RDDv4ROu3JnJn/Ig1r6ZD7/T2DXLLRpejic=\ngithub.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16/go.mod h1:uVW4OLBqbJXSHJYA9svT9BluSvvwbzLQ2Crf6UPzR3c=\ngithub.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E=\ngithub.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow=\ngithub.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7 h1:DIBqIrJ7hv+e4CmIk2z3pyKT+3B6qVMgRsawHiR3qso=\ngithub.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7/go.mod h1:vLm00xmBke75UmpNvOcZQ/Q30ZFjbczeLFqGx5urmGo=\ngithub.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 h1:oHjJHeUy0ImIV0bsrX0X91GkV5nJAyv1l1CC9lnO0TI=\ngithub.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16/go.mod h1:iRSNGgOYmiYwSCXxXaKb9HfOEj40+oTKn8pTxMlYkRM=\ngithub.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16 h1:NSbvS17MlI2lurYgXnCOLvCFX38sBW4eiVER7+kkgsU=\ngithub.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16/go.mod h1:SwT8Tmqd4sA6G1qaGdzWCJN99bUmPGHfRwwq3G5Qb+A=\ngithub.com/aws/aws-sdk-go-v2/service/s3 v1.93.2 h1:U3ygWUhCpiSPYSHOrRhb3gOl9T5Y3kB8k5Vjs//57bE=\ngithub.com/aws/aws-sdk-go-v2/service/s3 v1.93.2/go.mod h1:79S2BdqCJpScXZA2y+cpZuocWsjGjJINyXnOsf5DTz8=\ngithub.com/aws/aws-sdk-go-v2/service/signin v1.0.4 h1:HpI7aMmJ+mm1wkSHIA2t5EaFFv5EFYXePW30p1EIrbQ=\ngithub.com/aws/aws-sdk-go-v2/service/signin v1.0.4/go.mod h1:C5RdGMYGlfM0gYq/tifqgn4EbyX99V15P2V3R+VHbQU=\ngithub.com/aws/aws-sdk-go-v2/service/sso v1.30.7 h1:eYnlt6QxnFINKzwxP5/Ucs1vkG7VT3Iezmvfgc2waUw=\ngithub.com/aws/aws-sdk-go-v2/service/sso v1.30.7/go.mod h1:+fWt2UHSb4kS7Pu8y+BMBvJF0EWx+4H0hzNwtDNRTrg=\ngithub.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 h1:AHDr0DaHIAo8c9t1emrzAlVDFp+iMMKnPdYy6XO4MCE=\ngithub.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12/go.mod h1:GQ73XawFFiWxyWXMHWfhiomvP3tXtdNar/fi8z18sx0=\ngithub.com/aws/aws-sdk-go-v2/service/sts v1.41.5 h1:SciGFVNZ4mHdm7gpD1dgZYnCuVdX1s+lFTg4+4DOy70=\ngithub.com/aws/aws-sdk-go-v2/service/sts v1.41.5/go.mod h1:iW40X4QBmUxdP+fZNOpfmkdMZqsovezbAeO+Ubiv2pk=\ngithub.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk=\ngithub.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=\ngithub.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=\ngithub.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=\ngithub.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=\ngithub.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=\ngithub.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=\ngithub.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=\ngithub.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=\ngithub.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=\ngithub.com/cenkalti/backoff/v5 v5.0.2 h1:rIfFVxEf1QsI7E1ZHfp/B4DF/6QBAUhmgkxc0H7Zss8=\ngithub.com/cenkalti/backoff/v5 v5.0.2/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=\ngithub.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=\ngithub.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=\ngithub.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=\ngithub.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=\ngithub.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=\ngithub.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=\ngithub.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=\ngithub.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=\ngithub.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=\ngithub.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=\ngithub.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=\ngithub.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=\ngithub.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=\ngithub.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=\ngithub.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=\ngithub.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=\ngithub.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=\ngithub.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c=\ngithub.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0=\ngithub.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM=\ngithub.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=\ngithub.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=\ngithub.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=\ngithub.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=\ngithub.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=\ngithub.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A=\ngithub.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=\ngithub.com/facebookgo/ensure v0.0.0-20200202191622-63f1cf65ac4c h1:8ISkoahWXwZR41ois5lSJBSVw4D0OV19Ht/JSTzvSv0=\ngithub.com/facebookgo/ensure v0.0.0-20200202191622-63f1cf65ac4c/go.mod h1:Yg+htXGokKKdzcwhuNDwVvN+uBxDGXJ7G/VN1d8fa64=\ngithub.com/facebookgo/flagenv v0.0.0-20160425205200-fcd59fca7456 h1:CkmB2l68uhvRlwOTPrwnuitSxi/S3Cg4L5QYOcL9MBc=\ngithub.com/facebookgo/flagenv v0.0.0-20160425205200-fcd59fca7456/go.mod h1:zFhibDvPDWmtk4dAQ05sRobtyoffEHygEt3wSNuAzz8=\ngithub.com/facebookgo/stack v0.0.0-20160209184415-751773369052 h1:JWuenKqqX8nojtoVVWjGfOF9635RETekkoH6Cc9SX0A=\ngithub.com/facebookgo/stack v0.0.0-20160209184415-751773369052/go.mod h1:UbMTZqLaRiH3MsBH8va0n7s1pQYcu3uTb8G4tygF4Zg=\ngithub.com/facebookgo/subset v0.0.0-20200203212716-c811ad88dec4 h1:7HZCaLC5+BZpmbhCOZJ293Lz68O7PYrF2EzeiFMwCLk=\ngithub.com/facebookgo/subset v0.0.0-20200203212716-c811ad88dec4/go.mod h1:5tD+neXqOorC30/tWg0LCSkrqj/AR6gu8yY8/fpw1q0=\ngithub.com/fahedouch/go-logrotate v0.3.0 h1:XP+dHIDgWZ1ckz43mG6gl5ASer3PZDVr755SVMyzaUQ=\ngithub.com/fahedouch/go-logrotate v0.3.0/go.mod h1:X49m0bvPLkk71MHNCQ1yEfVEw8W/u+qvHa/hOnhCYf4=\ngithub.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=\ngithub.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=\ngithub.com/gaissmai/bart v0.26.0 h1:xOZ57E9hJLBiQaSyeZa9wgWhGuzfGACgqp4BE77OkO0=\ngithub.com/gaissmai/bart v0.26.0/go.mod h1:GREWQfTLRWz/c5FTOsIw+KkscuFkIV5t8Rp7Nd1Td5c=\ngithub.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=\ngithub.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=\ngithub.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=\ngithub.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=\ngithub.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=\ngithub.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=\ngithub.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=\ngithub.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=\ngithub.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=\ngithub.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=\ngithub.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=\ngithub.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=\ngithub.com/google/cel-go v0.26.1 h1:iPbVVEdkhTX++hpe3lzSk7D3G3QSYqLGoHOcEio+UXQ=\ngithub.com/google/cel-go v0.26.1/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM=\ngithub.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=\ngithub.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=\ngithub.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=\ngithub.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=\ngithub.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 h1:QGLs/O40yoNK9vmy4rhUGBVyMf1lISBGtXRpsu/Qu/o=\ngithub.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0/go.mod h1:hM2alZsMUni80N33RBe6J0e423LB+odMj7d3EMP9l20=\ngithub.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 h1:B+8ClL/kCQkRiU82d9xajRPKYMrB7E0MbtzWVi1K4ns=\ngithub.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3/go.mod h1:NbCUVmiS4foBGBHOYlCT25+YmGpJ32dZPi75pGEUpj4=\ngithub.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 h1:X5VWvz21y3gzm9Nw/kaUeku/1+uBhcekkmy4IkffJww=\ngithub.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1/go.mod h1:Zanoh4+gvIgluNqcfMVTJueD4wSS5hT7zTt4Mrutd90=\ngithub.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=\ngithub.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=\ngithub.com/jsha/minica v1.1.0 h1:O2ZbzAN75w4RTB+5+HfjIEvY5nxRqDlwj3ZlLVG5JD8=\ngithub.com/jsha/minica v1.1.0/go.mod h1:dxC3wNmD+gU1ewXo/R8jB2ihB6wNpyXrG8aUk5Iuf/k=\ngithub.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=\ngithub.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=\ngithub.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=\ngithub.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=\ngithub.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=\ngithub.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=\ngithub.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=\ngithub.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=\ngithub.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 h1:PpXWgLPs+Fqr325bN2FD2ISlRRztXibcX6e8f5FR5Dc=\ngithub.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=\ngithub.com/lum8rjack/go-ja4h v0.0.0-20250828030157-fa5266d50650 h1:hhx/Mo6+Hk0mAQS5MW311ON1VlSzp0D1cYhY27IcmnI=\ngithub.com/lum8rjack/go-ja4h v0.0.0-20250828030157-fa5266d50650/go.mod h1:bMqyXOakqQIdx82d4vcnk5TIZLptZ2gLqju9xmPrWYA=\ngithub.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=\ngithub.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=\ngithub.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=\ngithub.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=\ngithub.com/moby/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ=\ngithub.com/moby/go-archive v0.1.0/go.mod h1:G9B+YoujNohJmrIYFBpSd54GTUB4lt9S+xVQvsJyFuo=\ngithub.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=\ngithub.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=\ngithub.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=\ngithub.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs=\ngithub.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=\ngithub.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=\ngithub.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=\ngithub.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=\ngithub.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=\ngithub.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=\ngithub.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=\ngithub.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=\ngithub.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=\ngithub.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=\ngithub.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=\ngithub.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=\ngithub.com/nicksnyder/go-i18n/v2 v2.6.0 h1:C/m2NNWNiTB6SK4Ao8df5EWm3JETSTIGNXBpMJTxzxQ=\ngithub.com/nicksnyder/go-i18n/v2 v2.6.0/go.mod h1:88sRqr0C6OPyJn0/KRNaEz1uWorjxIKP7rUUcvycecE=\ngithub.com/nikandfor/spintax v0.0.0-20181023094358-fc346b245bb3 h1:foZ9X1bz2KmW7b8Yx5V0LAQKhTazdllv5rnGUe6iGTY=\ngithub.com/nikandfor/spintax v0.0.0-20181023094358-fc346b245bb3/go.mod h1:wwDYKfVF3WHdY0rugsAZoIpyQjDA3bn9wEzo/QXPx1Y=\ngithub.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=\ngithub.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=\ngithub.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=\ngithub.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=\ngithub.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=\ngithub.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=\ngithub.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=\ngithub.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=\ngithub.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=\ngithub.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=\ngithub.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=\ngithub.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=\ngithub.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=\ngithub.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=\ngithub.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=\ngithub.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc=\ngithub.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI=\ngithub.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=\ngithub.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=\ngithub.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI=\ngithub.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=\ngithub.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=\ngithub.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=\ngithub.com/sebest/xff v0.0.0-20210106013422-671bd2870b3a h1:iLcLb5Fwwz7g/DLK89F+uQBDeAhHhwdzB5fSlVdhGcM=\ngithub.com/sebest/xff v0.0.0-20210106013422-671bd2870b3a/go.mod h1:wozgYq9WEBQBaIJe4YZ0qTSFAMxmcwBhQH0fO0R34Z0=\ngithub.com/shirou/gopsutil/v4 v4.25.11 h1:X53gB7muL9Gnwwo2evPSE+SfOrltMoR6V3xJAXZILTY=\ngithub.com/shirou/gopsutil/v4 v4.25.11/go.mod h1:EivAfP5x2EhLp2ovdpKSozecVXn1TmuG7SMzs/Wh4PU=\ngithub.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=\ngithub.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=\ngithub.com/stoewer/go-strcase v1.3.1 h1:iS0MdW+kVTxgMoE1LAZyMiYJFKlOzLooE4MxjirtkAs=\ngithub.com/stoewer/go-strcase v1.3.1/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo=\ngithub.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=\ngithub.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=\ngithub.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=\ngithub.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=\ngithub.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=\ngithub.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=\ngithub.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=\ngithub.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=\ngithub.com/testcontainers/testcontainers-go v0.40.0 h1:pSdJYLOVgLE8YdUY2FHQ1Fxu+aMnb6JfVz1mxk7OeMU=\ngithub.com/testcontainers/testcontainers-go v0.40.0/go.mod h1:FSXV5KQtX2HAMlm7U3APNyLkkap35zNLxukw9oBi/MY=\ngithub.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=\ngithub.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=\ngithub.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=\ngithub.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=\ngithub.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=\ngithub.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=\ngo.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=\ngo.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=\ngo.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=\ngo.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=\ngo.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 h1:Hf9xI/XLML9ElpiHVDNwvqI0hIFlzV8dgIr35kV1kRU=\ngo.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0/go.mod h1:NfchwuyNoMcZ5MLHwPrODwUF1HWCXWrL31s8gSAdIKY=\ngo.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=\ngo.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=\ngo.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 h1:Ahq7pZmv87yiyn3jeFz/LekZmPLLdKejuO3NcK9MssM=\ngo.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0/go.mod h1:MJTqhM0im3mRLw1i8uGHnCvUEeS7VwRyxlLC78PA18M=\ngo.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.37.0 h1:bDMKF3RUSxshZ5OjOTi8rsHGaPKsAt76FaqgvIUySLc=\ngo.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.37.0/go.mod h1:dDT67G/IkA46Mr2l9Uj7HsQVwsjASyV9SjGofsiUZDA=\ngo.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=\ngo.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=\ngo.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=\ngo.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=\ngo.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=\ngo.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=\ngo.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=\ngo.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=\ngo.opentelemetry.io/proto/otlp v1.7.0 h1:jX1VolD6nHuFzOYso2E73H85i92Mv8JQYk0K9vz09os=\ngo.opentelemetry.io/proto/otlp v1.7.0/go.mod h1:fSKjH6YJ7HDlwzltzyMj036AJ3ejJLCgCSHGj4efDDo=\ngo.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=\ngo.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=\ngo.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=\ngo.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=\ngo.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=\ngo.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=\ngolang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=\ngolang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=\ngolang.org/x/exp v0.0.0-20251209150349-8475f28825e9 h1:MDfG8Cvcqlt9XXrmEiD4epKn7VJHZO84hejP9Jmp0MM=\ngolang.org/x/exp v0.0.0-20251209150349-8475f28825e9/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=\ngolang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=\ngolang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=\ngolang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=\ngolang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=\ngolang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=\ngolang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=\ngolang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=\ngolang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=\ngolang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=\ngolang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=\ngonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=\ngonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=\ngoogle.golang.org/genproto/googleapis/api v0.0.0-20251213004720-97cd9d5aeac2 h1:7LRqPCEdE4TP4/9psdaB7F2nhZFfBiGJomA5sojLWdU=\ngoogle.golang.org/genproto/googleapis/api v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto=\ngoogle.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA=\ngoogle.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=\ngoogle.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=\ngoogle.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=\ngoogle.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=\ngoogle.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=\ngopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=\ngopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=\ngopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=\ngopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=\ngopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=\ngopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\ngopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=\ngopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\ngotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=\ngotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=\nk8s.io/apimachinery v0.34.3 h1:/TB+SFEiQvN9HPldtlWOTp0hWbJ+fjU+wkxysf/aQnE=\nk8s.io/apimachinery v0.34.3/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw=\nsigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg=\nsigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=\nsigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=\nsigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=\n"
  },
  {
    "path": "test/healthcheck/docker-compose.yaml",
    "content": "services:\n  web:\n    image: ghcr.io/xe/nginx-micro:v1.29.0\n\n  anubis:\n    image: ko.local/anubis\n    environment:\n      TARGET: http://web:80\n      USE_REMOTE_ADDRESS: \"true\"\n    healthcheck:\n      test: [\"CMD\", \"anubis\", \"--healthcheck\"]\n      interval: 5s\n      timeout: 30s\n      retries: 5\n      start_period: 500ms\n"
  },
  {
    "path": "test/healthcheck/test.sh",
    "content": "#!/usr/bin/env bash\n\nset -eo pipefail\n\nexport VERSION=$GITHUB_COMMIT-test\nexport KO_DOCKER_REPO=ko.local\n\nset -u\n\nsource ../lib/lib.sh\n\nbuild_anubis_ko\ndocker compose up -d\n\nattempt=1\nmax_attempts=5\ndelay=2\n\nwhile ! docker compose ps | grep healthy; do\n  if (( attempt >= max_attempts )); then\n    echo \"Service did not become healthy after $max_attempts attempts.\"\n    exit 1\n  fi\n  echo \"Waiting for healthy service... attempt $attempt\"\n  sleep $delay\n  delay=$(( delay * 2 ))\n  attempt=$(( attempt + 1 ))\ndone\n\nexit 0"
  },
  {
    "path": "test/healthcheck/var/.gitignore",
    "content": "*\n!.gitignore"
  },
  {
    "path": "test/i18n/anubis.yaml",
    "content": "bots:\n  - name: challenge\n    user_agent_regex: CHALLENGE\n    action: CHALLENGE\n\nstatus_codes:\n  CHALLENGE: 200\n  DENY: 403\n"
  },
  {
    "path": "test/i18n/test.mjs",
    "content": "async function fetchLanguages() {\n  return fetch(\n    \"http://localhost:8923/.within.website/x/cmd/anubis/static/locales/manifest.json\",\n  )\n    .then((resp) => {\n      if (resp.status !== 200) {\n        throw new Error(`wanted status 200, got status: ${resp.status}`);\n      }\n      return resp;\n    })\n    .then((resp) => resp.json());\n}\n\nasync function getChallengePage(lang) {\n  return fetch(\"http://localhost:8923/reqmeta\", {\n    headers: {\n      \"Accept-Language\": lang,\n      \"User-Agent\": \"CHALLENGE\",\n    },\n  })\n    .then((resp) => {\n      if (resp.status !== 200) {\n        throw new Error(`wanted status 200, got status: ${resp.status}`);\n      }\n      return resp;\n    })\n    .then((resp) => resp.text());\n}\n\n(async () => {\n  const languages = await fetchLanguages();\n  console.log(languages);\n\n  const { supportedLanguages } = languages;\n\n  if (supportedLanguages.length === 0) {\n    throw new Error(`no languages defined`);\n  }\n\n  const resultSheet = {};\n  let failed = false;\n\n  for (const lang of supportedLanguages) {\n    console.log(`getting for ${lang}`);\n    const page = await getChallengePage(lang);\n\n    resultSheet[lang] = page.includes(`<html lang=\"${lang}\">`);\n  }\n\n  for (const [lang, result] of Object.entries(resultSheet)) {\n    if (!result) {\n      failed = true;\n      console.log(`${lang} did not show up in challenge page`);\n    }\n  }\n\n  console.log(resultSheet);\n\n  if (failed) {\n    throw new Error(\"i18n smoke test failed\");\n  }\n\n  process.exit(0);\n})();\n"
  },
  {
    "path": "test/i18n/test.sh",
    "content": "#!/usr/bin/env bash\n\nset -euo pipefail\n\nfunction cleanup() {\n  pkill -P $$\n}\n\ntrap cleanup EXIT SIGINT\n\n# Build static assets\n(cd ../.. && npm ci && npm run assets)\n\ngo tool anubis --help 2>/dev/null ||:\n\ngo run ../cmd/unixhttpd &\n\ngo tool anubis \\\n  --policy-fname ./anubis.yaml \\\n  --use-remote-address \\\n  --target=unix://$(pwd)/unixhttpd.sock &\n\nbackoff-retry node ./test.mjs\n"
  },
  {
    "path": "test/i18n/var/.gitignore",
    "content": "*\n!.gitignore"
  },
  {
    "path": "test/k8s/cert-manager/selfsigned-issuer.yaml",
    "content": "apiVersion: cert-manager.io/v1\nkind: ClusterIssuer\nmetadata:\n  name: selfsigned\nspec:\n  selfSigned: {}\n"
  },
  {
    "path": "test/k8s/deps/cert-manager.yaml",
    "content": "apiVersion: helm.cattle.io/v1\nkind: HelmChart\nmetadata:\n  name: cert-manager\n  namespace: kube-system\nspec:\n  repo: https://charts.jetstack.io\n  chart: cert-manager\n  targetNamespace: cert-manager\n  createNamespace: true\n  set:\n    installCRDs: \"true\"\n    \"prometheus.enabled\": \"false\"\n"
  },
  {
    "path": "test/lib/lib.sh",
    "content": "REPO_ROOT=$(git rev-parse --show-toplevel)\n(cd $REPO_ROOT && go install ./utils/cmd/...)\n\nmkdir -p pki\necho '*' >>./pki/.gitignore\n\nfunction cleanup() {\n\tset +e\n\n\tpkill -P $$\n\n\tif [ -f \"docker-compose.yaml\" ]; then\n\t\tdocker compose down -t 1 || :\n\t\tdocker compose rm -f || :\n\tfi\n}\n\ntrap cleanup EXIT SIGINT\n\nfunction build_anubis_ko() {\n\t(\n\t\tcd $REPO_ROOT && npm ci && npm run assets\n\t)\n\t(\n\t\tcd $REPO_ROOT &&\n\t\t\tVERSION=devel ko build \\\n\t\t\t\t--platform=all \\\n\t\t\t\t--base-import-paths \\\n\t\t\t\t--tags=\"latest\" \\\n\t\t\t\t--image-user=1000 \\\n\t\t\t\t--image-annotation=\"\" \\\n\t\t\t\t--image-label=\"\" \\\n\t\t\t\t./cmd/anubis \\\n\t\t\t\t--local\n\t)\n}\n\nfunction mint_cert() {\n\tif [ \"$#\" -ne 1 ]; then\n\t\techo \"Usage: mint_cert <domain.name>\"\n\tfi\n\n\tdomainName=\"$1\"\n\n\t# If the transient local TLS certificate doesn't exist, mint a new one\n\tif [ ! -f \"./pki/${domainName}/cert.pem\" ]; then\n\t\t# Subshell to contain the directory change\n\t\t(\n\t\t\tcd ./pki &&\n\t\t\t\tmkdir -p \"${domainName}\" &&\n\t\t\t\tgo tool minica -domains \"${domainName}\" &&\n\t\t\t\tcd \"${domainName}\" &&\n\t\t\t\tchmod 666 *\n\t\t)\n\tfi\n}\n"
  },
  {
    "path": "test/log-file/anubis.yaml",
    "content": "bots:\n  - name: challenge\n    user_agent_regex: CHALLENGE\n    action: CHALLENGE\n\nstatus_codes:\n  CHALLENGE: 200\n  DENY: 403\n\nlogging:\n  sink: file\n  parameters:\n    file: \"./var/anubis.log\"\n    maxBackups: 3 # keep at least 3 old copies\n    maxBytes: 67108864 # each file can have up to 64 Mi of logs\n    maxAge: 7 # rotate files out every n days\n    compress: true\n    useLocalTime: false # timezone for rotated files is UTC\n"
  },
  {
    "path": "test/log-file/input.txt",
    "content": "/wiki//bin\n/wiki//boot\n/wiki//dev\n/wiki//dev/de\n/wiki//dev/en\n/wiki//dev/en-ca\n/wiki//dev/es\n/wiki//dev/fr\n/wiki//dev/hr\n/wiki//dev/hu\n/wiki//dev/it\n/wiki//dev/ja\n/wiki//dev/ko\n/wiki//dev/pl\n/wiki//dev/pt-br\n/wiki//dev/ro\n/wiki//dev/ru\n/wiki//dev/sv\n/wiki//dev/uk\n/wiki//dev/zh-cn\n/wiki//etc\n/wiki//etc/conf.d\n/wiki//etc/env.d\n/wiki//etc/fstab\n/wiki//etc/fstab/de\n/wiki//etc/fstab/en\n/wiki//etc/fstab/es\n/wiki//etc/fstab/fr\n/wiki//etc/fstab/hu\n/wiki//etc/fstab/it\n/wiki//etc/fstab/ja\n/wiki//etc/fstab/ko\n/wiki//etc/fstab/ru\n/wiki//etc/fstab/sv\n/wiki//etc/fstab/uk\n/wiki//etc/fstab/zh-cn\n/wiki//etc/hosts\n/wiki//etc/local.d\n/wiki//etc/make.conf\n/wiki//etc/portage\n/wiki//etc/portage/bashrc\n/wiki//etc/portage/Bashrc\n/wiki//etc/portage/binrepos.conf\n/wiki//etc/portage/binrepos.conf/en\n/wiki//etc/portage/binrepos.conf/hu\n/wiki//etc/portage/binrepos.conf/ja\n/wiki//etc/portage/binrepos.conf/ru\n/wiki//etc/portage/categories\n/wiki//etc/portage/color.map\n/wiki//etc/portage/env\n/wiki//etc/portage/img/ico.png\n/wiki//etc/portage/license_groups\n/wiki//etc/portage/make.conf\n/wiki//etc/portage/make.conf/de\n/wiki//etc/portage/make.conf/de/etc/portage/make.conf\n/wiki//etc/portage/make.conf/en\n/wiki//etc/portage/make.conf/es\n/wiki//etc/portage/make.conf/fr\n/wiki//etc/portage/make.conf/hu\n/wiki//etc/portage/make.conf/it\n/wiki//etc/portage/make.conf/it/var/db/repos/gentoo/licenses\n/wiki//etc/portage/make.conf/ja\n/wiki//etc/portage/make.conf/pl\n/wiki//etc/portage/make.conf/ru\n/wiki//etc/portage/make.conf/uk\n/wiki//etc/portage/make.conf/zh-cn\n/wiki//etc/portage/make.profile\n/wiki//etc/portage/mirrors\n/wiki//etc/portage/modules\n/wiki//etc/portage/package.accept_keywords\n/wiki//etc/portage/package.env\n/wiki//etc/portage/package.license\n/wiki//etc/portage/package.license/en\n/wiki//etc/portage/package.license/es\n/wiki//etc/portage/package.license/hu\n/wiki//etc/portage/package.license/ja\n/wiki//etc/portage/package.mask\n/wiki//etc/portage/package.mask/en\n/wiki//etc/portage/package.mask/hu\n/wiki//etc/portage/package.mask/ja\n/wiki//etc/portage/package.properties\n/wiki//etc/portage/package.unmask\n/wiki//etc/portage/package.use\n/wiki//etc/portage/package.use/de\n/wiki//etc/portage/package.use/en\n/wiki//etc/portage/package.use/es\n/wiki//etc/portage/package.use/fr\n/wiki//etc/portage/package.use/hu\n/wiki//etc/portage/package.use/it\n/wiki//etc/portage/package.use/ja\n/wiki//etc/portage/package.use/ru\n/wiki//etc/portage/package.use/uk\n/wiki//etc/portage/package.use/zh-cn\n/wiki//etc/portage/patches\n/wiki//etc/portage/profile/make.defaults\n/wiki//etc/portage/profile/package.provided\n/wiki//etc/portage/profile/package.provided/etc/portage/profile/package.provided\n/wiki//etc/portage/profile/package.provided/etc/portage/profiles/package.provided\n/wiki//etc/portage/profile/package.use.mask\n/wiki//etc/portage/profiles/package.provided\n/wiki//etc/portage/profiles/package.use.mask\n/wiki//etc/portage/profiles/package.use.mask/etc/portage/profile/package.use.mask\n/wiki//etc/portage/profiles/package.use.mask/etc/portage/profiles/package.use.mask\n/wiki//etc/portage/profiles/use.mask\n/wiki//etc/portage/profile/use.mask\n/wiki//etc/portage/repos.conf\n/wiki//etc/portage/repos.conf/brother-overlay.conf\n/wiki//etc/portage/repos.conf/de\n/wiki//etc/portage/repos.conf/en\n/wiki//etc/portage/repos.conf/es\n/wiki//etc/portage/repos.conf/etc/portage/repos.conf/gentoo.conf\n/wiki//etc/portage/repos.conf/fr\n/wiki//etc/portage/repos.conf/fr/etc/portage/repos.conf/gentoo.conf\n/wiki//etc/portage/repos.conf/gentoo.conf\n/wiki//etc/portage/repos.conf/gentoo.conf/etc/portage/repos.conf/gentoo.conf\n/wiki//etc/portage/repos.conf/hr\n/wiki//etc/portage/repos.conf/hu\n/wiki//etc/portage/repos.conf/it\n/wiki//etc/portage/repos.conf/ja\n/wiki//etc/portage/repos.conf/ko\n/wiki//etc/portage/repos.conf/pl\n/wiki//etc/portage/repos.conf/pt-br\n/wiki//etc/portage/repos.conf/ru\n/wiki//etc/portage/repos.conf/uk\n/wiki//etc/portage/repos.conf/zh-cn\n/wiki//etc/portage/savedconfig\n/wiki//etc/portage/sets\n/wiki//etc/profile\n/wiki//etc/profile.env\n/wiki//etc/sandbox.conf\n/wiki//home\n/wiki//lib\n/wiki//lib64\n/wiki//media\n/wiki//mnt\n/wiki//opt\n/wiki//proc\n/wiki//proc/config.gz\n/wiki//run\n/wiki//sbin\n/wiki//srv\n/wiki//sys\n/wiki//tmp\n/wiki//usr\n/wiki//usr/bin\n/wiki//usr_move\n/wiki//usr/portage\n/wiki//usr/portage/distfiles\n/wiki//usr/portage/licenses\n/wiki//usr/portage/metadata\n/wiki//usr/portage/metadata/md5-cache\n/wiki//usr/portage/metadata/md5-cache/usr/portage/metadata/md5-cache\n/wiki//usr/portage/metadata/md5-cache/var/db/repos/gentoo//metadata/md5-cache\n/wiki//usr/portage/packages\n/wiki//usr/portage/profiles\n/wiki//usr/portage/profiles/license_groups\n/wiki//usr/portage/profiles/license_groups/usr/portage/profiles/license_groups\n/wiki//usr/portage/profiles/license_groups/var/db/repos/gentoo//profiles/license_groups\n/wiki//usr/share/doc/\n/wiki//var/cache/binpkgs\n/wiki//var/cache/distfiles\n/wiki//var/db/pkg\n/wiki//var/db/pkg%22\n/wiki//var/db/repos/gentoo\n/wiki//var/db/repos/gentoo/licenses\n/wiki//var/db/repos/gentoo/licenses/var/db/repos/gentoo//licenses\n/wiki//var/db/repos/gentoo/licenses/var/db/repos/gentoo/licenses\n/wiki//var/db/repos/gentoo/metadata\n/wiki//var/db/repos/gentoo/metadata/md5-cache\n/wiki//var/db/repos/gentoo/metadata/var/db/repos/gentoo//metadata\n/wiki//var/db/repos/gentoo/metadata/var/db/repos/gentoo/metadata\n/wiki//var/db/repos/gentoo/profiles\n/wiki//var/db/repos/gentoo/profiles/license_groups\n/wiki//var/db/repos/gentoo/profiles/package.mask\n/wiki//var/lib/portage\n/wiki//var/lib/portage/world\n/wiki//var/run\n/gcc-bugs/bug-122002-4@http.gcc.gnu.org%2Fbugzilla%2F/T/"
  },
  {
    "path": "test/log-file/test.mjs",
    "content": "import { statSync } from \"fs\";\n\nasync function getPage(path) {\n  return fetch(`http://localhost:8923${path}`, {\n    headers: {\n      \"User-Agent\": \"CHALLENGE\",\n    },\n  })\n    .then((resp) => {\n      if (resp.status !== 200) {\n        throw new Error(`wanted status 200, got status: ${resp.status}`);\n      }\n      return resp;\n    })\n    .then((resp) => resp.text());\n}\n\nasync function getFileSize(filePath) {\n  try {\n    return statSync(filePath).size;\n  } catch (error) {\n    return 0;\n  }\n}\n\n(async () => {\n  const logFilePath = \"./var/anubis.log\";\n\n  // Get initial log file size\n  const initialSize = await getFileSize(logFilePath);\n  console.log(`Initial log file size: ${initialSize} bytes`);\n\n  // Make 35 requests with different paths\n  const requests = [];\n  for (let i = 0; i < 35; i++) {\n    requests.push(`/test${i}`);\n  }\n\n  const resultSheet = {};\n  let failed = false;\n\n  for (const path of requests) {\n    try {\n      const resp = await getPage(path);\n      resultSheet[path] = {\n        success: true,\n        line: resp.split(\"\\n\")[0],\n      };\n    } catch (error) {\n      resultSheet[path] = {\n        success: false,\n        error: error.message,\n      };\n      console.log(`✗ Request to ${path} failed: ${error.message}`);\n      failed = true;\n    }\n  }\n\n  // Check final log file size\n  const finalSize = await getFileSize(logFilePath);\n  console.log(`Final log file size: ${finalSize} bytes`);\n  console.log(`Size increase: ${finalSize - initialSize} bytes`);\n\n  // Verify that log file size increased\n  if (finalSize <= initialSize) {\n    console.error(\n      \"ERROR: Log file size did not increase after making requests!\",\n    );\n    failed = true;\n  }\n\n  let successCount = 0;\n  for (let [k, v] of Object.entries(resultSheet)) {\n    if (!v.success) {\n      console.error({ path: k, error: v.error });\n    } else {\n      successCount++;\n    }\n  }\n\n  console.log(`Successful requests: ${successCount}/${requests.length}`);\n\n  if (failed) {\n    console.error(\n      \"Test failed: Some requests failed or log file size did not increase\",\n    );\n    process.exit(1);\n  } else {\n    console.log(\n      \"Test passed: All requests succeeded and log file size increased\",\n    );\n    process.exit(0);\n  }\n})();\n"
  },
  {
    "path": "test/log-file/test.sh",
    "content": "#!/usr/bin/env bash\n\nset -euo pipefail\n\nfunction cleanup() {\n\tpkill -P $$\n}\n\ntrap cleanup EXIT SIGINT\n\n# Build static assets\n(cd ../.. && npm ci && npm run assets)\n\ngo tool anubis --help 2>/dev/null || :\n\ngo run ../cmd/httpdebug &\n\ngo tool anubis \\\n\t--policy-fname ./anubis.yaml \\\n\t--use-remote-address \\\n\t--target=http://localhost:3923 &\n\nsleep 2\n\nbackoff-retry node ./test.mjs\n"
  },
  {
    "path": "test/log-file/var/.gitignore",
    "content": "*\n!.gitignore"
  },
  {
    "path": "test/nginx/conf/nginx/conf-anubis.inc",
    "content": "# /etc/nginx/conf-anubis.inc\n# Forward to anubis\nlocation / {\n  proxy_set_header Host $host;\n  proxy_set_header X-Real-IP $remote_addr;\n  proxy_pass http://anubis;\n}\n"
  },
  {
    "path": "test/nginx/conf/nginx/conf.d/server-mimi-techaro-lol.conf",
    "content": "# /etc/nginx/conf.d/server-mimi-techaro-lol.conf\n\nserver {\n  # Listen on 443 with SSL\n  listen 443 ssl;\n  listen [::]:443 ssl;\n  http2 on;\n\n  # Slipstream via Anubis\n  include \"conf-anubis.inc\";\n\n  server_name mimi.techaro.lol;\n\n  ssl_certificate /techaro/pki/mimi.techaro.lol/cert.pem;\n  ssl_certificate_key /techaro/pki/mimi.techaro.lol/key.pem;\n}\n\nserver {\n  listen unix:/tmp/nginx.sock;\n\n  server_name mimi.techaro.lol;\n\n  port_in_redirect off;\n  root \"/srv/http/mimi.techaro.lol\";\n  index index.html;\n\n  # Your normal configuration can go here\n  # location .php { fastcgi...} etc.\n}"
  },
  {
    "path": "test/nginx/conf/nginx/conf.d/upstream-anubis.conf",
    "content": "# /etc/nginx/conf.d/upstream-anubis.conf\n\nupstream anubis {\n  zone anubis_zone 64k;\n  # Make sure this matches the values you set for `BIND` and `BIND_NETWORK`.\n  # If this does not match, your services will not be protected by Anubis.\n\n  # Try anubis first over a UNIX socket\n  #server unix:/run/anubis/nginx.sock;\n  server anubis:3000 resolve;\n\n  # Optional: fall back to serving the websites directly. This allows your\n  # websites to be resilient against Anubis failing, at the risk of exposing\n  # them to the raw internet without protection. This is a tradeoff and can\n  # be worth it in some edge cases.\n  #server unix:/run/nginx.sock backup;\n}"
  },
  {
    "path": "test/nginx/conf/nginx/mime.types",
    "content": "\ntypes {\n    text/html                                        html htm shtml;\n    text/css                                         css;\n    text/xml                                         xml;\n    image/gif                                        gif;\n    image/jpeg                                       jpeg jpg;\n    application/javascript                           js;\n    application/atom+xml                             atom;\n    application/rss+xml                              rss;\n\n    text/mathml                                      mml;\n    text/plain                                       txt;\n    text/vnd.sun.j2me.app-descriptor                 jad;\n    text/vnd.wap.wml                                 wml;\n    text/x-component                                 htc;\n\n    image/avif                                       avif;\n    image/png                                        png;\n    image/svg+xml                                    svg svgz;\n    image/tiff                                       tif tiff;\n    image/vnd.wap.wbmp                               wbmp;\n    image/webp                                       webp;\n    image/x-icon                                     ico;\n    image/x-jng                                      jng;\n    image/x-ms-bmp                                   bmp;\n\n    font/woff                                        woff;\n    font/woff2                                       woff2;\n\n    application/java-archive                         jar war ear;\n    application/json                                 json;\n    application/mac-binhex40                         hqx;\n    application/msword                               doc;\n    application/pdf                                  pdf;\n    application/postscript                           ps eps ai;\n    application/rtf                                  rtf;\n    application/vnd.apple.mpegurl                    m3u8;\n    application/vnd.google-earth.kml+xml             kml;\n    application/vnd.google-earth.kmz                 kmz;\n    application/vnd.ms-excel                         xls;\n    application/vnd.ms-fontobject                    eot;\n    application/vnd.ms-powerpoint                    ppt;\n    application/vnd.oasis.opendocument.graphics      odg;\n    application/vnd.oasis.opendocument.presentation  odp;\n    application/vnd.oasis.opendocument.spreadsheet   ods;\n    application/vnd.oasis.opendocument.text          odt;\n    application/vnd.openxmlformats-officedocument.presentationml.presentation\n                                                     pptx;\n    application/vnd.openxmlformats-officedocument.spreadsheetml.sheet\n                                                     xlsx;\n    application/vnd.openxmlformats-officedocument.wordprocessingml.document\n                                                     docx;\n    application/vnd.wap.wmlc                         wmlc;\n    application/wasm                                 wasm;\n    application/x-7z-compressed                      7z;\n    application/x-cocoa                              cco;\n    application/x-java-archive-diff                  jardiff;\n    application/x-java-jnlp-file                     jnlp;\n    application/x-makeself                           run;\n    application/x-perl                               pl pm;\n    application/x-pilot                              prc pdb;\n    application/x-rar-compressed                     rar;\n    application/x-redhat-package-manager             rpm;\n    application/x-sea                                sea;\n    application/x-shockwave-flash                    swf;\n    application/x-stuffit                            sit;\n    application/x-tcl                                tcl tk;\n    application/x-x509-ca-cert                       der pem crt;\n    application/x-xpinstall                          xpi;\n    application/xhtml+xml                            xhtml;\n    application/xspf+xml                             xspf;\n    application/zip                                  zip;\n\n    application/octet-stream                         bin exe dll;\n    application/octet-stream                         deb;\n    application/octet-stream                         dmg;\n    application/octet-stream                         iso img;\n    application/octet-stream                         msi msp msm;\n\n    audio/midi                                       mid midi kar;\n    audio/mpeg                                       mp3;\n    audio/ogg                                        ogg;\n    audio/x-m4a                                      m4a;\n    audio/x-realaudio                                ra;\n\n    video/3gpp                                       3gpp 3gp;\n    video/mp2t                                       ts;\n    video/mp4                                        mp4;\n    video/mpeg                                       mpeg mpg;\n    video/quicktime                                  mov;\n    video/webm                                       webm;\n    video/x-flv                                      flv;\n    video/x-m4v                                      m4v;\n    video/x-mng                                      mng;\n    video/x-ms-asf                                   asx asf;\n    video/x-ms-wmv                                   wmv;\n    video/x-msvideo                                  avi;\n}\n"
  },
  {
    "path": "test/nginx/conf/nginx/nginx.conf",
    "content": "worker_processes auto;\n\nerror_log /var/log/nginx/error.log notice;\npid /run/nginx.pid;\n\n\nevents {\n    worker_connections 1024;\n}\n\n\nhttp {\n    resolver 169.254.42.1 valid=300s ipv6=on;\n    resolver_timeout 10s;\n    include /etc/nginx/mime.types;\n    default_type application/octet-stream;\n\n    log_format main '$remote_addr - $remote_user [$time_local] \"$request\" '\n    '$status $body_bytes_sent \"$http_referer\" '\n    '\"$http_user_agent\" \"$http_x_forwarded_for\"';\n\n    access_log /var/log/nginx/access.log main;\n\n    sendfile on;\n    #tcp_nopush     on;\n\n    keepalive_timeout 65;\n\n    #gzip  on;\n\n    include /etc/nginx/conf.d/*.conf;\n}\n"
  },
  {
    "path": "test/nginx/test.sh",
    "content": "#!/usr/bin/env bash\n\nsource ../lib/lib.sh\n\nexport KO_DOCKER_REPO=ko.local\n\nset -euo pipefail\n\nmint_cert mimi.techaro.lol\n\ndocker run --rm \\\n\t-v $PWD/conf/nginx:/etc/nginx:ro \\\n\t-v $PWD/pki:/techaro/pki:ro \\\n\tnginx \\\n\tnginx -t\n\nexit 0\n"
  },
  {
    "path": "test/nginx-external-auth/conf.d/default.conf",
    "content": "server {\n  listen 80;\n  listen [::]:80;\n  server_name nginx.local.cetacean.club;\n\n  proxy_set_header X-Real-IP $remote_addr;\n  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n\n  location /.within.website/ {\n    proxy_pass http://localhost:8923;\n    auth_request off;\n  }\n\n  location @redirectToAnubis {\n    return 307 /.within.website/?redir=$scheme://$host$request_uri;\n    auth_request off;\n  }\n\n  location / {\n    auth_request /.within.website/x/cmd/anubis/api/check;\n    error_page 401 = @redirectToAnubis;\n    root /usr/share/nginx/html;\n    index index.html index.htm;\n  }\n}"
  },
  {
    "path": "test/nginx-external-auth/deployment.yaml",
    "content": "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: nginx-external-auth\nspec:\n  selector:\n    matchLabels:\n      app: nginx-external-auth\n  template:\n    metadata:\n      labels:\n        app: nginx-external-auth\n    spec:\n      volumes:\n        - name: config\n          configMap:\n            name: nginx-cfg\n      containers:\n        - name: www\n          image: nginx:alpine\n          resources:\n            limits:\n              memory: \"128Mi\"\n              cpu: \"500m\"\n            requests:\n              memory: \"128Mi\"\n              cpu: \"500m\"\n          ports:\n            - containerPort: 80\n          volumeMounts:\n            - name: config\n              mountPath: /etc/nginx/conf.d\n              readOnly: true\n        - name: anubis\n          image: ttl.sh/techaro/anubis:latest\n          imagePullPolicy: Always\n          resources:\n            limits:\n              cpu: 500m\n              memory: 128Mi\n            requests:\n              cpu: 250m\n              memory: 128Mi\n          env:\n            - name: TARGET\n              value: \" \"\n            - name: REDIRECT_DOMAINS\n              value: nginx.local.cetacean.club\n"
  },
  {
    "path": "test/nginx-external-auth/ingress.yaml",
    "content": "apiVersion: networking.k8s.io/v1\nkind: Ingress\nmetadata:\n  name: nginx-external-auth\n  labels:\n    name: nginx-external-auth\n  annotations:\n    cert-manager.io/cluster-issuer: \"selfsigned\"\nspec:\n  ingressClassName: traefik\n  tls:\n    - hosts:\n        - nginx.local.cetacean.club\n      secretName: nginx-local-cetacean-club-public-tls\n  rules:\n    - host: nginx.local.cetacean.club\n      http:\n        paths:\n          - pathType: Prefix\n            path: \"/\"\n            backend:\n              service:\n                name: nginx-external-auth\n                port:\n                  name: http\n"
  },
  {
    "path": "test/nginx-external-auth/kustomization.yaml",
    "content": "resources:\n  - deployment.yaml\n  - service.yaml\n  - ingress.yaml\n\nconfigMapGenerator:\n  - name: nginx-cfg\n    behavior: create\n    files:\n      - ./conf.d/default.conf\n"
  },
  {
    "path": "test/nginx-external-auth/service.yaml",
    "content": "apiVersion: v1\nkind: Service\nmetadata:\n  name: nginx-external-auth\nspec:\n  selector:\n    app: nginx-external-auth\n  ports:\n    - name: http\n      protocol: TCP\n      port: 80\n      targetPort: 80\n  type: ClusterIP\n"
  },
  {
    "path": "test/nginx-external-auth/start.sh",
    "content": "#!/usr/bin/env bash\n\nset -euo pipefail\n\n# Build container image\n(\n\tcd ../.. &&\n\t\tnpm ci &&\n\t\tnpm run container -- \\\n\t\t\t--docker-repo ttl.sh/techaro/anubis \\\n\t\t\t--docker-tags ttl.sh/techaro/anubis:latest\n)\n\nkubectl apply -k .\necho \"open https://nginx.local.cetacean.club, press control c when done\"\n\ncontrol_c() {\n\tkubectl delete -k .\n\texit\n}\ntrap control_c SIGINT\n\nsleep infinity\n"
  },
  {
    "path": "test/palemoon/README.md",
    "content": "# Pale Moon CI tests\n\nPale 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.\n\nThis test is a fork of [dtinth/xtigervnc-docker](https://github.com/dtinth/xtigervnc-docker) but focused on Pale Moon.\n"
  },
  {
    "path": "test/palemoon/amd64/docker-compose.yml",
    "content": "services:\n  display:\n    image: ghcr.io/techarohq/ci-images/xserver:latest\n    pull_policy: always\n    ports:\n      - 5900:5900\n\n  anubis:\n    image: ko.local/anubis\n    environment:\n      BIND: \":3000\"\n      TARGET: http://$TARGET\n      POLICY_FNAME: /cfg/anubis.yaml\n      SLOG_LEVEL: DEBUG\n    volumes:\n      - ../anubis:/cfg\n    depends_on:\n      - relayd\n\n  relayd:\n    image: ghcr.io/xe/x/relayd\n    environment:\n      BIND: :443\n      CERT_DIR: /techaro/pki\n      CERT_FNAME: cert.pem\n      KEY_FNAME: key.pem\n      PROXY_TO: http://anubis:3000\n    volumes:\n      - ./pki/relayd:/techaro/pki:ro\n\n  # novnc:\n  #   image: geek1011/easy-novnc\n  #   command: -a :5800 -h display --no-url-password\n  #   ports:\n  #     - 5800:5800\n\n  palemoon:\n    platform: linux/amd64\n    init: true\n    image: ghcr.io/techarohq/ci-images/palemoon:latest\n    command: sleep inf\n    environment:\n      DISPLAY: display:0\n    volumes:\n      - ./pki:/usr/local/share/ca-certificates/minica:ro\n      - ../scripts:/hack/scripts:ro\n    depends_on:\n      - anubis\n      - relayd\n      - display\n"
  },
  {
    "path": "test/palemoon/amd64/test.sh",
    "content": "#!/usr/bin/env bash\n\nexport VERSION=$GITHUB_COMMIT-test\nexport KO_DOCKER_REPO=ko.local\n\nfunction capture_vnc_snapshots() {\n  sudo apt-get update && sudo apt-get install -y gvncviewer\n  mkdir -p ./var\n  while true; do\n    timestamp=$(date +\"%Y%m%d%H%M%S\")\n    gvnccapture localhost:0 ./var/snapshot_$timestamp.png 2>/dev/null\n    sleep 1\n  done\n}\n\nfunction timeout() {\n  sleep 180\n  exit 1\n}\n\nsource ../../lib/lib.sh\n\nif [ \"$GITHUB_ACTIONS\" = \"true\" ]; then\n  capture_vnc_snapshots &\nfi\n\nset -euo pipefail\n\nbuild_anubis_ko\nmint_cert relayd\n\ntimeout &\ngo run ../../cmd/cipra/ --compose-name $(basename $(pwd))\n\ndocker compose down -t 1 || :\ndocker compose rm -f || :\n\nexit 0\n"
  },
  {
    "path": "test/palemoon/amd64/var/.gitignore",
    "content": "*\n!.gitignore"
  },
  {
    "path": "test/palemoon/anubis/anubis.yaml",
    "content": "bots:\n  - name: palemoon\n    user_agent_regex: PaleMoon\n    action: CHALLENGE\n    challenge:\n      difficulty: 2\n      algorithm: fast\n\nstatus_codes:\n  CHALLENGE: 401\n  DENY: 403\n"
  },
  {
    "path": "test/palemoon/i386/docker-compose.yml",
    "content": "services:\n  display:\n    image: ghcr.io/techarohq/ci-images/xserver:latest\n    pull_policy: always\n    ports:\n      - 5900:5900\n\n  anubis:\n    image: ko.local/anubis\n    environment:\n      BIND: \":3000\"\n      TARGET: http://$TARGET\n      POLICY_FNAME: /cfg/anubis.yaml\n      SLOG_LEVEL: DEBUG\n    volumes:\n      - ../anubis:/cfg\n\n  relayd:\n    image: ghcr.io/xe/x/relayd\n    environment:\n      BIND: :443\n      CERT_DIR: /techaro/pki\n      CERT_FNAME: cert.pem\n      KEY_FNAME: key.pem\n      PROXY_TO: http://anubis:3000\n    volumes:\n      - ./pki/relayd:/techaro/pki:ro\n\n  # novnc:\n  #   image: geek1011/easy-novnc\n  #   command: -a :5800 -h display --no-url-password\n  #   ports:\n  #     - 5800:5800\n\n  palemoon:\n    platform: linux/386\n    init: true\n    image: ghcr.io/techarohq/ci-images/palemoon:latest\n    command: sleep inf\n    environment:\n      DISPLAY: display:0\n    volumes:\n      - ./pki:/usr/local/share/ca-certificates/minica:ro\n      - ../scripts:/hack/scripts:ro\n"
  },
  {
    "path": "test/palemoon/i386/test.sh",
    "content": "#!/usr/bin/env bash\n\nexport VERSION=$GITHUB_COMMIT-test\nexport KO_DOCKER_REPO=ko.local\n\nfunction capture_vnc_snapshots() {\n  sudo apt-get update && sudo apt-get install -y gvncviewer\n  mkdir -p ./var\n  while true; do\n    timestamp=$(date +\"%Y%m%d%H%M%S\")\n    gvnccapture localhost:0 ./var/snapshot_$timestamp.png 2>/dev/null\n    sleep 1\n  done\n}\n\nsource ../../lib/lib.sh\n\nif [ \"$GITHUB_ACTIONS\" = \"true\" ]; then\n  capture_vnc_snapshots &\nfi\n\nset -euo pipefail\n\nbuild_anubis_ko\nmint_cert relayd\n\ngo run ../../cmd/cipra/ --compose-name $(basename $(pwd))\n\ndocker compose down -t 1 || :\ndocker compose rm -f || :\n"
  },
  {
    "path": "test/palemoon/i386/var/.gitignore",
    "content": "*\n!.gitignore"
  },
  {
    "path": "test/palemoon/scripts/install-cert.sh",
    "content": "#!/usr/bin/env bash\n\nset -euo pipefail\n\nCERT_PATH=\"/usr/local/share/ca-certificates/minica/minica.pem\"\nCERT_NAME=\"minica\"\nTRUST_FLAGS=\"C,,\"\n\nFIREFOX_DIR=\"$HOME/.mozilla/firefox\"\nPALEMOON_DIR=\"$HOME/.moonchild productions/pale moon\"\n\necho \"🔄 Updating system CA certificates...\"\nupdate-ca-certificates\n\n# 🌀 Trigger Pale Moon to create its profile if needed\nif command -v palemoon &>/dev/null; then\n  echo \"🚀 Launching Pale Moon to initialize profile...\"\n  palemoon &>/dev/null &\n  PALEMOON_PID=$!\n\n  # Wait up to 20 seconds for prefs.js to be created\n  for i in {1..20}; do\n    set +e\n    PROFILE_DIR=$(grep Path ~/.moonchild\\ productions/pale\\ moon/profiles.ini | cut -d= -f2)\n    PREFS_FILE=\"$HOME/.moonchild productions/pale moon/$PROFILE_DIR/prefs.js\"\n\n    if [[ -f \"$PREFS_FILE\" ]]; then\n      set -e\n      echo \"✅ prefs.js found at: $PREFS_FILE\"\n      break\n    fi\n\n    sleep 5\n  done\n\n  kill $PALEMOON_PID 2>/dev/null || true\n  wait $PALEMOON_PID 2>/dev/null || true\n\n  if [[ ! -f \"$PREFS_FILE\" ]]; then\n    echo \"❌ prefs.js not found. Pale Moon did not fully initialize.\"\n    exit 1\n  fi\nelse\n  echo \"⚠️ Pale Moon is not installed or not in PATH. Skipping profile bootstrap.\"\nfi\n\necho 'user_pref(\"security.cert_pinning.enforcement_level\", 0);' >>\"$PREFS_FILE\"\n\necho \"✅ TLS cert validation disabled in Pale Moon profile: $PROFILE_DIR\"\n\n# 🔧 Ensure certutil is installed\nif ! command -v certutil &>/dev/null; then\n  if [ -f /etc/debian_version ]; then\n    echo \"🔧 'certutil' not found. Installing via apt...\"\n    apt-get update\n    apt-get install -y libnss3-tools\n  else\n    echo \"❌ 'certutil' not found and install is only supported on Debian-based systems.\"\n    exit 1\n  fi\nfi\n\nimport_cert_to_profiles() {\n  local base_dir=\"$1\"\n  local browser_name=\"$2\"\n  local profile_glob=\"$3\"\n\n  if [ ! -d \"$base_dir\" ]; then\n    echo \"⚠️  $browser_name profile directory not found: $base_dir\"\n    return\n  fi\n\n  echo \"📌 Searching for $browser_name profiles in: $base_dir\"\n\n  local found=0\n\n  for profile in \"$base_dir\"/$profile_glob; do\n    if [ ! -d \"$profile\" ]; then\n      continue\n    fi\n\n    found=1\n    local db_path=\"sql:$profile\"\n    echo \"🔍 Processing $browser_name profile: $profile\"\n\n    if certutil -L -d \"$db_path\" | grep -q \"^$CERT_NAME\"; then\n      echo \"  ✅ Certificate '$CERT_NAME' already exists in profile.\"\n      continue\n    fi\n\n    certutil -A -n \"$CERT_NAME\" -t \"$TRUST_FLAGS\" -i \"$CERT_PATH\" -d \"$db_path\"\n    echo \"  ➕ Added certificate '$CERT_NAME' to $browser_name profile.\"\n  done\n\n  if [ \"$found\" -eq 0 ]; then\n    echo \"⚠️  No $browser_name profiles found in: $base_dir\"\n  fi\n}\n\nimport_cert_to_profiles \"$FIREFOX_DIR\" \"Firefox\" \"*.default*\"\nimport_cert_to_profiles \"$PALEMOON_DIR\" \"Pale Moon\" \"*.*\"\n\necho \"✅ Done. Firefox and Pale Moon profiles updated with '$CERT_NAME' certificate.\"\n"
  },
  {
    "path": "test/pki/.gitignore",
    "content": "*\n!.gitignore"
  },
  {
    "path": "test/robots_txt/anubis.yaml",
    "content": "bots:\n  - name: challenge\n    user_agent_regex: CHALLENGE\n    action: CHALLENGE\n\nstatus_codes:\n  CHALLENGE: 200\n  DENY: 403\n"
  },
  {
    "path": "test/robots_txt/test.mjs",
    "content": "async function getRobotsTxt() {\n  return fetch(\"http://localhost:8923/robots.txt\", {\n    headers: {\n      \"Accept-Language\": \"en\",\n      \"User-Agent\": \"Mozilla/5.0\",\n    },\n  })\n    .then((resp) => {\n      if (resp.status !== 200) {\n        throw new Error(`wanted status 200, got status: ${resp.status}`);\n      }\n      return resp;\n    })\n    .then((resp) => resp.text());\n}\n\n(async () => {\n  const page = await getRobotsTxt();\n\n  if (page.includes(`<html>`)) {\n    console.log(page);\n    throw new Error(\"serve robots.txt smoke test failed\");\n  }\n\n  console.log(\"serve-robots-txt serves robots.txt\");\n  process.exit(0);\n})();\n"
  },
  {
    "path": "test/robots_txt/test.sh",
    "content": "#!/usr/bin/env bash\n\nset -euo pipefail\n\nfunction cleanup() {\n\tpkill -P $$\n}\n\ntrap cleanup EXIT SIGINT\n\n# Build static assets\n(cd ../.. && npm ci && npm run assets)\n\ngo tool anubis --help 2>/dev/null || :\n\ngo run ../cmd/unixhttpd &\n\ngo tool anubis \\\n\t--policy-fname ./anubis.yaml \\\n\t--use-remote-address \\\n\t--serve-robots-txt \\\n\t--target=unix://$(pwd)/unixhttpd.sock &\n\nbackoff-retry node ./test.mjs\n"
  },
  {
    "path": "test/robots_txt/var/.gitignore",
    "content": "*\n!.gitignore"
  },
  {
    "path": "test/shared/www/index.html",
    "content": "<!doctype html>\n<html>\n  <head>\n    <title>Anubis works!</title>\n    <link rel=\"stylesheet\" href=\"/.within.website/x/xess/xess.css\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n  </head>\n  <body id=\"top\">\n    <main>\n      <h1>Anubis works!</h1>\n\n      <p>If you see this, everything has gone according to keikaku.</p>\n\n      <img\n        height=\"128\"\n        src=\"/.within.website/x/cmd/anubis/static/img/happy.webp\"\n      />\n    </main>\n  </body>\n</html>\n"
  },
  {
    "path": "test/ssh-ci/Dockerfile",
    "content": "ARG ALPINE_VERSION=3.22\n\nFROM alpine:${ALPINE_VERSION}\nRUN apk add -U go nodejs git build-base git npm bash zstd brotli gzip\nLABEL org.opencontainers.image.source=\"https://github.com/TecharoHQ/anubis\""
  },
  {
    "path": "test/ssh-ci/docker-bake.hcl",
    "content": "variable \"ALPINE_VERSION\" { default = \"3.22\" }\n\ngroup \"default\" {\n  targets = [\n    \"ci-runner\",\n  ]\n}\n\ntarget \"ci-runner\" {\n  args = {\n    ALPINE_VERSION = \"3.22\"\n  }\n  context = \".\"\n  dockerfile = \"./Dockerfile\"\n  platforms = [\n    \"linux/amd64\",\n    \"linux/arm64\",\n    \"linux/arm/v7\",\n    \"linux/ppc64le\",\n    \"linux/riscv64\",\n  ]\n  pull = true\n  tags = [\n    \"ghcr.io/techarohq/anubis/ci-runner:latest\"\n  ]\n}"
  },
  {
    "path": "test/ssh-ci/in-container.sh",
    "content": "#!/usr/bin/env sh\n\nset -euo pipefail\nset -x\n\nnpm ci\nnpm run build\nSKIP_INTEGRATION=1 go test ./..."
  },
  {
    "path": "test/ssh-ci/rigging.sh",
    "content": "#!/usr/bin/env bash\n\nset -euo pipefail\n[ ! -z \"${DEBUG:-}\" ] && set -x\n\nif [ \"$#\" -ne 1 ]; then\n\techo \"Usage: rigging.sh <user@host>\"\nfi\n\ndeclare -A Hosts\n\nHosts[\"riscv64\"]=\"ubuntu@riscv64.techaro.lol\" # GOARCH=riscv64 GOOS=linux\nHosts[\"ppc64le\"]=\"ci@ppc64le.techaro.lol\"     # GOARCH=ppc64le GOOS=linux\nHosts[\"aarch64-4k\"]=\"rocky@192.168.2.52\"      # GOARCH=arm64 GOOS=linux 4k page size\nHosts[\"aarch64-16k\"]=\"ci@192.168.2.28\"        # GOARCH=arm64 GOOS=linux 16k page size\n\nCIRunnerImage=\"ghcr.io/techarohq/anubis/ci-runner:latest\"\nRunID=${GITHUB_RUN_ID:-$(uuidgen)}\nRunFolder=\"anubis/runs/${RunID}\"\nTarget=\"${Hosts[\"$1\"]}\"\n\nssh \"${Target}\" uname -av >/dev/null\nssh \"${Target}\" mkdir -p \"${RunFolder}\"\ngit archive HEAD | ssh \"${Target}\" tar xC \"${RunFolder}\"\n\nssh \"${Target}\" <<EOF\n  set -euo pipefail\n  set -x\n  mkdir -p anubis/cache/{go,go-build,node}\n  podman pull ${CIRunnerImage}\n  podman run --rm -it \\\n    -v \"\\$HOME/${RunFolder}:/app/anubis:z\" \\\n    -v \"\\$HOME/anubis/cache/go:/root/go:z\" \\\n    -v \"\\$HOME/anubis/cache/go-build:/root/.cache/go-build:z\" \\\n    -v \"\\$HOME/anubis/cache/node:/root/.npm:z\" \\\n    -w /app/anubis \\\n    ${CIRunnerImage} \\\n    sh /app/anubis/test/ssh-ci/in-container.sh\n  ssh \"${Target}\" rm -rf \"${RunFolder}\"\nEOF\n"
  },
  {
    "path": "test/unix-socket-xff/start.sh",
    "content": "#!/usr/bin/env bash\n\nset -euo pipefail\n\n# Remove lingering .sock files, relayd and unixhttpd will do that too but\n# measure twice, cut once.\nrm *.sock ||:\n\n# If the transient local TLS certificate doesn't exist, mint a new one\nif [ ! -f ../pki/relayd.local.cetacean.club/cert.pem ]; then\n  # Subshell to contain the directory change\n  (\n    cd ../pki \\\n    && mkdir -p relayd.local.cetacean.club \\\n    && \\\n    # Try using https://github.com/FiloSottile/mkcert for better DevEx,\n    # but fall back to using https://github.com/jsha/minica in case\n    # you don't have that installed.\n    (\n      mkcert \\\n        --cert-file ./relayd.local.cetacean.club/cert.pem \\\n        --key-file ./relayd.local.cetacean.club/key.pem relayd.local.cetacean.club \\\n      || go tool minica -domains relayd.local.cetacean.club\n    )\n  )\nfi\n\n# Build static assets\n(cd ../.. && npm ci && npm run assets)\n\n# Spawn three jobs:\n\n# HTTP daemon that listens over a unix socket (implicitly ./unixhttpd.sock)\ngo run ../cmd/unixhttpd &\n\n# A copy of Anubis, specifically for the current Git checkout\ngo tool anubis \\\n  --bind=./anubis.sock \\\n  --bind-network=unix \\\n  --policy-fname=../anubis_configs/aggressive_403.yaml \\\n  --target=unix://$(pwd)/unixhttpd.sock &\n\n# A simple TLS terminator that forwards to Anubis, which will forward to\n# unixhttpd\ngo run ../cmd/relayd \\\n  --proxy-to=unix://./anubis.sock \\\n  --cert-dir=../pki/relayd.local.cetacean.club &\n\n# When you press control c, kill all the child processes to clean things up\ntrap 'echo signal received!; kill $(jobs -p); wait' SIGINT SIGTERM\n\necho \"open https://relayd.local.cetacean.club:3004/reqmeta\"\n\n# Wait for all child processes to exit\nwait\n"
  },
  {
    "path": "test/unix-socket-xff/test.mjs",
    "content": "async function testWithUserAgent(userAgent) {\n  const statusCode = await fetch(\n    \"https://relayd.local.cetacean.club:3004/reqmeta\",\n    {\n      headers: {\n        \"User-Agent\": userAgent,\n      },\n    },\n  ).then((resp) => resp.status);\n  return statusCode;\n}\n\nconst codes = {\n  allow: await testWithUserAgent(\"ALLOW\"),\n  challenge: await testWithUserAgent(\"CHALLENGE\"),\n  deny: await testWithUserAgent(\"DENY\"),\n};\n\nconst expected = {\n  allow: 200,\n  challenge: 401,\n  deny: 403,\n};\n\nconsole.log(\"ALLOW:    \", codes.allow);\nconsole.log(\"CHALLENGE:\", codes.challenge);\nconsole.log(\"DENY:     \", codes.deny);\n\nif (JSON.stringify(codes) !== JSON.stringify(expected)) {\n  throw new Error(\n    `wanted ${JSON.stringify(expected)}, got: ${JSON.stringify(codes)}`,\n  );\n}\n"
  },
  {
    "path": "utils/cmd/backoff-retry/main.go",
    "content": "package main\n\nimport (\n\t\"flag\"\n\t\"fmt\"\n\t\"log/slog\"\n\t\"os\"\n\t\"os/exec\"\n\t\"strings\"\n\t\"time\"\n)\n\nvar (\n\tstartWait = flag.Duration(\"start-wait\", 250*time.Millisecond, \"amount of time to start with exponential backoff\")\n\ttryCount  = flag.Int(\"try-count\", 5, \"number of retries\")\n)\n\nfunc main() {\n\tflag.Parse()\n\n\tcmdStr := strings.Join(flag.Args(), \" \")\n\twait := *startWait\n\n\tfor i := range make([]struct{}, *tryCount) {\n\t\tslog.Info(\"executing\", \"try\", i+1, \"wait\", wait, \"cmd\", cmdStr)\n\n\t\tcmd := exec.Command(\"sh\", \"-c\", cmdStr)\n\t\tcmd.Stdin = nil\n\t\tcmd.Stdout = os.Stdout\n\t\tcmd.Stderr = os.Stderr\n\n\t\tif err := cmd.Run(); err != nil {\n\t\t\ttime.Sleep(wait)\n\t\t\twait = wait * 2\n\t\t} else {\n\t\t\tos.Exit(0)\n\t\t}\n\t}\n\n\tfmt.Printf(\"giving up after %d tries\\n\", *tryCount)\n\tos.Exit(1)\n}\n"
  },
  {
    "path": "utils/cmd/iplist2rule/blocklist.go",
    "content": "package main\n\nimport (\n\t\"bufio\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/netip\"\n\t\"strings\"\n)\n\n// FetchBlocklist reads the blocklist over HTTP and returns every non-commented\n// line parsed as an IP address in CIDR notation. IPv4 addresses are returned as\n// /32, IPv6 addresses as /128.\n//\n// This function was generated with GLM 4.7.\nfunc FetchBlocklist(url string) ([]string, error) {\n\tresp, err := http.Get(url)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn nil, fmt.Errorf(\"HTTP request failed with status: %s\", resp.Status)\n\t}\n\n\tvar lines []string\n\tscanner := bufio.NewScanner(resp.Body)\n\tfor scanner.Scan() {\n\t\tline := scanner.Text()\n\t\t// Skip empty lines and comments (lines starting with #)\n\t\tif line == \"\" || strings.HasPrefix(line, \"#\") {\n\t\t\tcontinue\n\t\t}\n\n\t\taddr, err := netip.ParseAddr(line)\n\t\tif err != nil {\n\t\t\t// Skip lines that aren't valid IP addresses\n\t\t\tcontinue\n\t\t}\n\n\t\tvar cidr string\n\t\tif addr.Is4() {\n\t\t\tcidr = fmt.Sprintf(\"%s/32\", addr.String())\n\t\t} else {\n\t\t\tcidr = fmt.Sprintf(\"%s/128\", addr.String())\n\t\t}\n\t\tlines = append(lines, cidr)\n\t}\n\n\tif err := scanner.Err(); err != nil && err != io.EOF {\n\t\treturn nil, err\n\t}\n\n\treturn lines, nil\n}\n"
  },
  {
    "path": "utils/cmd/iplist2rule/main.go",
    "content": "package main\n\nimport (\n\t\"flag\"\n\t\"fmt\"\n\t\"log\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/TecharoHQ/anubis/lib/config\"\n\t\"github.com/facebookgo/flagenv\"\n\t\"sigs.k8s.io/yaml\"\n)\n\ntype Rule struct {\n\tName       string         `yaml:\"name\" json:\"name\"`\n\tAction     config.Rule    `yaml:\"action\" json:\"action\"`\n\tRemoteAddr []string       `json:\"remote_addresses,omitempty\" yaml:\"remote_addresses,omitempty\"`\n\tWeight     *config.Weight `json:\"weight,omitempty\" yaml:\"weight,omitempty\"`\n}\n\nfunc init() {\n\tflag.Usage = func() {\n\t\tfmt.Printf(`Usage of %[1]s:\n\n\t%[1]s [flags] <blocklist-url> <filename>\n\nGrabs the contents of the blocklist, converts it to an Anubis ruleset, and writes it to filename.\n\nFlags:\n`, filepath.Base(os.Args[0]))\n\n\t\tflag.PrintDefaults()\n\t}\n}\n\nvar (\n\taction         = flag.String(\"action\", \"DENY\", \"Anubis action to take (ALLOW / DENY / WEIGH)\")\n\tmanualRuleName = flag.String(\"rule-name\", \"\", \"If set, prefer this name over inferring from filename\")\n\tweight         = flag.Int(\"weight\", 0, \"If set to any number, add/subtract this many weight points when --action=WEIGH\")\n)\n\nfunc main() {\n\tflagenv.Parse()\n\tflag.Parse()\n\n\tif flag.NArg() != 2 {\n\t\tflag.Usage()\n\t\tos.Exit(2)\n\t}\n\n\tblocklistURL := flag.Arg(0)\n\tfoutName := flag.Arg(1)\n\truleName := strings.TrimSuffix(foutName, filepath.Ext(foutName))\n\n\tif *manualRuleName != \"\" {\n\t\truleName = *manualRuleName\n\t}\n\n\truleAction := config.Rule(*action)\n\tif err := ruleAction.Valid(); err != nil {\n\t\tlog.Fatalf(\"--action=%q is invalid: %v\", *action, err)\n\t}\n\n\tresult := &Rule{\n\t\tName:   ruleName,\n\t\tAction: ruleAction,\n\t}\n\n\tif *weight != 0 {\n\t\tif ruleAction != config.RuleWeigh {\n\t\t\tlog.Fatalf(\"used --weight=%d but --action=%s\", *weight, *action)\n\t\t}\n\n\t\tresult.Weight = &config.Weight{\n\t\t\tAdjust: *weight,\n\t\t}\n\t}\n\n\tips, err := FetchBlocklist(blocklistURL)\n\tif err != nil {\n\t\tlog.Fatalf(\"can't fetch blocklist %s: %v\", blocklistURL, err)\n\t}\n\n\tresult.RemoteAddr = ips\n\n\tfout, err := os.Create(foutName)\n\tif err != nil {\n\t\tlog.Fatalf(\"can't create output file %q: %v\", foutName, err)\n\t}\n\tdefer fout.Close()\n\n\tfmt.Fprintf(fout, \"# Generated by %s on %s from %s\\n\\n\", filepath.Base(os.Args[0]), time.Now().Format(time.RFC3339), blocklistURL)\n\n\tdata, err := yaml.Marshal([]*Rule{result})\n\tif err != nil {\n\t\tlog.Fatalf(\"can't marshal yaml\")\n\t}\n\n\tfout.Write(data)\n}\n"
  },
  {
    "path": "var/.gitignore",
    "content": "*\n!.gitignore"
  },
  {
    "path": "web/build.sh",
    "content": "#!/usr/bin/env bash\n\nset -euo pipefail\n\ncd \"$(dirname \"$0\")\"\n\nLICENSE='/*\n@licstart  The following is the entire license notice for the\nJavaScript code in this page.\n\nCopyright (c) 2025 Xe Iaso <xe.iaso@techaro.lol>\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in\nall copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\nTHE SOFTWARE.\n\nIncludes code from https://github.com/aws/aws-sdk-js-crypto-helpers which is\nused under the terms of the Apache 2 license.\n\n@licend  The above is the entire license notice\nfor the JavaScript code in this page.\n*/'\n\n# Copy localization files to static directory\nmkdir -p static/locales\ncp ../lib/localization/locales/*.json static/locales/\n\nshopt -s nullglob globstar\n\nfor file in js/**/*.ts js/**/*.mjs; do\n  out=\"static/${file}\"\n  if [[ \"$file\" == *.ts ]]; then\n    out=\"static/${file%.ts}.mjs\"\n  fi\n\n  mkdir -p \"$(dirname \"$out\")\"\n\n  esbuild \"$file\" --sourcemap --bundle --minify --outfile=\"$out\" --banner:js=\"$LICENSE\"\n  gzip -f -k -n \"$out\"\n  zstd -f -k --ultra -22 \"$out\"\n  brotli -fZk \"$out\"\ndone\n"
  },
  {
    "path": "web/embed.go",
    "content": "package web\n\nimport \"embed\"\n\n//go:generate go tool github.com/a-h/templ/cmd/templ generate\n\nvar (\n\t//go:embed static\n\tStatic embed.FS\n)\n"
  },
  {
    "path": "web/index.go",
    "content": "package web\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\n\t\"github.com/a-h/templ\"\n\n\t\"github.com/TecharoHQ/anubis/lib/challenge\"\n\t\"github.com/TecharoHQ/anubis/lib/config\"\n\t\"github.com/TecharoHQ/anubis/lib/localization\"\n)\n\nfunc Base(title string, body templ.Component, impressum *config.Impressum, localizer *localization.SimpleLocalizer) templ.Component {\n\treturn base(title, body, impressum, nil, nil, localizer)\n}\n\nfunc BaseWithChallengeAndOGTags(title string, body templ.Component, impressum *config.Impressum, challenge *challenge.Challenge, rules *config.ChallengeRules, ogTags map[string]string, localizer *localization.SimpleLocalizer) templ.Component {\n\treturn base(title, body, impressum, struct {\n\t\tRules     *config.ChallengeRules `json:\"rules\"`\n\t\tChallenge any                    `json:\"challenge\"`\n\t}{\n\t\tChallenge: challenge,\n\t\tRules:     rules,\n\t}, ogTags, localizer)\n}\n\nfunc ErrorPage(msg, mail, code string, localizer *localization.SimpleLocalizer) templ.Component {\n\treturn errorPage(msg, mail, code, localizer)\n}\n\nfunc Bench(localizer *localization.SimpleLocalizer) templ.Component {\n\treturn bench(localizer)\n}\n\nfunc honeypotLink(href string) templ.Component {\n\treturn templ.ComponentFunc(func(ctx context.Context, w io.Writer) error {\n\t\tfmt.Fprintf(w, `<script type=\"ignore\"><a href=\"%s\">Don't click me</a></script>`, href)\n\t\treturn nil\n\t})\n}\n"
  },
  {
    "path": "web/index.templ",
    "content": "package web\n\nimport (\n\t\"fmt\"\n\t\"github.com/TecharoHQ/anubis\"\n\t\"github.com/TecharoHQ/anubis/lib/config\"\n\t\"github.com/TecharoHQ/anubis/lib/localization\"\n\t\"github.com/TecharoHQ/anubis/xess\"\n\t\"github.com/google/uuid\"\n)\n\ntempl base(title string, body templ.Component, impressum *config.Impressum, challenge any, ogTags map[string]string, localizer *localization.SimpleLocalizer) {\n\t<!DOCTYPE html>\n\t<html lang={ localizer.GetLang() }>\n\t\t<head>\n\t\t\t<title>{ title }</title>\n\t\t\t<link rel=\"stylesheet\" href={ anubis.BasePrefix + xess.URL }/>\n\t\t\t<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"/>\n\t\t\t<meta name=\"robots\" content=\"noindex,nofollow\"/>\n\t\t\tfor key, value := range ogTags {\n\t\t\t\t<meta property={ key } content={ value }/>\n\t\t\t}\n\t\t\t<style>\n        body,\n        html {\n            height: 100%;\n            display: flex;\n            justify-content: center;\n            align-items: center;\n            margin-left: auto;\n            margin-right: auto;\n        }\n\n        .centered-div {\n            text-align: center;\n        }\n\n        #status {\n            font-variant-numeric: tabular-nums;\n        }\n\n        #progress {\n            display: none;\n            width: 90%;\n            width: min(20rem, 90%);\n            height: 2rem;\n            border-radius: 1rem;\n            overflow: hidden;\n            margin: 1rem 0 2rem;\n            outline-offset: 2px;\n            outline: #b16286 solid 4px;\n        }\n\n        .bar-inner {\n            background-color: #b16286;\n            height: 100%;\n            width: 0;\n            transition: width 0.25s ease-in;\n        }\n    \t</style>\n\t\t\t@templ.JSONScript(\"anubis_version\", anubis.Version)\n\t\t\t@templ.JSONScript(\"anubis_challenge\", challenge)\n\t\t\t@templ.JSONScript(\"anubis_base_prefix\", anubis.BasePrefix)\n\t\t\t@templ.JSONScript(\"anubis_public_url\", anubis.PublicUrl)\n\t\t</head>\n\t\t<body id=\"top\">\n\t\t\t@honeypotLink(anubis.BasePrefix + fmt.Sprintf(\"%shoneypot/%s/init\", anubis.APIPrefix, uuid.NewString()))\n\t\t\t<main>\n\t\t\t\t<h1 id=\"title\" class=\"centered-div\">{ title }</h1>\n\t\t\t\t@body\n\t\t\t\t<footer>\n\t\t\t\t\t<div class=\"centered-div\">\n\t\t\t\t\t\t<p>\n\t\t\t\t\t\t\t{ localizer.T(\"protected_by\") } <a href=\"https://github.com/TecharoHQ/anubis\">Anubis</a> { localizer.T(\"protected_from\") } <a\n\thref=\"https://techaro.lol\"\n>Techaro</a>. { localizer.T(\"made_with\") }.\n\t\t\t\t\t\t</p>\n\t\t\t\t\t\t<p>{ localizer.T(\"mascot_design\") } <a href=\"https://bsky.app/profile/celphase.bsky.social\">{ localizer.T(\"celphase\") }</a>.</p>\n\t\t\t\t\t\tif impressum != nil {\n\t\t\t\t\t\t\t<p>\n\t\t\t\t\t\t\t\t@templ.Raw(impressum.Footer)\n\t\t\t\t\t\t\t\t-- <a href={ templ.SafeURL(anubis.BasePrefix + fmt.Sprintf(\"%simprint\", anubis.APIPrefix)) }>Imprint</a>\n\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t}\n\t\t\t\t\t\t<p>{ localizer.T(\"version_info\") } <code>{ anubis.Version }</code>.</p>\n\t\t\t\t\t</div>\n\t\t\t\t</footer>\n\t\t\t</main>\n\t\t</body>\n\t</html>\n}\n\ntempl errorPage(message, mail, code string, localizer *localization.SimpleLocalizer) {\n\t<div class=\"centered-div\">\n\t\t<img id=\"image\" alt=\"Sad Anubis\" style=\"width:100%;max-width:256px;\" src={ anubis.BasePrefix + \"/.within.website/x/cmd/anubis/static/img/reject.webp?cacheBuster=\" + anubis.Version }/>\n\t\t<p>{ message }.</p>\n\t\tif code != \"\" {\n\t\t\t<code><pre>{ code }</pre></code>\n\t\t}\n\t\tif mail != \"\" {\n\t\t\t<p>\n\t\t\t\t<a href=\"/\">{ localizer.T(\"go_home\") }</a> { localizer.T(\"contact_webmaster\") }\n\t\t\t\t<a href={ \"mailto:\" + templ.SafeURL(mail) }>\n\t\t\t\t\t{ mail }\n\t\t\t\t</a>\n\t\t\t</p>\n\t\t} else {\n\t\t\t<p><a href=\"/\">{ localizer.T(\"go_home\") }</a></p>\n\t\t}\n\t</div>\n}\n\ntempl StaticHappy(localizer *localization.SimpleLocalizer) {\n\t<div class=\"centered-div\">\n\t\t<img\n\t\t\tstyle=\"display:none;\"\n\t\t\tstyle=\"width:100%;max-width:256px;\"\n\t\t\tsrc={ \"/.within.website/x/cmd/anubis/static/img/happy.webp?cacheBuster=\" +\n    anubis.Version }\n\t\t/>\n\t\t<p>{ localizer.T(\"static_check_endpoint\") }</p>\n\t</div>\n}\n\ntempl bench(localizer *localization.SimpleLocalizer) {\n\t<div style=\"height:20rem;display:flex\">\n\t\t<table style=\"margin-top:1rem;display:grid;grid-template:auto 1fr/auto auto;gap:0 0.5rem\">\n\t\t\t<thead\n\t\t\t\tstyle=\"border-bottom:1px solid black;padding:0.25rem 0;display:grid;grid-template:1fr/subgrid;grid-column:1/-1\"\n\t\t\t>\n\t\t\t\t<tr id=\"table-header\" style=\"display:contents\">\n\t\t\t\t\t<th style=\"width:4.5rem\">{ localizer.T(\"time\") }</th>\n\t\t\t\t\t<th style=\"width:4rem\">{ localizer.T(\"iters\") }</th>\n\t\t\t\t</tr>\n\t\t\t\t<tr id=\"table-header-compare\" style=\"display:none\">\n\t\t\t\t\t<th style=\"width:4.5rem\">{ localizer.T(\"time_a\") }</th>\n\t\t\t\t\t<th style=\"width:4rem\">{ localizer.T(\"iters_a\") }</th>\n\t\t\t\t\t<th style=\"width:4.5rem\">{ localizer.T(\"time_b\") }</th>\n\t\t\t\t\t<th style=\"width:4rem\">{ localizer.T(\"iters_b\") }</th>\n\t\t\t\t</tr>\n\t\t\t</thead>\n\t\t\t<tbody\n\t\t\t\tid=\"results\"\n\t\t\t\tstyle=\"padding-top:0.25rem;display:grid;grid-template-columns:subgrid;grid-auto-rows:min-content;grid-column:1/-1;row-gap:0.25rem;overflow-y:auto;font-variant-numeric:tabular-nums\"\n\t\t\t></tbody>\n\t\t</table>\n\t\t<div class=\"centered-div\">\n\t\t\t<img id=\"image\" style=\"width:100%;max-width:256px;\" src={ anubis.BasePrefix + \"/.within.website/x/cmd/anubis/static/img/pensive.webp?cacheBuster=\" + anubis.Version }/>\n\t\t\t<p id=\"status\" style=\"max-width:256px\">{ localizer.T(\"loading\") }</p>\n\t\t\t<script async type=\"module\" src={ anubis.BasePrefix + \"/.within.website/x/cmd/anubis/static/js/bench.mjs?cacheBuster=\" + anubis.Version }></script>\n\t\t\t<div id=\"sparkline\"></div>\n\t\t\t<noscript>\n\t\t\t\t<p>{ localizer.T(\"benchmark_requires_js\") }</p>\n\t\t\t</noscript>\n\t\t</div>\n\t</div>\n\t<form id=\"controls\" style=\"position:fixed;top:0.5rem;right:0.5rem\">\n\t\t<div style=\"display:flex;justify-content:end\">\n\t\t\t<label for=\"difficulty-input\" style=\"margin-right:0.5rem\">{ localizer.T(\"difficulty\") }</label>\n\t\t\t<input id=\"difficulty-input\" type=\"number\" name=\"difficulty\" style=\"width:3rem\"/>\n\t\t</div>\n\t\t<div style=\"margin-top:0.25rem;display:flex;justify-content:end\">\n\t\t\t<label for=\"algorithm-select\" style=\"margin-right:0.5rem\">{ localizer.T(\"algorithm\") }</label>\n\t\t\t<select id=\"algorithm-select\" name=\"algorithm\"></select>\n\t\t</div>\n\t\t<div style=\"margin-top:0.25rem;display:flex;justify-content:end\">\n\t\t\t<label for=\"compare-select\" style=\"margin-right:0.5rem\">{ localizer.T(\"compare\") }</label>\n\t\t\t<select id=\"compare-select\" name=\"compare\">\n\t\t\t\t<option value=\"NONE\">-</option>\n\t\t\t</select>\n\t\t</div>\n\t</form>\n}\n"
  },
  {
    "path": "web/index_templ.go",
    "content": "// Code generated by templ - DO NOT EDIT.\n\n// templ: version: v0.3.960\npackage web\n\n//lint:file-ignore SA4006 This context is only used if a nested component is present.\n\nimport \"github.com/a-h/templ\"\nimport templruntime \"github.com/a-h/templ/runtime\"\n\nimport (\n\t\"fmt\"\n\t\"github.com/TecharoHQ/anubis\"\n\t\"github.com/TecharoHQ/anubis/lib/config\"\n\t\"github.com/TecharoHQ/anubis/lib/localization\"\n\t\"github.com/TecharoHQ/anubis/xess\"\n\t\"github.com/google/uuid\"\n)\n\nfunc base(title string, body templ.Component, impressum *config.Impressum, challenge any, ogTags map[string]string, localizer *localization.SimpleLocalizer) templ.Component {\n\treturn templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {\n\t\ttempl_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context\n\t\tif templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {\n\t\t\treturn templ_7745c5c3_CtxErr\n\t\t}\n\t\ttempl_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)\n\t\tif !templ_7745c5c3_IsBuffer {\n\t\t\tdefer func() {\n\t\t\t\ttempl_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)\n\t\t\t\tif templ_7745c5c3_Err == nil {\n\t\t\t\t\ttempl_7745c5c3_Err = templ_7745c5c3_BufErr\n\t\t\t\t}\n\t\t\t}()\n\t\t}\n\t\tctx = templ.InitializeContext(ctx)\n\t\ttempl_7745c5c3_Var1 := templ.GetChildren(ctx)\n\t\tif templ_7745c5c3_Var1 == nil {\n\t\t\ttempl_7745c5c3_Var1 = templ.NopComponent\n\t\t}\n\t\tctx = templ.ClearChildren(ctx)\n\t\ttempl_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, \"<!doctype html><html lang=\\\"\")\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\tvar templ_7745c5c3_Var2 string\n\t\ttempl_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.GetLang())\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 14, Col: 33}\n\t\t}\n\t\t_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\ttempl_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, \"\\\"><head><title>\")\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\tvar templ_7745c5c3_Var3 string\n\t\ttempl_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(title)\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 16, Col: 17}\n\t\t}\n\t\t_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\ttempl_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, \"</title><link rel=\\\"stylesheet\\\" href=\\\"\")\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\tvar templ_7745c5c3_Var4 templ.SafeURL\n\t\ttempl_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinURLErrs(anubis.BasePrefix + xess.URL)\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 17, Col: 61}\n\t\t}\n\t\t_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\ttempl_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, \"\\\"><meta name=\\\"viewport\\\" content=\\\"width=device-width, initial-scale=1.0\\\"><meta name=\\\"robots\\\" content=\\\"noindex,nofollow\\\">\")\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\tfor key, value := range ogTags {\n\t\t\ttempl_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, \"<meta property=\\\"\")\n\t\t\tif templ_7745c5c3_Err != nil {\n\t\t\t\treturn templ_7745c5c3_Err\n\t\t\t}\n\t\t\tvar templ_7745c5c3_Var5 string\n\t\t\ttempl_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(key)\n\t\t\tif templ_7745c5c3_Err != nil {\n\t\t\t\treturn templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 21, Col: 24}\n\t\t\t}\n\t\t\t_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))\n\t\t\tif templ_7745c5c3_Err != nil {\n\t\t\t\treturn templ_7745c5c3_Err\n\t\t\t}\n\t\t\ttempl_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, \"\\\" content=\\\"\")\n\t\t\tif templ_7745c5c3_Err != nil {\n\t\t\t\treturn templ_7745c5c3_Err\n\t\t\t}\n\t\t\tvar templ_7745c5c3_Var6 string\n\t\t\ttempl_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(value)\n\t\t\tif templ_7745c5c3_Err != nil {\n\t\t\t\treturn templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 21, Col: 42}\n\t\t\t}\n\t\t\t_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))\n\t\t\tif templ_7745c5c3_Err != nil {\n\t\t\t\treturn templ_7745c5c3_Err\n\t\t\t}\n\t\t\ttempl_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, \"\\\">\")\n\t\t\tif templ_7745c5c3_Err != nil {\n\t\t\t\treturn templ_7745c5c3_Err\n\t\t\t}\n\t\t}\n\t\ttempl_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, \"<style>\\n        body,\\n        html {\\n            height: 100%;\\n            display: flex;\\n            justify-content: center;\\n            align-items: center;\\n            margin-left: auto;\\n            margin-right: auto;\\n        }\\n\\n        .centered-div {\\n            text-align: center;\\n        }\\n\\n        #status {\\n            font-variant-numeric: tabular-nums;\\n        }\\n\\n        #progress {\\n            display: none;\\n            width: 90%;\\n            width: min(20rem, 90%);\\n            height: 2rem;\\n            border-radius: 1rem;\\n            overflow: hidden;\\n            margin: 1rem 0 2rem;\\n            outline-offset: 2px;\\n            outline: #b16286 solid 4px;\\n        }\\n\\n        .bar-inner {\\n            background-color: #b16286;\\n            height: 100%;\\n            width: 0;\\n            transition: width 0.25s ease-in;\\n        }\\n    \\t</style>\")\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\ttempl_7745c5c3_Err = templ.JSONScript(\"anubis_version\", anubis.Version).Render(ctx, templ_7745c5c3_Buffer)\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\ttempl_7745c5c3_Err = templ.JSONScript(\"anubis_challenge\", challenge).Render(ctx, templ_7745c5c3_Buffer)\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\ttempl_7745c5c3_Err = templ.JSONScript(\"anubis_base_prefix\", anubis.BasePrefix).Render(ctx, templ_7745c5c3_Buffer)\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\ttempl_7745c5c3_Err = templ.JSONScript(\"anubis_public_url\", anubis.PublicUrl).Render(ctx, templ_7745c5c3_Buffer)\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\ttempl_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, \"</head><body id=\\\"top\\\">\")\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\ttempl_7745c5c3_Err = honeypotLink(anubis.BasePrefix+fmt.Sprintf(\"%shoneypot/%s/init\", anubis.APIPrefix, uuid.NewString())).Render(ctx, templ_7745c5c3_Buffer)\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\ttempl_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, \"<main><h1 id=\\\"title\\\" class=\\\"centered-div\\\">\")\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\tvar templ_7745c5c3_Var7 string\n\t\ttempl_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(title)\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 69, Col: 47}\n\t\t}\n\t\t_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\ttempl_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, \"</h1>\")\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\ttempl_7745c5c3_Err = body.Render(ctx, templ_7745c5c3_Buffer)\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\ttempl_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, \"<footer><div class=\\\"centered-div\\\"><p>\")\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\tvar templ_7745c5c3_Var8 string\n\t\ttempl_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T(\"protected_by\"))\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 74, Col: 36}\n\t\t}\n\t\t_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\ttempl_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, \" <a href=\\\"https://github.com/TecharoHQ/anubis\\\">Anubis</a> \")\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\tvar templ_7745c5c3_Var9 string\n\t\ttempl_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T(\"protected_from\"))\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 74, Col: 127}\n\t\t}\n\t\t_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\ttempl_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, \" <a href=\\\"https://techaro.lol\\\">Techaro</a>. \")\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\tvar templ_7745c5c3_Var10 string\n\t\ttempl_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T(\"made_with\"))\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 76, Col: 40}\n\t\t}\n\t\t_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\ttempl_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, \".</p><p>\")\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\tvar templ_7745c5c3_Var11 string\n\t\ttempl_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T(\"mascot_design\"))\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 78, Col: 39}\n\t\t}\n\t\t_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\ttempl_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, \" <a href=\\\"https://bsky.app/profile/celphase.bsky.social\\\">\")\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\tvar templ_7745c5c3_Var12 string\n\t\ttempl_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T(\"celphase\"))\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 78, Col: 123}\n\t\t}\n\t\t_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\ttempl_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, \"</a>.</p>\")\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\tif impressum != nil {\n\t\t\ttempl_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, \"<p>\")\n\t\t\tif templ_7745c5c3_Err != nil {\n\t\t\t\treturn templ_7745c5c3_Err\n\t\t\t}\n\t\t\ttempl_7745c5c3_Err = templ.Raw(impressum.Footer).Render(ctx, templ_7745c5c3_Buffer)\n\t\t\tif templ_7745c5c3_Err != nil {\n\t\t\t\treturn templ_7745c5c3_Err\n\t\t\t}\n\t\t\ttempl_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, \"-- <a href=\\\"\")\n\t\t\tif templ_7745c5c3_Err != nil {\n\t\t\t\treturn templ_7745c5c3_Err\n\t\t\t}\n\t\t\tvar templ_7745c5c3_Var13 templ.SafeURL\n\t\t\ttempl_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(anubis.BasePrefix + fmt.Sprintf(\"%simprint\", anubis.APIPrefix)))\n\t\t\tif templ_7745c5c3_Err != nil {\n\t\t\t\treturn templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 82, Col: 98}\n\t\t\t}\n\t\t\t_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))\n\t\t\tif templ_7745c5c3_Err != nil {\n\t\t\t\treturn templ_7745c5c3_Err\n\t\t\t}\n\t\t\ttempl_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, \"\\\">Imprint</a></p>\")\n\t\t\tif templ_7745c5c3_Err != nil {\n\t\t\t\treturn templ_7745c5c3_Err\n\t\t\t}\n\t\t}\n\t\ttempl_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, \"<p>\")\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\tvar templ_7745c5c3_Var14 string\n\t\ttempl_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T(\"version_info\"))\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 85, Col: 38}\n\t\t}\n\t\t_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\ttempl_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, \" <code>\")\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\tvar templ_7745c5c3_Var15 string\n\t\ttempl_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(anubis.Version)\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 85, Col: 63}\n\t\t}\n\t\t_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\ttempl_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, \"</code>.</p></div></footer></main></body></html>\")\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\treturn nil\n\t})\n}\n\nfunc errorPage(message, mail, code string, localizer *localization.SimpleLocalizer) templ.Component {\n\treturn templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {\n\t\ttempl_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context\n\t\tif templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {\n\t\t\treturn templ_7745c5c3_CtxErr\n\t\t}\n\t\ttempl_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)\n\t\tif !templ_7745c5c3_IsBuffer {\n\t\t\tdefer func() {\n\t\t\t\ttempl_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)\n\t\t\t\tif templ_7745c5c3_Err == nil {\n\t\t\t\t\ttempl_7745c5c3_Err = templ_7745c5c3_BufErr\n\t\t\t\t}\n\t\t\t}()\n\t\t}\n\t\tctx = templ.InitializeContext(ctx)\n\t\ttempl_7745c5c3_Var16 := templ.GetChildren(ctx)\n\t\tif templ_7745c5c3_Var16 == nil {\n\t\t\ttempl_7745c5c3_Var16 = templ.NopComponent\n\t\t}\n\t\tctx = templ.ClearChildren(ctx)\n\t\ttempl_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, \"<div class=\\\"centered-div\\\"><img id=\\\"image\\\" alt=\\\"Sad Anubis\\\" style=\\\"width:100%;max-width:256px;\\\" src=\\\"\")\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\tvar templ_7745c5c3_Var17 string\n\t\ttempl_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(anubis.BasePrefix + \"/.within.website/x/cmd/anubis/static/img/reject.webp?cacheBuster=\" + anubis.Version)\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 95, Col: 181}\n\t\t}\n\t\t_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\ttempl_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, \"\\\"><p>\")\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\tvar templ_7745c5c3_Var18 string\n\t\ttempl_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(message)\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 96, Col: 14}\n\t\t}\n\t\t_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\ttempl_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, \".</p>\")\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\tif code != \"\" {\n\t\t\ttempl_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, \"<code><pre>\")\n\t\t\tif templ_7745c5c3_Err != nil {\n\t\t\t\treturn templ_7745c5c3_Err\n\t\t\t}\n\t\t\tvar templ_7745c5c3_Var19 string\n\t\t\ttempl_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(code)\n\t\t\tif templ_7745c5c3_Err != nil {\n\t\t\t\treturn templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 98, Col: 20}\n\t\t\t}\n\t\t\t_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))\n\t\t\tif templ_7745c5c3_Err != nil {\n\t\t\t\treturn templ_7745c5c3_Err\n\t\t\t}\n\t\t\ttempl_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, \"</pre></code> \")\n\t\t\tif templ_7745c5c3_Err != nil {\n\t\t\t\treturn templ_7745c5c3_Err\n\t\t\t}\n\t\t}\n\t\tif mail != \"\" {\n\t\t\ttempl_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, \"<p><a href=\\\"/\\\">\")\n\t\t\tif templ_7745c5c3_Err != nil {\n\t\t\t\treturn templ_7745c5c3_Err\n\t\t\t}\n\t\t\tvar templ_7745c5c3_Var20 string\n\t\t\ttempl_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T(\"go_home\"))\n\t\t\tif templ_7745c5c3_Err != nil {\n\t\t\t\treturn templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 102, Col: 40}\n\t\t\t}\n\t\t\t_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))\n\t\t\tif templ_7745c5c3_Err != nil {\n\t\t\t\treturn templ_7745c5c3_Err\n\t\t\t}\n\t\t\ttempl_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, \"</a> \")\n\t\t\tif templ_7745c5c3_Err != nil {\n\t\t\t\treturn templ_7745c5c3_Err\n\t\t\t}\n\t\t\tvar templ_7745c5c3_Var21 string\n\t\t\ttempl_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T(\"contact_webmaster\"))\n\t\t\tif templ_7745c5c3_Err != nil {\n\t\t\t\treturn templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 102, Col: 81}\n\t\t\t}\n\t\t\t_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))\n\t\t\tif templ_7745c5c3_Err != nil {\n\t\t\t\treturn templ_7745c5c3_Err\n\t\t\t}\n\t\t\ttempl_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, \" <a href=\\\"\")\n\t\t\tif templ_7745c5c3_Err != nil {\n\t\t\t\treturn templ_7745c5c3_Err\n\t\t\t}\n\t\t\tvar templ_7745c5c3_Var22 templ.SafeURL\n\t\t\ttempl_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinURLErrs(\"mailto:\" + templ.SafeURL(mail))\n\t\t\tif templ_7745c5c3_Err != nil {\n\t\t\t\treturn templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 103, Col: 45}\n\t\t\t}\n\t\t\t_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))\n\t\t\tif templ_7745c5c3_Err != nil {\n\t\t\t\treturn templ_7745c5c3_Err\n\t\t\t}\n\t\t\ttempl_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, \"\\\">\")\n\t\t\tif templ_7745c5c3_Err != nil {\n\t\t\t\treturn templ_7745c5c3_Err\n\t\t\t}\n\t\t\tvar templ_7745c5c3_Var23 string\n\t\t\ttempl_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(mail)\n\t\t\tif templ_7745c5c3_Err != nil {\n\t\t\t\treturn templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 104, Col: 11}\n\t\t\t}\n\t\t\t_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))\n\t\t\tif templ_7745c5c3_Err != nil {\n\t\t\t\treturn templ_7745c5c3_Err\n\t\t\t}\n\t\t\ttempl_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, \"</a></p>\")\n\t\t\tif templ_7745c5c3_Err != nil {\n\t\t\t\treturn templ_7745c5c3_Err\n\t\t\t}\n\t\t} else {\n\t\t\ttempl_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, \"<p><a href=\\\"/\\\">\")\n\t\t\tif templ_7745c5c3_Err != nil {\n\t\t\t\treturn templ_7745c5c3_Err\n\t\t\t}\n\t\t\tvar templ_7745c5c3_Var24 string\n\t\t\ttempl_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T(\"go_home\"))\n\t\t\tif templ_7745c5c3_Err != nil {\n\t\t\t\treturn templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 108, Col: 42}\n\t\t\t}\n\t\t\t_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))\n\t\t\tif templ_7745c5c3_Err != nil {\n\t\t\t\treturn templ_7745c5c3_Err\n\t\t\t}\n\t\t\ttempl_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, \"</a></p>\")\n\t\t\tif templ_7745c5c3_Err != nil {\n\t\t\t\treturn templ_7745c5c3_Err\n\t\t\t}\n\t\t}\n\t\ttempl_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, \"</div>\")\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\treturn nil\n\t})\n}\n\nfunc StaticHappy(localizer *localization.SimpleLocalizer) templ.Component {\n\treturn templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {\n\t\ttempl_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context\n\t\tif templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {\n\t\t\treturn templ_7745c5c3_CtxErr\n\t\t}\n\t\ttempl_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)\n\t\tif !templ_7745c5c3_IsBuffer {\n\t\t\tdefer func() {\n\t\t\t\ttempl_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)\n\t\t\t\tif templ_7745c5c3_Err == nil {\n\t\t\t\t\ttempl_7745c5c3_Err = templ_7745c5c3_BufErr\n\t\t\t\t}\n\t\t\t}()\n\t\t}\n\t\tctx = templ.InitializeContext(ctx)\n\t\ttempl_7745c5c3_Var25 := templ.GetChildren(ctx)\n\t\tif templ_7745c5c3_Var25 == nil {\n\t\t\ttempl_7745c5c3_Var25 = templ.NopComponent\n\t\t}\n\t\tctx = templ.ClearChildren(ctx)\n\t\ttempl_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, \"<div class=\\\"centered-div\\\"><img style=\\\"display:none;\\\" style=\\\"width:100%;max-width:256px;\\\" src=\\\"\")\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\tvar templ_7745c5c3_Var26 string\n\t\ttempl_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(\"/.within.website/x/cmd/anubis/static/img/happy.webp?cacheBuster=\" +\n\t\t\tanubis.Version)\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 119, Col: 18}\n\t\t}\n\t\t_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\ttempl_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, \"\\\"><p>\")\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\tvar templ_7745c5c3_Var27 string\n\t\ttempl_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T(\"static_check_endpoint\"))\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 121, Col: 43}\n\t\t}\n\t\t_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\ttempl_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, \"</p></div>\")\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\treturn nil\n\t})\n}\n\nfunc bench(localizer *localization.SimpleLocalizer) templ.Component {\n\treturn templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {\n\t\ttempl_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context\n\t\tif templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {\n\t\t\treturn templ_7745c5c3_CtxErr\n\t\t}\n\t\ttempl_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)\n\t\tif !templ_7745c5c3_IsBuffer {\n\t\t\tdefer func() {\n\t\t\t\ttempl_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)\n\t\t\t\tif templ_7745c5c3_Err == nil {\n\t\t\t\t\ttempl_7745c5c3_Err = templ_7745c5c3_BufErr\n\t\t\t\t}\n\t\t\t}()\n\t\t}\n\t\tctx = templ.InitializeContext(ctx)\n\t\ttempl_7745c5c3_Var28 := templ.GetChildren(ctx)\n\t\tif templ_7745c5c3_Var28 == nil {\n\t\t\ttempl_7745c5c3_Var28 = templ.NopComponent\n\t\t}\n\t\tctx = templ.ClearChildren(ctx)\n\t\ttempl_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, \"<div style=\\\"height:20rem;display:flex\\\"><table style=\\\"margin-top:1rem;display:grid;grid-template:auto 1fr/auto auto;gap:0 0.5rem\\\"><thead style=\\\"border-bottom:1px solid black;padding:0.25rem 0;display:grid;grid-template:1fr/subgrid;grid-column:1/-1\\\"><tr id=\\\"table-header\\\" style=\\\"display:contents\\\"><th style=\\\"width:4.5rem\\\">\")\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\tvar templ_7745c5c3_Var29 string\n\t\ttempl_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T(\"time\"))\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 132, Col: 51}\n\t\t}\n\t\t_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\ttempl_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, \"</th><th style=\\\"width:4rem\\\">\")\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\tvar templ_7745c5c3_Var30 string\n\t\ttempl_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T(\"iters\"))\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 133, Col: 50}\n\t\t}\n\t\t_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\ttempl_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, \"</th></tr><tr id=\\\"table-header-compare\\\" style=\\\"display:none\\\"><th style=\\\"width:4.5rem\\\">\")\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\tvar templ_7745c5c3_Var31 string\n\t\ttempl_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T(\"time_a\"))\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 136, Col: 53}\n\t\t}\n\t\t_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\ttempl_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, \"</th><th style=\\\"width:4rem\\\">\")\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\tvar templ_7745c5c3_Var32 string\n\t\ttempl_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T(\"iters_a\"))\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 137, Col: 52}\n\t\t}\n\t\t_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\ttempl_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, \"</th><th style=\\\"width:4.5rem\\\">\")\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\tvar templ_7745c5c3_Var33 string\n\t\ttempl_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T(\"time_b\"))\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 138, Col: 53}\n\t\t}\n\t\t_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\ttempl_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, \"</th><th style=\\\"width:4rem\\\">\")\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\tvar templ_7745c5c3_Var34 string\n\t\ttempl_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T(\"iters_b\"))\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 139, Col: 52}\n\t\t}\n\t\t_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\ttempl_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, \"</th></tr></thead> <tbody id=\\\"results\\\" style=\\\"padding-top:0.25rem;display:grid;grid-template-columns:subgrid;grid-auto-rows:min-content;grid-column:1/-1;row-gap:0.25rem;overflow-y:auto;font-variant-numeric:tabular-nums\\\"></tbody></table><div class=\\\"centered-div\\\"><img id=\\\"image\\\" style=\\\"width:100%;max-width:256px;\\\" src=\\\"\")\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\tvar templ_7745c5c3_Var35 string\n\t\ttempl_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(anubis.BasePrefix + \"/.within.website/x/cmd/anubis/static/img/pensive.webp?cacheBuster=\" + anubis.Version)\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 148, Col: 166}\n\t\t}\n\t\t_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\ttempl_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, \"\\\"><p id=\\\"status\\\" style=\\\"max-width:256px\\\">\")\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\tvar templ_7745c5c3_Var36 string\n\t\ttempl_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T(\"loading\"))\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 149, Col: 66}\n\t\t}\n\t\t_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\ttempl_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, \"</p><script async type=\\\"module\\\" src=\\\"\")\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\tvar templ_7745c5c3_Var37 string\n\t\ttempl_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(anubis.BasePrefix + \"/.within.website/x/cmd/anubis/static/js/bench.mjs?cacheBuster=\" + anubis.Version)\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 150, Col: 138}\n\t\t}\n\t\t_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\ttempl_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, \"\\\"></script><div id=\\\"sparkline\\\"></div><noscript><p>\")\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\tvar templ_7745c5c3_Var38 string\n\t\ttempl_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T(\"benchmark_requires_js\"))\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 153, Col: 45}\n\t\t}\n\t\t_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\ttempl_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, \"</p></noscript></div></div><form id=\\\"controls\\\" style=\\\"position:fixed;top:0.5rem;right:0.5rem\\\"><div style=\\\"display:flex;justify-content:end\\\"><label for=\\\"difficulty-input\\\" style=\\\"margin-right:0.5rem\\\">\")\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\tvar templ_7745c5c3_Var39 string\n\t\ttempl_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T(\"difficulty\"))\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 159, Col: 88}\n\t\t}\n\t\t_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\ttempl_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, \"</label> <input id=\\\"difficulty-input\\\" type=\\\"number\\\" name=\\\"difficulty\\\" style=\\\"width:3rem\\\"></div><div style=\\\"margin-top:0.25rem;display:flex;justify-content:end\\\"><label for=\\\"algorithm-select\\\" style=\\\"margin-right:0.5rem\\\">\")\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\tvar templ_7745c5c3_Var40 string\n\t\ttempl_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T(\"algorithm\"))\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 163, Col: 87}\n\t\t}\n\t\t_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\ttempl_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, \"</label> <select id=\\\"algorithm-select\\\" name=\\\"algorithm\\\"></select></div><div style=\\\"margin-top:0.25rem;display:flex;justify-content:end\\\"><label for=\\\"compare-select\\\" style=\\\"margin-right:0.5rem\\\">\")\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\tvar templ_7745c5c3_Var41 string\n\t\ttempl_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T(\"compare\"))\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 167, Col: 83}\n\t\t}\n\t\t_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\ttempl_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, \"</label> <select id=\\\"compare-select\\\" name=\\\"compare\\\"><option value=\\\"NONE\\\">-</option></select></div></form>\")\n\t\tif templ_7745c5c3_Err != nil {\n\t\t\treturn templ_7745c5c3_Err\n\t\t}\n\t\treturn nil\n\t})\n}\n\nvar _ = templruntime.GeneratedTemplate\n"
  },
  {
    "path": "web/index_test.go",
    "content": "package web\n\nimport (\n\t\"context\"\n\t\"net/http/httptest\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/TecharoHQ/anubis\"\n\t\"github.com/TecharoHQ/anubis/lib/config\"\n\t\"github.com/TecharoHQ/anubis/lib/localization\"\n\t\"github.com/a-h/templ\"\n)\n\nfunc TestBasePrefixInLinks(t *testing.T) {\n\ttests := []struct {\n\t\tname       string\n\t\tbasePrefix string\n\t\twantInLink string\n\t}{\n\t\t{\n\t\t\tname:       \"no prefix\",\n\t\t\tbasePrefix: \"\",\n\t\t\twantInLink: \"/.within.website/x/cmd/anubis/api/\",\n\t\t},\n\t\t{\n\t\t\tname:       \"with rififi prefix\",\n\t\t\tbasePrefix: \"/rififi\",\n\t\t\twantInLink: \"/rififi/.within.website/x/cmd/anubis/api/\",\n\t\t},\n\t\t{\n\t\t\tname:       \"with myapp prefix\",\n\t\t\tbasePrefix: \"/myapp\",\n\t\t\twantInLink: \"/myapp/.within.website/x/cmd/anubis/api/\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\t// Save original BasePrefix and restore after test\n\t\t\torigPrefix := anubis.BasePrefix\n\t\t\tdefer func() { anubis.BasePrefix = origPrefix }()\n\n\t\t\tanubis.BasePrefix = tt.basePrefix\n\n\t\t\t// Create test impressum\n\t\t\timpressum := &config.Impressum{\n\t\t\t\tFooter: \"<p>Test footer</p>\",\n\t\t\t\tPage: config.ImpressumPage{\n\t\t\t\t\tTitle: \"Test Imprint\",\n\t\t\t\t\tBody:  \"<p>Test imprint body</p>\",\n\t\t\t\t},\n\t\t\t}\n\n\t\t\t// Create localizer using a dummy request\n\t\t\treq := httptest.NewRequest(\"GET\", \"/\", nil)\n\t\t\tlocalizer := &localization.SimpleLocalizer{}\n\t\t\tlocalizer.Localizer = localization.NewLocalizationService().GetLocalizerFromRequest(req)\n\n\t\t\t// Render the base template to a buffer\n\t\t\tvar buf strings.Builder\n\t\t\tcomponent := base(tt.name, templ.NopComponent, impressum, nil, nil, localizer)\n\t\t\terr := component.Render(context.Background(), &buf)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"failed to render template: %v\", err)\n\t\t\t}\n\n\t\t\toutput := buf.String()\n\n\t\t\t// Check that honeypot link includes the base prefix\n\t\t\tif !strings.Contains(output, `href=\"`+tt.wantInLink+`honeypot/`) {\n\t\t\t\tt.Errorf(\"honeypot link does not contain base prefix %q\\noutput: %s\", tt.wantInLink, output)\n\t\t\t}\n\n\t\t\t// Check that imprint link includes the base prefix\n\t\t\tif !strings.Contains(output, `href=\"`+tt.wantInLink+`imprint`) {\n\t\t\t\tt.Errorf(\"imprint link does not contain base prefix %q\\noutput: %s\", tt.wantInLink, output)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "web/js/algorithms/fast.ts",
    "content": "type ProgressCallback = (nonce: number) => void;\n\ninterface ProcessOptions {\n  basePrefix: string;\n  version: string;\n}\n\nconst getHardwareConcurrency = () =>\n  navigator.hardwareConcurrency !== undefined\n    ? navigator.hardwareConcurrency\n    : 1;\n\nexport default function process(\n  options: ProcessOptions,\n  data: string,\n  difficulty: number = 5,\n  signal: AbortSignal | null = null,\n  progressCallback?: ProgressCallback,\n  threads: number = Math.trunc(Math.max(getHardwareConcurrency() / 2, 1)),\n): Promise<string> {\n  console.debug(\"fast algo\");\n\n  // Choose worker based on secure context.\n  // Use the WebCrypto worker if the page is a secure context; otherwise fall back to pure‑JS.\n  let workerMethod: \"webcrypto\" | \"purejs\" = \"purejs\";\n  if (window.isSecureContext) {\n    workerMethod = \"webcrypto\";\n  }\n\n  if (\n    navigator.userAgent.includes(\"Firefox\") ||\n    navigator.userAgent.includes(\"Goanna\")\n  ) {\n    console.log(\"Firefox detected, using pure-JS fallback\");\n    workerMethod = \"purejs\";\n  }\n\n  return new Promise((resolve, reject) => {\n    let webWorkerURL = `${options.basePrefix}/.within.website/x/cmd/anubis/static/js/worker/sha256-${workerMethod}.mjs?cacheBuster=${options.version}`;\n\n    const workers: Worker[] = [];\n    let settled = false;\n\n    const onAbort = () => {\n      console.log(\"PoW aborted\");\n      cleanup();\n      reject(new DOMException(\"Aborted\", \"AbortError\"));\n    };\n\n    const cleanup = () => {\n      if (settled) {\n        return;\n      }\n      settled = true;\n      workers.forEach((w) => w.terminate());\n      if (signal != null) {\n        signal.removeEventListener(\"abort\", onAbort);\n      }\n    };\n\n    if (signal != null) {\n      if (signal.aborted) {\n        return onAbort();\n      }\n      signal.addEventListener(\"abort\", onAbort, { once: true });\n    }\n\n    for (let i = 0; i < threads; i++) {\n      let worker = new Worker(webWorkerURL);\n\n      worker.onmessage = (event) => {\n        if (typeof event.data === \"number\") {\n          progressCallback?.(event.data);\n        } else {\n          cleanup();\n          resolve(event.data);\n        }\n      };\n\n      worker.onerror = (event) => {\n        cleanup();\n        reject(event);\n      };\n\n      worker.postMessage({\n        data,\n        difficulty,\n        nonce: i,\n        threads,\n      });\n\n      workers.push(worker);\n    }\n  });\n}\n"
  },
  {
    "path": "web/js/algorithms/index.ts",
    "content": "import fast from \"./fast\";\n\nexport default {\n  fast: fast,\n  slow: fast, // XXX(Xe): slow is deprecated, but keep this around in case anything goes bad\n};\n"
  },
  {
    "path": "web/js/bench.ts",
    "content": "import algorithms from \"./algorithms\";\n\nconst defaultDifficulty = 4;\n\nconst status: HTMLParagraphElement = document.getElementById(\n  \"status\",\n) as HTMLParagraphElement;\nconst difficultyInput: HTMLInputElement = document.getElementById(\n  \"difficulty-input\",\n) as HTMLInputElement;\nconst algorithmSelect: HTMLSelectElement = document.getElementById(\n  \"algorithm-select\",\n) as HTMLSelectElement;\nconst compareSelect: HTMLSelectElement = document.getElementById(\n  \"compare-select\",\n) as HTMLSelectElement;\nconst header: HTMLTableRowElement = document.getElementById(\n  \"table-header\",\n) as HTMLTableRowElement;\nconst headerCompare: HTMLTableSectionElement = document.getElementById(\n  \"table-header-compare\",\n) as HTMLTableSectionElement;\nconst results: HTMLTableRowElement = document.getElementById(\n  \"results\",\n) as HTMLTableRowElement;\n\nconst setupControls = () => {\n  if (defaultDifficulty == null) {\n    return;\n  }\n\n  difficultyInput.value = defaultDifficulty.toString();\n  for (const alg of Object.keys(algorithms)) {\n    const option1 = document.createElement(\"option\");\n    algorithmSelect?.append(option1);\n    const option2 = document.createElement(\"option\");\n    compareSelect.append(option2);\n    option1.value = option1.innerText = option2.value = option2.innerText = alg;\n  }\n};\n\nconst benchmarkTrial = async (stats, difficulty, algorithm, signal) => {\n  if (!(difficulty >= 1)) {\n    throw new Error(`Invalid difficulty: ${difficulty}`);\n  }\n  const process = algorithms[algorithm];\n  if (process == null) {\n    throw new Error(`Unknown algorithm: ${algorithm}`);\n  }\n\n  const rawChallenge = new Uint8Array(32);\n  crypto.getRandomValues(rawChallenge);\n  const challenge = Array.from(rawChallenge)\n    .map((c) => c.toString(16).padStart(2, \"0\"))\n    .join(\"\");\n\n  const t0 = performance.now();\n  const { hash, nonce } = await process(\n    { basePrefix: \"/\", version: \"devel\" },\n    challenge,\n    Number(difficulty),\n    signal,\n  );\n  const t1 = performance.now();\n  console.log({ hash, nonce });\n\n  stats.time += t1 - t0;\n  stats.iters += nonce;\n\n  return { time: t1 - t0, nonce };\n};\n\nconst stats = { time: 0, iters: 0 };\nconst comparison = { time: 0, iters: 0 };\nconst updateStatus = () => {\n  const mainRate = stats.iters / stats.time;\n  const compareRate = comparison.iters / comparison.time;\n  if (Number.isFinite(mainRate)) {\n    status.innerText = `Average hashrate: ${mainRate.toFixed(3)}kH/s`;\n    if (Number.isFinite(compareRate)) {\n      const change = ((mainRate - compareRate) / mainRate) * 100;\n      status.innerText += ` vs ${compareRate.toFixed(3)}kH/s (${change.toFixed(2)}% change)`;\n    }\n  } else {\n    status.innerText = \"Benchmarking...\";\n  }\n};\n\nconst tableCell = (text) => {\n  const td = document.createElement(\"td\");\n  td.innerText = text;\n  td.style.padding = \"0 0.25rem\";\n  return td;\n};\n\nconst benchmarkLoop = async (controller) => {\n  const difficulty = difficultyInput.value;\n  const algorithm = algorithmSelect.value;\n  const compareAlgorithm = compareSelect.value;\n  updateStatus();\n\n  try {\n    const { time, nonce } = await benchmarkTrial(\n      stats,\n      difficulty,\n      algorithm,\n      controller.signal,\n    );\n\n    const tr = document.createElement(\"tr\");\n    tr.style.display = \"contents\";\n    tr.append(tableCell(`${time}ms`), tableCell(nonce));\n\n    // auto-scroll to new rows\n    const atBottom =\n      results.scrollHeight - results.clientHeight <= results.scrollTop;\n    results.append(tr);\n    if (atBottom) {\n      results.scrollTop = results.scrollHeight - results.clientHeight;\n    }\n    updateStatus();\n\n    if (compareAlgorithm !== \"NONE\") {\n      const { time, nonce } = await benchmarkTrial(\n        comparison,\n        difficulty,\n        compareAlgorithm,\n        controller.signal,\n      );\n      tr.append(tableCell(`${time}ms`), tableCell(nonce));\n    }\n  } catch (e) {\n    if (e !== false) {\n      status.innerText = e;\n    }\n    return;\n  }\n\n  await benchmarkLoop(controller);\n};\n\nlet controller: AbortController | null = null;\nconst reset = () => {\n  stats.time = stats.iters = 0;\n  comparison.time = comparison.iters = 0;\n  results.innerHTML = status.innerText = \"\";\n\n  const table = results.parentElement as HTMLElement;\n  if (compareSelect.value !== \"NONE\") {\n    table.style.gridTemplateColumns = \"repeat(4,auto)\";\n    header.style.display = \"none\";\n    headerCompare.style.display = \"contents\";\n  } else {\n    table.style.gridTemplateColumns = \"repeat(2,auto)\";\n    header.style.display = \"contents\";\n    headerCompare.style.display = \"none\";\n  }\n\n  if (controller != null) {\n    controller.abort();\n  }\n  controller = new AbortController();\n  void benchmarkLoop(controller);\n};\n\nsetupControls();\ndifficultyInput.addEventListener(\"change\", reset);\nalgorithmSelect.addEventListener(\"change\", reset);\ncompareSelect.addEventListener(\"change\", reset);\nreset();\n"
  },
  {
    "path": "web/js/main.ts",
    "content": "import algorithms from \"./algorithms\";\n\n// from Xeact\nconst u = (url: string = \"\", params: Record<string, any> = {}) => {\n  let result = new URL(url, window.location.href);\n  Object.entries(params).forEach(([k, v]) => result.searchParams.set(k, v));\n  return result.toString();\n};\n\nconst j = (id: string): any | null => {\n  const elem = document.getElementById(id);\n  if (elem === null) {\n    return null;\n  }\n\n  return JSON.parse(elem.textContent);\n};\n\nconst imageURL = (mood, cacheBuster, basePrefix) =>\n  u(`${basePrefix}/.within.website/x/cmd/anubis/static/img/${mood}.webp`, {\n    cacheBuster,\n  });\n\n// Detect available languages by loading the manifest\nconst getAvailableLanguages = async () => {\n  const basePrefix = j(\"anubis_base_prefix\");\n  if (basePrefix === null) {\n    return;\n  }\n\n  try {\n    const response = await fetch(\n      `${basePrefix}/.within.website/x/cmd/anubis/static/locales/manifest.json`,\n    );\n    if (response.ok) {\n      const manifest = await response.json();\n      return manifest.supportedLanguages || [\"en\"];\n    }\n  } catch (error) {\n    console.warn(\n      \"Failed to load language manifest, falling back to default languages\",\n    );\n  }\n\n  // Fallback to default languages if manifest loading fails\n  return [\"en\"];\n};\n\n// Use the browser language from the HTML lang attribute which is set by the server settings or request headers\nconst getBrowserLanguage = async () => document.documentElement.lang;\n\n// Load translations from JSON files\nconst loadTranslations = async (lang) => {\n  const basePrefix = j(\"anubis_base_prefix\");\n  if (basePrefix === null) {\n    return;\n  }\n\n  try {\n    const response = await fetch(\n      `${basePrefix}/.within.website/x/cmd/anubis/static/locales/${lang}.json`,\n    );\n    return await response.json();\n  } catch (error) {\n    console.warn(\n      `Failed to load translations for ${lang}, falling back to English`,\n    );\n    if (lang !== \"en\") {\n      return await loadTranslations(\"en\");\n    }\n    throw error;\n  }\n};\n\nconst getRedirectUrl = () => {\n  const publicUrl = j(\"anubis_public_url\");\n  if (publicUrl === null) {\n    return;\n  }\n  if (publicUrl && window.location.href.startsWith(publicUrl)) {\n    const urlParams = new URLSearchParams(window.location.search);\n    return urlParams.get(\"redir\");\n  }\n  return window.location.href;\n};\n\nlet translations = {};\nlet currentLang;\n\n// Initialize translations\nconst initTranslations = async () => {\n  currentLang = await getBrowserLanguage();\n  translations = await loadTranslations(currentLang);\n};\n\nconst t = (key) => translations[`js_${key}`] || translations[key] || key;\n\n(async () => {\n  // Initialize translations first\n  await initTranslations();\n\n  const dependencies = [\n    {\n      name: \"Web Workers\",\n      msg: t(\"web_workers_error\"),\n      value: window.Worker,\n    },\n    {\n      name: \"Cookies\",\n      msg: t(\"cookies_error\"),\n      value: navigator.cookieEnabled,\n    },\n  ];\n\n  const status: HTMLParagraphElement = document.getElementById(\n    \"status\",\n  ) as HTMLParagraphElement;\n  const image: HTMLImageElement = document.getElementById(\n    \"image\",\n  ) as HTMLImageElement;\n  const title: HTMLHeadingElement = document.getElementById(\n    \"title\",\n  ) as HTMLHeadingElement;\n  const progress: HTMLDivElement = document.getElementById(\n    \"progress\",\n  ) as HTMLDivElement;\n\n  const anubisVersion = j(\"anubis_version\");\n  const basePrefix = j(\"anubis_base_prefix\");\n  const details = document.querySelector(\"details\");\n  let userReadDetails = false;\n\n  if (details) {\n    details.addEventListener(\"toggle\", () => {\n      if (details.open) {\n        userReadDetails = true;\n      }\n    });\n  }\n\n  const ohNoes = ({ titleMsg, statusMsg, imageSrc }) => {\n    title.innerHTML = titleMsg;\n    status.innerHTML = statusMsg;\n    image.src = imageSrc;\n    progress.style.display = \"none\";\n  };\n\n  status.innerHTML = t(\"calculating\");\n\n  for (const { value, name, msg } of dependencies) {\n    if (!value) {\n      ohNoes({\n        titleMsg: `${t(\"missing_feature\")} ${name}`,\n        statusMsg: msg,\n        imageSrc: imageURL(\"reject\", anubisVersion, basePrefix),\n      });\n      return;\n    }\n  }\n\n  const { challenge, rules } = j(\"anubis_challenge\");\n\n  const process = algorithms[rules.algorithm];\n  if (!process) {\n    ohNoes({\n      titleMsg: t(\"challenge_error\"),\n      statusMsg: t(\"challenge_error_msg\"),\n      imageSrc: imageURL(\"reject\", anubisVersion, basePrefix),\n    });\n    return;\n  }\n\n  status.innerHTML = `${t(\"calculating_difficulty\")} ${rules.difficulty}, `;\n  progress.style.display = \"inline-block\";\n\n  // the whole text, including \"Speed:\", as a single node, because some browsers\n  // (Firefox mobile) present screen readers with each node as a separate piece\n  // of text.\n  const rateText = document.createTextNode(`${t(\"speed\")} 0kH/s`);\n  status.appendChild(rateText);\n\n  let lastSpeedUpdate = 0;\n  let showingApology = false;\n  const likelihood = Math.pow(16, -rules.difficulty);\n\n  try {\n    const t0 = Date.now();\n    const { hash, nonce } = await process(\n      { basePrefix, version: anubisVersion },\n      challenge.randomData,\n      rules.difficulty,\n      null,\n      (iters) => {\n        const delta = Date.now() - t0;\n        // only update the speed every second so it's less visually distracting\n        if (delta - lastSpeedUpdate > 1000) {\n          lastSpeedUpdate = delta;\n          rateText.data = `${t(\"speed\")} ${(iters / delta).toFixed(3)}kH/s`;\n        }\n        // the probability of still being on the page is (1 - likelihood) ^ iters.\n        // by definition, half of the time the progress bar only gets to half, so\n        // apply a polynomial ease-out function to move faster in the beginning\n        // and then slow down as things get increasingly unlikely. quadratic felt\n        // the best in testing, but this may need adjustment in the future.\n\n        const probability = Math.pow(1 - likelihood, iters);\n        const distance = (1 - Math.pow(probability, 2)) * 100;\n        progress[\"aria-valuenow\"] = distance;\n        if (progress.firstElementChild !== null) {\n          (progress.firstElementChild as HTMLElement).style.width =\n            `${distance}%`;\n        }\n\n        if (probability < 0.1 && !showingApology) {\n          status.append(\n            document.createElement(\"br\"),\n            document.createTextNode(t(\"verification_longer\")),\n          );\n          showingApology = true;\n        }\n      },\n    );\n    const t1 = Date.now();\n    console.log({ hash, nonce });\n\n    if (userReadDetails) {\n      const container: HTMLDivElement = document.getElementById(\n        \"progress\",\n      ) as HTMLDivElement;\n\n      // Style progress bar as a continue button\n      container.style.display = \"flex\";\n      container.style.alignItems = \"center\";\n      container.style.justifyContent = \"center\";\n      container.style.height = \"2rem\";\n      container.style.borderRadius = \"1rem\";\n      container.style.cursor = \"pointer\";\n      container.style.background = \"#b16286\";\n      container.style.color = \"white\";\n      container.style.fontWeight = \"bold\";\n      container.style.outline = \"4px solid #b16286\";\n      container.style.outlineOffset = \"2px\";\n      container.style.width = \"min(20rem, 90%)\";\n      container.style.margin = \"1rem auto 2rem\";\n      container.innerHTML = t(\"finished_reading\");\n\n      function onDetailsExpand() {\n        const redir = getRedirectUrl();\n        window.location.replace(\n          u(`${basePrefix}/.within.website/x/cmd/anubis/api/pass-challenge`, {\n            id: challenge.id,\n            response: hash,\n            nonce,\n            redir,\n            elapsedTime: t1 - t0,\n          }),\n        );\n      }\n\n      container.onclick = onDetailsExpand;\n      setTimeout(onDetailsExpand, 30000);\n    } else {\n      const redir = getRedirectUrl();\n      window.location.replace(\n        u(`${basePrefix}/.within.website/x/cmd/anubis/api/pass-challenge`, {\n          id: challenge.id,\n          response: hash,\n          nonce,\n          redir,\n          elapsedTime: t1 - t0,\n        }),\n      );\n    }\n  } catch (err) {\n    ohNoes({\n      titleMsg: t(\"calculation_error\"),\n      statusMsg: `${t(\"calculation_error_msg\")} ${err.message}`,\n      imageSrc: imageURL(\"reject\", anubisVersion, basePrefix),\n    });\n  }\n})();\n"
  },
  {
    "path": "web/js/worker/sha256-purejs.ts",
    "content": "import { Sha256 } from \"@aws-crypto/sha256-js\";\n\nconst calculateSHA256 = (text) => {\n  const hash = new Sha256();\n  hash.update(text);\n  return hash.digest();\n};\n\nfunction toHexString(arr: Uint8Array): string {\n  return Array.from(arr)\n    .map((c) => c.toString(16).padStart(2, \"0\"))\n    .join(\"\");\n}\n\naddEventListener(\"message\", async ({ data: eventData }) => {\n  const { data, difficulty, threads } = eventData;\n  let nonce = eventData.nonce;\n  const isMainThread = nonce === 0;\n  let iterations = 0;\n\n  const requiredZeroBytes = Math.floor(difficulty / 2);\n  const isDifficultyOdd = difficulty % 2 !== 0;\n\n  for (;;) {\n    const hashBuffer = await calculateSHA256(data + nonce);\n    const hashArray = new Uint8Array(hashBuffer);\n\n    let isValid = true;\n    for (let i = 0; i < requiredZeroBytes; i++) {\n      if (hashArray[i] !== 0) {\n        isValid = false;\n        break;\n      }\n    }\n\n    if (isValid && isDifficultyOdd) {\n      if (hashArray[requiredZeroBytes] >> 4 !== 0) {\n        isValid = false;\n      }\n    }\n\n    if (isValid) {\n      const finalHash = toHexString(hashArray);\n      postMessage({\n        hash: finalHash,\n        data,\n        difficulty,\n        nonce,\n      });\n      return; // Exit worker\n    }\n\n    nonce += threads;\n    iterations++;\n\n    /* Truncate the decimal portion of the nonce. This is a bit of an evil bit\n     * hack, but it works reliably enough. The core of why this works is:\n     *\n     * > 13.4 % 1 !== 0\n     * true\n     * > 13 % 1 !== 0\n     * false\n     */\n    if (nonce % 1 !== 0) {\n      nonce = Math.trunc(nonce);\n    }\n\n    // Send a progress update from the main thread every 1024 iterations.\n    if (isMainThread && (iterations & 1023) === 0) {\n      postMessage(nonce);\n    }\n  }\n});\n"
  },
  {
    "path": "web/js/worker/sha256-webcrypto.ts",
    "content": "const encoder = new TextEncoder();\n\nconst calculateSHA256 = async (input: string) => {\n  const data = encoder.encode(input);\n  return await crypto.subtle.digest(\"SHA-256\", data);\n};\n\nconst toHexString = (byteArray: Uint8Array) => {\n  return byteArray.reduce(\n    (str, byte) => str + byte.toString(16).padStart(2, \"0\"),\n    \"\",\n  );\n};\n\naddEventListener(\"message\", async ({ data: eventData }) => {\n  const { data, difficulty, threads } = eventData;\n  let nonce = eventData.nonce;\n  const isMainThread = nonce === 0;\n  let iterations = 0;\n\n  const requiredZeroBytes = Math.floor(difficulty / 2);\n  const isDifficultyOdd = difficulty % 2 !== 0;\n\n  for (;;) {\n    const hashBuffer = await calculateSHA256(data + nonce);\n    const hashArray = new Uint8Array(hashBuffer);\n\n    let isValid = true;\n    for (let i = 0; i < requiredZeroBytes; i++) {\n      if (hashArray[i] !== 0) {\n        isValid = false;\n        break;\n      }\n    }\n\n    if (isValid && isDifficultyOdd) {\n      if (hashArray[requiredZeroBytes] >> 4 !== 0) {\n        isValid = false;\n      }\n    }\n\n    if (isValid) {\n      const finalHash = toHexString(hashArray);\n      postMessage({\n        hash: finalHash,\n        data,\n        difficulty,\n        nonce,\n      });\n      return; // Exit worker\n    }\n\n    nonce += threads;\n    iterations++;\n\n    /* Truncate the decimal portion of the nonce. This is a bit of an evil bit\n     * hack, but it works reliably enough. The core of why this works is:\n     *\n     * > 13.4 % 1 !== 0\n     * true\n     * > 13 % 1 !== 0\n     * false\n     */\n    if (nonce % 1 !== 0) {\n      nonce = Math.trunc(nonce);\n    }\n\n    // Send a progress update from the main thread every 1024 iterations.\n    if (isMainThread && (iterations & 1023) === 0) {\n      postMessage(nonce);\n    }\n  }\n});\n"
  },
  {
    "path": "web/static/img/ATTRIBUTIONS.txt",
    "content": "These mascot images were made by CELPHASE (https://bsky.app/profile/celphase.bsky.social).\n\nThese images are available under the terms of the MIT license that this repository uses."
  },
  {
    "path": "web/static/js/.gitignore",
    "content": "*\n!.gitignore"
  },
  {
    "path": "web/static/robots.txt",
    "content": "User-agent: AddSearchBot\nUser-agent: AI2Bot\nUser-agent: Ai2Bot-Dolma\nUser-agent: aiHitBot\nUser-agent: Amazonbot\nUser-agent: Andibot\nUser-agent: anthropic-ai\nUser-agent: Applebot\nUser-agent: Applebot-Extended\nUser-agent: Awario\nUser-agent: bedrockbot\nUser-agent: bigsur.ai\nUser-agent: Brightbot 1.0\nUser-agent: Bytespider\nUser-agent: CCBot\nUser-agent: ChatGPT Agent\nUser-agent: ChatGPT-User\nUser-agent: Claude-SearchBot\nUser-agent: Claude-User\nUser-agent: Claude-Web\nUser-agent: ClaudeBot\nUser-agent: CloudVertexBot\nUser-agent: cohere-ai\nUser-agent: cohere-training-data-crawler\nUser-agent: Cotoyogi\nUser-agent: Crawlspace\nUser-agent: Datenbank Crawler\nUser-agent: Devin\nUser-agent: Diffbot\nUser-agent: DuckAssistBot\nUser-agent: Echobot Bot\nUser-agent: EchoboxBot\nUser-agent: FacebookBot\nUser-agent: facebookexternalhit\nUser-agent: Factset_spyderbot\nUser-agent: FirecrawlAgent\nUser-agent: FriendlyCrawler\nUser-agent: Gemini-Deep-Research\nUser-agent: Google-CloudVertexBot\nUser-agent: Google-Extended\nUser-agent: GoogleAgent-Mariner\nUser-agent: GoogleOther\nUser-agent: GoogleOther-Image\nUser-agent: GoogleOther-Video\nUser-agent: GPTBot\nUser-agent: iaskspider/2.0\nUser-agent: ICC-Crawler\nUser-agent: ImagesiftBot\nUser-agent: img2dataset\nUser-agent: ISSCyberRiskCrawler\nUser-agent: Kangaroo Bot\nUser-agent: LinerBot\nUser-agent: meta-externalagent\nUser-agent: Meta-ExternalAgent\nUser-agent: meta-externalfetcher\nUser-agent: Meta-ExternalFetcher\nUser-agent: MistralAI-User\nUser-agent: MistralAI-User/1.0\nUser-agent: MyCentralAIScraperBot\nUser-agent: netEstate Imprint Crawler\nUser-agent: NovaAct\nUser-agent: OAI-SearchBot\nUser-agent: omgili\nUser-agent: omgilibot\nUser-agent: OpenAI\nUser-agent: Operator\nUser-agent: PanguBot\nUser-agent: Panscient\nUser-agent: panscient.com\nUser-agent: Perplexity-User\nUser-agent: PerplexityBot\nUser-agent: PetalBot\nUser-agent: PhindBot\nUser-agent: Poseidon Research Crawler\nUser-agent: QualifiedBot\nUser-agent: QuillBot\nUser-agent: quillbot.com\nUser-agent: SBIntuitionsBot\nUser-agent: Scrapy\nUser-agent: SemrushBot-OCOB\nUser-agent: SemrushBot-SWA\nUser-agent: Sidetrade indexer bot\nUser-agent: Thinkbot\nUser-agent: TikTokSpider\nUser-agent: Timpibot\nUser-agent: VelenPublicWebCrawler\nUser-agent: WARDBot\nUser-agent: Webzio-Extended\nUser-agent: wpbot\nUser-agent: YaK\nUser-agent: YandexAdditional\nUser-agent: YandexAdditionalBot\nUser-agent: YouBot\nDisallow: /\n\nUser-agent: *\nDisallow: /\n"
  },
  {
    "path": "xess/.gitignore",
    "content": "xess.min.css\n"
  },
  {
    "path": "xess/build.sh",
    "content": "#!/usr/bin/env bash\n\nset -euo pipefail\n\ncd \"$(dirname \"$0\")\"\npostcss ./xess.css -o xess.min.css"
  },
  {
    "path": "xess/postcss.config.js",
    "content": "module.exports = {\n  plugins: [\n    require(\"cssnano\")({\n      preset: \"advanced\",\n    }),\n    require(\"postcss-url\")({ url: \"inline\" }),\n  ],\n};\n"
  },
  {
    "path": "xess/static/podkova.css",
    "content": "@font-face {\n  font-family: \"Podkova\";\n  font-style: normal;\n  font-weight: 400 800;\n  font-display: swap;\n  src: url(\"podkova.woff2\") format(\"woff2\");\n}\n"
  },
  {
    "path": "xess/xess.css",
    "content": ":root {\n  --body-sans-font: Geist, sans-serif;\n  --body-preformatted-font: Iosevka Curly Iaso, monospace;\n  --body-title-font: Podkova, serif;\n\n  --background: #1d2021;\n  --text: #f9f5d7;\n  --text-selection: #d3869b;\n  --preformatted-background: #3c3836;\n  --link-foreground: #b16286;\n  --link-background: #282828;\n  --blockquote-border-left: 1px solid #bdae93;\n\n  --progress-bar-outline: #b16286 solid 4px;\n  --progress-bar-fill: #b16286;\n}\n@media (prefers-color-scheme: light) {\n  :root {\n    --background: #f9f5d7;\n    --text: #1d2021;\n    --text-selection: #d3869b;\n    --preformatted-background: #ebdbb2;\n    --link-foreground: #b16286;\n    --link-background: #fbf1c7;\n    --blockquote-border-left: 1px solid #655c54;\n  }\n}\n\n@font-face {\n  font-family: \"Geist\";\n  font-style: normal;\n  font-weight: 100 900;\n  font-display: swap;\n  src: url(\"./static/geist.woff2\") format(\"woff2\");\n}\n\n@font-face {\n  font-family: \"Podkova\";\n  font-style: normal;\n  font-weight: 400 800;\n  font-display: swap;\n  src: url(\"./static/podkova.woff2\") format(\"woff2\");\n}\n\n@font-face {\n  font-family: \"Iosevka Curly\";\n  font-style: monospace;\n  font-display: swap;\n  src: url(\"./static/iosevka-curly.woff2\") format(\"woff2\");\n}\n\nmain {\n  font-family: var(--body-sans-font);\n  max-width: 50rem;\n  padding: 2rem;\n  margin: auto;\n}\n\n::selection {\n  background: var(--text-selection);\n}\n\nbody {\n  background: var(--background);\n  color: var(--text);\n}\n\nbody,\nhtml {\n  height: 100%;\n  display: flex;\n  justify-content: center;\n  align-items: center;\n  margin-left: auto;\n  margin-right: auto;\n}\n\n.centered-div {\n  text-align: center;\n}\n\n#status {\n  font-variant-numeric: tabular-nums;\n}\n\n.centered-div {\n  text-align: center;\n}\n\n#status {\n  font-variant-numeric: tabular-nums;\n}\n\n#progress {\n  display: none;\n  width: min(20rem, 90%);\n  height: 2rem;\n  border-radius: 1rem;\n  overflow: hidden;\n  margin: 1rem 0 2rem;\n  outline-offset: 2px;\n  outline: var(--progress-bar-outline);\n}\n\n.bar-inner {\n  background-color: var(--progress-bar-fill);\n  height: 100%;\n  width: 0;\n  transition: width 0.25s ease-in;\n}\n\n@media (prefers-reduced-motion: no-preference) {\n  .bar-inner {\n    transition: width 0.25s ease-in;\n  }\n}\n\npre {\n  background-color: var(--preformatted-background);\n  padding: 1em;\n  border: 0;\n  font-family: var(--body-preformatted-font);\n}\n\na,\na:active,\na:visited {\n  color: var(--link-foreground);\n  background-color: var(--link-background);\n}\n\nh1,\nh2,\nh3,\nh4,\nh5 {\n  margin-bottom: 0.1rem;\n  font-family: var(--body-title-font);\n}\n\nblockquote {\n  border-left: var(--blockquote-border-left);\n  margin: 0.5em 10px;\n  padding: 0.5em 10px;\n}\n\nfooter {\n  text-align: center;\n}\n"
  },
  {
    "path": "xess/xess.go",
    "content": "// Package xess vendors a copy of Xess and makes it available at /.xess/xess.css\n//\n// This is intended to be used as a vendored package in other projects.\npackage xess\n\nimport (\n\t\"embed\"\n\t\"net/http\"\n\t\"path/filepath\"\n\n\t\"github.com/TecharoHQ/anubis\"\n\t\"github.com/TecharoHQ/anubis/internal\"\n)\n\nvar (\n\t//go:embed *.css static\n\tStatic embed.FS\n\n\tBasePrefix = \"/.within.website/x/xess/\"\n\tURL        = \"/.within.website/x/xess/xess.css\"\n)\n\nfunc init() {\n\tMount(http.DefaultServeMux)\n\n\t//goland:noinspection GoBoolExpressions\n\tif anubis.Version != \"devel\" {\n\t\tURL = filepath.Join(filepath.Dir(URL), \"xess.min.css\")\n\t}\n\n\tURL = URL + \"?cachebuster=\" + anubis.Version\n}\n\n// Mount registers the xess static file handlers on the given mux\nfunc Mount(mux *http.ServeMux) {\n\tprefix := anubis.BasePrefix + \"/.within.website/x/xess/\"\n\n\tmux.Handle(prefix, internal.UnchangingCache(http.StripPrefix(prefix, http.FileServerFS(Static))))\n}\n"
  },
  {
    "path": "yeetfile.js",
    "content": "$`npm run assets`;\n\n[\"amd64\", \"arm64\", \"ppc64le\", \"riscv64\"].forEach((goarch) => {\n  [deb, rpm, tarball].forEach((method) =>\n    method.build({\n      name: \"anubis\",\n      description:\n        \"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.\",\n      homepage: \"https://anubis.techaro.lol\",\n      license: \"MIT\",\n      goarch,\n\n      documentation: {\n        \"./README.md\": \"README.md\",\n        \"./LICENSE\": \"LICENSE\",\n        \"./data/botPolicies.yaml\": \"botPolicies.yaml\",\n      },\n\n      build: ({ bin, etc, systemd, doc }) => {\n        $`go build -o ${bin}/anubis -ldflags '-s -w -extldflags \"-static\" -X \"github.com/TecharoHQ/anubis.Version=${git.tag()}\"' ./cmd/anubis`;\n        $`go build -o ${bin}/anubis-robots2policy -ldflags '-s -w -extldflags \"-static\" -X \"github.com/TecharoHQ/anubis.Version=${git.tag()}\"' ./cmd/robots2policy`;\n\n        file.install(\"./run/anubis@.service\", `${systemd}/anubis@.service`);\n        file.install(\"./run/default.env\", `${etc}/default.env`);\n\n        $`mkdir -p ${doc}/docs`;\n        $`cp -a docs/docs ${doc}`;\n        $`find ${doc} -name _category_.json -delete`;\n        $`mkdir -p ${doc}/data`;\n        $`cp -a data/apps ${doc}/data/apps`;\n        $`cp -a data/bots ${doc}/data/bots`;\n        $`cp -a data/clients ${doc}/data/clients`;\n        $`cp -a data/common ${doc}/data/common`;\n        $`cp -a data/crawlers ${doc}/data/crawlers`;\n        $`cp -a data/meta ${doc}/data/meta`;\n      },\n    }),\n  );\n});\n\n// NOTE(Xe): Fixes #217. This is a \"half baked\" tarball that includes the harder\n// parts for deterministic distros already done. Distributions like NixOS, Gentoo\n// and *BSD ports have a difficult time fitting the square peg of their dependency\n// model into the bazaar of round holes that various modern languages use. Needless\n// to say, this makes adoption easier.\ntarball.build({\n  name: \"anubis-src-vendor\",\n  license: \"MIT\",\n  // XXX(Xe): This is needed otherwise go will be very sad.\n  platform: yeet.goos,\n  goarch: yeet.goarch,\n\n  build: ({ out }) => {\n    // prepare clean checkout in $out\n    $`git archive --format=tar HEAD | tar xC ${out}`;\n    // vendor Go dependencies\n    $`cd ${out} && go mod vendor`;\n    // write VERSION file\n    $`echo ${git.tag()} > ${out}/VERSION`;\n  },\n\n  mkFilename: ({ name, version }) => `${name}-${version}`,\n});\n\ntarball.build({\n  name: \"anubis-src-vendor-npm\",\n  license: \"MIT\",\n  // XXX(Xe): This is needed otherwise go will be very sad.\n  platform: yeet.goos,\n  goarch: yeet.goarch,\n\n  build: ({ out }) => {\n    // prepare clean checkout in $out\n    $`git archive --format=tar HEAD | tar xC ${out}`;\n    // vendor Go dependencies\n    $`cd ${out} && go mod vendor`;\n    // build NPM-bound dependencies\n    $`cd ${out} && npm ci && npm run assets && rm -rf node_modules`;\n    // write VERSION file\n    $`echo ${git.tag()} > ${out}/VERSION`;\n  },\n\n  mkFilename: ({ name, version }) => `${name}-${version}`,\n});\n"
  }
]