+
4. Resulting Hash (SHA-256)
@@ -193,18 +267,30 @@ export default function App() {
{/* Mining Controls */}
{!isMining ? (
-
diff --git a/docs/blog/2025-08-28-cpu-core-odd/ProofOfWorkDiagram/styles.module.css b/docs/blog/2025-08-28-cpu-core-odd/ProofOfWorkDiagram/styles.module.css
index 419b40486..157f84027 100644
--- a/docs/blog/2025-08-28-cpu-core-odd/ProofOfWorkDiagram/styles.module.css
+++ b/docs/blog/2025-08-28-cpu-core-odd/ProofOfWorkDiagram/styles.module.css
@@ -48,7 +48,9 @@
background-color: rgb(31 41 55);
padding: 1.5rem;
border-radius: 0.5rem;
- box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
+ box-shadow:
+ 0 10px 15px -3px rgb(0 0 0 / 0.1),
+ 0 4px 6px -4px rgb(0 0 0 / 0.1);
height: 100%;
display: flex;
flex-direction: column;
@@ -158,7 +160,9 @@
.hashContainer {
padding: 1.5rem;
border-radius: 0.5rem;
- box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
+ box-shadow:
+ 0 10px 15px -3px rgb(0 0 0 / 0.1),
+ 0 4px 6px -4px rgb(0 0 0 / 0.1);
transition: all 300ms;
border: 2px solid;
}
diff --git a/docs/blog/2025-10-31-file-abuse-reports/goose-pond.webp b/docs/blog/2025-10-31-file-abuse-reports/goose-pond.webp
new file mode 100644
index 000000000..bbdef9b96
Binary files /dev/null and b/docs/blog/2025-10-31-file-abuse-reports/goose-pond.webp differ
diff --git a/docs/blog/2025-10-31-file-abuse-reports/index.mdx b/docs/blog/2025-10-31-file-abuse-reports/index.mdx
new file mode 100644
index 000000000..20c968f21
--- /dev/null
+++ b/docs/blog/2025-10-31-file-abuse-reports/index.mdx
@@ -0,0 +1,75 @@
+---
+slug: 2025/file-abuse-reports
+title: Taking steps to end abusive traffic from cloud providers
+description: "Learn how to effectively file abuse reports with cloud providers to stop malicious traffic at its source and protect your services from automated abuse."
+authors: [xe]
+tags: [abuse, cloud, security, networking]
+image: goose-pond.webp
+---
+
+
+
+As part of Anubis's ongoing development, I've been working to reduce friction for legitimate users by minimizing unnecessary challenge pages. While this improves the user experience, it can potentially expose services to increased abuse from public cloud infrastructure. To help administrators better protect their services, I want to share my strategies for filing abuse reports with IP space owners, enabling us to address malicious scraping at its source.
+
+{/* truncate */}
+
+In general, there are two kinds of IP addresses:
+
+- Residential IP addresses: IP addresses that are allocated to residential customers such as home internet connections and cellular data plans. These IP addresses are increasingly shared between customers due to technologies like [CGNAT](https://en.wikipedia.org/wiki/Carrier-grade_NAT).
+- Commercial IP addresses: IP addresses that are allocated to commercial customers such as cloud providers, VPS providers, root server providers, and other such business to business companies. These IP addresses are almost always statically allocated to one customer for a very long period of time (typically the lifetime of the server unless they are using things like dedicated IP addresses).
+
+In general, filing abuse reports to residential IP addresses is a waste of time. The administrators do appreciate knowing what kinds of abusive traffic is causing grief, but many times the users of those IP addresses don't know that their computer is sending abusive traffic to your services. A lot of malware botnets that used to be used with DDOS for hire services are now being used as residential proxies. Those "free VPN apps" are almost certainly making you pay for your usage by making your computer a zombie in a botnet. At some level I really respect the hustle as they manage to sell other people's bandwidth for rates as ludicrous as $1.00 per gigabyte ingressed and egressed.
+
+:::note
+
+Keep in mind, I'm talking about the things you can find by searching "free VPN", not infrastructure for the public good like the Tor browser or I2P.
+
+:::
+
+What you should really focus on is traffic from commercial IP addresses, such as cloud providers. That's a case where the cloud customer is in direct violation of the acceptable use policy of the provider. Filing abuse reports gets the abuse team of the cloud provider to reach out to that customer and demand corrective action under threat of contractual violence.
+
+## How to make an abuse report
+
+In general, the best abuse reports contain the following information:
+
+- Time of abusive requests.
+- IP address, User-Agent header, or other unique identifiers that can help the abuse team educate the customer about their misbehaving infrastructure.
+- Does the abusive IP address request robots.txt? If not, be sure to include that information.
+- A brief description of the impact to your system such as high system load, pages not rendering, or database system crashes. This helps the provider establish the fact that their customer is causing you measurable harm.
+- Context as to what your service is, what it does, and why they should care.
+
+For example, let's say that someone was giving the Anubis docs a series of requests that caused the server to fall over and experience extended downtime. Here's what I would write to the abuse contact:
+
+> Hello,
+>
+> I have received abusive traffic from one of your customers that has resulted in a denial of service to the users of the Anubis documentation website. Anubis is a web application firewall that administrators use to protect their websites against mass scraping and this documentation website helps administrators get started.
+>
+> On or about Thursday, October 30th at 04:00 UTC, A flurry of requests from the IP range `127.34.0.0/24` started to hit the `/admin/` routes, which caused unreasonable database load and ended up crashing PostgreSQL. This caused the documentation website to go down for three hours as it happened while the administrators were asleep. Based on logs, this caused 353 distinct users to not be able to load the documentation and the users filed bugs about it.
+>
+> I have attached the HTTP frontend logs for the abusive requests from your IP range. To protect our systems in the meantime while we perform additional hardening, I have blocked that IP address range in both our IP firewall and web application firewall configuration. Based on these logs, your customer seems to not have requested the standard `robots.txt` file, which includes instructions to deny access to those routes.
+>
+> Please let me know what other information you need on your end.
+>
+> Sincerely,
+>
+> [normal email signature]
+
+Then in order to figure out where to send it, look the IP addresses up in the `whois` database. For example, if you want to find the abuse contact for the IP address `1.1.1.1`, use the [whois command](https://packages.debian.org/sid/whois) to find the abuse contact:
+
+```
+$ whois 1.1.1.1 | grep -i abuse
+% Abuse contact for '1.1.1.0 - 1.1.1.255' is 'helpdesk@apnic.net'
+abuse-c: AA1412-AP
+remarks: All Cloudflare abuse reporting can be done via
+remarks: resolver-abuse@cloudflare.com
+abuse-mailbox: helpdesk@apnic.net
+role: ABUSE APNICRANDNETAU
+abuse-mailbox: helpdesk@apnic.net
+mnt-by: APNIC-ABUSE
+```
+
+The abuse contact will be named either `abuse-c` or `abuse-mailbox`. For greatest effect, I suggest including all listed email addresses in your email to the abuse contact.
+
+Once you send your email, you should expect a response within 2 business days at most. If they don't get back to you, please feel free to [contact me](https://xeiaso.net/contact/) so that the default set of Anubis rules can be edited according to patterns I'm seeing across the ecosystem.
+
+Just remember that many cloud providers do not know how bad the scraping problem is. Filing abuse complaints makes it their problem. They don't want it to be their problem.
diff --git a/docs/blog/2026-08-08-release-1.27.0/anubis-install.webp b/docs/blog/2026-08-08-release-1.27.0/anubis-install.webp
new file mode 100644
index 000000000..2b6b26f4d
Binary files /dev/null and b/docs/blog/2026-08-08-release-1.27.0/anubis-install.webp differ
diff --git a/docs/blog/2026-08-08-release-1.27.0/index.mdx b/docs/blog/2026-08-08-release-1.27.0/index.mdx
new file mode 100644
index 000000000..ac5156041
--- /dev/null
+++ b/docs/blog/2026-08-08-release-1.27.0/index.mdx
@@ -0,0 +1,78 @@
+---
+slug: release/v1.27.0
+title: Anubis v1.27.0 is now available!
+authors: [xe]
+tags: [release]
+image: la-noscea.webp
+---
+
+
+
+Anubis v1.27.0 (Moenbryda Wilfsunnwyn) is [now available](https://github.com/TecharoHQ/anubis/releases/tag/v1.27.0) via Docker and direct download from GitHub releases. This release adds Windows Server support, automatically renames cookies based on settings to avoid infinite challenge loops, adds two new localizations, and more.
+
+{/* truncate */}
+
+### Breaking change: cookie names are dynamically created based on cookie settings
+
+Anubis tries to avoid breaking changes as much as possible, but sometimes we have to make them for the sake of the users. This is technically a breaking change in something that is not part of the public API of Anubis; but some administrators rely heavily on cookie names in advanced configurations.
+
+It seems that browsers store cookies disambiguated with their options. This means you can have multiple cookies named the same but with different options. Browsers will send these cookies to the server without the list of options. This means that changing any cookie settings requires you to change `COOKIE_PREFIX`, creating a new "cookie epoch" that will set things properly.
+
+In order to be more robust, Anubis will automatically change cookie names based on the cookie settings. For example, the default configuration creates cookies named `techaro.lol-anubis-auth-347ddb4a`.
+
+Without this change, changing _any_ cookie setting without every client clearing their cookies causes challenges to become an infinite loop of thrashing, making it appear that Anubis "blocked" them.
+
+If this becomes onerous in practice for administrators of HAProxy and other advanced setups that rely on cookie names, we will add an escape hatch in the policy file.
+
+### Windows Server support (beta)
+
+Anubis now publishes `.msi` packages, allowing administrators to install and run Anubis on Windows Server. Please read the [Windows Server page](https://anubis.techaro.lol/docs/admin/environments/windows) for more information.
+
+This support is beta-grade as the Anubis team does not have a lot of experience with developing software for Windows Server. Feedback is more than welcome.
+
+Please let us know how it works for you!
+
+
+
+### Pre-release docker images no longer populate the `latest` tag
+
+Due to a misconfiguration of the GitHub Action [docker/metadata-action](https://github.com/docker/metadata-action), pre-release Docker images previously populated the `:latest` tag. This means that administrators that expected the `:latest` tag to result in a stable release of Anubis got a prerelease version suddenly when they ran automatic updates.
+
+If administrators want to opt-in to the prerelease build track of Anubis for more frequent access to new features, they can use the `:pre` tag:
+
+```yaml
+image: ghcr.io/techarohq/anubis:pre
+```
+
+### Features
+
+- Allow importing bot policy rules [using wildcard matching](https://anubis.techaro.lol/docs/admin/configuration/import#importing-many-files-at-once) ([#1815](https://github.com/TecharoHQ/anubis/issues/1815)).
+- Add support for JSON sources to iplist2rule ([#1833](https://github.com/TecharoHQ/anubis/pull/1833))
+- Make the honeypot feature log detected addresses to the disk every minute when `honeypot.ip_log_file` is set. See the [IP address logging](https://anubis.techaro.lol/docs/admin/honeypot/overview#ip-address-logging) section for more information.
+- Add standard library rules for making Dillo less threatening to Anubis and [document how to enable them](https://anubis.techaro.lol/docs/admin/faq#how-do-i-allow-small-internet-browsers-like-dillo-netsurf-and-pale-moon-to-bypass-anubis).
+
+#### Crawlers
+
+- Allow [Arquivo.pt](https://arquivo.pt), the Portuguese web archive, by default via its crawling network.
+- Add `(data)/bots/lyrenth.yaml` snippet that denies [Lyrenth](https://lyrenth.com/bot)'s `AIWebIndex` crawler and `AIWebIndex-Agent` on-demand fetcher by user agent and by their [published IP ranges](https://www.lyrenth.com/bot/ip-ranges.json). This is imported by `(data)/bots/_deny-pathological.yaml`.
+- Updates Alibaba cloud IP list ([#1813](https://github.com/TecharoHQ/anubis/pull/1813))
+- Updates Googlebot IP list ([#1812](https://github.com/TecharoHQ/anubis/pull/1812))
+- Updates IP list for DuckDuckBot ([#1810](https://github.com/TecharoHQ/anubis/pull/1810))
+- Update Huawei Cloud IP list ([#1814](https://github.com/TecharoHQ/anubis/pull/1814))
+
+### Fixes
+
+- Fix bot policy imports to not require pedantically correct YAML formatting when using wildcard matching.
+- JavaScript served by the `fast` challenge is loaded using [`defer`](https://www.w3schools.com/tags/att_script_defer.asp) instead of `async` ([#1782](https://github.com/TecharoHQ/anubis/issues/1782)).
+- Amend default [Lightpanda](https://lightpanda.io/) rule to match current behaviour, add smoke test to ensure it keeps working ([#1822](https://github.com/TecharoHQ/anubis/issues/1822)).
+- Fix a panic when a request asks for the undetermined language tag, such as `Accept-Language: und` ([#1776](https://github.com/TecharoHQ/anubis/issues/1776)).
+- Allow user agents that start with capital-G `Git` in `(data)/clients/git.yaml`.
+- Enabled the Partitioned flag on cookies by default ([#1701](https://github.com/TecharoHQ/anubis/issues/1701)).
+- Fix Windows MSI builds on prerelease tags such as `v1.27.0-pre1`.
+- Bump AI-robots.txt to version 1.47.
+
+### i18n
+
+- Add Basque (eu) localization.
+- Update Bulgarian locale ([#1708](https://github.com/TecharoHQ/anubis/pull/1708))
+- Add Croatian (hr) localization.
diff --git a/docs/blog/2026-08-08-release-1.27.0/la-noscea.webp b/docs/blog/2026-08-08-release-1.27.0/la-noscea.webp
new file mode 100644
index 000000000..6304fdf30
Binary files /dev/null and b/docs/blog/2026-08-08-release-1.27.0/la-noscea.webp differ
diff --git a/docs/blog/2026-09-06-anubis-wasm/chromesweep.webp b/docs/blog/2026-09-06-anubis-wasm/chromesweep.webp
new file mode 100644
index 000000000..c0a71db7b
Binary files /dev/null and b/docs/blog/2026-09-06-anubis-wasm/chromesweep.webp differ
diff --git a/docs/blog/2026-09-06-anubis-wasm/chrystarium.webp b/docs/blog/2026-09-06-anubis-wasm/chrystarium.webp
new file mode 100644
index 000000000..fb8d26c12
Binary files /dev/null and b/docs/blog/2026-09-06-anubis-wasm/chrystarium.webp differ
diff --git a/docs/blog/2026-09-06-anubis-wasm/diagrams/DifficultyVisualizer/index.jsx b/docs/blog/2026-09-06-anubis-wasm/diagrams/DifficultyVisualizer/index.jsx
new file mode 100644
index 000000000..832facb02
--- /dev/null
+++ b/docs/blog/2026-09-06-anubis-wasm/diagrams/DifficultyVisualizer/index.jsx
@@ -0,0 +1,320 @@
+import { useEffect, useRef, useState } from "react";
+import clsx from "clsx";
+import styles from "./styles.module.css";
+
+// The diagram shows a 64 bit hash rather than a full SHA-256 digest. Real
+// hashes are 256 bits, but 64 of them fit on a phone screen and the point
+// about bits versus nibbles is the same either way.
+const HASH_BYTES = 8;
+const HEX_CHARS = HASH_BYTES * 2;
+const HASH_BITS = HASH_BYTES * 8;
+
+// Each row of the diagram covers four hex characters, so 16 bits.
+const HEX_PER_CHUNK = 4;
+const BITS_PER_CHUNK = HEX_PER_CHUNK * 4;
+const CHUNKS = HEX_CHARS / HEX_PER_CHUNK;
+
+// A fixed starting hash so the server-rendered markup matches the first
+// client render. Mining replaces it with random hashes.
+const INITIAL_HEX = "f3a2b1c4d5e6f7a8";
+const INITIAL_BIN = hexToBin(INITIAL_HEX);
+
+// Number of hashes to try per animation frame. High enough to feel like
+// mining, low enough to keep the page responsive.
+const HASHES_PER_FRAME = 200;
+
+// How often the hash display is allowed to change when the reader has asked
+// for reduced motion. Mining still runs every frame; only the repaint slows
+// down, which turns a 60fps strobe into a readable ticker.
+const REDUCED_MOTION_PAINT_MS = 400;
+
+// Converts a hex string into its binary expansion, four bits per character.
+function hexToBin(hex) {
+ return hex
+ .split("")
+ .map((c) => parseInt(c, 16).toString(2).padStart(4, "0"))
+ .join("");
+}
+
+// Generates a random hash value in both hex and binary form.
+function generateRandomHash() {
+ const buffer = new Uint8Array(HASH_BYTES);
+ crypto.getRandomValues(buffer);
+ const hex = Array.from(buffer)
+ .map((byte) => byte.toString(16).padStart(2, "0"))
+ .join("");
+ return { hex, bin: hexToBin(hex) };
+}
+
+// Reports whether a hash clears the target: `difficulty` leading zero bits in
+// bit mode, or `difficulty` leading zero hex characters in nibble mode.
+function meetsTarget(mode, difficulty, hex, bin) {
+ const prefix = "0".repeat(difficulty);
+ return mode === "bit" ? bin.startsWith(prefix) : hex.startsWith(prefix);
+}
+
+// Tracks the reader's reduced motion preference. Starts false so the server
+// and the first client render agree, then corrects itself on mount.
+function usePrefersReducedMotion() {
+ const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);
+
+ useEffect(() => {
+ const query = window.matchMedia("(prefers-reduced-motion: reduce)");
+ setPrefersReducedMotion(query.matches);
+
+ const handleChange = (event) => setPrefersReducedMotion(event.matches);
+ query.addEventListener("change", handleChange);
+ return () => query.removeEventListener("change", handleChange);
+ }, []);
+
+ return prefersReducedMotion;
+}
+
+export default function DifficultyVisualizer() {
+ const [mode, setMode] = useState("nibble");
+ const [difficulty, setDifficulty] = useState(1);
+ const [nonce, setNonce] = useState(0);
+ const [hashHex, setHashHex] = useState(INITIAL_HEX);
+ const [hashBin, setHashBin] = useState(INITIAL_BIN);
+ const [isMining, setIsMining] = useState(false);
+ const frameRef = useRef(null);
+ const untalliedRef = useRef(0);
+ const lastPaintRef = useRef(0);
+ const prefersReducedMotion = usePrefersReducedMotion();
+
+ const maxDifficulty = mode === "bit" ? HASH_BITS : HEX_CHARS;
+ const isSolved = nonce > 0 && meetsTarget(mode, difficulty, hashHex, hashBin);
+ const isSlow =
+ (mode === "bit" && difficulty > 20) ||
+ (mode === "nibble" && difficulty > 5);
+
+ // Drives the mining loop. Reruns whenever the target changes so the loop
+ // always tests against the current mode and difficulty.
+ useEffect(() => {
+ if (!isMining) return;
+
+ const mineStep = () => {
+ let found = false;
+ let latest = null;
+
+ for (let i = 0; i < HASHES_PER_FRAME; i++) {
+ untalliedRef.current++;
+ latest = generateRandomHash();
+ if (meetsTarget(mode, difficulty, latest.hex, latest.bin)) {
+ found = true;
+ break;
+ }
+ }
+
+ // Under reduced motion, keep hashing at full speed but let the display
+ // settle between updates. The winning hash always paints immediately.
+ const now = performance.now();
+ const shouldPaint =
+ found ||
+ !prefersReducedMotion ||
+ now - lastPaintRef.current >= REDUCED_MOTION_PAINT_MS;
+
+ if (shouldPaint) {
+ const tried = untalliedRef.current;
+ untalliedRef.current = 0;
+ lastPaintRef.current = now;
+ setNonce((prev) => prev + tried);
+ setHashHex(latest.hex);
+ setHashBin(latest.bin);
+ }
+
+ if (found) {
+ setIsMining(false);
+ } else {
+ frameRef.current = requestAnimationFrame(mineStep);
+ }
+ };
+
+ frameRef.current = requestAnimationFrame(mineStep);
+
+ return () => {
+ if (frameRef.current !== null) {
+ cancelAnimationFrame(frameRef.current);
+ frameRef.current = null;
+ }
+
+ // Stopping between throttled repaints would otherwise drop the hashes
+ // tried since the last one, leaving the nonce reading low.
+ if (untalliedRef.current > 0) {
+ const tried = untalliedRef.current;
+ untalliedRef.current = 0;
+ setNonce((prev) => prev + tried);
+ }
+ };
+ }, [isMining, mode, difficulty, prefersReducedMotion]);
+
+ const handleModeChange = (nextMode) => {
+ setMode(nextMode);
+ setDifficulty(1);
+ setIsMining(false);
+ };
+
+ const handleDifficultyChange = (event) => {
+ setDifficulty(Number(event.target.value));
+ setIsMining(false);
+ };
+
+ const handleMineClick = () => {
+ setNonce(0);
+ untalliedRef.current = 0;
+ lastPaintRef.current = 0;
+ setIsMining(true);
+ };
+
+ const handleStopClick = () => {
+ setIsMining(false);
+ };
+
+ // Renders one slice of the hash: a few hex characters with the bits they
+ // encode lined up directly underneath them.
+ const renderChunk = (chunkIndex) => {
+ const hexOffset = chunkIndex * HEX_PER_CHUNK;
+ const binOffset = chunkIndex * BITS_PER_CHUNK;
+ const hexChunk = hashHex.slice(hexOffset, hexOffset + HEX_PER_CHUNK);
+ const binChunk = hashBin.slice(binOffset, binOffset + BITS_PER_CHUNK);
+
+ return (
+
+ {chunkIndex === 0 && (
+
Hexadecimal
+ )}
+
+ {hexChunk.split("").map((char, localIndex) => {
+ const index = hexOffset + localIndex;
+ const isTarget = mode === "nibble" && index < difficulty;
+ return (
+
+ {char}
+
+ );
+ })}
+
+
+ {chunkIndex === 0 && (
+
Binary ({HASH_BITS} bits)
+ )}
+
+ {binChunk.split("").map((bit, localIndex) => {
+ const index = binOffset + localIndex;
+ const isBitTarget = mode === "bit" && index < difficulty;
+ const isNibbleTarget =
+ mode === "nibble" && Math.floor(index / 4) < difficulty;
+ const isZero = bit === "0";
+
+ let state = null;
+ if (isBitTarget) {
+ state = isZero ? styles.cellOk : styles.cellBad;
+ } else if (isNibbleTarget) {
+ state = isZero ? styles.cellHit : styles.cellBad;
+ }
+
+ return (
+
+ {bit}
+
+ );
+ })}
+
+
+ );
+ };
+
+ return (
+
+ {/* Controls */}
+
+
+
Measurement mode
+
+ handleModeChange("nibble")}
+ className={clsx(
+ styles.modeButton,
+ mode === "nibble" && styles.modeButtonActiveHit,
+ )}
+ >
+ Nibbles (hex)
+
+ handleModeChange("bit")}
+ className={clsx(
+ styles.modeButton,
+ mode === "bit" && styles.modeButtonActiveOk,
+ )}
+ >
+ Bits (binary)
+
+
+
+
+
+
+
+
+
+ {mode === "nibble" ? difficulty * 4 : difficulty} / {HASH_BITS}{" "}
+ leading zero bits
+
+ {isSlow &&
Slow
}
+
+
+
+
+ {/* Mining dashboard */}
+
+ {!isMining && isSolved &&
}
+
+
+
+ Nonce:
+ {nonce.toLocaleString()}
+
+
+ {isMining ? "Stop mining" : "Start mining"}
+
+
+
+
+ {Array.from({ length: CHUNKS }, (_, i) => renderChunk(i))}
+
+
+
+ );
+}
diff --git a/docs/blog/2026-09-06-anubis-wasm/diagrams/DifficultyVisualizer/styles.module.css b/docs/blog/2026-09-06-anubis-wasm/diagrams/DifficultyVisualizer/styles.module.css
new file mode 100644
index 000000000..69760fa10
--- /dev/null
+++ b/docs/blog/2026-09-06-anubis-wasm/diagrams/DifficultyVisualizer/styles.module.css
@@ -0,0 +1,370 @@
+/* Theme tokens. Light values are the default, dark values override below. */
+.container {
+ --dv-surface: var(--ifm-background-surface-color);
+ --dv-raised: var(--ifm-color-emphasis-100);
+ --dv-sunken: var(--ifm-color-emphasis-200);
+ --dv-border: var(--ifm-color-emphasis-300);
+ --dv-muted: var(--ifm-color-emphasis-700);
+
+ --dv-hit-text: var(--ifm-color-primary-dark);
+ --dv-hit-border: var(--ifm-color-primary);
+ --dv-hit-bg: rgb(255 86 48 / 0.12);
+
+ --dv-ok-text: rgb(21 128 61);
+ --dv-ok-border: rgb(34 197 94);
+ --dv-ok-bg: rgb(34 197 94 / 0.12);
+
+ --dv-bad-text: rgb(185 28 28);
+ --dv-bad-border: rgb(239 68 68);
+ --dv-bad-bg: rgb(239 68 68 / 0.12);
+}
+
+[data-theme="dark"] .container {
+ --dv-hit-text: var(--ifm-color-primary-lighter);
+ --dv-hit-bg: rgb(255 86 48 / 0.18);
+
+ --dv-ok-text: rgb(74 222 128);
+ --dv-ok-bg: rgb(34 197 94 / 0.18);
+
+ --dv-bad-text: rgb(248 113 113);
+ --dv-bad-bg: rgb(239 68 68 / 0.18);
+}
+
+/* Main container styles */
+.container {
+ width: 100%;
+ max-width: 40rem;
+ margin: 1.5rem auto;
+ overflow: hidden;
+ color: var(--ifm-font-color-base);
+ background-color: var(--dv-raised);
+ border: 1px solid var(--dv-border);
+ border-radius: var(--ifm-global-radius);
+}
+
+/* Control panel styles */
+.controls {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+ padding: 0.75rem;
+ border-bottom: 1px solid var(--dv-border);
+}
+
+.controlGroup {
+ flex: 1;
+ min-width: 0;
+}
+
+.label {
+ display: block;
+ font-size: 0.8125rem;
+ font-weight: 600;
+ margin-bottom: 0.375rem;
+}
+
+.labelRow {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.5rem;
+}
+
+.difficultyValue {
+ font-family: var(--ifm-font-family-monospace);
+ font-weight: 400;
+ padding: 0.125rem 0.375rem;
+ background-color: var(--dv-sunken);
+ border-radius: var(--ifm-global-radius);
+}
+
+/* Mode switch styles */
+.modeButtons {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.375rem;
+}
+
+.modeButton {
+ font: inherit;
+ font-size: 0.875rem;
+ font-weight: 600;
+ padding: 0.375rem 0.75rem;
+ color: var(--ifm-font-color-base);
+ background-color: var(--dv-sunken);
+ border: 1px solid transparent;
+ border-radius: var(--ifm-global-radius);
+ cursor: pointer;
+ transition:
+ background-color 200ms,
+ color 200ms;
+}
+
+.modeButton:hover {
+ background-color: var(--dv-border);
+}
+
+.modeButtonActiveHit,
+.modeButtonActiveHit:hover {
+ color: var(--ifm-button-color);
+ background-color: var(--ifm-color-primary);
+}
+
+.modeButtonActiveOk,
+.modeButtonActiveOk:hover {
+ color: var(--ifm-button-color);
+ background-color: rgb(21 128 61);
+}
+
+/* Difficulty slider styles */
+.slider {
+ display: block;
+ width: 100%;
+ accent-color: var(--ifm-color-primary);
+ cursor: pointer;
+}
+
+.hintRow {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 0.5rem;
+ margin-top: 0.25rem;
+}
+
+.hint {
+ font-size: 0.75rem;
+ font-family: var(--ifm-font-family-monospace);
+ color: var(--dv-muted);
+ margin-bottom: 0;
+}
+
+.warning {
+ font-size: 0.75rem;
+ font-weight: 600;
+ color: var(--ifm-color-warning-darkest);
+ margin-bottom: 0;
+ animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
+}
+
+[data-theme="dark"] .warning {
+ color: var(--ifm-color-warning);
+}
+
+/* Mining dashboard styles */
+.dashboard {
+ position: relative;
+ overflow: hidden;
+ padding: 0.75rem;
+}
+
+.successGlow {
+ position: absolute;
+ inset: 0;
+ background-color: var(--dv-ok-bg);
+ pointer-events: none;
+ animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
+}
+
+.statusRow {
+ position: relative;
+ z-index: 1;
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.5rem;
+ margin-bottom: 0.75rem;
+}
+
+.nonce {
+ font-family: var(--ifm-font-family-monospace);
+ font-size: 0.8125rem;
+ color: var(--dv-muted);
+ padding: 0.375rem 0.625rem;
+ background-color: var(--dv-surface);
+ border: 1px solid var(--dv-border);
+ border-radius: var(--ifm-global-radius);
+}
+
+.nonceValue {
+ color: var(--ifm-font-color-base);
+ margin-left: 0.375rem;
+}
+
+.mineButton {
+ font: inherit;
+ font-size: 0.875rem;
+ font-weight: 700;
+ padding: 0.5rem 1rem;
+ color: var(--ifm-button-color);
+ background-color: var(--ifm-color-primary);
+ border: 1px solid transparent;
+ border-radius: var(--ifm-global-radius);
+ cursor: pointer;
+ transition: background-color 200ms;
+}
+
+.mineButton:hover {
+ background-color: var(--ifm-color-primary-dark);
+}
+
+.mineButtonMining,
+.mineButtonMining:hover {
+ color: var(--dv-bad-text);
+ background-color: var(--dv-bad-bg);
+ border-color: var(--dv-bad-border);
+}
+
+/* Hash display styles */
+.hashGrid {
+ position: relative;
+ z-index: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ max-width: 32rem;
+ margin: 0 auto;
+}
+
+.chunk {
+ padding: 0.5rem;
+ background-color: var(--dv-surface);
+ border: 1px solid var(--dv-border);
+ border-radius: var(--ifm-global-radius);
+}
+
+.sectionLabel {
+ font-size: 0.6875rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: var(--dv-muted);
+ margin-bottom: 0.25rem;
+}
+
+/* Both rows share one 16 column grid, and each hex cell spans four columns.
+ That puts every hex character directly above the four bits it encodes
+ without needing spacer elements. */
+.hexRow,
+.binRow {
+ display: grid;
+ grid-template-columns: repeat(16, minmax(0, 1fr));
+ gap: 2px;
+ font-family: var(--ifm-font-family-monospace);
+}
+
+.hexRow {
+ margin-bottom: 0.5rem;
+ font-size: 1.125rem;
+}
+
+.hexCell {
+ grid-column: span 4;
+ height: 2.25rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--dv-muted);
+ background-color: var(--dv-sunken);
+ border: 1px solid var(--dv-border);
+ border-radius: var(--ifm-global-radius);
+ transition: all 200ms;
+}
+
+.binRow {
+ font-size: 0.75rem;
+}
+
+.binCell {
+ height: 1.75rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--dv-muted);
+ background-color: var(--dv-sunken);
+ border-bottom: 2px solid var(--dv-border);
+ transition: all 200ms;
+}
+
+/* Cell highlight states */
+.cellHit {
+ color: var(--dv-hit-text);
+ background-color: var(--dv-hit-bg);
+ border-color: var(--dv-hit-border);
+}
+
+.cellOk {
+ color: var(--dv-ok-text);
+ background-color: var(--dv-ok-bg);
+ border-color: var(--dv-ok-border);
+}
+
+.cellBad {
+ color: var(--dv-bad-text);
+ background-color: var(--dv-bad-bg);
+ border-color: var(--dv-bad-border);
+}
+
+/* Glow animation */
+@keyframes pulse {
+ 0%,
+ 100% {
+ opacity: 1;
+ }
+ 50% {
+ opacity: 0.5;
+ }
+}
+
+/* Responsive styles */
+@media (min-width: 768px) {
+ .controls {
+ flex-direction: row;
+ gap: 1rem;
+ }
+}
+
+/* Every nested padding costs the bit grid 16 columns worth of width, so trim
+ the padding and the type together on small screens. */
+@media (max-width: 480px) {
+ .controls,
+ .dashboard {
+ padding: 0.5rem;
+ }
+
+ .chunk {
+ padding: 0.375rem;
+ }
+
+ .hexRow {
+ font-size: 1rem;
+ }
+
+ .hexCell {
+ height: 2rem;
+ }
+
+ .binRow {
+ font-size: 0.625rem;
+ }
+
+ .binCell {
+ height: 1.5rem;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .warning,
+ .successGlow {
+ animation: none;
+ }
+
+ .modeButton,
+ .mineButton,
+ .hexCell,
+ .binCell {
+ transition: none;
+ }
+}
diff --git a/docs/blog/2026-09-06-anubis-wasm/index.mdx b/docs/blog/2026-09-06-anubis-wasm/index.mdx
new file mode 100644
index 000000000..059b06279
--- /dev/null
+++ b/docs/blog/2026-09-06-anubis-wasm/index.mdx
@@ -0,0 +1,362 @@
+---
+slug: 2026/anubis-wasm
+title: It took a year to ship WebAssembly in Anubis
+authors: [xe]
+tags: [wasm, engineering]
+image: chrystarium.webp
+---
+
+
+
+After a year of work, hundreds of commits, 5 generations of pull requests, dozens of tests, rewriting part of Anubis in Rust, the first compiler bug of my career, and at least three times making my tower run out of ram I think I have finally done it. The next version of Anubis will ship with [WebAssembly-based proof of work checks](https://anubis.techaro.lol/docs/admin/configuration/challenges/wasm) that admins can enable in their thresholds or bot rules:
+
+{/* truncate */}
+
+```yaml
+thresholds:
+ - name: moderate-suspicion
+ expression:
+ all:
+ - weight >= 10
+ - weight < 20
+ action: CHALLENGE
+ challenge:
+ algorithm: argon2id
+ difficulty: 6
+```
+
+This makes Anubis challenges use a [memory-hard](https://en.wikipedia.org/wiki/Memory-hard_function) proof of work function ([argon2id](https://en.wikipedia.org/wiki/Argon2)) instead of just a CPU hard one. It also means that the "hey Claude vibeslop me a CUDA Anubis solver" route is on its way to being fundamentally dead. Overall, this project taught me a lot of things about how WebAssembly works in practice, what the rough edges are when operating on the bleeding edge like I am, and led to the first genuine compiler bug of my career.
+
+Today I want to take you through the journey and process behind adding these WebAssembly based proof of work checks and all the problems that came up along the way.
+
+## Why use WebAssembly in the first place?
+
+The big impetus behind wanting to use WebAssembly in Anubis is that there's a constant tension that's underlined a lot of the performance work I've been doing: phone CPUs suck and trying to balance equitability to phone CPUs with trying to punish scraper CPUs is a huge challenge.
+
+:::note Aside
+
+To be clear: I am tackling the "Anubis makes my phone overheat" problem. It's hard to just lower the difficulty for phones without giving attackers enough information to lower the difficulty for scrapers. If you want to contribute performance data so that I can improve my classification process and/or target future development, please [get in touch with me](https://xeiaso.net/contact/). I want to make things better but can't without data. I am actively testing with a [Moto G8 Power](https://www.gsmarena.com/motorola_moto_g8_power-10052.php) alongside my normal testing steps.
+
+:::
+
+Using WebAssembly here means that the binary will run faster, which will make Anubis _go away_ faster, which is what we all want, right?
+
+### The same binary runs on both the client and the server
+
+One of the other big advantages of this setup is that it lets clients and servers run the same binary in order to solve and validate challenges. Note that I didn't say the same code, I said the same _binary_. This means that the client and server are in lockstep, much like the [CIC chip in the NES](
). This also does mean that a bug in the shared code means that the client could send an invalid value that the server would accept as valid, but I don't think this is a practical concern until something like that happens.
+
+Right now the [`fast` challenge](https://anubis.techaro.lol/docs/admin/configuration/challenges/proof-of-work) has two implementations in Anubis: one [in JavaScript](https://github.com/TecharoHQ/anubis/blob/main/web/js/worker/sha256.ts) and the other [in Go](https://github.com/TecharoHQ/anubis/blob/main/lib/challenge/proofofwork/proofofwork.go). Updating it in one place means making the same updates in other places and also making sure that everywhere else that assumes how challenges work is also updated.
+
+:::note Aside
+
+Yeah, it's probably bad to have made those assumptions about how challenges work across the codebase. There's a lot of weird technical debt here that needs to be solved at some point. I expect that to be a fair bit of effort to untangle, it may end up with keeping the `fast` challenge only for tests when the WebAssembly based routes are working out well enough to make the default/only option.
+
+:::
+
+Making the same binary run on both the client and the server means that future experiments like per-client program synthesis can also proceed. I'll get more into that sometime in the future.
+
+### This lets us sneak Rust into Anubis
+
+Right now the core of Anubis is written in Go. Go's standard library HTTP server is surprisingly performant and is the key component of things like Google's HTTP frontend service. I have no intention to rewrite Anubis in Rust or anything as drastic as that.
+
+However breaking up Anubis from one monolithic binary into what amounts to a plugin loader is a lot more interesting from a maintenance standpoint. One of the main problems with Anubis is that the combination of rules I've set for myself (no CGo allowed and everything must cross compile to prod from my MacBook) means that updating any part of Anubis means recompiling all of Anubis across prod. Making Anubis be _able_ to have parts of it downloaded and swapped out at runtime is really interesting to me because it means being able to adapt to threats _as soon as the threat actors change behaviour_.
+
+Also I figured out pretty quickly that Rust builds some of the smallest WebAssembly binaries that run hilariously fast, so I wrote all the WebAssembly code in Rust (no-std, wasm32-unknown-unknown)\!
+
+### Finally fixing the challenge difficulty scaling issue
+
+Along the way this also lets me fix one of the bigger administrative problems with Anubis: challenges scale incorrectly with the `fast` challenge. Anubis uses string comparison to count the number of leading zero _nibbles_ in a hash instead of the number of leading zero _bits_. This means that adding one (1) to the difficulty of a challenge makes it 16 (sixteen) times as hard to solve in the worst case.
+
+Here's an interactive diagram to help you understand the difference in scaling at play:
+
+import DiffViz from "./diagrams/DifficultyVisualizer";
+
+
+
+This was a mistake in retrospect, but if we're changing details about how challenges work here then we may as well wrap up the fix into that.
+
+## How you normally add WebAssembly to something like this
+
+In a normal world you'd not have about half of the restrictions that I have when working with Anubis. Normally you just write your code, compile it to WebAssembly, and then make sure it runs on modern browsers. This is nice and simple. I wish I could live in this world.
+
+Comparatively, this is what it's like getting all of this working across browser versions, platforms, and so many other things:
+
+
+
+Anubis supports Chrome 75 and newer. I wanted to reduce the support range to not have to deal with Chrome that old (the feature difference between Chrome 155 and Chrome 75 is [absolutely massive](https://caniuse.com/?compare=chrome+75,chrome+155&compareCats=all)), but there are a lot of smartphones, smart TVs, and other electronic devices running Android out there that are just marooned on Chrome that old with _no path to upgrade it_. As a result, I gotta target Chrome 75 for features, but out of an abundance of caution and to make sure that things are generally compatible with browsers older than Chrome 75 (eg: old iOS releases), I target my JavaScript to Chrome 66 as an abundance of caution.
+
+I haven't been able to test with iOS or Android versions of the same vintage as Chrome 75, so a lot of this is aspirational backwards compatibility. I sure hope it's good enough\!
+
+### The host -> guest WASM ABI
+
+When you're doing this kind of work, you're effectively writing an operating system kernel that makes the WebAssembly guests run as processes. Any calls you pass to the WebAssembly guest are effectively system calls into the host. Normally I'd love to be able to reuse the [WebAssembly Component Model](https://component-model.bytecodealliance.org/introduction.html) so that a lot of the "hard part" is done for me. WebAssembly Components make you define your calls in packages that contain interfaces with functions in worlds.
+
+Here's an example of the kind of WebAssembly Interface Types world Anubis would need:
+
+```wit
+// theoretical/wit/proof-of-work.wit
+package techaro:anubis;
+
+interface update-ui {
+ // update the UI with the current nonce every 1024 iterations.
+ update-nonce: func(u32);
+}
+
+interface proof-of-work {
+ // possible ways the computation can go wrong.
+ enum error {
+ no-error,
+ input-too-big,
+ cant-find-solution,
+ computed-different-result,
+ }
+
+ // write up to 4096 bytes of challenge data to the challenge buffer,
+ // if the user tries to write more then throw an error.
+ write-data: func(data: list) -> result;
+
+ // read the generated result
+ read-result: func() -> list;
+
+ // write to the verification buffer, if the user tries to write more
+ // than the buffer supports throw an error.
+ write-verification: func(data: list) -> error;
+
+ // grind at the right hash in a loop and return the nonce that passes
+ // validation to send to the server.
+ anubis-work: func(difficulty: u32, initial-nonce: u32, difficulty: u32, iterand: u32) -> result;
+
+ // validate that running the proof of work function on the data buffer
+ // with the given nonce results in the same output that the client
+ // calculated.
+ anubis-validate: func(difficulty: u32, nonce: u32) -> result;
+}
+
+world anubis {
+ import update-ui;
+ export proof-of-work;
+}
+```
+
+If this worked (I wrote this all out in one go based on how the current handcrafted ABI works and have not tested it, sorry), this would describe the shape of the API that we need for doing the proof of work operations. At the time I wrote the API/ABI that Anubis uses, the main tool for generating the server-side bindings for WebAssembly Component Model stuff in Go [gravity](https://github.com/arcjet/gravity) didn't support passing records (structs) or bytestrings (`list` or `Vec` / `[]byte`) from the host to the guest. As such, I had to do it by hand.
+
+### API design
+
+So given that we can't do it the "right way", we have to do it the "bad way". Also given that no matter what I pick for this I'm going to be "wrong", I just decided to treat the WebAssembly modules as dynamic libraries that just happen to use WebAssembly as an implementation detail. There only needs to be three buffers that are read from / written to, so let's just focus around those:
+
+1. The data buffer: up to 4096 bytes of challenge data. This is 4096 bytes so that it can (hopefully) land in its own 4Ki machine page. This is the only variable-length buffer in the setup, so it needs two calls:
+ 1. `data_ptr() -> *const u8`: return the pointer to the data buffer in WASM linear memory. This is used as the base address for copying data into the guest.
+ 2. `set_data_length(len: u32)`: update the globally mutable "data length" variable to signal to the guest how much data was actually written into the data buffer. The combination of these two calls lets you treat that global data buffer as a slice.
+2. The result buffer: a challenge-defined buffer that usually has about 32 bytes of data. This is read out of the guest's linear memory when the challenge is done processing.
+ 1. `result_hash_ptr() -> *const u8`: return the pointer to the result buffer in WASM linear memory. This is used as the base address for reading out of the guest.
+ 2. `result_hash_size() -> usize`: return the length of the result buffer (a compile time constant based on the needs of the challenge, but the runtime can't know that).
+3. The verification buffer: a challenge-defined buffer that usually has about 32 bytes of data. This is written into when the server is validating a challenge.
+ 1. `verification_hash_ptr() -> *const u8`: return the pointer to the result buffer in WASM linear memory. This is used as the base address for reading out of the guest.
+ 2. `verification_hash_size() -> usize`: return the length of the result buffer (a compile time constant based on the needs of the challenge, but the runtime can't know that).
+
+Challenge modules also expose two entrypoints:
+
+1. `anubis_work`: the main entrypoint for browsers. Given the data loaded into the challenge buffer, hash it in a tight loop until you get a solution that matches the difficulty.
+2. `anubis_verify`: the main entrypoint for the server. Given the data loaded into the challenge and verification buffers, ensuring that one run through the hashing function produces a result that both meets the difficulty demands and exactly matches what the client sent.
+
+Challenge modules also import `anubis.anubis_update_nonce` from the environment so that they can periodically report their hash rate back to the frontend. This allows users to see a progress bar based on how long it should take to finish the process.
+
+This works enough for now. It'll be interesting to see how this falls short in the real world\!
+
+## The ride never ends
+
+Honestly, the first bit of this took a few days at most. Most of the hard work was making sure that pointers, offsets, and whatnot were all wired up correctly so that the browser worked the same way as the server. My experience building and messing around with [many](https://github.com/Xe/olin) [WebAssembly](https://github.com/Xe/pahi) [runtimes](https://xeiaso.net/talks/unix-philosophy-logical-extreme-wasm/) and [egregious hacks](https://xeiaso.net/talks/wazero-lightning-2023/) meant that making it was really easy for me.
+
+The devil came out in the details. Here are all the things that came up while I was working on this. All of these problems added up is the sole reason that this took a year instead of a week.
+
+### SIMD means speed in my dispatch
+
+Early on in development I found out that WebAssembly has a [SIMD extension](https://developer.mozilla.org/en-US/docs/WebAssembly/Reference/SIMD). SIMD stands for [Single Instruction Multiple Data](https://en.wikipedia.org/wiki/Single_instruction,_multiple_data) and is a family of instructions that let you do operations on multiple values at a time. This gives programmers data-level parallelism (this is distinct from multi-threading) so that things like hash calculations and MP3 decoding can be done faster than they would be if each operation had to be its own instruction.
+
+[CanIUse considers WebAssembly SIMD to be "baseline"](https://caniuse.com/wasm-simd) (supported by browsers newer than Chrome 91), but I have a lower version bound of Chrome 75\. However the benefits from SIMD on mobile devices are _so drastic_ that it's worth having two builds of the WebAssembly code: one with SIMD and one without it. In the browser it dispatches which version to use by using [wasm-feature-detect](https://www.npmjs.com/package/wasm-feature-detect) to probe WebAssembly functionality by trying to parse/run trivial minimal programs that exercise those features.
+
+Hopefully I don't need to add [an additional build](https://github.com/TecharoHQ/anubis/blob/4578023de7b631537e3a43d89b1998e802beb7e0/web/js/algorithms/wasm.ts#L58-L62) into this process, but there is nothing in the tooling that would prevent it\!
+
+### The birth and death of Yavascript
+
+One of the big things that blocked this shipping for so long was not having an escape hatch of some kind to allow clients that disable WebAssembly by policy to get through the gate. In my experience most clients don't have JavaScript enabled but WebAssembly disabled, however there are a few notable usecases that forced my hand: iOS [Lockdown mode](https://support.apple.com/en-us/105120) and GrapheneOS' [Vanadium](https://discuss.grapheneos.org/d/4196-webassembly-on-vanadium)'s default configuration. This combination of factors means that there would need to be another implementation of the proof of work code in JavaScript that would actually execute the number crunching.
+
+I don't want to make another implementation of the proof of work function (the entire point of this is to only have _one_ implementation\!) so while I was browsing around I came across the legendary talk [The Birth & Death of JavaScript](https://www.destroyallsoftware.com/talks/the-birth-and-death-of-javascript) and got a horrible idea. What if you just compiled the WebAssembly to JavaScript? Would that even work? It's "just" turning one Turing machine into another, right? How would it fare in practice?
+
+Turns out I'm not the first person to think about this\! The team behind [binaryen](https://github.com/WebAssembly/binaryen) have made this escape hatch in the form of [wasm2js](https://github.com/WebAssembly/binaryen/blob/main/src/tools/wasm2js.cpp) which takes WebAssembly binaries and produces moderately cromulent JavaScript in response. One of the main downsides is that the generated binaries tend to be rather large.
+
+For example consider this simple WebAssembly module that exposes a function that adds two numbers together:
+
+```
+(module
+ (func $add (param $lhs i32) (param $rhs i32) (result i32)
+ local.get $lhs
+ local.get $rhs
+ i32.add)
+ (export "add" (func $add))
+)
+```
+
+Seems simple enough, right? Here's the JavaScript that generates:
+
+```javascript
+function asmFunc(imports) {
+ var Math_imul = Math.imul;
+ var Math_fround = Math.fround;
+ var Math_abs = Math.abs;
+ var Math_clz32 = Math.clz32;
+ var Math_min = Math.min;
+ var Math_max = Math.max;
+ var Math_floor = Math.floor;
+ var Math_ceil = Math.ceil;
+ var Math_trunc = Math.trunc;
+ var Math_sqrt = Math.sqrt;
+ function $0($0_1, $1) {
+ $0_1 = $0_1 | 0;
+ $1 = $1 | 0;
+ return ($0_1 + $1) | 0 | 0;
+ }
+
+ return {
+ add: $0,
+ };
+}
+
+var retasmFunc = asmFunc({});
+export var add = retasmFunc.add;
+```
+
+As you can imagine, this only gets [progressively worse](https://anubis.techaro.lol/.within.website/x/cmd/anubis/static/js/worker/wasm2js.mjs) as you end up making the Rust standard library get compiled from WASM to JavaScript, and even worse when you actually get hashing functions into the mix. The end result is probably very optimized when you run it through a JIT, but given that this runs on an interpreter it's probably gonna be slow no matter what I do.
+
+Sorry\! I tried\!
+
+### How I found the first compiler bug in my career
+
+This ended up working fairly well in testing, but I tried building it in GitHub Actions and ran into an issue. I noticed that wasm2js was [packaged in Ubuntu](https://manpages.ubuntu.com/manpages/resolute/man1/wasm2js.1.html) and that the version in Fedora worked fine but the version in Ubuntu did not and threw an obscure error message about not understanding the tail call extension that Rust was using for some reason.
+
+I ended up bisecting versions of binaryen by downloading a tarball, building it from source, and then seeing if the result of running it on the Anubis WebAssembly modules worked in a browser. I ended up selecting Binaryen version 128, the newest version at the time. It was new enough that most of [the distributions that package Anubis](https://repology.org/project/anubis-anti-crawler/versions) don't have that version of Binaryen packaged.
+
+However I didn't want to make my life more complicated by having some kind of conditional compilation step that would effectively tell end users "sorry, the admin is using an unofficial build that just so happens to not support your browser, please complain to them" because they'll just end up complaining to me. In my experience the kinds of people who run this exact combination of circumstances also tend to be the kind of people that have a wide variance in the level of kindness they display to the authors of open source programs that happen to be in their way. So I needed an escape hatch that would _force_ build systems to use _the exact version_ that I use in my builds.
+
+Then inspiration hit me as if Apollo himself sniped me from the heavens. We're dealing with WebAssembly here right? What's stopping us from just compiling the WebAssembly to JS tool to WebAssembly with some kind of reproducible build, committing that blob to the repo, and then moving on with life?
+
+I ended up [finding a bug in LLVM](https://github.com/llvm/llvm-project/issues/204883) around how it was iterating over exception handling blocks by the compiler iterating over them in machine pointer order. As a result each build would drift by about 29 bytes per build:
+
+```diff
+ i32.load offset=4 ;; 28 02 04
+ i32.const 6 ;; 41 06
+ i32.ne ;; 47
+ br_if 0 ;; 0d 00
+- try_table (catch_all_ref 8) ;; 1f 40 01 03 08
++ try_table (catch_all_ref 4) ;; 1f 40 01 03 04
++ try_table (catch_all_ref 9) ;; 1f 40 01 03 09
+ local.get 2 ;; 20 02
+ i32.const 32 ;; 41 20
+ i32.add ;; 6a
+ local.set 3 ;; 21 03
+ local.get 2 ;; 20 02
+ i32.const 56 ;; 41 38
+ i32.add ;; 6a
+ local.get 1 ;; 20 01
+ i32.const 8 ;; 41 08
+ i32.add ;; 6a
+ call 17461 ;; 10 b5 88 81 80 00
+ local.set 4 ;; 21 04
+ end ;; 0b
+- try_table (catch_all_ref 4) ;; 1f 40 01 03 04
+ local.get 3 ;; 20 03
+ local.get 4 ;; 20 04
+```
+
+This is what lead me to write [I hate compilers](https://xeiaso.net/notes/2026/anubis-wasm-vendor-binary/) as a combination blogpost/cry for help which made me realize this was actually an LLVM bug. I didn't instantly lean towards it being an LLVM bug until I figured out that disabling ASLR (via `setarch --addr-no-randomize`) made the results consistent on the same host within the same boot.
+
+Honestly this is the first time in my career I've ever run into a compiler bug like this. When I do a lot of my work I usually work under the assumption that the compiler is bug-free and that my inputs are wrong somehow. As such, even thinking it could possibly be an LLVM bug was just outside of the realm of possibility for me.
+
+Once that LLVM bug got fixed and a new version of [wasi-sdk](https://github.com/WebAssembly/wasi-sdk) with that fix got released, I was off to the races, updated my build of wasm-opt/wasm2js, made my build scripts run it with [wasmtime](https://wasmtime.dev/) (alongside a wazero-based fallback process that would be slower, but did work enough) and everything worked out. Every time Anubis builds the WebAssembly in CI it uses the version of wasm-opt and wasm2js that ships in the repo to ensure that everything is as byte-for-byte deterministic as possible.
+
+Your build tools can't differ from my build tools if I ship you the build tools I use.
+
+### Okay, but will it work on Ukrainian Smart TVs?
+
+Then we get into the other big problem that made this difficult: browser testing. One of the most common failure modes of Anubis is that someone uses some browser that I don't test in CI and then things don't work with it. I'm tired of installing 50 different browsers on several machines to test things and I have gone through so many throwaway VMs that I'm sure it's reduced the lifetime of my SSD.
+
+:::note Aside
+
+Yes, I really have been testing Anubis by hand in god knows how many browsers. Why do you think it takes so long to tag new releases?
+
+:::
+
+I built a harness that I call "chromesweep" that lets me spawn many Googles Chrome (term c.f. Attorneys General, et.al) in their default configuration to try and hit a version of Anubis listening over HTTPS. Getting this far meant making a library of all of these browser versions. I've put that library up on Github at [TecharoHQ/gubal](https://github.com/TecharoHQ/gubal) in case it's useful for you.
+
+One of the other big problems I ran into while getting browser testing working was making sure that my Googles Chrome strictly stay within the bounds of my Kubernetes cluster's network. Chrome this old is actively radioactive and I want to treat it like the security threat it is. As such, I set up a strict [NetworkPolicy](https://kubernetes.io/docs/concepts/services-networking/network-policies/) to only allow it to access the Anubis instance under test and make DNS queries. I also wrap each Chrome pod in a microVM with [Kata containers](https://katacontainers.io/) as an additional layer of security.
+
+:::note Aside
+
+It honestly terrifies me to think that I am putting more effort into securing these Googles Chrome than [big AI companies are putting into securing their AI agent testing infrastructure](https://collusion.wiki/). It literally doesn't take much to put a big dent into securing things! The current state of our industry boggles the mind. All I want is for Techaro's [FelonyBench](https://www.felonybench.com/) score to remain at 0, is that too much to ask?
+
+:::
+
+I also rigged the browser testing infrastructure up to a single slash command in GitHub pull requests. Doing it makes my office very warm so I try to avoid doing it when possible. This works well enough that it lets me move on to the next stage and has already caught something that lead to building all the JavaScript with the `--chrome66` flag.
+
+
+
+I wonder if this is yet another case where making infrastructure for Anubis could result in that infrastructure alone being its own viable tech product. I run into a lot of those.
+
+### Yo dawg, heard you liked compiling
+
+In the process of doing that automated browser testing I found out that Chrome 75 had a weird error pop up when it tried to compile Anubis' WASM to native code:
+
+```
+CompileError: WebAssembly.compile(): Compiling function
+#4:\"_RINvMs0_NtNtNtNtCsdl5sGgnNXvY_3std3sys4sync4on...\" failed:
+expected table index 0, found 128 @+664
+```
+
+This also caused failures up to Chrome 100, so this signaled to me that something I was doing with my "strict MVP" build of Anubis' WASM wasn't in fact sticking to just the MVP features of WebAssembly. It turns out that the function referenced (probably somewhere in [std::sync::Once](https://doc.rust-lang.org/std/sync/struct.Once.html)? I probably should have traced it down to the exact bit) was in the standard library. This surprised me because I assumed that building Rust code with CPU features selected would apply that to everything, including the standard library, right?
+
+No, turns out that when you download the [`rust-std` component](https://rust-lang.github.io/rustup/concepts/components.html) in [rustup](https://rustup.rs/), that doesn't just download the standard library. To aid in cross compilation and I guess to avoid disk space waste, the Rust standard library is precompiled. This surprised me as Go typically has you recompile the standard library (and runtime for that matter) when doing normal builds and cross compilation.
+
+:::note Aside
+
+The actual issue here is that the stdlib function in question was compiled down to use [reference types](https://bytecodealliance.org/articles/reference-types-in-wasmtime), which made [`call_indirect`](https://webassembly.github.io/spec/core/exec/instructions.html#xref-syntax-instructions-syntax-instr-control-mathsf-call-indirect-x-y) references get stored as a table index instead of what MVP WebAssembly would put there. Chrome tried to read a null byte, got not a null byte, and then understandably exploded.
+
+:::
+
+I looked into the process involved for rebuilding the standard library twice: once with only MVP wasm features enabled and once with an "all yes config" like usual. Based on [some research I did](https://wiki.osdev.org/Porting_Rust_standard_library) this seemed like a massive pain.
+
+However, I had gone through that effort to build reproducible WASI versions of wasm2js and wasm-opt. [wasm-opt](https://github.com/WebAssembly/binaryen#tools) is a tool that lets you take compiled WebAssembly modules, optimize them, and more importantly remove features from them so they can run in older browsers. After a bit of hacking to make sure that the tools were able to run properly, I set up a Claude Opus / GLM 5.2 loop to fuzz various wasm-opt flags and make sure Chrome 75 could parse the output. I ended up with these flags:
+
+```shell
+# Chrome shipped sign-ext and mutable-globals in 74, bulk-memory and
+# nontrapping-fptoint in 75, multivalue in 85 and simd in 91.
+baseline_features="-mvp --enable-sign-ext --enable-mutable-globals \
+--enable-bulk-memory --enable-nontrapping-float-to-int"
+simd128_features="${baseline_features} --enable-multivalue --enable-simd"
+```
+
+This strips away all the other WebAssembly features from the build like unwanted paint (the `-mvp` flag means "disable everything not in the original MVP definition of WebAssembly"). I think that it'd be safe-ish to enable reference types in the SIMD build (they were added before Chrome added SIMD), but it's not hurting anything to remove them so I'll just let cowardice win here. Either way, I threw the results into chromesweep and got a successful response, so win\! If/when this comes to bite me I'll try and improve it.
+
+## Things that probably need to be fixed at some point but I want to see what's on fire in practice first, you know?
+
+I'm pretty sure that this work isn't _perfect_, but at some point you gotta cut your losses, ship it, and then see where things fail to prioritize perfecting it. These issues include but are not limited to:
+
+- The wasm2js flow doesn't currently have a way to update the progress bar with its `update_nonce` import stubbed out. I have no idea how to properly wire that up with the constraints of my runtime, but I'm sure I can figure it out eventually.
+- The WebAssembly that's shipped with this flow is ridiculously performant. This may mean you need to adjust the difficulty to compensate for this. Oops\! Sorry\!
+- This is better on mobile phones, but I'm working on a mobile request classifier that will use a combination of IP address reputation, TLS fingerprinting, and other signals to determine if a request is from a phone and give it the appropriate amount of grace. This is hard. Please contact me if you have ideas on how to make my current prototype better.
+- I need some way to dynamically rotate out challenge programs at runtime instead of just at compile time when I work on Anubis. Eventually I hope to have this pull WebAssembly files and supporting bundles from OCI/Docker registries, but I need to do a lot more experimentation with this before I can conclude how good/bad of an idea this is.
+
+Overall though, I'm hopeful that most of the worst parts of this can be solved. It would be nice if I didn't have to work what amounts to two full time jobs.
+
+## Ad astra per aspera
+
+I'm pretty sure that this is stable enough to ship as off-by-default in Anubis v1.28.0: [Wuk Lamat](https://ffxiv.consolegameswiki.com/wiki/Wuk_Lamat). Based on the feedback I get from administrators and users, I'll enable it in the default configuration in Anubis v1.29.0.
+
+I hope this look into how Anubis is developed can give you ideas as to the scale and challenge involved. Making something like this is tireless and thankless work and it's really weird to see people talk about it in the same breath as Cloudflare or AWS' WAF.
+
+Have a good day all!
+
+:::note Aside
+
+AI was not used in the production of the prose of this article. I have my draft as [a Google Doc](https://docs.google.com/document/d/1n-KE0M3gvXXmkWVRy-stT0pWHrz0u0aApaOastqwNEM/edit?tab=t.cknmv81px79i#heading=h.bh5cic8vnftr) so you can see exactly where and when I typed every word myself. The only use of AI was Claude Opus to help me make the visual bit/nibble diagram.
+
+:::
diff --git a/docs/blog/2026-09-06-anubis-wasm/mental-gymnastics.webp b/docs/blog/2026-09-06-anubis-wasm/mental-gymnastics.webp
new file mode 100644
index 000000000..521a19b97
Binary files /dev/null and b/docs/blog/2026-09-06-anubis-wasm/mental-gymnastics.webp differ
diff --git a/docs/docs/CHANGELOG.md b/docs/docs/CHANGELOG.md
index 08074638d..131034e0f 100644
--- a/docs/docs/CHANGELOG.md
+++ b/docs/docs/CHANGELOG.md
@@ -13,12 +13,352 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
+## v1.28.0-pre2: Wuk Lamat
+
+- Add WebAssembly-based proof of work checks to decrease client load and increase the complexity required to scrape past Anubis. See [Proof of Work (WebAssembly)](./admin/configuration/challenges/wasm.mdx) for more information.
+- Use a bundled version of `wasm2js` in order to make the WebAssembly proof of work checks run in non-wasm environments.
+- Make the bundled `wasm2js`/`wasm-opt` WebAssembly modules build reproducibly and fix the build on arm64.
+- Add the concept of [Challenge Extensions](./admin/configuration/challenges/extensions/index.mdx) and add the sample [css-load](./admin/configuration/challenges/extensions/css-load.mdx) extension.
+- Fix `npm run test:integration` so the Playwright suite can connect to browsers and Firefox can reach the test server again.
+- Add weighing rule for [Cloudflare Kitesurf](https://blog.cloudflare.com/kitesurf/). Kitesurf doesn't currently support Cookies, but it might in the future.
+- Improved Norwegian Nynorsk localization.
+- Stop clearing the authorization cookie when a challenged request did not send one. A subresource request that starts before the challenge is passed but finishes after it no longer deletes the cookie that `pass-challenge` just issued ([#1314](https://github.com/TecharoHQ/anubis/issues/1314)).
+- Fix proof of work worker spawning fallback logic to properly detect Content-Security-Policy failures and fall back to the older logic that fans out to one request per hardware core ([#1864](https://github.com/TecharoHQ/anubis/issues/1864)).
+- [Content-Security-Policy advice](./admin/configuration/content-security-policy.mdx) has been added to the documentation.
+- Passthru `Host` header as `X-Forwarded-Host` in Open Graph requests so backends can dispatch based on host.
+- Detect and block trivial attempts at [domain fronting](https://en.wikipedia.org/wiki/Domain_fronting) as bots have been starting to use that to try and turn web applications or HTTP servers into open proxies.
+- Add [HyperAgent](https://github.com/hyperbrowserai/HyperAgent/issues/91) to the headless browsers block rule.
+- Fix Dutch localization typo in the "go home" link.
+- WASM challenges now share watchdog, CSP, and defer fixes from v1.27.0.
+- WASM challenge workers now report errors to the challenge page instead of failing silently.
+- Add prebuilt binaries for NetBSD on amd64 and arm64.
+- Fix WASM build scripts on macOS, which has ancient coreutils that lacks features present on GNU+Linux.
+- Consolidate the purejs and webcrypto workers into one sha256 browser worker to avoid code duplication.
+- Rename X-Real-Ip to X-Real-I**P** in challenge metadata.
+- Fix client-supplied `X-Anubis-*` header spoofing
+- Log "challenge accepted" at INFO level when challenge is accepted, providing challenge lifecycle observability at quieter log levels than DEBUG.
+- Fix `npm run test:integration` and the Playwright CI step, which were pinned to `playwright@1.61.1` while `go.mod`'s `mxschmitt/playwright-go` had already been bumped to a client expecting protocol 1.62.x, causing every Playwright-driven test to fail with a version mismatch.
+- Restore the original `Referer` header on the request forwarded to the target after a challenge is passed, so server-side logs and analytics no longer see the internal challenge page as the referrer. Add an opt-in `--preserve-referer-query-param` flag that also appends `utm_source`/`utm_medium` query parameters to the post-challenge redirect for client-side analytics tools (e.g. Plausible) that read query parameters instead of `document.referrer`, which cannot be corrected from the server side ([#1596](https://github.com/TecharoHQ/anubis/issues/1596)).
+- Clarify getChallenge failure response and cite related log entry.
+- Share redirect validation between challenge completion and subrequest authentication. Reject ambiguous URL forms before checking allowed domains.
+- Respond with the configured `DENY` status code instead of HTTP 500 when a challenged client is rejected for not advertising gzip support, and log that rejection at `INFO` instead of `ERROR`. The rejection is deliberate, so it no longer shows up in 5xx rates or as a server fault. The log message text is unchanged, but fail2ban filters that match on the `ERROR` level need updating ([#1009](https://github.com/TecharoHQ/anubis/issues/1009)).
+
+## v1.27.0: Moenbryda Wilfsunnwyn
+
+Anubis v1.27.0 adds Windows Server support, automatically renames cookies based on settings to avoid infinite challenge loops, adds two new localizations, and more.
+
+### Breaking change: cookie names are dynamically created based on cookie settings
+
+Anubis tries to avoid breaking changes as much as possible, but sometimes we have to make them for the sake of the users. This is technically a breaking change in something that is not part of the public API of Anubis; but some administrators rely heavily on cookie names in advanced configurations.
+
+It seems that browsers store cookies disambiguated with their options. This means you can have multiple cookies named the same but with different options. Browsers will send these cookies to the server without the list of options. This means that changing any cookie settings requires you to change `COOKIE_PREFIX`, creating a new "cookie epoch" that will set things properly.
+
+In order to be more robust, Anubis will automatically change cookie names based on the cookie settings. For example, the default configuration creates cookies named `techaro.lol-anubis-auth-347ddb4a`.
+
+Without this change, changing _any_ cookie setting without every client clearing their cookies causes challenges to become an infinite loop of thrashing, making it appear that Anubis "blocked" them.
+
+If this becomes onerous in practice for administrators of HAProxy and other advanced setups that rely on cookie names, we will add an escape hatch in the policy file.
+
+### Windows Server support (beta)
+
+Anubis now publishes `.msi` packages, allowing administrators to install and run Anubis on Windows Server. Please read the [Windows Server page](./admin/environments/windows.mdx) for more information.
+
+This support is beta-grade as the Anubis team does not have a lot of experience with developing software for Windows Server. Feedback is more than welcome.
+
+Please let us know how it works for you!
+
+### Pre-release docker images no longer populate the `latest` tag
+
+Due to a misconfiguration of the GitHub Action [docker/metadata-action](https://github.com/docker/metadata-action), pre-release Docker images previously populated the `:latest` tag. This means that administrators that expected the `:latest` tag to result in a stable release of Anubis got a prerelease version suddenly when they ran automatic updates.
+
+If administrators want to opt-in to the prerelease build track of Anubis for more frequent access to new features, they can use the `:pre` tag:
+
+```yaml
+image: ghcr.io/techarohq/anubis:pre
+```
+
+### Features
+
+- Allow importing bot policy rules [using wildcard matching](./admin/configuration/import.mdx#importing-many-files-at-once) ([#1815](https://github.com/TecharoHQ/anubis/issues/1815)).
+- Add support for JSON sources to iplist2rule ([#1833](https://github.com/TecharoHQ/anubis/pull/1833))
+- Make the honeypot feature log detected addresses to the disk every minute when `honeypot.ip_log_file` is set. See the [IP address logging](./admin/honeypot/overview.mdx#ip-address-logging) section for more information.
+- Add standard library rules for making Dillo less threatening to Anubis and [document how to enable them](./admin/faq.mdx#how-do-i-allow-small-internet-browsers-like-dillo-netsurf-and-pale-moon-to-bypass-anubis).
+
+#### Crawlers
+
+- Allow [Arquivo.pt](https://arquivo.pt), the Portuguese web archive, by default via its crawling network.
+- Add `(data)/bots/lyrenth.yaml` snippet that denies [Lyrenth](https://lyrenth.com/bot)'s `AIWebIndex` crawler and `AIWebIndex-Agent` on-demand fetcher by user agent and by their [published IP ranges](https://www.lyrenth.com/bot/ip-ranges.json). This is imported by `(data)/bots/_deny-pathological.yaml`.
+- Updates Alibaba cloud IP list ([#1813](https://github.com/TecharoHQ/anubis/pull/1813))
+- Updates Googlebot IP list ([#1812](https://github.com/TecharoHQ/anubis/pull/1812))
+- Updates IP list for DuckDuckBot ([#1810](https://github.com/TecharoHQ/anubis/pull/1810))
+- Update Huawei Cloud IP list ([#1814](https://github.com/TecharoHQ/anubis/pull/1814))
+
+### Fixes
+
+- Fix bot policy imports to not require pedantically correct YAML formatting when using wildcard matching.
+- JavaScript served by the `fast` challenge is loaded using [`defer`](https://www.w3schools.com/tags/att_script_defer.asp) instead of `async` ([#1782](https://github.com/TecharoHQ/anubis/issues/1782)).
+- Amend default [Lightpanda](https://lightpanda.io/) rule to match current behaviour, add smoke test to ensure it keeps working ([#1822](https://github.com/TecharoHQ/anubis/issues/1822)).
+- Fix a panic when a request asks for the undetermined language tag, such as `Accept-Language: und` ([#1776](https://github.com/TecharoHQ/anubis/issues/1776)).
+- Allow user agents that start with capital-G `Git` in `(data)/clients/git.yaml`.
+- Enabled the Partitioned flag on cookies by default ([#1701](https://github.com/TecharoHQ/anubis/issues/1701)).
+- Fix Windows MSI builds on prerelease tags such as `v1.27.0-pre1`.
+- Bump AI-robots.txt to version 1.47.
+
+### i18n
+
+- Add Basque (eu) localization.
+- Update Bulgarian locale ([#1708](https://github.com/TecharoHQ/anubis/pull/1708))
+- Add Croatian (hr) localization.
+
+## v1.26.2: Papalymo Totolymo: Echo 2
+
+- Automatically verify correct parsing of everything in `(data)`. While doing post-release checks on v1.26.1, I discovered that I incorrectly merged `(data)/services/updown.yaml` in such a way that it became syntactically invalid. This has been mended and multiple layers of CI have been put into place to make sure that `(data)` entries are syntactically and semantically valid.
+
+## v1.26.1: Papalymo Totolymo: Echo 1
+
+- Fix support for semicolon-delimited query parameters that was dropped when moving from [net/http/httputil#ReverseProxy](https://pkg.go.dev/net/http/httputil#ReverseProxy).Director (deprecated) to net/http/httputil#ReverseProxy.Rewrite. This re-enables support for upstreams like gitweb ([#1763](https://github.com/TecharoHQ/anubis/issues/1763)). A functional test has been added to ensure this does not repeat.
+
+### Challenge page robustness
+
+The challenge page can now survive transient failures, reduces the number of requests it makes to the Anubis app, and adds exponential backoff with retries to counteract an overwhelmed server being unable to serve any assets.
+
+Previously if any request for JavaScript assets failed, the entire challenge attempt failed and users were forced to manually refresh the page, which is a bit of a bad user experience. This was made worse when the load balancer does not support HTTP/2, did not have resumable sessions enabled, and was implemented with Apache httpd pre-fork; making each asset fetch do its own TCP/TLS handshake. Under periods of heavy load such that TCP/TLS handshakes timed out, this made Anubis unable to fetch assets consistently or even made in-progress challenge attempts fail, which made challenges impossible to pass.
+
+This has been fixed in a few ways:
+
+- Fetch operations now retry with [exponential backoff](https://en.wikipedia.org/wiki/Exponential_backoff) in hopes that they will eventually be able to get through when the server is less stressed.
+- Attempting to fetch the `main.mjs` script now has fallback watchdog logic that periodically re-attempts to load the script.
+- Worker source code is fetched _once_ and then loaded into workers with a [Blob](https://developer.mozilla.org/en-US/docs/Web/API/Blob) instead of having each worker do an independent fetch of the worker source code.
+- Individual workers can now die without making the entire challenge attempt fail. Surviving workers will cover the rest of the nonce space.
+- When worker construction fails, already running proof of work workers are terminated instead of staying active as headless unmonitored infinite loops.
+- Proof of work failures are now exposed as untranslated real errors. Browsers signal script load failures with real Events so the challenge failure page now shows a useful message.
+
+## v1.26.0: Papalymo Totolymo
+
+- Add option to [disable the honeypot](./admin/policies.mdx#honeypot-configuration).
+- Add `(data)/clients/google-user-triggered-fetchers.yaml` snippet that allows Google-owned user-triggered fetchers (Google Translate's website translation proxy, Google Read Aloud, Google Messages link previews) by their [published IP ranges](https://developers.google.com/static/crawling/ipranges/user-triggered-fetchers-google.json), fixing the infinite challenge loop for visitors using Google Translate ([#444](https://github.com/TecharoHQ/anubis/issues/444))
+- Update QwantBot remote addresses range with latest value
+- Migrate check-spelling workflow config to [cspell](https://cspell.org/)
+- Add [Anubis Kubernetes Operator](https://github.com/eznix86/anubis-kubernetes-operator/) to the docs ([#1675](https://github.com/TecharoHQ/anubis/pull/1675)).
+- Bump Playwright browser tooling to 1.61.1 and playwright-go to v0.6100.0.
+- Add FreeBSD/Windows binaries to the yeetfile.
+- Add systemd system extensions to the yeetfile.
+- Set an explicit esbuild `--target=chrome66` so modern syntax (e.g. optional chaining) is transpiled down. This lowers the minimum supported browser from Chrome 80 to Chrome 66.
+- Patch [GHSA-6wcg-mqvh-fcvg](https://github.com/TecharoHQ/anubis/security/advisories/GHSA-6wcg-mqvh-fcvg) by containing subrequest logic to Anubis instances in subrequest mode.
+- Implement robot9001 style delays on the honeypot feature so that the first hit takes 1 millisecond, the second takes 2, etc.
+- Move metrics server configuration to [the policy file](./admin/policies.mdx#metrics-server).
+- Expose [pprof endpoints](https://pkg.go.dev/net/http/pprof) on the metrics listener to enable profiling Anubis in production.
+- fix: prevent nil pointer panic in challenge validation when threshold rules match during PassChallenge (#1463)
+- Instruct reverse proxies to not cache error pages.
+- Fixed mixed tab/space indentation in Caddy documentation code block
+- Improve error messages and fix broken REDIRECT_DOMAINS link in docs ([#1193](https://github.com/TecharoHQ/anubis/issues/1193))
+- Add Bulgarian locale ([#1394](https://github.com/TecharoHQ/anubis/pull/1394))
+- Fixed case-sensitivity mismatch in geoipchecker.go
+- Use [Go's native version stamping](https://michael.stapelberg.ch/posts/2026-04-05-stamp-it-all-programs-must-report-their-version/) instead of a handrolled variant.
+- Fix CEL internal errors when iterating `headers`/`query` map wrappers by implementing map iterators for `HTTPHeaders` and `URLValues` ([#1465](https://github.com/TecharoHQ/anubis/pull/1465)).
+- Enable [metrics serving via TLS](./admin/policies.mdx#tls), including [mutual TLS (mTLS)](./admin/policies.mdx#mtls).
+- Enable [HTTP basic auth](./admin/policies.mdx#http-basic-authentication) for the metrics server.
+- Fix a bug in the dataset poisoning maze that could allow denial of service [#1580](https://github.com/TecharoHQ/anubis/issues/1580).
+- Add config option to add ASN to logs/metrics.
+- Log weight when issuing challenge.
+- Block x.ai's crawler for code review training.
+- Gate pprof endpoints behind `metrics.debug` in the policy file.
+- Limit naive honeypot r9k delay to one second.
+- Fix an obscure case where adding query values to a subrequest match could cause an invalid rule match when using path based matching for protected resources.
+- Anubis now requires Go 1.26 to build.
+- Fix an edge case where load average expression values could nil pointer dereference when Anubis just started up.
+- Fix an obscure case where Anubis in subrequest mode could allow redirects to invalid domains with strange instructions.
+- Fix `path_regex` and CEL `path` rules not matching when using Traefik `forwardAuth` middleware. Anubis now checks `X-Forwarded-Uri` (Traefik) in addition to `X-Original-URI` (nginx) when resolving the request path in subrequest mode ([#1628](https://github.com/TecharoHQ/anubis/issues/1628)).
+- Validate bounds in the CEL `randInt` helper so non-positive or platform-overflowing arguments surface a typed CEL error instead of an evaluator panic.
+- Fix a race in the bbolt store where the asynchronous cleanup scheduled by an expired read could delete a value that had just been refreshed; the delete now only fires when the key still carries the same expired generation it observed.
+- Marginally increase the performances of requests processing
+- Marginally improve the performances of PoW validation
+- Marginally improve the performances of challenges generation/display
+- Significantly improve the performances of the gzip middleware
+- Significantly improve the performances of the PoW validation
+- Add trimpath option to artifact builds
+- Add COOKIE_HTTP_ONLY option to set the HttpOnly flag on Anubis cookies
+- Improve the performances of rules validation
+- Only compute the JA4H fingerprint when a policy references the `X-Http-Fingerprint-JA4H` header, taking it off the hot path for configurations that don't use it ([#834](https://github.com/TecharoHQ/anubis/pull/834)).
+- Migrate the target reverse proxy off the deprecated `httputil.ReverseProxy.Director` to `Rewrite` for Go 1.26 compatibility, preserving the inbound `Host` and `X-Forwarded-*`/`Forwarded` headers.
+
+## v1.25.0: Necron
+
+Hey all,
+
+I'm sure you've all been aware that things have been slowing down a little with Anubis development, and I want to apologize for that. A lot has been going on in my life lately (my blog will have a post out on Friday with more information), and as a result I haven't really had the energy to work on Anubis in publicly visible ways. There are things going on behind the scenes, but nothing is really shippable yet, sorry!
+
+I've also been feeling some burnout in the wake of perennial waves of anger directed towards me. I'm handling it, I'll be fine, I've just had a lot going on in my life and it's been rough.
+
+I've been missing the sense of wanderlust and discovery that comes with the artistic way I playfully develop software. I suspect that some of the stresses I've been through (setting up a complicated surgery in a country whose language you aren't fluent in is kind of an experience) have been sapping my energy. I'd gonna try to mess with things on my break, but realistically I'm probably just gonna be either watching Stargate SG-1 or doing unreasonable amounts of ocean fishing in Final Fantasy 14. Normally I'd love to keep the details about my medical state fairly private, but I'm more of a public figure now than I was this time last year so I don't really get the invisibility I'm used to for this.
+
+I've also had a fair amount of negativity directed at me for simply being much more visible than the anonymous threat actors running the scrapers that are ruining everything, which though understandable has not helped.
+
+Anyways, it all worked out and I'm about to be in the hospital for a week, so if things go really badly with this release please downgrade to the last version and/or upgrade to the main branch when the fix PR is inevitably merged. I hoped to have time to tame GPG and set up full release automation in the Anubis repo, but that didn't work out this time and that's okay.
+
+If I can challenge you all to do something, go out there and try to actually create something new somehow. Combine ideas you've never mixed before. Be creative, be human, make something purely for yourself to scratch an itch that you've always had yet never gotten around to actually mending.
+
+At the very least, try to be an example of how you want other people to act, even when you're in a situation where software written by someone else is configured to require a user agent to execute javascript to access a webpage.
+
+Be well,
+
+Xe
+
+PS: if you're well-versed in FFXIV lore, the release title should give you an idea of the kind of stuff I've been going through mentally.
+
+- Add iplist2rule tool that lets admins turn an IP address blocklist into an Anubis ruleset.
+- Add Polish locale ([#1292](https://github.com/TecharoHQ/anubis/pull/1309))
+- Fix honeypot and imprint links missing `BASE_PREFIX` when deployed behind a path prefix ([#1402](https://github.com/TecharoHQ/anubis/issues/1402))
+- Add ANEXIA Sponsor logo to docs ([#1409](https://github.com/TecharoHQ/anubis/pull/1409))
+- Improve idle performance in memory storage
+- Add HAProxy Configurations to Docs ([#1424](https://github.com/TecharoHQ/anubis/pull/1424))
+
+## v1.24.0: Y'shtola Rhul
+
+Anubis is back and better than ever! Lots of minor fixes with some big ones interspersed.
+
+- Fix panic when validating challenges after privacy-mode browsers strip headers and the follow-up request matches an `ALLOW` threshold.
+- Expose WEIGHT rule matches as Prometheus metrics.
+- Allow more OCI registry clients [based on feedback](https://github.com/TecharoHQ/anubis/pull/1253#issuecomment-3506744184).
+- Expose services directory in the embedded `(data)` filesystem.
+- Add Ukrainian locale ([#1044](https://github.com/TecharoHQ/anubis/pull/1044)).
+- Allow Renovate as an OCI registry client.
+- Properly handle 4in6 addresses so that IP matching works with those addresses.
+- Add support to simple Valkey/Redis cluster mode
+- Open Graph passthrough now reuses the configured target Host/SNI/TLS settings, so metadata fetches succeed when the upstream certificate differs from the public domain. ([1283](https://github.com/TecharoHQ/anubis/pull/1283))
+- Stabilize the CVE-2025-24369 regression test by always submitting an invalid proof instead of relying on random POW failures.
+- Refine the check that ensures the presence of the Accept header to avoid breaking docker clients.
+- Removed rules intended to reward actual browsers due to abuse in the wild.
+
+### Dataset poisoning
+
+Anubis has the ability to engage in [dataset poisoning attacks](https://www.anthropic.com/research/small-samples-poison) using the [dataset poisoning subsystem](./admin/honeypot/overview.mdx). This allows every Anubis instance to be a honeypot to attract and flag abusive scrapers so that no administrator action is required to ban them.
+
+There is much more information about this feature in [the dataset poisoning subsystem documentation](./admin/honeypot/overview.mdx). Administrators that are interested in learning how this feature works should consult that documentation.
+
+### Deprecate `report_as` in challenge configuration
+
+Previously Anubis let you lie to users about the difficulty of a challenge to interfere with operators of malicious scrapers as a psychological attack:
+
+```yaml
+bots:
+ # Punish any bot with "bot" in the user-agent string
+ # This is known to have a high false-positive rate, use at your own risk
+ - name: generic-bot-catchall
+ user_agent_regex: (?i:bot|crawler)
+ action: CHALLENGE
+ challenge:
+ difficulty: 16 # impossible
+ report_as: 4 # lie to the operator
+ algorithm: slow # intentionally waste CPU cycles and time
+```
+
+This has turned out to be a bad idea because it has caused massive user experience problems and has been removed. If you are using this setting, you will get a warning in your logs like this:
+
+```json
+{
+ "time": "2025-11-25T23:10:31.092201549-05:00",
+ "level": "WARN",
+ "source": {
+ "function": "github.com/TecharoHQ/anubis/lib/policy.ParseConfig",
+ "file": "/home/xe/code/TecharoHQ/anubis/lib/policy/policy.go",
+ "line": 201
+ },
+ "msg": "use of deprecated report_as setting detected, please remove this from your policy file when possible",
+ "at": "config-validate",
+ "name": "mild-suspicion"
+}
+```
+
+To remove this warning, remove this setting from your policy file.
+
+### Logging customization
+
+Anubis now supports the ability to log to multiple backends ("sinks"). This allows you to have Anubis [log to a file](./admin/policies.mdx#file-sink) instead of just logging to standard out. You can also customize the [logging level](./admin/policies.mdx#log-levels) in the policy file:
+
+```yaml
+logging:
+ level: "warn" # much less verbose logging
+ sink: file # log to a file
+ parameters:
+ file: "./var/anubis.log"
+ maxBackups: 3 # keep at least 3 old copies
+ maxBytes: 67108864 # each file can have up to 64 Mi of logs
+ maxAge: 7 # rotate files out every n days
+ oldFileTimeFormat: 2006-01-02T15-04-05 # RFC 3339-ish
+ compress: true # gzip-compress old log files
+ useLocalTime: false # timezone for rotated files is UTC
+```
+
+Additionally, information about [how Anubis uses each logging level](./admin/policies.mdx#log-levels) has been added to the documentation.
+
+### DNS Features
+
+- CEL expressions for:
+ - FCrDNS checks
+ - Forward DNS queries
+ - Reverse DNS queries
+ - `arpaReverseIP` to transform IPv4/6 addresses into ARPA reverse IP notation.
+ - `regexSafe` to escape regex special characters (useful for including `remoteAddress` or headers in regular expressions).
+- DNS cache and other optimizations to minimize unnecessary DNS queries.
+
+The DNS cache TTL can be changed in the bots config like this:
+
+```yaml
+dns_ttl:
+ forward: 600
+ reverse: 600
+```
+
+The default value for both forward and reverse queries is 300 seconds.
+
+The `verifyFCrDNS` CEL function has two overloads:
+
+- `(addr)`
+ Simply verifies that the remote side has PTR records pointing to the target address.
+- `(addr, ptrPattern)`
+ Verifies that the remote side refers to a specific domain and that this domain points to the target IP.
+
+## v1.23.1: Lyse Hext - Echo 1
+
+- Fix `SERVE_ROBOTS_TXT` setting after the double slash fix broke it.
+
+### Potentially breaking changes
+
+#### Remove default Tencent Cloud block rule
+
+v1.23.0 added a default rule to block Tencent Cloud. After an email from their abuse team where they promised to take action to clean up their reputation, I have removed the default block rule. If this network causes you problems, please contact [abuse@tencent.com](mailto:abuse@tencent.com) and supply the following information:
+
+- Time of abusive requests.
+- IP address, User-Agent header, or other unique identifiers that can help the abuse team educate the customer about their misbehaving infrastructure.
+- Does the abusive IP address request robots.txt? If not, be sure to include that information.
+- A brief description of the impact to your system such as high system load, pages not rendering, or database system crashes. This helps the provider establish the fact that their customer is causing you measurable harm.
+- Context as to what your service is, what it does, and why they should care.
+
+Mention that you are using Anubis or BotStopper to protect your services. If they do not respond to you, please [contact me](https://xeiaso.net/contact) as soon as possible.
+
+#### Docker / OCI registry clients
+
+Anubis v1.23.0 accidentally blocked Docker / OCI registry clients. In order to explicitly allow them, add an import for `(data)/clients/docker-client.yaml`:
+
+```yaml
+bots:
+ - import: (data)/meta/default-config.yaml
+ - import: (data)/clients/docker-client.yaml
+```
+
+This is technically a regression as these clients used to work in Anubis v1.22.0, however it is allowable to make this opt-in as most websites do not expect to be serving Docker / OCI registry client traffic.
+
+## v1.23.0: Lyse Hext
+
+- Add default tencent cloud DENY rule.
- Added `(data)/meta/default-config.yaml` for importing the entire default configuration at once.
- Add `-custom-real-ip-header` flag to get the original request IP from a different header than `x-real-ip`.
- Add `contentLength` variable to bot expressions.
- Add `COOKIE_SAME_SITE_MODE` to force anubis cookies SameSite value, and downgrade automatically from `None` to `Lax` if cookie is insecure.
- Fix lock convoy problem in decaymap ([#1103](https://github.com/TecharoHQ/anubis/issues/1103)).
- Fix lock convoy problem in bbolt by implementing the actor pattern ([#1103](https://github.com/TecharoHQ/anubis/issues/1103)).
+- Remove bbolt actorify implementation due to causing production issues.
- Document missing environment variables in installation guide: `SLOG_LEVEL`, `COOKIE_PREFIX`, `FORCED_LANGUAGE`, and `TARGET_DISABLE_KEEPALIVE` ([#1086](https://github.com/TecharoHQ/anubis/pull/1086)).
- Add validation warning when persistent storage is used without setting signing keys.
- Fixed `robots2policy` to properly group consecutive user agents into `any:` instead of only processing the last one ([#925](https://github.com/TecharoHQ/anubis/pull/925)).
@@ -36,6 +376,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Allow multiple consecutive slashes in a row in application paths ([#754](https://github.com/TecharoHQ/anubis/issues/754)).
- Add option to set `targetSNI` to special keyword 'auto' to indicate that it should be automatically set to the request Host name ([424](https://github.com/TecharoHQ/anubis/issues/424)).
- The Preact challenge has been removed from the default configuration. It will be deprecated in the future.
+- An open redirect when in subrequest mode has been fixed.
+
+### Potentially breaking changes
+
+#### Multiple checks at once has and-like semantics instead of or-like semantics
+
+Anubis lets you stack multiple checks at once with blocks like this:
+
+```yaml
+name: allow-prometheus
+action: ALLOW
+user_agent_regex: ^prometheus-probe$
+remote_addresses:
+ - 192.168.2.0/24
+```
+
+Previously, this only returned ALLOW if _any one_ of the conditions matched. This behaviour has changed to only return ALLOW if _all_ of the conditions match. I expect this to have some issues with user configs, however this fix is grave enough that it's worth the risk of breaking configs. If this bites you, please let me know so we can make an escape hatch.
+
+### Better error messages
+
+In order to make it easier for legitimate clients to debug issues with their browser configuration and Anubis, Anubis will emit internal error detail in base 64 so that administrators can chase down issues. Future versions of this may also include a variant that encrypts the error detail messages.
### Bug Fixes
@@ -214,7 +575,7 @@ Anubis now is able to store things persistently [in memory](./admin/policies.mdx
Anubis now supports localized responses. Locales can be added in [lib/localization/locales/](https://github.com/TecharoHQ/anubis/tree/main/lib/localization/locales). This release includes support for the following languages:
-- [Brazilian Portugese](https://github.com/TecharoHQ/anubis/pull/726)
+- [Brazilian Portuguese](https://github.com/TecharoHQ/anubis/pull/726)
- [Chinese (Simplified)](https://github.com/TecharoHQ/anubis/pull/774)
- [Chinese (Traditional)](https://github.com/TecharoHQ/anubis/pull/759)
- English
@@ -686,7 +1047,7 @@ Livia sas Junius: Echo 2
Livia sas Junius: Echo 1
-- Set the `X-Real-Ip` header based on the contents of `X-Forwarded-For`
+- Set the `X-Real-IP` header based on the contents of `X-Forwarded-For`
[#62](https://github.com/TecharoHQ/anubis/issues/62)
## v1.14.0
diff --git a/docs/docs/admin/_category_.json b/docs/docs/admin/_category_.json
index 4b7ba50a3..a55865515 100644
--- a/docs/docs/admin/_category_.json
+++ b/docs/docs/admin/_category_.json
@@ -5,4 +5,4 @@
"type": "generated-index",
"description": "Tradeoffs and considerations you may want to keep in mind when using Anubis."
}
-}
\ No newline at end of file
+}
diff --git a/docs/docs/admin/botstopper.mdx b/docs/docs/admin/botstopper.mdx
index d98df8db7..1839ba5e6 100644
--- a/docs/docs/admin/botstopper.mdx
+++ b/docs/docs/admin/botstopper.mdx
@@ -51,9 +51,8 @@ If you are using Kubernetes, you will need to create an image pull secret:
kubectl create secret docker-registry \
techarohq-botstopper \
--docker-server ghcr.io \
- --docker-username your-username \
- --docker-password your-access-token \
- --docker-email your@email.address
+ --docker-username any-username \
+ --docker-password \
```
Then attach it to your Deployment:
@@ -85,7 +84,7 @@ Follow [the upstream Docker compose directions](https://anubis.techaro.lol/docs/
OG_EXPIRY_TIME: "24h"
+ # botstopper config here
-+ CHALLENGE_TITLE: "Doing math for your connnection!"
++ CHALLENGE_TITLE: "Doing math for your connection!"
+ ERROR_TITLE: "Something went wrong!"
+ OVERLAY_FOLDER: /assets
+ volumes:
diff --git a/docs/docs/admin/caveats-gitea-forgejo.mdx b/docs/docs/admin/caveats-gitea-forgejo.mdx
index 6569a7a97..3b1b3f95a 100644
--- a/docs/docs/admin/caveats-gitea-forgejo.mdx
+++ b/docs/docs/admin/caveats-gitea-forgejo.mdx
@@ -2,7 +2,7 @@
title: When using Caddy with Gitea/Forgejo
---
-Gitea/Forgejo relies on the reverse proxy setting the `X-Real-Ip` header. Caddy does not do this out of the gate. Modify your Caddyfile like this:
+Gitea/Forgejo relies on the reverse proxy setting the `X-Real-IP` header. Caddy does not do this out of the gate. Modify your Caddyfile like this:
```python
ellenjoe.int.within.lgbt {
@@ -12,7 +12,7 @@ ellenjoe.int.within.lgbt {
# diff-add
reverse_proxy http://localhost:3000 {
# diff-add
- header_up X-Real-Ip {remote_host}
+ header_up X-Real-IP {remote_host}
# diff-add
}
# ...
diff --git a/docs/docs/admin/configuration/_category_.json b/docs/docs/admin/configuration/_category_.json
index e22c7a2f6..6b797b2b4 100644
--- a/docs/docs/admin/configuration/_category_.json
+++ b/docs/docs/admin/configuration/_category_.json
@@ -5,4 +5,4 @@
"type": "generated-index",
"description": "Detailed information about configuring parts of Anubis."
}
-}
\ No newline at end of file
+}
diff --git a/docs/docs/admin/configuration/challenges/_category_.json b/docs/docs/admin/configuration/challenges/_category_.json
index 96a70d2fe..a01e84f18 100644
--- a/docs/docs/admin/configuration/challenges/_category_.json
+++ b/docs/docs/admin/configuration/challenges/_category_.json
@@ -2,4 +2,4 @@
"label": "Challenges",
"position": 10,
"link": null
-}
\ No newline at end of file
+}
diff --git a/docs/docs/admin/configuration/challenges/extensions/_category_.json b/docs/docs/admin/configuration/challenges/extensions/_category_.json
new file mode 100644
index 000000000..539af9e0c
--- /dev/null
+++ b/docs/docs/admin/configuration/challenges/extensions/_category_.json
@@ -0,0 +1,5 @@
+{
+ "label": "Challenge Extensions",
+ "position": 10,
+ "link": null
+}
\ No newline at end of file
diff --git a/docs/docs/admin/configuration/challenges/extensions/css-load.mdx b/docs/docs/admin/configuration/challenges/extensions/css-load.mdx
new file mode 100644
index 000000000..d50eb4331
--- /dev/null
+++ b/docs/docs/admin/configuration/challenges/extensions/css-load.mdx
@@ -0,0 +1,23 @@
+# CSS Loading
+
+This extension asserts that a client is able to load CSS from the server. It works by sending instructions to import a test route and then ensures that the client actually hits that route.
+
+## Configuration
+
+Add it to an `extensions` block in an existing `challenge` definition:
+
+```yaml
+thresholds:
+ - name: mild-suspicion
+ expression:
+ all:
+ - weight > 0
+ - weight < 10
+ action: CHALLENGE
+ challenge:
+ algorithm: sha256
+ difficulty: 8
+ report_as: 8
+ extensions:
+ - css-load
+```
diff --git a/docs/docs/admin/configuration/challenges/extensions/index.mdx b/docs/docs/admin/configuration/challenges/extensions/index.mdx
new file mode 100644
index 000000000..e3f22f824
--- /dev/null
+++ b/docs/docs/admin/configuration/challenges/extensions/index.mdx
@@ -0,0 +1,28 @@
+---
+title: Challenge Extensions
+position: 0
+---
+
+Challenge Extensions are extra conditions you can layer on top of existing challenges. This allows Anubis to assert more behaviour from clients without having to modify existing challenge implementations.
+
+To enable extensions on an existing challenge, add it to the `extensions` field under your challenge definition:
+
+```yaml
+thresholds:
+ - name: mild-suspicion
+ expression:
+ all:
+ - weight > 0
+ - weight < 10
+ action: CHALLENGE
+ challenge:
+ algorithm: sha256
+ difficulty: 8
+ report_as: 8
+ extensions:
+ - css-load
+```
+
+This will augment the [sha256 challenge](../wasm.mdx#sha256) with the [`css-load`](./css-load.mdx) extension.
+
+Extensions are designed to be very cheap and easy to add to existing challenges and can be useful for evading bypass/detection logic for Anubis solving scripts.
diff --git a/docs/docs/admin/configuration/challenges/metarefresh.mdx b/docs/docs/admin/configuration/challenges/metarefresh.mdx
index 730c5bbb4..dcf876fad 100644
--- a/docs/docs/admin/configuration/challenges/metarefresh.mdx
+++ b/docs/docs/admin/configuration/challenges/metarefresh.mdx
@@ -12,7 +12,6 @@ To use it in your Anubis configuration:
action: CHALLENGE
challenge:
difficulty: 1 # Number of seconds to wait before refreshing the page
- report_as: 4 # Unused by this challenge method
algorithm: metarefresh # Specify a non-JS challenge method
```
diff --git a/docs/docs/admin/configuration/challenges/preact.mdx b/docs/docs/admin/configuration/challenges/preact.mdx
index 572149004..35f988334 100644
--- a/docs/docs/admin/configuration/challenges/preact.mdx
+++ b/docs/docs/admin/configuration/challenges/preact.mdx
@@ -12,7 +12,6 @@ To use it in your Anubis configuration:
action: CHALLENGE
challenge:
difficulty: 1 # Number of seconds to wait before refreshing the page
- report_as: 4 # Unused by this challenge method
algorithm: preact
```
diff --git a/docs/docs/admin/configuration/challenges/wasm.mdx b/docs/docs/admin/configuration/challenges/wasm.mdx
new file mode 100644
index 000000000..3fb578591
--- /dev/null
+++ b/docs/docs/admin/configuration/challenges/wasm.mdx
@@ -0,0 +1,94 @@
+# Proof of Work (WebAssembly)
+
+Anubis supports using [WebAssembly](https://en.wikipedia.org/wiki/WebAssembly) to speed up proof of work validation. Proof of work functions as a randomized delay to prevent clients from overwhelming your server with traffic. All WebAssembly proof of work functions are written in Rust.
+
+:::note
+
+When clients are configured to disable WebAssembly, Anubis falls back to a pure JavaScript implementation of the WebAssembly-based check logic. As clients that disable WebAssembly usually disable the JavaScript JIT (the thing that makes JavaScript fast), this makes checks slower.
+
+Additionally due to complicated facts and circumstances involving it being complicated to pass the messages from the wasm2js world back to JavaScript, the progress bar will not update while a wasm2js check is running. This is a known issue and will be fixed in a later release.
+
+:::
+
+## Implementations
+
+Anubis offers the following WebAssembly proof of work functions:
+
+- [argon2id](#argon2id) (via the [`argon2id`](https://docs.rs/argon2/latest/argon2/) crate)
+- [hashx](#hashx) (via the [`hashx`](https://docs.rs/hashx/latest/hashx/index.html) crate)
+- [sha256](#sha256) (via the [`sha2`](https://docs.rs/sha2/latest/sha2/) crate)
+
+:::note
+
+The difficulty values for the WebAssembly based checks are going to be much greater than the equivalent difficulty values for the [JavaScript based checks](./proof-of-work.mdx). Generally these count the number of leading _bits_ that much match instead of the number of leading _nibbles_ that must match. Here's a rough translation table:
+
+| `fast` difficulty | `argon2id` difficulty | `sha256` difficulty | `hashx` difficulty |
+| :---------------- | :-------------------- | :------------------ | :----------------- |
+| `4` | `4` | `16` | `15` |
+| `2` | `2` | `8` | `7` |
+| `6` | `6` | `24` | `20` |
+
+:::
+
+## `argon2id`
+
+Uses the memory-hard [Argon2](https://en.wikipedia.org/wiki/Argon2) key derivation function as a hashing algorithm. This uses a large fixed-size memory region in order to make brute-force attacks computationally expensive.
+
+Usage:
+
+```yaml
+thresholds:
+ - name: moderate-suspicion
+ expression:
+ all:
+ - weight >= 10
+ - weight < 20
+ action: CHALLENGE
+ challenge:
+ algorithm: argon2id
+ difficulty: 6
+```
+
+## `hashx`
+
+Uses the ASIC-resistant [hashx](https://github.com/tevador/hashx) as the proof of work function. This is resistant to GPU and ASIC attacks. In practice this means that users need to be using a standards-compliant browser to pass the challenge.
+
+Usage:
+
+```yaml
+thresholds:
+ - name: moderate-suspicion
+ expression:
+ all:
+ - weight >= 10
+ - weight < 20
+ action: CHALLENGE
+ challenge:
+ algorithm: hashx
+ difficulty: 16
+```
+
+## `sha256`
+
+Uses [SHA-256](https://en.wikipedia.org/wiki/SHA-2) as the proof of work function.
+
+:::note
+
+This is included mostly as a fallback for usecases that specifically require this hashing function. There are known tools that solve this particular challenge. Prefer [`argon2id`](#argon2id) or [`hashx`](#hashx) unless you know what you are doing and have a very specific usecase in mind.
+
+:::
+
+Usage:
+
+```yaml
+thresholds:
+ - name: moderate-suspicion
+ expression:
+ all:
+ - weight >= 10
+ - weight < 20
+ action: CHALLENGE
+ challenge:
+ algorithm: sha256
+ difficulty: 16
+```
diff --git a/docs/docs/admin/configuration/content-security-policy.mdx b/docs/docs/admin/configuration/content-security-policy.mdx
new file mode 100644
index 000000000..9968e5a86
--- /dev/null
+++ b/docs/docs/admin/configuration/content-security-policy.mdx
@@ -0,0 +1,42 @@
+# Content-Security-Policy advice
+
+Many web server configuration guides recommend setting a strict [Content-Security-Policy (CSP)](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP) on your web applications in order to avoid [Cross-Site-Scripting (XSS)](https://en.wikipedia.org/wiki/Cross-site_scripting) from being able to affect your services. The way Anubis works can conflict with some common CSP settings that many guides and web applications enforce out of the gate.
+
+:::warning
+
+Browsers cache the contents of `Content-Security-Policy` headers for a very long time. Please be sure to test any of these changes on staging domains before deploying changes to production.
+
+:::
+
+The most strict yet correct CSP for Anubis looks like this (reformatted for readability):
+
+```text
+default-src 'none';
+script-src 'self' 'unsafe-inline';
+style-src 'self' 'unsafe-inline';
+img-src 'self';
+font-src 'self' data:;
+connect-src 'self';
+worker-src 'self' blob:;
+base-uri 'none';
+form-action 'self';
+```
+
+This is an overly strict CSP and you **will** need to edit this as it **will** interfere with the normal operation of many web applications, especially ones that include resources from remote destinations to function (EG: the image CDN for a social media network).
+
+Breaking it down:
+
+| Setting | Justification |
+| :--------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `default-src 'none'` | Blocks every kind of subresource load unless specified below. This denies by default. This setting alone is likely to break web applications. |
+| `script-src 'self'` | Anubis checks that load JavaScript from the server via the `
+```
+
+Any hits that match the URL `^/.within.website/x/cmd/anubis/api/honeypot/.*` are _malicious_ and should be blocked. A spec-compliant HTML parser will not parse this link, so any parser that does and visits it is categorically in the wrong.
+
+## Plans
+
+Future versions of this feature will allow for more customization. In the near future this will be configurable via the following mechanisms:
+
+- WebAssembly logic for customizing how the poisoning data is generated (with examples including the existing spintax method).
+- Weight thresholds and logic for how they are interpreted by Anubis.
+- Other configuration settings as facts and circumstances dictate.
+
+## Implementation notes
+
+In its current implementation, the Anubis dataset poisoning feature has the following flaws that may hinder production deployments:
+
+- All Anubis instances use the same method for generating dataset poisoning information. This may be easy for malicious actors to detect and ignore.
+- Anubis dataset poisoning routes are under the `/.within.website/x/cmd/anubis` URL hierarchy. This may be easy for malicious actors to detect and ignore.
+
+Right now Anubis assigns 30 weight points if the following criteria are met:
+
+- A client's User-Agent has been observed in the dataset poisoning maze at least 25 times.
+- The network-clamped IP address (/24 for IPv4 and /48 for IPv6) has been observed in the dataset poisoning maze at least 25 times.
+
+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).
+
+### IP address logging
+
+Anubis' honeypot feature can log IP addresses to a file. These logs let you configure fail2ban pipelines or integrate with other software to block bot traffic (eg: Panorama).
+
+To use this feature:
+
+- Anubis must have access to storage it can write to.
+- The backing filesystem must support writes, seeking, and truncation.
+- The Anubis user must have write permissions to the log file.
+
+Configure this log file in the `honeypot` section of your [policy file](../policies.mdx):
+
+```yaml
+honeypot:
+ enabled: true
+ implementation: naive
+ ip_log_file: /data/anubis/honeypot.addrs
+```
+
+Anubis collects the IP addresses that hit the honeypot feature continuously and buffers them into memory. Anubis writes collected IP addresses every minute or when Anubis collects 32 kilobytes of addresses, whichever happens first. Once the file gets bigger than 64 kilobytes, the file is reset to avoid infinite growth.
diff --git a/docs/docs/admin/installation.mdx b/docs/docs/admin/installation.mdx
index 3dbc2ee59..fa1bd202c 100644
--- a/docs/docs/admin/installation.mdx
+++ b/docs/docs/admin/installation.mdx
@@ -39,6 +39,7 @@ Anubis is shipped in the Docker repo [`ghcr.io/techarohq/anubis`](https://github
| Tag | Meaning |
| :------------------ | :--------------------------------------------------------------------------------------------------------------------------------- |
| `latest` | The latest [tagged release](https://github.com/TecharoHQ/anubis/releases), if you are in doubt, start here. |
+| `pre` | The latest prerelease of Anubis. These releases will soon become full tagged releases. |
| `v` | The Anubis image for [any given tagged release](https://github.com/TecharoHQ/anubis/tags) |
| `main` | The current build on the `main` branch. Only use this if you need the latest and greatest features as they are merged into `main`. |
@@ -79,6 +80,7 @@ Anubis uses these environment variables for configuration:
| `CUSTOM_REAL_IP_HEADER` | unset | If set, Anubis will read the client's real IP address from this header, and set it in `X-Real-IP` header. |
| `COOKIE_PARTITIONED` | `false` | If set to `true`, enables the [partitioned (CHIPS) flag](https://developers.google.com/privacy-sandbox/cookies/chips), meaning that Anubis inside an iframe has a different set of cookies than the domain hosting the iframe. |
| `COOKIE_PREFIX` | `anubis-cookie` | The prefix used for browser cookies created by Anubis. Useful for customization or avoiding conflicts with other applications. |
+| `COOKIE_HTTP_ONLY` | `false` | If set to `true`, enables the [HttpOnly flag](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Cookies#block_access_to_your_cookies), meaning that the cookies will only be readable by the server. |
| `COOKIE_SECURE` | `true` | If set to `true`, enables the [Secure flag](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Cookies#block_access_to_your_cookies), meaning that the cookies will only be transmitted over HTTPS. If Anubis is used in an unsecure context (plain HTTP), this will be need to be set to false |
| `COOKIE_SAME_SITE` | `None` | Controls the cookie’s [`SameSite` attribute](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value). Allowed: `None`, `Lax`, `Strict`, `Default`. `None` permits cross-site use but modern browsers require it to be **Secure**—so if `COOKIE_SECURE=false` or you serve over plain HTTP, use `Lax` (recommended) or `Strict` or the cookie will be rejected. `Default` uses the Go runtime’s `SameSiteDefaultMode`. `None` will be downgraded to `Lax` automatically if cookie is set NOT to be secure. |
| `DIFFICULTY` | `4` | The difficulty of the challenge, or the number of leading zeroes that must be in successful responses. |
@@ -87,15 +89,15 @@ Anubis uses these environment variables for configuration:
| `ED25519_PRIVATE_KEY_HEX_FILE` | unset | Path to a file containing the hex-encoded ed25519 private key. Only one of this or its sister option may be set. **Required when using persistent storage backends** (like bbolt) to ensure challenges survive service restarts. When running multiple instances on the same base domain, the key must be the same across all instances. |
| `ERROR_TITLE` | unset | If set, override the translation stack to show a custom title for error pages such as "Something went wrong!". See [Customizing messages](./botstopper.mdx#customizing-messages) for more details. |
| `JWT_RESTRICTION_HEADER` | `X-Real-IP` | If set, the JWT is only valid if the current value of this header matches the value when the JWT was created. You can use it e.g. to restrict a JWT to the source IP of the user using `X-Real-IP`. |
-| `METRICS_BIND` | `:9090` | The network address that Anubis serves Prometheus metrics on. See `BIND` for more information. |
-| `METRICS_BIND_NETWORK` | `tcp` | The address family that the Anubis metrics server listens on. See `BIND_NETWORK` for more information. |
+| `METRICS_BIND` | `:9090` | The legacy configuration value for the network address that Anubis serves Prometheus metrics on. Please migrate this to [the policy file](./policies.mdx#metrics-server) as soon as possible. |
+| `METRICS_BIND_NETWORK` | `tcp` | The legacy configuration value for the address family that Anubis serves Prometheus metrics on. Please migrate this to [the policy file](./policies.mdx#metrics-server) as soon as possible. |
| `OG_EXPIRY_TIME` | `24h` | The expiration time for the Open Graph tag cache. Prefer using [the policy file](./configuration/open-graph.mdx) to configure the Open Graph subsystem. |
| `OG_PASSTHROUGH` | `false` | If set to `true`, Anubis will enable Open Graph tag passthrough. Prefer using [the policy file](./configuration/open-graph.mdx) to configure the Open Graph subsystem. |
| `OG_CACHE_CONSIDER_HOST` | `false` | If set to `true`, Anubis will consider the host in the Open Graph tag cache key. Prefer using [the policy file](./configuration/open-graph.mdx) to configure the Open Graph subsystem. |
| `OVERLAY_FOLDER` | unset | If set, treat the given path as an [overlay folder](./botstopper.mdx#custom-images-and-css), allowing you to customize CSS, fonts, images, and add other assets to BotStopper deployments. |
| `POLICY_FNAME` | unset | The file containing [bot policy configuration](./policies.mdx). See the bot policy documentation for more details. If unset, the default bot policy configuration is used. |
-| `PUBLIC_URL` | unset | The externally accessible URL for this Anubis instance, used for constructing redirect URLs (e.g., for Traefik forwardAuth). |
-| `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. |
+| `PUBLIC_URL` | unset | The externally accessible URL for this Anubis instance, used for constructing redirect URLs (e.g., for Traefik forwardAuth). Leave it unset when Anubis terminates traffic directly (sidecar/standalone deployments) or redirect building will fail with `redir=null`. |
+| `REDIRECT_DOMAINS` | unset | Comma-separated list of domain names that Anubis should allow redirects to when passing a challenge. See [Redirect Domain Configuration](./configuration/redirect-domains.mdx) for more details. |
| `SERVE_ROBOTS_TXT` | `false` | If set `true`, Anubis will serve a default `robots.txt` file that disallows all known AI scrapers by name and then additionally disallows every scraper. This is useful if facts and circumstances make it difficult to change the underlying service to serve such a `robots.txt` file. |
| `SLOG_LEVEL` | `INFO` | The log level for structured logging. Valid values are `DEBUG`, `INFO`, `WARN`, and `ERROR`. Set to `DEBUG` to see all requests, evaluations, and detailed diagnostic information. |
| `SOCKET_MODE` | `0770` | _Only used when at least one of the `*_BIND_NETWORK` variables are set to `unix`._ The socket mode (permissions) for Unix domain sockets. |
@@ -123,7 +125,7 @@ If you don't know or understand what these settings mean, ignore them. These are
| `TARGET_DISABLE_KEEPALIVE` | `false` | If `true`, disables HTTP keep-alive for connections to the target backend. Useful for backends that don't handle keep-alive properly. |
| `TARGET_HOST` | unset | If set, overrides the Host header in requests forwarded to `TARGET`. |
| `TARGET_INSECURE_SKIP_VERIFY` | `false` | If `true`, skip TLS certificate validation for targets that listen over `https`. If your backend does not listen over `https`, ignore this setting. |
-| `TARGET_SNI` | unset | If set, TLS handshake hostname when forwarding requests to the `TARGET`. If set to auto, use Host header. |
+| `TARGET_SNI` | unset | If set, TLS handshake hostname when forwarding requests to the `TARGET`. If set to auto, use Host header. |
@@ -203,6 +205,7 @@ To get Anubis filtering your traffic, you need to make sure it's added to your H
- [Kubernetes](./environments/kubernetes.mdx)
- [Nginx](./environments/nginx.mdx)
- [Traefik](./environments/traefik.mdx)
+- [HAProxy](./environments/haproxy.mdx)
:::note
diff --git a/docs/docs/admin/iplist2rule.mdx b/docs/docs/admin/iplist2rule.mdx
new file mode 100644
index 000000000..4ff1d3df2
--- /dev/null
+++ b/docs/docs/admin/iplist2rule.mdx
@@ -0,0 +1,50 @@
+---
+title: iplist2rule CLI tool
+---
+
+The `iplist2rule` tool converts IP blocklists into Anubis challenge policies. It reads common IP block list formats and generates the appropriate Anubis policy file for IP address filtering.
+
+## Installation
+
+Install directly with Go
+
+```bash
+go install github.com/TecharoHQ/anubis/utils/cmd/iplist2rule@latest
+```
+
+## Usage
+
+Basic conversion from URL:
+
+```bash
+iplist2rule https://raw.githubusercontent.com/7c/torfilter/refs/heads/main/lists/txt/torfilter-1m-flat.txt filter-tor.yaml
+```
+
+Explicitly allow every IP address on a list:
+
+```bash
+iplist2rule --action ALLOW https://raw.githubusercontent.com/7c/torfilter/refs/heads/main/lists/txt/torfilter-1m-flat.txt filter-tor.yaml
+```
+
+Add weight to requests matching IP addresses on a list:
+
+```bash
+iplist2rule --action WEIGH --weight 20 https://raw.githubusercontent.com/7c/torfilter/refs/heads/main/lists/txt/torfilter-1m-flat.txt filter-tor.yaml
+```
+
+## Options
+
+| Flag | Description | Default |
+| :------------ | :----------------------------------------------------------------------------------------------- | :-------------------------------- |
+| `--action` | The Anubis action to take for the IP address in question, must be in ALL CAPS. | `DENY` (forbids traffic) |
+| `--rule-name` | The name for the generated Anubis rule, should be in kebab-case. | (not set, inferred from filename) |
+| `--weight` | When `--action=WEIGH`, how many weight points should be added or removed from matching requests? | 0 (not set) |
+
+## Using the Generated Policy
+
+Save the output and import it in your main policy file:
+
+```yaml
+bots:
+ - import: "./filter-tor.yaml"
+```
diff --git a/docs/docs/admin/native-install.mdx b/docs/docs/admin/native-install.mdx
index bdda59545..9abafa918 100644
--- a/docs/docs/admin/native-install.mdx
+++ b/docs/docs/admin/native-install.mdx
@@ -143,3 +143,4 @@ For more details on particular reverse proxies, see here:
- [Apache](./environments/apache.mdx)
- [Nginx](./environments/nginx.mdx)
+- [HAProxy](./environments/haproxy.mdx)
diff --git a/docs/docs/admin/policies.mdx b/docs/docs/admin/policies.mdx
index f95821dfe..627b936b2 100644
--- a/docs/docs/admin/policies.mdx
+++ b/docs/docs/admin/policies.mdx
@@ -84,7 +84,6 @@ This rule has been known to have a high false positive rate in testing. Please u
action: CHALLENGE
challenge:
difficulty: 16 # impossible
- report_as: 4 # lie to the operator
algorithm: slow # intentionally waste CPU cycles and time
```
@@ -93,7 +92,6 @@ Challenges can be configured with these settings:
| Key | Example | Description |
| :----------- | :------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `difficulty` | `4` | The challenge difficulty (number of leading zeros) for proof-of-work. See [Why does Anubis use Proof-of-Work?](/docs/design/why-proof-of-work) for more details. |
-| `report_as` | `4` | What difficulty the UI should report to the user. Useful for messing with industrial-scale scraping efforts. |
| `algorithm` | `"fast"` | The challenge method to use. See [the list of challenge methods](./configuration/challenges/) for more information. |
### Remote IP based filtering
@@ -119,6 +117,96 @@ remote_addresses:
- 100.64.0.0/10
```
+## Metrics server
+
+Anubis includes support for [Prometheus-style metrics](https://prometheus.io/docs/introduction/overview/), allowing systems administrators to monitor Anubis' performance and effectiveness. This is a separate HTTP server with metrics, health checking, and debug routes.
+
+Anubis' metrics server is configured with the `metrics` block in the configuration file:
+
+```yaml
+metrics:
+ bind: ":9090"
+ network: "tcp"
+```
+
+If you want to bind metrics to a Unix socket, make sure to set the network to `unix` and add a socket mode:
+
+```yaml
+metrics:
+ bind: "/tmp/anubis_metrics.sock"
+ network: unix
+ socketMode: "0700" # must be a string
+```
+
+### Debug routes
+
+Anubis' metrics server supports [pprof](https://pkg.go.dev/runtime/pprof), the Go standard library tool for profiling Go applications. This is very useful for debugging how Anubis works in the wild with regards to CPU, multicore, and RAM usage. pprof is very powerful and can expose command line arguments as part of the debugging setup (inside Google, everything is done with command line flags).
+
+Prior versions of Anubis exposed pprof endpoints on all TCP bindhosts by default. This means that machines with incorrectly configured firewalls can expose command line arguments to the public internet in the right conditions.
+
+In order to enable pprof profiling endpoints on the Metrics server, set the `debug` flag under the `metrics` block:
+
+```yaml
+metrics:
+ bind: ":9090"
+ network: "tcp"
+
+ debug: true
+```
+
+To err on the side of caution, this defaults to disabled. If this defaults migration breaks your configuration, please let us know in a ticket.
+
+### TLS
+
+If you want to serve the metrics server over TLS, use the `tls` block:
+
+```yaml
+metrics:
+ bind: ":9090"
+ network: "tcp"
+
+ tls:
+ certificate: /path/to/tls.crt
+ key: /path/to/tls.key
+```
+
+The certificate and key will automatically be reloaded when the respective files change.
+
+### mTLS
+
+If you want to validate requests to ensure that they use a client certificate signed by a certificate authority (mutual TLS or mTLS), set the `ca` value in the `tls` block:
+
+```yaml
+metrics:
+ bind: ":9090"
+ network: "tcp"
+
+ tls:
+ certificate: /path/to/tls.crt
+ key: /path/to/tls.key
+ ca: /path/to/ca.crt
+```
+
+As it is not expected for certificate authority certificates to change often, the CA certificate will NOT be automatically reloaded when the respective file changes.
+
+### HTTP basic authentication
+
+Anubis' metrics server also supports setting HTTP basic auth as a lightweight protection against unauthorized users viewing metrics data. As the basic auth credentials are hardcoded in the configuration file, administrators SHOULD use randomly generated credentials, such as type-4 UUIDs or other high entropy strings. These credentials MUST NOT be sensitive or used to protect other high value systems.
+
+Configure it with the `basicAuth` block under `metrics`:
+
+```yaml
+metrics:
+ bind: ":9090"
+ network: "tcp"
+
+ basicAuth:
+ username: azurediamond
+ password: hunter2
+```
+
+If you have Python installed, you can generate a high entropy password with `python -c 'import secrets; print(secrets.token_urlsafe(32))'`.
+
## Imprint / Impressum support
Anubis has support for showing imprint / impressum information. This is defined in the `impressum` block of your configuration. See [Imprint / Impressum configuration](./configuration/impressum.mdx) for more information.
@@ -225,10 +313,10 @@ Using this backend will cause a lot of S3 operations, at least one for creating
The `s3api` backend takes the following configuration options:
-| Name | Type | Example | Description |
-| :----------- | :------ | :------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------ |
-| `bucketName` | string | The name of the dedicated bucket for Anubis to store information in. |
-| `pathStyle` | boolean | `false` | If true, use path-style S3 API operations. Please consult your storage provider's documentation if you don't know what you should put here. |
+| Name | Type | Example | Description |
+| :----------- | :------ | :------------ | :------------------------------------------------------------------------------------------------------------------------------------------ |
+| `bucketName` | string | `anubis-data` | (Required) The name of the dedicated bucket for Anubis to store information in. |
+| `pathStyle` | boolean | `false` | If true, use path-style S3 API operations. Please consult your storage provider's documentation if you don't know what you should put here. |
:::note
@@ -279,7 +367,7 @@ store:
:::note
-You can also use [Redis](http://redis.io/) with Anubis.
+You can also use [Redis™](http://redis.io/) with Anubis.
:::
@@ -291,15 +379,17 @@ This backend is ideal if you are running multiple instances of Anubis in a worke
| Does your service get a lot of traffic? | ✅ Yes |
| Do you want to store data persistently when Anubis restarts? | ✅ Yes |
| Do you run Anubis without mutable filesystem storage? | ✅ Yes |
-| Do you have Redis or Valkey installed? | ✅ Yes |
+| Do you have Redis™ or Valkey installed? | ✅ Yes |
#### Configuration
The `valkey` backend takes the following configuration options:
-| Name | Type | Example | Description |
-| :---- | :----- | :---------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------- |
-| `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. |
+| Name | Type | Example | Description |
+| :--------- | :----- | :---------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------ |
+| `cluster` | bool | `false` | If true, use [Redis™ Clustering](https://redis.io/topics/cluster-spec) for storing Anubis data. |
+| `sentinel` | object | `{}` | See [Redis™ Sentinel docs](#redis-sentinel) for more detail and examples |
+| `url` | string | `redis://valkey:6379/0` | The URL for the instance of Redis™ or Valkey that Anubis should store data in. This is in the same format as `REDIS_URL` in many cloud providers. |
Example:
@@ -314,6 +404,153 @@ store:
This would have the Valkey client connect to host `valkey.int.techaro.lol` on port `6379` with database `0` (the default database).
+#### Redis™ Sentinel
+
+If you are using [Redis™ Sentinel](https://redis.io/docs/latest/operate/oss_and_stack/management/sentinel/) for a high availability setup, you need to configure the `sentinel` object. This object takes the following configuration options:
+
+| Name | Type | Example | Description |
+| :----------- | :----------------------- | :-------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `addr` | string or list of string | `10.43.208.130:26379` | (Required) The host and port of the Redis™ Sentinel server. When possible, use DNS names for this. If you have multiple addresses, supply a list of them. |
+| `clientName` | string | `Anubis` | The client name reported to Redis™ Sentinel. Set this if you want to track Anubis connections to your Redis™ Sentinel. |
+| `masterName` | string | `mymaster` | (Required) The name of the master in the Redis™ Sentinel configuration. This is used to discover where to find client connection hosts/ports. |
+| `username` | string | `azurediamond` | The username used to authenticate against the Redis™ Sentinel and Redis™ servers. |
+| `password` | string | `hunter2` | The password used to authenticate against the Redis™ Sentinel and Redis™ servers. |
+
+## Logging management
+
+Anubis has very verbose logging out of the box. This is intentional and allows administrators to be sure that it is working merely by watching it work in real time. Some administrators may not appreciate this level of logging out of the box. As such, Anubis lets you customize details about how it logs data.
+
+Anubis uses a practice called [structured logging](https://stackify.com/what-is-structured-logging-and-why-developers-need-it/) to emit log messages with key-value pair context. In order to make analyzing large amounts of log messages easier, Anubis encodes all logs in JSON. This allows you to use any tool that can parse JSON to perform analytics or monitor for issues.
+
+Anubis exposes the following logging settings in the policy file:
+
+| Name | Type | Example | Description |
+| :----------- | :----------------------- | :-------------- | :--------------------------------------------------------------------------------------------------------------------------------------- |
+| `level` | [log level](#log-levels) | `info` | The logging level threshold. Any logs that are at or above this threshold will be drained to the sink. Any other logs will be discarded. |
+| `sink` | string | `stdio`, `file` | The sink where the logs drain to as they are being recorded in Anubis. |
+| `parameters` | object | | Parameters for the given logging sink. This will vary based on the logging sink of choice. See below for more information. |
+| `asn` | bool | `true`, `false` | Add ASN information to logs/metrics. (Requires a Thoth client configured) |
+
+Anubis supports the following logging sinks:
+
+1. `file`: logs are emitted to a file that is rotated based on size and age. Old log files are compressed with gzip to save space. This allows for better integration with users that decide to use legacy service managers (OpenRC, FreeBSD's init, etc).
+2. `stdio`: logs are emitted to the standard error stream of the Anubis process. This allows runtimes such as Docker, Podman, Systemd, and Kubernetes to capture logs with their native logging subsystems without any additional configuration.
+
+### Log levels
+
+Anubis uses Go's [standard library `log/slog` package](https://pkg.go.dev/log/slog) to emit structured logs. By default, Anubis logs at the [Info level](https://pkg.go.dev/log/slog#Level), which is fairly verbose out of the box. Here are the possible logging levels in Anubis:
+
+| Log level | Use in Anubis |
+| :-------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `DEBUG` | The raw unfiltered torrent of doom. Only use this if you are actively working on Anubis or have very good reasons to use it. |
+| `INFO` | The default logging level, fairly verbose in order to make it easier for automation to parse. |
+| `WARN` | A "more silent" logging level. Much less verbose. Some things that are now at the `info` level need to be moved up to the `warn` level in future patches. |
+| `ERROR` | Only log error messages. |
+
+Additionally, you can set a "slightly higher" log level if you need to, such as:
+
+```yaml
+logging:
+ sink: stdio
+ level: "INFO+1"
+```
+
+This isn't currently used by Anubis, but will be in the future for "slightly important" information.
+
+### `file` sink
+
+The `file` sink makes Anubis write its logs to the filesystem and rotate them out when the log file meets certain thresholds. This logging sink takes the following parameters:
+
+| Name | Type | Example | Description |
+| :------------- | :-------------- | :-------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `file` | string | `/var/log/anubis.log` | The file where Anubis logs should be written to. Make sure the user Anubis is running as has write and file creation permissions to this directory. |
+| `maxBackups` | number | `3` | The number of old log files that should be maintained when log files are rotated out. |
+| `maxBytes` | number of bytes | `67108864` (64Mi) | The maximum size of each log file before it is rotated out. |
+| `maxAge` | number of days | `7` | If a log file is more than this many days old, rotate it out. |
+| `compress` | boolean | `true` | If true, compress old log files with gzip. This should be set to `true` and is only exposed as an option for dealing with legacy workflows where there is magical thinking about log files at play. |
+| `useLocalTime` | boolean | `false` | If true, use the system local time zone to create log filenames instead of UTC. This should almost always be set to `false` and is only exposed for legacy workflows where there is magical thinking about time zones at play. |
+
+```yaml
+logging:
+ sink: file
+ parameters:
+ file: "./var/anubis.log"
+ maxBackups: 3 # keep at least 3 old copies
+ maxBytes: 67108864 # each file can have up to 64 Mi of logs
+ maxAge: 7 # rotate files out every n days
+ compress: true # gzip-compress old log files
+ useLocalTime: false # timezone for rotated files is UTC
+```
+
+When files are rotated out, the old files will be named after the rotation timestamp in [RFC 3339 format](https://www.rfc-editor.org/rfc/rfc3339).
+
+:::note
+
+If you are running Anubis in systemd via a native package, the default systemd unit settings are very restrictive and will forbid writing to folders in `/var/log`. In order to fix this, please make a [drop-in unit](https://www.flatcar.org/docs/latest/setup/systemd/drop-in-units/) like the following:
+
+```text
+# /etc/systemd/anubis@instance-name.service.d/50-var-log-readwrite.conf
+[Service]
+ReadWritePaths=/run /var/log/anubis
+```
+
+Once you write this to the correct place, reload the systemd configuration:
+
+```text
+sudo systemctl daemon-reload
+```
+
+And then restart Anubis:
+
+```text
+sudo systemctl restart anubis@instance-name
+```
+
+You may be required to make drop-ins for each Anubis instance depending on the facts and circumstances of your deployment.
+
+:::
+
+### `stdio` sink
+
+By default, Anubis logs everything to the standard error stream of its process. This requires no configuration:
+
+```yaml
+logging:
+ sink: stdio
+```
+
+If you use a service orchestration platform that does not capture the standard error stream of processes, you need to use a different logging sink.
+
+## Honeypot configuration
+
+Anubis has support for [dataset poisoning](./honeypot/overview.mdx). Out of the box this is enabled so that administrators can feed fail2ban pipelines. This works by adding an entry point to the honeypot maze in every page like this:
+
+```html
+
+```
+
+Any hits that match the URL `^/.within.website/x/cmd/anubis/api/honeypot/.*` are _malicious_ and should be blocked. A spec-compliant HTML parser will not parse this link, so any parser that does and visits it is categorically in the wrong.
+
+The following configuration options are supported:
+
+```yaml
+## Honeypot configuration
+honeypot:
+ ## If not set, Anubis will assume you meant `true`.
+ enabled: true
+ ## Currently the only supported method is the naive method.
+ ## See https://anubis.techaro.lol/docs/admin/honeypot/overview#implementation-notes for more information
+ implementation: naive
+```
+
+For more information about this feature, consult the [Dataset Poisoning](./honeypot/overview.mdx) page.
+
## Risk calculation for downstream services
In case your service needs it for risk calculation reasons, Anubis exposes information about the rules that any requests match using a few headers:
diff --git a/docs/docs/admin/robots2policy.mdx b/docs/docs/admin/robots2policy.mdx
index 30f0eab08..fdbab6ff7 100644
--- a/docs/docs/admin/robots2policy.mdx
+++ b/docs/docs/admin/robots2policy.mdx
@@ -12,6 +12,7 @@ Install directly with Go:
```bash
go install github.com/TecharoHQ/anubis/cmd/robots2policy@latest
```
+
## Usage
Basic conversion from URL:
@@ -35,8 +36,8 @@ robots2policy -input robots.txt -action DENY -format json
## Options
| Flag | Description | Default |
-|-----------------------|--------------------------------------------------------------------|---------------------|
-| `-input` | robots.txt file path or URL (use `-` for stdin) | *required* |
+| --------------------- | ------------------------------------------------------------------ | ------------------- |
+| `-input` | robots.txt file path or URL (use `-` for stdin) | _required_ |
| `-output` | Output file (use `-` for stdout) | stdout |
| `-format` | Output format: `yaml` or `json` | `yaml` |
| `-action` | Action for disallowed paths: `ALLOW`, `DENY`, `CHALLENGE`, `WEIGH` | `CHALLENGE` |
@@ -47,6 +48,7 @@ robots2policy -input robots.txt -action DENY -format json
## Example
Input robots.txt:
+
```txt
User-agent: *
Disallow: /admin/
@@ -57,6 +59,7 @@ Disallow: /
```
Generated policy:
+
```yaml
- name: robots-txt-policy-disallow-1
action: CHALLENGE
@@ -77,8 +80,8 @@ Generated policy:
Save the output and import it in your main policy file:
```yaml
-import:
- - path: "./robots-policy.yaml"
+bots:
+ - import: "./robots-policy.yaml"
```
The tool handles wildcard patterns, user-agent specific rules, and blacklisted bots automatically.
diff --git a/docs/docs/admin/roles/_category_.json b/docs/docs/admin/roles/_category_.json
new file mode 100644
index 000000000..d087404dc
--- /dev/null
+++ b/docs/docs/admin/roles/_category_.json
@@ -0,0 +1,8 @@
+{
+ "label": "Server Roles",
+ "position": 40,
+ "link": {
+ "type": "generated-index",
+ "description": "Various server roles you will need to keep in mind with Anubis."
+ }
+}
diff --git a/docs/docs/admin/roles/oci-registry.mdx b/docs/docs/admin/roles/oci-registry.mdx
new file mode 100644
index 000000000..56e64abcb
--- /dev/null
+++ b/docs/docs/admin/roles/oci-registry.mdx
@@ -0,0 +1,10 @@
+# OCI Registries
+
+If you are serving an OCI registry behind Anubis, you will need to import the `(data)/clients/docker-client.yaml` file in order to make sure that OCI registry clients can download images:
+
+```yaml
+bots:
+ - import: (data)/meta/default-config.yaml
+ - import: (data)/clients/docker-client.yaml
+# ... the rest of your config
+```
diff --git a/docs/docs/design/_category_.json b/docs/docs/design/_category_.json
index 65764e855..87b1010ee 100644
--- a/docs/docs/design/_category_.json
+++ b/docs/docs/design/_category_.json
@@ -5,4 +5,4 @@
"type": "generated-index",
"description": "How Anubis is designed and the tradeoffs it makes."
}
-}
\ No newline at end of file
+}
diff --git a/docs/docs/design/how-anubis-works.mdx b/docs/docs/design/how-anubis-works.mdx
index e274320c8..69b32b094 100644
--- a/docs/docs/design/how-anubis-works.mdx
+++ b/docs/docs/design/how-anubis-works.mdx
@@ -104,4 +104,4 @@ This ensures that the token has enough metadata to prove that the token is valid
## JWT signing
-Anubis uses an ed25519 keypair to sign the JWTs issued when challenges are passed. Anubis will generate a new ed25519 keypair every time it starts. At this time, there is no way to share this keypair between instance of Anubis, but that will be addressed in future versions.
+Anubis uses an ed25519 keypair to sign the JWTs issued when challenges are passed. Anubis will generate a new ed25519 keypair every time it starts. At this time, there is no way to share this keypair between instances of Anubis, but that will be addressed in future versions.
diff --git a/docs/docs/developer/CODE_OF_CONDUCT.md b/docs/docs/developer/CODE_OF_CONDUCT.md
new file mode 100644
index 000000000..a545f08a3
--- /dev/null
+++ b/docs/docs/developer/CODE_OF_CONDUCT.md
@@ -0,0 +1,132 @@
+# Contributor Covenant Code of Conduct
+
+## Our Pledge
+
+We as members, contributors, and leaders pledge to make participation in our
+community a harassment-free experience for everyone, regardless of age, body
+size, visible or invisible disability, ethnicity, sex characteristics, gender
+identity and expression, level of experience, education, socio-economic status,
+nationality, personal appearance, race, caste, color, religion, or sexual
+identity and orientation.
+
+We pledge to act and interact in ways that contribute to an open, welcoming,
+diverse, inclusive, and healthy community.
+
+## Our Standards
+
+Examples of behavior that contributes to a positive environment for our
+community include:
+
+- Demonstrating empathy and kindness toward other people
+- Being respectful of differing opinions, viewpoints, and experiences
+- Giving and gracefully accepting constructive feedback
+- Accepting responsibility and apologizing to those affected by our mistakes,
+ and learning from the experience
+- Focusing on what is best not just for us as individuals, but for the overall
+ community
+
+Examples of unacceptable behavior include:
+
+- The use of sexualized language or imagery, and sexual attention or advances of
+ any kind
+- Trolling, insulting or derogatory comments, and personal or political attacks
+- Public or private harassment
+- Publishing others' private information, such as a physical or email address,
+ without their explicit permission
+- Other conduct which could reasonably be considered inappropriate in a
+ professional setting
+
+## Enforcement Responsibilities
+
+Community leaders are responsible for clarifying and enforcing our standards of
+acceptable behavior and will take appropriate and fair corrective action in
+response to any behavior that they deem inappropriate, threatening, offensive,
+or harmful.
+
+Community leaders have the right and responsibility to remove, edit, or reject
+comments, commits, code, wiki edits, issues, and other contributions that are
+not aligned to this Code of Conduct, and will communicate reasons for moderation
+decisions when appropriate.
+
+## Scope
+
+This Code of Conduct applies within all community spaces, and also applies when
+an individual is officially representing the community in public spaces.
+Examples of representing our community include using an official email address,
+posting via an official social media account, or acting as an appointed
+representative at an online or offline event.
+
+## Enforcement
+
+Instances of abusive, harassing, or otherwise unacceptable behavior may be
+reported to the community leaders responsible for enforcement at
+[INSERT CONTACT METHOD].
+All complaints will be reviewed and investigated promptly and fairly.
+
+All community leaders are obligated to respect the privacy and security of the
+reporter of any incident.
+
+## Enforcement Guidelines
+
+Community leaders will follow these Community Impact Guidelines in determining
+the consequences for any action they deem in violation of this Code of Conduct:
+
+### 1. Correction
+
+**Community Impact**: Use of inappropriate language or other behavior deemed
+unprofessional or unwelcome in the community.
+
+**Consequence**: A private, written warning from community leaders, providing
+clarity around the nature of the violation and an explanation of why the
+behavior was inappropriate. A public apology may be requested.
+
+### 2. Warning
+
+**Community Impact**: A violation through a single incident or series of
+actions.
+
+**Consequence**: A warning with consequences for continued behavior. No
+interaction with the people involved, including unsolicited interaction with
+those enforcing the Code of Conduct, for a specified period of time. This
+includes avoiding interactions in community spaces as well as external channels
+like social media. Violating these terms may lead to a temporary or permanent
+ban.
+
+### 3. Temporary Ban
+
+**Community Impact**: A serious violation of community standards, including
+sustained inappropriate behavior.
+
+**Consequence**: A temporary ban from any sort of interaction or public
+communication with the community for a specified period of time. No public or
+private interaction with the people involved, including unsolicited interaction
+with those enforcing the Code of Conduct, is allowed during this period.
+Violating these terms may lead to a permanent ban.
+
+### 4. Permanent Ban
+
+**Community Impact**: Demonstrating a pattern of violation of community
+standards, including sustained inappropriate behavior, harassment of an
+individual, or aggression toward or disparagement of classes of individuals.
+
+**Consequence**: A permanent ban from any sort of public interaction within the
+community.
+
+## Attribution
+
+This Code of Conduct is adapted from the [Contributor Covenant][homepage],
+version 2.1, available at
+[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
+
+Community Impact Guidelines were inspired by
+[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
+
+For answers to common questions about this code of conduct, see the FAQ at
+[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
+[https://www.contributor-covenant.org/translations][translations].
+
+[homepage]: https://www.contributor-covenant.org
+[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
+[Mozilla CoC]: https://github.com/mozilla/diversity
+[FAQ]: https://www.contributor-covenant.org/faq
+[translations]: https://www.contributor-covenant.org/translations
diff --git a/docs/docs/developer/CONTRIBUTING.md b/docs/docs/developer/CONTRIBUTING.md
new file mode 100644
index 000000000..a3d53da64
--- /dev/null
+++ b/docs/docs/developer/CONTRIBUTING.md
@@ -0,0 +1,161 @@
+# Contributing to Anubis
+
+Anubis is a Web AI Firewall Utility (WAIFU) written in Go. This is security software, correctness and test coverage matters.
+
+## Build & Run
+
+Prerequisites:
+
+- Go 1.26 or later.
+- Node.js (any supported LTS version).
+- Rust version 1.80 or later.
+- Optionally: Wasmtime
+- Optionally: binaryen version 130 (exact match).
+- esbuild, gzip, zstd, brotli, msitools
+
+If you have [Homebrew](https://brew.sh) installed: install all development tools at once with `brew bundle`.
+
+### Useful commands
+
+```shell
+npm ci # install source-level dependencies
+npm run assets # build JS/CSS (required before any Go build/test)
+npm run build # assets + go build -> ./var/anubis
+npm run dev # assets + run locally with --use-remote-address
+npm run format # format all code to project standards
+```
+
+When using `npm run dev`, Anubis runs on `http://localhost:8923` and points to `http://localhost:3000`. The documentation site listens on port `3000` by default.
+
+## Testing
+
+```shell
+# Run all unit tests (assets must be built first)
+npm run test
+
+# Run integration tests with real browsers
+npm run test:integration
+
+# Run a single test by name
+go test -run TestClampIP ./internal/
+
+# Run a single test file's package
+go test ./lib/config/
+
+# Run tests with verbose output
+go test -v -run TestBotValid ./lib/config/
+```
+
+### Smoke tests
+
+Anubis has functionality tests called smoke tests. Read more about them in the [smoke testing page](./smoke-tests.mdx).
+
+## Linting
+
+```shell
+go vet ./...
+go tool staticcheck ./...
+go tool govulncheck ./...
+```
+
+## Code Generation
+
+Anubis makes heavy use of code generation with `go generate` for HTML templating with [templ](https://templ.guide/), CSS optimization, and JavaScript building.
+
+Run all code generation logic with `npm run assets`.
+
+## Project Layout
+
+Important folders:
+
+- `cmd/anubis`: Main entrypoint for the project. Parses command line flags and starts listening over HTTP.
+- `lib/*`: The core library for Anubis and all of its features. This is internal code that is made public for ease of downstream consumption by [BotStopper](../admin/botstopper.mdx).
No API stability is guaranteed. Use at your own risk.
+- `internal/*`: Actual internal code that is private to the implementation of Anubis. If you need to use a package in this, please copy it out and manually vendor it in your own project.
+- `test/*`: [Smoke tests](./smoke-tests.mdx).
+- `web`: Frontend HTML templates.
+- `xess`: Frontend CSS framework and build logic.
+
+## Code Style
+
+Anubis is written in several programming languages. Please keep these guidelines in mind:
+
+### Go
+
+This project follows standard Go idioms. These include:
+
+- The standard library is already a dependency. Use the standard library as much as possible.
+- Avoid package import aliases unless you have no other choice.
+- Use `go tool goimports` to format code. Run with `npm run format`.
+- Use sentinel errors as package-level variables prefixed with `Err` (such as `ErrImportIsInvalid`). Wrap errors with [`fmt.Errorf`](https://pkg.go.dev/fmt#Errorf)'s `%w` formatting verb. Compare against sentinel errors in tests with `errors.Is`.
+- Use [`log/slog`](https://pkg.go.dev/log/slog) for all logging. Use the local logger variable named `lg` or create it from the `Server` instance. Preload context with [`lg.With`](https://pkg.go.dev/log/slog#With).
+- Pass logger instances to functions as a variable right after the context argument.
+- Do not log messages with a level above `Debug` unless the condition being logged is important for administrators to know.
+- Administrators have configured fail2ban pipelines on Anubis' logging output. Keep this in mind when changing any aspect of any log line.
+- Be conservative in what you send but liberal in what you accept.
+- Use [table-driven tests](https://go.dev/wiki/TableDrivenTests) when writing test code.
+- Use [`t.Helper()`](https://pkg.go.dev/testing#T.Helper) in helper code (setup/teardown scaffolding).
+- Use [`t.Cleanup()`](https://pkg.go.dev/testing#T.Cleanup) to tear down per-test or per-suite scaffolding.
+
+### Configuration parsing
+
+The configuration file is the main interface administrators have into Anubis. Keep these things in mind when adding features to the configuration file:
+
+- Return all configuration errors at once from validators (`Valid() error` methods that return many errors at once with `errors.Join`) so administrators can fix all their configuration problems at once.
+- Enumerations should use strong types with validation logic for parsing remote input.
+- All configuration values should have both `json` and `yaml` struct tags set.
+- Use pointer values for optional configuration values.
+
+### TypeScript
+
+Please follow these guidelines:
+
+- Prefer writing all front-end code in TypeScript. It is mostly there to ensure that development is done correctly.
+- Try to avoid JavaScript features newer than Chrome 75 unless you have a polyfill for backwards compatibility.
+- Keep functions small.
+- Use `const` by default and `let` if values need to change.
+- Make the code as unambiguous as possible.
+
+### HTML Templates
+
+Anubis uses [templ](https://templ.guide) for generating HTML on the server.
+
+- Running `go generate` or `npm run assets` must regenerate HTML templates.
+- Templates receive typed Go parameters.
+- Templates are for presentation only. Any business logic belongs in Go, not templ.
+
+## Commit Messages
+
+Commit messages follow the [**Conventional Commits**](https://www.conventionalcommits.org/en/v1.0.0/) format:
+
+```text
+[optional scope]:
+
+[optional body]
+
+[optional footer(s)]
+```
+
+**Types**: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`, `chore`, `revert`
+
+- Avoid breaking changes when possible. If you must introduce a breaking change, add `!` after the type/scope and include `BREAKING CHANGE:` with the reason for the breaking change in the bottom of your commit message.
+- Keep descriptions concise, imperative, lowercase, and without trailing punctuation.
+- All git commits must include a [Developer Certificate of Origin](https://en.wikipedia.org/wiki/Developer_Certificate_of_Origin). TL;DR: `git commit --signoff`.
+
+Mark commits as closing or fixing issues in trailers. For example:
+
+```text
+Closes: #1234
+Fixes: #1234
+Replaces: #1234
+Ref: TecharoHQ/yeet#4
+```
+
+## PR Checklist
+
+When you are ready to send your contribution as a pull request, please do the following:
+
+- Add description of changes to the `[Unreleased]` section of `docs/docs/CHANGELOG.md`.
+- Add test cases for bug fixes and behavior changes.
+- Add a [smoke test](./smoke-tests.mdx) if applicable.
+- Run integration tests: `npm run test:integration`.
+- All commits must have [verified (signed) signatures](./signed-commits.md).
diff --git a/docs/docs/developer/_category_.json b/docs/docs/developer/_category_.json
index cf3805e05..f1843c5db 100644
--- a/docs/docs/developer/_category_.json
+++ b/docs/docs/developer/_category_.json
@@ -1,8 +1,4 @@
{
"label": "Developer guides",
- "position": 50,
- "link": {
- "type": "generated-index",
- "description": "Guides and suggestions to make Anubis development go smoothly for everyone."
- }
-}
\ No newline at end of file
+ "position": 50
+}
diff --git a/docs/docs/developer/ai-coding-policy.md b/docs/docs/developer/ai-coding-policy.md
new file mode 100644
index 000000000..f644d7538
--- /dev/null
+++ b/docs/docs/developer/ai-coding-policy.md
@@ -0,0 +1,31 @@
+# AI Coding Policy
+
+The Anubis repository has `AGENTS.md` and `CLAUDE.md` files in it. These files are there to point AI agent tools at the [contribution guidelines](./CONTRIBUTING.md) and to add the following text to context windows:
+
+## Attribution Requirements
+
+AI agents must disclose what tool and model they are using in the "Assisted-by" commit footer:
+
+```text
+Assisted-by: [Model Name] via [Tool Name]
+```
+
+Example:
+
+```text
+Assisted-by: GLM 4.6 via Claude Code
+```
+
+This is here intentionally so that low-effort AI agent use is detected. This makes AI agents tattle on themselves.
+
+## Guidelines
+
+If your use of AI agent tools is largely respectful of the fact that maintainers have limited time for review, you will likely not run into issues.
+
+We kindly ask the following:
+
+- Pull requests that contain commits with this metadata attached will have their reviews be deprioritized to make room for human authored work.
+- People that abuse the system with a flood of low-quality contributions ("AI slop") will be banned upon their first infraction.
+- Please understand the actions and implications of every contribution you make, regardless of what tools were used in its creation.
+- Please do not put direct AI agent output in documentation or error messages.
+- If you really are going to submit an AI-authored PR, please make sure that your code has sufficient test coverage. Look into skills such as [go-table-driven-tests](https://www.skills.sh/tigrisdata/skills/go-table-driven-tests).
diff --git a/docs/docs/developer/building-anubis.md b/docs/docs/developer/building-anubis.md
index 63bafa443..f857f593d 100644
--- a/docs/docs/developer/building-anubis.md
+++ b/docs/docs/developer/building-anubis.md
@@ -1,45 +1,55 @@
---
-title: Building Anubis without Docker
+title: Packaging Anubis
---
+Anubis is a web application written in Go with components in JavaScript. Packaging it should be similar to other applications with the same constraints.
+
:::note
-These instructions may work, but for right now they are informative for downstream packagers more than they are ready-made instructions for administrators wanting to run Anubis on their servers. Pre-made binary package support is being tracked in [#156](https://github.com/TecharoHQ/anubis/issues/156).
+Please do not sidestep the build logic shipped by Anubis. We assume that the build logic shipped by Anubis is run. Any deviations from the semantics of this build logic may result in bugs that we will be blamed for. Please make our lives easier by not reinventing the wheel.
:::
## Entirely from source
-If you are doing a build entirely from source, here's what you need to do:
+In order to build a production-ready binary of Anubis, you need the following packages in your environment:
-:::info
+- [Go](https://go.dev) at least version 1.26
+- [esbuild](https://esbuild.github.io/)
+- [Rust and Cargo](https://rust-lang.org) at least version 1.80 with the [wasm32-unknown-unknown](https://doc.rust-lang.org/rustc/platform-support/wasm32-unknown-unknown.html) target installed
+- [Node.JS & NPM](https://nodejs.org/en) (latest LTS)
+- `gzip`
+- `zstd`
+- `brotli`
+- Optionally: [Wasmtime](https://wasmtime.dev/) or [binaryen version 130](https://github.com/WebAssembly/binaryen/releases#release-version_130).
-If you maintain a package for Anubis v1.15.x or older, you will need to update your package build. You may want to use one of the half-baked tarballs if your distro/environment of choice makes it difficult to use npm.
+:::note
-:::
+The binaryen version match must be exact. If it is not met, then Anubis' build system will fall back to a bundled version. If neither Wasmtime or binaryen version 130 are installed, Anubis will fall back to using a bootstrap compiler to run these required tools.
-### Tools needed
+:::
-In order to build a production-ready binary of Anubis, you need the following packages in your environment:
+Anubis does not require a C compiler to build, meaning it is fully compatible with cross-compilation.
-- [Go](https://go.dev) at least version 1.24 - the programming language that Anubis is written in
-- [esbuild](https://esbuild.github.io/) - the JavaScript bundler Anubis uses for its production JS assets
-- [Node.JS & NPM](https://nodejs.org/en) - manages some build dependencies
-- `gzip` - compresses production JS (part of coreutils)
-- `zstd` - compresses production JS
-- `brotli` - compresses production JS
+:::note
-To upgrade your version of Go without system package manager support, install `golang.org/dl/go1.24.2` (this can be done from any version of Go):
+To upgrade your version of Go without system package manager support, install `golang.org/dl/go1.27.0` (this can be done from any version of Go):
```text
-go install golang.org/dl/go1.24.2@latest
-go1.24.2 download
+go install golang.org/dl/go1.27.0@latest
+go1.27.0 download
```
+Then make a symbolic link from `go1.27.0` to `go` in your `$PATH`.
+
+:::
+
### Install dependencies
```text
-make deps
+npm ci
+go mod download
+cargo fetch
```
This will download Go and NPM dependencies.
@@ -47,7 +57,7 @@ This will download Go and NPM dependencies.
### Building static assets
```text
-make assets
+npm run assets
```
This will build all static assets (CSS, JavaScript) for distribution.
@@ -55,12 +65,10 @@ This will build all static assets (CSS, JavaScript) for distribution.
### Building Anubis to the `./var` folder
```text
-make build
+npm run build
```
-From this point it is up to you to make sure that `./var/anubis` and `./var/robots2policy` end up in
-the right place. You may want to consult the `./run` folder for useful files such as a systemd unit
-and `anubis.env.default` file.
+From this point it is up to you to make sure that `./var/anubis` and `./var/robots2policy` end up in the right place. You may want to consult the `./run` folder for useful files such as a systemd unit and the `anubis.env.default` file. The default configuration file is in `./data/botPolicies.yaml`.
## "Pre-baked" tarball
@@ -77,7 +85,7 @@ When using this tarball, all you need to do is build `./cmd/anubis`:
make prebaked-build
```
-Anubis will be built to `./var/anubis` and the robots2policy tool to `./var/robots2policy`.
+Anubis will be built to `./var/anubis` and the robots2policy tool to `./var/robots2policy`. Install these via your normal packaging process.
## Development dependencies
diff --git a/docs/docs/developer/code-quality.md b/docs/docs/developer/code-quality.md
deleted file mode 100644
index 6dcc44ab5..000000000
--- a/docs/docs/developer/code-quality.md
+++ /dev/null
@@ -1,31 +0,0 @@
----
-title: Code quality guidelines
----
-
-When submitting code to Anubis, please take the time to consider the fact that this project is security software. If things go bad, bots can pummel sites into oblivion. This is not ideal for uptime.
-
-As such, code reviews will be a bit more strict than you have seen in other projects. This is not people trying to be mean, this is a side effect of taking the problem seriously.
-
-When making code changes, try to do the following:
-
-- If you're submitting a bugfix, add a test case for it
-- If you're changing the JavaScript, make sure the integration tests pass (`npm run test:integration`)
-
-## Commit messages
-
-Anubis follows the Go project's conventions for commit messages. In general, an ideal commit message should read like this:
-
-```text
-path/to/folder: brief description of the change
-
-If the change is subtle, has implementation consequences, or is otherwise
-not entirely self-describing: take the time to spell out why. If things
-are very subtle, please also amend the documentation accordingly
-```
-
-The subject of a commit message should be the second half of the sentence "This commit changes the Anubis project to:". Here's a few examples:
-
-- `disable DroneBL by default`
-- `port the challenge to WebAssembly`
-
-The extended commit message is also your place to give rationale for a new feature. When maintainers are reviewing your code, they will use this to figure out if the burden from feature maintainership is worth the merge.
diff --git a/docs/docs/developer/cspell.md b/docs/docs/developer/cspell.md
new file mode 100644
index 000000000..02f0a89ce
--- /dev/null
+++ b/docs/docs/developer/cspell.md
@@ -0,0 +1,34 @@
+# Spellchecking
+
+Anubis uses [CSpell](https://cspell.org/) to ensure words are spelled correctly. The project dictionary is at `.vscode/project-words.txt`.
+
+## Manually testing spellchecking
+
+To manually run spellchecking against the files you have modified before commit:
+
+```sh
+npm run test:spelling
+```
+
+## Adding words to the dictionary
+
+To add a word to the project dictionary (eg: `${WORD}`), do the following:
+
+### Append the word to the dictionary
+
+```sh
+echo ${WORD} >> .vscode/project-words.txt
+```
+
+### Sort the dictionary list
+
+```sh
+npm run test:spelling:sort
+```
+
+### Commit the results
+
+```sh
+git add .vscode/project-words.txt
+git commit -sm "chore: spelling"
+```
diff --git a/docs/docs/developer/index.mdx b/docs/docs/developer/index.mdx
new file mode 100644
index 000000000..58ba56b46
--- /dev/null
+++ b/docs/docs/developer/index.mdx
@@ -0,0 +1,22 @@
+---
+title: Developer Guides
+---
+
+Thank you for wanting to contribute to Anubis, the Web AI Firewall Utility that protects web applications from abusive scrapers! This guide is your cheat sheet to making sure your contributions meet standards and can be merged as soon as possible.
+
+:::note
+
+This program is mostly written by a single person in a basement in Canada. Sometimes issues and pull requests get missed. Please remember the human on the other end.
+
+:::
+
+## Links
+
+- [AI coding policy](./ai-coding-policy.md)
+- [Code of Conduct](./CODE_OF_CONDUCT.md)
+- [Contribution guidelines](./CONTRIBUTING.md)
+- [Local development](./local-dev.md)
+- [Packaging Anubis](./building-anubis.md)
+- [Signed commits](./signed-commits.md)
+- [Smoke tests](./smoke-tests.mdx)
+- [Spellchecking](./cspell.md)
diff --git a/docs/docs/developer/local-dev.md b/docs/docs/developer/local-dev.md
index aadf2ef5b..cb1cf6f42 100644
--- a/docs/docs/developer/local-dev.md
+++ b/docs/docs/developer/local-dev.md
@@ -17,6 +17,8 @@ Anubis requires the following tools to be installed to do local development:
- [Go](https://go.dev) - the programming language that Anubis is written in
- [esbuild](https://esbuild.github.io/) - the JavaScript bundler Anubis uses for its production JS assets
- [Node.JS & NPM](https://nodejs.org/en) - manages some build dependencies
+- Optionally: [Wasmtime](https://wasmtime.dev/) - used to run the fallback vendored version of binaryen committed to this repository.
+- Optionally: [binaryen version 130](https://github.com/WebAssembly/binaryen/releases/tag/version_130) (exact match) - optimizes and compiles WebAssembly code so that it runs on all browsers Anubis supports.
- `gzip` - compresses production JS (part of coreutils)
- `zstd` - compresses production JS
- `brotli` - compresses production JS
@@ -64,27 +66,20 @@ DOCKER_REPO=registry.host/org/repo DOCKER_METADATA_OUTPUT_TAGS=registry.host/org
For more information, see [Building native packages is complicated](https://xeiaso.net/blog/2025/anubis-packaging/) and [#156: Debian, RPM, and binary tarball packages](https://github.com/TecharoHQ/anubis/issues/156).
-Install `yeet`:
-
-:::note
-
-`yeet` will soon be moved to a dedicated TecharoHQ repository. This is currently done in a hacky way in order to get this ready for user feedback.
-
-:::
+Install the dependencies for Anubis:
```text
-go install within.website/x/cmd/yeet@v1.13.4
+npm ci
```
-Install the dependencies for Anubis:
+Build the static assets:
```text
-npm ci
-go mod download
+npm run assets
```
-Build the packages into `./var`:
+Build the packages into `./var/packages`:
```text
-yeet
+go tool yeet --package-dest-dir ./var/packages
```
diff --git a/docs/docs/developer/smoke-tests.mdx b/docs/docs/developer/smoke-tests.mdx
new file mode 100644
index 000000000..9ec025f99
--- /dev/null
+++ b/docs/docs/developer/smoke-tests.mdx
@@ -0,0 +1,112 @@
+# Smoke testing
+
+Anubis has three types of tests:
+
+1. Unit tests in `_test.go` files, which test individual parts of the Anubis code.
+2. Integration tests in package `internal/test`, which tests common scenarios that Anubis is used with.
+3. Smoke tests in `test`, which test server infrastructure commonly used with Anubis.
+
+This guide helps you write a smoke test so you can make sure a given configuration is known to be supported because CI runs it constantly.
+
+## What is a smoke test?
+
+In Anubis, a smoke test is a folder with an executable file named `test.sh`. If the script returns exit code 0 (zero), it passed. If it returns any other exit code, it failed and is retried up to 10 (ten) times.
+
+### Shell script (simple)
+
+Here is an example smoke test that builds Anubis, points it to a simple HTTP endpoint, starts it up, and makes sure `curl` can reach that endpoint:
+
+```sh
+#!/usr/bin/env bash
+# test/example/test.sh
+
+set -euo pipefail
+
+function cleanup() {
+ pkill -P $$
+}
+
+trap cleanup EXIT SIGINT
+
+# Build static assets
+(cd ../.. && npm ci && npm run assets)
+
+go tool anubis --help 2>/dev/null ||:
+
+go run ../cmd/unixhttpd &
+
+go tool anubis \
+ --policy-fname ./anubis.yaml \
+ --use-remote-address \
+ --target=unix://$(pwd)/unixhttpd.sock &
+
+backoff-retry node ./test.mjs
+```
+
+```js
+// test.mjs
+
+async function fetchData() {
+ return fetch("http://localhost:8923/")
+ .then((resp) => {
+ if (resp.status !== 200) {
+ throw new Error(`wanted status 200, got status: ${resp.status}`);
+ }
+ return resp;
+ })
+ .then((resp) => resp.text());
+}
+
+(async () => {
+ const data = await fetchData();
+ if (!data.includes("test.mjs")) {
+ console.log(
+ "HTML response did not include `test.mjs` file, meaning Anubis is blocking this test",
+ );
+ process.exit(1);
+ }
+ console.log(data);
+ console.log("All is well");
+ process.exit(0);
+})();
+```
+
+### Docker compose (complicated)
+
+Some tests may require starting up infrastructure with `docker compose`. If this is the case, create a `docker-compose.yaml` file with the needed infrastructure and build the Anubis Docker image with `build_anubis_ko`:
+
+```sh
+#!/usr/bin/env bash
+# test/example/test.sh
+
+set -eo pipefail
+
+export VERSION=$GITHUB_COMMIT-test
+export KO_DOCKER_REPO=ko.local
+
+set -u
+
+source ../lib/lib.sh
+
+build_anubis_ko
+
+docker compose up -d
+
+backoff-retry node ./test.mjs
+```
+
+## Registering the test in CI
+
+All smoke tests are registered in matrix subjobs in `.github/workflows/smoke-tests.yml`. This allocates and uses a new CI worker per test. This appears slightly wasteful at first glance, however these tests are doing a lot of work that assumes port numbers in fiddly ways. It's easier to have dedicated machines for them than having to worry about sharing port numbers.
+
+To add a test named `linear-search`:
+
+```diff
+ - healthcheck
+ - i18n
+ - lightpanda
++ - linear-search
+ - log-file
+ - nginx
+ - palemoon/amd64
+```
diff --git a/docs/docs/index.mdx b/docs/docs/index.mdx
index ba1de7bf9..19a28970e 100644
--- a/docs/docs/index.mdx
+++ b/docs/docs/index.mdx
@@ -29,11 +29,26 @@ Anubis is brought to you by sponsors and donors like:
height="64"
/>
+
+
+
+
+
+
### Gold Tier
-
-
+
+
+
+
+
+
+
+
+
+
+
-
+
+
+
+
+
+
+
+
+
+
## Overview
@@ -91,19 +115,19 @@ For live chat, please join the [Patreon](https://patreon.com/cadey) or join [Git
## Star History
-
+
diff --git a/docs/docs/user/_category_.json b/docs/docs/user/_category_.json
index ce1dbafe1..2e646499e 100644
--- a/docs/docs/user/_category_.json
+++ b/docs/docs/user/_category_.json
@@ -5,4 +5,4 @@
"type": "generated-index",
"description": "Information for users on sites that use Anubis."
}
-}
\ No newline at end of file
+}
diff --git a/docs/docs/user/frequently-asked-questions.mdx b/docs/docs/user/frequently-asked-questions.mdx
index 3fa8da4f8..852343568 100644
--- a/docs/docs/user/frequently-asked-questions.mdx
+++ b/docs/docs/user/frequently-asked-questions.mdx
@@ -1,24 +1,45 @@
# Frequently Asked Questions
-## Why can't you just put details about the proof of work challenge into the challenge page so I don't need to run JavaScript?
+## Why are challenge details not embedded into the human-readable part of challenge pages?
-A common question is something along the lines of "why can't you give me a shell script to run the challenge on my laptop so that I don't have to enable JavaScript". Malware has been known to show an interstitial that [asks the user to paste something into their run box on Windows](https://www.malwarebytes.com/blog/news/2025/03/fake-captcha-websites-hijack-your-clipboard-to-install-information-stealers), which will then make that machine a zombie in a botnet.
+Many people have asked why the challenge details are not embedded into challenge pages so they do not have to run JavaScript. There are many instances where malware [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). This malware adds your computer to a botnet, steal information, or encrypt all file contents for a ransom.
-It would be in very bad taste to associate a security product such as Anubis with behavior similar to what malware uses. This would destroy user trust in the product and potentially result in reputational damage for the contributors. When at all possible, we want to avoid this happening.
+In order for Anubis' protection to be effective, Anubis must not give users instructions akin to this kind of malware. Anubis does not require user input and must function automatically via JavaScript. Anubis must not require users to paste code into terminals or run boxes to pass challenges.
-Technically inclined users are easily able to understand how the proof of work check works by either reading the JavaScript on the page or [reading the source code of the JavaScript program](https://github.com/TecharoHQ/anubis/tree/main/web/js). Please note that the format of the challenges and the algorithms used to solve them are liable to change without notice and are not considered part of the public API of Anubis. When such a change occurs, this will break your workarounds.
+Technically inclined users are able to understand how the proof of work challenge works and write their own solver. The format of the challenges and algorithms used are liable to change without notice. Any details about challenges are not part of the public API of Anubis. When such a change occurs, those workarounds will break.
-If [sufficient funding is raised](https://github.com/TecharoHQ/anubis/discussions/278), a browser extension that packages the proof of work checks and looks for Anubis challenge pages to solve them will be created.
+## Why does Anubis use Web Workers?
-## Why does Anubis use [Web Workers](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Using_web_workers) to do its proof of work challenge?
+Anubis uses [Web Workers](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Using_web_workers) to execute challenge logic for two main reasons:
-Anubis uses [Web Workers](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Using_web_workers) to do its proof of work challenge for two main reasons:
+1. Browsers block the main rendering thread when JavaScript executes. The challenge logic comprises many blocking math operations. Executing challenge logic on the main thread can cause browsers to hang and not respond to user input. Running challenge logic in Web Workers fixes this.
+2. Web Workers enable executing JavaScript code across multiple CPU threads. Multithreading enables Anubis challenges to run in parallel across all system cores, making the challenge page complete as fast as possible. Anubis challenges scale horizontally because modern hardware advancements are mostly around parallelism.
-1. The proof of work operation is a lot of serially blocking calls. If you do serially blocking calls in JavaScript, some browsers will hang and not respond to user input. This is bad user experience. Using a Web Worker allows the browser to do this computation in the background so your browser will not hang.
-2. Web Workers allow you to do multithreaded execution of JavaScript code. This lets Anubis run its checks in parallel across all your system cores so that the challenge can complete as fast as possible. In the last decade, most CPU advancements have come from making cores and code extremely parallel. Using Web Workers lets Anubis take advantage of your hardware as much as possible so that the challenge finishes as fast as possible.
-
-If you use a browser extension such as [JShelter](https://jshelter.org/), you will need to [modify your JShelter configuration](./known-broken-extensions.md#jshelter) to allow Anubis' proof of work computation to complete.
+If you use a browser extension such as [JShelter](https://jshelter.org/), you will need to [modify settings](./known-broken-extensions.md#jshelter) to allow challenges to complete.
## Does Anubis mine Bitcoin?
No. Anubis does not mine Bitcoin or any other cryptocurrency.
+
+## I disabled Just-in-time compilation in my browser. Why is Anubis slow?
+
+Anubis challenges run many complicated math operations in a tight loop. [Just-in-time (JIT) compilation](https://en.wikipedia.org/wiki/Just-in-time_compilation) optimizes JavaScript execution by a significant degree. The JIT enables browsers to execute code as if it was natively written for your computer. Anubis' JavaScript challenges benefit from JIT complication, running much faster with the JIT enabled.
+
+Without the JIT enabled, all JavaScript programs run through a slow interpreter. This slow interpreter translates single lines of JavaScript into many dozens of machine instructions instead of JIT-ing down to single machine instructions. It takes longer to emulate the JavaScript environment to add numbers than it does to have the JIT add the numbers directly.
+
+Some users choose to disable the JIT as a security hardening measure. Depending on threat profiles, this may be a reasonable action. Keep in mind that disabling the browser feature that makes Anubis fast makes Anubis slow.
+
+If you have disabled the JIT and find Anubis checks slow, please re-enable the JIT. There is no way for Anubis to work around you choosing to disable the browser feature that makes Anubis fast.
+
+## What versions of browsers does Anubis support?
+
+Anubis supports at least the following:
+
+- Google Chrome/Chromium 75 and later
+- Mozilla Firefox 120 and later
+- Safari 10 and later
+- Pale Moon 33 and later
+
+Anubis may support other browsers, but development prioritizes the above browsers. If you discover compatibility issues, please file a bug report with OS, browser, and hardware model number information.
+
+If you work on a browser that does not work with Anubis and want us to automatically test it, please [submit a functional test](https://github.com/TecharoHQ/anubis/tree/main/test/palemoon/amd64) so that we know of failures instantly.
diff --git a/docs/docs/user/known-broken-extensions.md b/docs/docs/user/known-broken-extensions.md
index 864b5dc08..2d23405a4 100644
--- a/docs/docs/user/known-broken-extensions.md
+++ b/docs/docs/user/known-broken-extensions.md
@@ -2,7 +2,7 @@
title: List of known browser extensions that can break Anubis
---
-This page contains a list of all of the browser extensions that are known to break Anubis' functionality and their associated GitHub issues, along with instructions on how to work around the issue.
+This page outlines browser extensions known to break Anubis' client code and workarounds.
## [JShelter](https://jshelter.org/)
@@ -12,21 +12,22 @@ This page contains a list of all of the browser extensions that are known to bre
| GitHub issue | https://github.com/TecharoHQ/anubis/issues/25 |
| Be aware of | [What are Web Workers, and what are the threats that I face?](https://jshelter.org/faq/#what-are-web-workers-and-what-are-the-threats-that-i-face) |
-### Workaround steps (recommended):
+### Workaround 1
-1. Click on the JShelter badge icon (typically in the toolbar next to your navigation bar; if you cannot locate the icon, see [this question](https://jshelter.org/faq/#can-i-see-a-jshelter-badge-icon-next-to-my-navigation-bar-i-want-to-interact-with-the-extension-easily-and-avoid-going-through-settings)).
+Disable WebWorker protection for the given site.
+
+1. Click on the JShelter badge icon.
2. Expand JavaScript Shield settings by clicking on the `Modify` button.
3. Click on the `Detail tweaks of JS shield for this site` button.
-4. Click and drag the `WebWorker` slider to the left until `Remove` is replaced by the `Unprotected`.
-5. Refresh the page, for example, by clicking on the `Refresh page` button at the top of the JShelter pop up window.
-6. You might want to restore the Worker settings once you go through the challenge.
+4. Click and drag the `WebWorker` slider to the left, showing `Unprotected`.
+5. Refresh the page.
-### Workaround steps (alternative if you do not want to dig in JShelter's pop up):
+### Workaround 2
-1. Click on the JShelter badge icon (typically in the toolbar next to your navigation bar; if you cannot locate the icon, see [this question](https://jshelter.org/faq/#can-i-see-a-jshelter-badge-icon-next-to-my-navigation-bar-i-want-to-interact-with-the-extension-easily-and-avoid-going-through-settings)).
+1. Click on the JShelter badge icon.
2. Expand JavaScript Shield settings by clicking on the `Modify` button.
3. Choose "Turn JavaScript Shield off"
-4. Refresh the page, for example, by clicking on the `Refresh page` button at the top of the JShelter pop up window.
+4. Refresh the page.
:::note
@@ -34,7 +35,7 @@ Taking these actions will remove all protections of JavaScript Shield for all pa
:::
-### Workaround steps (alternative if you do not like JShelter's pop up):
+### Workaround 3
1. Open JShelter extension settings
2. Click on JS Shield details
diff --git a/docs/docs/user/known-instances.md b/docs/docs/user/known-instances.md
index ddbc52ba5..21f884b6a 100644
--- a/docs/docs/user/known-instances.md
+++ b/docs/docs/user/known-instances.md
@@ -4,67 +4,91 @@ title: List of known websites using Anubis
This page contains a non-exhaustive list with all websites using Anubis.
--
- The Linux Foundation
- - https://git.kernel.org/
- - https://lore.kernel.org/
-
-- https://gitlab.gnome.org/
-- https://scioly.org/
+- https://azurlane.koumakan.jp/
- https://bugs.winehq.org/
-- https://svnweb.freebsd.org/
-- https://trac.ffmpeg.org/
-- https://xeiaso.net/
-- https://source.puri.sm/
-- https://git.enlightenment.org/
-- https://superlove.sayitditto.net/
-- https://linktaco.com/
-- https://jaredallard.dev/
-- https://dev.sanctum.geek.nz/
+- https://bugzilla.proxmox.com
- https://canine.tools/
-- https://git.lupancham.net/
+- https://clew.se/
+- https://code.hackerspace.pl/
+- https://codeberg.org/
- https://dev.haiku-os.org
-- http://code.hackerspace.pl/
-- https://wiki.archlinux.org/
+- https://dev.sanctum.geek.nz/
+- https://ebird.org/
+- https://extensions.typo3.org/
+- https://fabulous.systems/
+- https://git.aya.so/
- https://git.devuan.org/
-- https://hydra.nixos.org/
-- https://codeberg.org/
-- https://www.cfaarchive.org/
+- https://git.enlightenment.org/
+- https://gitea.com/
- https://gitlab.freedesktop.org/
-- https://bugzilla.proxmox.com
-- https://hofstede.io/
-- https://www.indiemag.fr/
-- https://reddit.nerdvpn.de/
+- https://gitlab.gnome.org/
+- https://gitlab.postmarketos.org/
- https://hosted.weblate.org/
-- https://gitea.com/
+- https://hydra.nixos.org/
+- https://lab.civicrm.org/
+- https://marginalia-search.com/
+- https://mozillazine.org/
- https://openwrt.org/
-- https://minihoot.site
-- https://catgirl.click/
-- https://wiki.dolphin-emu.org/
-- https://squirreljme.cc/
-- https://gitlab.postmarketos.org/
-- https://wiki.koha-community.org/
-- https://extensions.typo3.org/
-- https://ebird.org/
-- https://fabulous.systems/
-- https://coinhoards.org/
- https://pluralpedia.org/
-- https://git.aya.so/
-- https://marginalia-search.com/
+- https://reddit.nerdvpn.de/
- https://repositorio.ufrn.br/home/
-- https://mozillazine.org/
-- https://clew.se/
-- https://tumfatig.net/
- https://rpmfusion.org/
+- https://scioly.org/
+- https://source.puri.sm/
+- https://squirreljme.cc/
+- https://superlove.sayitditto.net/
+- https://svnweb.freebsd.org/
+- https://tumfatig.net/
+- https://wiki.archlinux.org/
- https://wiki.freepascal.org/
-- https://azurlane.koumakan.jp/
-- https://lab.civicrm.org/
-- https://git.door43.org/
+- https://wiki.koha-community.org/
+- https://www.cfaarchive.org/
+- https://www.indiemag.fr/
+- https://xeiaso.net/
+-
+ archlinux32.org
+ - https://www.archlinux32.org/packages/
+ - https://bbs.archlinux32.org/
+ - https://bugs.archlinux32.org/
+
+-
+ Dolphin Emulator
+ - https://forums.dolphin-emu.org/
+ - https://wiki.dolphin-emu.org/
+
+-
+ Duke University
+ - https://repository.duke.edu/
+ - https://archives.lib.duke.edu/
+ - https://find.library.duke.edu/
+ - https://nicholas.duke.edu/
+
+-
+ FFmpeg
+ - https://git.ffmpeg.org/
+ - https://trac.ffmpeg.org/
+
+-
+ Forschungszentrum Jülich
+ - https://juser.fz-juelich.de/
+
-
FreeCAD
- https://forum.freecad.org/
- https://wiki.freecad.org/
+-
+ HackLab.TO
+ - https://hacklab.to/
+ - https://knowledge.hacklab.to/
+
+-
+ hebis (Alliance of Hessian Libraries)
+ - https://ubmr.hds.hebis.de/
+ - https://tufind.hds.hebis.de/
+ - https://karla.hds.hebis.de/
+ - and many more (see https://www.hebis.de/dienste/hebis-discovery-system/)
+
-
ReactOS
- https://reactos.org/forum
@@ -77,6 +101,11 @@ This page contains a non-exhaustive list with all websites using Anubis.
- https://forums.scummvm.org/
- https://wiki.scummvm.org/
+-
+ Slackware
+ - https://git.slackware.nl/
+ - https://git.liveslak.org/
+
-
Sourceware
- https://sourceware.org/cgit
@@ -87,40 +116,12 @@ This page contains a non-exhaustive list with all websites using Anubis.
- https://gcc.gnu.org/cgit
-
- The United Nations
- - https://policytoolbox.iiep.unesco.org/
-
--
- hebis (Alliance of Hessian Libraries)
- - https://ubmr.hds.hebis.de/
- - https://tufind.hds.hebis.de/
- - https://karla.hds.hebis.de/
- - and many more (see https://www.hebis.de/dienste/hebis-discovery-system/)
-
--
- Duke University
- - https://repository.duke.edu/
- - https://archives.lib.duke.edu/
- - https://find.library.duke.edu/
- - https://nicholas.duke.edu/
-
--
- Forschungszentrum Jülich
- - https://juser.fz-juelich.de/
-
--
- archlinux32.org
- - https://www.archlinux32.org/packages/
- - https://bbs.archlinux32.org/
- - https://bugs.archlinux32.org/
-
--
- HackLab.TO
- - https://hacklab.to/
- - https://knowledge.hacklab.to/
+ The Linux Foundation
+ - https://git.kernel.org/
+ - https://lore.kernel.org/
-
- Slackware
- - https://git.slackware.nl/
- - https://git.liveslak.org/
+ Valve Corporation
+ - https://developer.valvesoftware.com/wiki/Main_Page
+ - https://wiki.teamfortress.com/wiki/Main_Page
diff --git a/docs/docs/user/why-see-challenge.md b/docs/docs/user/why-see-challenge.md
index 046fec357..33dfb5e31 100644
--- a/docs/docs/user/why-see-challenge.md
+++ b/docs/docs/user/why-see-challenge.md
@@ -2,8 +2,8 @@
title: Why is Anubis showing up on a website?
---
-You are seeing Anubis because the administrator of that website has set up [Anubis](https://github.com/TecharoHQ/anubis) to protect the server against the scourge of [AI companies aggressively scraping websites](https://thelibre.news/foss-infrastructure-is-under-attack-by-ai-companies/). This can and does cause downtime for the websites, which makes their resources inaccessible for everyone.
+Anubis shows up on a website because the administrator set up Anubis to protect their server. There are many abusive scrapers [destroying websites with too many requests](https://thelibre.news/foss-infrastructure-is-under-attack-by-ai-companies/), making servers collapse under the pressure. This causes downtime for the websites, making resources inaccessible for everyone.
-Anubis is a compromise. Anubis uses a [proof-of-work](/docs/design/why-proof-of-work) scheme in the vein of [Hashcash](https://en.wikipedia.org/wiki/Hashcash), a proposed proof-of-work scheme for reducing email spam. The idea is that at individual scales the additional load is ignorable, but at mass scraper levels it adds up and makes scraping much more expensive.
+Anubis is a web application firewall that uses heuristics to discover suspect clients and present them a simple challenge. The idea behind these challenges is that they are easy to solve for individuals, but prohibitively expensive to solve en masse for abusive scraping.
-Ultimately, this is a hack whose real purpose is to give a "good enough" placeholder solution so that more time can be spent on fingerprinting and identifying headless browsers (EG: via how they do font rendering) so that the challenge proof of work page doesn't need to be presented to users that are much more likely to be legitimate.
+Research and development continues such that Anubis shows challenge pages less often. Please keep updated with Anubis' development to learn more about the progress in this matter.
diff --git a/docs/docusaurus.config.ts b/docs/docusaurus.config.ts
index df12939b4..e686522f4 100644
--- a/docs/docusaurus.config.ts
+++ b/docs/docusaurus.config.ts
@@ -1,62 +1,62 @@
-import { themes as prismThemes } from 'prism-react-renderer';
-import type { Config } from '@docusaurus/types';
-import type * as Preset from '@docusaurus/preset-classic';
+import { themes as prismThemes } from "prism-react-renderer";
+import type { Config } from "@docusaurus/types";
+import type * as Preset from "@docusaurus/preset-classic";
// This runs in Node.js - Don't use client-side code here (browser APIs, JSX...)
const config: Config = {
- title: 'Anubis',
- tagline: 'Weigh the soul of incoming HTTP requests to protect your website!',
- favicon: 'img/favicon.ico',
+ title: "Anubis",
+ tagline: "Weigh the soul of incoming HTTP requests to protect your website!",
+ favicon: "img/favicon.ico",
// Set the production url of your site here
- url: 'https://anubis.techaro.lol',
+ url: "https://anubis.techaro.lol",
// Set the // pathname under which your site is served
// For GitHub pages deployment, it is often '//'
- baseUrl: '/',
+ baseUrl: "/",
// GitHub pages deployment config.
// If you aren't using GitHub pages, you don't need these.
- organizationName: 'TecharoHQ', // Usually your GitHub org/user name.
- projectName: 'anubis', // Usually your repo name.
+ organizationName: "TecharoHQ", // Usually your GitHub org/user name.
+ projectName: "anubis", // Usually your repo name.
- onBrokenLinks: 'throw',
- onBrokenMarkdownLinks: 'warn',
+ onBrokenLinks: "throw",
+ onBrokenMarkdownLinks: "warn",
// Even if you don't use internationalization, you can use this field to set
// useful metadata like html lang. For example, if your site is Chinese, you
// may want to replace "en" with "zh-Hans".
i18n: {
- defaultLocale: 'en',
- locales: ['en'],
+ defaultLocale: "en",
+ locales: ["en"],
},
markdown: {
mermaid: true,
},
- themes: ['@docusaurus/theme-mermaid'],
+ themes: ["@docusaurus/theme-mermaid"],
presets: [
[
- 'classic',
+ "classic",
{
blog: {
showReadingTime: true,
feedOptions: {
- type: ['rss', 'atom', "json"],
+ type: ["rss", "atom", "json"],
xslt: true,
},
- editUrl: 'https://github.com/TecharoHQ/anubis/tree/main/docs/',
- onInlineTags: 'warn',
- onInlineAuthors: 'warn',
- onUntruncatedBlogPosts: 'throw',
+ editUrl: "https://github.com/TecharoHQ/anubis/tree/main/docs/",
+ onInlineTags: "warn",
+ onInlineAuthors: "warn",
+ onUntruncatedBlogPosts: "throw",
},
docs: {
- sidebarPath: './sidebars.ts',
- editUrl: 'https://github.com/TecharoHQ/anubis/tree/main/docs/',
+ sidebarPath: "./sidebars.ts",
+ editUrl: "https://github.com/TecharoHQ/anubis/tree/main/docs/",
},
theme: {
- customCss: './src/css/custom.css',
+ customCss: "./src/css/custom.css",
},
} satisfies Preset.Options,
],
@@ -67,47 +67,47 @@ const config: Config = {
respectPrefersColorScheme: true,
},
// Replace with your project's social card
- image: 'img/social-card.jpg',
+ image: "img/social-card.jpg",
navbar: {
- title: 'Anubis',
+ title: "Anubis",
logo: {
- alt: 'A happy jackal woman with brown hair and red eyes',
- src: 'img/favicon.webp',
+ alt: "A happy jackal woman with brown hair and red eyes",
+ src: "img/favicon.webp",
},
items: [
- { to: '/blog', label: 'Blog', position: 'left' },
+ { to: "/blog", label: "Blog", position: "left" },
{
- type: 'docSidebar',
- sidebarId: 'tutorialSidebar',
- position: 'left',
- label: 'Docs',
+ type: "docSidebar",
+ sidebarId: "tutorialSidebar",
+ position: "left",
+ label: "Docs",
},
{
- to: '/docs/admin/botstopper',
+ to: "/docs/admin/botstopper",
label: "Unbranded Version",
- position: "left"
+ position: "left",
},
{
- href: 'https://github.com/TecharoHQ/anubis',
- label: 'GitHub',
- position: 'right',
+ href: "https://github.com/TecharoHQ/anubis",
+ label: "GitHub",
+ position: "right",
},
{
- href: 'https://github.com/sponsors/Xe',
+ href: "https://github.com/sponsors/Xe",
label: "Sponsor the Project",
- position: 'right'
+ position: "right",
},
],
},
footer: {
- style: 'dark',
+ style: "dark",
links: [
{
- title: 'Docs',
+ title: "Docs",
items: [
{
- label: 'Intro',
- to: '/docs/',
+ label: "Intro",
+ to: "/docs/",
},
{
label: "Installation",
@@ -116,32 +116,32 @@ const config: Config = {
],
},
{
- title: 'Community',
+ title: "Community",
items: [
{
- label: 'GitHub Discussions',
- href: 'https://github.com/TecharoHQ/anubis/discussions',
+ label: "GitHub Discussions",
+ href: "https://github.com/TecharoHQ/anubis/discussions",
},
{
- label: 'Bluesky',
- href: 'https://bsky.app/profile/techaro.lol',
+ label: "Bluesky",
+ href: "https://bsky.app/profile/techaro.lol",
},
],
},
{
- title: 'More',
+ title: "More",
items: [
{
- label: 'Blog',
- to: '/blog',
+ label: "Blog",
+ to: "/blog",
},
{
- label: 'GitHub',
- href: 'https://github.com/TecharoHQ/anubis',
+ label: "GitHub",
+ href: "https://github.com/TecharoHQ/anubis",
},
{
- label: 'Status',
- href: 'https://techarohq.github.io/status/'
+ label: "Status",
+ href: "https://techarohq.github.io/status/",
},
],
},
@@ -153,13 +153,13 @@ const config: Config = {
darkTheme: prismThemes.dracula,
magicComments: [
{
- className: 'code-block-diff-add-line',
- line: 'diff-add'
+ className: "code-block-diff-add-line",
+ line: "diff-add",
},
{
- className: 'code-block-diff-remove-line',
- line: 'diff-remove'
- }
+ className: "code-block-diff-remove-line",
+ line: "diff-remove",
+ },
],
},
} satisfies Preset.ThemeConfig,
diff --git a/docs/manifest/cfg/anubis/botPolicies.yaml b/docs/manifest/cfg/anubis/botPolicies.yaml
index ffb2d15ae..d1c8a281c 100644
--- a/docs/manifest/cfg/anubis/botPolicies.yaml
+++ b/docs/manifest/cfg/anubis/botPolicies.yaml
@@ -49,7 +49,6 @@ bots:
# action: CHALLENGE
# challenge:
# difficulty: 16 # impossible
- # report_as: 4 # lie to the operator
# algorithm: slow # intentionally waste CPU cycles and time
- name: rss-feed-blog
@@ -75,6 +74,8 @@ bots:
weight:
adjust: -5
+ - import: (data)/clients/small-internet-browsers/*.yaml
+
# The weight thresholds for when to trigger individual challenges. Any
# CHALLENGE will take precedence over this.
#
@@ -105,7 +106,6 @@ thresholds:
# https://anubis.techaro.lol/docs/admin/configuration/challenges/metarefresh
algorithm: metarefresh
difficulty: 1
- report_as: 1
# For clients that are browser-like but have either gained points from custom rules or
# report as a standard browser.
- name: moderate-suspicion
@@ -120,9 +120,8 @@ thresholds:
# This challenge proves the client can run a webapp written with Preact.
# The preact webapp simply loads, calculates the SHA-256 checksum of the
# challenge data, and forwards that to the client.
- algorithm: preact
- difficulty: 1
- report_as: 1
+ algorithm: sha256
+ difficulty: 16
- name: mild-proof-of-work
expression:
all:
@@ -131,21 +130,23 @@ thresholds:
action: CHALLENGE
challenge:
# https://anubis.techaro.lol/docs/admin/configuration/challenges/proof-of-work
- algorithm: fast
- difficulty: 2 # two leading zeros, very fast for most clients
- report_as: 2
+ algorithm: argon2id
+ difficulty: 4 # two leading zeros, very fast for most clients
# For clients that are browser like and have gained many points from custom rules
- name: extreme-suspicion
expression: weight >= 30
action: CHALLENGE
challenge:
# https://anubis.techaro.lol/docs/admin/configuration/challenges/proof-of-work
- algorithm: fast
- difficulty: 4
- report_as: 4
+ algorithm: argon2id
+ difficulty: 6
dnsbl: false
+logging:
+ sink: stdio
+ level: "WARN"
+
impressum:
footer: |
This website is hosted by Techaro. If you have any complaints or notes about the service, please contact support@techaro.lol and we will assist you as soon as possible.
@@ -165,7 +166,7 @@ impressum:
How the Information is used
- The information is used to enhance the vistor's experience when using the website to display personalised content and possibly advertising.
+ The information is used to enhance the visitor's experience when using the website to display personalised content and possibly advertising.
E-mail addresses will not be sold, rented or leased to 3rd parties.
@@ -207,3 +208,15 @@ store:
backend: bbolt
parameters:
path: /xe/data/anubis/data.bdb
+
+## Honeypot configuration
+honeypot:
+ ## If not set, Anubis will assume you meant `true`.
+ enabled: true
+ ## Currently the only supported implementation is the naive method.
+ ## See https://anubis.techaro.lol/docs/admin/honeypot/overview#implementation-notes for more information
+ implementation: naive
+ ## Setting ip_log_file makes Anubis log IP addresses that hit honeypot URLs to a file.
+ ## Anubis writes to this file either once per minute or after Anubis records 32
+ ## kilobytes of IP addresses, whichever happens first.
+ #ip_log_file: ./var/honeypot.addrs
diff --git a/docs/manifest/ingress.yaml b/docs/manifest/ingress.yaml
index 52478361d..8ce3b9fc0 100644
--- a/docs/manifest/ingress.yaml
+++ b/docs/manifest/ingress.yaml
@@ -21,4 +21,4 @@ spec:
service:
name: anubis-docs
port:
- name: anubis
\ No newline at end of file
+ name: anubis
diff --git a/docs/manifest/onionservice.yaml b/docs/manifest/onionservice.yaml
index 81ab9729f..e0584468f 100644
--- a/docs/manifest/onionservice.yaml
+++ b/docs/manifest/onionservice.yaml
@@ -5,10 +5,10 @@ metadata:
spec:
version: 3
rules:
- - port:
- number: 80
- backend:
- service:
- name: anubis-docs
- port:
- number: 80
\ No newline at end of file
+ - port:
+ number: 80
+ backend:
+ service:
+ name: anubis-docs
+ port:
+ number: 80
diff --git a/docs/manifest/service.yaml b/docs/manifest/service.yaml
index dadac058d..1acdc45dd 100644
--- a/docs/manifest/service.yaml
+++ b/docs/manifest/service.yaml
@@ -6,9 +6,9 @@ spec:
selector:
app: anubis-docs
ports:
- - port: 80
- targetPort: 80
- name: http
- - port: 8081
- targetPort: 8081
- name: anubis
+ - port: 80
+ targetPort: 80
+ name: http
+ - port: 8081
+ targetPort: 8081
+ name: anubis
diff --git a/docs/package-lock.json b/docs/package-lock.json
index dbac4465a..90f34e278 100644
--- a/docs/package-lock.json
+++ b/docs/package-lock.json
@@ -14,6 +14,7 @@
"@mdx-js/react": "^3.0.0",
"clsx": "^2.0.0",
"prism-react-renderer": "^2.3.0",
+ "raw-loader": "^4.0.2",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
@@ -161,6 +162,7 @@
"resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.29.0.tgz",
"integrity": "sha512-cZ0Iq3OzFUPpgszzDr1G1aJV5UMIZ4VygJ2Az252q4Rdf5cQMhYEIKArWY/oUjMhQmosM8ygOovNq7gvA9CdCg==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@algolia/client-common": "5.29.0",
"@algolia/requester-browser-xhr": "5.29.0",
@@ -308,6 +310,7 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz",
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.3",
@@ -2145,6 +2148,7 @@
}
],
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=18"
},
@@ -2167,6 +2171,7 @@
}
],
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=18"
}
@@ -2247,6 +2252,7 @@
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz",
"integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
@@ -2610,6 +2616,7 @@
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz",
"integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
@@ -3523,6 +3530,7 @@
"resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-docs/-/plugin-content-docs-3.8.1.tgz",
"integrity": "sha512-oByRkSZzeGNQByCMaX+kif5Nl2vmtj2IHQI2fWjCfCootsdKZDPFLonhIp5s3IGJO7PLUfe0POyw0Xh/RrGXJA==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@docusaurus/core": "3.8.1",
"@docusaurus/logger": "3.8.1",
@@ -4246,6 +4254,7 @@
"resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-3.1.0.tgz",
"integrity": "sha512-QjHtSaoameoalGnKDT3FoIl4+9RwyTmo9ZJGBdLOks/YOiWHoRDI3PUwEzOE7kEmGcV3AFcp9K6dYu9rEuKLAQ==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@types/mdx": "^2.0.0"
},
@@ -4558,6 +4567,7 @@
"resolved": "https://registry.npmjs.org/@svgr/core/-/core-8.1.0.tgz",
"integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/core": "^7.21.3",
"@svgr/babel-preset": "8.1.0",
@@ -5200,6 +5210,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.12.tgz",
"integrity": "sha512-V6Ar115dBDrjbtXSrS+/Oruobc+qVbbUxDFC1RSbRqLt5SYvxxyIDrSC85RWml54g+jfNeEMZhEj7wW07ONQhA==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -5539,6 +5550,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
+ "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -5594,6 +5606,7 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@@ -5639,6 +5652,7 @@
"resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.29.0.tgz",
"integrity": "sha512-E2l6AlTWGznM2e7vEE6T6hzObvEyXukxMOlBmVlMyixZyK1umuO/CiVc6sDBbzVH0oEviCE5IfVY1oZBmccYPQ==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@algolia/client-abtesting": "5.29.0",
"@algolia/client-analytics": "5.29.0",
@@ -6092,6 +6106,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"caniuse-lite": "^1.0.30001737",
"electron-to-chromium": "^1.5.211",
@@ -6375,6 +6390,7 @@
"resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.0.3.tgz",
"integrity": "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==",
"license": "Apache-2.0",
+ "peer": true,
"dependencies": {
"@chevrotain/cst-dts-gen": "11.0.3",
"@chevrotain/gast": "11.0.3",
@@ -7079,6 +7095,7 @@
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz",
"integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
@@ -7398,6 +7415,7 @@
"resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.32.0.tgz",
"integrity": "sha512-5JHBC9n75kz5851jeklCPmZWcg3hUe6sjqJvyk3+hVqFaKcHwHgxsjeN1yLmggoUc6STbtm9/NQyabQehfjvWQ==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=0.10"
}
@@ -7819,6 +7837,7 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
+ "peer": true,
"engines": {
"node": ">=12"
}
@@ -8977,6 +8996,7 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@@ -13596,6 +13616,7 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@@ -14170,6 +14191,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -15073,6 +15095,7 @@
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz",
"integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
@@ -15845,6 +15868,76 @@
"node": ">= 0.8"
}
},
+ "node_modules/raw-loader": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/raw-loader/-/raw-loader-4.0.2.tgz",
+ "integrity": "sha512-ZnScIV3ag9A4wPX/ZayxL/jZH+euYb6FcUinPcgiQW0+UBtEv0O6Q3lGd3cqJ+GHH+rksEv3Pj99oxJ3u3VIKA==",
+ "license": "MIT",
+ "dependencies": {
+ "loader-utils": "^2.0.0",
+ "schema-utils": "^3.0.0"
+ },
+ "engines": {
+ "node": ">= 10.13.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ },
+ "peerDependencies": {
+ "webpack": "^4.0.0 || ^5.0.0"
+ }
+ },
+ "node_modules/raw-loader/node_modules/ajv": {
+ "version": "6.12.6",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
+ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "fast-deep-equal": "^3.1.1",
+ "fast-json-stable-stringify": "^2.0.0",
+ "json-schema-traverse": "^0.4.1",
+ "uri-js": "^4.2.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/raw-loader/node_modules/ajv-keywords": {
+ "version": "3.5.2",
+ "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz",
+ "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "ajv": "^6.9.1"
+ }
+ },
+ "node_modules/raw-loader/node_modules/json-schema-traverse": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
+ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
+ "license": "MIT"
+ },
+ "node_modules/raw-loader/node_modules/schema-utils": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz",
+ "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/json-schema": "^7.0.8",
+ "ajv": "^6.12.5",
+ "ajv-keywords": "^3.5.2"
+ },
+ "engines": {
+ "node": ">= 10.13.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ }
+ },
"node_modules/rc": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
@@ -15874,6 +15967,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz",
"integrity": "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -15883,6 +15977,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0.tgz",
"integrity": "sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"scheduler": "^0.25.0"
},
@@ -15938,6 +16033,7 @@
"resolved": "https://registry.npmjs.org/@docusaurus/react-loadable/-/react-loadable-6.0.0.tgz",
"integrity": "sha512-YMMxTUQV/QFSnbgrP3tjDzLHRg7vsbMn8e9HAa8o/1iXoiomo48b7sk/kkmWEuWNDPJVlKSJRB6Y2fHqdJk+SQ==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@types/react": "*"
},
@@ -15966,6 +16062,7 @@
"resolved": "https://registry.npmjs.org/react-router/-/react-router-5.3.4.tgz",
"integrity": "sha512-Ys9K+ppnJah3QuaRiLxk+jDWOR1MekYQrlytiXxC1RyfbdsZkS5pvKAzCCr031xHixZwpnsYNT5xysdFHQaYsA==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/runtime": "^7.12.13",
"history": "^4.9.0",
@@ -17804,6 +17901,7 @@
"integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==",
"devOptional": true,
"license": "Apache-2.0",
+ "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -18151,6 +18249,7 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@@ -18398,6 +18497,7 @@
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.98.0.tgz",
"integrity": "sha512-UFynvx+gM44Gv9qFgj0acCQK2VE1CtdfwFdimkapco3hlPCJ/zeq73n2yVKimVbtm+TnApIugGhLJnkU6gjYXA==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@types/eslint-scope": "^3.7.7",
"@types/estree": "^1.0.6",
diff --git a/docs/package.json b/docs/package.json
index cde6077e6..5c5a04784 100644
--- a/docs/package.json
+++ b/docs/package.json
@@ -21,6 +21,7 @@
"@mdx-js/react": "^3.0.0",
"clsx": "^2.0.0",
"prism-react-renderer": "^2.3.0",
+ "raw-loader": "^4.0.2",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
diff --git a/docs/sidebars.ts b/docs/sidebars.ts
index 289713975..574ed394c 100644
--- a/docs/sidebars.ts
+++ b/docs/sidebars.ts
@@ -1,4 +1,4 @@
-import type {SidebarsConfig} from '@docusaurus/plugin-content-docs';
+import type { SidebarsConfig } from "@docusaurus/plugin-content-docs";
// This runs in Node.js - Don't use client-side code here (browser APIs, JSX...)
@@ -14,7 +14,7 @@ import type {SidebarsConfig} from '@docusaurus/plugin-content-docs';
*/
const sidebars: SidebarsConfig = {
// By default, Docusaurus generates a sidebar from the docs folder structure
- tutorialSidebar: [{type: 'autogenerated', dirName: '.'}],
+ tutorialSidebar: [{ type: "autogenerated", dirName: "." }],
// But you can create a sidebar manually
/*
diff --git a/docs/src/components/EnterpriseOnly/index.jsx b/docs/src/components/EnterpriseOnly/index.jsx
index bfd9486c4..ac6bab1dd 100644
--- a/docs/src/components/EnterpriseOnly/index.jsx
+++ b/docs/src/components/EnterpriseOnly/index.jsx
@@ -1,4 +1,4 @@
-import styles from './styles.module.css';
+import styles from "./styles.module.css";
export default function EnterpriseOnly({ link }) {
return (
@@ -8,4 +8,4 @@ export default function EnterpriseOnly({ link }) {
);
-}
\ No newline at end of file
+}
diff --git a/docs/src/components/EnterpriseOnly/styles.module.css b/docs/src/components/EnterpriseOnly/styles.module.css
index f3ac7a158..056cedc08 100644
--- a/docs/src/components/EnterpriseOnly/styles.module.css
+++ b/docs/src/components/EnterpriseOnly/styles.module.css
@@ -8,7 +8,9 @@
font-weight: 700;
padding: 0.5rem 1rem; /* py-2 px-4 */
border-radius: 9999px; /* rounded-full */
- box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); /* shadow-lg approximation */
+ box-shadow:
+ 0 10px 15px -3px rgba(0, 0, 0, 0.1),
+ 0 4px 6px -2px rgba(0, 0, 0, 0.05); /* shadow-lg approximation */
display: inline-flex; /* flex */
align-items: center; /* items-center */
}
diff --git a/docs/src/pages/index.tsx b/docs/src/pages/index.tsx
index 8f505a6b2..fb1be7e42 100644
--- a/docs/src/pages/index.tsx
+++ b/docs/src/pages/index.tsx
@@ -18,7 +18,10 @@ function HomepageHeader() {
{siteConfig.tagline}
-
+
Get started
diff --git a/docs/static/img/sponsors/anexia-cloudsolutions-logo.webp b/docs/static/img/sponsors/anexia-cloudsolutions-logo.webp
new file mode 100644
index 000000000..3358e41b0
Binary files /dev/null and b/docs/static/img/sponsors/anexia-cloudsolutions-logo.webp differ
diff --git a/docs/static/img/sponsors/databento-logo.webp b/docs/static/img/sponsors/databento-logo.webp
new file mode 100644
index 000000000..d5252d1b9
Binary files /dev/null and b/docs/static/img/sponsors/databento-logo.webp differ
diff --git a/docs/static/img/sponsors/ddwrt-logo.webp b/docs/static/img/sponsors/ddwrt-logo.webp
new file mode 100644
index 000000000..dca6fa7af
Binary files /dev/null and b/docs/static/img/sponsors/ddwrt-logo.webp differ
diff --git a/docs/static/img/sponsors/distrust-logo.webp b/docs/static/img/sponsors/distrust-logo.webp
deleted file mode 100644
index cda07e80d..000000000
Binary files a/docs/static/img/sponsors/distrust-logo.webp and /dev/null differ
diff --git a/docs/static/img/sponsors/felsqualle-logo.webp b/docs/static/img/sponsors/felsqualle-logo.webp
new file mode 100644
index 000000000..522d21777
Binary files /dev/null and b/docs/static/img/sponsors/felsqualle-logo.webp differ
diff --git a/docs/static/img/sponsors/gitea-logo.webp b/docs/static/img/sponsors/gitea-logo.webp
new file mode 100644
index 000000000..97e0e8fee
Binary files /dev/null and b/docs/static/img/sponsors/gitea-logo.webp differ
diff --git a/docs/static/img/sponsors/prolocation-logo.svg b/docs/static/img/sponsors/prolocation-logo.svg
new file mode 100644
index 000000000..43ffc0bf2
--- /dev/null
+++ b/docs/static/img/sponsors/prolocation-logo.svg
@@ -0,0 +1,37 @@
+
+
+
+
diff --git a/docs/static/img/sponsors/qwertiko-logo.webp b/docs/static/img/sponsors/qwertiko-logo.webp
new file mode 100644
index 000000000..1e36ffd39
Binary files /dev/null and b/docs/static/img/sponsors/qwertiko-logo.webp differ
diff --git a/docs/static/img/sponsors/spur-logo.webp b/docs/static/img/sponsors/spur-logo.webp
new file mode 100644
index 000000000..0a5630398
Binary files /dev/null and b/docs/static/img/sponsors/spur-logo.webp differ
diff --git a/docs/static/img/sponsors/unipromos.webp b/docs/static/img/sponsors/unipromos.webp
new file mode 100644
index 000000000..c0a0213e9
Binary files /dev/null and b/docs/static/img/sponsors/unipromos.webp differ
diff --git a/docs/static/img/sponsors/uvensys.webp b/docs/static/img/sponsors/uvensys.webp
new file mode 100644
index 000000000..aaa641f97
Binary files /dev/null and b/docs/static/img/sponsors/uvensys.webp differ
diff --git a/docs/static/img/sponsors/wenet-logo.webp b/docs/static/img/sponsors/wenet-logo.webp
new file mode 100644
index 000000000..39d35de3e
Binary files /dev/null and b/docs/static/img/sponsors/wenet-logo.webp differ
diff --git a/go.mod b/go.mod
index aec1f8761..a168eb515 100644
--- a/go.mod
+++ b/go.mod
@@ -1,80 +1,89 @@
module github.com/TecharoHQ/anubis
-go 1.24.2
+go 1.26.3
require (
- github.com/TecharoHQ/thoth-proto v0.4.0
- github.com/a-h/templ v0.3.924
- github.com/aws/aws-sdk-go-v2 v1.38.3
- github.com/aws/aws-sdk-go-v2/config v1.31.6
- github.com/aws/aws-sdk-go-v2/service/s3 v1.87.3
+ github.com/TecharoHQ/thoth-proto v0.5.0
+ github.com/a-h/templ v0.3.1020
+ github.com/aws/aws-sdk-go-v2 v1.45.1
+ github.com/aws/aws-sdk-go-v2/config v1.33.1
+ github.com/aws/aws-sdk-go-v2/service/s3 v1.109.1
github.com/cespare/xxhash/v2 v2.3.0
github.com/facebookgo/flagenv v0.0.0-20160425205200-fcd59fca7456
- github.com/gaissmai/bart v0.23.0
- github.com/golang-jwt/jwt/v5 v5.2.3
- github.com/google/cel-go v0.26.0
+ github.com/fahedouch/go-logrotate v0.3.0
+ github.com/fvbommel/sortorder v1.1.0
+ github.com/gaissmai/bart v0.29.0
+ github.com/golang-jwt/jwt/v5 v5.3.1
+ github.com/google/cel-go v0.28.1
github.com/google/uuid v1.6.0
+ github.com/goreleaser/fileglob v1.4.0
github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0
- github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2
+ github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.4
github.com/joho/godotenv v1.5.1
- github.com/lum8rjack/go-ja4h v0.0.0-20250606032308-3a989c6635be
- github.com/nicksnyder/go-i18n/v2 v2.6.0
- github.com/playwright-community/playwright-go v0.5200.0
- github.com/prometheus/client_golang v1.22.0
- github.com/redis/go-redis/v9 v9.11.0
+ github.com/lum8rjack/go-ja4h v0.0.0-20250828030157-fa5266d50650
+ github.com/mxschmitt/playwright-go v0.6201.1
+ github.com/neilotoole/slogt/v2 v2.0.0
+ github.com/nicksnyder/go-i18n/v2 v2.6.1
+ github.com/nikandfor/spintax v0.0.0-20181023094358-fc346b245bb3
+ github.com/prometheus/client_golang v1.24.1
+ github.com/redis/go-redis/v9 v9.22.0
github.com/sebest/xff v0.0.0-20210106013422-671bd2870b3a
- github.com/shirou/gopsutil/v4 v4.25.6
- github.com/testcontainers/testcontainers-go v0.38.0
- go.etcd.io/bbolt v1.4.2
- golang.org/x/net v0.42.0
- golang.org/x/text v0.27.0
- google.golang.org/grpc v1.74.2
+ github.com/shirou/gopsutil/v4 v4.26.8
+ github.com/testcontainers/testcontainers-go v0.44.0
+ github.com/tetratelabs/wazero v1.12.1-0.20260714071631-236c2458ed22
+ go.etcd.io/bbolt v1.5.0
+ golang.org/x/net v0.58.0
+ golang.org/x/sync v0.22.0
+ golang.org/x/sys v0.47.0
+ golang.org/x/text v0.41.0
+ google.golang.org/grpc v1.83.2
gopkg.in/yaml.v3 v3.0.1
- k8s.io/apimachinery v0.33.3
+ k8s.io/apimachinery v0.37.0
sigs.k8s.io/yaml v1.6.0
)
require (
al.essio.dev/pkg/shellescape v1.6.0 // indirect
- buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.6-20250717185734-6c6e0d3c608e.1 // indirect
- cel.dev/expr v0.24.0 // indirect
+ buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20251209175733-2a1774d88802.1 // indirect
+ cel.dev/expr v0.25.2 // indirect
dario.cat/mergo v1.0.2 // indirect
github.com/AlekSi/pointer v1.2.0 // indirect
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
- github.com/BurntSushi/toml v1.5.0 // indirect
+ github.com/BurntSushi/toml v1.6.0 // indirect
github.com/Masterminds/goutils v1.1.1 // indirect
github.com/Masterminds/semver/v3 v3.4.0 // indirect
github.com/Masterminds/sprig/v3 v3.3.0 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
- github.com/ProtonMail/go-crypto v1.3.0 // indirect
- github.com/Songmu/gitconfig v0.2.1 // indirect
- github.com/TecharoHQ/yeet v0.6.3 // indirect
+ github.com/ProtonMail/go-crypto v1.4.1 // indirect
+ github.com/Songmu/gitconfig v0.2.2 // indirect
+ github.com/TecharoHQ/yeet v0.12.1 // indirect
+ github.com/Xe/erofs v0.6.1 // indirect
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e // indirect
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
- github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.1 // indirect
- github.com/aws/aws-sdk-go-v2/credentials v1.18.10 // indirect
- github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.6 // indirect
- github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.6 // indirect
- github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.6 // indirect
- github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect
- github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.6 // indirect
- github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 // indirect
- github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.8.6 // indirect
- github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.6 // indirect
- github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.6 // indirect
- github.com/aws/aws-sdk-go-v2/service/sso v1.29.1 // indirect
- github.com/aws/aws-sdk-go-v2/service/ssooidc v1.34.2 // indirect
- github.com/aws/aws-sdk-go-v2/service/sts v1.38.2 // indirect
- github.com/aws/smithy-go v1.23.0 // indirect
+ github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20 // indirect
+ github.com/aws/aws-sdk-go-v2/credentials v1.20.1 // indirect
+ github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.19.1 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.1 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.1 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.1 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.1 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.1 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.1 // indirect
+ github.com/aws/aws-sdk-go-v2/service/signin v1.7.1 // indirect
+ github.com/aws/aws-sdk-go-v2/service/sso v1.35.1 // indirect
+ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.40.1 // indirect
+ github.com/aws/aws-sdk-go-v2/service/sts v1.47.1 // indirect
+ github.com/aws/smithy-go v1.28.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb // indirect
github.com/cavaliergopher/cpio v1.0.1 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cli/browser v1.3.0 // indirect
- github.com/cli/go-gh/v2 v2.12.1 // indirect
+ github.com/cli/go-gh/v2 v2.13.0 // indirect
github.com/cli/safeexec v1.0.1 // indirect
- github.com/cloudflare/circl v1.6.1 // indirect
+ github.com/cloudflare/circl v1.6.3 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect
@@ -84,119 +93,113 @@ require (
github.com/cyphar/filepath-securejoin v0.4.1 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/deckarep/golang-set/v2 v2.8.0 // indirect
- github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/distribution/reference v0.6.0 // indirect
+ github.com/djherbis/times v1.6.0 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
- github.com/docker/docker v28.3.3+incompatible // indirect
- github.com/docker/go-connections v0.5.0 // indirect
+ github.com/docker/go-connections v0.7.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/dop251/goja v0.0.0-20250630131328-58d95d85e994 // indirect
- github.com/ebitengine/purego v0.8.4 // indirect
+ github.com/ebitengine/purego v0.10.2 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/facebookgo/ensure v0.0.0-20160127193407-b4ab57deab51 // indirect
github.com/facebookgo/stack v0.0.0-20160209184415-751773369052 // indirect
github.com/facebookgo/subset v0.0.0-20150612182917-8dac2c3c4870 // indirect
github.com/fatih/color v1.18.0 // indirect
- github.com/felixge/httpsnoop v1.0.4 // indirect
+ github.com/felixge/httpsnoop v1.1.0 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
- github.com/go-git/go-billy/v5 v5.6.2 // indirect
- github.com/go-git/go-git/v5 v5.16.2 // indirect
- github.com/go-jose/go-jose/v3 v3.0.4 // indirect
+ github.com/go-git/go-billy/v5 v5.7.0 // indirect
+ github.com/go-git/go-git/v5 v5.16.5 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect
github.com/go-stack/stack v1.8.1 // indirect
github.com/gobwas/glob v0.2.3 // indirect
- github.com/goccy/go-yaml v1.18.0 // indirect
- github.com/gogo/protobuf v1.3.2 // indirect
+ github.com/goccy/go-yaml v1.19.2 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/google/go-github/v70 v70.0.0 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/google/pprof v0.0.0-20250630185457-6e76a2b096b5 // indirect
github.com/google/rpmpack v0.7.1 // indirect
- github.com/goreleaser/chglog v0.7.3 // indirect
- github.com/goreleaser/fileglob v1.3.0 // indirect
- github.com/goreleaser/nfpm/v2 v2.43.0 // indirect
+ github.com/goreleaser/chglog v0.7.4 // indirect
+ github.com/goreleaser/nfpm/v2 v2.45.2 // indirect
github.com/hashicorp/go-version v1.7.0 // indirect
github.com/huandu/xstrings v1.5.0 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/kevinburke/ssh_config v1.2.0 // indirect
- github.com/klauspost/compress v1.18.0 // indirect
+ github.com/klauspost/compress v1.19.1 // indirect
github.com/klauspost/pgzip v1.2.6 // indirect
- github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 // indirect
+ github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect
github.com/magiconair/properties v1.8.10 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
- github.com/moby/go-archive v0.1.0 // indirect
- github.com/moby/patternmatcher v0.6.0 // indirect
- github.com/moby/sys/sequential v0.6.0 // indirect
+ github.com/moby/go-archive v0.2.0 // indirect
+ github.com/moby/moby/api v1.55.0 // indirect
+ github.com/moby/moby/client v0.5.0 // indirect
+ github.com/moby/patternmatcher v0.6.1 // indirect
+ github.com/moby/sys/sequential v0.7.0 // indirect
github.com/moby/sys/user v0.4.0 // indirect
github.com/moby/sys/userns v0.1.0 // indirect
github.com/moby/term v0.5.2 // indirect
- github.com/morikuni/aec v1.0.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/natefinch/atomic v1.0.1 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
+ github.com/pierrec/lz4/v4 v4.1.26 // indirect
github.com/pjbgf/sha1cd v0.4.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
- github.com/prometheus/common v0.65.0 // indirect
- github.com/prometheus/procfs v0.17.0 // indirect
+ github.com/prometheus/common v0.70.1 // indirect
+ github.com/prometheus/procfs v0.21.1 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/sergi/go-diff v1.4.0 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
- github.com/sirupsen/logrus v1.9.3 // indirect
+ github.com/sirupsen/logrus v1.9.4 // indirect
github.com/skeema/knownhosts v1.3.1 // indirect
- github.com/spf13/afero v1.14.0 // indirect
- github.com/spf13/cast v1.9.2 // indirect
- github.com/stoewer/go-strcase v1.3.1 // indirect
- github.com/stretchr/testify v1.10.0 // indirect
+ github.com/spf13/afero v1.15.0 // indirect
+ github.com/spf13/cast v1.10.0 // indirect
+ github.com/stretchr/testify v1.11.1 // indirect
github.com/suzuki-shunsuke/logrus-error v0.1.4 // indirect
github.com/suzuki-shunsuke/pinact v1.6.0 // indirect
github.com/suzuki-shunsuke/urfave-cli-help-all v0.0.4 // indirect
- github.com/tklauser/go-sysconf v0.3.15 // indirect
- github.com/tklauser/numcpus v0.10.0 // indirect
- github.com/ulikunitz/xz v0.5.14 // indirect
+ github.com/tklauser/go-sysconf v0.4.0 // indirect
+ github.com/tklauser/numcpus v0.12.0 // indirect
+ github.com/ulikunitz/xz v0.5.15 // indirect
github.com/urfave/cli/v2 v2.27.7 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
gitlab.com/digitalxero/go-conventional-commit v1.0.7 // indirect
- go.opentelemetry.io/auto/sdk v1.1.0 // indirect
- go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 // indirect
- go.opentelemetry.io/otel v1.37.0 // indirect
- go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 // indirect
- go.opentelemetry.io/otel/metric v1.37.0 // indirect
- go.opentelemetry.io/otel/trace v1.37.0 // indirect
- go.opentelemetry.io/proto/otlp v1.7.0 // indirect
- go.yaml.in/yaml/v2 v2.4.2 // indirect
+ go.opentelemetry.io/auto/sdk v1.2.1 // indirect
+ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect
+ go.opentelemetry.io/otel v1.44.0 // indirect
+ go.opentelemetry.io/otel/metric v1.44.0 // indirect
+ go.opentelemetry.io/otel/trace v1.44.0 // indirect
+ go.uber.org/atomic v1.11.0 // indirect
+ go.yaml.in/yaml/v2 v2.4.4 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
- golang.org/x/crypto v0.40.0 // indirect
- golang.org/x/exp v0.0.0-20250718183923-645b1fa84792 // indirect
+ golang.org/x/crypto v0.55.0 // indirect
+ golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 // indirect
golang.org/x/exp/typeparams v0.0.0-20250718183923-645b1fa84792 // indirect
- golang.org/x/mod v0.26.0 // indirect
- golang.org/x/oauth2 v0.30.0 // indirect
- golang.org/x/sync v0.16.0 // indirect
- golang.org/x/sys v0.34.0 // indirect
- golang.org/x/telemetry v0.0.0-20250721140356-96f361d9aaf7 // indirect
- golang.org/x/term v0.33.0 // indirect
- golang.org/x/tools v0.35.0 // indirect
+ golang.org/x/mod v0.38.0 // indirect
+ golang.org/x/oauth2 v0.36.0 // indirect
+ golang.org/x/telemetry v0.0.0-20260708182218-49f421fb7959 // indirect
+ golang.org/x/term v0.45.0 // indirect
+ golang.org/x/tools v0.48.0 // indirect
golang.org/x/vuln v1.1.4 // indirect
- google.golang.org/genproto/googleapis/api v0.0.0-20250721164621-a45f3dfb1074 // indirect
- google.golang.org/genproto/googleapis/rpc v0.0.0-20250721164621-a45f3dfb1074 // indirect
- google.golang.org/protobuf v1.36.6 // indirect
+ google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect
+ google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
+ google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
- honnef.co/go/tools v0.6.1 // indirect
- mvdan.cc/sh/v3 v3.12.0 // indirect
- sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect
+ honnef.co/go/tools v0.8.1 // indirect
+ mvdan.cc/sh/v3 v3.13.0 // indirect
+ sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
)
tool (
diff --git a/go.sum b/go.sum
index c38d2f192..8ca6c66a0 100644
--- a/go.sum
+++ b/go.sum
@@ -1,9 +1,9 @@
al.essio.dev/pkg/shellescape v1.6.0 h1:NxFcEqzFSEVCGN2yq7Huv/9hyCEGVa/TncnOOBBeXHA=
al.essio.dev/pkg/shellescape v1.6.0/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890=
-buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.6-20250717185734-6c6e0d3c608e.1 h1:Lg6klmCi3v7VvpqeeLEER9/m5S8y9e9DjhqQnSCNy4k=
-buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.6-20250717185734-6c6e0d3c608e.1/go.mod h1:avRlCjnFzl98VPaeCtJ24RrV/wwHFzB8sWXhj26+n/U=
-cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY=
-cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw=
+buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20251209175733-2a1774d88802.1 h1:j9yeqTWEFrtimt8Nng2MIeRrpoCvQzM9/g25XTvqUGg=
+buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20251209175733-2a1774d88802.1/go.mod h1:tvtbpgaVXZX4g6Pn+AnzFycuRK3MOz5HJfEGeEllXYM=
+cel.dev/expr v0.25.2 h1:K6j46C81hXtZQfuX60cVWQFBJahKSE2gfRbNuvr5bFs=
+cel.dev/expr v0.25.2/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
@@ -12,10 +12,13 @@ github.com/AlekSi/pointer v1.2.0 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w=
github.com/AlekSi/pointer v1.2.0/go.mod h1:gZGfd3dpW4vEc/UlyfKKi1roIqcCgwOIvb0tSNSBle0=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
-github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
-github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
+github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
+github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/DataDog/zstd v1.5.5 h1:oWf5W7GtOLgp6bciQYDmhHHjdhYkALu6S/5Ni9ZgSvQ=
github.com/DataDog/zstd v1.5.5/go.mod h1:g4AWEaM3yOg3HYfnJ3YIawPnVdXJh9QME85blwSAmyw=
+github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
+github.com/MakeNowJust/heredoc/v2 v2.0.1 h1:rlCHh70XXXv7toz95ajQWOWQnN4WNLt0TdpZYIR/J6A=
+github.com/MakeNowJust/heredoc/v2 v2.0.1/go.mod h1:6/2Abh5s+hc3g9nbWLe9ObDIOhaRrqsyY9MWy+4JdRM=
github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI=
github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU=
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
@@ -25,24 +28,26 @@ github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSC
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
-github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
-github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
+github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM=
+github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo=
github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f h1:tCbYj7/299ekTTXpdwKYF8eBlsYsDVoggDAuAjoK66k=
github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f/go.mod h1:gcr0kNtGBqin9zDW9GOHcVntrwnjrK+qdJ06mWYBybw=
github.com/ProtonMail/gopenpgp/v2 v2.7.1 h1:Awsg7MPc2gD3I7IFac2qE3Gdls0lZW8SzrFZ3k1oz0s=
github.com/ProtonMail/gopenpgp/v2 v2.7.1/go.mod h1:/BU5gfAVwqyd8EfC3Eu7zmuhwYQpKs+cGD8M//iiaxs=
-github.com/ProtonMail/gopenpgp/v3 v3.3.0 h1:N6rHCH5PWwB6zSRMgRj1EbAMQHUAAHxH3Oo4KibsPwY=
-github.com/ProtonMail/gopenpgp/v3 v3.3.0/go.mod h1:J+iNPt0/5EO9wRt7Eit9dRUlzyu3hiGX3zId6iuaKOk=
-github.com/Songmu/gitconfig v0.2.1 h1:cZsqELfMtxWVI8ovq17gbvsR4qLfoYLAiXy5GwtJWbk=
-github.com/Songmu/gitconfig v0.2.1/go.mod h1:XM4O3SoXFnli9Ql2G7qXK2Fg7LJwf7Hs8GLFEOJlzmM=
-github.com/TecharoHQ/thoth-proto v0.4.0 h1:UbkvfgCku0Dm1R6O4ug3HOsJNnE6F3wB8x+Dpw2lzFI=
-github.com/TecharoHQ/thoth-proto v0.4.0/go.mod h1:IcGnZt3iYUZQVEa0Lwk5l4ix0hCeXlWUV1TJMZvbWx0=
-github.com/TecharoHQ/yeet v0.6.3 h1:Iev6TYt/tpFYU73kbkNIYjCObYTvlihtby+htGF4Us8=
-github.com/TecharoHQ/yeet v0.6.3/go.mod h1:ltt+PWPjnvmQJxEHsdJ5K9u3GoWK83vSLWCCp8XbxqI=
+github.com/ProtonMail/gopenpgp/v3 v3.4.0 h1:WW0VK+mZjbu5SqhWNm58TYKFxyvduiUHTfyIKs60dgY=
+github.com/ProtonMail/gopenpgp/v3 v3.4.0/go.mod h1:bGdV9f6edhmd581wzXsQCTKdH8bXBbyhkgDKPjwPc6U=
+github.com/Songmu/gitconfig v0.2.2 h1:tiQY2KZqgdgjs1C7slWd31EB8NbItN7BOKxCIHSlK4Y=
+github.com/Songmu/gitconfig v0.2.2/go.mod h1:lZ0lKL6K+xvT+QzmmGxHQgOnneiWcbhGAV2DKI6PBpM=
+github.com/TecharoHQ/thoth-proto v0.5.0 h1:Fa663s4soYiURSU8MfW9tZ2wF+LsCRSaYmjUSyagfBM=
+github.com/TecharoHQ/thoth-proto v0.5.0/go.mod h1:C/U7FqTxpVn4V/qebC/GcW32I0h9xzsmWehF27KFOJs=
+github.com/TecharoHQ/yeet v0.12.1 h1:iRY3pfnwAg52ZhjbjnRUCIaZcRrdrTHEuM7R5EQ2ZUg=
+github.com/TecharoHQ/yeet v0.12.1/go.mod h1:jGPbUZ9B8EIdnDjaZsFN655wJn9c6YfozsFHTaCBeG4=
+github.com/Xe/erofs v0.6.1 h1:GnrHZ+dxAQG9+Gy+hI06ANT9X8CA79uDwbaZqNad4bU=
+github.com/Xe/erofs v0.6.1/go.mod h1:GGxvJkCKU4yKfJ+Trap2xC8iN0HGuTUEsD5oz+olLJI=
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e h1:HjVbSQHy+dnlS6C3XajZ69NYAb5jbGNfHanvm1+iYlo=
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e/go.mod h1:3mnrkvGpurZ4ZrTDbYU84xhwXW2TjTKShSwjRi2ihfQ=
-github.com/a-h/templ v0.3.924 h1:t5gZqTneXqvehpNZsgtnlOscnBboNh9aASBH2MgV/0k=
-github.com/a-h/templ v0.3.924/go.mod h1:FFAu4dI//ESmEN7PQkJ7E7QfnSEMdcnu7QrAY8Dn334=
+github.com/a-h/templ v0.3.1020 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw=
+github.com/a-h/templ v0.3.1020/go.mod h1:A2DlK61v+K+NRoGnhmYbNYVmtYHcFO5/AisMvBdDxTM=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
@@ -51,42 +56,42 @@ github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYW
github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
-github.com/aws/aws-sdk-go-v2 v1.38.3 h1:B6cV4oxnMs45fql4yRH+/Po/YU+597zgWqvDpYMturk=
-github.com/aws/aws-sdk-go-v2 v1.38.3/go.mod h1:sDioUELIUO9Znk23YVmIk86/9DOpkbyyVb1i/gUNFXY=
-github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.1 h1:i8p8P4diljCr60PpJp6qZXNlgX4m2yQFpYk+9ZT+J4E=
-github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.1/go.mod h1:ddqbooRZYNoJ2dsTwOty16rM+/Aqmk/GOXrK8cg7V00=
-github.com/aws/aws-sdk-go-v2/config v1.31.6 h1:a1t8fXY4GT4xjyJExz4knbuoxSCacB5hT/WgtfPyLjo=
-github.com/aws/aws-sdk-go-v2/config v1.31.6/go.mod h1:5ByscNi7R+ztvOGzeUaIu49vkMk2soq5NaH5PYe33MQ=
-github.com/aws/aws-sdk-go-v2/credentials v1.18.10 h1:xdJnXCouCx8Y0NncgoptztUocIYLKeQxrCgN6x9sdhg=
-github.com/aws/aws-sdk-go-v2/credentials v1.18.10/go.mod h1:7tQk08ntj914F/5i9jC4+2HQTAuJirq7m1vZVIhEkWs=
-github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.6 h1:wbjnrrMnKew78/juW7I2BtKQwa1qlf6EjQgS69uYY14=
-github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.6/go.mod h1:AtiqqNrDioJXuUgz3+3T0mBWN7Hro2n9wll2zRUc0ww=
-github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.6 h1:uF68eJA6+S9iVr9WgX1NaRGyQ/6MdIyc4JNUo6TN1FA=
-github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.6/go.mod h1:qlPeVZCGPiobx8wb1ft0GHT5l+dc6ldnwInDFaMvC7Y=
-github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.6 h1:pa1DEC6JoI0zduhZePp3zmhWvk/xxm4NB8Hy/Tlsgos=
-github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.6/go.mod h1:gxEjPebnhWGJoaDdtDkA0JX46VRg1wcTHYe63OfX5pE=
-github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo=
-github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo=
-github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.6 h1:R0tNFJqfjHL3900cqhXuwQ+1K4G0xc9Yf8EDbFXCKEw=
-github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.6/go.mod h1:y/7sDdu+aJvPtGXr4xYosdpq9a6T9Z0jkXfugmti0rI=
-github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 h1:oegbebPEMA/1Jny7kvwejowCaHz1FWZAQ94WXFNCyTM=
-github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1/go.mod h1:kemo5Myr9ac0U9JfSjMo9yHLtw+pECEHsFtJ9tqCEI8=
-github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.8.6 h1:hncKj/4gR+TPauZgTAsxOxNcvBayhUlYZ6LO/BYiQ30=
-github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.8.6/go.mod h1:OiIh45tp6HdJDDJGnja0mw8ihQGz3VGrUflLqSL0SmM=
-github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.6 h1:LHS1YAIJXJ4K9zS+1d/xa9JAA9sL2QyXIQCQFQW/X08=
-github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.6/go.mod h1:c9PCiTEuh0wQID5/KqA32J+HAgZxN9tOGXKCiYJjTZI=
-github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.6 h1:nEXUSAwyUfLTgnc9cxlDWy637qsq4UWwp3sNAfl0Z3Y=
-github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.6/go.mod h1:HGzIULx4Ge3Do2V0FaiYKcyKzOqwrhUZgCI77NisswQ=
-github.com/aws/aws-sdk-go-v2/service/s3 v1.87.3 h1:ETkfWcXP2KNPLecaDa++5bsQhCRa5M5sLUJa5DWYIIg=
-github.com/aws/aws-sdk-go-v2/service/s3 v1.87.3/go.mod h1:+/3ZTqoYb3Ur7DObD00tarKMLMuKg8iqz5CHEanqTnw=
-github.com/aws/aws-sdk-go-v2/service/sso v1.29.1 h1:8OLZnVJPvjnrxEwHFg9hVUof/P4sibH+Ea4KKuqAGSg=
-github.com/aws/aws-sdk-go-v2/service/sso v1.29.1/go.mod h1:27M3BpVi0C02UiQh1w9nsBEit6pLhlaH3NHna6WUbDE=
-github.com/aws/aws-sdk-go-v2/service/ssooidc v1.34.2 h1:gKWSTnqudpo8dAxqBqZnDoDWCiEh/40FziUjr/mo6uA=
-github.com/aws/aws-sdk-go-v2/service/ssooidc v1.34.2/go.mod h1:x7+rkNmRoEN1U13A6JE2fXne9EWyJy54o3n6d4mGaXQ=
-github.com/aws/aws-sdk-go-v2/service/sts v1.38.2 h1:YZPjhyaGzhDQEvsffDEcpycq49nl7fiGcfJTIo8BszI=
-github.com/aws/aws-sdk-go-v2/service/sts v1.38.2/go.mod h1:2dIN8qhQfv37BdUYGgEC8Q3tteM3zFxTI1MLO2O3J3c=
-github.com/aws/smithy-go v1.23.0 h1:8n6I3gXzWJB2DxBDnfxgBaSX6oe0d/t10qGz7OKqMCE=
-github.com/aws/smithy-go v1.23.0/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI=
+github.com/aws/aws-sdk-go-v2 v1.45.1 h1:iIoG3NaLhV6UZpPXyPXlDj2I9oS8tV/nMcMnITCC6Ks=
+github.com/aws/aws-sdk-go-v2 v1.45.1/go.mod h1:bttEH6JqnUL8LepvDVfdrds/fZ5bCIxzpe3abyUrhDU=
+github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20 h1:GPRlPwz40I2B2VrBEASOA3Bi77NyeqejNLkifosX0rs=
+github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20/go.mod h1:g7PNzKcsOKWb4fkSRBA7BZVAS6Y8IcxzN+nRohhQ1Q8=
+github.com/aws/aws-sdk-go-v2/config v1.33.1 h1:bq9jze1hQ5YTCLoVxNnbp0T7rglrlOE7N9YsHqjGkEw=
+github.com/aws/aws-sdk-go-v2/config v1.33.1/go.mod h1:2A3HQwG4zaL5Tm80rc6RZj8LmWWv4WYT5v8raSz/L7A=
+github.com/aws/aws-sdk-go-v2/credentials v1.20.1 h1:Z8GRNEx0u9sDkZOq4PUnN8mjGwbUQGRzMSXpvt3d8xQ=
+github.com/aws/aws-sdk-go-v2/credentials v1.20.1/go.mod h1:uBIK00kFo95dnemqfFMTWx0X8YRqsh6ecIoCjjOkZqM=
+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.19.1 h1:YIEBqcqRnpi4Pfv0YHImtgi6czGCwKHANC7SwmUAVD0=
+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.19.1/go.mod h1:imEf0oufgAo8KAkCHhrOdqGEC0YWx1PPBQH82shSxGw=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.1 h1:pc138gM1CW+XPc60rEwUlwwuwWFQK16CI1T7v1F9Oec=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.1/go.mod h1:1+koxpPIbfBdfzP6vojm5/zTpTQ/micYwlxIiNB3TxI=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.1 h1:K0JsbZQj+1h208Ro1zHeA4l7bMp0NvRffHQ91q8Ol1s=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.1/go.mod h1:W3/vL6EtCIatICGy9ab29QhMuae+cOKPWcMxv02CO+Q=
+github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.1 h1:yhw5KD1phVyP9vijxOUzDfEtJx+bt+L63k+VfuiYFAA=
+github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.1/go.mod h1:ZW2e0d7DYlRxlS9hEiMXE47gTdX5KRN4byUiNbUpG+Q=
+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 h1:bAdDl/HkGCcGPoe25ToSHEw23VIxt6CT5fLcg111BKg=
+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19/go.mod h1:KaUzbLxv4CeSxh6ZCl9B4m7CuFenS8kUEaDs+f/DQr4=
+github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.1 h1:s67hBfG5t9rn1NCvDuB4E3QIep3UFhHPtaIqFDjV3N8=
+github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.1/go.mod h1:FpvjBMXtSNMLPmDJsWwcY5cRnqJlpS2y1R6n4pvzs4k=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.1 h1:RmmWQPREQdk9U+PfqeHW3MqZaBaNK7TpV9W3RY+b+7g=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.1/go.mod h1:0A3W4F+68ZnNk5XcNL/e9HFMwnP8RlEicFfy6eOEDyw=
+github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.1 h1:ZMbtPZZQRca+3+XYQne9PBvRiYpHZlNJJOZfE9WNfT0=
+github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.1/go.mod h1:YAGWQdCYlVCoqrzvfv3RLxO6zKwti7gsAULOGWPLYv4=
+github.com/aws/aws-sdk-go-v2/service/s3 v1.109.1 h1:kVpzaDBzOdRtOftmiSpTdQbWVqRg0kONLXijktiwXnk=
+github.com/aws/aws-sdk-go-v2/service/s3 v1.109.1/go.mod h1:CUr46sCpGAg/rHaclRyhJX0LJAmH73uWSJPPSaMUrSk=
+github.com/aws/aws-sdk-go-v2/service/signin v1.7.1 h1:mdMtSVKdQ3+mzBh+l0ogrFYZVQUCg6pJZOirA2ARsYE=
+github.com/aws/aws-sdk-go-v2/service/signin v1.7.1/go.mod h1:9IqUlsJDbUPcg6cgx3WEzXdjrbWzLDQrak0aaSqlTcI=
+github.com/aws/aws-sdk-go-v2/service/sso v1.35.1 h1:B6WFn91tobD6gG4724ONHaqrpKsoETGnv98LHe/yIGM=
+github.com/aws/aws-sdk-go-v2/service/sso v1.35.1/go.mod h1:tWuiVBUtPBr8/rgRiYS8Uf85sHcAN+G7XS3D3CEoUh8=
+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.40.1 h1:6yeYCWFvgbI2TI3K6jr9LtBNhXgJ7g4xqD+DEiaDDmM=
+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.40.1/go.mod h1:naFe83jSMuYkH+QjQPX8n1MLhBkeCFM5Lsnh5m5wz3c=
+github.com/aws/aws-sdk-go-v2/service/sts v1.47.1 h1:Sv2xPnRHlThSUtVujYuUBPI/Il8si6UPHXL8DMiB/F0=
+github.com/aws/aws-sdk-go-v2/service/sts v1.47.1/go.mod h1:mKo/CzaCz8qytGW70NG4vIIGAx1HXTlb5lHNkC5k3lk=
+github.com/aws/smithy-go v1.28.1 h1:R/nXH00c8qcfCzQVELtRw+eLQWtzv+VAIEFJ1/xxXlQ=
+github.com/aws/smithy-go v1.28.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb h1:m935MPodAbYS46DG4pJSv7WO+VECIWUQ7OJYSoTrMh4=
@@ -107,12 +112,12 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cli/browser v1.3.0 h1:LejqCrpWr+1pRqmEPDGnTZOjsMe7sehifLynZJuqJpo=
github.com/cli/browser v1.3.0/go.mod h1:HH8s+fOAxjhQoBUAsKuPCbqUuxZDhQ2/aD+SzsEfBTk=
-github.com/cli/go-gh/v2 v2.12.1 h1:SVt1/afj5FRAythyMV3WJKaUfDNsxXTIe7arZbwTWKA=
-github.com/cli/go-gh/v2 v2.12.1/go.mod h1:+5aXmEOJsH9fc9mBHfincDwnS02j2AIA/DsTH0Bk5uw=
+github.com/cli/go-gh/v2 v2.13.0 h1:jEHZu/VPVoIJkciK3pzZd3rbT8J90swsK5Ui4ewH1ys=
+github.com/cli/go-gh/v2 v2.13.0/go.mod h1:Us/NbQ8VNM0fdaILgoXSz6PKkV5PWaEzkJdc9vR2geM=
github.com/cli/safeexec v1.0.1 h1:e/C79PbXF4yYTN/wauC4tviMxEV13BwljGj0N9j+N00=
github.com/cli/safeexec v1.0.1/go.mod h1:Z/D4tTN8Vs5gXYHDCbaM1S/anmEDnJb1iW0+EJ5zx3Q=
-github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
-github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
+github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
+github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
@@ -135,22 +140,20 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/deckarep/golang-set/v2 v2.8.0 h1:swm0rlPCmdWn9mESxKOjWk8hXSqoxOp+ZlfuyaAdFlQ=
github.com/deckarep/golang-set/v2 v2.8.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4=
-github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
-github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
+github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c=
+github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
-github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI=
-github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
-github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
-github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
+github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c=
+github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/dop251/goja v0.0.0-20250630131328-58d95d85e994 h1:aQYWswi+hRL2zJqGacdCZx32XjKYV8ApXFGntw79XAM=
github.com/dop251/goja v0.0.0-20250630131328-58d95d85e994/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
-github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw=
-github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
+github.com/ebitengine/purego v0.10.2 h1:W809HbnvzAxgdm+aOvlSekrM16wGCdT/e76+9tS7gzE=
+github.com/ebitengine/purego v0.10.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
@@ -163,28 +166,30 @@ github.com/facebookgo/stack v0.0.0-20160209184415-751773369052 h1:JWuenKqqX8nojt
github.com/facebookgo/stack v0.0.0-20160209184415-751773369052/go.mod h1:UbMTZqLaRiH3MsBH8va0n7s1pQYcu3uTb8G4tygF4Zg=
github.com/facebookgo/subset v0.0.0-20150612182917-8dac2c3c4870 h1:E2s37DuLxFhQDg5gKsWoLBOB0n+ZW8s599zru8FJ2/Y=
github.com/facebookgo/subset v0.0.0-20150612182917-8dac2c3c4870/go.mod h1:5tD+neXqOorC30/tWg0LCSkrqj/AR6gu8yY8/fpw1q0=
+github.com/fahedouch/go-logrotate v0.3.0 h1:XP+dHIDgWZ1ckz43mG6gl5ASer3PZDVr755SVMyzaUQ=
+github.com/fahedouch/go-logrotate v0.3.0/go.mod h1:X49m0bvPLkk71MHNCQ1yEfVEw8W/u+qvHa/hOnhCYf4=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
-github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
-github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
+github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc=
+github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
-github.com/gaissmai/bart v0.23.0 h1:ct+78nySK5MaO+citQAUeef7QZ0ApXM3b+AYuCZYGIk=
-github.com/gaissmai/bart v0.23.0/go.mod h1:RpLtt3lWq1BoRz3AAyDAJ7jhLWBkYhVCfi+ximB2t68=
+github.com/fvbommel/sortorder v1.1.0 h1:fUmoe+HLsBTctBDoaBwpQo5N+nrCp8g/BjKb/6ZQmYw=
+github.com/fvbommel/sortorder v1.1.0/go.mod h1:uk88iVf1ovNn1iLfgUVU2F9o5eO30ui720w+kxuqRs0=
+github.com/gaissmai/bart v0.29.0 h1:wO6HGE8g9YE0Wm0bCpYxwRzfQ4+fbJKOhL64e5ACGCI=
+github.com/gaissmai/bart v0.29.0/go.mod h1:GREWQfTLRWz/c5FTOsIw+KkscuFkIV5t8Rp7Nd1Td5c=
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
-github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM=
-github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU=
+github.com/go-git/go-billy/v5 v5.7.0 h1:83lBUJhGWhYp0ngzCMSgllhUSuoHP1iEWYjsPl9nwqM=
+github.com/go-git/go-billy/v5 v5.7.0/go.mod h1:/1IUejTKH8xipsAcdfcSAlUlo2J7lkYV8GTKxAT/L3E=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
-github.com/go-git/go-git/v5 v5.16.2 h1:fT6ZIOjE5iEnkzKyxTHK1W4HGAsPhqEqiSAssSO77hM=
-github.com/go-git/go-git/v5 v5.16.2/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8=
-github.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY=
-github.com/go-jose/go-jose/v3 v3.0.4/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ=
+github.com/go-git/go-git/v5 v5.16.5 h1:mdkuqblwr57kVfXri5TTH+nMFLNUxIj9Z7F5ykFbw5s=
+github.com/go-git/go-git/v5 v5.16.5/go.mod h1:QOMLpNf1qxuSY4StA/ArOdfFR2TrKEjJiye2kel2m+M=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
@@ -201,22 +206,19 @@ github.com/go-stack/stack v1.8.1 h1:ntEHSVwIt7PNXNpgPmVfMrNhLtgjlmnZha2kOpuRiDw=
github.com/go-stack/stack v1.8.1/go.mod h1:dcoOX6HbPZSZptuspn9bctJ+N/CnF5gGygcUP3XYfe4=
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
-github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
-github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
-github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
-github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
-github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0=
-github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
+github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
+github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
+github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
+github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
-github.com/google/cel-go v0.26.0 h1:DPGjXackMpJWH680oGY4lZhYjIameYmR+/6RBdDGmaI=
-github.com/google/cel-go v0.26.0/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM=
+github.com/google/cel-go v0.28.1 h1:YWIwi77J4xIsYUwAF/iIuS6haffzIHS8yWI8glSbLWM=
+github.com/google/cel-go v0.28.1/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8=
github.com/google/go-cmdtest v0.4.1-0.20220921163831-55ab3332a786 h1:rcv+Ippz6RAtvaGgKxc+8FQIpxHgsF+HBzPyYL2cyVU=
github.com/google/go-cmdtest v0.4.1-0.20220921163831-55ab3332a786/go.mod h1:apVn/GCasLZUVpAJ6oWAuyP7Ne7CEsQbTnc0plM3m+o=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-github/v70 v70.0.0 h1:/tqCp5KPrcvqCc7vIvYyFYTiCGrYvaWoYMGHSQbo55o=
@@ -235,18 +237,16 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g=
github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k=
-github.com/goreleaser/chglog v0.7.3 h1:eCKJrvsDgG+F1F2fhwM6qX+S5yMiZgsQ4VNTPFl9qEM=
-github.com/goreleaser/chglog v0.7.3/go.mod h1:HXPf4avc1kTD00a46LuTEH0i1dZctLq8Xs2BxUfROnY=
-github.com/goreleaser/fileglob v1.3.0 h1:/X6J7U8lbDpQtBvGcwwPS6OpzkNVlVEsFUVRx9+k+7I=
-github.com/goreleaser/fileglob v1.3.0/go.mod h1:Jx6BoXv3mbYkEzwm9THo7xbr5egkAraxkGorbJb4RxU=
-github.com/goreleaser/nfpm/v2 v2.43.0 h1:o5oureuZkhu55RK0M9WSN8JLW7hu6MymtMh7LypInlk=
-github.com/goreleaser/nfpm/v2 v2.43.0/go.mod h1:f//PE8PjNHjaPCbd7Jkok+aPKdLTrzM+fuIWg3PfVRg=
+github.com/goreleaser/chglog v0.7.4 h1:3pnNt/XCrUcAOq+KC91Azlgp5CRv4GHo1nl8Aws7OzI=
+github.com/goreleaser/chglog v0.7.4/go.mod h1:dTVoZZagTz7hHdWaZ9OshHntKiF44HbWIHWxYJQ/h0Y=
+github.com/goreleaser/fileglob v1.4.0 h1:Y7zcUnzQjT1gbntacGAkIIfLv+OwojxTXBFxjSFoBBs=
+github.com/goreleaser/fileglob v1.4.0/go.mod h1:1pbHx7hhmJIxNZvm6fi6WVrnP0tndq6p3ayWdLn1Yf8=
+github.com/goreleaser/nfpm/v2 v2.45.2 h1:sWBXUErMSKHMvT8f1mgtY3yn2jbNBAE2TNBXNXpSDGc=
+github.com/goreleaser/nfpm/v2 v2.45.2/go.mod h1:NxYAJCKAuq8NpL57WvL39xIK39/1jViS9hD6MDwKTgw=
github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 h1:QGLs/O40yoNK9vmy4rhUGBVyMf1lISBGtXRpsu/Qu/o=
github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0/go.mod h1:hM2alZsMUni80N33RBe6J0e423LB+odMj7d3EMP9l20=
-github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2 h1:sGm2vDRFUrQJO/Veii4h4zG2vvqG6uWNkBHSTqXOZk0=
-github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2/go.mod h1:wd1YpapPLivG6nQgbf7ZkG1hhSOXDhhn4MLTknx2aAc=
-github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo=
-github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI=
+github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.4 h1:9ZJYjPEJpcleIKcqysXOo14NLQYwu23fzmKRFpIjPjc=
+github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.4/go.mod h1:0xydIeg2omQ9DH6zYMP24Kk57793rnLUvP8fwdbFvz8=
github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY=
github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI=
@@ -259,12 +259,12 @@ github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
-github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
-github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/kjk/lzma v0.0.0-20161016003348-3fd93898850d h1:RnWZeH8N8KXfbwMTex/KKMYMj0FJRCF6tQubUuQ02GM=
github.com/kjk/lzma v0.0.0-20161016003348-3fd93898850d/go.mod h1:phT/jsRPBAEqjAibu1BurrabCBNTYiVI+zbmyCZJY6Q=
-github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
-github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
+github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
+github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
+github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
+github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU=
github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
@@ -276,75 +276,79 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
-github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 h1:PpXWgLPs+Fqr325bN2FD2ISlRRztXibcX6e8f5FR5Dc=
-github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
-github.com/lum8rjack/go-ja4h v0.0.0-20250606032308-3a989c6635be h1:dVIND0nXGXPQnFZYrMXT6CxHhBYhTPMm0GFqcmfaIC4=
-github.com/lum8rjack/go-ja4h v0.0.0-20250606032308-3a989c6635be/go.mod h1:q68TUR45WDa2r3yU4aO6WgxfCc0Vj1qtRaKaRE3yMLM=
+github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak=
+github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
+github.com/lum8rjack/go-ja4h v0.0.0-20250828030157-fa5266d50650 h1:hhx/Mo6+Hk0mAQS5MW311ON1VlSzp0D1cYhY27IcmnI=
+github.com/lum8rjack/go-ja4h v0.0.0-20250828030157-fa5266d50650/go.mod h1:bMqyXOakqQIdx82d4vcnk5TIZLptZ2gLqju9xmPrWYA=
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
-github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE=
-github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
+github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
+github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
-github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc=
-github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg=
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
-github.com/moby/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ=
-github.com/moby/go-archive v0.1.0/go.mod h1:G9B+YoujNohJmrIYFBpSd54GTUB4lt9S+xVQvsJyFuo=
-github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
-github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
-github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
-github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs=
-github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
-github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
+github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8=
+github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU=
+github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc=
+github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
+github.com/moby/moby/client v0.5.0 h1:5XhyPk2fuOWf6RlSFa3MkIIgDZkF25xToXW8Q/BH7cc=
+github.com/moby/moby/client v0.5.0/go.mod h1:rcVpF8ncl9vo5gaIBdol6CnbEtSj1uxMvEV/UrykF/s=
+github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U=
+github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
+github.com/moby/sys/sequential v0.7.0 h1:ASQNGNROJSuOO6LL6bPHbKvuZu6NU8P4ldPWk31zj/8=
+github.com/moby/sys/sequential v0.7.0/go.mod h1:NfSTAp6V3fw4tmkD62PEcOKeZKquXT8VKCkf7aVR79o=
github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
-github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
-github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
+github.com/mxschmitt/playwright-go v0.6201.1 h1:KBBDopE+IIdjAp2FIk6zhLcKCCSSx/T6rGjKZmfo/Bs=
+github.com/mxschmitt/playwright-go v0.6201.1/go.mod h1:vLBP2yVO1gokUlb9ENA+YE3VQaVqf2A9BGxcb7aPjMI=
github.com/natefinch/atomic v1.0.1 h1:ZPYKxkqQOx3KZ+RsbnP/YsgvxWQPGxjC0oBt2AhwV0A=
github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM=
-github.com/nicksnyder/go-i18n/v2 v2.6.0 h1:C/m2NNWNiTB6SK4Ao8df5EWm3JETSTIGNXBpMJTxzxQ=
-github.com/nicksnyder/go-i18n/v2 v2.6.0/go.mod h1:88sRqr0C6OPyJn0/KRNaEz1uWorjxIKP7rUUcvycecE=
-github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4=
-github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog=
+github.com/neilotoole/slogt/v2 v2.0.0 h1:Kz8VWdkjXZnkVvN1hsGYI1ervWhlcDcIcVizUJbLbH8=
+github.com/neilotoole/slogt/v2 v2.0.0/go.mod h1:M/RN37tAuM3cpb/wD6lCpOSvphLMjuAzA/IV2hqJh3E=
+github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ=
+github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA=
+github.com/nikandfor/spintax v0.0.0-20181023094358-fc346b245bb3 h1:foZ9X1bz2KmW7b8Yx5V0LAQKhTazdllv5rnGUe6iGTY=
+github.com/nikandfor/spintax v0.0.0-20181023094358-fc346b245bb3/go.mod h1:wwDYKfVF3WHdY0rugsAZoIpyQjDA3bn9wEzo/QXPx1Y=
+github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
+github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
+github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY=
+github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
github.com/pjbgf/sha1cd v0.4.0 h1:NXzbL1RvjTUi6kgYZCX3fPwwl27Q1LJndxtUDVfJGRY=
github.com/pjbgf/sha1cd v0.4.0/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
-github.com/playwright-community/playwright-go v0.5200.0 h1:z/5LGuX2tBrg3ug1HupMXLjIG93f1d2MWdDsNhkMQ9c=
-github.com/playwright-community/playwright-go v0.5200.0/go.mod h1:UnnyQZaqUOO5ywAZu60+N4EiWReUqX1MQBBA3Oofvf8=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
-github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
-github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
+github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU=
+github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
-github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE=
-github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
-github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0=
-github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw=
-github.com/redis/go-redis/v9 v9.11.0 h1:E3S08Gl/nJNn5vkxd2i78wZxWAPNZgUNTp8WIJUAiIs=
-github.com/redis/go-redis/v9 v9.11.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw=
+github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY=
+github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc=
+github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
+github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
+github.com/redis/go-redis/v9 v9.22.0 h1:laDvpYXTJtZLloinw1fA5Kqd6HAEH2XKxOkG/PDq2F0=
+github.com/redis/go-redis/v9 v9.22.0/go.mod h1:y2g0Wj8rQvuK0ELM+oxSudcLtC09JScs98I/X9gRWY4=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
@@ -355,52 +359,46 @@ github.com/sebest/xff v0.0.0-20210106013422-671bd2870b3a h1:iLcLb5Fwwz7g/DLK89F+
github.com/sebest/xff v0.0.0-20210106013422-671bd2870b3a/go.mod h1:wozgYq9WEBQBaIJe4YZ0qTSFAMxmcwBhQH0fO0R34Z0=
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
-github.com/shirou/gopsutil/v4 v4.25.6 h1:kLysI2JsKorfaFPcYmcJqbzROzsBWEOAtw6A7dIfqXs=
-github.com/shirou/gopsutil/v4 v4.25.6/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c=
+github.com/shirou/gopsutil/v4 v4.26.8 h1:YQMTF/1J50B5+Y0vlo1eDRf5DoR7Gk69hY+8wjYkQeo=
+github.com/shirou/gopsutil/v4 v4.26.8/go.mod h1:5O9FjBiXoTDFatIWjZZosqj4pV0DRtLx598xGbBehzM=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
-github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
-github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
+github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
+github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8=
github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY=
github.com/smarty/assertions v1.15.0 h1:cR//PqUBUiQRakZWqBiFFQ9wb8emQGDb0HeGdqGByCY=
github.com/smarty/assertions v1.15.0/go.mod h1:yABtdzeQs6l1brC900WlRNwj6ZR55d7B+E8C6HtKdec=
github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sSznIX1xY=
github.com/smartystreets/goconvey v1.8.1/go.mod h1:+/u4qLyY6x1jReYOp7GOM2FSt8aP9CzCZL03bI28W60=
-github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA=
-github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo=
-github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE=
-github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
-github.com/stoewer/go-strcase v1.3.1 h1:iS0MdW+kVTxgMoE1LAZyMiYJFKlOzLooE4MxjirtkAs=
-github.com/stoewer/go-strcase v1.3.1/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo=
+github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
+github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
+github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
+github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
-github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
-github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
-github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
-github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
+github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
+github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
-github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
-github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
-github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
-github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/suzuki-shunsuke/logrus-error v0.1.4 h1:nWo98uba1fANHdZ9Y5pJ2RKs/PpVjrLzRp5m+mRb9KE=
github.com/suzuki-shunsuke/logrus-error v0.1.4/go.mod h1:WsVvvw6SKSt08/fB2qbnsKIMJA4K1MYCUprqsBJbMiM=
github.com/suzuki-shunsuke/pinact v1.6.0 h1:2QvSzREOquwLwKXhF9Hj0AInE/Rl63SZz9dKkHFC6so=
github.com/suzuki-shunsuke/pinact v1.6.0/go.mod h1:FDUMck0mmL0mcnNZ23Vjh/aOR5cIdZhF1IIpGksT4dQ=
github.com/suzuki-shunsuke/urfave-cli-help-all v0.0.4 h1:YGHgrVjGTYHY98II6zijXUHP+OyvrzSCvd8m9iUcaK8=
github.com/suzuki-shunsuke/urfave-cli-help-all v0.0.4/go.mod h1:sSi6xaUaHfaqu32ECLeyE7NTMv+ZM5dW0JikhllaalY=
-github.com/testcontainers/testcontainers-go v0.38.0 h1:d7uEapLcv2P8AvH8ahLqDMMxda2W9gQN1nRbHS28HBw=
-github.com/testcontainers/testcontainers-go v0.38.0/go.mod h1:C52c9MoHpWO+C4aqmgSU+hxlR5jlEayWtgYrb8Pzz1w=
-github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=
-github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4=
-github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso=
-github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=
-github.com/ulikunitz/xz v0.5.14 h1:uv/0Bq533iFdnMHZdRBTOlaNMdb1+ZxXIlHDZHIHcvg=
-github.com/ulikunitz/xz v0.5.14/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
+github.com/testcontainers/testcontainers-go v0.44.0 h1:/Fwh6HY1mIikhnm9e7HwoxGycx0lzRAE0f5VQpjFxzI=
+github.com/testcontainers/testcontainers-go v0.44.0/go.mod h1:IcnwQrYTO86xHXu5bvMaBH7ATlbS3Qn1M1QWW3c66rE=
+github.com/tetratelabs/wazero v1.12.1-0.20260714071631-236c2458ed22 h1:TnM7no4lj5GodEnEBnB3iR1MHf+BpGH88LPhs2Hhs80=
+github.com/tetratelabs/wazero v1.12.1-0.20260714071631-236c2458ed22/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0=
+github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU=
+github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI=
+github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4=
+github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg=
+github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
+github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU=
github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4=
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
@@ -411,169 +409,124 @@ github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAz
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
-github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
-github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
-github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
+github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
+github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
+gitlab.alpinelinux.org/alpine/go v0.10.1 h1:QoidnfDyC9yeIMj+CvYVyjlroZD/Kl7JRXGEQBvY5XM=
+gitlab.alpinelinux.org/alpine/go v0.10.1/go.mod h1:zwds+1zTmPDgwf/9lOzzn+oZVBr6jyfVgH3zuwkfkzc=
gitlab.com/digitalxero/go-conventional-commit v1.0.7 h1:8/dO6WWG+98PMhlZowt/YjuiKhqhGlOCwlIV8SqqGh8=
gitlab.com/digitalxero/go-conventional-commit v1.0.7/go.mod h1:05Xc2BFsSyC5tKhK0y+P3bs0AwUtNuTp+mTpbCU/DZ0=
-go.etcd.io/bbolt v1.4.2 h1:IrUHp260R8c+zYx/Tm8QZr04CX+qWS5PGfPdevhdm1I=
-go.etcd.io/bbolt v1.4.2/go.mod h1:Is8rSHO/b4f3XigBC0lL0+4FwAQv3HXEEIgFMuKHceM=
-go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
-go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
-go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 h1:Hf9xI/XLML9ElpiHVDNwvqI0hIFlzV8dgIr35kV1kRU=
-go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0/go.mod h1:NfchwuyNoMcZ5MLHwPrODwUF1HWCXWrL31s8gSAdIKY=
-go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
-go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
-go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 h1:Ahq7pZmv87yiyn3jeFz/LekZmPLLdKejuO3NcK9MssM=
-go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0/go.mod h1:MJTqhM0im3mRLw1i8uGHnCvUEeS7VwRyxlLC78PA18M=
-go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg=
-go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU=
-go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
-go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
-go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
-go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
-go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc=
-go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
-go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
-go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
-go.opentelemetry.io/proto/otlp v1.7.0 h1:jX1VolD6nHuFzOYso2E73H85i92Mv8JQYk0K9vz09os=
-go.opentelemetry.io/proto/otlp v1.7.0/go.mod h1:fSKjH6YJ7HDlwzltzyMj036AJ3ejJLCgCSHGj4efDDo=
-go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
-go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
+go.etcd.io/bbolt v1.5.0 h1:S7GAl7Fxv12yohbwFfIbQCGDWbQbtDGPET4P/bD4lxU=
+go.etcd.io/bbolt v1.5.0/go.mod h1:mkltfYE5aUHQxUct9N9V+Kp7aSjFqjgrhcXIS70Lrdk=
+go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
+go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI=
+go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
+go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
+go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
+go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
+go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
+go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
+go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
+go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
+go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
+go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
+go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
+go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
+go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
+go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
+go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
+go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
-golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
-golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
-golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
-golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
-golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
-golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
-golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
-golang.org/x/exp v0.0.0-20250718183923-645b1fa84792 h1:R9PFI6EUdfVKgwKjZef7QIwGcBKu86OEFpJ9nUEP2l4=
-golang.org/x/exp v0.0.0-20250718183923-645b1fa84792/go.mod h1:A+z0yzpGtvnG90cToK5n2tu8UJVP2XUATh+r+sfOOOc=
+golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
+golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
+golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 h1:MDfG8Cvcqlt9XXrmEiD4epKn7VJHZO84hejP9Jmp0MM=
+golang.org/x/exp v0.0.0-20251209150349-8475f28825e9/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=
golang.org/x/exp/typeparams v0.0.0-20250718183923-645b1fa84792 h1:54/e+WfmhvjR2Zuz8Q7dzLGxIBM+s5WZpvo1QfVDGB8=
golang.org/x/exp/typeparams v0.0.0-20250718183923-645b1fa84792/go.mod h1:LKZHyeOpPuZcMgxeHjJp4p5yvxrCX1xDvH10zYHhjjQ=
-golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
-golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
-golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
-golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
-golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg=
-golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ=
-golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
-golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
-golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
+golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
-golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
-golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
-golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
-golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
-golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
-golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
-golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
-golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
-golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
-golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
+golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
+golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
+golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
+golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
+golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
-golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
-golang.org/x/telemetry v0.0.0-20250721140356-96f361d9aaf7 h1:Z53b3vgJH20Us6ljHm4MNVLnJzJEjD3KrU+sNcT4vfs=
-golang.org/x/telemetry v0.0.0-20250721140356-96f361d9aaf7/go.mod h1:4ZwOYna0/zsOKwuR5X/m0QFOJpSZvAxFfkQT+Erd9D4=
+golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
+golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/telemetry v0.0.0-20260708182218-49f421fb7959 h1:RJhm5l6Fo4rmEIcndxDllNhhf/fAx8qIm4t6A7vpm2A=
+golang.org/x/telemetry v0.0.0-20260708182218-49f421fb7959/go.mod h1:LV7u5Oco+Z/g6XI7PqN+EUUUGGkEcmB1uj2ceI0fOVg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
-golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
-golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
-golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
-golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
-golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg=
-golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0=
-golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
-golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
+golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
-golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
-golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
-golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
-golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
-golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
-golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
-golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
+golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
+golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
-golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
-golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
-golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
-golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0=
-golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw=
-golang.org/x/tools/go/expect v0.1.0-deprecated h1:jY2C5HGYR5lqex3gEniOQL0r7Dq5+VGVgY1nudX5lXY=
-golang.org/x/tools/go/expect v0.1.0-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
+golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
+golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
+golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM=
+golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM=
golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8=
golang.org/x/vuln v1.1.4 h1:Ju8QsuyhX3Hk8ma3CesTbO8vfJD9EvUBgHvkxHBzj0I=
golang.org/x/vuln v1.1.4/go.mod h1:F+45wmU18ym/ca5PLTPLsSzr2KppzswxPP603ldA67s=
-golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-google.golang.org/genproto/googleapis/api v0.0.0-20250721164621-a45f3dfb1074 h1:mVXdvnmR3S3BQOqHECm9NGMjYiRtEvDYcqAqedTXY6s=
-google.golang.org/genproto/googleapis/api v0.0.0-20250721164621-a45f3dfb1074/go.mod h1:vYFwMYFbmA8vl6Z/krj/h7+U/AqpHknwJX4Uqgfyc7I=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20250721164621-a45f3dfb1074 h1:qJW29YvkiJmXOYMu5Tf8lyrTp3dOS+K4z6IixtLaCf8=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20250721164621-a45f3dfb1074/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
-google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4=
-google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM=
-google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
-google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
+gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
+gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
+google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8=
+google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
+google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU=
+google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8=
+google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI=
+google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
+gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
-gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
-honnef.co/go/tools v0.6.1 h1:R094WgE8K4JirYjBaOpz/AvTyUu/3wbmAoskKN/pxTI=
-honnef.co/go/tools v0.6.1/go.mod h1:3puzxxljPCe8RGJX7BIy1plGbxEOZni5mR2aXe3/uk4=
-k8s.io/apimachinery v0.33.3 h1:4ZSrmNa0c/ZpZJhAgRdcsFcZOw1PQU1bALVQ0B3I5LA=
-k8s.io/apimachinery v0.33.3/go.mod h1:BHW0YOu7n22fFv/JkYOEfkUYNRN0fj0BlvMFWA7b+SM=
-mvdan.cc/sh/v3 v3.12.0 h1:ejKUR7ONP5bb+UGHGEG/k9V5+pRVIyD+LsZz7o8KHrI=
-mvdan.cc/sh/v3 v3.12.0/go.mod h1:Se6Cj17eYSn+sNooLZiEUnNNmNxg0imoYlTu4CyaGyg=
-pault.ag/go/debian v0.18.0 h1:nr0iiyOU5QlG1VPnhZLNhnCcHx58kukvBJp+dvaM6CQ=
-pault.ag/go/debian v0.18.0/go.mod h1:JFl0XWRCv9hWBrB5MDDZjA5GSEs1X3zcFK/9kCNIUmE=
+honnef.co/go/tools v0.8.1 h1:+JKf3xJ1ni4CwrhVg4/pqsfPGP6vNAXcKbMXJodYx3w=
+honnef.co/go/tools v0.8.1/go.mod h1:XA+OnlRA9EDh/ukGvXMNSZNKGwFQJ+5dER0ioUkOxks=
+k8s.io/apimachinery v0.37.0 h1:Np2AbDtf8x6RDHiD8T9LbKJ9gaegeVNa8yNm5FuGKm0=
+k8s.io/apimachinery v0.37.0/go.mod h1:RN3nhprFSCxOi5Selxd7oMTXOe/c+ZbcE7Im+TS2zkE=
+mvdan.cc/sh/v3 v3.13.0 h1:dSfq/MVsY4w0Vsi6Lbs0IcQquMVqLdKLESAOZjuHdLg=
+mvdan.cc/sh/v3 v3.13.0/go.mod h1:KV1GByGPc/Ho0X1E6Uz9euhsIQEj4hwyKnodLlFLoDM=
+pault.ag/go/debian v0.19.0 h1:RUxCjScMbnlqFH5I+qsmyjZH8fXXtQ05rlkMJop3tjo=
+pault.ag/go/debian v0.19.0/go.mod h1:1LMojDAazlJ7cA5Ne6H2ZHD4hh3o8NRiW+MpvQRji2o=
pault.ag/go/topsort v0.1.1 h1:L0QnhUly6LmTv0e3DEzbN2q6/FGgAcQvaEw65S53Bg4=
pault.ag/go/topsort v0.1.1/go.mod h1:r1kc/L0/FZ3HhjezBIPaNVhkqv8L0UJ9bxRuHRVZ0q4=
-sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE=
-sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
+pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
+pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=
+sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg=
+sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
diff --git a/internal/basicauth.go b/internal/basicauth.go
new file mode 100644
index 000000000..00ce71328
--- /dev/null
+++ b/internal/basicauth.go
@@ -0,0 +1,52 @@
+package internal
+
+import (
+ "crypto/sha256"
+ "crypto/subtle"
+ "fmt"
+ "log/slog"
+ "net/http"
+)
+
+// BasicAuth wraps next in HTTP Basic authentication using the provided
+// credentials. If either username or password is empty, next is returned
+// unchanged and a debug log line is emitted.
+//
+// Credentials are compared in constant time to avoid leaking information
+// through timing side channels.
+func BasicAuth(realm, username, password string, next http.Handler) http.Handler {
+ if username == "" || password == "" {
+ slog.Debug("skipping middleware, basic auth credentials are empty")
+ return next
+ }
+
+ expectedUser := sha256.Sum256([]byte(username))
+ expectedPass := sha256.Sum256([]byte(password))
+ challenge := fmt.Sprintf("Basic realm=%q, charset=\"UTF-8\"", realm)
+
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ user, pass, ok := r.BasicAuth()
+ if !ok {
+ unauthorized(w, challenge)
+ return
+ }
+
+ gotUser := sha256.Sum256([]byte(user))
+ gotPass := sha256.Sum256([]byte(pass))
+
+ userMatch := subtle.ConstantTimeCompare(gotUser[:], expectedUser[:])
+ passMatch := subtle.ConstantTimeCompare(gotPass[:], expectedPass[:])
+
+ if userMatch&passMatch != 1 {
+ unauthorized(w, challenge)
+ return
+ }
+
+ next.ServeHTTP(w, r)
+ })
+}
+
+func unauthorized(w http.ResponseWriter, challenge string) {
+ w.Header().Set("WWW-Authenticate", challenge)
+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
+}
diff --git a/internal/basicauth_test.go b/internal/basicauth_test.go
new file mode 100644
index 000000000..2fcb80028
--- /dev/null
+++ b/internal/basicauth_test.go
@@ -0,0 +1,138 @@
+package internal
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+func okHandler() http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte("ok"))
+ })
+}
+
+func TestBasicAuth(t *testing.T) {
+ t.Parallel()
+
+ const (
+ realm = "test-realm"
+ username = "admin"
+ password = "hunter2"
+ )
+
+ for _, tt := range []struct {
+ name string
+ setAuth bool
+ user string
+ pass string
+ wantStatus int
+ wantBody string
+ wantChall bool
+ }{
+ {
+ name: "valid credentials",
+ setAuth: true,
+ user: username,
+ pass: password,
+ wantStatus: http.StatusOK,
+ wantBody: "ok",
+ },
+ {
+ name: "missing credentials",
+ setAuth: false,
+ wantStatus: http.StatusUnauthorized,
+ wantChall: true,
+ },
+ {
+ name: "wrong username",
+ setAuth: true,
+ user: "nobody",
+ pass: password,
+ wantStatus: http.StatusUnauthorized,
+ wantChall: true,
+ },
+ {
+ name: "wrong password",
+ setAuth: true,
+ user: username,
+ pass: "wrong",
+ wantStatus: http.StatusUnauthorized,
+ wantChall: true,
+ },
+ {
+ name: "empty supplied credentials",
+ setAuth: true,
+ user: "",
+ pass: "",
+ wantStatus: http.StatusUnauthorized,
+ wantChall: true,
+ },
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ h := BasicAuth(realm, username, password, okHandler())
+
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
+ if tt.setAuth {
+ req.SetBasicAuth(tt.user, tt.pass)
+ }
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+
+ if rec.Code != tt.wantStatus {
+ t.Errorf("status = %d, want %d", rec.Code, tt.wantStatus)
+ }
+
+ if tt.wantBody != "" && rec.Body.String() != tt.wantBody {
+ t.Errorf("body = %q, want %q", rec.Body.String(), tt.wantBody)
+ }
+
+ chall := rec.Header().Get("WWW-Authenticate")
+ if tt.wantChall {
+ if chall == "" {
+ t.Error("WWW-Authenticate header missing on 401")
+ }
+ if !strings.Contains(chall, realm) {
+ t.Errorf("WWW-Authenticate = %q, want realm %q", chall, realm)
+ }
+ } else if chall != "" {
+ t.Errorf("unexpected WWW-Authenticate header: %q", chall)
+ }
+ })
+ }
+}
+
+func TestBasicAuthPassthrough(t *testing.T) {
+ t.Parallel()
+
+ for _, tt := range []struct {
+ name string
+ username string
+ password string
+ }{
+ {name: "empty username", username: "", password: "hunter2"},
+ {name: "empty password", username: "admin", password: ""},
+ {name: "both empty", username: "", password: ""},
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ h := BasicAuth("realm", tt.username, tt.password, okHandler())
+
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Errorf("status = %d, want %d (passthrough expected)", rec.Code, http.StatusOK)
+ }
+ if rec.Body.String() != "ok" {
+ t.Errorf("body = %q, want %q", rec.Body.String(), "ok")
+ }
+ })
+ }
+}
diff --git a/internal/bundler/bundler.go b/internal/bundler/bundler.go
new file mode 100644
index 000000000..29a049c30
--- /dev/null
+++ b/internal/bundler/bundler.go
@@ -0,0 +1,407 @@
+// Copyright 2016 Google LLC.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+// Package bundler supports bundling (batching) of items. Bundling amortizes an
+// action with fixed costs over multiple items. For example, if an API provides
+// an RPC that accepts a list of items as input, but clients would prefer
+// adding items one at a time, then a Bundler can accept individual items from
+// the client and bundle many of them into a single RPC.
+//
+// This package is experimental and subject to change without notice.
+package bundler
+
+import (
+ "context"
+ "errors"
+ "sync"
+ "time"
+
+ "golang.org/x/sync/semaphore"
+)
+
+type mode int
+
+const (
+ DefaultDelayThreshold = time.Second
+ DefaultBundleHandlerDeadline = time.Minute
+ DefaultBundleCountThreshold = 10
+ DefaultBundleByteThreshold = 1e6 // 1M
+ DefaultBufferedByteLimit = 1e9 // 1G
+)
+
+const (
+ none mode = iota
+ add
+ addWait
+)
+
+var (
+ // ErrOverflow indicates that Bundler's stored bytes exceeds its BufferedByteLimit.
+ ErrOverflow = errors.New("bundler: reached buffered byte limit")
+
+ // ErrOversizedItem indicates that an item's size exceeds the maximum bundle size.
+ ErrOversizedItem = errors.New("bundler: item size exceeds bundle byte limit")
+
+ // errMixedMethods indicates that mutually exclusive methods has been
+ // called subsequently.
+ errMixedMethods = errors.New("bundler: calls to Add and AddWait cannot be mixed")
+)
+
+// A Bundler collects items added to it into a bundle until the bundle
+// exceeds a given size, then calls a user-provided function to handle the
+// bundle.
+//
+// The exported fields are only safe to modify prior to the first call to Add
+// or AddWait.
+type Bundler[T any] struct {
+ // Starting from the time that the first message is added to a bundle, once
+ // this delay has passed, handle the bundle. The default is DefaultDelayThreshold.
+ DelayThreshold time.Duration
+
+ // Once a bundle has this many items, handle the bundle. Since only one
+ // item at a time is added to a bundle, no bundle will exceed this
+ // threshold, so it also serves as a limit. The default is
+ // DefaultBundleCountThreshold.
+ BundleCountThreshold int
+
+ // Once the number of bytes in current bundle reaches this threshold, handle
+ // the bundle. The default is DefaultBundleByteThreshold. This triggers handling,
+ // but does not cap the total size of a bundle.
+ BundleByteThreshold int
+
+ // The maximum size of a bundle, in bytes. Zero means unlimited.
+ BundleByteLimit int
+
+ // The maximum number of bytes that the Bundler will keep in memory before
+ // returning ErrOverflow. The default is DefaultBufferedByteLimit.
+ BufferedByteLimit int
+
+ // The maximum number of handler invocations that can be running at once.
+ // The default is 1.
+ HandlerLimit int
+
+ // ContextDeadline is the deadline for the context attached to bundle handling.
+ ContextDeadline time.Duration
+
+ handler func(context.Context, []T) // called to handle a bundle
+
+ mu sync.Mutex // guards access to fields below
+ flushTimer *time.Timer // implements DelayThreshold
+ handlerCount int // # of bundles currently being handled (i.e. handler is invoked on them)
+ sem *semaphore.Weighted // enforces BufferedByteLimit
+ semOnce sync.Once // guards semaphore initialization
+ // The current bundle we're adding items to. Not yet in the queue.
+ // Appended to the queue once the flushTimer fires or the bundle
+ // thresholds/limits are reached. If curBundle is nil and tail is
+ // not, we first try to add items to tail. Once tail is full or handled,
+ // we create a new curBundle for the incoming item.
+ curBundle *bundle[T]
+ // The next bundle in the queue to be handled. Nil if the queue is
+ // empty.
+ head *bundle[T]
+ // The last bundle in the queue to be handled. Nil if the queue is
+ // empty. If curBundle is nil and tail isn't, we attempt to add new
+ // items to the tail until if becomes full or has been passed to the
+ // handler.
+ tail *bundle[T]
+ curFlush *sync.WaitGroup // counts outstanding bundles since last flush
+ prevFlush chan bool // signal used to wait for prior flush
+
+ // The first call to Add or AddWait, mode will be add or addWait respectively.
+ // If there wasn't call yet then mode is none.
+ mode mode
+ // TODO: consider alternative queue implementation for head/tail bundle. see:
+ // https://code-review.googlesource.com/c/google-api-go-client/+/47991/4/support/bundler/bundler.go#74
+}
+
+// A bundle is a group of items that were added individually and will be passed
+// to a handler as a slice.
+type bundle[T any] struct {
+ items []T // slice of T
+ size int // size in bytes of all items
+ next *bundle[T] // bundles are handled in order as a linked list queue
+ flush *sync.WaitGroup // the counter that tracks flush completion
+}
+
+// add appends item to this bundle and increments the total size. It requires
+// that b.mu is locked.
+func (bu *bundle[T]) add(item T, size int) {
+ bu.items = append(bu.items, item)
+ bu.size += size
+}
+
+// New creates a new Bundler.
+//
+// handler is a function that will be called on each bundle. If itemExample is
+// of type T, the argument to handler is of type []T. handler is always called
+// sequentially for each bundle, and never in parallel.
+//
+// Configure the Bundler by setting its thresholds and limits before calling
+// any of its methods.
+func New[T any](handler func(context.Context, []T)) *Bundler[T] {
+ b := &Bundler[T]{
+ DelayThreshold: DefaultDelayThreshold,
+ BundleCountThreshold: DefaultBundleCountThreshold,
+ BundleByteThreshold: DefaultBundleByteThreshold,
+ BufferedByteLimit: DefaultBufferedByteLimit,
+ HandlerLimit: 1,
+
+ handler: handler,
+ curFlush: &sync.WaitGroup{},
+ }
+ return b
+}
+
+func (b *Bundler[T]) initSemaphores() {
+ // Create the semaphores lazily, because the user may set limits
+ // after NewBundler.
+ b.semOnce.Do(func() {
+ b.sem = semaphore.NewWeighted(int64(b.BufferedByteLimit))
+ })
+}
+
+// enqueueCurBundle moves curBundle to the end of the queue. The bundle may be
+// handled immediately if we are below HandlerLimit. It requires that b.mu is
+// locked.
+func (b *Bundler[T]) enqueueCurBundle() {
+ // We don't require callers to check if there is a pending bundle. It
+ // may have already been appended to the queue. If so, return early.
+ if b.curBundle == nil {
+ return
+ }
+ // If we are below the HandlerLimit, the queue must be empty. Handle
+ // immediately with a new goroutine.
+ if b.handlerCount < b.HandlerLimit {
+ b.handlerCount++
+ go b.handle(b.curBundle)
+ } else if b.tail != nil {
+ // There are bundles on the queue, so append to the end
+ b.tail.next = b.curBundle
+ b.tail = b.curBundle
+ } else {
+ // The queue is empty, so initialize the queue
+ b.head = b.curBundle
+ b.tail = b.curBundle
+ }
+ b.curBundle = nil
+ if b.flushTimer != nil {
+ b.flushTimer.Stop()
+ b.flushTimer = nil
+ }
+}
+
+// setMode sets the state of Bundler's mode. If mode was defined before
+// and passed state is different from it then return an error.
+func (b *Bundler[T]) setMode(m mode) error {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ if b.mode == m || b.mode == none {
+ b.mode = m
+ return nil
+ }
+ return errMixedMethods
+}
+
+// canFit returns true if bu can fit an additional item of size bytes based
+// on the limits of Bundler b.
+func (b *Bundler[T]) canFit(bu *bundle[T], size int) bool {
+ return (b.BundleByteLimit <= 0 || bu.size+size <= b.BundleByteLimit) &&
+ (b.BundleCountThreshold <= 0 || len(bu.items) < b.BundleCountThreshold)
+}
+
+// Add adds item to the current bundle. It marks the bundle for handling and
+// starts a new one if any of the thresholds or limits are exceeded.
+// The type of item must be assignable to the itemExample parameter of the NewBundler
+// method, otherwise there will be a panic.
+//
+// If the item's size exceeds the maximum bundle size (Bundler.BundleByteLimit), then
+// the item can never be handled. Add returns ErrOversizedItem in this case.
+//
+// If adding the item would exceed the maximum memory allowed
+// (Bundler.BufferedByteLimit) or an AddWait call is blocked waiting for
+// memory, Add returns ErrOverflow.
+//
+// Add never blocks.
+func (b *Bundler[T]) Add(item T, size int) error {
+ if err := b.setMode(add); err != nil {
+ return err
+ }
+ // If this item exceeds the maximum size of a bundle,
+ // we can never send it.
+ if b.BundleByteLimit > 0 && size > b.BundleByteLimit {
+ return ErrOversizedItem
+ }
+
+ // If adding this item would exceed our allotted memory
+ // footprint, we can't accept it.
+ // (TryAcquire also returns false if anything is waiting on the semaphore,
+ // so calls to Add and AddWait shouldn't be mixed.)
+ b.initSemaphores()
+ if !b.sem.TryAcquire(int64(size)) {
+ return ErrOverflow
+ }
+
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ return b.add(item, size)
+}
+
+// add adds item to the tail of the bundle queue or curBundle depending on space
+// and nil-ness (see inline comments). It marks curBundle for handling (by
+// appending it to the queue) if any of the thresholds or limits are exceeded.
+// curBundle is lazily initialized. It requires that b.mu is locked.
+func (b *Bundler[T]) add(item T, size int) error {
+ // If we don't have a curBundle, see if we can add to the queue tail.
+ if b.tail != nil && b.curBundle == nil && b.canFit(b.tail, size) {
+ b.tail.add(item, size)
+ return nil
+ }
+
+ // If we can't fit in the existing curBundle, move it onto the queue.
+ if b.curBundle != nil && !b.canFit(b.curBundle, size) {
+ b.enqueueCurBundle()
+ }
+
+ // Create a curBundle if we don't have one.
+ if b.curBundle == nil {
+ b.curFlush.Add(1)
+ b.curBundle = &bundle[T]{
+ items: []T{},
+ flush: b.curFlush,
+ }
+ }
+
+ // Add the item.
+ b.curBundle.add(item, size)
+
+ // If curBundle is ready for handling, move it to the queue.
+ if b.curBundle.size >= b.BundleByteThreshold ||
+ len(b.curBundle.items) == b.BundleCountThreshold {
+ b.enqueueCurBundle()
+ }
+
+ // If we created a new bundle and it wasn't immediately handled, set a timer
+ if b.curBundle != nil && b.flushTimer == nil {
+ b.flushTimer = time.AfterFunc(b.DelayThreshold, b.tryHandleBundles)
+ }
+
+ return nil
+}
+
+// tryHandleBundles is the timer callback that handles or queues any current
+// bundle after DelayThreshold time, even if the bundle isn't completely full.
+func (b *Bundler[T]) tryHandleBundles() {
+ b.mu.Lock()
+ b.enqueueCurBundle()
+ b.mu.Unlock()
+}
+
+// next returns the next bundle that is ready for handling and removes it from
+// the internal queue. It requires that b.mu is locked.
+func (b *Bundler[T]) next() *bundle[T] {
+ if b.head == nil {
+ return nil
+ }
+ out := b.head
+ b.head = b.head.next
+ if b.head == nil {
+ b.tail = nil
+ }
+ out.next = nil
+ return out
+}
+
+// handle calls the user-specified handler on the given bundle. handle is
+// intended to be run as a goroutine. After the handler returns, we update the
+// byte total. handle continues processing additional bundles that are ready.
+// If no more bundles are ready, the handler count is decremented and the
+// goroutine ends.
+func (b *Bundler[T]) handle(bu *bundle[T]) {
+ for bu != nil {
+ ctx, cancel := context.WithTimeout(context.Background(), b.ContextDeadline)
+ b.handler(ctx, bu.items)
+ cancel()
+ bu = b.postHandle(bu)
+ }
+}
+
+func (b *Bundler[T]) postHandle(bu *bundle[T]) *bundle[T] {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+
+ b.sem.Release(int64(bu.size))
+ bu.flush.Done()
+
+ bu = b.next()
+ if bu == nil {
+ b.handlerCount--
+ }
+ return bu
+}
+
+// AddWait adds item to the current bundle. It marks the bundle for handling and
+// starts a new one if any of the thresholds or limits are exceeded.
+//
+// If the item's size exceeds the maximum bundle size (Bundler.BundleByteLimit), then
+// the item can never be handled. AddWait returns ErrOversizedItem in this case.
+//
+// If adding the item would exceed the maximum memory allowed (Bundler.BufferedByteLimit),
+// AddWait blocks until space is available or ctx is done.
+//
+// Calls to Add and AddWait should not be mixed on the same Bundler.
+func (b *Bundler[T]) AddWait(ctx context.Context, item T, size int) error {
+ if err := b.setMode(addWait); err != nil {
+ return err
+ }
+ // If this item exceeds the maximum size of a bundle,
+ // we can never send it.
+ if b.BundleByteLimit > 0 && size > b.BundleByteLimit {
+ return ErrOversizedItem
+ }
+ // If adding this item would exceed our allotted memory footprint, block
+ // until space is available. The semaphore is FIFO, so there will be no
+ // starvation.
+ b.initSemaphores()
+ if err := b.sem.Acquire(ctx, int64(size)); err != nil {
+ return err
+ }
+
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ return b.add(item, size)
+}
+
+// Flush invokes the handler for all remaining items in the Bundler and waits
+// for it to return.
+func (b *Bundler[T]) Flush() {
+ b.mu.Lock()
+
+ // If a curBundle is pending, move it to the queue.
+ b.enqueueCurBundle()
+
+ // Store a pointer to the WaitGroup that counts outstanding bundles
+ // in the current flush and create a new one to track the next flush.
+ wg := b.curFlush
+ b.curFlush = &sync.WaitGroup{}
+
+ // Flush must wait for all prior, outstanding flushes to complete.
+ // We use a channel to communicate completion between each flush in
+ // the sequence.
+ prev := b.prevFlush
+ next := make(chan bool)
+ b.prevFlush = next
+
+ b.mu.Unlock()
+
+ // Wait until the previous flush is finished.
+ if prev != nil {
+ <-prev
+ }
+
+ // Wait until this flush is finished.
+ wg.Wait()
+
+ // Allow the next flush to finish.
+ close(next)
+}
diff --git a/internal/bundler/bundler_test.go b/internal/bundler/bundler_test.go
new file mode 100644
index 000000000..008523791
--- /dev/null
+++ b/internal/bundler/bundler_test.go
@@ -0,0 +1,39 @@
+package bundler
+
+import (
+ "context"
+ "testing"
+)
+
+func TestBundler(t *testing.T) {
+ input := []int{1, 2, 3, 4}
+ done := false
+ b := New[int](func(_ context.Context, data []int) {
+ if len(data) != 4 {
+ t.Errorf("Wanted len(data) == %d, got: %d", len(input), len(data))
+ }
+
+ sum := 0
+ const wantSum = 10
+ for _, i := range data {
+ sum += i
+ }
+
+ if sum != wantSum {
+ t.Errorf("wanted sum of inputs to be %d, got: %d", wantSum, sum)
+ }
+ done = true
+ })
+
+ for _, i := range input {
+ if err := b.Add(i, 1); err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ b.Flush()
+
+ if !done {
+ t.Fatal("function wasn't called")
+ }
+}
diff --git a/internal/clampip.go b/internal/clampip.go
new file mode 100644
index 000000000..e8220ab59
--- /dev/null
+++ b/internal/clampip.go
@@ -0,0 +1,33 @@
+package internal
+
+import "net/netip"
+
+func ClampIP(addr netip.Addr) (netip.Prefix, bool) {
+ switch {
+ case addr.Is4():
+ result, err := addr.Prefix(24)
+ if err != nil {
+ return netip.Prefix{}, false
+ }
+ return result, true
+
+ case addr.Is4In6():
+ // Extract the IPv4 address from IPv4-mapped IPv6 and clamp it
+ ipv4 := addr.Unmap()
+ result, err := ipv4.Prefix(24)
+ if err != nil {
+ return netip.Prefix{}, false
+ }
+ return result, true
+
+ case addr.Is6():
+ result, err := addr.Prefix(48)
+ if err != nil {
+ return netip.Prefix{}, false
+ }
+ return result, true
+
+ default:
+ return netip.Prefix{}, false
+ }
+}
diff --git a/internal/clampip_test.go b/internal/clampip_test.go
new file mode 100644
index 000000000..d83f143c0
--- /dev/null
+++ b/internal/clampip_test.go
@@ -0,0 +1,274 @@
+package internal
+
+import (
+ "net/netip"
+ "testing"
+)
+
+func TestClampIP(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ expected string
+ }{
+ // IPv4 addresses
+ {
+ name: "IPv4 normal address",
+ input: "192.168.1.100",
+ expected: "192.168.1.0/24",
+ },
+ {
+ name: "IPv4 boundary - network address",
+ input: "192.168.1.0",
+ expected: "192.168.1.0/24",
+ },
+ {
+ name: "IPv4 boundary - broadcast address",
+ input: "192.168.1.255",
+ expected: "192.168.1.0/24",
+ },
+ {
+ name: "IPv4 class A address",
+ input: "10.0.0.1",
+ expected: "10.0.0.0/24",
+ },
+ {
+ name: "IPv4 loopback",
+ input: "127.0.0.1",
+ expected: "127.0.0.0/24",
+ },
+ {
+ name: "IPv4 link-local",
+ input: "169.254.0.1",
+ expected: "169.254.0.0/24",
+ },
+ {
+ name: "IPv4 public address",
+ input: "203.0.113.1",
+ expected: "203.0.113.0/24",
+ },
+
+ // IPv6 addresses
+ {
+ name: "IPv6 normal address",
+ input: "2001:db8::1",
+ expected: "2001:db8::/48",
+ },
+ {
+ name: "IPv6 with full expansion",
+ input: "2001:0db8:0000:0000:0000:0000:0000:0001",
+ expected: "2001:db8::/48",
+ },
+ {
+ name: "IPv6 loopback",
+ input: "::1",
+ expected: "::/48",
+ },
+ {
+ name: "IPv6 unspecified address",
+ input: "::",
+ expected: "::/48",
+ },
+ {
+ name: "IPv6 link-local",
+ input: "fe80::1",
+ expected: "fe80::/48",
+ },
+ {
+ name: "IPv6 unique local",
+ input: "fc00::1",
+ expected: "fc00::/48",
+ },
+ {
+ name: "IPv6 documentation prefix",
+ input: "2001:db8:abcd:ef01::1234",
+ expected: "2001:db8:abcd::/48",
+ },
+ {
+ name: "IPv6 global unicast",
+ input: "2606:4700:4700::1111",
+ expected: "2606:4700:4700::/48",
+ },
+ {
+ name: "IPv6 multicast",
+ input: "ff02::1",
+ expected: "ff02::/48",
+ },
+
+ // IPv4-mapped IPv6 addresses
+ {
+ name: "IPv4-mapped IPv6 address",
+ input: "::ffff:192.168.1.100",
+ expected: "192.168.1.0/24",
+ },
+ {
+ name: "IPv4-mapped IPv6 with different format",
+ input: "::ffff:10.0.0.1",
+ expected: "10.0.0.0/24",
+ },
+ {
+ name: "IPv4-mapped IPv6 loopback",
+ input: "::ffff:127.0.0.1",
+ expected: "127.0.0.0/24",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ addr := netip.MustParseAddr(tt.input)
+
+ result, ok := ClampIP(addr)
+ if !ok {
+ t.Fatalf("ClampIP(%s) returned false, want true", tt.input)
+ }
+
+ if result.String() != tt.expected {
+ t.Errorf("ClampIP(%s) = %s, want %s", tt.input, result.String(), tt.expected)
+ }
+ })
+ }
+}
+
+func TestClampIPSuccess(t *testing.T) {
+ // Test that valid inputs return success
+ tests := []struct {
+ name string
+ input string
+ }{
+ {
+ name: "IPv4 address",
+ input: "192.168.1.100",
+ },
+ {
+ name: "IPv6 address",
+ input: "2001:db8::1",
+ },
+ {
+ name: "IPv4-mapped IPv6",
+ input: "::ffff:192.168.1.100",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ addr := netip.MustParseAddr(tt.input)
+
+ result, ok := ClampIP(addr)
+ if !ok {
+ t.Fatalf("ClampIP(%s) returned false, want true", tt.input)
+ }
+
+ // For valid inputs, we should get the clamped prefix
+ if addr.Is4() || addr.Is4In6() {
+ if result.Bits() != 24 {
+ t.Errorf("Expected 24 bits for IPv4, got %d", result.Bits())
+ }
+ } else if addr.Is6() {
+ if result.Bits() != 48 {
+ t.Errorf("Expected 48 bits for IPv6, got %d", result.Bits())
+ }
+ }
+ })
+ }
+}
+
+func TestClampIPZeroValue(t *testing.T) {
+ // Test that when ClampIP fails, it returns zero value
+ // Note: It's hard to make addr.Prefix() fail with valid inputs,
+ // so this test demonstrates the expected behavior
+ addr := netip.MustParseAddr("192.168.1.100")
+
+ // Manually create a zero value for comparison
+ zeroPrefix := netip.Prefix{}
+
+ // Call ClampIP - it should succeed with valid input
+ result, ok := ClampIP(addr)
+
+ // Verify the function succeeded
+ if !ok {
+ t.Error("ClampIP should succeed with valid input")
+ }
+
+ // Verify that the result is not a zero value
+ if result == zeroPrefix {
+ t.Error("Result should not be zero value for successful operation")
+ }
+}
+
+func TestClampIPSpecialCases(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ expectedPrefix int
+ expectedNetwork string
+ }{
+ {
+ name: "Minimum IPv4",
+ input: "0.0.0.0",
+ expectedPrefix: 24,
+ expectedNetwork: "0.0.0.0",
+ },
+ {
+ name: "Maximum IPv4",
+ input: "255.255.255.255",
+ expectedPrefix: 24,
+ expectedNetwork: "255.255.255.0",
+ },
+ {
+ name: "Minimum IPv6",
+ input: "::",
+ expectedPrefix: 48,
+ expectedNetwork: "::",
+ },
+ {
+ name: "Maximum IPv6 prefix part",
+ input: "ffff:ffff:ffff::",
+ expectedPrefix: 48,
+ expectedNetwork: "ffff:ffff:ffff::",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ addr := netip.MustParseAddr(tt.input)
+
+ result, ok := ClampIP(addr)
+ if !ok {
+ t.Fatalf("ClampIP(%s) returned false, want true", tt.input)
+ }
+
+ if result.Bits() != tt.expectedPrefix {
+ t.Errorf("ClampIP(%s) bits = %d, want %d", tt.input, result.Bits(), tt.expectedPrefix)
+ }
+
+ if result.Addr().String() != tt.expectedNetwork {
+ t.Errorf("ClampIP(%s) network = %s, want %s", tt.input, result.Addr().String(), tt.expectedNetwork)
+ }
+ })
+ }
+}
+
+// Benchmark to ensure the function is performant
+func BenchmarkClampIP(b *testing.B) {
+ ipv4 := netip.MustParseAddr("192.168.1.100")
+ ipv6 := netip.MustParseAddr("2001:db8::1")
+ ipv4mapped := netip.MustParseAddr("::ffff:192.168.1.100")
+
+ b.Run("IPv4", func(b *testing.B) {
+ for i := 0; i < b.N; i++ {
+ ClampIP(ipv4)
+ }
+ })
+
+ b.Run("IPv6", func(b *testing.B) {
+ for i := 0; i < b.N; i++ {
+ ClampIP(ipv6)
+ }
+ })
+
+ b.Run("IPv4-mapped", func(b *testing.B) {
+ for i := 0; i < b.N; i++ {
+ ClampIP(ipv4mapped)
+ }
+ })
+}
diff --git a/internal/cmd/mkmsi/README.md b/internal/cmd/mkmsi/README.md
new file mode 100644
index 000000000..dd58c1675
--- /dev/null
+++ b/internal/cmd/mkmsi/README.md
@@ -0,0 +1,31 @@
+# mkmsi
+
+Converts a yeet-generated .zip file to an MSI using [msitools](https://gitlab.gnome.org/GNOME/msitools).
+
+It needs `wixl`, `wixl-heat`, `msibuild` and `msiinfo` from msitools 0.106 or
+later on PATH. Use Homebrew (`brew install msitools`) on both macOS and Linux.
+
+Distribution packages are usually too old. Debian and Ubuntu also split the
+tools across two packages -- `msibuild` and `msiinfo` in `msitools`, `wixl` and
+`wixl-heat` in `wixl` -- and Ubuntu 24.04 ships 0.103, which aborts with
+`unhandled child Component node Condition` on the `
` element in
+`run/windows/anubis.wxs`.
+
+> [!NOTE]
+> This code was generated using Claude Opus 5 as a correctness fuzzer to ensure that this output is valid, works on Windows systems, and generally behaves like a well-behaved Windows installer. Due to facts and circumstances beyond my control with how these tools and the MSI format works, it is not currently possible to make these MSI builds reproducible.
+>
+> I tried.
+
+This is intended to be run by `yeet` at package build time:
+
+```js
+// Build MSI installers from the Windows zips that were just built.
+packages
+ .flat()
+ .filter((pkg) => pkg.includes("windows"))
+ .filter((pkg) => pkg.endsWith(".zip"))
+ .forEach((zip) => {
+ const msiPath = zip.replace(/\.zip$/, ".msi");
+ $`go run ./internal/cmd/mkmsi --zip ${zip} --out ${msiPath}`;
+ });
+```
diff --git a/internal/cmd/mkmsi/build.go b/internal/cmd/mkmsi/build.go
new file mode 100644
index 000000000..6c1a2e8b0
--- /dev/null
+++ b/internal/cmd/mkmsi/build.go
@@ -0,0 +1,201 @@
+package main
+
+import (
+ "archive/zip"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "regexp"
+ "slices"
+ "strings"
+ "time"
+)
+
+// ErrWixlDroppedAttribute reports that wixl did not understand part of the wxs.
+//
+// wixl writes a warning to stderr and then exits 0 with an installer that is
+// missing whatever it did not understand, so its exit status cannot be trusted
+// on its own.
+var ErrWixlDroppedAttribute = errors.New("mkmsi: wixl rejected part of the wxs")
+
+// zipNameRe pulls the version and architecture out of a yeet zip name such as
+// anubis-1.26.2-1-gec3cce8a-dev-windows-amd64.zip.
+var zipNameRe = regexp.MustCompile(`^anubis-(.+)-windows-(amd64|arm64)\.zip$`)
+
+// versionFromZipName extracts the Anubis version from a zip file's name.
+func versionFromZipName(name string) (string, error) {
+ m := zipNameRe.FindStringSubmatch(filepath.Base(name))
+ if m == nil {
+ return "", fmt.Errorf("%w: %q is not an Anubis Windows zip", ErrBadVersion, name)
+ }
+
+ return m[1], nil
+}
+
+// archFromZipName extracts the Go architecture from a zip file's name.
+func archFromZipName(name string) (string, error) {
+ m := zipNameRe.FindStringSubmatch(filepath.Base(name))
+ if m == nil {
+ return "", fmt.Errorf("%w: %q is not an Anubis Windows zip", ErrBadVersion, name)
+ }
+
+ return m[2], nil
+}
+
+// checkWixlOutput fails when wixl reported that it could not handle something.
+func checkWixlOutput(stderr string) error {
+ for line := range strings.SplitSeq(stderr, "\n") {
+ if strings.Contains(line, "CRITICAL") {
+ return fmt.Errorf("%w: %s", ErrWixlDroppedAttribute, strings.TrimSpace(line))
+ }
+ }
+
+ return nil
+}
+
+// unzip extracts src into dest, stripping the single top level directory that
+// yeet puts in its archives. It returns the list of extracted file paths,
+// relative to dest and sorted, so downstream steps are reproducible, along
+// with the single modification time yeet stamped every entry with.
+//
+// That shared mtime is handed back rather than rediscovered so files this
+// program generates itself after unzipping -- the config templates
+// stageConfigTemplates writes -- can be stamped with it too. Without that,
+// only files that came straight out of the zip would have a reproducible
+// mtime, and the CAB archive wixl builds embeds every file's mtime
+// regardless of where it came from.
+func unzip(src, dest string) ([]string, time.Time, error) {
+ r, err := zip.OpenReader(src)
+ if err != nil {
+ return nil, time.Time{}, fmt.Errorf("mkmsi: cannot open %s: %w", src, err)
+ }
+ defer func() { _ = r.Close() }()
+
+ if len(r.File) == 0 {
+ return nil, time.Time{}, fmt.Errorf("mkmsi: %s has no entries", src)
+ }
+
+ // yeet stamps every entry in the zip with the same mtime; verified
+ // empirically across a real build's 151 entries. Any entry's mtime is
+ // therefore as good as any other's.
+ srcMTime := r.File[0].Modified
+
+ var names []string
+
+ for _, f := range r.File {
+ // yeet wraps everything in one directory named after the package.
+ rel := f.Name
+ if i := strings.IndexByte(rel, '/'); i >= 0 {
+ rel = rel[i+1:]
+ }
+
+ if rel == "" {
+ continue
+ }
+
+ // Reject traversal before touching the filesystem: the zip is built
+ // locally, but this program also runs over files from CI artifacts.
+ out := filepath.Join(dest, filepath.FromSlash(rel))
+ if !strings.HasPrefix(out, filepath.Clean(dest)+string(os.PathSeparator)) {
+ return nil, time.Time{}, fmt.Errorf("mkmsi: refusing path %q outside %s", f.Name, dest)
+ }
+
+ if f.FileInfo().IsDir() {
+ if err := os.MkdirAll(out, 0o755); err != nil {
+ return nil, time.Time{}, fmt.Errorf("mkmsi: cannot create %s: %w", out, err)
+ }
+ continue
+ }
+
+ if err := extractOne(f, out); err != nil {
+ return nil, time.Time{}, err
+ }
+
+ names = append(names, rel)
+ }
+
+ slices.Sort(names)
+
+ return names, srcMTime, nil
+}
+
+// extractOne writes a single zip entry to disk.
+func extractOne(f *zip.File, out string) error {
+ if err := os.MkdirAll(filepath.Dir(out), 0o755); err != nil {
+ return fmt.Errorf("mkmsi: cannot create %s: %w", filepath.Dir(out), err)
+ }
+
+ rc, err := f.Open()
+ if err != nil {
+ return fmt.Errorf("mkmsi: cannot read %s from zip: %w", f.Name, err)
+ }
+ defer func() { _ = rc.Close() }()
+
+ w, err := os.OpenFile(out, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode().Perm()|0o200)
+ if err != nil {
+ return fmt.Errorf("mkmsi: cannot create %s: %w", out, err)
+ }
+
+ if _, err := io.Copy(w, rc); err != nil {
+ _ = w.Close()
+ return fmt.Errorf("mkmsi: cannot write %s: %w", out, err)
+ }
+
+ if err := w.Close(); err != nil {
+ return fmt.Errorf("mkmsi: cannot close %s: %w", out, err)
+ }
+
+ // yeet pins every zip entry's modification time to a fixed epoch so its
+ // zips are byte-identical across builds. os.OpenFile above stamps the
+ // file with the current wall clock instead, and the CAB archive wixl
+ // builds embeds each file's mtime -- so without restoring it here, the
+ // CAB stream (and therefore the MSI) differs on every build even when
+ // the input zip is identical. This must run after w.Close(), not
+ // before: closing a file does not touch its mtime, but running before
+ // the copy finishes would use the file's creation time instead.
+ if err := os.Chtimes(out, f.Modified, f.Modified); err != nil {
+ return fmt.Errorf("mkmsi: cannot set mtime on %s: %w", out, err)
+ }
+
+ return nil
+}
+
+// concatFiles writes the contents of srcs, in order, to dest, then stamps it
+// with mtime.
+//
+// The stamp matters for the same reason extractOne restores one on every
+// file it extracts from the zip: dest is a File the MSI installs, so its
+// mtime is embedded in the CAB archive wixl builds. Left at the wall clock
+// os.WriteFile would otherwise use, it would make the CAB -- and therefore
+// the MSI -- differ on every build even when every other input is identical.
+// mtime is srcMTime, the single mtime yeet stamped every entry in the source
+// zip with (see unzip), so these generated files end up sharing it rather
+// than inventing a separate fixed point in time of their own.
+func concatFiles(dest string, mtime time.Time, srcs ...string) error {
+ var buf []byte
+
+ for _, src := range srcs {
+ data, err := os.ReadFile(src)
+ if err != nil {
+ return fmt.Errorf("mkmsi: cannot read %s: %w", src, err)
+ }
+
+ buf = append(buf, data...)
+ }
+
+ if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
+ return fmt.Errorf("mkmsi: cannot create %s: %w", filepath.Dir(dest), err)
+ }
+
+ if err := os.WriteFile(dest, buf, 0o644); err != nil {
+ return fmt.Errorf("mkmsi: cannot write %s: %w", dest, err)
+ }
+
+ if err := os.Chtimes(dest, mtime, mtime); err != nil {
+ return fmt.Errorf("mkmsi: cannot set mtime on %s: %w", dest, err)
+ }
+
+ return nil
+}
diff --git a/internal/cmd/mkmsi/build_test.go b/internal/cmd/mkmsi/build_test.go
new file mode 100644
index 000000000..9b81eab37
--- /dev/null
+++ b/internal/cmd/mkmsi/build_test.go
@@ -0,0 +1,92 @@
+package main
+
+import (
+ "errors"
+ "testing"
+)
+
+func TestVersionFromZipName(t *testing.T) {
+ for _, tt := range []struct {
+ name string
+ in string
+ want string
+ wantErr error
+ }{
+ {
+ name: "dev build with path",
+ in: "var/win/anubis-1.26.2-1-gec3cce8a-dev-windows-amd64.zip",
+ want: "1.26.2-1-gec3cce8a-dev",
+ },
+ {
+ name: "tagged release",
+ in: "anubis-1.26.2-windows-amd64.zip",
+ want: "1.26.2",
+ },
+ {
+ name: "arm64",
+ in: "anubis-1.26.2-windows-arm64.zip",
+ want: "1.26.2",
+ },
+ {name: "not an anubis zip", in: "something-else.zip", wantErr: ErrBadVersion},
+ {name: "no platform suffix", in: "anubis-1.26.2.zip", wantErr: ErrBadVersion},
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := versionFromZipName(tt.in)
+
+ if tt.wantErr != nil {
+ if !errors.Is(err, tt.wantErr) {
+ t.Fatalf("versionFromZipName(%q): got error %v, want %v", tt.in, err, tt.wantErr)
+ }
+ return
+ }
+
+ if err != nil {
+ t.Fatalf("versionFromZipName(%q): unexpected error: %v", tt.in, err)
+ }
+
+ if got != tt.want {
+ t.Errorf("versionFromZipName(%q) = %q, want %q", tt.in, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestCheckWixlOutput(t *testing.T) {
+ for _, tt := range []struct {
+ name string
+ in string
+ wantErr error
+ }{
+ {name: "clean", in: ""},
+ {name: "harmless chatter", in: "building anubis.msi\n"},
+ {
+ name: "dropped attribute",
+ in: "(wixl:1234): GLib-GObject-CRITICAL **: object class 'WixlWixFile' has no property named 'NeverOverwrite'\n",
+ wantErr: ErrWixlDroppedAttribute,
+ },
+ {
+ name: "critical among other lines",
+ in: "fine\n** (wixl:1): CRITICAL **: assertion failed\nalso fine\n",
+ wantErr: ErrWixlDroppedAttribute,
+ },
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ // checkWixlOutput is a pure string-matching function over
+ // captured sample wixl stderr; it never shells out to wixl
+ // itself, so unlike msi_test.go's tests this one needs no
+ // requireTool gate and must always run.
+ err := checkWixlOutput(tt.in)
+
+ if tt.wantErr == nil {
+ if err != nil {
+ t.Fatalf("checkWixlOutput(%q): unexpected error: %v", tt.in, err)
+ }
+ return
+ }
+
+ if !errors.Is(err, tt.wantErr) {
+ t.Errorf("checkWixlOutput(%q): got %v, want %v", tt.in, err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/mkmsi/docfragment.go b/internal/cmd/mkmsi/docfragment.go
new file mode 100644
index 000000000..f8f3e6e1d
--- /dev/null
+++ b/internal/cmd/mkmsi/docfragment.go
@@ -0,0 +1,146 @@
+package main
+
+import (
+ "bytes"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/xml"
+ "fmt"
+ "io"
+ "strings"
+)
+
+// rewriteDirectoryIDs replaces wixl-heat's randomized dir directory IDs
+// in a generated fragment with IDs derived deterministically from each
+// directory's path, so the fragment -- and therefore the MSI wixl builds
+// from it -- is reproducible across builds.
+//
+// wixl-heat's cmp and fil IDs are already stable, derived from
+// each component's and file's relative path. Only its dir IDs are
+// random: running wixl-heat twice over an identical tree produces two
+// completely disjoint sets of directory IDs (verified empirically). Each
+// dir ID appears exactly once in the fragment, as a Directory
+// element's own Id attribute -- nothing else in the document references it
+// -- which is what makes a single rewrite pass safe.
+//
+// This walks the document with encoding/xml rather than a regex: a Name
+// attribute or a doc file's path could otherwise contain something that
+// looks like an id, and a regex over the raw text would have no way to tell
+// that apart from a real dir attribute.
+func rewriteDirectoryIDs(doc []byte) ([]byte, error) {
+ dec := xml.NewDecoder(bytes.NewReader(doc))
+
+ var out bytes.Buffer
+ enc := xml.NewEncoder(&out)
+
+ // dirNames accumulates the Name attributes of open Directory elements,
+ // in nesting order, so each directory's deterministic ID is derived
+ // from its full relative path rather than just its own Name -- two
+ // directories with the same leaf name under different parents (e.g. two
+ // "haproxy" directories in the Anubis docs tree) must not collide.
+ var dirNames []string
+
+ for {
+ tok, err := dec.Token()
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ return nil, fmt.Errorf("mkmsi: cannot parse doc fragment: %w", err)
+ }
+
+ switch t := tok.(type) {
+ case xml.StartElement:
+ // encoding/xml's Encoder writes an xmlns declaration for every
+ // element with a resolved namespace, regardless of whether an
+ // ancestor already declared it. Left alone, the xmlns attribute
+ // this document's root already carries in Attr collides with
+ // that auto-generated one and produces a duplicate attribute --
+ // invalid XML that libxml2 (and therefore wixl) rejects. Every
+ // element's namespace is preserved via Name.Space either way,
+ // so dropping the explicit declarations here is lossless.
+ t.Attr = dropXMLNSAttrs(t.Attr)
+
+ if t.Name.Local == "Directory" {
+ dirNames = append(dirNames, attrValue(t.Attr, "Name"))
+
+ id := directoryID(directoryPath(dirNames))
+ for i := range t.Attr {
+ if t.Attr[i].Name.Local == "Id" {
+ t.Attr[i].Value = id
+ }
+ }
+ }
+
+ tok = t
+ case xml.EndElement:
+ if t.Name.Local == "Directory" {
+ dirNames = dirNames[:len(dirNames)-1]
+ }
+ }
+
+ if err := enc.EncodeToken(tok); err != nil {
+ return nil, fmt.Errorf("mkmsi: cannot rewrite doc fragment: %w", err)
+ }
+ }
+
+ if err := enc.Flush(); err != nil {
+ return nil, fmt.Errorf("mkmsi: cannot rewrite doc fragment: %w", err)
+ }
+
+ return out.Bytes(), nil
+}
+
+// dropXMLNSAttrs removes default and prefixed xmlns declarations from an
+// attribute list, keeping everything else in order.
+func dropXMLNSAttrs(attrs []xml.Attr) []xml.Attr {
+ kept := attrs[:0]
+ for _, a := range attrs {
+ if a.Name.Space == "xmlns" || (a.Name.Space == "" && a.Name.Local == "xmlns") {
+ continue
+ }
+ kept = append(kept, a)
+ }
+ return kept
+}
+
+// attrValue returns the value of the first attribute named local, or "" if
+// there is none.
+func attrValue(attrs []xml.Attr, local string) string {
+ for _, a := range attrs {
+ if a.Name.Local == local {
+ return a.Value
+ }
+ }
+ return ""
+}
+
+// directoryPath joins accumulated Directory Name attributes into a relative
+// path. wixl-heat's own root directory is named ".", which is dropped so
+// the path starts clean instead of carrying a leading "./".
+func directoryPath(names []string) string {
+ var parts []string
+ for _, n := range names {
+ if n == "." {
+ continue
+ }
+ parts = append(parts, n)
+ }
+ return strings.Join(parts, "/")
+}
+
+// directoryID derives a deterministic Directory Id from a relative path, in
+// the same dir shape wixl-heat itself uses for cmp and fil: a
+// fixed three-letter prefix plus a 32-character uppercase hex digest. This
+// is an identifier, not a security boundary, so the first 16 bytes of a
+// SHA-256 digest are used rather than MD5, purely to keep the digest the
+// same length wixl-heat's own IDs use.
+//
+// It is path-derived rather than sequential on purpose: a sequential
+// numbering scheme would renumber every directory whenever a doc file is
+// added or removed anywhere in the tree, while a path-derived one only
+// changes for a directory that itself moved.
+func directoryID(path string) string {
+ sum := sha256.Sum256([]byte(path))
+ return "dir" + strings.ToUpper(hex.EncodeToString(sum[:16]))
+}
diff --git a/internal/cmd/mkmsi/main.go b/internal/cmd/mkmsi/main.go
new file mode 100644
index 000000000..302a7daeb
--- /dev/null
+++ b/internal/cmd/mkmsi/main.go
@@ -0,0 +1,735 @@
+// Command mkmsi turns the Windows zip that yeet builds into an MSI installer.
+//
+// It needs wixl, wixl-heat, msibuild and msiinfo from msitools on PATH.
+package main
+
+import (
+ "bytes"
+ "flag"
+ "fmt"
+ "log"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "slices"
+ "strconv"
+ "strings"
+ "time"
+)
+
+var (
+ zipPath = flag.String("zip", "", "path to the Windows zip built by yeet")
+ outPath = flag.String("out", "", "path to write the MSI to")
+ packagingDir = flag.String("packaging-dir", filepath.Join("run", "windows"), "directory holding anubis.wxs and the config templates")
+ stagingDir = flag.String("staging-dir", filepath.Join("var", "mkmsi"), "directory to stage the MSI build in; an subdirectory is used and its previous contents are removed first")
+)
+
+// The installer is 64-bit only. anubis.wxs installs into ProgramFiles64Folder
+// and marks its components Win64, so there is no meaningful 32-bit build to
+// select. An --arch flag here would only let a caller produce an MSI that
+// claims to be 32-bit while installing 64-bit components.
+const (
+ msiArch = "x64"
+ msiWin64 = "yes"
+
+ // msiInstallerVersionAmd64 declares the Windows Installer engine version
+ // the package requires. 200 is the long-standing baseline and is what
+ // amd64 has always shipped.
+ msiInstallerVersionAmd64 = "200"
+ // msiInstallerVersionArm64 must be 500: the Arm64 package template (see
+ // setArm64Template) was only introduced in Windows Installer 5.0. An MSI
+ // declaring a lower InstallerVersion alongside an Arm64 template risks
+ // being rejected on the target as "not supported by this processor
+ // type" instead of failing at build time.
+ msiInstallerVersionArm64 = "500"
+)
+
+func main() {
+ flag.Parse()
+
+ if *zipPath == "" || *outPath == "" {
+ log.Fatal("both --zip and --out are required")
+ }
+
+ if err := build(); err != nil {
+ log.Fatalf("%v", err)
+ }
+
+ fmt.Println(*outPath)
+}
+
+func build() error {
+ version, err := versionFromZipName(*zipPath)
+ if err != nil {
+ return err
+ }
+
+ arch, err := archFromZipName(*zipPath)
+ if err != nil {
+ return err
+ }
+
+ msiVer, err := msiVersion(version)
+ if err != nil {
+ return err
+ }
+
+ code, err := productCode(version, arch)
+ if err != nil {
+ return err
+ }
+
+ // Computed here, from the raw version string, rather than inside
+ // runWixl/patchSummaryInfo: those only ever see msiVer, the already-
+ // converted numeric x.y.z.w form, which msiVersion (and therefore
+ // packageCode) cannot parse back.
+ pkgCode, err := packageCode(version, arch)
+ if err != nil {
+ return err
+ }
+
+ // Staged per architecture so an amd64 and an arm64 build running one
+ // after the other -- as both branches of buildTestMSI do -- cannot
+ // contaminate each other's tree.
+ staging := filepath.Join(*stagingDir, arch)
+
+ // Wipe any previous run's contents before staging fresh ones. A leftover
+ // file from an earlier build would otherwise still be sitting in
+ // staging and silently end up in this MSI. The tree is intentionally
+ // left in place after a successful build (no deferred cleanup) so it
+ // can be inspected -- var/mkmsi already only lives under a gitignored
+ // directory, and being able to diff two builds' staging trees directly
+ // is worth more than tidying up after every run.
+ if err := os.RemoveAll(staging); err != nil {
+ return fmt.Errorf("mkmsi: cannot clear staging directory %s: %w", staging, err)
+ }
+ if err := os.MkdirAll(staging, 0o755); err != nil {
+ return fmt.Errorf("mkmsi: cannot create staging directory %s: %w", staging, err)
+ }
+
+ _, srcMTime, err := unzip(*zipPath, staging)
+ if err != nil {
+ return err
+ }
+
+ if err := stageConfigTemplates(staging, srcMTime); err != nil {
+ return err
+ }
+
+ docWxs := filepath.Join(staging, "doc-files.wxs")
+ if err := generateDocFragment(staging, docWxs); err != nil {
+ return err
+ }
+
+ return runWixl(staging, docWxs, msiVer, code, pkgCode, arch)
+}
+
+// stageConfigTemplates builds the etc directory the installer ships: the env
+// file as written, and the default policy with the Windows logging block
+// appended. mtime is stamped on both, so they carry the same reproducible
+// mtime as everything unzip extracted from the source zip; see concatFiles.
+func stageConfigTemplates(staging string, mtime time.Time) error {
+ etc := filepath.Join(staging, "etc")
+
+ if err := concatFiles(filepath.Join(etc, "anubis.env"), mtime, filepath.Join(*packagingDir, "anubis.env")); err != nil {
+ return err
+ }
+
+ policy := filepath.Join(staging, "doc", "botPolicies.yaml")
+ if err := concatFiles(filepath.Join(etc, "anubis.yaml"), mtime, policy, filepath.Join(*packagingDir, "logging.yaml")); err != nil {
+ return err
+ }
+
+ return nil
+}
+
+// generateDocFragment runs wixl-heat over the documentation tree. The file
+// list is sorted so the fragment, and therefore the MSI, is reproducible.
+func generateDocFragment(staging, out string) error {
+ docDir := filepath.Join(staging, "doc")
+
+ var files []string
+ err := filepath.WalkDir(docDir, func(path string, d os.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ if !d.IsDir() {
+ files = append(files, path)
+ }
+ return nil
+ })
+ if err != nil {
+ return fmt.Errorf("mkmsi: cannot walk %s: %w", docDir, err)
+ }
+
+ slices.Sort(files)
+
+ // Root the fragment at DOCDIR, which anubis.wxs already declares, and give
+ // it its own source variable. Pointing wixl-heat at INSTALLDIR instead
+ // would make it emit a second "doc" directory alongside the declared one.
+ cmd := exec.Command("wixl-heat",
+ "--prefix", docDir+string(os.PathSeparator),
+ "--directory-ref", "DOCDIR",
+ "--component-group", "DocFiles",
+ "--var", "var.DocSourceDir",
+ "--win64",
+ )
+ cmd.Stdin = strings.NewReader(strings.Join(files, "\n") + "\n")
+
+ var stdout, stderr bytes.Buffer
+ cmd.Stdout = &stdout
+ cmd.Stderr = &stderr
+
+ runErr := cmd.Run()
+
+ // Check stderr before the exit status, exactly as runWixl does. wixl
+ // reports a dropped attribute this way and then exits 0, so the exit
+ // status alone cannot be trusted, and a non-zero exit accompanied by a
+ // dropped attribute must still surface as ErrWixlDroppedAttribute.
+ if err := checkWixlOutput(stderr.String()); err != nil {
+ return err
+ }
+
+ if runErr != nil {
+ return fmt.Errorf("mkmsi: wixl-heat failed: %w: %s", runErr, stderr.String())
+ }
+
+ // wixl-heat randomizes its Directory IDs on every invocation, even over
+ // an identical tree; see rewriteDirectoryIDs for the details and why
+ // that alone breaks build reproducibility.
+ fragment, err := rewriteDirectoryIDs(stdout.Bytes())
+ if err != nil {
+ return err
+ }
+
+ if err := os.WriteFile(out, fragment, 0o644); err != nil {
+ return fmt.Errorf("mkmsi: cannot write %s: %w", out, err)
+ }
+
+ return nil
+}
+
+// runWixl compiles the wxs sources into the output MSI. version and code are
+// the MSI ProductVersion and ProductCode (msiVersion/productCode's output);
+// pkgCode is the PackageCode patched in afterward (packageCode's output,
+// still the raw UUID, not yet braced/uppercased).
+func runWixl(staging, docWxs, version, code, pkgCode, arch string) error {
+ if err := os.MkdirAll(filepath.Dir(*outPath), 0o755); err != nil {
+ return fmt.Errorf("mkmsi: cannot create %s: %w", filepath.Dir(*outPath), err)
+ }
+
+ installerVersion := msiInstallerVersionAmd64
+ if arch == "arm64" {
+ installerVersion = msiInstallerVersionArm64
+ }
+
+ cmd := exec.Command("wixl",
+ "-a", msiArch,
+ "--ext", "ui",
+ "-D", "SourceDir="+staging,
+ "-D", "DocSourceDir="+filepath.Join(staging, "doc"),
+ "-D", "Win64="+msiWin64,
+ "-D", "Version="+version,
+ "-D", "ProductCode="+code,
+ "-D", "InstallerVersion="+installerVersion,
+ "-o", *outPath,
+ filepath.Join(*packagingDir, "anubis.wxs"),
+ docWxs,
+ )
+
+ var stderr bytes.Buffer
+ cmd.Stderr = &stderr
+ cmd.Stdout = os.Stdout
+
+ runErr := cmd.Run()
+
+ // Check stderr first: wixl reports a dropped attribute this way and then
+ // exits 0, which would otherwise look like success.
+ if err := checkWixlOutput(stderr.String()); err != nil {
+ return err
+ }
+
+ if runErr != nil {
+ return fmt.Errorf("mkmsi: wixl failed: %w: %s", runErr, stderr.String())
+ }
+
+ if err := secureInstallDir(*outPath); err != nil {
+ return err
+ }
+
+ if err := patchInstallerImages(*outPath); err != nil {
+ return err
+ }
+
+ if err := patchSummaryInfo(*outPath, pkgCode, arch); err != nil {
+ return err
+ }
+
+ if err := patchEnvironmentID(*outPath); err != nil {
+ return err
+ }
+
+ if err := patchCustomActionSequence(*outPath); err != nil {
+ return err
+ }
+
+ return nil
+}
+
+// installerImage pairs one wixl UI extension Binary stream with the image in
+// *packagingDir that replaces it.
+type installerImage struct {
+ stream string
+ file string
+}
+
+// installerImages lists the streams patchInstallerImages replaces.
+// WixUI_Bmp_Banner is the strip shown across the top of every wizard page;
+// WixUI_Bmp_Dialog is the full-height image on the welcome and exit pages.
+//
+// This is a slice, not a map, so the msibuild arguments below are built in a
+// fixed order: build outputs should be reproducible, and map iteration order
+// is not.
+var installerImages = []installerImage{
+ {"Binary.WixUI_Bmp_Banner", "banner.bmp"},
+ {"Binary.WixUI_Bmp_Dialog", "dialog.bmp"},
+}
+
+// patchInstallerImages replaces wixl's stock WiX banner and dialog artwork
+// with Anubis's own, for both architectures.
+//
+// There is no declarative way to do this: ,
+// how real WiX overrides these, is not supported by wixl. The alternative --
+// pointing --extdir at a local copy of wixl's ext/ui tree so the Binary
+// SourceFile paths can be edited -- would mean vendoring ~18 MS-RL licensed
+// .wxs files into this MIT-licensed repo, so this patches the built MSI's
+// Binary streams directly instead, the same way secureInstallDir and
+// setArm64Template patch other parts of the file after wixl runs.
+func patchInstallerImages(msiPath string) error {
+ args := []string{msiPath}
+ for _, img := range installerImages {
+ args = append(args, "-a", img.stream, filepath.Join(*packagingDir, img.file))
+ }
+
+ cmd := exec.Command("msibuild", args...)
+
+ var stderr bytes.Buffer
+ cmd.Stderr = &stderr
+ cmd.Stdout = os.Stdout
+
+ if err := cmd.Run(); err != nil {
+ return fmt.Errorf("mkmsi: msibuild failed to patch installer images: %w: %s", err, stderr.String())
+ }
+
+ // msibuild's exit status is not proof either stream actually changed --
+ // see secureInstallDir and setArm64Template for why that trust has
+ // already twice proven misplaced in this file. Extract each stream back
+ // and compare its length against the source image rather than assume
+ // the write landed.
+ for _, img := range installerImages {
+ source := filepath.Join(*packagingDir, img.file)
+
+ wantInfo, err := os.Stat(source)
+ if err != nil {
+ return fmt.Errorf("mkmsi: cannot stat %s: %w", source, err)
+ }
+
+ got, err := exec.Command("msiinfo", "extract", msiPath, img.stream).Output()
+ if err != nil {
+ return fmt.Errorf("mkmsi: msiinfo extract %s %s failed: %w", msiPath, img.stream, err)
+ }
+
+ if int64(len(got)) != wantInfo.Size() {
+ return fmt.Errorf("mkmsi: msibuild did not patch %s on %s: stream is %d bytes, %s is %d bytes",
+ img.stream, msiPath, len(got), source, wantInfo.Size())
+ }
+ }
+
+ return nil
+}
+
+// secureInstallDir marks INSTALLDIR as a secure custom property, so that a
+// command-line `msiexec /i anubis.msi INSTALLDIR=...` override survives
+// elevation into the deferred, system-context part of the install. Without
+// it, the override is silently ignored and Anubis installs to the default
+// location anyway.
+//
+// This can't be done declaratively in anubis.wxs. Both obvious forms fail
+// under wixl 0.106:
+//
+// - Property/@Secure="yes" is silently dropped; no SecureCustomProperties
+// row results at all.
+// - A hand-authored collides with
+// the row wixl generates automatically for MajorUpgrade (populated with
+// WIX_UPGRADE_DETECTED and WIX_DOWNGRADE_DETECTED) and aborts the wixl
+// build outright with a bare "libmsi_query_execute" and no further
+// explanation.
+//
+// So, the same way setArm64Template patches the summary Template after the
+// fact, this patches the SecureCustomProperties row after wixl has built it.
+func secureInstallDir(msiPath string) error {
+ existing, err := readProperty(msiPath, "SecureCustomProperties")
+ if err != nil {
+ return err
+ }
+
+ if slices.Contains(strings.Split(existing, ";"), "INSTALLDIR") {
+ return nil
+ }
+
+ want := "INSTALLDIR"
+ if existing != "" {
+ want += ";" + existing
+ }
+
+ // This assumes the SecureCustomProperties row already exists, which
+ // MajorUpgrade above guarantees today. If that ever stops being true,
+ // this UPDATE silently affects zero rows -- but the read-back below
+ // still catches it and fails loudly rather than shipping an MSI where
+ // INSTALLDIR= quietly does nothing.
+ cmd := exec.Command("msibuild", msiPath, "-q",
+ fmt.Sprintf("UPDATE Property SET Value='%s' WHERE Property='SecureCustomProperties'", want))
+
+ var stderr bytes.Buffer
+ cmd.Stderr = &stderr
+ cmd.Stdout = os.Stdout
+
+ if err := cmd.Run(); err != nil {
+ return fmt.Errorf("mkmsi: msibuild failed to secure INSTALLDIR: %w: %s", err, stderr.String())
+ }
+
+ got, err := readProperty(msiPath, "SecureCustomProperties")
+ if err != nil {
+ return err
+ }
+
+ if !slices.Contains(strings.Split(got, ";"), "INSTALLDIR") {
+ return fmt.Errorf("mkmsi: msibuild did not add INSTALLDIR to SecureCustomProperties on %s: got %q", msiPath, got)
+ }
+
+ return nil
+}
+
+// readProperty returns one row's value from an MSI's Property table, as
+// reported by `msiinfo export`, or "" if the property has no row.
+func readProperty(msiPath, name string) (string, error) {
+ out, err := exec.Command("msiinfo", "export", msiPath, "Property").Output()
+ if err != nil {
+ return "", fmt.Errorf("mkmsi: msiinfo export %s Property failed: %w", msiPath, err)
+ }
+
+ for _, line := range strings.Split(strings.ReplaceAll(string(out), "\r\n", "\n"), "\n") {
+ if prop, value, ok := strings.Cut(line, "\t"); ok && prop == name {
+ return value, nil
+ }
+ }
+
+ return "", nil
+}
+
+// patchSummaryInfo rewrites the summary information stream after wixl has
+// built the MSI: the Template field, and the PackageCode (the summary's
+// "Revision number (UUID)" field).
+//
+// Both go through one msibuild -s call site on purpose. msibuild -s
+// overwrites Subject, Template and PackageCode together every time it is
+// called -- there is no way to set one without restating the other two --
+// so a second call site (there used to be one here, setting only Template
+// for arm64) would silently clobber whatever this one had just written, or
+// vice versa depending on call order. One call site that always states all
+// three is the only way they cannot fight.
+//
+// wixl 0.106 has no arm64 target — it accepts only intel, intel64 and ia64 —
+// so an arm64 package is built as x64 and its Template corrected here. Only
+// the declared architecture is wrong; the component bitness and the
+// ProgramFiles64Folder install location are already right for arm64.
+//
+// PackageCode is set to a deterministic UUIDv5 (see packageCode) instead of
+// the random value wixl assigns on every build; see packageCode's doc
+// comment for why turning off that randomness is correct once the rest of
+// the build is already reproducible. pkgCode is packageCode's raw output,
+// formatted here to match the braced, uppercase shape wixl itself writes
+// ProductCode and UpgradeCode in.
+func patchSummaryInfo(msiPath, pkgCode, arch string) error {
+ template := "x64;1033"
+ if arch == "arm64" {
+ template = "Arm64;1033"
+ }
+
+ wantCode := "{" + strings.ToUpper(pkgCode) + "}"
+
+ cmd := exec.Command("msibuild", msiPath,
+ "-s", "Anubis Web AI Firewall Utility", "Techaro", template, wantCode,
+ )
+
+ var stderr bytes.Buffer
+ cmd.Stderr = &stderr
+ cmd.Stdout = os.Stdout
+
+ if err := cmd.Run(); err != nil {
+ return fmt.Errorf("mkmsi: msibuild failed to set summary information: %w: %s", err, stderr.String())
+ }
+
+ // msibuild's exit status alone is not proof either field was actually
+ // changed: this project has already learned wixl exits 0 while silently
+ // dropping data, and msibuild deserves no more trust. Read both back and
+ // fail loudly rather than shipping an MSI with the wrong Template, or a
+ // PackageCode nobody can reproduce.
+ gotTemplate, err := readSummaryTemplate(msiPath)
+ if err != nil {
+ return err
+ }
+ if gotTemplate != template {
+ return fmt.Errorf("mkmsi: msibuild did not set the summary template on %s: got %q, want %q", msiPath, gotTemplate, template)
+ }
+
+ gotCode, err := readSummaryPackageCode(msiPath)
+ if err != nil {
+ return err
+ }
+ if gotCode != wantCode {
+ return fmt.Errorf("mkmsi: msibuild did not set the PackageCode on %s: got %q, want %q", msiPath, gotCode, wantCode)
+ }
+
+ return nil
+}
+
+// readSummaryTemplate returns the Template field from an MSI's summary
+// information stream, as reported by `msiinfo suminfo`.
+func readSummaryTemplate(msiPath string) (string, error) {
+ out, err := exec.Command("msiinfo", "suminfo", msiPath).Output()
+ if err != nil {
+ return "", fmt.Errorf("mkmsi: msiinfo suminfo %s failed: %w", msiPath, err)
+ }
+
+ for _, line := range strings.Split(string(out), "\n") {
+ if rest, ok := strings.CutPrefix(line, "Template:"); ok {
+ return strings.TrimSpace(rest), nil
+ }
+ }
+
+ return "", fmt.Errorf("mkmsi: msiinfo suminfo %s has no Template line", msiPath)
+}
+
+// readSummaryPackageCode returns the "Revision number (UUID)" field -- the
+// PackageCode -- from an MSI's summary information stream, as reported by
+// `msiinfo suminfo`.
+func readSummaryPackageCode(msiPath string) (string, error) {
+ out, err := exec.Command("msiinfo", "suminfo", msiPath).Output()
+ if err != nil {
+ return "", fmt.Errorf("mkmsi: msiinfo suminfo %s failed: %w", msiPath, err)
+ }
+
+ for _, line := range strings.Split(string(out), "\n") {
+ if rest, ok := strings.CutPrefix(line, "Revision number (UUID):"); ok {
+ return strings.TrimSpace(rest), nil
+ }
+ }
+
+ return "", fmt.Errorf("mkmsi: msiinfo suminfo %s has no Revision number (UUID) line", msiPath)
+}
+
+// patchEnvironmentID overwrites the Environment table's primary key for the
+// PATH row with a fixed value.
+//
+// wixl 0.106 ignores the Id="env_path" declared in anubis.wxs for this row
+// and assigns a random GUID to the Environment table's primary key column
+// instead, so the row's key differs on every otherwise-identical build.
+//
+// This can't reuse secureInstallDir's UPDATE approach: Environment is the
+// primary key of this table, and the Windows Installer SQL dialect
+// (verified empirically -- msibuild reports "failed to execute query" and
+// libmsi logs a CRITICAL on the attempt) refuses to UPDATE a primary key
+// column. Deleting the row and re-inserting it with the same Name and Value
+// but a fixed key is the documented way around that.
+func patchEnvironmentID(msiPath string) error {
+ const (
+ component = "cmp_path"
+ want = "env_path"
+ )
+
+ name, value, err := readEnvironmentRow(msiPath, component)
+ if err != nil {
+ return err
+ }
+
+ cmd := exec.Command("msibuild", msiPath, "-q",
+ fmt.Sprintf("DELETE FROM Environment WHERE Component_='%s'", sqlEscape(component)),
+ "-q",
+ fmt.Sprintf("INSERT INTO Environment (Environment, Name, Value, Component_) VALUES ('%s', '%s', '%s', '%s')",
+ sqlEscape(want), sqlEscape(name), sqlEscape(value), sqlEscape(component)),
+ )
+
+ var stderr bytes.Buffer
+ cmd.Stderr = &stderr
+ cmd.Stdout = os.Stdout
+
+ if err := cmd.Run(); err != nil {
+ return fmt.Errorf("mkmsi: msibuild failed to patch the Environment table: %w: %s", err, stderr.String())
+ }
+
+ gotID, gotName, gotValue, err := readEnvironmentRowByID(msiPath, want)
+ if err != nil {
+ return err
+ }
+
+ if gotID != want || gotName != name || gotValue != value {
+ return fmt.Errorf("mkmsi: msibuild did not replace the Environment row for %s on %s correctly: got (%q, %q, %q), want (%q, %q, %q)",
+ component, msiPath, gotID, gotName, gotValue, want, name, value)
+ }
+
+ return nil
+}
+
+// sqlEscape doubles single quotes so a value can be embedded in an MSI SQL
+// string literal. None of the values this program passes through it are
+// expected to contain one -- Name and Value come from anubis.wxs, which
+// this project controls -- but the values still flow through here rather
+// than straight into a query string, the same discipline this being
+// security-sensitive software calls for anywhere user- or file-sourced text
+// reaches a query.
+func sqlEscape(s string) string {
+ return strings.ReplaceAll(s, "'", "''")
+}
+
+// readEnvironmentRow returns the Name and Value columns of the Environment
+// table's row for component, as reported by `msiinfo export`.
+func readEnvironmentRow(msiPath, component string) (name, value string, err error) {
+ out, err := exec.Command("msiinfo", "export", msiPath, "Environment").Output()
+ if err != nil {
+ return "", "", fmt.Errorf("mkmsi: msiinfo export %s Environment failed: %w", msiPath, err)
+ }
+
+ for _, line := range strings.Split(strings.ReplaceAll(string(out), "\r\n", "\n"), "\n") {
+ // Environment Name Value Component_
+ fields := strings.Split(line, "\t")
+ if len(fields) == 4 && fields[3] == component {
+ return fields[1], fields[2], nil
+ }
+ }
+
+ return "", "", fmt.Errorf("mkmsi: msiinfo export %s Environment has no row for component %s", msiPath, component)
+}
+
+// readEnvironmentRowByID returns the Name and Value columns of the
+// Environment table's row keyed by id, as reported by `msiinfo export`.
+func readEnvironmentRowByID(msiPath, id string) (gotID, name, value string, err error) {
+ out, err := exec.Command("msiinfo", "export", msiPath, "Environment").Output()
+ if err != nil {
+ return "", "", "", fmt.Errorf("mkmsi: msiinfo export %s Environment failed: %w", msiPath, err)
+ }
+
+ for _, line := range strings.Split(strings.ReplaceAll(string(out), "\r\n", "\n"), "\n") {
+ // Environment Name Value Component_
+ fields := strings.Split(line, "\t")
+ if len(fields) == 4 && fields[0] == id {
+ return fields[0], fields[1], fields[2], nil
+ }
+ }
+
+ return "", "", "", fmt.Errorf("mkmsi: msiinfo export %s Environment has no row with key %s", msiPath, id)
+}
+
+// patchCustomActionSequence pins the InstallExecuteSequence Sequence values
+// of SetAnubisExePath and BootstrapConfig -- the two custom actions
+// anubis.wxs schedules only relative to each other and to InstallFiles
+// ("Before BootstrapConfig" and "After InstallFiles" respectively), never to
+// an absolute Sequence number of their own.
+//
+// This is not one of the five sources of drift this file was written to
+// fix; it was found while verifying them. wixl resolves relative
+// Before/After hints into absolute Sequence numbers itself, and that
+// resolution is not deterministic: across repeated builds of
+// byte-identical staged input, it has been observed (empirically, running
+// the same build eight times in a row) to place SetAnubisExePath at either
+// 1402 (immediately after RemoveExistingProducts) or 4001 (immediately
+// after InstallFiles, immediately before BootstrapConfig), with
+// BootstrapConfig following one step behind either way. Both placements
+// satisfy the wxs's own constraints, so wixl's exit status gives no signal
+// anything is wrong.
+//
+// The tightly-packed placement is the one this pins to, because it is the
+// only one actually implied by the wxs text: nothing in anubis.wxs
+// schedules SetAnubisExePath relative to RemoveExistingProducts, so pinning
+// to that position would invent an ordering guarantee the wxs never asked
+// for. InstallFiles's own Sequence is read back rather than hardcoded, so
+// this keeps working if its position ever changes.
+//
+// This corrects the value only, not the table's on-disk row order, which
+// was tried and abandoned: deleting a row and reinserting it under the same
+// Action was observed, empirically, to always land the row back in its
+// original physical slot regardless of when or in what order the insert
+// ran -- slot placement in this MSI table format appears to be a function
+// of the Action string alone, immutable from outside once wixl first
+// writes the row, and not reachable through msibuild's SQL interface. Since
+// Windows Installer executes actions in Sequence order regardless of
+// on-disk row order, that residual is cosmetic -- but it does mean two
+// otherwise byte-identical MSIs can still differ in exactly where this
+// table's rows for SetAnubisExePath and BootstrapConfig physically sit, in
+// addition to the summary information timestamps. TestMSIReproducibleBuild
+// tolerates both.
+func patchCustomActionSequence(msiPath string) error {
+ installFiles, err := readSequenceNumber(msiPath, "InstallFiles")
+ if err != nil {
+ return err
+ }
+
+ // Applied in this fixed order, not map iteration, so the msibuild
+ // invocations themselves are reproducible.
+ type pin struct {
+ action string
+ want int
+ }
+ pins := []pin{
+ {"SetAnubisExePath", installFiles + 1},
+ {"BootstrapConfig", installFiles + 2},
+ }
+
+ for _, p := range pins {
+ cmd := exec.Command("msibuild", msiPath, "-q",
+ fmt.Sprintf("UPDATE InstallExecuteSequence SET Sequence=%d WHERE Action='%s'", p.want, sqlEscape(p.action)))
+
+ var stderr bytes.Buffer
+ cmd.Stderr = &stderr
+ cmd.Stdout = os.Stdout
+
+ if err := cmd.Run(); err != nil {
+ return fmt.Errorf("mkmsi: msibuild failed to pin the sequence for %s: %w: %s", p.action, err, stderr.String())
+ }
+ }
+
+ for _, p := range pins {
+ got, err := readSequenceNumber(msiPath, p.action)
+ if err != nil {
+ return err
+ }
+ if got != p.want {
+ return fmt.Errorf("mkmsi: msibuild did not pin the sequence for %s on %s: got %d, want %d", p.action, msiPath, got, p.want)
+ }
+ }
+
+ return nil
+}
+
+// readSequenceNumber returns the Sequence column for one Action row of the
+// InstallExecuteSequence table, as reported by `msiinfo export`.
+func readSequenceNumber(msiPath, action string) (int, error) {
+ out, err := exec.Command("msiinfo", "export", msiPath, "InstallExecuteSequence").Output()
+ if err != nil {
+ return 0, fmt.Errorf("mkmsi: msiinfo export %s InstallExecuteSequence failed: %w", msiPath, err)
+ }
+
+ for _, line := range strings.Split(strings.ReplaceAll(string(out), "\r\n", "\n"), "\n") {
+ // Action Condition Sequence
+ fields := strings.Split(line, "\t")
+ if len(fields) == 3 && fields[0] == action {
+ seq, err := strconv.Atoi(fields[2])
+ if err != nil {
+ return 0, fmt.Errorf("mkmsi: InstallExecuteSequence row for %s in %s has a non-numeric Sequence %q: %w", action, msiPath, fields[2], err)
+ }
+ return seq, nil
+ }
+ }
+
+ return 0, fmt.Errorf("mkmsi: msiinfo export %s InstallExecuteSequence has no row for action %s", msiPath, action)
+}
diff --git a/internal/cmd/mkmsi/msi_test.go b/internal/cmd/mkmsi/msi_test.go
new file mode 100644
index 000000000..166680750
--- /dev/null
+++ b/internal/cmd/mkmsi/msi_test.go
@@ -0,0 +1,805 @@
+package main
+
+import (
+ "bytes"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "regexp"
+ "runtime"
+ "slices"
+ "sort"
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+)
+
+// mkmsiRequireVerifyEnv is the opt-in environment variable that turns a skip
+// into a failure. It must only be set by workflow jobs that actually install
+// msitools and build the zips these tests inspect -- currently
+// package-builds-stable.yml and package-builds-unstable.yml's "Verify MSI
+// contents" step. Gating on CI alone was tried and failed: GitHub Actions
+// sets CI=true on every job, including go.yml, which never installs msitools
+// or runs `go tool yeet`, so that made the main Go workflow fail on every PR.
+const mkmsiRequireVerifyEnv = "MKMSI_REQUIRE_VERIFY"
+
+// skipOrFail skips the test locally but fails it when mkmsiRequireVerifyEnv
+// is set.
+//
+// These tests are the only automated verification that the MSI's tables
+// survived wixl, which silently drops attributes it does not understand. A
+// skip and a pass are both exit 0, so a skip in a job that claims to verify
+// this would show a green check while checking nothing. Everywhere else --
+// including a contributor's laptop and the main Go workflow, neither of
+// which installs msitools -- skipping is right.
+func skipOrFail(t *testing.T, format string, args ...any) {
+ t.Helper()
+
+ if os.Getenv(mkmsiRequireVerifyEnv) == "true" {
+ t.Fatalf("refusing to skip with "+mkmsiRequireVerifyEnv+"=true: "+format, args...)
+ }
+
+ t.Skipf(format, args...)
+}
+
+// requireTool skips the test when a required external program is missing, so
+// contributors without msitools installed are not blocked.
+func requireTool(t *testing.T, name string) {
+ t.Helper()
+
+ if _, err := exec.LookPath(name); err != nil {
+ skipOrFail(t, "%s is not installed, skipping: %v", name, err)
+ }
+}
+
+// findZip returns the Windows zip yeet has built for arch, skipping the test
+// if there is none.
+//
+// It checks both var/ directly and one level of subdirectory: yeet's default
+// destination is var/, but the human partner routinely builds with something
+// like --package-dest-dir ./var/win.
+//
+// No zip at all is always a skip, including with mkmsiRequireVerifyEnv set.
+// There is nothing to verify without a build, and a checkout that never ran
+// `go tool yeet` must not be reported as a broken MSI.
+//
+// Several candidates for the same architecture are normal on a working
+// checkout: var/ accumulates the zips of every release the human partner has
+// built. The one that matters is always the newest, so this picks the
+// candidate with the latest modification time instead of making the
+// contributor delete their own build output to get a green run. Ties break on
+// path order, which only happens when two copies were written in the same
+// filesystem timestamp tick.
+func findZip(t *testing.T, arch string) string {
+ t.Helper()
+
+ pattern := "anubis-*-windows-" + arch + ".zip"
+
+ var matches []string
+ for _, glob := range []string{
+ filepath.Join("..", "..", "..", "var", pattern),
+ filepath.Join("..", "..", "..", "var", "*", pattern),
+ } {
+ found, err := filepath.Glob(glob)
+ if err != nil {
+ t.Fatalf("Glob: %v", err)
+ }
+ matches = append(matches, found...)
+ }
+ sort.Strings(matches)
+
+ switch len(matches) {
+ case 0:
+ t.Skipf("no Windows %s zip in var or var/*, run `go tool yeet` first", arch)
+ case 1:
+ return matches[0]
+ }
+
+ newest := matches[0]
+ newestMod, err := modTime(newest)
+ if err != nil {
+ t.Fatalf("stat %s: %v", newest, err)
+ }
+
+ for _, m := range matches[1:] {
+ mod, err := modTime(m)
+ if err != nil {
+ t.Fatalf("stat %s: %v", m, err)
+ }
+
+ if mod.After(newestMod) {
+ newest, newestMod = m, mod
+ }
+ }
+
+ t.Logf("found %d Windows %s zips under var, using the newest: %s (modified %s)", len(matches), arch, newest, newestMod.Format(time.RFC3339))
+
+ return newest
+}
+
+// modTime returns the modification time of a file.
+func modTime(path string) (time.Time, error) {
+ st, err := os.Stat(path)
+ if err != nil {
+ return time.Time{}, err
+ }
+
+ return st.ModTime(), nil
+}
+
+// buildTestMSI builds an MSI for arch from the newest zip and returns its
+// path.
+func buildTestMSI(t *testing.T, arch string) string {
+ t.Helper()
+
+ requireTool(t, "wixl")
+ requireTool(t, "wixl-heat")
+ requireTool(t, "msiinfo")
+ // Every arch shells out to msibuild now, to patch SecureCustomProperties
+ // after wixl builds the package (see secureInstallDir); arm64 additionally
+ // uses it to patch the summary Template.
+ requireTool(t, "msibuild")
+
+ out := filepath.Join(t.TempDir(), "anubis-"+arch+".msi")
+
+ cmd := exec.Command("go", "run", ".",
+ "--zip", findZip(t, arch),
+ "--out", out,
+ "--packaging-dir", filepath.Join("..", "..", "..", "run", "windows"),
+ "--staging-dir", filepath.Join("..", "..", "..", "var", "mkmsi"),
+ )
+ cmd.Stderr = os.Stderr
+
+ if err := cmd.Run(); err != nil {
+ t.Fatalf("mkmsi failed: %v", err)
+ }
+
+ return out
+}
+
+// exportTable returns one MSI table as text.
+//
+// msiinfo export writes CRLF line endings. Left alone, the trailing \r rides
+// along on whichever column is last in each row -- silently, since it prints
+// invisibly and only breaks exact-match comparisons against that column.
+// Normalizing here means every caller that splits on "\n" and "\t" gets clean
+// fields.
+func exportTable(t *testing.T, msi, table string) string {
+ t.Helper()
+
+ out, err := exec.Command("msiinfo", "export", msi, table).Output()
+ if err != nil {
+ t.Fatalf("msiinfo export %s: %v", table, err)
+ }
+
+ return strings.ReplaceAll(string(out), "\r\n", "\n")
+}
+
+// suminfo returns the value of one field from `msiinfo suminfo`, such as
+// "Template" or "Version", with the "Field:" prefix and surrounding
+// whitespace stripped.
+func suminfo(t *testing.T, msi, field string) string {
+ t.Helper()
+
+ out, err := exec.Command("msiinfo", "suminfo", msi).Output()
+ if err != nil {
+ t.Fatalf("msiinfo suminfo %s: %v", msi, err)
+ }
+
+ prefix := field + ":"
+ for _, line := range strings.Split(string(out), "\n") {
+ if rest, ok := strings.CutPrefix(line, prefix); ok {
+ return strings.TrimSpace(rest)
+ }
+ }
+
+ t.Fatalf("msiinfo suminfo %s has no %q line:\n%s", msi, field, out)
+ return ""
+}
+
+// suminfoVersion returns the numeric InstallerVersion from `msiinfo
+// suminfo`'s Version line, which msitools renders as "200 (c8)".
+func suminfoVersion(t *testing.T, msi string) string {
+ t.Helper()
+
+ v := suminfo(t, msi, "Version")
+ if i := strings.IndexByte(v, ' '); i >= 0 {
+ v = v[:i]
+ }
+
+ return v
+}
+
+// property returns one row's value from the MSI's Property table.
+func property(t *testing.T, msi, name string) string {
+ t.Helper()
+
+ for _, line := range strings.Split(exportTable(t, msi, "Property"), "\n") {
+ fields := strings.SplitN(line, "\t", 2)
+ if len(fields) == 2 && fields[0] == name {
+ return fields[1]
+ }
+ }
+
+ t.Fatalf("property %s not found in %s", name, msi)
+ return ""
+}
+
+// lastTableRow returns the last line of an exported table, which is its
+// final data row when the table has exactly one.
+func lastTableRow(t *testing.T, msi, table string) string {
+ t.Helper()
+
+ lines := strings.Split(strings.TrimRight(exportTable(t, msi, table), "\n"), "\n")
+ if len(lines) == 0 || lines[0] == "" {
+ t.Fatalf("table %s in %s is empty", table, msi)
+ }
+
+ return lines[len(lines)-1]
+}
+
+// componentConditions maps every Component in the MSI to its Condition
+// column, which is empty for components that always install.
+func componentConditions(t *testing.T, msi string) map[string]string {
+ t.Helper()
+
+ conditions := make(map[string]string)
+
+ for _, line := range strings.Split(exportTable(t, msi, "Component"), "\n") {
+ // Component ComponentId Directory_ Attributes Condition KeyPath
+ fields := strings.Split(line, "\t")
+ if len(fields) < 5 {
+ continue
+ }
+
+ // Attributes is a required integer column; the two header rows
+ // (column names, column types) do not parse as one, so this is a
+ // clean way to skip them without hardcoding the header text.
+ if _, err := strconv.Atoi(fields[3]); err != nil {
+ continue
+ }
+
+ conditions[fields[0]] = fields[4]
+ }
+
+ return conditions
+}
+
+func TestMSIContents(t *testing.T) {
+ if runtime.GOOS == "darwin" {
+ t.Skip("skipping on development machines")
+ }
+
+ msi := buildTestMSI(t, "amd64")
+
+ for _, tt := range []struct {
+ name string
+ table string
+ // want are substrings that must all appear in the exported table.
+ want []string
+ }{
+ {
+ name: "service is registered as manual start under its virtual account",
+ table: "ServiceInstall",
+ // Columns: ServiceInstall, Name, DisplayName, ServiceType(16 =
+ // own process), StartType(3 = manual), ErrorControl(1 = normal).
+ //
+ // StartName is the NT SERVICE virtual account the service control
+ // manager creates alongside the service. It has to be the fully
+ // qualified name: a bare "Anubis" or "LocalService" gets looked up
+ // as a local user account and the install fails.
+ want: []string{"svc_anubis\tAnubis\tAnubis\t16\t3\t1", `NT SERVICE\Anubis`},
+ },
+ {
+ name: "service is stopped and deleted on uninstall",
+ table: "ServiceControl",
+ // Event 162 = stop on install, stop and delete on uninstall.
+ want: []string{"Anubis\t162"},
+ },
+ {
+ name: "bin directory is added to the system PATH",
+ table: "Environment",
+ want: []string{"=*PATH", "[BINDIR]"},
+ },
+ {
+ name: "bootstrap custom action is present and deferred",
+ table: "CustomAction",
+ // 3122 = exe named by a property, deferred, no impersonation.
+ want: []string{"SetAnubisExePath\t51", "BootstrapConfig\t3122", "--windows-bootstrap-config"},
+ },
+ {
+ name: "major upgrades are configured",
+ table: "Upgrade",
+ want: []string{"{92F5FC1F-F6F2-4BD1-BBEF-79B8A46C665F}"},
+ },
+ {
+ name: "both binaries and both config templates are installed",
+ table: "File",
+ want: []string{
+ "anubis.exe",
+ "anubis-robots2policy.exe",
+ "fil_tmpl_env",
+ "fil_tmpl_yaml",
+ },
+ },
+ {
+ name: "the documentation tree is installed",
+ table: "File",
+ want: []string{"CHANGELOG.md", "installation.mdx"},
+ },
+ {
+ // Proof the WixUI_Minimal dialog set actually landed in the
+ // MSI rather than being silently dropped: wixl has crashed
+ // internally on hand-authored dialogs before now and emitted
+ // an MSI with no Dialog table at all.
+ name: "the WixUI_Minimal dialog set is present",
+ table: "Dialog",
+ want: []string{"WelcomeEulaDlg", "VerifyReadyDlg", "ProgressDlg", "ExitDialog"},
+ },
+ {
+ // secureInstallDir patches this row in after wixl builds the
+ // MSI (see its doc comment for why it cannot be done
+ // declaratively in anubis.wxs). Without it, a command-line
+ // INSTALLDIR= would not survive elevation into the deferred
+ // part of the install.
+ name: "INSTALLDIR is a secure custom property",
+ table: "Property",
+ want: []string{"SecureCustomProperties\tINSTALLDIR"},
+ },
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ got := exportTable(t, msi, tt.table)
+
+ for _, want := range tt.want {
+ if !strings.Contains(got, want) {
+ t.Errorf("table %s does not contain %q\ngot:\n%s", tt.table, want, got)
+ }
+ }
+ })
+ }
+}
+
+// TestMSIStartsServiceOnlyOnUpgrade verifies the fix for an upgrade silently
+// leaving Anubis stopped: RemoveExistingProducts sequences before
+// InstallServices, so the outgoing product's ServiceControl (Event=162)
+// stops and deletes the service, and the incoming ServiceInstall recreates it
+// with StartType=demand. Without an explicit start request an upgrade always
+// reports success while leaving the site unprotected.
+//
+// Every ServiceControl row that requests a start (Event bit 0 set) must
+// belong to a Component conditioned on WIX_UPGRADE_DETECTED -- the property
+// MajorUpgrade sets only when an earlier version of Anubis is already
+// installed -- so a fresh install, which has a placeholder target and no
+// signing key, never starts the service.
+func TestMSIStartsServiceOnlyOnUpgrade(t *testing.T) {
+ if runtime.GOOS == "darwin" {
+ t.Skip("skipping on development machines")
+ }
+
+ msi := buildTestMSI(t, "amd64")
+
+ conditions := componentConditions(t, msi)
+
+ var sawStartRow bool
+
+ for _, line := range strings.Split(exportTable(t, msi, "ServiceControl"), "\n") {
+ fields := strings.Split(line, "\t")
+ if len(fields) < 6 || fields[1] != "Anubis" {
+ continue
+ }
+
+ event, err := strconv.Atoi(fields[2])
+ if err != nil {
+ // Header rows carry column names and types, not numbers.
+ continue
+ }
+
+ if event&1 == 0 {
+ continue
+ }
+
+ sawStartRow = true
+
+ component := fields[5]
+ if cond := conditions[component]; cond != "WIX_UPGRADE_DETECTED" {
+ t.Errorf("ServiceControl row %q requests a start (event %d) via component %q, whose condition is %q, want %q",
+ fields[0], event, component, cond, "WIX_UPGRADE_DETECTED")
+ }
+ }
+
+ if !sawStartRow {
+ t.Error("no ServiceControl row for the anubis service requests a start; the upgrade fix appears to be missing")
+ }
+}
+
+func TestMSIArm64(t *testing.T) {
+ if runtime.GOOS == "darwin" {
+ t.Skip("skipping on development machines")
+ }
+
+ amd64 := buildTestMSI(t, "amd64")
+ arm64 := buildTestMSI(t, "arm64")
+
+ t.Run("summary Template names each architecture", func(t *testing.T) {
+ if got, want := suminfo(t, amd64, "Template"), "x64;1033"; got != want {
+ t.Errorf("amd64 Template = %q, want %q", got, want)
+ }
+ if got, want := suminfo(t, arm64, "Template"), "Arm64;1033"; got != want {
+ t.Errorf("arm64 Template = %q, want %q", got, want)
+ }
+ })
+
+ t.Run("InstallerVersion is 500 on arm64, 200 on amd64", func(t *testing.T) {
+ // Arm64 package templates require Windows Installer 5.0; declaring a
+ // lower InstallerVersion risks the MSI being rejected on the target
+ // as "not supported by this processor type".
+ if got, want := suminfoVersion(t, amd64), "200"; got != want {
+ t.Errorf("amd64 Version = %q, want %q", got, want)
+ }
+ if got, want := suminfoVersion(t, arm64), "500"; got != want {
+ t.Errorf("arm64 Version = %q, want %q", got, want)
+ }
+ })
+
+ t.Run("ProductCode differs between architectures", func(t *testing.T) {
+ amdCode := property(t, amd64, "ProductCode")
+ armCode := property(t, arm64, "ProductCode")
+
+ if amdCode == armCode {
+ t.Errorf("amd64 and arm64 MSIs share ProductCode %q; Windows would treat installing one as uninstalling the other", amdCode)
+ }
+ })
+
+ t.Run("ServiceInstall is identical across architectures", func(t *testing.T) {
+ amdSvc := lastTableRow(t, amd64, "ServiceInstall")
+ armSvc := lastTableRow(t, arm64, "ServiceInstall")
+
+ if amdSvc != armSvc {
+ t.Errorf("ServiceInstall row differs between architectures:\namd64: %s\narm64: %s", amdSvc, armSvc)
+ }
+ })
+}
+
+// extractStream returns the raw bytes of one binary stream from an MSI, as
+// reported by `msiinfo extract`.
+func extractStream(t *testing.T, msi, stream string) []byte {
+ t.Helper()
+
+ out, err := exec.Command("msiinfo", "extract", msi, stream).Output()
+ if err != nil {
+ t.Fatalf("msiinfo extract %s %s: %v", msi, stream, err)
+ }
+
+ return out
+}
+
+// TestMSIInstallerImages verifies patchInstallerImages actually replaced
+// wixl's stock WiX banner and dialog artwork with run/windows/banner.bmp and
+// run/windows/dialog.bmp, rather than the msibuild -a calls silently no-oping
+// the way Property/@Secure="yes" did earlier in this file's history. Byte
+// length is a cheap, robust stand-in for a full comparison: it is exactly
+// what changes if someone swaps in a real image and the patch stops running,
+// since the stock artwork's size is fixed and different from Anubis's.
+func TestMSIInstallerImages(t *testing.T) {
+ if runtime.GOOS == "darwin" {
+ t.Skip("skipping on development machines")
+ }
+
+ msi := buildTestMSI(t, "amd64")
+
+ for _, img := range installerImages {
+ t.Run(img.stream, func(t *testing.T) {
+ source := filepath.Join("..", "..", "..", "run", "windows", img.file)
+
+ wantInfo, err := os.Stat(source)
+ if err != nil {
+ t.Fatalf("stat %s: %v", source, err)
+ }
+
+ got := extractStream(t, msi, img.stream)
+
+ if int64(len(got)) != wantInfo.Size() {
+ t.Errorf("stream %s is %d bytes, %s is %d bytes -- patchInstallerImages may not have run",
+ img.stream, len(got), source, wantInfo.Size())
+ }
+ })
+ }
+}
+
+// rtfControlWord matches one RTF control word, with its optional numeric
+// parameter and the single trailing space that terminates it, such as
+// `\ansicpg1252 ` or `\par`.
+var rtfControlWord = regexp.MustCompile(`\\[a-zA-Z]+-?[0-9]*[ ]?`)
+
+// stripRTF reduces an RTF document to plain text: control words (\rtf1,
+// \par, \f0, ...) and group braces are removed, and the rest -- including
+// stray literal text from groups like the font table, which this does not
+// try to identify -- is collapsed to single-spaced words.
+//
+// This is deliberately crude rather than a real RTF parser. It only needs to
+// let TestMSILicenseRTFMatchesLicense find the license wording inside
+// License.rtf; a byte-exact comparison would make that test brittle for no
+// benefit.
+func stripRTF(s string) string {
+ s = rtfControlWord.ReplaceAllString(s, " ")
+ s = strings.NewReplacer("{", " ", "}", " ").Replace(s)
+ return strings.Join(strings.Fields(s), " ")
+}
+
+// TestMSILicenseRTFMatchesLicense fails if run/windows/License.rtf, which
+// WixUI_Minimal's EULA dialog displays, has fallen out of sync with the
+// repo's LICENSE. Someone editing LICENSE has no other reason to think about
+// the installer, so nothing else catches this.
+func TestMSILicenseRTFMatchesLicense(t *testing.T) {
+ license, err := os.ReadFile(filepath.Join("..", "..", "..", "LICENSE"))
+ if err != nil {
+ t.Fatalf("reading LICENSE: %v", err)
+ }
+
+ rtf, err := os.ReadFile(filepath.Join("..", "..", "..", "run", "windows", "License.rtf"))
+ if err != nil {
+ t.Fatalf("reading run/windows/License.rtf: %v", err)
+ }
+
+ if !bytes.HasPrefix(rtf, []byte(`{\rtf1`)) {
+ t.Fatalf("run/windows/License.rtf does not start with %q, is it valid RTF?", `{\rtf1`)
+ }
+
+ rtfText := stripRTF(string(rtf))
+
+ for _, para := range strings.Split(string(license), "\n\n") {
+ // LICENSE hard-wraps its paragraphs; License.rtf does not, so compare
+ // each paragraph as one line.
+ para = strings.Join(strings.Fields(para), " ")
+ if para == "" {
+ continue
+ }
+
+ if !strings.Contains(rtfText, para) {
+ t.Errorf("run/windows/License.rtf is missing text from LICENSE, or has fallen out of sync with it\nmissing paragraph: %q", para)
+ }
+ }
+}
+
+// msiTables returns the sorted list of table names in an MSI, as reported by
+// `msiinfo tables`.
+func msiTables(t *testing.T, msi string) []string {
+ t.Helper()
+
+ out, err := exec.Command("msiinfo", "tables", msi).Output()
+ if err != nil {
+ t.Fatalf("msiinfo tables %s: %v", msi, err)
+ }
+
+ var tables []string
+ for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
+ if line != "" {
+ tables = append(tables, line)
+ }
+ }
+ sort.Strings(tables)
+
+ return tables
+}
+
+// msiStreams returns the sorted list of stream names in an MSI, as reported
+// by `msiinfo streams`.
+func msiStreams(t *testing.T, msi string) []string {
+ t.Helper()
+
+ out, err := exec.Command("msiinfo", "streams", msi).Output()
+ if err != nil {
+ t.Fatalf("msiinfo streams %s: %v", msi, err)
+ }
+
+ var streams []string
+ for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
+ if line != "" {
+ streams = append(streams, line)
+ }
+ }
+ sort.Strings(streams)
+
+ return streams
+}
+
+// sortedLines splits s on newlines and sorts the result, so two exports of
+// the same table can be compared as sets when row order is not guaranteed
+// to match.
+func sortedLines(s string) []string {
+ if strings.TrimSpace(s) == "" {
+ return nil
+ }
+ lines := strings.Split(strings.TrimRight(s, "\n"), "\n")
+ sort.Strings(lines)
+ return lines
+}
+
+// diffByteCount returns how many bytes differ between a and b at the same
+// offset, plus any length mismatch, for a more informative log message than
+// a bare "not equal" when two builds turn out not to be byte-identical.
+func diffByteCount(a, b []byte) int {
+ n := 0
+ for i := 0; i < len(a) && i < len(b); i++ {
+ if a[i] != b[i] {
+ n++
+ }
+ }
+ if len(a) > len(b) {
+ n += len(a) - len(b)
+ } else {
+ n += len(b) - len(a)
+ }
+ return n
+}
+
+// requireSuminfoMatchesExceptTimestamps fails the test if two MSIs' summary
+// information differs anywhere other than the Created and Last saved
+// fields. wixl stamps both from the wall clock when it builds the package,
+// and msibuild's -s -- the only tool available to rewrite summary
+// information -- has no argument for either one; see
+// TestMSIReproducibleBuild's doc comment.
+func requireSuminfoMatchesExceptTimestamps(t *testing.T, msi1, msi2 string) {
+ t.Helper()
+
+ out1, err := exec.Command("msiinfo", "suminfo", msi1).Output()
+ if err != nil {
+ t.Fatalf("msiinfo suminfo %s: %v", msi1, err)
+ }
+ out2, err := exec.Command("msiinfo", "suminfo", msi2).Output()
+ if err != nil {
+ t.Fatalf("msiinfo suminfo %s: %v", msi2, err)
+ }
+
+ lines1 := strings.Split(strings.TrimRight(string(out1), "\n"), "\n")
+ lines2 := strings.Split(strings.TrimRight(string(out2), "\n"), "\n")
+
+ if len(lines1) != len(lines2) {
+ t.Fatalf("suminfo line count differs between %s (%d) and %s (%d):\n%s\nvs\n%s",
+ msi1, len(lines1), msi2, len(lines2), out1, out2)
+ }
+
+ for i := range lines1 {
+ if lines1[i] == lines2[i] {
+ continue
+ }
+ if strings.HasPrefix(lines1[i], "Created:") || strings.HasPrefix(lines1[i], "Last saved:") {
+ continue
+ }
+ t.Errorf("summary information differs beyond the known-unfixable Created/Last saved timestamps:\n%q\nvs\n%q", lines1[i], lines2[i])
+ }
+}
+
+// TestMSIReproducibleBuild builds the same zip into two separate MSIs and
+// asserts the results match, aside from two residuals that could not be
+// removed with the tools available (msitools' wixl, wixl-heat and
+// msibuild):
+//
+// - The summary information's Created and Last saved timestamps: wixl
+// stamps both from the wall clock when it builds the package, and
+// msibuild's -s -- the only available way to rewrite summary
+// information -- has no argument for either one.
+// - The InstallExecuteSequence table's on-disk row order for
+// SetAnubisExePath and BootstrapConfig. patchCustomActionSequence pins
+// their Sequence *values* deterministically, but not their physical
+// storage position: deleting a row and reinserting it under the same
+// Action was observed, empirically, to always land the row back in its
+// original slot, so nothing reachable through msibuild's SQL interface
+// can move it. Windows Installer executes actions in Sequence order
+// regardless of on-disk row order, so this has no effect on how the
+// installer behaves -- see patchCustomActionSequence's doc comment for
+// the full account.
+//
+// Everything else -- every other table, and every stream including the CAB
+// archive -- is required to be exactly byte-identical.
+func TestMSIReproducibleBuild(t *testing.T) {
+ if runtime.GOOS == "darwin" {
+ t.Skip("skipping on development machines")
+ }
+
+ requireTool(t, "wixl")
+ requireTool(t, "wixl-heat")
+ requireTool(t, "msiinfo")
+ requireTool(t, "msibuild")
+
+ zip := findZip(t, "amd64")
+
+ build := func(out string) {
+ t.Helper()
+
+ cmd := exec.Command("go", "run", ".",
+ "--zip", zip,
+ "--out", out,
+ "--packaging-dir", filepath.Join("..", "..", "..", "run", "windows"),
+ "--staging-dir", filepath.Join("..", "..", "..", "var", "mkmsi"),
+ )
+ cmd.Stderr = os.Stderr
+
+ if err := cmd.Run(); err != nil {
+ t.Fatalf("mkmsi failed building %s: %v", out, err)
+ }
+ }
+
+ out1 := filepath.Join(t.TempDir(), "build1.msi")
+ out2 := filepath.Join(t.TempDir(), "build2.msi")
+ build(out1)
+ build(out2)
+
+ data1, err := os.ReadFile(out1)
+ if err != nil {
+ t.Fatalf("reading %s: %v", out1, err)
+ }
+ data2, err := os.ReadFile(out2)
+ if err != nil {
+ t.Fatalf("reading %s: %v", out2, err)
+ }
+
+ if bytes.Equal(data1, data2) {
+ // Byte-identical: every check below would necessarily pass too.
+ return
+ }
+
+ t.Logf("%s and %s are not byte-identical (%d of %d bytes differ); checking whether the difference is limited to the known residuals",
+ out1, out2, diffByteCount(data1, data2), max(len(data1), len(data2)))
+
+ requireSuminfoMatchesExceptTimestamps(t, out1, out2)
+
+ tables1 := msiTables(t, out1)
+ tables2 := msiTables(t, out2)
+ if !slices.Equal(tables1, tables2) {
+ t.Fatalf("table lists differ:\nbuild1: %v\nbuild2: %v", tables1, tables2)
+ }
+
+ for _, table := range tables1 {
+ switch table {
+ case "_SummaryInformation":
+ // Already checked, field by field, via `msiinfo suminfo`
+ // above -- more precisely than a raw property-stream export
+ // could, since it can name exactly which field differs.
+ continue
+
+ case "InstallExecuteSequence":
+ // Row order in this one table is a known residual; compare it
+ // as a set of rows instead of as ordered text. If the values
+ // themselves differ, that is a real bug in
+ // patchCustomActionSequence, not a tolerated residual.
+ rows1 := sortedLines(exportTable(t, out1, table))
+ rows2 := sortedLines(exportTable(t, out2, table))
+ if !slices.Equal(rows1, rows2) {
+ t.Errorf("table %s differs even as a set of rows (this would mean the Sequence *values* differ, not just row order):\nbuild1: %v\nbuild2: %v",
+ table, rows1, rows2)
+ }
+ continue
+ }
+
+ if got, want := exportTable(t, out1, table), exportTable(t, out2, table); got != want {
+ t.Errorf("table %s differs:\n--- %s ---\n%s\n--- %s ---\n%s", table, out1, got, out2, want)
+ }
+ }
+
+ streams1 := msiStreams(t, out1)
+ streams2 := msiStreams(t, out2)
+ if !slices.Equal(streams1, streams2) {
+ t.Fatalf("stream lists differ:\nbuild1: %v\nbuild2: %v", streams1, streams2)
+ }
+
+ for _, stream := range streams1 {
+ // OLE marks a property-set stream by prefixing its name with 0x05;
+ // `msiinfo streams` prints that literally, so this is
+ // "\x05SummaryInformation", not "SummaryInformation".
+ if strings.HasSuffix(stream, "SummaryInformation") {
+ // The raw property-set stream backing both the
+ // _SummaryInformation table and `msiinfo suminfo` -- already
+ // checked field by field, with the Created/Last saved
+ // exception, by requireSuminfoMatchesExceptTimestamps above.
+ continue
+ }
+
+ a := extractStream(t, out1, stream)
+ b := extractStream(t, out2, stream)
+ if !bytes.Equal(a, b) {
+ t.Errorf("stream %s differs: %d bytes in build1, %d bytes in build2", stream, len(a), len(b))
+ }
+ }
+}
diff --git a/internal/cmd/mkmsi/version.go b/internal/cmd/mkmsi/version.go
new file mode 100644
index 000000000..0c03d0ecb
--- /dev/null
+++ b/internal/cmd/mkmsi/version.go
@@ -0,0 +1,151 @@
+package main
+
+import (
+ "errors"
+ "fmt"
+ "regexp"
+ "strconv"
+
+ "github.com/google/uuid"
+)
+
+var (
+ ErrBadVersion = errors.New("mkmsi: cannot parse version")
+ ErrVersionOutOfRange = errors.New("mkmsi: version component out of range")
+)
+
+// upgradeCode is the fixed MSI UpgradeCode for Anubis. Windows uses it to
+// relate one installed version to another. It must never change: changing it
+// makes every future installer fail to upgrade existing installs.
+const upgradeCode = "92f5fc1f-f6f2-4bd1-bbef-79b8a46c665f"
+
+// versionRe matches the versions yeet produces, such as "1.26.2" for a tagged
+// release, "1.27.0-pre1" for a tagged prerelease, and
+// "1.26.2-1-gec3cce8a-dev" for a build off a later commit.
+//
+// It is anchored at both ends on purpose. Without the trailing anchor a string
+// like "1.26.2.4" matches its prefix and silently becomes 1.26.2.0, turning a
+// version this code does not understand into a plausible wrong answer instead
+// of a build failure.
+var versionRe = regexp.MustCompile(`^v?(?P\d+)\.(?P\d+)\.(?P\d+)(?:-pre(?P\d+))?(?:-(?P\d+)-g[0-9a-fA-F]+)?(?:-dev)?$`)
+
+// versionFields maps the numeric fields of an MSI ProductVersion onto the
+// capture groups of versionRe, in the order they appear in the output, along
+// with the maximum value MSI allows in each.
+//
+// The prerelease number has no field of its own: MSI compares only
+// major.minor.build when deciding whether one package upgrades another, and
+// there is no way to express "1.27.0-pre1 sorts below 1.27.0" in three
+// unsigned numbers. It therefore reaches the installer only through the
+// ProductCode, which hashes the raw version string, so a prerelease and its
+// release are distinct products that share a ProductVersion. The wxs sets
+// AllowSameVersionUpgrades for exactly this reason, so installing 1.27.0 over
+// 1.27.0-pre1 still removes the prerelease instead of leaving both registered.
+var versionFields = []struct {
+ name string
+ group string
+ limit int
+}{
+ {name: "major", group: "major", limit: 255},
+ {name: "minor", group: "minor", limit: 255},
+ {name: "build", group: "patch", limit: 65535},
+ {name: "revision", group: "commits", limit: 65535},
+}
+
+// msiVersion converts a yeet version string into an MSI ProductVersion.
+//
+// MSI requires a numeric major.minor.build.revision, where major and minor are
+// at most 255 and build and revision are at most 65535. The commit count from a
+// dev build becomes the revision; a tagged release gets revision 0.
+//
+// Note that Windows ignores the revision field when deciding whether one
+// package upgrades another, so a dev build and its base release look identical
+// to the installer.
+func msiVersion(in string) (string, error) {
+ m := versionRe.FindStringSubmatch(in)
+ if m == nil {
+ return "", fmt.Errorf("%w: %q", ErrBadVersion, in)
+ }
+
+ parts := make([]int, len(versionFields))
+
+ for i, f := range versionFields {
+ raw := m[versionRe.SubexpIndex(f.group)]
+ if raw == "" {
+ continue
+ }
+
+ n, err := strconv.Atoi(raw)
+ if err != nil {
+ // A digit string too long for an int is semantically out of
+ // range, not malformed. Callers distinguish the two.
+ if errors.Is(err, strconv.ErrRange) {
+ return "", fmt.Errorf("%w: %s %q does not fit in an int", ErrVersionOutOfRange, f.name, raw)
+ }
+ return "", fmt.Errorf("%w: %s %q: %w", ErrBadVersion, f.name, raw, err)
+ }
+
+ if n > f.limit {
+ return "", fmt.Errorf("%w: %s is %d, maximum is %d", ErrVersionOutOfRange, f.name, n, f.limit)
+ }
+
+ parts[i] = n
+ }
+
+ return fmt.Sprintf("%d.%d.%d.%d", parts[0], parts[1], parts[2], parts[3]), nil
+}
+
+// productCode derives the MSI ProductCode for a version and architecture.
+// Every distinct version needs a distinct ProductCode, and rebuilding the
+// same commit must produce the same one, so it is a UUIDv5 of the version and
+// architecture in the UpgradeCode's namespace rather than a random UUID.
+//
+// The architecture is part of the hash input because it, like the version,
+// must vary the ProductCode: two packages built for different architectures
+// still install to the same UpgradeCode family, so if they shared a
+// ProductCode too, Windows would treat them as the same product and
+// installing one would silently uninstall the other.
+func productCode(version, arch string) (string, error) {
+ if _, err := msiVersion(version); err != nil {
+ return "", err
+ }
+
+ ns, err := uuid.Parse(upgradeCode)
+ if err != nil {
+ return "", fmt.Errorf("mkmsi: cannot parse upgrade code %q: %w", upgradeCode, err)
+ }
+
+ return uuid.NewSHA1(ns, []byte(version+"/"+arch)).String(), nil
+}
+
+// packageCode derives the MSI PackageCode -- the summary information's
+// "Revision number (UUID)" field -- for a version and architecture, the
+// same way productCode derives the Product Id: a UUIDv5 in the
+// UpgradeCode's namespace, so rebuilding the same commit reproduces the same
+// value instead of getting whatever wixl randomizes it to.
+//
+// This is a deliberate departure from MSI convention, which calls for a
+// unique PackageCode per built package file -- PackageCode exists to
+// distinguish physical .msi files even when they install the same product,
+// and normally two builds of identical bits still deserve two different
+// PackageCodes because nothing guarantees the bits stayed identical. Making
+// it deterministic is correct here specifically because the rest of this
+// build has been made reproducible: once identical inputs are guaranteed to
+// produce byte-identical output, a PackageCode that still changed on every
+// rebuild would be actively misleading, not conservative.
+//
+// The hash input is distinguished from productCode's ("packagecode/" vs no
+// prefix) so the two never collide: Windows must never see ProductCode and
+// PackageCode take on the same GUID.
+func packageCode(version, arch string) (string, error) {
+ if _, err := msiVersion(version); err != nil {
+ return "", err
+ }
+
+ ns, err := uuid.Parse(upgradeCode)
+ if err != nil {
+ return "", fmt.Errorf("mkmsi: cannot parse upgrade code %q: %w", upgradeCode, err)
+ }
+
+ return uuid.NewSHA1(ns, []byte("packagecode/"+version+"/"+arch)).String(), nil
+}
diff --git a/internal/cmd/mkmsi/version_test.go b/internal/cmd/mkmsi/version_test.go
new file mode 100644
index 000000000..716b8d72c
--- /dev/null
+++ b/internal/cmd/mkmsi/version_test.go
@@ -0,0 +1,138 @@
+package main
+
+import (
+ "errors"
+ "testing"
+)
+
+func TestMSIVersion(t *testing.T) {
+ for _, tt := range []struct {
+ name string
+ in string
+ want string
+ wantErr error
+ }{
+ {name: "plain release", in: "1.26.2", want: "1.26.2.0"},
+ {name: "v prefix", in: "v1.26.2", want: "1.26.2.0"},
+ {name: "dev build", in: "1.26.2-1-gec3cce8a-dev", want: "1.26.2.1"},
+ {name: "dev build many commits", in: "1.26.2-137-gdeadbee", want: "1.26.2.137"},
+ // Prereleases share a ProductVersion with their release; only the
+ // ProductCode tells them apart. See versionFields.
+ {name: "prerelease", in: "1.27.0-pre1", want: "1.27.0.0"},
+ {name: "prerelease with v prefix", in: "v1.27.0-pre2", want: "1.27.0.0"},
+ {name: "dev build off a prerelease", in: "1.27.0-pre1-4-gec3cce8a-dev", want: "1.27.0.4"},
+ {name: "prerelease number is not a version component", in: "1.27.0-pre256", want: "1.27.0.0"},
+ {name: "major too big", in: "256.0.0", wantErr: ErrVersionOutOfRange},
+ {name: "minor too big", in: "1.256.0", wantErr: ErrVersionOutOfRange},
+ {name: "patch too big", in: "1.0.65536", wantErr: ErrVersionOutOfRange},
+ {name: "revision too big", in: "1.0.0-65536-gdeadbee", wantErr: ErrVersionOutOfRange},
+ {name: "empty", in: "", wantErr: ErrBadVersion},
+ {name: "not a version", in: "devel", wantErr: ErrBadVersion},
+ {name: "two components", in: "1.26", wantErr: ErrBadVersion},
+ {name: "four components", in: "1.26.2.4", wantErr: ErrBadVersion},
+ {name: "trailing garbage", in: "1.26.2extra", wantErr: ErrBadVersion},
+ {name: "garbage after commit hash", in: "1.26.2-1-gdead-devXXX", wantErr: ErrBadVersion},
+ {name: "prerelease without a number", in: "1.27.0-pre", wantErr: ErrBadVersion},
+ {name: "unknown prerelease flavour", in: "1.27.0-rc1", wantErr: ErrBadVersion},
+ {name: "prerelease after commit count", in: "1.27.0-4-gdeadbee-pre1", wantErr: ErrBadVersion},
+ {name: "overflows an int", in: "99999999999999999999.0.0", wantErr: ErrVersionOutOfRange},
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := msiVersion(tt.in)
+
+ if tt.wantErr != nil {
+ if !errors.Is(err, tt.wantErr) {
+ t.Fatalf("msiVersion(%q): got error %v, want %v", tt.in, err, tt.wantErr)
+ }
+ return
+ }
+
+ if err != nil {
+ t.Fatalf("msiVersion(%q): unexpected error: %v", tt.in, err)
+ }
+
+ if got != tt.want {
+ t.Errorf("msiVersion(%q) = %q, want %q", tt.in, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestProductCodeIsDeterministic(t *testing.T) {
+ const version = "1.26.2-1-gec3cce8a-dev"
+
+ first, err := productCode(version, "amd64")
+ if err != nil {
+ t.Fatalf("productCode(%q): %v", version, err)
+ }
+
+ second, err := productCode(version, "amd64")
+ if err != nil {
+ t.Fatalf("productCode(%q): %v", version, err)
+ }
+
+ if first != second {
+ t.Errorf("productCode is not deterministic: %q != %q", first, second)
+ }
+}
+
+func TestProductCodeVariesByVersion(t *testing.T) {
+ a, err := productCode("1.26.2", "amd64")
+ if err != nil {
+ t.Fatalf("productCode: %v", err)
+ }
+
+ b, err := productCode("1.26.3", "amd64")
+ if err != nil {
+ t.Fatalf("productCode: %v", err)
+ }
+
+ if a == b {
+ t.Errorf("different versions produced the same ProductCode %q", a)
+ }
+}
+
+func TestProductCodeVariesByPrerelease(t *testing.T) {
+ pre, err := productCode("1.27.0-pre1", "amd64")
+ if err != nil {
+ t.Fatalf("productCode: %v", err)
+ }
+
+ release, err := productCode("1.27.0", "amd64")
+ if err != nil {
+ t.Fatalf("productCode: %v", err)
+ }
+
+ // A prerelease and its release compare equal as MSI ProductVersions, so
+ // the ProductCode is the only thing that keeps them from looking like the
+ // same installed package to Windows.
+ if pre == release {
+ t.Errorf("1.27.0-pre1 and 1.27.0 produced the same ProductCode %q", pre)
+ }
+}
+
+func TestProductCodeVariesByArch(t *testing.T) {
+ const version = "1.26.2"
+
+ amd64, err := productCode(version, "amd64")
+ if err != nil {
+ t.Fatalf("productCode: %v", err)
+ }
+
+ arm64, err := productCode(version, "arm64")
+ if err != nil {
+ t.Fatalf("productCode: %v", err)
+ }
+
+ // Sharing a ProductCode across architectures makes Windows treat the two
+ // packages as one product, so installing either one uninstalls the other.
+ if amd64 == arm64 {
+ t.Errorf("amd64 and arm64 produced the same ProductCode %q", amd64)
+ }
+}
+
+func TestProductCodeRejectsBadVersion(t *testing.T) {
+ if _, err := productCode("devel", "amd64"); !errors.Is(err, ErrBadVersion) {
+ t.Errorf("productCode(\"devel\"): got %v, want %v", err, ErrBadVersion)
+ }
+}
diff --git a/internal/dns/cache.go b/internal/dns/cache.go
new file mode 100644
index 000000000..7d05e3c6f
--- /dev/null
+++ b/internal/dns/cache.go
@@ -0,0 +1,70 @@
+package dns
+
+import (
+ "log/slog"
+ "time"
+
+ "github.com/TecharoHQ/anubis/lib/store"
+
+ _ "github.com/TecharoHQ/anubis/lib/store/all"
+)
+
+type DnsCache struct {
+ forward store.JSON[[]string]
+ reverse store.JSON[[]string]
+ forwardTTL time.Duration
+ reverseTTL time.Duration
+}
+
+func NewDNSCache(forwardTTL int, reverseTTL int, backend store.Interface) *DnsCache {
+ return &DnsCache{
+ forward: store.JSON[[]string]{
+ Underlying: backend,
+ Prefix: "forwardDNS",
+ },
+ reverse: store.JSON[[]string]{
+ Underlying: backend,
+ Prefix: "reverseDNS",
+ },
+ forwardTTL: time.Duration(forwardTTL) * time.Second,
+ reverseTTL: time.Duration(reverseTTL) * time.Second,
+ }
+}
+
+func (d *Dns) getCachedForward(host string) ([]string, bool) {
+ if d.cache == nil {
+ return nil, false
+ }
+ if cached, err := d.cache.forward.Get(d.ctx, host); err == nil {
+ slog.Debug("DNS: forward cache hit", "name", host, "ips", cached)
+ return cached, true
+ }
+ slog.Debug("DNS: forward cache miss", "name", host)
+ return nil, false
+}
+
+func (d *Dns) getCachedReverse(addr string) ([]string, bool) {
+ if d.cache == nil {
+ return nil, false
+ }
+ if cached, err := d.cache.reverse.Get(d.ctx, addr); err == nil {
+ slog.Debug("DNS: reverse cache hit", "addr", addr, "names", cached)
+ return cached, true
+ }
+ slog.Debug("DNS: reverse cache miss", "addr", addr)
+ return nil, false
+}
+
+func (d *Dns) forwardCachePut(host string, entries []string) {
+ if d.cache == nil {
+ return
+ }
+ _ = d.cache.forward.Set(d.ctx, host, entries, d.cache.forwardTTL)
+}
+
+func (d *Dns) reverseCachePut(addr string, entries []string) {
+ if d.cache == nil {
+ return
+ }
+ _ = d.cache.reverse.Set(d.ctx, addr, entries, d.cache.reverseTTL)
+}
diff --git a/internal/dns/dns.go b/internal/dns/dns.go
new file mode 100644
index 000000000..ca1950cc0
--- /dev/null
+++ b/internal/dns/dns.go
@@ -0,0 +1,174 @@
+package dns
+
+import (
+ "context"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "log/slog"
+ "net"
+ "regexp"
+ "slices"
+ "strings"
+)
+
+var (
+ DNSLookupAddr = net.LookupAddr
+ DNSLookupHost = net.LookupHost
+)
+
+type Dns struct {
+ cache *DnsCache
+ ctx context.Context
+}
+
+func New(ctx context.Context, cache *DnsCache) *Dns {
+ return &Dns{
+ cache: cache,
+ ctx: ctx,
+ }
+}
+
+// ReverseDNS performs a reverse DNS lookup for the given IP address and trims the trailing dot from the results.
+func (d *Dns) ReverseDNS(addr string) ([]string, error) {
+ slog.Debug("DNS: performing reverse lookup", "addr", addr)
+
+ if cached, ok := d.getCachedReverse(addr); ok {
+ return cached, nil
+ }
+
+ names, err := DNSLookupAddr(addr)
+ if err != nil {
+ if dnsErr, ok := err.(*net.DNSError); ok && dnsErr.IsNotFound {
+ slog.Debug("DNS: no PTR record found", "addr", addr)
+ return []string{}, nil
+ }
+ slog.Error("DNS: reverse lookup failed", "addr", addr, "err", err)
+ return nil, err
+ }
+
+ slog.Debug("DNS: reverse lookup successful", "addr", addr, "names", names)
+
+ trimmedNames := make([]string, len(names))
+ for i, name := range names {
+ trimmedNames[i] = strings.TrimSuffix(name, ".")
+ }
+ d.reverseCachePut(addr, trimmedNames)
+
+ return trimmedNames, nil
+}
+
+// LookupHost performs a forward DNS lookup for the given hostname.
+func (d *Dns) LookupHost(host string) ([]string, error) {
+ slog.Debug("DNS: performing forward lookup", "host", host)
+
+ if cached, ok := d.getCachedForward(host); ok {
+ return cached, nil
+ }
+
+ addrs, err := DNSLookupHost(host)
+ if err != nil {
+ if dnsErr, ok := err.(*net.DNSError); ok && dnsErr.IsNotFound {
+ slog.Debug("DNS: no A/AAAA record found", "host", host)
+ return []string{}, nil
+ }
+ slog.Error("DNS: forward lookup failed", "host", host, "err", err)
+ return nil, err
+ }
+
+ slog.Debug("DNS: forward lookup successful", "host", host, "addrs", addrs)
+ d.forwardCachePut(host, addrs)
+ return addrs, nil
+}
+
+// verifyFCrDNSInternal performs the second half of the FCrDNS check, using a
+// pre-fetched list of names to perform the forward lookups.
+func (d *Dns) verifyFCrDNSInternal(addr string, names []string) bool {
+ for _, name := range names {
+ if cached, err := d.LookupHost(name); err == nil {
+ if slices.Contains(cached, addr) {
+ slog.Info("DNS: forward lookup confirmed original IP", "name", name, "addr", addr)
+ return true
+ }
+ continue
+ }
+ }
+
+ slog.Info("DNS: could not confirm original IP in forward lookups", "addr", addr)
+ return false
+}
+
+// VerifyFCrDNS performs a forward-confirmed reverse DNS (FCrDNS) lookup for the given IP address,
+// optionally matching against a provided pattern.
+func (d *Dns) VerifyFCrDNS(addr string, pattern *string) bool {
+ var patternVal string
+ if pattern != nil {
+ patternVal = *pattern
+ }
+ slog.Debug("DNS: performing FCrDNS lookup", "addr", addr, "pattern", patternVal)
+
+ names, err := d.ReverseDNS(addr)
+ if err != nil {
+ return false
+ }
+ if len(names) == 0 {
+ return pattern == nil // If no pattern specified, check is passed
+ }
+
+ // If a pattern is provided, check for a match.
+ if pattern != nil {
+ anyNameMatched := false
+ for _, name := range names {
+ matched, err := regexp.MatchString(*pattern, name)
+ if err != nil {
+ slog.Error("DNS: verifyFCrDNS invalid regex pattern", "err", err)
+ return false // Invalid pattern is a failure.
+ }
+ if matched {
+ anyNameMatched = true
+ break
+ }
+ }
+
+ if !anyNameMatched {
+ slog.Debug("DNS: FCrDNS no PTR matches the pattern", "addr", addr, "pattern", *pattern)
+ return false
+ }
+ slog.Debug("DNS: FCrDNS PTR matched pattern, proceeding with forward check", "addr", addr, "pattern", *pattern)
+ }
+
+ // If we're here, either there was no pattern, or the pattern matched.
+ // Proceed with the forward lookup confirmation.
+ return d.verifyFCrDNSInternal(addr, names)
+}
+
+// ArpaReverseIP performs translation from ip v4/v6 to arpa reverse notation
+func (d *Dns) ArpaReverseIP(addr string) (string, error) {
+ ip := net.ParseIP(addr)
+ if ip == nil {
+ return addr, errors.New("invalid IP address")
+ }
+
+ if ipv4 := ip.To4(); ipv4 != nil {
+ return fmt.Sprintf("%d.%d.%d.%d", ipv4[3], ipv4[2], ipv4[1], ipv4[0]), nil
+ }
+
+ ipv6 := ip.To16()
+ if ipv6 == nil {
+ return addr, errors.New("invalid IPv6 address")
+ }
+
+ hexBytes := make([]byte, hex.EncodedLen(len(ipv6)))
+ hex.Encode(hexBytes, ipv6)
+
+ var sb strings.Builder
+ sb.Grow(len(hexBytes)*2 - 1)
+
+ for i := len(hexBytes) - 1; i >= 0; i-- {
+ sb.WriteByte(hexBytes[i])
+ if i > 0 {
+ sb.WriteByte('.')
+ }
+ }
+ return sb.String(), nil
+}
diff --git a/internal/dns/dns_test.go b/internal/dns/dns_test.go
new file mode 100644
index 000000000..9cbcb0a51
--- /dev/null
+++ b/internal/dns/dns_test.go
@@ -0,0 +1,308 @@
+package dns
+
+import (
+ "context"
+ "errors"
+ "net"
+ "reflect"
+ "testing"
+ "time"
+
+ "github.com/TecharoHQ/anubis/lib/store/memory"
+)
+
+// newTestDNS is a helper function to create a new Dns object with an in-memory cache for testing.
+func newTestDNS(forwardTTL int, reverseTTL int) *Dns {
+ ctx := context.Background()
+ memStore := memory.New(ctx)
+ cache := NewDNSCache(forwardTTL, reverseTTL, memStore)
+ return New(ctx, cache)
+}
+
+// mockLookupAddr is a mock implementation of the net.LookupAddr function.
+func mockLookupAddr(addr string) ([]string, error) {
+ switch addr {
+ case "8.8.8.8":
+ return []string{"dns.google."}, nil
+ case "1.1.1.1":
+ return []string{"one.one.one.one."}, nil
+ case "208.67.222.222":
+ return []string{"resolver1.opendns.com."}, nil
+ case "9.9.9.9":
+ return nil, &net.DNSError{Err: "no such host", Name: "9.9.9.9", IsNotFound: true}
+ case "1.2.3.4":
+ return nil, errors.New("unknown error")
+ default:
+ return nil, &net.DNSError{Err: "no such host", Name: addr, IsNotFound: true}
+ }
+}
+
+// mockLookupHost is a mock implementation of the net.LookupHost function.
+func mockLookupHost(host string) ([]string, error) {
+ switch host {
+ case "dns.google":
+ return []string{"8.8.8.8", "8.8.4.4"}, nil
+ case "one.one.one.one":
+ return []string{"1.1.1.1", "1.0.0.1"}, nil
+ case "resolver1.opendns.com":
+ return []string{"208.67.222.222"}, nil
+ case "example.com":
+ return nil, &net.DNSError{Err: "no such host", Name: "example.com", IsNotFound: true}
+ default:
+ return nil, &net.DNSError{Err: "no such host", Name: host, IsNotFound: true}
+ }
+}
+
+func TestMain(m *testing.M) {
+ // Before all tests
+ originalLookupAddr := DNSLookupAddr
+ originalLookupHost := DNSLookupHost
+
+ DNSLookupAddr = mockLookupAddr
+ DNSLookupHost = mockLookupHost
+
+ // Run tests
+ exitCode := m.Run()
+
+ // After all tests
+ DNSLookupAddr = originalLookupAddr
+ DNSLookupHost = originalLookupHost
+
+ // Exit
+ if exitCode != 0 {
+ panic(exitCode)
+ }
+}
+
+func TestDns_ArpaReverseIP(t *testing.T) {
+ d := newTestDNS(0, 0)
+ tests := []struct {
+ name string
+ ip string
+ want string
+ wantErr bool
+ }{
+ {"ipv4", "192.0.2.1", "1.2.0.192", false},
+ {"ipv6", "2001:db8::1", "1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2", false},
+ {"invalid ip", "invalid", "invalid", true},
+ {"ipv4-mapped ipv6", "::ffff:192.0.2.1", "1.2.0.192", false},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := d.ArpaReverseIP(tt.ip)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("ArpaReverseIP() error = %v, wantErr %v", err, tt.wantErr)
+ return
+ }
+ if got != tt.want {
+ t.Errorf("ArpaReverseIP() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestDns_ReverseDNS(t *testing.T) {
+ d := newTestDNS(1, 1) // short TTL for testing cache
+
+ // First call - cache miss
+ t.Run("cache miss", func(t *testing.T) {
+ got, err := d.ReverseDNS("8.8.8.8")
+ if err != nil {
+ t.Fatalf("ReverseDNS() error = %v", err)
+ }
+ want := []string{"dns.google"}
+ if !reflect.DeepEqual(got, want) {
+ t.Errorf("ReverseDNS() = %v, want %v", got, want)
+ }
+ })
+
+ // Second call - cache hit
+ t.Run("cache hit", func(t *testing.T) {
+ // Temporarily replace lookup function to ensure cache is used
+ originalLookupAddr := DNSLookupAddr
+ DNSLookupAddr = func(addr string) ([]string, error) {
+ return nil, errors.New("should not be called")
+ }
+ defer func() { DNSLookupAddr = originalLookupAddr }()
+
+ got, err := d.ReverseDNS("8.8.8.8")
+ if err != nil {
+ t.Fatalf("ReverseDNS() error = %v", err)
+ }
+ want := []string{"dns.google"}
+ if !reflect.DeepEqual(got, want) {
+ t.Errorf("ReverseDNS() = %v, want %v", got, want)
+ }
+ })
+
+ // Test cache expiration
+ t.Run("cache expiration", func(t *testing.T) {
+ time.Sleep(2 * time.Second)
+ // Now the cache should be expired
+ // We expect the mock to be called again
+ // To test this we will change the mock to return something different
+ originalLookupAddr := DNSLookupAddr
+ DNSLookupAddr = func(addr string) ([]string, error) {
+ if addr == "8.8.8.8" {
+ return []string{"expired.google."}, nil
+ }
+ return mockLookupAddr(addr)
+ }
+ defer func() { DNSLookupAddr = originalLookupAddr }()
+
+ got, err := d.ReverseDNS("8.8.8.8")
+ if err != nil {
+ t.Fatalf("ReverseDNS() error = %v", err)
+ }
+ want := []string{"expired.google"}
+ if !reflect.DeepEqual(got, want) {
+ t.Errorf("ReverseDNS() = %v, want %v", got, want)
+ }
+ })
+
+ // Test not found
+ t.Run("not found", func(t *testing.T) {
+ got, err := d.ReverseDNS("9.9.9.9")
+ if err != nil {
+ t.Fatalf("ReverseDNS() error = %v", err)
+ }
+ if len(got) != 0 {
+ t.Errorf("ReverseDNS() = %v, want empty slice", got)
+ }
+ })
+}
+
+func TestDns_LookupHost(t *testing.T) {
+ d := newTestDNS(1, 1)
+
+ t.Run("cache miss", func(t *testing.T) {
+ got, err := d.LookupHost("dns.google")
+ if err != nil {
+ t.Fatalf("LookupHost() error = %v", err)
+ }
+ want := []string{"8.8.8.8", "8.8.4.4"}
+ if !reflect.DeepEqual(got, want) {
+ t.Errorf("LookupHost() = %v, want %v", got, want)
+ }
+ })
+
+ t.Run("cache hit", func(t *testing.T) {
+ originalLookupHost := DNSLookupHost
+ DNSLookupHost = func(host string) ([]string, error) {
+ return nil, errors.New("should not be called")
+ }
+ defer func() { DNSLookupHost = originalLookupHost }()
+
+ got, err := d.LookupHost("dns.google")
+ if err != nil {
+ t.Fatalf("LookupHost() error = %v", err)
+ }
+ want := []string{"8.8.8.8", "8.8.4.4"}
+ if !reflect.DeepEqual(got, want) {
+ t.Errorf("LookupHost() = %v, want %v", got, want)
+ }
+ })
+
+ t.Run("cache expiration", func(t *testing.T) {
+ time.Sleep(2 * time.Second)
+ originalLookupHost := DNSLookupHost
+ DNSLookupHost = func(host string) ([]string, error) {
+ if host == "dns.google" {
+ return []string{"9.9.9.9"}, nil
+ }
+ return mockLookupHost(host)
+ }
+ defer func() { DNSLookupHost = originalLookupHost }()
+
+ got, err := d.LookupHost("dns.google")
+ if err != nil {
+ t.Fatalf("LookupHost() error = %v", err)
+ }
+ want := []string{"9.9.9.9"}
+ if !reflect.DeepEqual(got, want) {
+ t.Errorf("LookupHost() = %v, want %v", got, want)
+ }
+ })
+
+ t.Run("not found", func(t *testing.T) {
+ got, err := d.LookupHost("example.com")
+ if err != nil {
+ t.Fatalf("LookupHost() error = %v", err)
+ }
+ if len(got) != 0 {
+ t.Errorf("LookupHost() = %v, want empty slice", got)
+ }
+ })
+}
+
+func TestDns_VerifyFCrDNS(t *testing.T) {
+ d := newTestDNS(1, 1)
+
+ // Helper to convert string to *string
+ p := func(s string) *string {
+ return &s
+ }
+
+ tests := []struct {
+ name string
+ ip string
+ pattern *string
+ want bool
+ }{
+ // Cases without pattern
+ {"valid no pattern", "8.8.8.8", nil, true},
+ {"valid partial no pattern", "1.1.1.1", nil, true},
+ {"not found no pattern", "9.9.9.9", nil, true},
+ {"unknown error no pattern", "1.2.3.4", nil, false},
+
+ // Cases with pattern
+ {"valid match", "8.8.8.8", p(`.*\.google$`), true},
+ {"valid no match", "8.8.8.8", p(`\.com$`), false},
+ {"not found with pattern", "9.9.9.9", p(".*"), false},
+ {"unknown error with pattern", "1.2.3.4", p(".*"), false},
+ {"invalid pattern", "8.8.8.8", p(`[`), false},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := d.VerifyFCrDNS(tt.ip, tt.pattern); got != tt.want {
+ t.Errorf("VerifyFCrDNS() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+
+ t.Run("reverse cache hit", func(t *testing.T) {
+ // Prime the cache
+ if got := d.VerifyFCrDNS("8.8.8.8", nil); got != true {
+ t.Fatalf("VerifyFCrDNS() priming failed, got %v, want true", got)
+ }
+
+ // Now test with a failing lookup to ensure cache is used
+ originalLookupAddr := DNSLookupAddr
+ DNSLookupAddr = func(addr string) ([]string, error) {
+ return nil, errors.New("should not be called")
+ }
+ defer func() { DNSLookupAddr = originalLookupAddr }()
+
+ if got := d.VerifyFCrDNS("8.8.8.8", nil); got != true {
+ t.Errorf("VerifyFCrDNS() = %v, want true", got)
+ }
+ })
+
+ t.Run("forward cache hit", func(t *testing.T) {
+ // Prime the cache
+ if got := d.VerifyFCrDNS("8.8.8.8", nil); got != true {
+ t.Fatalf("VerifyFCrDNS() priming failed, got %v, want true", got)
+ }
+
+ // Now test with a failing lookup to ensure cache is used
+ originalLookupHost := DNSLookupHost
+ DNSLookupHost = func(host string) ([]string, error) {
+ return nil, errors.New("should not be called")
+ }
+ defer func() { DNSLookupHost = originalLookupHost }()
+
+ if got := d.VerifyFCrDNS("8.8.8.8", nil); got != true {
+ t.Errorf("VerifyFCrDNS() = %v, want true", got)
+ }
+ })
+}
diff --git a/internal/dnsbl/dnsbl.go b/internal/dnsbl/dnsbl.go
index 60edd5c12..9ddbbb82f 100644
--- a/internal/dnsbl/dnsbl.go
+++ b/internal/dnsbl/dnsbl.go
@@ -59,7 +59,7 @@ func reverse6(ip net.IP) string {
lowNibble := ipBytes[i] & 0x0F
// Append the nibbles in reversed order
- sb.WriteString(fmt.Sprintf("%x.%x.", lowNibble, highNibble))
+ fmt.Fprintf(&sb, "%x.%x.", lowNibble, highNibble)
}
return sb.String()[:len(sb.String())-1]
diff --git a/internal/glob/glob.go b/internal/glob/glob.go
index 44c1a67ac..e2e640de5 100644
--- a/internal/glob/glob.go
+++ b/internal/glob/glob.go
@@ -36,7 +36,7 @@ func Glob(pattern, subj string) bool {
end := len(parts) - 1
// Go over the leading parts and ensure they match.
- for i := 0; i < end; i++ {
+ for i := range end {
idx := strings.Index(subj, parts[i])
switch i {
diff --git a/internal/gzip.go b/internal/gzip.go
index c83a0edf0..06446e6a5 100644
--- a/internal/gzip.go
+++ b/internal/gzip.go
@@ -2,11 +2,48 @@ package internal
import (
"compress/gzip"
+ "fmt"
+ "io"
"net/http"
"strings"
+ "sync"
)
+// gzipWriterPools holds one *sync.Pool of *gzip.Writer per compression level.
+//
+// The pools are process-global on purpose. GzipMiddleware is constructed
+// per-request at its RenderIndex call site (the wrapped handler embeds the
+// request-specific challenge page), so a closure-local pool would be created
+// empty, used once, and garbage collected on every challenge render; never
+// actually reusing a writer. Keeping the pools here lets the ~1.18 MiB deflate
+// buffers inside each *gzip.Writer survive across requests regardless of how
+// often the middleware is rebuilt.
+var gzipWriterPools sync.Map // map[int]*sync.Pool
+
+func gzipWriterPool(level int) *sync.Pool {
+ if p, ok := gzipWriterPools.Load(level); ok {
+ return p.(*sync.Pool)
+ }
+ p := &sync.Pool{
+ New: func() any {
+ gz, _ := gzip.NewWriterLevel(io.Discard, level)
+ return gz
+ },
+ }
+ actual, _ := gzipWriterPools.LoadOrStore(level, p)
+ return actual.(*sync.Pool)
+}
+
func GzipMiddleware(level int, next http.Handler) http.Handler {
+ // Validate the level with the same range check gzip.NewWriterLevel uses,
+ // but without allocating a throwaway ~1.18 MiB writer on every (per-request)
+ // middleware construction. gzip only rejects out-of-range levels.
+ if level < gzip.HuffmanOnly || level > gzip.BestCompression {
+ panic(fmt.Sprintf("gzip: invalid compression level: %d", level))
+ }
+
+ pool := gzipWriterPool(level)
+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
next.ServeHTTP(w, r)
@@ -14,11 +51,13 @@ func GzipMiddleware(level int, next http.Handler) http.Handler {
}
w.Header().Set("Content-Encoding", "gzip")
- gz, err := gzip.NewWriterLevel(w, level)
- if err != nil {
- panic(err)
- }
- defer gz.Close()
+ gz := pool.Get().(*gzip.Writer)
+ gz.Reset(w)
+ defer func() {
+ _ = gz.Close()
+ gz.Reset(io.Discard)
+ pool.Put(gz)
+ }()
grw := gzipResponseWriter{ResponseWriter: w, sink: gz}
next.ServeHTTP(grw, r)
diff --git a/internal/gzip_test.go b/internal/gzip_test.go
new file mode 100644
index 000000000..dc9d36abe
--- /dev/null
+++ b/internal/gzip_test.go
@@ -0,0 +1,146 @@
+package internal
+
+import (
+ "bytes"
+ "compress/gzip"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+)
+
+// gzipTestPayload returns a deterministic, compressible body so the gzip
+// middleware tests and benchmark are comparable across runs.
+func gzipTestPayload() []byte {
+ payload := make([]byte, 4096)
+ for i := range payload {
+ payload[i] = byte(i)
+ }
+ return payload
+}
+
+func TestGzipMiddleware(t *testing.T) {
+ payload := gzipTestPayload()
+ inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ _, _ = w.Write(payload)
+ })
+
+ t.Run("compresses when client accepts gzip", func(t *testing.T) {
+ h := GzipMiddleware(gzip.BestSpeed, inner)
+
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
+ req.Header.Set("Accept-Encoding", "gzip")
+ rec := httptest.NewRecorder()
+
+ h.ServeHTTP(rec, req)
+
+ if got := rec.Header().Get("Content-Encoding"); got != "gzip" {
+ t.Fatalf("Content-Encoding = %q, want %q", got, "gzip")
+ }
+
+ gz, err := gzip.NewReader(rec.Body)
+ if err != nil {
+ t.Fatalf("response body is not valid gzip: %v", err)
+ }
+ got, err := io.ReadAll(gz)
+ if err != nil {
+ t.Fatalf("can't read gzip body: %v", err)
+ }
+ if !bytes.Equal(got, payload) {
+ t.Fatalf("decompressed body does not match original (got %d bytes, want %d)", len(got), len(payload))
+ }
+ })
+
+ t.Run("passes through when client does not accept gzip", func(t *testing.T) {
+ h := GzipMiddleware(gzip.BestSpeed, inner)
+
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
+ rec := httptest.NewRecorder()
+
+ h.ServeHTTP(rec, req)
+
+ if got := rec.Header().Get("Content-Encoding"); got != "" {
+ t.Fatalf("Content-Encoding = %q, want empty", got)
+ }
+ if !bytes.Equal(rec.Body.Bytes(), payload) {
+ t.Fatal("body was modified despite client not accepting gzip")
+ }
+ })
+}
+
+// TestGzipMiddlewareInvalidLevel covers the construction-time range check:
+// an out-of-range compression level must panic up front rather than mid-request.
+func TestGzipMiddlewareInvalidLevel(t *testing.T) {
+ for _, tt := range []struct {
+ name string
+ level int
+ }{
+ {"below-huffman-only", gzip.HuffmanOnly - 1},
+ {"above-best-compression", gzip.BestCompression + 1},
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ defer func() {
+ if recover() == nil {
+ t.Fatalf("GzipMiddleware(%d, ...) did not panic on invalid level", tt.level)
+ }
+ }()
+ GzipMiddleware(tt.level, http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
+ })
+ }
+}
+
+// GzipMiddleware is rebuilt on every request at its production
+// call site (RenderIndex wraps the request-specific challenge page), so the
+// *gzip.Writer pool must outlive any single middleware instance. If the pool is
+// ever moved back inside the closure, each request reallocates a ~1.18 MiB
+// deflate writer and the per-request allocation count jumps from the mid-teens
+// into the high thirties. Assert it stays low.
+func TestGzipMiddlewarePoolSurvivesReconstruction(t *testing.T) {
+ payload := gzipTestPayload()
+ inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ _, _ = w.Write(payload)
+ })
+
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
+ req.Header.Set("Accept-Encoding", "gzip")
+
+ serve := func() {
+ // Construct the middleware per call, exactly like RenderIndex does.
+ h := GzipMiddleware(gzip.BestSpeed, inner)
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ _, _ = io.Copy(io.Discard, rec.Body)
+ }
+
+ const maxAllocs = 25 // healthy steady state is ~14; a defeated pool is ~37
+ got := testing.AllocsPerRun(50, serve)
+ t.Logf("allocations per (construct + serve): %.0f", got)
+ if got > maxAllocs {
+ t.Fatalf("GzipMiddleware allocates %.0f times per request; want <= %d. "+
+ "The *gzip.Writer pool is likely no longer shared across middleware "+
+ "reconstructions (see gzipWriterPools).", got, maxAllocs)
+ }
+}
+
+// BenchmarkGzipMiddleware exercises the production pattern where the middleware
+// is constructed inside the per-request handler. Constructing it once up front
+// would hide whether the writer pool actually survives reconstruction, which is
+// exactly the regression this benchmark exists to surface.
+func BenchmarkGzipMiddleware(b *testing.B) {
+ payload := gzipTestPayload()
+ inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ _, _ = w.Write(payload)
+ })
+
+ b.ReportAllocs()
+ b.RunParallel(func(pb *testing.PB) {
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
+ req.Header.Set("Accept-Encoding", "gzip")
+ for pb.Next() {
+ h := GzipMiddleware(gzip.BestSpeed, inner)
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ _, _ = io.Copy(io.Discard, rec.Body)
+ }
+ })
+}
diff --git a/internal/hash.go b/internal/hash.go
index aeec173d3..c2913b6a9 100644
--- a/internal/hash.go
+++ b/internal/hash.go
@@ -11,9 +11,8 @@ import (
// SHA256sum computes a cryptographic hash. Still used for proof-of-work challenges
// where we need the security properties of a cryptographic hash function.
func SHA256sum(text string) string {
- hash := sha256.New()
- hash.Write([]byte(text))
- return hex.EncodeToString(hash.Sum(nil))
+ sum := sha256.Sum256([]byte(text))
+ return hex.EncodeToString(sum[:])
}
// FastHash is a high-performance non-cryptographic hash function suitable for
diff --git a/internal/hash_bench_test.go b/internal/hash_bench_test.go
index 5384570a2..9b77c8944 100644
--- a/internal/hash_bench_test.go
+++ b/internal/hash_bench_test.go
@@ -184,7 +184,7 @@ func TestHashCollisions(t *testing.T) {
for _, prefix := range prefixes {
for _, suffix := range suffixes {
for _, variation := range variations {
- for i := 0; i < 100; i++ {
+ for i := range 100 {
input := fmt.Sprintf("%s%s%s-%d", prefix, suffix, variation, i)
hash := XXHash64sum(input)
if existing, exists := xxhashHashes[hash]; exists {
@@ -211,7 +211,7 @@ func TestHashCollisions(t *testing.T) {
seqCount := 0
for _, pattern := range patterns {
- for i := 0; i < 10000; i++ {
+ for i := range 10000 {
input := fmt.Sprintf(pattern, i)
hash := XXHash64sum(input)
if existing, exists := xxhashHashes[hash]; exists {
@@ -251,6 +251,7 @@ func TestXXHashFormat(t *testing.T) {
// Make sure it's all hex characters
for _, char := range hash {
+ //nolint:staticcheck
if !((char >= '0' && char <= '9') || (char >= 'a' && char <= 'f')) {
t.Errorf("Non-hex character %c in hash %s for input %q", char, hash, input)
}
diff --git a/internal/headers.go b/internal/headers.go
index 21601d246..3e7548aed 100644
--- a/internal/headers.go
+++ b/internal/headers.go
@@ -1,18 +1,27 @@
package internal
import (
+ "context"
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"net/netip"
+ "slices"
"strings"
"github.com/TecharoHQ/anubis"
"github.com/sebest/xff"
)
+type realIPKey struct{}
+
+func RealIP(r *http.Request) (netip.Addr, bool) {
+ result, ok := r.Context().Value(realIPKey{}).(netip.Addr)
+ return result, ok
+}
+
// TODO: move into config
type XFFComputePreferences struct {
StripPrivate bool
@@ -54,7 +63,7 @@ func CustomRealIPHeader(customRealIPHeaderValue string, next http.Handler) http.
})
}
-// RemoteXRealIP sets the X-Real-Ip header to the request's real IP if
+// RemoteXRealIP sets the X-Real-IP header to the request's real IP if
// the setting is enabled by the user.
func RemoteXRealIP(useRemoteAddress bool, bindNetwork string, next http.Handler) http.Handler {
if !useRemoteAddress {
@@ -66,7 +75,7 @@ func RemoteXRealIP(useRemoteAddress bool, bindNetwork string, next http.Handler)
// For local sockets there is no real remote address but the localhost
// address should be sensible.
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- r.Header.Set("X-Real-Ip", "127.0.0.1")
+ r.Header.Set("X-Real-IP", "127.0.0.1")
next.ServeHTTP(w, r)
})
}
@@ -76,19 +85,25 @@ func RemoteXRealIP(useRemoteAddress bool, bindNetwork string, next http.Handler)
if err != nil {
panic(err) // this should never happen
}
- r.Header.Set("X-Real-Ip", host)
+ r.Header.Set("X-Real-IP", host)
+ if addr, err := netip.ParseAddr(host); err == nil {
+ r = r.WithContext(context.WithValue(r.Context(), realIPKey{}, addr))
+ }
next.ServeHTTP(w, r)
})
}
-// XForwardedForToXRealIP sets the X-Real-Ip header based on the contents
+// XForwardedForToXRealIP sets the X-Real-IP header based on the contents
// of the X-Forwarded-For header.
func XForwardedForToXRealIP(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if xffHeader := r.Header.Get("X-Forwarded-For"); r.Header.Get("X-Real-Ip") == "" && xffHeader != "" {
+ if xffHeader := r.Header.Get("X-Forwarded-For"); r.Header.Get("X-Real-IP") == "" && xffHeader != "" {
ip := xff.Parse(xffHeader)
- slog.Debug("setting x-real-ip", "val", ip)
- r.Header.Set("X-Real-Ip", ip)
+ slog.DebugContext(r.Context(), "setting X-Real-IP from X-Forwarded-For", "to", ip, "x-forwarded-for", xffHeader)
+ r.Header.Set("X-Real-IP", ip)
+ if addr, err := netip.ParseAddr(ip); err == nil {
+ r = r.WithContext(context.WithValue(r.Context(), realIPKey{}, addr))
+ }
}
next.ServeHTTP(w, r)
@@ -120,7 +135,7 @@ func XForwardedForUpdate(stripPrivate bool, next http.Handler) http.Handler {
xffHeaderString, err := computeXFFHeader(remoteAddr, origXFFHeader, pref)
if err != nil {
- slog.Debug("computing X-Forwarded-For header failed", "err", err)
+ slog.DebugContext(r.Context(), "computing X-Forwarded-For header failed", "err", err)
return
}
@@ -147,7 +162,7 @@ func computeXFFHeader(remoteAddr string, origXFFHeader string, pref XFFComputePr
return "", fmt.Errorf("%w: %w", ErrCantParseRemoteIP, err)
}
- origForwardedList := make([]string, 0, 4)
+ var origForwardedList []string
if origXFFHeader != "" {
origForwardedList = strings.Split(origXFFHeader, ",")
for i := range origForwardedList {
@@ -189,8 +204,12 @@ func computeXFFHeader(remoteAddr string, origXFFHeader string, pref XFFComputePr
if pref.StripCGNAT && CGNat.Contains(segmentIP) {
continue
}
- forwardedList = append([]string{segmentIP.String()}, forwardedList...)
+ // Build the kept chain in reverse (cheap appends into the
+ // pre-sized slice) and flip it once below, instead of prepending
+ // a freshly allocated slice on every iteration.
+ forwardedList = append(forwardedList, segmentIP.String())
}
+ slices.Reverse(forwardedList)
var xffHeaderString string
if len(forwardedList) == 0 {
xffHeaderString = ""
diff --git a/internal/honeypot/honeypot.go b/internal/honeypot/honeypot.go
new file mode 100644
index 000000000..f03b2db42
--- /dev/null
+++ b/internal/honeypot/honeypot.go
@@ -0,0 +1,23 @@
+package honeypot
+
+import (
+ "time"
+
+ "github.com/prometheus/client_golang/prometheus"
+ "github.com/prometheus/client_golang/prometheus/promauto"
+)
+
+var Timings = promauto.NewHistogramVec(prometheus.HistogramOpts{
+ Namespace: "anubis",
+ Subsystem: "honeypot",
+ Name: "pagegen_timings",
+ Help: "The amount of time honeypot page generation takes per method",
+ Buckets: prometheus.ExponentialBuckets(0.5, 2, 32),
+}, []string{"method"})
+
+type Info struct {
+ CreatedAt time.Time `json:"createdAt"`
+ UserAgent string `json:"userAgent"`
+ IPAddress string `json:"ipAddress"`
+ HitCount int `json:"hitCount"`
+}
diff --git a/internal/honeypot/naive/100bytes.css b/internal/honeypot/naive/100bytes.css
new file mode 100644
index 000000000..7de70f3f9
--- /dev/null
+++ b/internal/honeypot/naive/100bytes.css
@@ -0,0 +1,7 @@
+html {
+ max-width: 70ch;
+ padding: 3em 1em;
+ margin: auto;
+ line-height: 1.75;
+ font-size: 1.25em;
+}
diff --git a/internal/honeypot/naive/affirmations.txt b/internal/honeypot/naive/affirmations.txt
new file mode 100644
index 000000000..2b568fb9c
--- /dev/null
+++ b/internal/honeypot/naive/affirmations.txt
@@ -0,0 +1 @@
+{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!}
\ No newline at end of file
diff --git a/internal/honeypot/naive/naive.go b/internal/honeypot/naive/naive.go
new file mode 100644
index 000000000..38d33686e
--- /dev/null
+++ b/internal/honeypot/naive/naive.go
@@ -0,0 +1,279 @@
+package naive
+
+import (
+ "bytes"
+ "context"
+ _ "embed"
+ "fmt"
+ "io"
+ "log/slog"
+ "math"
+ "math/rand/v2"
+ "net/http"
+ "net/netip"
+ "os"
+ "slices"
+ "sort"
+ "time"
+
+ "github.com/TecharoHQ/anubis/internal"
+ "github.com/TecharoHQ/anubis/internal/bundler"
+ "github.com/TecharoHQ/anubis/internal/honeypot"
+ "github.com/TecharoHQ/anubis/lib/config"
+ "github.com/TecharoHQ/anubis/lib/policy/checker"
+ "github.com/TecharoHQ/anubis/lib/store"
+ "github.com/a-h/templ"
+ "github.com/google/uuid"
+ "github.com/nikandfor/spintax"
+)
+
+//go:generate go tool github.com/a-h/templ/cmd/templ generate
+
+// XXX(Xe): All of this was generated by ChatGPT, GLM 4.6, and GPT-OSS 120b. This is pseudoprofound bullshit in spintax[1] format so that the bullshit generator can emit plausibly human-authored text while being very computationally cheap.
+//
+// It feels somewhat poetic to use spammer technology in Anubis.
+//
+// [1]: https://outboundly.ai/blogs/what-is-spintax-and-how-to-use-it/
+//
+//go:embed spintext.txt
+var spintext string
+
+//go:embed titles.txt
+var titles string
+
+//go:embed affirmations.txt
+var affirmations string
+
+func New(cfg *config.Honeypot, st store.Interface, lg *slog.Logger) (*Impl, error) {
+ affirmation, err := spintax.Parse(affirmations)
+ if err != nil {
+ return nil, fmt.Errorf("can't parse affirmations: %w", err)
+ }
+
+ body, err := spintax.Parse(spintext)
+ if err != nil {
+ return nil, fmt.Errorf("can't parse bodies: %w", err)
+ }
+
+ title, err := spintax.Parse(titles)
+ if err != nil {
+ return nil, fmt.Errorf("can't parse titles: %w", err)
+ }
+
+ var fout *os.File
+ var foutBundler *bundler.Bundler[string]
+
+ if cfg.IPLogFile != "" {
+ lg.InfoContext(context.Background(), "logging honeypot IP addresses", "foutName", cfg.IPLogFile)
+
+ fout, err = os.Create(cfg.IPLogFile)
+ if err != nil {
+ return nil, fmt.Errorf("can't open ip log file %q: %w", cfg.IPLogFile, err)
+ }
+
+ if _, err := fout.Seek(0, 0); err != nil {
+ return nil, fmt.Errorf("can't rewind ip log file %q: %w", cfg.IPLogFile, err)
+ }
+
+ if err := fout.Truncate(0); err != nil {
+ return nil, fmt.Errorf("can't truncate ip log file %q: %w", cfg.IPLogFile, err)
+ }
+
+ lg := lg.With("in", "foutBundler")
+
+ foutBundler = bundler.New(func(ctx context.Context, ips []string) {
+ st, err := fout.Stat()
+ if err != nil {
+ lg.ErrorContext(ctx, "can't stat ip log file", "foutName", cfg.IPLogFile, "err", err)
+ return
+ }
+
+ if st.Size() >= 65536 {
+ if _, err := fout.Seek(0, 0); err != nil {
+ lg.ErrorContext(ctx, "can't rewind ip log file", "foutName", cfg.IPLogFile, "err", err)
+ return
+ }
+
+ if err := fout.Truncate(0); err != nil {
+ lg.ErrorContext(ctx, "can't truncate ip log file", "foutName", cfg.IPLogFile, "err", err)
+ return
+ }
+ }
+
+ sort.Strings(ips)
+ ips = slices.Compact(ips)
+
+ buf := bytes.NewBuffer(nil)
+ for _, ip := range ips {
+ fmt.Fprintln(buf, ip)
+ }
+
+ if _, err := io.Copy(fout, buf); err != nil {
+ lg.ErrorContext(ctx, "can't write buffered IP addresses to output file", "foutName", cfg.IPLogFile, "err", err)
+ return
+ }
+
+ if err := fout.Sync(); err != nil {
+ lg.ErrorContext(ctx, "can't sync output file", "foutName", cfg.IPLogFile, "err", err)
+ }
+ })
+
+ foutBundler.BundleByteLimit = 32768
+ foutBundler.DelayThreshold = time.Minute
+ }
+
+ lg.Debug("initialized basic bullshit generator", "affirmations", affirmation.Count(), "bodies", body.Count(), "titles", title.Count())
+
+ return &Impl{
+ st: st,
+ infos: store.JSON[honeypot.Info]{Underlying: st, Prefix: "honeypot:info"},
+ uaWeight: store.JSON[int]{Underlying: st, Prefix: "honeypot:user-agent"},
+ networkWeight: store.JSON[int]{Underlying: st, Prefix: "honeypot:network"},
+ affirmation: affirmation,
+ body: body,
+ title: title,
+ lg: lg.With("component", "honeypot/naive"),
+ fout: fout,
+ foutBundler: foutBundler,
+ }, nil
+}
+
+type Impl struct {
+ st store.Interface
+ infos store.JSON[honeypot.Info]
+ uaWeight store.JSON[int]
+ networkWeight store.JSON[int]
+ lg *slog.Logger
+ fout *os.File
+ foutBundler *bundler.Bundler[string]
+
+ affirmation, body, title spintax.Spintax
+}
+
+func (i *Impl) incrementNetwork(ctx context.Context, network string) int {
+ key := internal.SHA256sum(network)
+ result, _ := i.networkWeight.Get(ctx, key)
+ result++
+ _ = i.networkWeight.Set(ctx, key, result, time.Hour)
+ return result
+}
+
+func (i *Impl) CheckNetwork() checker.Impl {
+ return checker.Func(func(r *http.Request) (bool, error) {
+ realIP, _ := internal.RealIP(r)
+ if !realIP.IsValid() {
+ realIP = netip.MustParseAddr(r.Header.Get("X-Real-IP"))
+ }
+
+ network, ok := internal.ClampIP(realIP)
+ if !ok {
+ return false, nil
+ }
+
+ result, _ := i.networkWeight.Get(r.Context(), internal.SHA256sum(network.String()))
+ if result >= 25 {
+ return true, nil
+ }
+
+ return false, nil
+ })
+}
+
+func (i *Impl) Hash() string {
+ return internal.SHA256sum("naive honeypot")
+}
+
+func (i *Impl) makeAffirmations() []string {
+ count := rand.IntN(5) + 1
+
+ var result []string
+ for range count {
+ result = append(result, i.affirmation.Spin())
+ }
+
+ return result
+}
+
+func (i *Impl) makeSpins() []string {
+ count := rand.IntN(5) + 1
+
+ var result []string
+ for range count {
+ result = append(result, i.body.Spin())
+ }
+
+ return result
+}
+
+func (i *Impl) makeTitle() string {
+ return i.title.Spin()
+}
+
+func (i *Impl) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ t0 := time.Now()
+ lg := internal.GetRequestLogger(i.lg, r)
+
+ id := r.PathValue("id")
+ if id == "" {
+ id = uuid.NewString()
+ }
+
+ realIP, _ := internal.RealIP(r)
+ if !realIP.IsValid() {
+ realIP = netip.MustParseAddr(r.Header.Get("X-Real-IP"))
+ }
+
+ if i.foutBundler != nil {
+ rip := realIP.String()
+ _ = i.foutBundler.Add(rip, len(rip)+1) // for the newline
+ }
+
+ network, ok := internal.ClampIP(realIP)
+ if !ok {
+ lg.ErrorContext(r.Context(), "clampIP failed", "output", network, "ok", ok)
+ http.Error(w, "The cake is a lie", http.StatusTeapot)
+ return
+ }
+
+ networkCount := i.incrementNetwork(r.Context(), network.String())
+
+ stage := r.PathValue("stage")
+
+ if stage == "init" {
+ lg.DebugContext(r.Context(), "found new entrance point", "id", id, "stage", stage, "userAgent", r.UserAgent(), "clampedIP", network)
+ } else {
+ switch networkCount % 256 {
+ case 0:
+ lg.WarnContext(r.Context(), "found possible crawler", "id", id, "network", network, "userAgent", r.UserAgent())
+ }
+ }
+
+ millisecondAmount := min(math.Pow(float64(networkCount), 2), 1000)
+ time.Sleep(time.Duration(millisecondAmount) * time.Millisecond)
+
+ spins := i.makeSpins()
+ affirmations := i.makeAffirmations()
+ title := i.makeTitle()
+
+ var links []link
+ for _, affirmation := range affirmations {
+ links = append(links, link{
+ href: uuid.NewString(),
+ body: affirmation,
+ })
+ }
+
+ templ.Handler(
+ base(title, i.maze(spins, links)),
+ templ.WithStreaming(),
+ templ.WithStatus(http.StatusOK),
+ ).ServeHTTP(w, r)
+
+ t1 := time.Since(t0)
+ honeypot.Timings.WithLabelValues("naive").Observe(float64(t1.Milliseconds()))
+}
+
+type link struct {
+ href string
+ body string
+}
diff --git a/internal/honeypot/naive/page.templ b/internal/honeypot/naive/page.templ
new file mode 100644
index 000000000..eb3ccec43
--- /dev/null
+++ b/internal/honeypot/naive/page.templ
@@ -0,0 +1,36 @@
+package naive
+
+import "fmt"
+
+templ base(title string, body templ.Component) {
+
+
+
+
+ { title }
+
+
+ { title }
+ @body
+
+
+}
+
+templ (i Impl) maze(body []string, links []link) {
+ for _, paragraph := range body {
+ { paragraph }
+ }
+
+}
diff --git a/internal/honeypot/naive/page_templ.go b/internal/honeypot/naive/page_templ.go
new file mode 100644
index 000000000..6e1c7b8c6
--- /dev/null
+++ b/internal/honeypot/naive/page_templ.go
@@ -0,0 +1,160 @@
+// Code generated by templ - DO NOT EDIT.
+
+// templ: version: v0.3.1020
+package naive
+
+//lint:file-ignore SA4006 This context is only used if a nested component is present.
+
+import "github.com/a-h/templ"
+import templruntime "github.com/a-h/templ/runtime"
+
+import "fmt"
+
+func base(title string, body templ.Component) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var1 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var1 == nil {
+ templ_7745c5c3_Var1 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var2 string
+ templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(title)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `page.templ`, Line: 18, Col: 17}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var3 string
+ templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(title)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `page.templ`, Line: 21, Col: 14}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = body.Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
+func (i Impl) maze(body []string, links []link) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var4 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var4 == nil {
+ templ_7745c5c3_Var4 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ for _, paragraph := range body {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var5 string
+ templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(paragraph)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `page.templ`, Line: 29, Col: 16}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ for _, link := range links {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "- ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var7 string
+ templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(link.body)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `page.templ`, Line: 33, Col: 76}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
+var _ = templruntime.GeneratedTemplate
diff --git a/internal/honeypot/naive/spintext.txt b/internal/honeypot/naive/spintext.txt
new file mode 100644
index 000000000..0b06e58b7
--- /dev/null
+++ b/internal/honeypot/naive/spintext.txt
@@ -0,0 +1 @@
+{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}.
\ No newline at end of file
diff --git a/internal/honeypot/naive/titles.txt b/internal/honeypot/naive/titles.txt
new file mode 100644
index 000000000..ff1b88b55
--- /dev/null
+++ b/internal/honeypot/naive/titles.txt
@@ -0,0 +1 @@
+{{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}}
\ No newline at end of file
diff --git a/internal/ja4h.go b/internal/ja4h.go
index d4c8c1d2e..601c8fc22 100644
--- a/internal/ja4h.go
+++ b/internal/ja4h.go
@@ -6,9 +6,15 @@ import (
"github.com/lum8rjack/go-ja4h"
)
+// JA4HHeaderName is the name of the HTTP header that the [JA4H] middleware adds
+// to requests, holding the request's JA4H fingerprint. It is also used to
+// detect whether a policy references the fingerprint, so that the relatively
+// expensive computation can be skipped when no rule needs it.
+const JA4HHeaderName = "X-Http-Fingerprint-Ja4h"
+
func JA4H(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- r.Header.Add("X-Http-Fingerprint-JA4H", ja4h.JA4H(r))
+ r.Header.Add(JA4HHeaderName, ja4h.JA4H(r))
next.ServeHTTP(w, r)
})
}
diff --git a/internal/listor.go b/internal/listor.go
new file mode 100644
index 000000000..73110b79f
--- /dev/null
+++ b/internal/listor.go
@@ -0,0 +1,39 @@
+package internal
+
+import (
+ "encoding/json"
+)
+
+// ListOr[T any] is a slice that can contain either a single T or multiple T values.
+// During JSON unmarshaling, it checks if the first character is '[' to determine
+// whether to treat the JSON as an array or a single value.
+type ListOr[T any] []T
+
+func (lo *ListOr[T]) UnmarshalJSON(data []byte) error {
+ if len(data) == 0 {
+ return nil
+ }
+
+ // Check if first non-whitespace character is '['
+ firstChar := data[0]
+ for i := range data {
+ if data[i] != ' ' && data[i] != '\t' && data[i] != '\n' && data[i] != '\r' {
+ firstChar = data[i]
+ break
+ }
+ }
+
+ if firstChar == '[' {
+ // It's an array, unmarshal directly
+ return json.Unmarshal(data, (*[]T)(lo))
+ } else {
+ // It's a single value, unmarshal as a single item in a slice
+ var single T
+ if err := json.Unmarshal(data, &single); err != nil {
+ return err
+ }
+ *lo = ListOr[T]{single}
+ }
+
+ return nil
+}
diff --git a/internal/listor_test.go b/internal/listor_test.go
new file mode 100644
index 000000000..f67fc7a0b
--- /dev/null
+++ b/internal/listor_test.go
@@ -0,0 +1,79 @@
+package internal
+
+import (
+ "encoding/json"
+ "testing"
+)
+
+func TestListOr_UnmarshalJSON(t *testing.T) {
+ t.Run("single value should be unmarshaled as single item", func(t *testing.T) {
+ var lo ListOr[string]
+
+ err := json.Unmarshal([]byte(`"hello"`), &lo)
+ if err != nil {
+ t.Fatalf("Failed to unmarshal single string: %v", err)
+ }
+
+ if len(lo) != 1 {
+ t.Fatalf("Expected 1 item, got %d", len(lo))
+ }
+
+ if lo[0] != "hello" {
+ t.Errorf("Expected 'hello', got %q", lo[0])
+ }
+ })
+
+ t.Run("array should be unmarshaled as multiple items", func(t *testing.T) {
+ var lo ListOr[string]
+
+ err := json.Unmarshal([]byte(`["hello", "world"]`), &lo)
+ if err != nil {
+ t.Fatalf("Failed to unmarshal array: %v", err)
+ }
+
+ if len(lo) != 2 {
+ t.Fatalf("Expected 2 items, got %d", len(lo))
+ }
+
+ if lo[0] != "hello" {
+ t.Errorf("Expected 'hello', got %q", lo[0])
+ }
+ if lo[1] != "world" {
+ t.Errorf("Expected 'world', got %q", lo[1])
+ }
+ })
+
+ t.Run("single number should be unmarshaled as single item", func(t *testing.T) {
+ var lo ListOr[int]
+
+ err := json.Unmarshal([]byte(`42`), &lo)
+ if err != nil {
+ t.Fatalf("Failed to unmarshal single number: %v", err)
+ }
+
+ if len(lo) != 1 {
+ t.Fatalf("Expected 1 item, got %d", len(lo))
+ }
+
+ if lo[0] != 42 {
+ t.Errorf("Expected 42, got %d", lo[0])
+ }
+ })
+
+ t.Run("array of numbers should be unmarshaled as multiple items", func(t *testing.T) {
+ var lo ListOr[int]
+
+ err := json.Unmarshal([]byte(`[1, 2, 3]`), &lo)
+ if err != nil {
+ t.Fatalf("Failed to unmarshal number array: %v", err)
+ }
+
+ if len(lo) != 3 {
+ t.Fatalf("Expected 3 items, got %d", len(lo))
+ }
+
+ if lo[0] != 1 || lo[1] != 2 || lo[2] != 3 {
+ t.Errorf("Expected [1, 2, 3], got %v", lo)
+ }
+ })
+}
diff --git a/internal/log.go b/internal/log.go
index fb2f2509c..fa2a81701 100644
--- a/internal/log.go
+++ b/internal/log.go
@@ -2,6 +2,7 @@ package internal
import (
"fmt"
+ "io"
"log"
"log/slog"
"net/http"
@@ -9,7 +10,7 @@ import (
"strings"
)
-func InitSlog(level string) {
+func InitSlog(level string, sink io.Writer) *slog.Logger {
var programLevel slog.Level
if err := (&programLevel).UnmarshalText([]byte(level)); err != nil {
fmt.Fprintf(os.Stderr, "invalid log level %s: %v, using info\n", level, err)
@@ -19,11 +20,12 @@ func InitSlog(level string) {
leveler := &slog.LevelVar{}
leveler.Set(programLevel)
- h := slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{
+ h := slog.NewJSONHandler(sink, &slog.HandlerOptions{
AddSource: true,
Level: leveler,
})
- slog.SetDefault(slog.New(h))
+ result := slog.New(h)
+ return result
}
func GetRequestLogger(base *slog.Logger, r *http.Request) *slog.Logger {
@@ -39,9 +41,8 @@ func GetRequestLogger(base *slog.Logger, r *http.Request) *slog.Logger {
"user_agent", r.UserAgent(),
"accept_language", r.Header.Get("Accept-Language"),
"priority", r.Header.Get("Priority"),
- "x-forwarded-for",
- r.Header.Get("X-Forwarded-For"),
- "x-real-ip", r.Header.Get("X-Real-Ip"),
+ "x-forwarded-for", r.Header.Get("X-Forwarded-For"),
+ "x-real-ip", r.Header.Get("X-Real-IP"),
)
}
diff --git a/internal/mimetype.go b/internal/mimetype.go
index a0a156621..e7a2c21a2 100644
--- a/internal/mimetype.go
+++ b/internal/mimetype.go
@@ -3,5 +3,5 @@ package internal
import "mime"
func init() {
- mime.AddExtensionType(".mjs", "text/javascript")
+ _ = mime.AddExtensionType(".mjs", "text/javascript")
}
diff --git a/internal/multifile/multifile.go b/internal/multifile/multifile.go
new file mode 100644
index 000000000..37594d682
--- /dev/null
+++ b/internal/multifile/multifile.go
@@ -0,0 +1,140 @@
+// Package multifile combines multiple fs.File instances into one logical file.
+//
+// This is used in the configuration parsing subsystem to load multiple logical
+// bot rules files as one composite yaml file.
+//
+// See function YAMLList for more information.
+package multifile
+
+import (
+ "bytes"
+ "errors"
+ "fmt"
+ "io"
+ "io/fs"
+ "os"
+ "time"
+)
+
+var (
+ ErrCantOpen = errors.New("multifile: can't open")
+ ErrCantStat = errors.New("multifile: can't stat")
+ ErrCantClose = errors.New("multifile: can't close")
+)
+
+// YAMLList opens multiple files from the given filesystem and gloms them
+// together into one big YAML reader where each component file is its own
+// YAML document.
+//
+// This works because the import logic calls yaml decoding in a loop,
+// making sure that every YAML document is decoded until the file runs
+// out of data.
+//
+// Make sure to call yaml decoding in a loop until this file returns
+// io.EOF.
+func YAMLList(fsys fs.FS, fnames []string) (fs.File, error) {
+ var files []fs.File
+ var readers []io.Reader
+ var errs []error
+ var mfi multiFileInfo
+
+ mfi.name = fmt.Sprintf("multifile: %v", fnames)
+ mfi.mode = 0666 // the filemode of the beast
+
+ for _, fname := range fnames {
+ var (
+ fin fs.File
+ err error
+ )
+
+ switch fsys == nil {
+ case true:
+ fin, err = os.Open(fname)
+ case false:
+ fin, err = fsys.Open(fname)
+ }
+ if err != nil {
+ errs = append(errs, fmt.Errorf("%w: %q: %w", ErrCantOpen, fname, err))
+ continue
+ }
+ files = append(files, fin)
+ readers = append(readers, fin)
+ readers = append(readers, bytes.NewBufferString("\n---\n"))
+ mfi.size += 5
+
+ st, err := fin.Stat()
+ if err != nil {
+ if closeErr := fin.Close(); closeErr != nil {
+ errs = append(errs, fmt.Errorf("%w: %q: %w", ErrCantClose, fname, err))
+ }
+
+ errs = append(errs, fmt.Errorf("%w: %q: %w", ErrCantStat, fname, err))
+ continue
+ }
+
+ mfi.size += st.Size()
+
+ if mfi.modTime.Before(st.ModTime()) {
+ mfi.modTime = st.ModTime()
+ }
+ }
+
+ if len(errs) != 0 {
+ // drain files and clean up
+ for _, fin := range files {
+ _ = fin.Close() // if there's an error here, we're already in trouble
+ }
+
+ return nil, errors.Join(errs...)
+ }
+
+ return multiFile{
+ files: files,
+ reader: io.MultiReader(readers...),
+ st: mfi,
+ }, nil
+}
+
+type multiFile struct {
+ files []fs.File
+ reader io.Reader
+ st fs.FileInfo
+}
+
+func (mf multiFile) Stat() (fs.FileInfo, error) { return mf.st, nil }
+func (mf multiFile) Read(p []byte) (int, error) { return mf.reader.Read(p) }
+
+func (mf multiFile) Close() error {
+ var errs []error
+
+ for _, fin := range mf.files {
+ if err := fin.Close(); err != nil {
+ st, stErr := fin.Stat()
+ if stErr != nil {
+ errs = append(errs, fmt.Errorf("%w: %w", ErrCantStat, err))
+ continue
+ }
+ errs = append(errs, fmt.Errorf("%q (%T): %w", st.Name(), st, err))
+ }
+ }
+
+ if len(errs) != 0 {
+ return fmt.Errorf("%w: %w", ErrCantClose, errors.Join(errs...))
+ }
+
+ return nil
+}
+
+type multiFileInfo struct {
+ name string
+ size int64
+ mode fs.FileMode
+ modTime time.Time
+}
+
+func (mfi multiFileInfo) Name() string { return mfi.name }
+func (mfi multiFileInfo) Size() int64 { return mfi.size }
+func (mfi multiFileInfo) Mode() fs.FileMode { return mfi.mode }
+func (mfi multiFileInfo) ModTime() time.Time { return mfi.modTime }
+func (mfi multiFileInfo) IsDir() bool { return false }
+func (mfi multiFileInfo) Sys() any { return nil }
diff --git a/internal/multifile/multifile_test.go b/internal/multifile/multifile_test.go
new file mode 100644
index 000000000..92ee077df
--- /dev/null
+++ b/internal/multifile/multifile_test.go
@@ -0,0 +1,151 @@
+package multifile
+
+import (
+ "bytes"
+ "embed"
+ "errors"
+ "io"
+ "io/fs"
+ "slices"
+ "testing"
+
+ datapkg "github.com/TecharoHQ/anubis/data"
+ "github.com/goreleaser/fileglob"
+ "k8s.io/apimachinery/pkg/util/yaml"
+)
+
+//go:embed testdata/*.yaml testdata/*.json
+var testdata embed.FS
+
+type data struct {
+ Name string `json:"name" yaml:"name"`
+}
+
+type testCase struct {
+ name string
+ fnames []string
+ wantYamlError bool
+ validate func(*testing.T, []data)
+}
+
+func runTestCase(tt testCase, t *testing.T, fsys fs.FS) {
+ fin, err := YAMLList(fsys, tt.fnames)
+ if err != nil {
+ t.Fatal(err)
+ }
+ st, err := fin.Stat()
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Log(st.Name())
+ defer func() {
+ if err := fin.Close(); err != nil {
+ t.Logf("can't close file: %v", err)
+ }
+ }()
+
+ var got []data
+
+ dec := yaml.NewYAMLToJSONDecoder(fin)
+
+outer:
+ for {
+ var thisGot []data
+ err = dec.Decode(&thisGot)
+ switch {
+ case errors.Is(err, io.EOF):
+ break outer
+ case err == nil && tt.wantYamlError:
+ t.Error("wanted yaml error but got none")
+ case err != nil && !tt.wantYamlError:
+ t.Errorf("did not want yaml error but got: %v", err)
+ default:
+ if err != nil {
+ t.Log(err)
+ }
+ }
+ got = append(got, thisGot...)
+ }
+
+ if tt.validate != nil {
+ tt.validate(t, got)
+ }
+}
+
+func TestYAMLList(t *testing.T) {
+ for _, tt := range []testCase{
+ {
+ name: "simple happy",
+ fnames: []string{"testdata/a.yaml", "testdata/b.yaml"},
+ validate: func(t *testing.T, got []data) {
+ want := []data{
+ {"foo"},
+ {"bar"},
+ }
+
+ if !slices.EqualFunc(want, got, func(a data, b data) bool { return a.Name == b.Name }) {
+ t.Logf("want: %#v", want)
+ t.Logf(" got: %#v", got)
+ t.Error("did not parse yaml correctly")
+ }
+ },
+ },
+ {
+ name: "invalid object when wanted list",
+ fnames: []string{"testdata/object.yaml"},
+ wantYamlError: true,
+ validate: func(t *testing.T, got []data) {
+ if len(got) != 0 {
+ t.Error("wanted data to not be populated")
+ }
+ },
+ },
+ {
+ name: "mixed indentation",
+ fnames: []string{"testdata/a.yaml", "testdata/b.yaml", "testdata/indented.yaml"},
+ },
+ {
+ name: "mixed json and yaml",
+ fnames: []string{"testdata/a.yaml", "testdata/data.json"},
+ },
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Run("embed fs", func(t *testing.T) {
+ runTestCase(tt, t, testdata)
+ })
+ t.Run("os", func(t *testing.T) {
+ runTestCase(tt, t, nil)
+ })
+ })
+ }
+}
+
+func TestReadAllOfData(t *testing.T) {
+ matches, err := fileglob.Glob("{apps,bots,bots/irc-bots,clients,common,crawlers,meta,services}/*.{yaml,yml}", fileglob.WithFs(datapkg.BotPolicies))
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Log(matches)
+
+ fin, err := YAMLList(datapkg.BotPolicies, matches)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() {
+ if err := fin.Close(); err != nil {
+ t.Logf("can't close file: %v", err)
+ }
+ }()
+
+ buf, err := io.ReadAll(fin)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ var got []any
+ if err := yaml.NewYAMLToJSONDecoder(bytes.NewBuffer(buf)).Decode(&got); err != nil {
+ t.Fatal(err)
+ }
+
+ t.Logf("read %d entries", len(got))
+}
diff --git a/internal/multifile/testdata/a.yaml b/internal/multifile/testdata/a.yaml
new file mode 100644
index 000000000..1c7d56296
--- /dev/null
+++ b/internal/multifile/testdata/a.yaml
@@ -0,0 +1 @@
+- name: foo
diff --git a/internal/multifile/testdata/b.yaml b/internal/multifile/testdata/b.yaml
new file mode 100644
index 000000000..a556bce43
--- /dev/null
+++ b/internal/multifile/testdata/b.yaml
@@ -0,0 +1 @@
+- name: bar
diff --git a/internal/multifile/testdata/data.json b/internal/multifile/testdata/data.json
new file mode 100644
index 000000000..c060cb93a
--- /dev/null
+++ b/internal/multifile/testdata/data.json
@@ -0,0 +1,5 @@
+[
+ {
+ "name": "foo"
+ }
+]
diff --git a/internal/multifile/testdata/indented.yaml b/internal/multifile/testdata/indented.yaml
new file mode 100644
index 000000000..d4b895275
--- /dev/null
+++ b/internal/multifile/testdata/indented.yaml
@@ -0,0 +1 @@
+- name: boz
diff --git a/internal/multifile/testdata/object.yaml b/internal/multifile/testdata/object.yaml
new file mode 100644
index 000000000..496c82128
--- /dev/null
+++ b/internal/multifile/testdata/object.yaml
@@ -0,0 +1 @@
+name: toplevel
diff --git a/internal/ogtags/cache.go b/internal/ogtags/cache.go
index 838d01692..0b777d7a7 100644
--- a/internal/ogtags/cache.go
+++ b/internal/ogtags/cache.go
@@ -31,7 +31,7 @@ func (c *OGTagCache) GetOGTags(ctx context.Context, url *url.URL, originalHost s
// Fetch HTML content, passing the original host
doc, err := c.fetchHTMLDocumentWithCache(ctx, target, originalHost, cacheKey)
if errors.Is(err, syscall.ECONNREFUSED) {
- slog.Debug("Connection refused, returning empty tags")
+ slog.DebugContext(ctx, "Connection refused, returning empty tags")
return nil, nil
} else if errors.Is(err, ErrOgHandled) {
// Error was handled in fetchHTMLDocument, return empty tags
@@ -44,17 +44,17 @@ func (c *OGTagCache) GetOGTags(ctx context.Context, url *url.URL, originalHost s
// Extract OG tags
ogTags := c.extractOGTags(doc)
- // Store in cache
- c.cache.Set(ctx, cacheKey, ogTags, c.ogTimeToLive)
+ // Store in cache, the error doesn't matter
+ _ = c.cache.Set(ctx, cacheKey, ogTags, c.ogTimeToLive)
for k, v := range ogTags {
switch {
case strings.HasSuffix(k, "image"), strings.HasSuffix(k, "audio"), strings.HasSuffix(k, "secure_url"), strings.HasSuffix(k, "video"):
v, _ = strings.CutPrefix(v, "http://")
v, _ = strings.CutPrefix(v, "https://")
- slog.Debug("setting ogtags allow for", "url", k)
+ slog.DebugContext(ctx, "setting ogtags allow for", "url", k)
if err := c.cache.Underlying.Set(ctx, "ogtags:allow:"+v, []byte(k), time.Hour); err != nil {
- slog.Debug("can't set ogtag allow cache", "err", err)
+ slog.DebugContext(ctx, "can't set ogtag allow cache", "err", err)
}
}
}
@@ -76,9 +76,9 @@ func (c *OGTagCache) generateCacheKey(target string, originalHost string) string
// checkCache checks if we have the tags cached and returns them if so
func (c *OGTagCache) checkCache(ctx context.Context, cacheKey string) map[string]string {
if cachedTags, err := c.cache.Get(ctx, cacheKey); err == nil {
- slog.Debug("cache hit", "tags", cachedTags)
+ slog.DebugContext(ctx, "cache hit", "tags", cachedTags)
return cachedTags
}
- slog.Debug("cache miss", "url", cacheKey)
+ slog.DebugContext(ctx, "cache miss", "url", cacheKey)
return nil
}
diff --git a/internal/ogtags/cache_test.go b/internal/ogtags/cache_test.go
index 08bf4e347..214793fd2 100644
--- a/internal/ogtags/cache_test.go
+++ b/internal/ogtags/cache_test.go
@@ -9,7 +9,7 @@ import (
"testing"
"time"
- "github.com/TecharoHQ/anubis/lib/policy/config"
+ "github.com/TecharoHQ/anubis/lib/config"
"github.com/TecharoHQ/anubis/lib/store"
"github.com/TecharoHQ/anubis/lib/store/memory"
)
@@ -24,7 +24,7 @@ func TestCacheReturnsDefault(t *testing.T) {
TimeToLive: time.Minute,
ConsiderHost: false,
Override: want,
- }, memory.New(t.Context()))
+ }, memory.New(t.Context()), TargetOptions{})
u, err := url.Parse("https://anubis.techaro.lol")
if err != nil {
@@ -52,7 +52,7 @@ func TestCheckCache(t *testing.T) {
Enabled: true,
TimeToLive: time.Minute,
ConsiderHost: false,
- }, memory.New(t.Context()))
+ }, memory.New(t.Context()), TargetOptions{})
// Set up test data
urlStr := "http://example.com/page"
@@ -69,7 +69,9 @@ func TestCheckCache(t *testing.T) {
}
// Manually add to cache
- cache.cache.Set(t.Context(), cacheKey, expectedTags, time.Minute)
+ if err := cache.cache.Set(t.Context(), cacheKey, expectedTags, time.Minute); err != nil {
+ t.Fatal(err)
+ }
// Test cache hit
tags = cache.checkCache(t.Context(), cacheKey)
@@ -94,6 +96,7 @@ func TestGetOGTags(t *testing.T) {
t.Fatalf("Test route loaded more than once, cache failed")
}
w.Header().Set("Content-Type", "text/html")
+ // nolint:errcheck
w.Write([]byte(`
@@ -115,7 +118,7 @@ func TestGetOGTags(t *testing.T) {
Enabled: true,
TimeToLive: time.Minute,
ConsiderHost: false,
- }, memory.New(t.Context()))
+ }, memory.New(t.Context()), TargetOptions{})
// Parse the test server URL
parsedURL, err := url.Parse(ts.URL)
@@ -185,6 +188,7 @@ func TestGetOGTagsWithHostConsideration(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
loadCount++ // Increment counter on each request to the server
w.Header().Set("Content-Type", "text/html")
+ // nolint:errcheck
w.Write([]byte(`
@@ -271,7 +275,7 @@ func TestGetOGTagsWithHostConsideration(t *testing.T) {
Enabled: true,
TimeToLive: time.Minute,
ConsiderHost: tc.ogCacheConsiderHost,
- }, memory.New(t.Context()))
+ }, memory.New(t.Context()), TargetOptions{})
for i, req := range tc.requests {
ogTags, err := cache.GetOGTags(t.Context(), parsedURL, req.host)
diff --git a/internal/ogtags/fetch.go b/internal/ogtags/fetch.go
index 26a0af2a7..e58379305 100644
--- a/internal/ogtags/fetch.go
+++ b/internal/ogtags/fetch.go
@@ -27,21 +27,39 @@ func (c *OGTagCache) fetchHTMLDocumentWithCache(ctx context.Context, urlStr stri
}
// Set the Host header to the original host
- if originalHost != "" {
- req.Host = originalHost
+ var hostForRequest string
+ switch {
+ case c.targetHost != "":
+ hostForRequest = c.targetHost
+ case originalHost != "":
+ hostForRequest = originalHost
+ }
+ if hostForRequest != "" {
+ req.Host = hostForRequest
}
// Add proxy headers
req.Header.Set("X-Forwarded-Proto", "https")
+ // Forward Host header as X-Forwarded-Host for webapps to dispatch against
+ if originalHost != "" {
+ req.Header.Set("X-Forwarded-Host", originalHost)
+ }
req.Header.Set("User-Agent", "Anubis-OGTag-Fetcher/1.0") // For tracking purposes
+ serverName := hostForRequest
+ if serverName == "" {
+ serverName = req.URL.Hostname()
+ }
+ client := c.clientForSNI(serverName)
+
// Send the request
- resp, err := c.client.Do(req)
+ resp, err := client.Do(req)
if err != nil {
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
- slog.Debug("og: request timed out", "url", urlStr)
- c.cache.Set(ctx, cacheKey, emptyMap, c.ogTimeToLive/2) // Cache empty result for half the TTL to not spam the server
+ slog.DebugContext(ctx, "og: request timed out", "url", urlStr)
+ // Cache empty result for half the TTL to not spam the server, errors don't matter
+ _ = c.cache.Set(ctx, cacheKey, emptyMap, c.ogTimeToLive/2)
}
return nil, fmt.Errorf("http get failed: %w", err)
}
@@ -50,13 +68,14 @@ func (c *OGTagCache) fetchHTMLDocumentWithCache(ctx context.Context, urlStr stri
defer func(Body io.ReadCloser) {
err := Body.Close()
if err != nil {
- slog.Debug("og: error closing response body", "url", urlStr, "error", err)
+ slog.DebugContext(ctx, "og: error closing response body", "url", urlStr, "error", err)
}
}(resp.Body)
if resp.StatusCode != http.StatusOK {
- slog.Debug("og: received non-OK status code", "url", urlStr, "status", resp.StatusCode)
- c.cache.Set(ctx, cacheKey, emptyMap, c.ogTimeToLive) // Cache empty result for non-successful status codes
+ slog.DebugContext(ctx, "og: received non-OK status code", "url", urlStr, "status", resp.StatusCode)
+ // Cache empty result for non-successful status codes
+ _ = c.cache.Set(ctx, cacheKey, emptyMap, c.ogTimeToLive)
return nil, fmt.Errorf("%w: page not found", ErrOgHandled)
}
@@ -67,12 +86,12 @@ func (c *OGTagCache) fetchHTMLDocumentWithCache(ctx context.Context, urlStr stri
} else {
mediaType, _, err := mime.ParseMediaType(ct)
if err != nil {
- slog.Debug("og: malformed Content-Type header", "url", urlStr, "contentType", ct)
+ slog.DebugContext(ctx, "og: malformed Content-Type header", "url", urlStr, "contentType", ct)
return nil, fmt.Errorf("%w malformed Content-Type header: %w", ErrOgHandled, err)
}
if mediaType != "text/html" && mediaType != "application/xhtml+xml" {
- slog.Debug("og: unsupported Content-Type", "url", urlStr, "contentType", mediaType)
+ slog.DebugContext(ctx, "og: unsupported Content-Type", "url", urlStr, "contentType", mediaType)
return nil, fmt.Errorf("%w unsupported Content-Type: %s", ErrOgHandled, mediaType)
}
}
@@ -84,7 +103,7 @@ func (c *OGTagCache) fetchHTMLDocumentWithCache(ctx context.Context, urlStr stri
// Check if the error is specifically because the limit was exceeded
var maxBytesErr *http.MaxBytesError
if errors.As(err, &maxBytesErr) {
- slog.Debug("og: content exceeded max length", "url", urlStr, "limit", maxContentLength)
+ slog.DebugContext(ctx, "og: content exceeded max length", "url", urlStr, "limit", maxContentLength)
return nil, fmt.Errorf("content too large: exceeded %d bytes", maxContentLength)
}
return nil, fmt.Errorf("failed to parse HTML: %w", err)
diff --git a/internal/ogtags/fetch_test.go b/internal/ogtags/fetch_test.go
index c986272ad..e48303df5 100644
--- a/internal/ogtags/fetch_test.go
+++ b/internal/ogtags/fetch_test.go
@@ -11,7 +11,7 @@ import (
"testing"
"time"
- "github.com/TecharoHQ/anubis/lib/policy/config"
+ "github.com/TecharoHQ/anubis/lib/config"
"github.com/TecharoHQ/anubis/lib/store/memory"
"golang.org/x/net/html"
)
@@ -75,10 +75,10 @@ func TestFetchHTMLDocument(t *testing.T) {
if tt.contentLength > 0 {
// Simulate content length but avoid sending too much actual data
w.Header().Set("Content-Length", fmt.Sprintf("%d", tt.contentLength))
- io.CopyN(w, strings.NewReader("X"), tt.contentLength)
+ io.CopyN(w, strings.NewReader("X"), tt.contentLength) //nolint:errcheck
} else {
w.WriteHeader(tt.statusCode)
- w.Write([]byte(tt.htmlContent))
+ w.Write([]byte(tt.htmlContent)) //nolint:errcheck
}
}))
defer ts.Close()
@@ -87,7 +87,7 @@ func TestFetchHTMLDocument(t *testing.T) {
Enabled: true,
TimeToLive: time.Minute,
ConsiderHost: false,
- }, memory.New(t.Context()))
+ }, memory.New(t.Context()), TargetOptions{})
doc, err := cache.fetchHTMLDocument(t.Context(), ts.URL, "anything")
if tt.expectError {
@@ -118,7 +118,7 @@ func TestFetchHTMLDocumentInvalidURL(t *testing.T) {
Enabled: true,
TimeToLive: time.Minute,
ConsiderHost: false,
- }, memory.New(t.Context()))
+ }, memory.New(t.Context()), TargetOptions{})
doc, err := cache.fetchHTMLDocument(t.Context(), "http://invalid.url.that.doesnt.exist.example", "anything")
@@ -136,3 +136,64 @@ func (c *OGTagCache) fetchHTMLDocument(ctx context.Context, urlStr string, origi
cacheKey := c.generateCacheKey(urlStr, originalHost)
return c.fetchHTMLDocumentWithCache(ctx, urlStr, originalHost, cacheKey)
}
+
+// TestFetchForwardsOriginalHostHeader ensures the fetcher forwards the public
+// hostname as X-Forwarded-Host so name-based backends can dispatch.
+func TestFetchForwardsOriginalHostHeader(t *testing.T) {
+ for _, tt := range []struct {
+ name string
+ targetHost string
+ originalHost string
+ wantHost string
+ wantForwardedHost string
+ }{
+ {
+ name: "target host pinned, original host still forwarded",
+ targetHost: "origin-herisau.sp-ar.ch",
+ originalHost: "sp-ar.ch",
+ wantHost: "origin-herisau.sp-ar.ch",
+ wantForwardedHost: "sp-ar.ch",
+ },
+ {
+ name: "no target host falls back to original host",
+ targetHost: "",
+ originalHost: "sp-ar.ch",
+ wantHost: "sp-ar.ch",
+ wantForwardedHost: "sp-ar.ch",
+ },
+ {
+ name: "no original host means no forwarded host header",
+ targetHost: "origin-herisau.sp-ar.ch",
+ originalHost: "",
+ wantHost: "origin-herisau.sp-ar.ch",
+ wantForwardedHost: "",
+ },
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ var gotHost, gotForwardedHost string
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotHost = r.Host
+ gotForwardedHost = r.Header.Get("X-Forwarded-Host")
+ w.Header().Set("Content-Type", "text/html")
+ w.Write([]byte(``)) //nolint:errcheck
+ }))
+ defer ts.Close()
+
+ cache := NewOGTagCache("", config.OpenGraph{
+ Enabled: true,
+ TimeToLive: time.Minute,
+ }, memory.New(t.Context()), TargetOptions{Host: tt.targetHost})
+
+ if _, err := cache.fetchHTMLDocument(t.Context(), ts.URL, tt.originalHost); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ if gotHost != tt.wantHost {
+ t.Errorf("Host header: got %q, want %q", gotHost, tt.wantHost)
+ }
+ if gotForwardedHost != tt.wantForwardedHost {
+ t.Errorf("X-Forwarded-Host header: got %q, want %q", gotForwardedHost, tt.wantForwardedHost)
+ }
+ })
+ }
+}
diff --git a/internal/ogtags/integration_test.go b/internal/ogtags/integration_test.go
index 574172d09..69df2ba38 100644
--- a/internal/ogtags/integration_test.go
+++ b/internal/ogtags/integration_test.go
@@ -7,10 +7,11 @@ import (
"testing"
"time"
- "github.com/TecharoHQ/anubis/lib/policy/config"
+ "github.com/TecharoHQ/anubis/lib/config"
"github.com/TecharoHQ/anubis/lib/store/memory"
)
+//nolint:errcheck
func TestIntegrationGetOGTags(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
@@ -111,7 +112,7 @@ func TestIntegrationGetOGTags(t *testing.T) {
Enabled: true,
TimeToLive: time.Minute,
ConsiderHost: false,
- }, memory.New(t.Context()))
+ }, memory.New(t.Context()), TargetOptions{})
// Create URL for test
testURL, _ := url.Parse(ts.URL)
diff --git a/internal/ogtags/mem_test.go b/internal/ogtags/mem_test.go
index b415cda40..aebb4f6ff 100644
--- a/internal/ogtags/mem_test.go
+++ b/internal/ogtags/mem_test.go
@@ -6,7 +6,7 @@ import (
"strings"
"testing"
- "github.com/TecharoHQ/anubis/lib/policy/config"
+ "github.com/TecharoHQ/anubis/lib/config"
"github.com/TecharoHQ/anubis/lib/store/memory"
"golang.org/x/net/html"
)
@@ -31,7 +31,7 @@ func BenchmarkGetTarget(b *testing.B) {
for _, tt := range tests {
b.Run(tt.name, func(b *testing.B) {
- cache := NewOGTagCache(tt.target, config.OpenGraph{}, memory.New(b.Context()))
+ cache := NewOGTagCache(tt.target, config.OpenGraph{}, memory.New(b.Context()), TargetOptions{})
urls := make([]*url.URL, len(tt.paths))
for i, path := range tt.paths {
u, _ := url.Parse(path)
@@ -67,7 +67,7 @@ func BenchmarkExtractOGTags(b *testing.B) {
`,
}
- cache := NewOGTagCache("http://example.com", config.OpenGraph{}, memory.New(b.Context()))
+ cache := NewOGTagCache("http://example.com", config.OpenGraph{}, memory.New(b.Context()), TargetOptions{})
docs := make([]*html.Node, len(htmlSamples))
for i, sample := range htmlSamples {
@@ -85,7 +85,7 @@ func BenchmarkExtractOGTags(b *testing.B) {
// Memory usage test
func TestMemoryUsage(t *testing.T) {
- cache := NewOGTagCache("http://example.com", config.OpenGraph{}, memory.New(t.Context()))
+ cache := NewOGTagCache("http://example.com", config.OpenGraph{}, memory.New(t.Context()), TargetOptions{})
// Force GC and wait for it to complete
runtime.GC()
@@ -95,7 +95,7 @@ func TestMemoryUsage(t *testing.T) {
// Run getTarget many times
u, _ := url.Parse("/path/to/resource?query=1&foo=bar&baz=qux")
- for i := 0; i < 10000; i++ {
+ for range 10000 {
_ = cache.getTarget(u)
}
@@ -129,7 +129,7 @@ func TestMemoryUsage(t *testing.T) {
runtime.GC()
runtime.ReadMemStats(&m1)
- for i := 0; i < 1000; i++ {
+ for range 1000 {
_ = cache.extractOGTags(doc)
}
diff --git a/internal/ogtags/ogtags.go b/internal/ogtags/ogtags.go
index 68fb164c7..62cd89c2f 100644
--- a/internal/ogtags/ogtags.go
+++ b/internal/ogtags/ogtags.go
@@ -2,14 +2,16 @@ package ogtags
import (
"context"
+ "crypto/tls"
"log/slog"
"net"
"net/http"
"net/url"
"strings"
+ "sync"
"time"
- "github.com/TecharoHQ/anubis/lib/policy/config"
+ "github.com/TecharoHQ/anubis/lib/config"
"github.com/TecharoHQ/anubis/lib/store"
)
@@ -22,21 +24,34 @@ const (
)
type OGTagCache struct {
- cache store.JSON[map[string]string]
- targetURL *url.URL
- client *http.Client
+ ogOverride map[string]string
+ targetURL *url.URL
+ client *http.Client
+ transport *http.Transport
+ cache store.JSON[map[string]string]
// Pre-built strings for optimization
unixPrefix string // "http://unix"
- approvedTags []string
+ targetSNI string
+ targetHost string
approvedPrefixes []string
+ approvedTags []string
ogTimeToLive time.Duration
- ogCacheConsiderHost bool
ogPassthrough bool
- ogOverride map[string]string
+ ogCacheConsiderHost bool
+ targetSNIAuto bool
+ insecureSkipVerify bool
+ sniClients map[string]*http.Client
+ transportMu sync.RWMutex
}
-func NewOGTagCache(target string, conf config.OpenGraph, backend store.Interface) *OGTagCache {
+type TargetOptions struct {
+ Host string
+ SNI string
+ InsecureSkipVerify bool
+}
+
+func NewOGTagCache(target string, conf config.OpenGraph, backend store.Interface, targetOpts TargetOptions) *OGTagCache {
// Predefined approved tags and prefixes
defaultApprovedTags := []string{"description", "keywords", "author"}
defaultApprovedPrefixes := []string{"og:", "twitter:", "fediverse:"}
@@ -62,18 +77,35 @@ func NewOGTagCache(target string, conf config.OpenGraph, backend store.Interface
}
}
- client := &http.Client{
- Timeout: httpTimeout,
- }
+ transport := http.DefaultTransport.(*http.Transport).Clone()
// Configure custom transport for Unix sockets
if parsedTargetURL.Scheme == "unix" {
socketPath := parsedTargetURL.Path // For unix scheme, path is the socket path
- client.Transport = &http.Transport{
- DialContext: func(_ context.Context, _, _ string) (net.Conn, error) {
- return net.Dial("unix", socketPath)
- },
+ transport.DialContext = func(_ context.Context, _, _ string) (net.Conn, error) {
+ return net.Dial("unix", socketPath)
+ }
+ }
+
+ targetSNIAuto := targetOpts.SNI == "auto"
+
+ if targetOpts.SNI != "" && !targetSNIAuto {
+ if transport.TLSClientConfig == nil {
+ transport.TLSClientConfig = &tls.Config{}
}
+ transport.TLSClientConfig.ServerName = targetOpts.SNI
+ }
+
+ if targetOpts.InsecureSkipVerify {
+ if transport.TLSClientConfig == nil {
+ transport.TLSClientConfig = &tls.Config{}
+ }
+ transport.TLSClientConfig.InsecureSkipVerify = true
+ }
+
+ client := &http.Client{
+ Timeout: httpTimeout,
+ Transport: transport,
}
return &OGTagCache{
@@ -89,7 +121,13 @@ func NewOGTagCache(target string, conf config.OpenGraph, backend store.Interface
approvedTags: defaultApprovedTags,
approvedPrefixes: defaultApprovedPrefixes,
client: client,
+ transport: transport,
unixPrefix: "http://unix",
+ targetHost: targetOpts.Host,
+ targetSNI: targetOpts.SNI,
+ targetSNIAuto: targetSNIAuto,
+ insecureSkipVerify: targetOpts.InsecureSkipVerify,
+ sniClients: make(map[string]*http.Client),
}
}
diff --git a/internal/ogtags/ogtags_fuzz_test.go b/internal/ogtags/ogtags_fuzz_test.go
index 1656e58cb..8b7abb91d 100644
--- a/internal/ogtags/ogtags_fuzz_test.go
+++ b/internal/ogtags/ogtags_fuzz_test.go
@@ -3,11 +3,12 @@ package ogtags
import (
"context"
"net/url"
+ "slices"
"strings"
"testing"
"unicode/utf8"
- "github.com/TecharoHQ/anubis/lib/policy/config"
+ "github.com/TecharoHQ/anubis/lib/config"
"github.com/TecharoHQ/anubis/lib/store/memory"
"golang.org/x/net/html"
)
@@ -48,7 +49,7 @@ func FuzzGetTarget(f *testing.F) {
}
// Create cache - should not panic
- cache := NewOGTagCache(target, config.OpenGraph{}, memory.New(context.Background()))
+ cache := NewOGTagCache(target, config.OpenGraph{}, memory.New(context.Background()), TargetOptions{})
// Create URL
u := &url.URL{
@@ -78,7 +79,7 @@ func FuzzGetTarget(f *testing.F) {
}
// Ensure no memory corruption by calling multiple times
- for i := 0; i < 3; i++ {
+ for range 3 {
result2 := cache.getTarget(u)
if result != result2 {
t.Errorf("getTarget not deterministic: %q != %q", result, result2)
@@ -132,7 +133,7 @@ func FuzzExtractOGTags(f *testing.F) {
return
}
- cache := NewOGTagCache("http://example.com", config.OpenGraph{}, memory.New(context.Background()))
+ cache := NewOGTagCache("http://example.com", config.OpenGraph{}, memory.New(context.Background()), TargetOptions{})
// Should not panic
tags := cache.extractOGTags(doc)
@@ -148,11 +149,8 @@ func FuzzExtractOGTags(f *testing.F) {
}
}
if !approved {
- for _, tag := range cache.approvedTags {
- if property == tag {
- approved = true
- break
- }
+ if slices.Contains(cache.approvedTags, property) {
+ approved = true
}
}
if !approved {
@@ -188,7 +186,7 @@ func FuzzGetTargetRoundTrip(f *testing.F) {
t.Skip()
}
- cache := NewOGTagCache(target, config.OpenGraph{}, memory.New(context.Background()))
+ cache := NewOGTagCache(target, config.OpenGraph{}, memory.New(context.Background()), TargetOptions{})
u := &url.URL{Path: path, RawQuery: query}
result := cache.getTarget(u)
@@ -245,7 +243,7 @@ func FuzzExtractMetaTagInfo(f *testing.F) {
},
}
- cache := NewOGTagCache("http://example.com", config.OpenGraph{}, memory.New(context.Background()))
+ cache := NewOGTagCache("http://example.com", config.OpenGraph{}, memory.New(context.Background()), TargetOptions{})
// Should not panic
property, content := cache.extractMetaTagInfo(node)
@@ -260,11 +258,8 @@ func FuzzExtractMetaTagInfo(f *testing.F) {
}
}
if !approved {
- for _, tag := range cache.approvedTags {
- if property == tag {
- approved = true
- break
- }
+ if slices.Contains(cache.approvedTags, property) {
+ approved = true
}
}
if !approved {
@@ -298,7 +293,7 @@ func BenchmarkFuzzedGetTarget(b *testing.B) {
for _, input := range inputs {
b.Run(input.name, func(b *testing.B) {
- cache := NewOGTagCache(input.target, config.OpenGraph{}, memory.New(context.Background()))
+ cache := NewOGTagCache(input.target, config.OpenGraph{}, memory.New(context.Background()), TargetOptions{})
u := &url.URL{Path: input.path, RawQuery: input.query}
b.ResetTimer()
diff --git a/internal/ogtags/ogtags_test.go b/internal/ogtags/ogtags_test.go
index c936b0151..cf658c8d7 100644
--- a/internal/ogtags/ogtags_test.go
+++ b/internal/ogtags/ogtags_test.go
@@ -2,19 +2,27 @@ package ogtags
import (
"context"
+ "crypto/rand"
+ "crypto/rsa"
+ "crypto/tls"
+ "crypto/x509"
+ "crypto/x509/pkix"
"errors"
"fmt"
+ "math/big"
"net"
"net/http"
+ "net/http/httptest"
"net/url"
"os"
"path/filepath"
"reflect"
"strings"
+ "sync"
"testing"
"time"
- "github.com/TecharoHQ/anubis/lib/policy/config"
+ "github.com/TecharoHQ/anubis/lib/config"
"github.com/TecharoHQ/anubis/lib/store/memory"
)
@@ -45,7 +53,7 @@ func TestNewOGTagCache(t *testing.T) {
Enabled: tt.ogPassthrough,
TimeToLive: tt.ogTimeToLive,
ConsiderHost: false,
- }, memory.New(t.Context()))
+ }, memory.New(t.Context()), TargetOptions{})
if cache == nil {
t.Fatal("expected non-nil cache, got nil")
@@ -85,7 +93,7 @@ func TestNewOGTagCache_UnixSocket(t *testing.T) {
Enabled: true,
TimeToLive: 5 * time.Minute,
ConsiderHost: false,
- }, memory.New(t.Context()))
+ }, memory.New(t.Context()), TargetOptions{})
if cache == nil {
t.Fatal("expected non-nil cache, got nil")
@@ -110,7 +118,7 @@ func TestNewOGTagCache_UnixSocket(t *testing.T) {
// Attempt a dummy dial to see if it uses the correct path (optional, more involved check)
dummyConn, err := transport.DialContext(context.Background(), "", "")
if err == nil {
- dummyConn.Close()
+ _ = dummyConn.Close() // error doesn't matter
t.Log("DialContext seems functional, but couldn't verify path without a listener")
} else if !strings.Contains(err.Error(), "connect: connection refused") && !strings.Contains(err.Error(), "connect: no such file or directory") {
// We expect connection refused or not found if nothing is listening
@@ -170,7 +178,7 @@ func TestGetTarget(t *testing.T) {
Enabled: true,
TimeToLive: time.Minute,
ConsiderHost: false,
- }, memory.New(t.Context()))
+ }, memory.New(t.Context()), TargetOptions{})
u := &url.URL{
Path: tt.path,
@@ -217,6 +225,7 @@ func TestIntegrationGetOGTags_UnixSocket(t *testing.T) {
}(listener, socketPath)
server := &http.Server{
+ //nolint:errcheck
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
fmt.Fprintln(w, `Test`)
@@ -243,7 +252,7 @@ func TestIntegrationGetOGTags_UnixSocket(t *testing.T) {
Enabled: true,
TimeToLive: time.Minute,
ConsiderHost: false,
- }, memory.New(t.Context()))
+ }, memory.New(t.Context()), TargetOptions{})
// Create a dummy URL for the request (path and query matter)
testReqURL, _ := url.Parse("/some/page?query=1")
@@ -274,3 +283,247 @@ func TestIntegrationGetOGTags_UnixSocket(t *testing.T) {
t.Errorf("Expected cached OG tags %v, got %v", expectedTags, cachedTags)
}
}
+
+func TestGetOGTagsWithTargetHostOverride(t *testing.T) {
+ originalHost := "example.test"
+ overrideHost := "backend.internal"
+ seenHosts := make(chan string, 10)
+
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ seenHosts <- r.Host
+ w.Header().Set("Content-Type", "text/html")
+ //nolint:errcheck
+ fmt.Fprintln(w, `ok`)
+ }))
+ defer ts.Close()
+
+ targetURL, err := url.Parse(ts.URL)
+ if err != nil {
+ t.Fatalf("failed to parse server URL: %v", err)
+ }
+
+ conf := config.OpenGraph{
+ Enabled: true,
+ TimeToLive: time.Minute,
+ ConsiderHost: false,
+ }
+
+ t.Run("default host uses original", func(t *testing.T) {
+ cache := NewOGTagCache(ts.URL, conf, memory.New(t.Context()), TargetOptions{})
+ if _, err := cache.GetOGTags(t.Context(), targetURL, originalHost); err != nil {
+ t.Fatalf("GetOGTags failed: %v", err)
+ }
+ select {
+ case host := <-seenHosts:
+ if host != originalHost {
+ t.Fatalf("expected host %q, got %q", originalHost, host)
+ }
+ case <-time.After(time.Second):
+ t.Fatal("server did not receive request")
+ }
+ })
+
+ t.Run("override host respected", func(t *testing.T) {
+ cache := NewOGTagCache(ts.URL, conf, memory.New(t.Context()), TargetOptions{
+ Host: overrideHost,
+ })
+ if _, err := cache.GetOGTags(t.Context(), targetURL, originalHost); err != nil {
+ t.Fatalf("GetOGTags failed: %v", err)
+ }
+ select {
+ case host := <-seenHosts:
+ if host != overrideHost {
+ t.Fatalf("expected host %q, got %q", overrideHost, host)
+ }
+ case <-time.After(time.Second):
+ t.Fatal("server did not receive request")
+ }
+ })
+}
+
+func TestGetOGTagsWithInsecureSkipVerify(t *testing.T) {
+ handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/html")
+ //nolint:errcheck
+ fmt.Fprintln(w, `hello`)
+ })
+ ts := httptest.NewTLSServer(handler)
+ defer ts.Close()
+
+ parsedURL, err := url.Parse(ts.URL)
+ if err != nil {
+ t.Fatalf("failed to parse server URL: %v", err)
+ }
+
+ conf := config.OpenGraph{
+ Enabled: true,
+ TimeToLive: time.Minute,
+ ConsiderHost: false,
+ }
+
+ // Without skip verify we should get a TLS error
+ cacheStrict := NewOGTagCache(ts.URL, conf, memory.New(t.Context()), TargetOptions{})
+ if _, err := cacheStrict.GetOGTags(t.Context(), parsedURL, parsedURL.Host); err == nil {
+ t.Fatal("expected TLS verification error without InsecureSkipVerify")
+ }
+
+ cacheSkip := NewOGTagCache(ts.URL, conf, memory.New(t.Context()), TargetOptions{
+ InsecureSkipVerify: true,
+ })
+
+ tags, err := cacheSkip.GetOGTags(t.Context(), parsedURL, parsedURL.Host)
+ if err != nil {
+ t.Fatalf("expected successful fetch with InsecureSkipVerify, got: %v", err)
+ }
+ if tags["og:title"] != "Self-Signed" {
+ t.Fatalf("expected og:title to be %q, got %q", "Self-Signed", tags["og:title"])
+ }
+}
+
+func TestGetOGTagsWithTargetSNI(t *testing.T) {
+ originalHost := "hecate.test"
+ conf := config.OpenGraph{
+ Enabled: true,
+ TimeToLive: time.Minute,
+ ConsiderHost: false,
+ }
+
+ t.Run("explicit SNI override", func(t *testing.T) {
+ expectedSNI := "backend.internal"
+ ts, recorder := newSNIServer(t, `ok`)
+ defer ts.Close()
+
+ targetURL, err := url.Parse(ts.URL)
+ if err != nil {
+ t.Fatalf("failed to parse server URL: %v", err)
+ }
+
+ cacheExplicit := NewOGTagCache(ts.URL, conf, memory.New(t.Context()), TargetOptions{
+ SNI: expectedSNI,
+ InsecureSkipVerify: true,
+ })
+ if _, err := cacheExplicit.GetOGTags(t.Context(), targetURL, originalHost); err != nil {
+ t.Fatalf("expected successful fetch with explicit SNI, got: %v", err)
+ }
+ if got := recorder.last(); got != expectedSNI {
+ t.Fatalf("expected server to see SNI %q, got %q", expectedSNI, got)
+ }
+ })
+
+ t.Run("auto SNI uses original host", func(t *testing.T) {
+ ts, recorder := newSNIServer(t, `ok`)
+ defer ts.Close()
+
+ targetURL, err := url.Parse(ts.URL)
+ if err != nil {
+ t.Fatalf("failed to parse server URL: %v", err)
+ }
+
+ cacheAuto := NewOGTagCache(ts.URL, conf, memory.New(t.Context()), TargetOptions{
+ SNI: "auto",
+ InsecureSkipVerify: true,
+ })
+ if _, err := cacheAuto.GetOGTags(t.Context(), targetURL, originalHost); err != nil {
+ t.Fatalf("expected successful fetch with auto SNI, got: %v", err)
+ }
+ if got := recorder.last(); got != originalHost {
+ t.Fatalf("expected server to see SNI %q with auto, got %q", originalHost, got)
+ }
+ })
+
+ t.Run("default SNI uses backend host", func(t *testing.T) {
+ ts, recorder := newSNIServer(t, `ok`)
+ defer ts.Close()
+
+ targetURL, err := url.Parse(ts.URL)
+ if err != nil {
+ t.Fatalf("failed to parse server URL: %v", err)
+ }
+
+ cacheDefault := NewOGTagCache(ts.URL, conf, memory.New(t.Context()), TargetOptions{
+ InsecureSkipVerify: true,
+ })
+ if _, err := cacheDefault.GetOGTags(t.Context(), targetURL, originalHost); err != nil {
+ t.Fatalf("expected successful fetch without explicit SNI, got: %v", err)
+ }
+ wantSNI := ""
+ if net.ParseIP(targetURL.Hostname()) == nil {
+ wantSNI = targetURL.Hostname()
+ }
+ if got := recorder.last(); got != wantSNI {
+ t.Fatalf("expected default SNI %q, got %q", wantSNI, got)
+ }
+ })
+}
+
+func newSNIServer(t *testing.T, body string) (*httptest.Server, *sniRecorder) {
+ t.Helper()
+ handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/html")
+ //nolint:errcheck
+ fmt.Fprint(w, body)
+ })
+
+ recorder := &sniRecorder{}
+ ts := httptest.NewUnstartedServer(handler)
+ cert := mustCertificateForHost(t, "sni.test")
+ ts.TLS = &tls.Config{
+ GetCertificate: func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
+ recorder.record(hello.ServerName)
+ return &cert, nil
+ },
+ }
+ ts.StartTLS()
+ return ts, recorder
+}
+
+func mustCertificateForHost(t *testing.T, host string) tls.Certificate {
+ t.Helper()
+ priv, err := rsa.GenerateKey(rand.Reader, 2048)
+ if err != nil {
+ t.Fatalf("failed to generate key: %v", err)
+ }
+
+ template := &x509.Certificate{
+ SerialNumber: big.NewInt(1),
+ Subject: pkix.Name{
+ CommonName: host,
+ },
+ NotBefore: time.Now().Add(-time.Hour),
+ NotAfter: time.Now().Add(time.Hour),
+ ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
+ KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
+ BasicConstraintsValid: true,
+ DNSNames: []string{host},
+ }
+
+ der, err := x509.CreateCertificate(rand.Reader, template, template, &priv.PublicKey, priv)
+ if err != nil {
+ t.Fatalf("failed to create certificate: %v", err)
+ }
+
+ return tls.Certificate{
+ Certificate: [][]byte{der},
+ PrivateKey: priv,
+ }
+}
+
+type sniRecorder struct {
+ mu sync.Mutex
+ names []string
+}
+
+func (r *sniRecorder) record(name string) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ r.names = append(r.names, name)
+}
+
+func (r *sniRecorder) last() string {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ if len(r.names) == 0 {
+ return ""
+ }
+ return r.names[len(r.names)-1]
+}
diff --git a/internal/ogtags/parse.go b/internal/ogtags/parse.go
index c21fd795c..98176558f 100644
--- a/internal/ogtags/parse.go
+++ b/internal/ogtags/parse.go
@@ -1,6 +1,7 @@
package ogtags
import (
+ "slices"
"strings"
"golang.org/x/net/html"
@@ -65,10 +66,8 @@ func (c *OGTagCache) extractMetaTagInfo(n *html.Node) (property, content string)
}
// Check exact matches
- for _, tag := range c.approvedTags {
- if propertyKey == tag {
- return propertyKey, content
- }
+ if slices.Contains(c.approvedTags, propertyKey) {
+ return propertyKey, content
}
return "", content
diff --git a/internal/ogtags/parse_test.go b/internal/ogtags/parse_test.go
index d479d1109..2c92cbfb8 100644
--- a/internal/ogtags/parse_test.go
+++ b/internal/ogtags/parse_test.go
@@ -6,7 +6,7 @@ import (
"testing"
"time"
- "github.com/TecharoHQ/anubis/lib/policy/config"
+ "github.com/TecharoHQ/anubis/lib/config"
"github.com/TecharoHQ/anubis/lib/store/memory"
"golang.org/x/net/html"
)
@@ -18,7 +18,7 @@ func TestExtractOGTags(t *testing.T) {
Enabled: false,
ConsiderHost: false,
TimeToLive: time.Minute,
- }, memory.New(t.Context()))
+ }, memory.New(t.Context()), TargetOptions{})
// Manually set approved tags/prefixes based on the user request for clarity
testCache.approvedTags = []string{"description"}
testCache.approvedPrefixes = []string{"og:"}
@@ -199,7 +199,7 @@ func TestExtractMetaTagInfo(t *testing.T) {
Enabled: false,
ConsiderHost: false,
TimeToLive: time.Minute,
- }, memory.New(t.Context()))
+ }, memory.New(t.Context()), TargetOptions{})
testCache.approvedTags = []string{"description"}
testCache.approvedPrefixes = []string{"og:"}
diff --git a/internal/ogtags/sni.go b/internal/ogtags/sni.go
new file mode 100644
index 000000000..46cfe031f
--- /dev/null
+++ b/internal/ogtags/sni.go
@@ -0,0 +1,42 @@
+package ogtags
+
+import (
+ "crypto/tls"
+ "net/http"
+)
+
+// clientForSNI returns a cached client for the given server name, creating one if needed.
+func (c *OGTagCache) clientForSNI(serverName string) *http.Client {
+ if !c.targetSNIAuto || serverName == "" {
+ return c.client
+ }
+
+ c.transportMu.RLock()
+ cli, ok := c.sniClients[serverName]
+ c.transportMu.RUnlock()
+ if ok {
+ return cli
+ }
+
+ c.transportMu.Lock()
+ defer c.transportMu.Unlock()
+ if cli, ok := c.sniClients[serverName]; ok {
+ return cli
+ }
+
+ tr := c.transport.Clone()
+ if tr.TLSClientConfig == nil {
+ tr.TLSClientConfig = &tls.Config{}
+ }
+ tr.TLSClientConfig.ServerName = serverName
+ if c.insecureSkipVerify {
+ tr.TLSClientConfig.InsecureSkipVerify = true
+ }
+
+ cli = &http.Client{
+ Timeout: httpTimeout,
+ Transport: tr,
+ }
+ c.sniClients[serverName] = cli
+ return cli
+}
diff --git a/internal/servicesid/servicesid.go b/internal/servicesid/servicesid.go
new file mode 100644
index 000000000..b5293cd34
--- /dev/null
+++ b/internal/servicesid/servicesid.go
@@ -0,0 +1,57 @@
+package servicesid
+
+import (
+ "crypto/sha1"
+ "encoding/binary"
+ "fmt"
+ "strings"
+ "unicode/utf16"
+)
+
+// serviceSIDPrefix is the identifier authority Windows files per-service SIDs
+// under. Everything below it is derived from the service name.
+const serviceSIDPrefix = "S-1-5-80"
+
+// anubisServiceName is the name the MSI registers the service under. It is the
+// input to the SID derivation below, so it has to match ServiceInstall/@Name in
+// run/windows/anubis.wxs exactly, case aside.
+const AnubisServiceName = "Anubis"
+
+// anubisServiceSID is the SID of the NT SERVICE\Anubis virtual account the
+// service runs as, spelled out rather than computed so that the value the
+// installer grants access to is visible in a diff and pinned by a test.
+const AnubisServiceSID = "S-1-5-80-765274699-3418405142-632509039-2036741013-1444054785"
+
+// Encode returns the SID of the NT SERVICE account belonging to a Windows
+// service, in the string form icacls and the rest of Windows accept.
+//
+// Windows derives these rather than allocating them: it uppercases the service
+// name, encodes it as UTF-16LE, takes the SHA-1 of that, and appends the digest
+// to S-1-5-80 as five little-endian uint32 subauthorities. The service does not
+// have to exist for the SID to be meaningful, which is what lets the installer
+// grant access to the config directory before the service is registered.
+//
+// The SHA-1 here is Windows' choice of construction, not a security decision of
+// ours. Any other hash would produce a SID that names nothing.
+//
+// This lives outside service_windows.go on purpose. Nothing in the build or in
+// CI runs Windows, so anything behind that build constraint is compiled and
+// never executed; keeping the arithmetic portable is what makes it testable.
+func Encode(name string) string {
+ encoded := utf16.Encode([]rune(strings.ToUpper(name)))
+
+ buf := make([]byte, 2*len(encoded))
+ for i, unit := range encoded {
+ binary.LittleEndian.PutUint16(buf[2*i:], unit)
+ }
+
+ sum := sha1.Sum(buf)
+
+ var sb strings.Builder
+ sb.WriteString(serviceSIDPrefix)
+ for i := 0; i < len(sum); i += 4 {
+ fmt.Fprintf(&sb, "-%d", binary.LittleEndian.Uint32(sum[i:]))
+ }
+
+ return sb.String()
+}
diff --git a/internal/servicesid/servicesid_test.go b/internal/servicesid/servicesid_test.go
new file mode 100644
index 000000000..c307c6f08
--- /dev/null
+++ b/internal/servicesid/servicesid_test.go
@@ -0,0 +1,44 @@
+package servicesid
+
+import "testing"
+
+func TestServiceSID(t *testing.T) {
+ for _, tt := range []struct {
+ name string
+ service string
+ want string
+ }{
+ {
+ // TrustedInstaller ships with Windows and its SID is published in
+ // enough places to serve as a known-answer test for the whole
+ // derivation. If this case fails, the arithmetic is wrong.
+ name: "TrustedInstaller matches the published SID",
+ service: "TrustedInstaller",
+ want: "S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464",
+ },
+ {
+ // Pins the hardcoded constant to the derivation. Renaming the
+ // service without recomputing the SID fails here rather than on a
+ // Windows box, where it would surface as the service being unable
+ // to read its own configuration.
+ name: "the pinned Anubis SID is the derived one",
+ service: AnubisServiceName,
+ want: AnubisServiceSID,
+ },
+ {
+ // The name is uppercased before hashing, so case in the service
+ // name must not reach the digest.
+ name: "case in the service name does not matter",
+ service: "aNuBiS",
+ want: AnubisServiceSID,
+ },
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ if got := Encode(tt.service); got != tt.want {
+ t.Errorf("Encode(%q) = %q, want %q", tt.service, got, tt.want)
+ }
+ })
+ }
+}
diff --git a/internal/setuplistener.go b/internal/setuplistener.go
new file mode 100644
index 000000000..72a2f28d8
--- /dev/null
+++ b/internal/setuplistener.go
@@ -0,0 +1,92 @@
+package internal
+
+import (
+ "errors"
+ "fmt"
+ "net"
+ "net/url"
+ "os"
+ "strconv"
+ "strings"
+)
+
+// parseBindNetFromAddr determine bind network and address based on the given network and address.
+func parseBindNetFromAddr(address string) (string, string, error) {
+ defaultScheme := "http://"
+ if !strings.Contains(address, "://") {
+ if strings.HasPrefix(address, ":") {
+ address = defaultScheme + "localhost" + address
+ } else {
+ address = defaultScheme + address
+ }
+ }
+
+ bindUri, err := url.Parse(address)
+ if err != nil {
+ return "", "", fmt.Errorf("failed to parse bind URL: %w", err)
+ }
+
+ switch bindUri.Scheme {
+ case "unix":
+ return "unix", bindUri.Path, nil
+ case "tcp", "http", "https":
+ return "tcp", bindUri.Host, nil
+ default:
+ return "", "", fmt.Errorf("unsupported network scheme %s in address %s", bindUri.Scheme, address)
+ }
+}
+
+// SetupListener sets up a network listener based on the input from configuration
+// envvars. It returns a network listener and the URL to that listener or an error.
+func SetupListener(network, address, socketMode string) (net.Listener, string, error) {
+ formattedAddress := ""
+ var err error
+
+ if network == "" {
+ // keep compatibility
+ network, address, err = parseBindNetFromAddr(address)
+ }
+
+ if err != nil {
+ return nil, "", fmt.Errorf("can't parse bind and network: %w", err)
+ }
+
+ switch network {
+ case "unix":
+ formattedAddress = "unix:" + address
+ case "tcp":
+ if strings.HasPrefix(address, ":") { // assume it's just a port e.g. :4259
+ formattedAddress = "http://localhost" + address
+ } else {
+ formattedAddress = "http://" + address
+ }
+ default:
+ formattedAddress = fmt.Sprintf(`(%s) %s`, network, address)
+ }
+
+ ln, err := net.Listen(network, address)
+ if err != nil {
+ return nil, "", fmt.Errorf("failed to bind to %s: %w", formattedAddress, err)
+ }
+
+ // additional permission handling for unix sockets
+ if network == "unix" {
+ mode, err := strconv.ParseUint(socketMode, 8, 0)
+ if err != nil {
+ _ = ln.Close()
+ return nil, "", fmt.Errorf("could not parse socket mode %s: %w", socketMode, err)
+ }
+
+ err = os.Chmod(address, os.FileMode(mode))
+ if err != nil {
+ err := fmt.Errorf("could not change socket mode: %w", err)
+ clErr := ln.Close()
+ if clErr != nil {
+ return nil, "", errors.Join(err, clErr)
+ }
+ return nil, "", err
+ }
+ }
+
+ return ln, formattedAddress, nil
+}
diff --git a/internal/setuplistener_test.go b/internal/setuplistener_test.go
new file mode 100644
index 000000000..2048873a9
--- /dev/null
+++ b/internal/setuplistener_test.go
@@ -0,0 +1,180 @@
+package internal
+
+import (
+ "io/fs"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "testing"
+)
+
+func TestParseBindNetFromAddr(t *testing.T) {
+ for _, tt := range []struct {
+ name string
+ address string
+ wantErr bool
+ network string
+ bind string
+ }{
+ {
+ name: "simple tcp",
+ address: "localhost:9090",
+ wantErr: false,
+ network: "tcp",
+ bind: "localhost:9090",
+ },
+ {
+ name: "simple unix",
+ address: "unix:///tmp/foo.sock",
+ wantErr: false,
+ network: "unix",
+ bind: "/tmp/foo.sock",
+ },
+ {
+ name: "invalid network",
+ address: "foo:///tmp/bar.sock",
+ wantErr: true,
+ },
+ {
+ name: "tcp uri",
+ address: "tcp://[::]:9090",
+ wantErr: false,
+ network: "tcp",
+ bind: "[::]:9090",
+ },
+ {
+ name: "http uri",
+ address: "http://[::]:9090",
+ wantErr: false,
+ network: "tcp",
+ bind: "[::]:9090",
+ },
+ {
+ name: "https uri",
+ address: "https://[::]:9090",
+ wantErr: false,
+ network: "tcp",
+ bind: "[::]:9090",
+ },
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ network, bind, err := parseBindNetFromAddr(tt.address)
+
+ switch {
+ case tt.wantErr && err == nil:
+ t.Errorf("parseBindNetFromAddr(%q) should have errored but did not", tt.address)
+ case !tt.wantErr && err != nil:
+ t.Errorf("parseBindNetFromAddr(%q) threw an error: %v", tt.address, err)
+ }
+
+ if network != tt.network {
+ t.Errorf("parseBindNetFromAddr(%q) wanted network: %q, got: %q", tt.address, tt.network, network)
+ }
+
+ if bind != tt.bind {
+ t.Errorf("parseBindNetFromAddr(%q) wanted bind: %q, got: %q", tt.address, tt.bind, bind)
+ }
+ })
+ }
+}
+
+func TestSetupListener(t *testing.T) {
+ td := t.TempDir()
+
+ for _, tt := range []struct {
+ name string
+ network, address, socketMode string
+ wantErr bool
+ socketURLPrefix string
+ }{
+ {
+ name: "simple tcp",
+ network: "",
+ address: ":0",
+ wantErr: false,
+ socketURLPrefix: "http://localhost:",
+ },
+ {
+ name: "simple unix",
+ network: "",
+ address: "unix://" + filepath.Join(td, "a"),
+ socketMode: "0770",
+ wantErr: false,
+ socketURLPrefix: "unix:" + filepath.Join(td, "a"),
+ },
+ {
+ name: "tcp",
+ network: "tcp",
+ address: ":0",
+ wantErr: false,
+ socketURLPrefix: "http://localhost:",
+ },
+ {
+ name: "udp",
+ network: "udp",
+ address: ":0",
+ wantErr: true,
+ socketURLPrefix: "http://localhost:",
+ },
+ {
+ name: "unix socket",
+ network: "unix",
+ socketMode: "0770",
+ address: filepath.Join(td, "a"),
+ wantErr: false,
+ socketURLPrefix: "unix:" + filepath.Join(td, "a"),
+ },
+ {
+ name: "invalid socket mode",
+ network: "unix",
+ socketMode: "taco bell",
+ address: filepath.Join(td, "a"),
+ wantErr: true,
+ socketURLPrefix: "unix:" + filepath.Join(td, "a"),
+ },
+ {
+ name: "empty socket mode",
+ network: "unix",
+ socketMode: "",
+ address: filepath.Join(td, "a"),
+ wantErr: true,
+ socketURLPrefix: "unix:" + filepath.Join(td, "a"),
+ },
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ ln, socketURL, err := SetupListener(tt.network, tt.address, tt.socketMode)
+ switch {
+ case tt.wantErr && err == nil:
+ t.Errorf("SetupListener(%q, %q, %q) should have errored but did not", tt.network, tt.address, tt.socketMode)
+ case !tt.wantErr && err != nil:
+ t.Fatalf("SetupListener(%q, %q, %q) threw an error: %v", tt.network, tt.address, tt.socketMode, err)
+ }
+
+ if ln != nil {
+ defer ln.Close() //nolint:errcheck
+ }
+
+ if !tt.wantErr && !strings.HasPrefix(socketURL, tt.socketURLPrefix) {
+ t.Errorf("SetupListener(%q, %q, %q) should have returned a URL with prefix %q but got: %q", tt.network, tt.address, tt.socketMode, tt.socketURLPrefix, socketURL)
+ }
+
+ if tt.socketMode != "" {
+ mode, err := strconv.ParseUint(tt.socketMode, 8, 0)
+ if err != nil {
+ return
+ }
+
+ sockPath := strings.TrimPrefix(socketURL, "unix:")
+ st, err := os.Stat(sockPath)
+ if err != nil {
+ t.Fatalf("can't os.Stat(%q): %v", sockPath, err)
+ }
+
+ if st.Mode().Perm() != fs.FileMode(mode) {
+ t.Errorf("file mode of %q should be %s but is actually %s", sockPath, strconv.FormatUint(mode, 8), strconv.FormatUint(uint64(st.Mode()), 8))
+ }
+ }
+ })
+ }
+}
diff --git a/internal/test/csp_test.go b/internal/test/csp_test.go
new file mode 100644
index 000000000..efb695315
--- /dev/null
+++ b/internal/test/csp_test.go
@@ -0,0 +1,235 @@
+package test
+
+import (
+ "fmt"
+ "net"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "strconv"
+ "testing"
+ "time"
+
+ "github.com/TecharoHQ/anubis"
+ libanubis "github.com/TecharoHQ/anubis/lib"
+ "github.com/mxschmitt/playwright-go"
+)
+
+// blobBlockingCSP is the Content-Security-Policy (CSP) reported in
+// TecharoHQ/anubis#1864. This CSP doesn't set worker-src, so worker-src
+// falls back through script-src to `default-src 'self'`, which does not
+// cover blob: URIs.
+//
+// In order to reduce server request pressure, Anubis pre-fetches browser
+// worker JS from the server and packs it into a blob: URI. This policy
+// makes browsers reject that behaviour as an error event, leading the
+// workers to all die off and then users to be SOL.
+//
+// This policy also enforces Anubis to fall back to the old behaviour
+// where each worker fetches its own copy of the worker script in one
+// request per thread. This kinda sucks, but such is life in late-stage
+// capitalism.
+const blobBlockingCSP = "default-src 'self' 'unsafe-eval' 'unsafe-inline'; img-src * data:;"
+
+func TestPlaywrightBlobWorkerBlockedByCSP(t *testing.T) {
+ if os.Getenv("DONT_USE_NETWORK") != "" {
+ t.Skip("test requires network egress")
+ return
+ }
+
+ if os.Getenv("SKIP_INTEGRATION") != "" {
+ t.Skip("SKIP_INTEGRATION was set")
+ return
+ }
+
+ startPlaywright(t)
+
+ pw := setupPlaywright(t)
+
+ // Every JavaScript-driven challenge shares the same worker spawning logic,
+ // so every one of them has to survive a policy that forbids blob: workers.
+ // An empty policy file means the default policy, which issues "fast"
+ // challenges.
+ for _, cs := range []struct {
+ name string
+ policyFname string
+ }{
+ {name: "fast", policyFname: ""},
+ {name: "sha256", policyFname: "testdata/wasm-sha256.yaml"},
+ {name: "hashx", policyFname: "testdata/wasm-hashx.yaml"},
+ {name: "argon2id", policyFname: "testdata/wasm-argon2id.yaml"},
+ } {
+ t.Run(cs.name, func(t *testing.T) {
+ anubisURL := spawnAnubisWithPolicy(t, cs.policyFname, "", blobBlockingCSP)
+
+ for _, typ := range []playwright.BrowserType{pw.Chromium, pw.Firefox, pw.WebKit} {
+ t.Run(typ.Name(), func(t *testing.T) {
+ mustSolveChallenge(t, typ, anubisURL)
+ })
+ }
+ })
+ }
+}
+
+// TestPlaywrightWASMAlgorithms checks the WASM challenges on the happy path,
+// where the worker source is served out of a blob: URL and the compiled
+// WebAssembly.Module is handed to each worker over postMessage.
+//
+// TestPlaywrightBlobWorkerBlockedByCSP only ever exercises the fallback, so
+// without this the blob path for these algorithms is untested.
+func TestPlaywrightWASMAlgorithms(t *testing.T) {
+ if os.Getenv("DONT_USE_NETWORK") != "" {
+ t.Skip("test requires network egress")
+ return
+ }
+
+ if os.Getenv("SKIP_INTEGRATION") != "" {
+ t.Skip("SKIP_INTEGRATION was set")
+ return
+ }
+
+ startPlaywright(t)
+
+ pw := setupPlaywright(t)
+
+ for _, algorithm := range []string{"sha256", "hashx", "argon2id"} {
+ t.Run(algorithm, func(t *testing.T) {
+ anubisURL := spawnAnubisWithPolicy(t, "testdata/wasm-"+algorithm+".yaml", "", "")
+
+ for _, typ := range []playwright.BrowserType{pw.Chromium, pw.Firefox, pw.WebKit} {
+ t.Run(typ.Name(), func(t *testing.T) {
+ mustSolveChallenge(t, typ, anubisURL)
+ })
+ }
+ })
+ }
+}
+
+// mustSolveChallenge drives a browser through whatever challenge the server at
+// anubisURL issues and fails the test unless the browser lands on the target.
+func mustSolveChallenge(t *testing.T, typ playwright.BrowserType, anubisURL string) {
+ t.Helper()
+
+ browser, err := typ.Connect(buildBrowserConnect(typ.Name()))
+ if err != nil {
+ t.Fatalf("could not connect to remote browser: %v", err)
+ }
+ defer browser.Close()
+
+ ctx, err := browser.NewContext(playwright.BrowserNewContextOptions{
+ AcceptDownloads: playwright.Bool(false),
+ ExtraHttpHeaders: map[string]string{
+ "X-Real-IP": placeholderIP,
+ },
+ UserAgent: playwright.String("Mozilla/5.0 (X11; Linux x86_64; rv:136.0) Gecko/20100101 Firefox/136.0"),
+ })
+ if err != nil {
+ t.Fatalf("could not create context: %v", err)
+ }
+ defer ctx.Close()
+
+ page, err := ctx.NewPage()
+ if err != nil {
+ t.Fatalf("could not create page: %v", err)
+ }
+ defer page.Close()
+
+ page.OnConsole(func(msg playwright.ConsoleMessage) {
+ t.Logf("console: %s", msg.Text())
+ })
+
+ if _, err := page.Goto(anubisURL, playwright.PageGotoOptions{
+ Timeout: playwright.Float(float64(playwrightMaxTime.Milliseconds())),
+ }); err != nil {
+ t.Fatalf("could not navigate to test server: %v", err)
+ }
+
+ if err := page.Locator("#anubis-test").WaitFor(playwright.LocatorWaitForOptions{
+ Timeout: playwright.Float(float64(challengeSolveTimeout.Milliseconds())),
+ }); err != nil {
+ t.Fatal(pwFail(t, page, "challenge did not complete: %v", err))
+ }
+}
+
+// challengeSolveTimeout is the maximum amount of time browsers get to solve
+// test challenges. By default this _should_ be good enough on the hardware
+// that CI runs on. If this ends up being a bad assumption, revise this
+// constant and document the tale of woe here.
+const challengeSolveTimeout = 30 * time.Second
+
+// spawnAnubisWithCSP starts Anubis with an additional inline middleware
+// that stamps every request with the given Content-Security-Policy, which
+// imitates the behaviour in TecharoHQ/anubis#1864 that caused workers to
+// be unable to spawn.
+//
+// If you set no csp value, no Content-Security-Policy is injected.
+func spawnAnubisWithCSP(t *testing.T, basePrefix, csp string) string {
+ t.Helper()
+
+ return spawnAnubisWithPolicy(t, "", basePrefix, csp)
+}
+
+// spawnAnubisWithPolicy is spawnAnubisWithCSP with control over which policy
+// file Anubis loads, so that tests can pin the challenge algorithm. An empty
+// policyFname loads the default policy, which issues "fast" challenges.
+func spawnAnubisWithPolicy(t *testing.T, policyFname, basePrefix, csp string) string {
+ t.Helper()
+
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Add("Content-Type", "text/html")
+ fmt.Fprintf(w, "%d", time.Now().Unix())
+ })
+
+ policy, err := libanubis.LoadPoliciesOrDefault(t.Context(), policyFname, anubis.DefaultDifficulty, "info", false)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // Bind loopback explicitly: binding every interface makes ts.URL the
+ // unspecified address (http://[::]:port), which Firefox refuses to navigate
+ // to with NS_ERROR_CONNECTION_REFUSED.
+ listener, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatalf("can't listen on random port: %v", err)
+ }
+
+ // XXX(Xe): I'd love to use httptest.NewServer for this, but we need to know
+ // the address of the target ahead of time.
+ addr := listener.Addr().(*net.TCPAddr)
+ host := "localhost"
+ port := strconv.Itoa(addr.Port)
+
+ s, err := libanubis.New(libanubis.Options{
+ Next: h,
+ Policy: policy,
+ ServeRobotsTXT: true,
+ Target: "http://" + host + ":" + port,
+ BasePrefix: basePrefix,
+ CookieExpiration: anubis.CookieDefaultExpirationTime,
+ })
+ if err != nil {
+ t.Fatalf("can't construct libanubis.Server: %v", err)
+ }
+
+ var handler http.Handler = s
+ if csp != "" {
+ inner := handler
+ handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Security-Policy", csp)
+ inner.ServeHTTP(w, r)
+ })
+ }
+
+ ts := &httptest.Server{
+ Listener: listener,
+ Config: &http.Server{Handler: handler},
+ }
+ ts.Start()
+ t.Log(ts.URL)
+
+ t.Cleanup(func() {
+ ts.Close()
+ })
+
+ return ts.URL
+}
diff --git a/internal/test/playwright_test.go b/internal/test/playwright_test.go
index 0cb72e98e..10886b3da 100644
--- a/internal/test/playwright_test.go
+++ b/internal/test/playwright_test.go
@@ -7,7 +7,7 @@
// Anubis must be configured to redirect to the server started by the test suite.
// The bind address and the Anubis server can be specified using the flags `-bind` and `-anubis` respectively.
//
-// Playwright must be started in server mode using `npx playwright@1.50.1 run-server --port 3000`.
+// Playwright must be started in server mode using `npx playwright@1.61.1 run-server --port 9001`.
// The version must match the minor used by the playwright-go package.
//
// On unsupported systems you may be able to use a container instead: https://playwright.dev/docs/docker#remote-connection
@@ -18,19 +18,17 @@ package test
import (
"flag"
"fmt"
- "net"
"net/http"
- "net/http/httptest"
"net/url"
"os"
"os/exec"
- "strconv"
+ "strings"
+ "syscall"
"testing"
"time"
"github.com/TecharoHQ/anubis"
- libanubis "github.com/TecharoHQ/anubis/lib"
- "github.com/playwright-community/playwright-go"
+ "github.com/mxschmitt/playwright-go"
)
var (
@@ -99,7 +97,7 @@ const (
actionChallenge action = "CHALLENGE"
placeholderIP = "fd11:5ee:bad:c0de::"
- playwrightVersion = "1.52.0"
+ playwrightVersion = "1.62.1"
)
type action string
@@ -158,20 +156,27 @@ func daemonize(t *testing.T, command string) {
cmd.Stdin = nil
cmd.Stderr = os.Stderr
cmd.Stdout = os.Stdout
+ // Run in its own process group so cleanup can reap the whole tree. Killing
+ // only sh leaves the server holding the test binary's stdout, which makes go
+ // test report "Test I/O incomplete" and fail even when every test passed.
+ cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
if err := cmd.Start(); err != nil {
t.Fatalf("can't daemonize command: %v", err)
}
t.Cleanup(func() {
- cmd.Process.Kill()
+ if err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL); err != nil {
+ t.Logf("can't kill process group of %d: %v", cmd.Process.Pid, err)
+ }
})
}
func startPlaywright(t *testing.T) {
t.Helper()
- if *playwrightRunner == "npx" {
+ switch *playwrightRunner {
+ case "npx":
doesCommandExist(t, "npx")
if os.Getenv("CI") == "true" {
@@ -181,7 +186,7 @@ func startPlaywright(t *testing.T) {
}
daemonize(t, fmt.Sprintf("npx --yes playwright@%s run-server --port %d", playwrightVersion, *playwrightPort))
- } else if *playwrightRunner == "docker" || *playwrightRunner == "podman" {
+ case "docker", "podman":
doesCommandExist(t, *playwrightRunner)
// docs: https://playwright.dev/docs/docker
@@ -190,9 +195,9 @@ func startPlaywright(t *testing.T) {
t.Cleanup(func() {
run(t, fmt.Sprintf("%s rm --force %s", *playwrightRunner, container))
})
- } else if *playwrightRunner == "none" {
+ case "none":
t.Log("not starting Playwright, assuming it is already running")
- } else {
+ default:
t.Skipf("unknown runner: %s, skipping", *playwrightRunner)
}
@@ -228,9 +233,7 @@ func TestPlaywrightBrowser(t *testing.T) {
for _, typ := range browsers {
t.Run(typ.Name()+"/warmup", func(t *testing.T) {
- browser, err := typ.Connect(buildBrowserConnect(typ.Name()), playwright.BrowserTypeConnectOptions{
- ExposeNetwork: playwright.String(""),
- })
+ browser, err := typ.Connect(buildBrowserConnect(typ.Name()))
if err != nil {
t.Fatalf("could not connect to remote browser: %v", err)
}
@@ -239,7 +242,7 @@ func TestPlaywrightBrowser(t *testing.T) {
ctx, err := browser.NewContext(playwright.BrowserNewContextOptions{
AcceptDownloads: playwright.Bool(false),
ExtraHttpHeaders: map[string]string{
- "X-Real-Ip": "127.0.0.1",
+ "X-Real-IP": "127.0.0.1",
},
UserAgent: playwright.String("Sephiroth"),
})
@@ -270,7 +273,7 @@ func TestPlaywrightBrowser(t *testing.T) {
var performedAction action
var err error
- for i := 0; i < 5; i++ {
+ for i := range 5 {
performedAction, err = executeTestCase(t, tc, typ, anubisURL)
if performedAction == tc.action {
break
@@ -316,9 +319,7 @@ func TestPlaywrightWithBasePrefix(t *testing.T) {
for _, typ := range browsers {
t.Run(typ.Name()+"/basePrefix", func(t *testing.T) {
- browser, err := typ.Connect(buildBrowserConnect(typ.Name()), playwright.BrowserTypeConnectOptions{
- ExposeNetwork: playwright.String(""),
- })
+ browser, err := typ.Connect(buildBrowserConnect(typ.Name()))
if err != nil {
t.Fatalf("could not connect to remote browser: %v", err)
}
@@ -327,7 +328,7 @@ func TestPlaywrightWithBasePrefix(t *testing.T) {
ctx, err := browser.NewContext(playwright.BrowserNewContextOptions{
AcceptDownloads: playwright.Bool(false),
ExtraHttpHeaders: map[string]string{
- "X-Real-Ip": "127.0.0.1",
+ "X-Real-IP": "127.0.0.1",
},
UserAgent: playwright.String("Mozilla/5.0 (X11; Linux x86_64; rv:136.0) Gecko/20100101 Firefox/136.0"),
})
@@ -400,9 +401,11 @@ func TestPlaywrightWithBasePrefix(t *testing.T) {
pwFail(t, page, "could not get cookies: %v", err)
}
+ // The cookie name has a suffix that is derived from the cookie
+ // settings, so match on the prefix only.
var found bool
for _, cookie := range cookies {
- if cookie.Name == anubis.CookieName {
+ if strings.HasPrefix(cookie.Name, anubis.CookieName+"-") {
found = true
if cookie.Path != basePrefix+"/" {
t.Errorf("cookie path is wrong, wanted %s, got: %s", basePrefix+"/", cookie.Path)
@@ -418,6 +421,12 @@ func TestPlaywrightWithBasePrefix(t *testing.T) {
}
}
+// buildBrowserConnect returns the URL to connect to for the named browser.
+//
+// Callers pass no BrowserTypeConnectOptions: playwright-go does not implement
+// the SocksSupport channel that exposeNetwork relies on, so requesting it makes
+// the server close the connection. The browser therefore has to share the
+// host's loopback, which both supported runners do.
func buildBrowserConnect(name string) string {
u, _ := url.Parse(*playwrightServer)
@@ -431,9 +440,7 @@ func buildBrowserConnect(name string) string {
func executeTestCase(t *testing.T, tc testCase, typ playwright.BrowserType, anubisURL string) (action, error) {
deadline, _ := t.Deadline()
- browser, err := typ.Connect(buildBrowserConnect(typ.Name()), playwright.BrowserTypeConnectOptions{
- ExposeNetwork: playwright.String(""),
- })
+ browser, err := typ.Connect(buildBrowserConnect(typ.Name()))
if err != nil {
return "", fmt.Errorf("could not connect to remote browser: %w", err)
}
@@ -442,7 +449,7 @@ func executeTestCase(t *testing.T, tc testCase, typ playwright.BrowserType, anub
ctx, err := browser.NewContext(playwright.BrowserNewContextOptions{
AcceptDownloads: playwright.Bool(false),
ExtraHttpHeaders: map[string]string{
- "X-Real-Ip": tc.realIP,
+ "X-Real-IP": tc.realIP,
},
UserAgent: playwright.String(tc.userAgent),
})
@@ -588,48 +595,5 @@ func spawnAnubis(t *testing.T) string {
}
func spawnAnubisWithOptions(t *testing.T, basePrefix string) string {
- t.Helper()
-
- h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.Header().Add("Content-Type", "text/html")
- fmt.Fprintf(w, "%d", time.Now().Unix())
- })
-
- policy, err := libanubis.LoadPoliciesOrDefault(t.Context(), "", anubis.DefaultDifficulty)
- if err != nil {
- t.Fatal(err)
- }
-
- listener, err := net.Listen("tcp", ":0")
- if err != nil {
- t.Fatalf("can't listen on random port: %v", err)
- }
-
- addr := listener.Addr().(*net.TCPAddr)
- host := "localhost"
- port := strconv.Itoa(addr.Port)
-
- s, err := libanubis.New(libanubis.Options{
- Next: h,
- Policy: policy,
- ServeRobotsTXT: true,
- Target: "http://" + host + ":" + port,
- BasePrefix: basePrefix,
- })
- if err != nil {
- t.Fatalf("can't construct libanubis.Server: %v", err)
- }
-
- ts := &httptest.Server{
- Listener: listener,
- Config: &http.Server{Handler: s},
- }
- ts.Start()
- t.Log(ts.URL)
-
- t.Cleanup(func() {
- ts.Close()
- })
-
- return ts.URL
+ return spawnAnubisWithCSP(t, basePrefix, "")
}
diff --git a/internal/test/testdata/wasm-argon2id.yaml b/internal/test/testdata/wasm-argon2id.yaml
new file mode 100644
index 000000000..c7df68c18
--- /dev/null
+++ b/internal/test/testdata/wasm-argon2id.yaml
@@ -0,0 +1,9 @@
+# Forces every client through the argon2id WASM challenge so that the browser
+# tests exercise the WASM path rather than the default "fast" challenge.
+bots:
+ - name: everyone
+ user_agent_regex: .
+ action: CHALLENGE
+ challenge:
+ algorithm: argon2id
+ difficulty: 2
diff --git a/internal/test/testdata/wasm-hashx.yaml b/internal/test/testdata/wasm-hashx.yaml
new file mode 100644
index 000000000..9a7564c45
--- /dev/null
+++ b/internal/test/testdata/wasm-hashx.yaml
@@ -0,0 +1,9 @@
+# Forces every client through the hashx WASM challenge so that the browser
+# tests exercise the WASM path rather than the default "fast" challenge.
+bots:
+ - name: everyone
+ user_agent_regex: .
+ action: CHALLENGE
+ challenge:
+ algorithm: hashx
+ difficulty: 2
diff --git a/internal/test/testdata/wasm-sha256.yaml b/internal/test/testdata/wasm-sha256.yaml
new file mode 100644
index 000000000..4184ba49c
--- /dev/null
+++ b/internal/test/testdata/wasm-sha256.yaml
@@ -0,0 +1,9 @@
+# Forces every client through the sha256 WASM challenge so that the browser
+# tests exercise the WASM path rather than the default "fast" challenge.
+bots:
+ - name: everyone
+ user_agent_regex: .
+ action: CHALLENGE
+ challenge:
+ algorithm: sha256
+ difficulty: 2
diff --git a/internal/unbreakdocker.go b/internal/unbreakdocker.go
index 8bf29fd36..2547c7cf6 100644
--- a/internal/unbreakdocker.go
+++ b/internal/unbreakdocker.go
@@ -17,6 +17,6 @@ func UnbreakDocker() {
// This is a horrifying monstrosity, but the part that scares me the most
// is the fact that it works.
if hostname, err := os.Hostname(); err == nil {
- exec.Command("docker", "network", "connect", "bridge", hostname).Run()
+ _ = exec.Command("docker", "network", "connect", "bridge", hostname).Run()
}
}
diff --git a/lib/anubis.go b/lib/anubis.go
index 6a44e1fcd..e4547bd1d 100644
--- a/lib/anubis.go
+++ b/lib/anubis.go
@@ -4,6 +4,7 @@ import (
"context"
"crypto/ed25519"
"crypto/rand"
+ "encoding/hex"
"encoding/json"
"errors"
"fmt"
@@ -11,6 +12,8 @@ import (
"net"
"net/http"
"net/url"
+ "slices"
+ "strconv"
"strings"
"time"
@@ -27,65 +30,114 @@ import (
"github.com/TecharoHQ/anubis/internal/dnsbl"
"github.com/TecharoHQ/anubis/internal/ogtags"
"github.com/TecharoHQ/anubis/lib/challenge"
+ "github.com/TecharoHQ/anubis/lib/challenge/extension"
+ "github.com/TecharoHQ/anubis/lib/config"
"github.com/TecharoHQ/anubis/lib/localization"
"github.com/TecharoHQ/anubis/lib/policy"
"github.com/TecharoHQ/anubis/lib/policy/checker"
- "github.com/TecharoHQ/anubis/lib/policy/config"
"github.com/TecharoHQ/anubis/lib/store"
+ iptoasnv1 "github.com/TecharoHQ/thoth-proto/gen/techaro/thoth/iptoasn/v1"
// challenge implementations
_ "github.com/TecharoHQ/anubis/lib/challenge/metarefresh"
_ "github.com/TecharoHQ/anubis/lib/challenge/preact"
_ "github.com/TecharoHQ/anubis/lib/challenge/proofofwork"
+ _ "github.com/TecharoHQ/anubis/lib/challenge/wasm"
+
+ // extension implementations
+ _ "github.com/TecharoHQ/anubis/lib/challenge/extension/css-load"
)
+type contextKey int
+
+const asnContextKey contextKey = iota
+
+type asnInfo struct {
+ ASN string
+ Description string
+}
+
+func asnFromContext(ctx context.Context) (string, string) {
+ if v, ok := ctx.Value(asnContextKey).(asnInfo); ok {
+ return v.ASN, v.Description
+ }
+ return "", ""
+}
+
var (
challengesIssued = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "anubis_challenges_issued",
Help: "The total number of challenges issued",
- }, []string{"method"})
+ }, []string{"method", "asn", "asn_description"})
challengesValidated = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "anubis_challenges_validated",
Help: "The total number of challenges validated",
- }, []string{"method"})
+ }, []string{"method", "asn", "asn_description"})
droneBLHits = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "anubis_dronebl_hits",
Help: "The total number of hits from DroneBL",
- }, []string{"status"})
+ }, []string{"status", "asn", "asn_description"})
failedValidations = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "anubis_failed_validations",
Help: "The total number of failed validations",
- }, []string{"method"})
+ }, []string{"method", "asn", "asn_description"})
requestsProxied = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "anubis_proxied_requests_total",
Help: "Number of requests proxied through Anubis to upstream targets",
- }, []string{"host"})
+ }, []string{"host", "asn", "asn_description"})
+
+ requestsByASN = promauto.NewCounterVec(prometheus.CounterOpts{
+ Name: "anubis_requests_by_asn_total",
+ Help: "Number of requests by ASN",
+ }, []string{"asn", "asn_description"})
)
type Server struct {
next http.Handler
+ store store.Interface
mux *http.ServeMux
policy *policy.ParsedConfig
OGTags *ogtags.OGTagCache
+ logger *slog.Logger
+ opts Options
ed25519Priv ed25519.PrivateKey
hs512Secret []byte
- opts Options
- store store.Interface
- logger *slog.Logger
+}
+
+func (s *Server) getRequestLogger(r *http.Request) (*slog.Logger, *http.Request) {
+ lg := internal.GetRequestLogger(s.logger, r)
+
+ if s.policy.LogASN && s.policy.ThothClient != nil {
+ ctx, cancel := context.WithTimeout(r.Context(), 500*time.Millisecond)
+ defer cancel()
+
+ ip := r.Header.Get("X-Real-IP")
+ if info, err := s.policy.ThothClient.IPToASN.Lookup(ctx, &iptoasnv1.LookupRequest{IpAddress: ip}); err == nil && info.GetAnnounced() {
+ asn := strconv.FormatUint(uint64(info.GetAsNumber()), 10)
+ lg = lg.With("asn", info.GetAsNumber(), "asn_description", info.GetDescription())
+ requestsByASN.WithLabelValues(asn, info.GetDescription()).Inc()
+ r = r.WithContext(context.WithValue(r.Context(), asnContextKey, asnInfo{
+ ASN: asn,
+ Description: info.GetDescription(),
+ }))
+ }
+ }
+
+ return lg, r
}
func (s *Server) getTokenKeyfunc() jwt.Keyfunc {
// return ED25519 key if HS512 is not set
if len(s.hs512Secret) == 0 {
- return func(token *jwt.Token) (interface{}, error) {
+ return func(token *jwt.Token) (any, error) {
return s.ed25519Priv.Public().(ed25519.PublicKey), nil
}
} else {
- return func(token *jwt.Token) (interface{}, error) {
+ return func(token *jwt.Token) (any, error) {
return s.hs512Secret, nil
}
}
@@ -102,14 +154,22 @@ func (s *Server) getChallenge(r *http.Request) (*challenge.Challenge, error) {
func (s *Server) issueChallenge(ctx context.Context, r *http.Request, lg *slog.Logger, cr policy.CheckResult, rule *policy.Bot) (*challenge.Challenge, error) {
if cr.Rule != config.RuleChallenge {
- slog.Error("this should be impossible, asked to issue a challenge but the rule is not a challenge rule", "cr", cr, "rule", rule)
+ slog.ErrorContext(ctx, "this should be impossible, asked to issue a challenge but the rule is not a challenge rule", "cr", cr, "rule", rule)
//return nil, errors.New("[unexpected] this codepath should be impossible, asked to issue a challenge for a non-challenge rule")
}
+ if rule.Challenge == nil {
+ rule.Challenge = &config.ChallengeRules{
+ Difficulty: s.policy.DefaultDifficulty,
+ Algorithm: config.DefaultAlgorithm,
+ }
+ }
+
id, err := uuid.NewV7()
if err != nil {
return nil, err
}
+ idStr := id.String()
var randomData = make([]byte, 64)
if _, err := rand.Read(randomData); err != nil {
@@ -117,26 +177,68 @@ func (s *Server) issueChallenge(ctx context.Context, r *http.Request, lg *slog.L
}
chall := challenge.Challenge{
- ID: id.String(),
- Method: rule.Challenge.Algorithm,
- RandomData: fmt.Sprintf("%x", randomData),
- IssuedAt: time.Now(),
+ ID: idStr,
+ Method: rule.Challenge.Algorithm,
+ RandomData: hex.EncodeToString(randomData),
+ IssuedAt: time.Now(),
+ Difficulty: rule.Challenge.Difficulty,
+ PolicyRuleHash: rule.Hash(),
+ Extensions: slices.Clone(rule.Challenge.Extensions),
Metadata: map[string]string{
"User-Agent": r.Header.Get("User-Agent"),
- "X-Real-Ip": r.Header.Get("X-Real-Ip"),
+ "X-Real-IP": r.Header.Get("X-Real-IP"),
+ "Referer": r.Header.Get("Referer"),
},
}
j := store.JSON[challenge.Challenge]{Underlying: s.store}
- if err := j.Set(ctx, "challenge:"+id.String(), chall, 30*time.Minute); err != nil {
+ if err := j.Set(ctx, "challenge:"+idStr, chall, 30*time.Minute); err != nil {
return nil, err
}
- lg.Info("new challenge issued", "challenge", id.String())
+ lg.InfoContext(ctx, "new challenge issued", "challenge", idStr, "weight", cr.Weight)
return &chall, err
}
+func (s *Server) hydrateChallengeRule(rule *policy.Bot, chall *challenge.Challenge, lg *slog.Logger) *policy.Bot {
+ if chall == nil {
+ return rule
+ }
+
+ if rule == nil {
+ rule = &policy.Bot{
+ Rules: &checker.List{},
+ }
+ }
+
+ if chall.Difficulty == 0 {
+ // fall back to whatever the policy currently says or the global default
+ if rule.Challenge != nil && rule.Challenge.Difficulty != 0 {
+ chall.Difficulty = rule.Challenge.Difficulty
+ } else {
+ chall.Difficulty = s.policy.DefaultDifficulty
+ }
+ }
+
+ if rule.Challenge == nil {
+ lg.Warn("rule missing challenge configuration; using stored challenge metadata", "rule", rule.Name)
+ rule.Challenge = &config.ChallengeRules{}
+ }
+
+ if rule.Challenge.Difficulty == 0 {
+ rule.Challenge.Difficulty = chall.Difficulty
+ }
+ if rule.Challenge.ReportAs != 0 {
+ s.logger.Warn("[DEPRECATION] the report_as field in this bot rule is deprecated, see https://github.com/TecharoHQ/anubis/issues/1310 for more information", "bot_name", rule.Name, "difficulty", rule.Challenge.Difficulty, "report_as", rule.Challenge.ReportAs)
+ }
+ if rule.Challenge.Algorithm == "" {
+ rule.Challenge.Algorithm = chall.Method
+ }
+
+ return rule
+}
+
func (s *Server) maybeReverseProxyHttpStatusOnly(w http.ResponseWriter, r *http.Request) {
s.maybeReverseProxy(w, r, true)
}
@@ -145,14 +247,78 @@ func (s *Server) maybeReverseProxyOrPage(w http.ResponseWriter, r *http.Request)
s.maybeReverseProxy(w, r, false)
}
-func (s *Server) maybeReverseProxy(w http.ResponseWriter, r *http.Request, httpStatusOnly bool) {
- lg := internal.GetRequestLogger(s.logger, r)
+const (
+ downstreamRiskRuleHeader = "X-Anubis-Rule"
+ downstreamRiskActionHeader = "X-Anubis-Action"
+ downstreamRiskStatusHeader = "X-Anubis-Status"
+
+ // originalRefererCookieName carries the Referer header seen on the request that
+ // triggered a challenge across the redirect back to the original URL. Browsers
+ // don't send it on that follow-up navigation since it's a same-site redirect, so
+ // without this relay upstream analytics see the challenge page as the referrer
+ // instead of the site the visitor actually came from. See
+ // https://github.com/TecharoHQ/anubis/issues/1596
+ originalRefererCookieName = "techaro.lol-anubis-original-referer"
+)
- if val, _ := s.store.Get(r.Context(), fmt.Sprintf("ogtags:allow:%s%s", r.Host, r.URL.String())); val != nil {
- lg.Debug("serving opengraph tag asset")
- s.ServeHTTPNext(w, r)
+func isDownstreamRiskHeader(name string) bool {
+ switch http.CanonicalHeaderKey(name) {
+ case downstreamRiskRuleHeader, downstreamRiskActionHeader, downstreamRiskStatusHeader:
+ return true
+ }
+ return false
+}
+
+func removeDownstreamRiskConnectionTokens(header http.Header) {
+ connectionValues := header.Values("Connection")
+ header.Del("Connection")
+ for _, value := range connectionValues {
+ var remaining []string
+ for token := range strings.SplitSeq(value, ",") {
+ token = strings.TrimSpace(token)
+ if token == "" || isDownstreamRiskHeader(token) {
+ continue
+ }
+ remaining = append(remaining, token)
+ }
+ if len(remaining) != 0 {
+ header.Add("Connection", strings.Join(remaining, ", "))
+ }
+ }
+}
+
+func clearDownstreamRiskHeaders(header http.Header) {
+ // Remove client-supplied values for headers owned by Anubis.
+ header.Del(downstreamRiskRuleHeader)
+ header.Del(downstreamRiskActionHeader)
+ header.Del(downstreamRiskStatusHeader)
+
+ // Remove Connection options that could strip those headers downstream.
+ removeDownstreamRiskConnectionTokens(header)
+}
+
+func setDownstreamRiskHeaders(header http.Header, cr policy.CheckResult, status string) {
+ removeDownstreamRiskConnectionTokens(header)
+ header.Set(downstreamRiskRuleHeader, cr.Name)
+ header.Set(downstreamRiskActionHeader, string(cr.Rule))
+ if status == "" {
+ header.Del(downstreamRiskStatusHeader)
return
}
+ header.Set(downstreamRiskStatusHeader, status)
+}
+
+func (s *Server) maybeReverseProxy(w http.ResponseWriter, r *http.Request, httpStatusOnly bool) {
+ lg, r := s.getRequestLogger(r)
+
+ if s.opts.OpenGraph.Enabled {
+ if val, _ := s.store.Get(r.Context(), "ogtags:allow:"+r.Host+r.URL.String()); val != nil {
+ clearDownstreamRiskHeaders(r.Header)
+ lg.DebugContext(r.Context(), "serving opengraph tag asset")
+ s.ServeHTTPNext(w, r)
+ return
+ }
+ }
// Adjust cookie path if base prefix is not empty
cookiePath := "/"
@@ -162,18 +328,19 @@ func (s *Server) maybeReverseProxy(w http.ResponseWriter, r *http.Request, httpS
cr, rule, err := s.check(r, lg)
if err != nil {
- lg.Error("check failed", "err", err)
+ lg.ErrorContext(r.Context(), "check failed", "err", err)
localizer := localization.GetLocalizer(r)
- s.respondWithError(w, r, fmt.Sprintf("%s \"maybeReverseProxy\"", localizer.T("internal_server_error")))
+ s.respondWithError(w, r, fmt.Sprintf("%s \"maybeReverseProxy\"", localizer.T("internal_server_error")), makeCode(err))
return
}
- r.Header.Add("X-Anubis-Rule", cr.Name)
- r.Header.Add("X-Anubis-Action", string(cr.Rule))
lg = lg.With("check_result", cr)
- policy.Applications.WithLabelValues(cr.Name, string(cr.Rule)).Add(1)
+ {
+ asn, asnDesc := asnFromContext(r.Context())
+ policy.Applications.WithLabelValues(cr.Name, string(cr.Rule), asn, asnDesc).Add(1)
+ }
- ip := r.Header.Get("X-Real-Ip")
+ ip := r.Header.Get("X-Real-IP")
if s.handleDNSBL(w, r, ip, lg) {
return
@@ -183,23 +350,25 @@ func (s *Server) maybeReverseProxy(w http.ResponseWriter, r *http.Request, httpS
return
}
- ckie, err := r.Cookie(anubis.CookieName)
+ ckie, err := s.getCookie(r, anubis.CookieName)
if err != nil {
- lg.Debug("cookie not found", "path", r.URL.Path)
- s.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host})
+ // Don't clear the cookie, there is none to clear. This response can land
+ // after a concurrent pass-challenge issued a valid one and would delete
+ // it. See https://github.com/TecharoHQ/anubis/issues/1314
+ lg.DebugContext(r.Context(), "cookie not found", "path", r.URL.Path)
s.RenderIndex(w, r, cr, rule, httpStatusOnly)
return
}
if err := ckie.Valid(); err != nil {
- lg.Debug("cookie is invalid", "err", err)
+ lg.DebugContext(r.Context(), "cookie is invalid", "err", err)
s.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host})
s.RenderIndex(w, r, cr, rule, httpStatusOnly)
return
}
if time.Now().After(ckie.Expires) && !ckie.Expires.IsZero() {
- lg.Debug("cookie expired", "path", r.URL.Path)
+ lg.DebugContext(r.Context(), "cookie expired", "path", r.URL.Path)
s.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host})
s.RenderIndex(w, r, cr, rule, httpStatusOnly)
return
@@ -208,7 +377,7 @@ func (s *Server) maybeReverseProxy(w http.ResponseWriter, r *http.Request, httpS
token, err := jwt.ParseWithClaims(ckie.Value, jwt.MapClaims{}, s.getTokenKeyfunc(), jwt.WithExpirationRequired(), jwt.WithStrictDecoding())
if err != nil || !token.Valid {
- lg.Debug("invalid token", "path", r.URL.Path, "err", err)
+ lg.DebugContext(r.Context(), "invalid token", "path", r.URL.Path, "err", err)
s.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host})
s.RenderIndex(w, r, cr, rule, httpStatusOnly)
return
@@ -216,7 +385,7 @@ func (s *Server) maybeReverseProxy(w http.ResponseWriter, r *http.Request, httpS
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
- lg.Debug("invalid token claims type", "path", r.URL.Path)
+ lg.DebugContext(r.Context(), "invalid token claims type", "path", r.URL.Path)
s.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host})
s.RenderIndex(w, r, cr, rule, httpStatusOnly)
return
@@ -224,30 +393,49 @@ func (s *Server) maybeReverseProxy(w http.ResponseWriter, r *http.Request, httpS
policyRule, ok := claims["policyRule"].(string)
if !ok {
- lg.Debug("policyRule claim is not a string")
+ lg.DebugContext(r.Context(), "policyRule claim is not a string")
s.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host})
s.RenderIndex(w, r, cr, rule, httpStatusOnly)
return
}
if policyRule != rule.Hash() {
- lg.Debug("user originally passed with a different rule, issuing new challenge", "old", policyRule, "new", rule.Name)
+ lg.DebugContext(r.Context(), "user originally passed with a different rule, issuing new challenge", "old", policyRule, "new", rule.Name)
s.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host})
s.RenderIndex(w, r, cr, rule, httpStatusOnly)
return
}
if s.opts.JWTRestrictionHeader != "" && claims["restriction"] != internal.SHA256sum(r.Header.Get(s.opts.JWTRestrictionHeader)) {
- lg.Debug("JWT restriction header is invalid")
+ lg.DebugContext(r.Context(), "JWT restriction header is invalid")
s.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host})
s.RenderIndex(w, r, cr, rule, httpStatusOnly)
return
}
- r.Header.Add("X-Anubis-Status", "PASS")
+ s.restoreOriginalReferer(w, r, cookiePath)
+
+ setDownstreamRiskHeaders(r.Header, cr, "PASS")
s.ServeHTTPNext(w, r)
}
+// restoreOriginalReferer looks for the one-shot cookie set by PassChallenge and, if
+// present, overwrites the request's Referer header with the value it carries before
+// the request is proxied upstream. The cookie only survives the single redirect from
+// PassChallenge back to the original URL, so this only ever fires for that one request.
+func (s *Server) restoreOriginalReferer(w http.ResponseWriter, r *http.Request, cookiePath string) {
+ ckie, err := s.getCookie(r, originalRefererCookieName)
+ if err != nil || ckie.Value == "" {
+ return
+ }
+
+ s.ClearCookie(w, CookieOpts{Name: originalRefererCookieName, Path: cookiePath, Host: r.Host})
+
+ if referer, err := url.QueryUnescape(ckie.Value); err == nil {
+ r.Header.Set("Referer", referer)
+ }
+}
+
func (s *Server) checkRules(w http.ResponseWriter, r *http.Request, cr policy.CheckResult, lg *slog.Logger, rule *policy.Bot) bool {
// Adjust cookie path if base prefix is not empty
cookiePath := "/"
@@ -259,32 +447,33 @@ func (s *Server) checkRules(w http.ResponseWriter, r *http.Request, cr policy.Ch
switch cr.Rule {
case config.RuleAllow:
- lg.Debug("allowing traffic to origin (explicit)")
+ lg.DebugContext(r.Context(), "allowing traffic to origin (explicit)")
+ setDownstreamRiskHeaders(r.Header, cr, "")
s.ServeHTTPNext(w, r)
return true
case config.RuleDeny:
s.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host})
- lg.Info("explicit deny")
+ lg.InfoContext(r.Context(), "explicit deny")
if rule == nil {
- lg.Error("rule is nil, cannot calculate checksum")
- s.respondWithError(w, r, fmt.Sprintf("%s \"maybeReverseProxy.RuleDeny\"", localizer.T("internal_server_error")))
+ lg.ErrorContext(r.Context(), "rule is nil, cannot calculate checksum")
+ s.respondWithError(w, r, fmt.Sprintf("%s \"maybeReverseProxy.RuleDeny\"", localizer.T("internal_server_error")), makeCode(ErrActualAnubisBug))
return true
}
hash := rule.Hash()
- lg.Debug("rule hash", "hash", hash)
- s.respondWithStatus(w, r, fmt.Sprintf("%s %s", localizer.T("access_denied"), hash), s.policy.StatusCodes.Deny)
+ lg.DebugContext(r.Context(), "rule hash", "hash", hash)
+ s.respondWithStatus(w, r, fmt.Sprintf("%s %s", localizer.T("access_denied"), hash), "", s.policy.StatusCodes.Deny)
return true
case config.RuleChallenge:
- lg.Debug("challenge requested")
+ lg.DebugContext(r.Context(), "challenge requested")
case config.RuleBenchmark:
- lg.Debug("serving benchmark page")
+ lg.DebugContext(r.Context(), "serving benchmark page")
s.RenderBench(w, r)
return true
default:
s.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host})
- lg.Error("CONFIG ERROR: unknown rule", "rule", cr.Rule)
- s.respondWithError(w, r, fmt.Sprintf("%s \"maybeReverseProxy.Rules\"", localizer.T("internal_server_error")))
+ lg.ErrorContext(r.Context(), "CONFIG ERROR: unknown rule", "rule", cr.Rule)
+ s.respondWithError(w, r, fmt.Sprintf("%s \"maybeReverseProxy.Rules\"", localizer.T("internal_server_error")), makeCode(ErrActualAnubisBug))
return true
}
return false
@@ -295,23 +484,24 @@ func (s *Server) handleDNSBL(w http.ResponseWriter, r *http.Request, ip string,
if s.policy.DNSBL && ip != "" {
resp, err := db.Get(r.Context(), ip)
if err != nil {
- lg.Debug("looking up ip in dnsbl")
+ lg.DebugContext(r.Context(), "looking up ip in dnsbl")
resp, err := dnsbl.Lookup(ip)
if err != nil {
- lg.Error("can't look up ip in dnsbl", "err", err)
+ lg.ErrorContext(r.Context(), "can't look up ip in dnsbl", "err", err)
}
- db.Set(r.Context(), ip, resp, 24*time.Hour)
- droneBLHits.WithLabelValues(resp.String()).Inc()
+ _ = db.Set(r.Context(), ip, resp, 24*time.Hour) // worst case we do the dns lookup again
+ asn, asnDesc := asnFromContext(r.Context())
+ droneBLHits.WithLabelValues(resp.String(), asn, asnDesc).Inc()
}
if resp != dnsbl.AllGood {
- lg.Info("DNSBL hit", "status", resp.String())
+ lg.InfoContext(r.Context(), "DNSBL hit", "status", resp.String())
localizer := localization.GetLocalizer(r)
s.respondWithStatus(w, r, fmt.Sprintf("%s: %s, %s https://dronebl.org/lookup?ip=%s",
localizer.T("dronebl_entry"),
resp.String(),
localizer.T("see_dronebl_lookup"),
- ip), s.policy.StatusCodes.Deny)
+ ip), "", s.policy.StatusCodes.Deny)
return true
}
}
@@ -319,19 +509,21 @@ func (s *Server) handleDNSBL(w http.ResponseWriter, r *http.Request, ip string,
}
func (s *Server) MakeChallenge(w http.ResponseWriter, r *http.Request) {
- lg := internal.GetRequestLogger(s.logger, r)
+ lg, r := s.getRequestLogger(r)
localizer := localization.GetLocalizer(r)
redir := r.FormValue("redir")
if redir == "" {
w.WriteHeader(http.StatusBadRequest)
encoder := json.NewEncoder(w)
- lg.Error("invalid invocation of MakeChallenge", "redir", redir)
- encoder.Encode(struct {
+ lg.ErrorContext(r.Context(), "invalid invocation of MakeChallenge", "redir", redir)
+ if err := encoder.Encode(struct {
Error string `json:"error"`
}{
Error: localizer.T("invalid_invocation"),
- })
+ }); err != nil {
+ s.logger.DebugContext(r.Context(), "can't write invalid invocation error to client, did they disconnect?", "err", err)
+ }
return
}
@@ -340,7 +532,7 @@ func (s *Server) MakeChallenge(w http.ResponseWriter, r *http.Request) {
encoder := json.NewEncoder(w)
cr, rule, err := s.check(r, lg)
if err != nil {
- lg.Error("check failed", "err", err)
+ lg.ErrorContext(r.Context(), "check failed", "err", err)
w.WriteHeader(http.StatusInternalServerError)
err := encoder.Encode(struct {
Error string `json:"error"`
@@ -348,7 +540,7 @@ func (s *Server) MakeChallenge(w http.ResponseWriter, r *http.Request) {
Error: fmt.Sprintf("%s \"makeChallenge\"", localizer.T("internal_server_error")),
})
if err != nil {
- lg.Error("failed to encode error response", "err", err)
+ lg.ErrorContext(r.Context(), "failed to encode error response", "err", err)
w.WriteHeader(http.StatusInternalServerError)
}
return
@@ -357,7 +549,7 @@ func (s *Server) MakeChallenge(w http.ResponseWriter, r *http.Request) {
chall, err := s.issueChallenge(r.Context(), r, lg, cr, rule)
if err != nil {
- lg.Error("failed to fetch or issue challenge", "err", err)
+ lg.ErrorContext(r.Context(), "failed to fetch or issue challenge", "err", err)
w.WriteHeader(http.StatusInternalServerError)
err := encoder.Encode(struct {
Error string `json:"error"`
@@ -365,7 +557,7 @@ func (s *Server) MakeChallenge(w http.ResponseWriter, r *http.Request) {
Error: fmt.Sprintf("%s \"makeChallenge\"", localizer.T("internal_server_error")),
})
if err != nil {
- lg.Error("failed to encode error response", "err", err)
+ lg.ErrorContext(r.Context(), "failed to encode error response", "err", err)
w.WriteHeader(http.StatusInternalServerError)
}
return
@@ -383,32 +575,36 @@ func (s *Server) MakeChallenge(w http.ResponseWriter, r *http.Request) {
ID: chall.ID,
})
if err != nil {
- lg.Error("failed to encode challenge", "err", err)
+ lg.ErrorContext(r.Context(), "failed to encode challenge", "err", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
- lg.Debug("made challenge", "challenge", chall, "rules", rule.Challenge, "cr", cr)
- challengesIssued.WithLabelValues("api").Inc()
+ lg.DebugContext(r.Context(), "made challenge", "challenge", chall, "rules", rule.Challenge, "cr", cr)
+ {
+ asn, asnDesc := asnFromContext(r.Context())
+ challengesIssued.WithLabelValues("api", asn, asnDesc).Inc()
+ }
+}
+
+func (s *Server) validateExtension(name string, r *http.Request, lg *slog.Logger, in *challenge.ValidateInput) error {
+ ext, ok := extension.Get(name)
+ if !ok {
+ return fmt.Errorf("%w: %q", ErrUnknownChallengeExtension, name)
+ }
+ return ext.Validate(r, lg, in)
}
func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) {
- lg := internal.GetRequestLogger(s.logger, r)
+ lg, r := s.getRequestLogger(r)
localizer := localization.GetLocalizer(r)
redir := r.FormValue("redir")
- redirURL, err := url.ParseRequestURI(redir)
- if err != nil {
- lg.Error("invalid redirect", "err", err)
- s.respondWithStatus(w, r, localizer.T("invalid_redirect"), http.StatusBadRequest)
- return
+ redirURL, err := s.validateRedirect(redir)
+ if err == nil && (redir == "" || (redirURL.Scheme == "" && !strings.HasPrefix(redir, "/"))) {
+ err = ErrInvalidRedirect
}
-
- switch redirURL.Scheme {
- case "", "http", "https":
- // allowed
- default:
- lg.Error("XSS attempt blocked, invalid redirect scheme", "scheme", redirURL.Scheme)
- s.respondWithStatus(w, r, localizer.T("invalid_redirect"), http.StatusBadRequest)
+ if err != nil {
+ s.rejectRedirect(w, r, err)
return
}
@@ -418,53 +614,44 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) {
cookiePath = strings.TrimSuffix(anubis.BasePrefix, "/") + "/"
}
- if _, err := r.Cookie(anubis.TestCookieName); errors.Is(err, http.ErrNoCookie) {
+ if _, err := s.getCookie(r, anubis.TestCookieName); errors.Is(err, http.ErrNoCookie) {
s.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host})
s.ClearCookie(w, CookieOpts{Name: anubis.TestCookieName, Host: r.Host})
- lg.Warn("user has cookies disabled, this is not an anubis bug")
- s.respondWithError(w, r, localizer.T("cookies_disabled"))
+ lg.WarnContext(r.Context(), "user has cookies disabled, this is not an anubis bug")
+ s.respondWithError(w, r, localizer.T("cookies_disabled"), "")
return
}
// used by the path checker rule
r.URL = redirURL
- urlParsed, err := r.URL.Parse(redir)
- if err != nil {
- s.respondWithError(w, r, localizer.T("redirect_not_parseable"))
- return
- }
- if (len(urlParsed.Host) > 0 && len(s.opts.RedirectDomains) != 0 && !matchRedirectDomain(s.opts.RedirectDomains, urlParsed.Host)) || urlParsed.Host != r.URL.Host {
- lg.Debug("domain not allowed", "domain", urlParsed.Host)
- s.respondWithError(w, r, localizer.T("redirect_domain_not_allowed"))
- return
- }
-
cr, rule, err := s.check(r, lg)
if err != nil {
- lg.Error("check failed", "err", err)
- s.respondWithError(w, r, fmt.Sprintf("%s \"passChallenge\"", localizer.T("internal_server_error")))
+ lg.ErrorContext(r.Context(), "check failed", "err", err)
+ s.respondWithError(w, r, fmt.Sprintf("%s \"passChallenge\"", localizer.T("internal_server_error")), makeCode(err))
return
}
lg = lg.With("check_result", cr)
chall, err := s.getChallenge(r)
if err != nil {
- lg.Error("getChallenge failed", "err", err)
- s.respondWithError(w, r, fmt.Sprintf("%s: %s", localizer.T("internal_server_error"), rule.Challenge.Algorithm))
+ lg.ErrorContext(r.Context(), "getChallenge failed", "err", err)
+ s.respondWithError(w, r, fmt.Sprintf("%s: %s", localizer.T("internal_server_error"), "getChallenge failed"), makeCode(err))
return
}
if chall.Spent {
- lg.Error("double spend prevented", "reason", "double_spend")
- s.respondWithError(w, r, fmt.Sprintf("%s: %s", localizer.T("internal_server_error"), "double_spend"))
+ lg.ErrorContext(r.Context(), "double spend prevented", "reason", "double_spend")
+ s.respondWithError(w, r, fmt.Sprintf("%s: %s", localizer.T("internal_server_error"), "double_spend"), "")
return
}
+ rule = s.hydrateChallengeRule(rule, chall, lg)
+
impl, ok := challenge.Get(chall.Method)
if !ok {
- lg.Error("check failed", "err", err)
- s.respondWithError(w, r, fmt.Sprintf("%s: %s", localizer.T("internal_server_error"), rule.Challenge.Algorithm))
+ lg.ErrorContext(r.Context(), "check failed", "err", err)
+ s.respondWithError(w, r, fmt.Sprintf("%s: %s", localizer.T("internal_server_error"), rule.Challenge.Algorithm), makeCode(ErrActualAnubisBug))
return
}
@@ -477,26 +664,46 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) {
}
if err := impl.Validate(r, lg, in); err != nil {
- failedValidations.WithLabelValues(rule.Challenge.Algorithm).Inc()
+ asn, asnDesc := asnFromContext(r.Context())
+ failedValidations.WithLabelValues(rule.Challenge.Algorithm, asn, asnDesc).Inc()
var cerr *challenge.Error
s.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host})
- lg.Debug("challenge validate call failed", "err", err)
+ lg.DebugContext(r.Context(), "challenge validate call failed", "err", err)
switch {
case errors.As(err, &cerr):
switch {
case errors.Is(err, challenge.ErrFailed):
- lg.Error("challenge failed", "err", err)
- s.respondWithStatus(w, r, cerr.PublicReason, cerr.StatusCode)
+ lg.ErrorContext(r.Context(), "challenge failed", "err", err)
+ s.respondWithStatus(w, r, cerr.PublicReason, makeCode(err), cerr.StatusCode)
return
case errors.Is(err, challenge.ErrInvalidFormat), errors.Is(err, challenge.ErrMissingField):
- lg.Error("invalid challenge format", "err", err)
- s.respondWithError(w, r, cerr.PublicReason)
+ lg.ErrorContext(r.Context(), "invalid challenge format", "err", err)
+ s.respondWithError(w, r, cerr.PublicReason, makeCode(err))
return
}
}
}
+ for _, name := range chall.Extensions {
+ if err := s.validateExtension(name, r, lg, in); err != nil {
+ asn, asnDesc := asnFromContext(r.Context())
+ failedValidations.WithLabelValues("extension/"+name, asn, asnDesc).Inc()
+ s.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host})
+ lg.ErrorContext(r.Context(), "challenge extension failed", "extension", name, "err", err)
+
+ var cerr *challenge.Error
+ if errors.As(err, &cerr) {
+ s.respondWithStatus(w, r, cerr.PublicReason, makeCode(err), cerr.StatusCode)
+ } else {
+ s.respondWithError(w, r, localizer.T("internal_server_error"), makeCode(err))
+ }
+ return
+ }
+ }
+
+ lg.InfoContext(r.Context(), "challenge accepted")
+
// generate JWT cookie
var tokenString string
@@ -509,9 +716,9 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) {
}
if s.opts.JWTRestrictionHeader != "" {
if r.Header.Get(s.opts.JWTRestrictionHeader) == "" {
- lg.Error("JWTRestrictionHeader is set in config but not found in request, please check your reverse proxy config.")
+ lg.ErrorContext(r.Context(), "JWTRestrictionHeader is set in config but not found in request, please check your reverse proxy config.")
s.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host})
- s.respondWithError(w, r, "failed to sign JWT")
+ s.respondWithError(w, r, "failed to sign JWT", makeCode(err))
return
} else {
claims["restriction"] = internal.SHA256sum(r.Header.Get(s.opts.JWTRestrictionHeader))
@@ -523,23 +730,83 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) {
tokenString, err = s.signJWT(claims)
if err != nil {
- lg.Error("failed to sign JWT", "err", err)
+ lg.ErrorContext(r.Context(), "failed to sign JWT", "err", err)
s.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host})
- s.respondWithError(w, r, localizer.T("failed_to_sign_jwt"))
+ s.respondWithError(w, r, localizer.T("failed_to_sign_jwt"), makeCode(err))
return
}
s.SetCookie(w, CookieOpts{Path: cookiePath, Host: r.Host, Value: tokenString})
+ if origReferer := chall.Metadata["Referer"]; origReferer != "" {
+ s.SetCookie(w, CookieOpts{
+ Path: cookiePath,
+ Host: r.Host,
+ Name: originalRefererCookieName,
+ Value: url.QueryEscape(origReferer),
+ Expiry: 1 * time.Minute,
+ })
+ }
+
chall.Spent = true
j := store.JSON[challenge.Challenge]{Underlying: s.store}
if err := j.Set(r.Context(), "challenge:"+chall.ID, *chall, 30*time.Minute); err != nil {
- lg.Debug("can't update information about challenge", "err", err)
+ lg.DebugContext(r.Context(), "can't update information about challenge", "err", err)
}
- challengesValidated.WithLabelValues(rule.Challenge.Algorithm).Inc()
- lg.Debug("challenge passed, redirecting to app")
- http.Redirect(w, r, redir, http.StatusFound)
+ {
+ asn, asnDesc := asnFromContext(r.Context())
+ challengesValidated.WithLabelValues(rule.Challenge.Algorithm, asn, asnDesc).Inc()
+ }
+ lg.DebugContext(r.Context(), "challenge passed, redirecting to app")
+ http.Redirect(w, r, s.withRefererAttributionQuery(redir, chall.Metadata["Referer"], r.Host), http.StatusFound)
+}
+
+// refererAttributionQueryParams lists query parameters that already carry
+// campaign or referrer attribution. When the redirect target has any of these,
+// withRefererAttributionQuery leaves it alone rather than overwrite genuine
+// campaign data.
+var refererAttributionQueryParams = []string{
+ "ref", "source",
+ "utm_source", "utm_medium", "utm_campaign", "utm_content", "utm_term",
+ "gclid", "msclkid",
+}
+
+// withRefererAttributionQuery appends utm_source/utm_medium query parameters
+// derived from the original external Referer to redir, so client-side analytics
+// tools that fall back to query parameters when document.referrer is unusable
+// (e.g. Plausible, GA4) can still attribute the visit. This exists because
+// document.referrer itself cannot be fixed here: it reflects the browser's
+// actual navigation history -- the challenge page -- and neither Anubis nor any
+// page script can override it. Opt-in via PreserveRefererQueryParam since it
+// changes the URL the visitor lands on. See
+// https://github.com/TecharoHQ/anubis/issues/1596
+func (s *Server) withRefererAttributionQuery(redir, origReferer, host string) string {
+ if !s.opts.PreserveRefererQueryParam || origReferer == "" {
+ return redir
+ }
+
+ refURL, err := url.Parse(origReferer)
+ if err != nil || refURL.Host == "" || refURL.Host == host {
+ return redir
+ }
+
+ u, err := url.Parse(redir)
+ if err != nil {
+ return redir
+ }
+
+ q := u.Query()
+ for _, name := range refererAttributionQueryParams {
+ if q.Has(name) {
+ return redir
+ }
+ }
+
+ q.Set("utm_source", refURL.Host)
+ q.Set("utm_medium", "referral")
+ u.RawQuery = q.Encode()
+ return u.String()
}
func cr(name string, rule config.Rule, weight int) policy.CheckResult {
@@ -552,9 +819,9 @@ func cr(name string, rule config.Rule, weight int) policy.CheckResult {
// Check evaluates the list of rules, and returns the result
func (s *Server) check(r *http.Request, lg *slog.Logger) (policy.CheckResult, *policy.Bot, error) {
- host := r.Header.Get("X-Real-Ip")
+ host := r.Header.Get("X-Real-IP")
if host == "" {
- return decaymap.Zilch[policy.CheckResult](), nil, fmt.Errorf("[misconfiguration] X-Real-Ip header is not set")
+ return decaymap.Zilch[policy.CheckResult](), nil, fmt.Errorf("[misconfiguration] X-Real-IP header is not set")
}
addr := net.ParseIP(host)
@@ -564,7 +831,9 @@ func (s *Server) check(r *http.Request, lg *slog.Logger) (policy.CheckResult, *p
weight := 0
- for _, b := range s.policy.Bots {
+ // Ranging by index keeps b from escaping to the heap on every iteration.
+ for i := range s.policy.Bots {
+ b := &s.policy.Bots[i]
match, err := b.Rules.Check(r)
if err != nil {
return decaymap.Zilch[policy.CheckResult](), nil, fmt.Errorf("can't run check %s: %w", b.Name, err)
@@ -573,9 +842,13 @@ func (s *Server) check(r *http.Request, lg *slog.Logger) (policy.CheckResult, *p
if match {
switch b.Action {
case config.RuleDeny, config.RuleAllow, config.RuleBenchmark, config.RuleChallenge:
- return cr("bot/"+b.Name, b.Action, weight), &b, nil
+ // Return a copy of the rule, as the shared policy must not be modified.
+ bot := *b
+ return cr("bot/"+b.Name, b.Action, weight), &bot, nil
case config.RuleWeigh:
- lg.Debug("adjusting weight", "name", b.Name, "delta", b.Weight.Adjust)
+ lg.DebugContext(r.Context(), "adjusting weight", "name", b.Name, "delta", b.Weight.Adjust)
+ asn, asnDesc := asnFromContext(r.Context())
+ policy.Applications.WithLabelValues("bot/"+b.Name, "WEIGH", asn, asnDesc).Add(1)
weight += b.Weight.Adjust
}
}
@@ -584,7 +857,7 @@ func (s *Server) check(r *http.Request, lg *slog.Logger) (policy.CheckResult, *p
for _, t := range s.policy.Thresholds {
result, _, err := t.Program.ContextEval(r.Context(), &policy.ThresholdRequest{Weight: weight})
if err != nil {
- lg.Error("error when evaluating threshold expression", "expression", t.Expression.String(), "err", err)
+ lg.ErrorContext(r.Context(), "error when evaluating threshold expression", "expression", t.Expression.String(), "err", err)
continue
}
@@ -595,8 +868,16 @@ func (s *Server) check(r *http.Request, lg *slog.Logger) (policy.CheckResult, *p
}
if matches {
+ challRules := t.Challenge
+ if challRules == nil {
+ // Non-CHALLENGE thresholds (ALLOW/DENY) don't have challenge config.
+ // Use an empty struct so hydrateChallengeRule can fill from stored
+ // challenge data during validation, rather than baking in defaults
+ // that could mismatch the difficulty the client actually solved for.
+ challRules = &config.ChallengeRules{}
+ }
return cr("threshold/"+t.Name, t.Action, weight), &policy.Bot{
- Challenge: t.Challenge,
+ Challenge: challRules,
Rules: &checker.List{},
}, nil
}
@@ -605,7 +886,6 @@ func (s *Server) check(r *http.Request, lg *slog.Logger) (policy.CheckResult, *p
return cr("default/allow", config.RuleAllow, weight), &policy.Bot{
Challenge: &config.ChallengeRules{
Difficulty: s.policy.DefaultDifficulty,
- ReportAs: s.policy.DefaultDifficulty,
Algorithm: config.DefaultAlgorithm,
},
Rules: &checker.List{},
diff --git a/lib/anubis_test.go b/lib/anubis_test.go
index f8993c375..8df92c57f 100644
--- a/lib/anubis_test.go
+++ b/lib/anubis_test.go
@@ -2,6 +2,7 @@ package lib
import (
"bytes"
+ "context"
"encoding/json"
"fmt"
"io"
@@ -18,8 +19,10 @@ import (
"github.com/TecharoHQ/anubis"
"github.com/TecharoHQ/anubis/data"
"github.com/TecharoHQ/anubis/internal"
+ "github.com/TecharoHQ/anubis/lib/challenge"
+ "github.com/TecharoHQ/anubis/lib/config"
"github.com/TecharoHQ/anubis/lib/policy"
- "github.com/TecharoHQ/anubis/lib/policy/config"
+ "github.com/TecharoHQ/anubis/lib/store"
"github.com/TecharoHQ/anubis/lib/thoth/thothmock"
)
@@ -35,8 +38,8 @@ func NewTLogWriter(t *testing.T) io.Writer {
// Write splits input on newlines and logs each line separately.
func (w *TLogWriter) Write(p []byte) (n int, err error) {
- lines := strings.Split(string(p), "\n")
- for _, line := range lines {
+ lines := strings.SplitSeq(string(p), "\n")
+ for line := range lines {
if line != "" {
w.t.Log(line)
}
@@ -55,7 +58,7 @@ func loadPolicies(t *testing.T, fname string, difficulty int) *policy.ParsedConf
t.Logf("loading policy file: %s", fname)
- anubisPolicy, err := LoadPoliciesOrDefault(ctx, fname, difficulty)
+ anubisPolicy, err := LoadPoliciesOrDefault(ctx, fname, difficulty, "info", false)
if err != nil {
t.Fatal(err)
}
@@ -104,7 +107,7 @@ func makeChallenge(t *testing.T, ts *httptest.Server, cli *http.Client) challeng
if err != nil {
t.Fatalf("can't request challenge: %v", err)
}
- defer resp.Body.Close()
+ defer resp.Body.Close() //nolint:errcheck
var chall challengeResp
if err := json.NewDecoder(resp.Body).Decode(&chall); err != nil {
@@ -149,10 +152,34 @@ func handleChallengeZeroDifficulty(t *testing.T, ts *httptest.Server, cli *http.
return resp
}
+func handleChallengeInvalidProof(t *testing.T, ts *httptest.Server, cli *http.Client, chall challengeResp) *http.Response {
+ t.Helper()
+
+ req, err := http.NewRequest(http.MethodGet, ts.URL+"/.within.website/x/cmd/anubis/api/pass-challenge", nil)
+ if err != nil {
+ t.Fatalf("can't make request: %v", err)
+ }
+
+ q := req.URL.Query()
+ q.Set("response", strings.Repeat("f", 64)) // "hash" that never starts with the nonce
+ q.Set("nonce", "0")
+ q.Set("redir", "/")
+ q.Set("elapsedTime", "0")
+ q.Set("id", chall.ID)
+ req.URL.RawQuery = q.Encode()
+
+ resp, err := cli.Do(req)
+ if err != nil {
+ t.Fatalf("can't do request: %v", err)
+ }
+
+ return resp
+}
+
type loggingCookieJar struct {
t *testing.T
- lock sync.Mutex
cookies map[string][]*http.Cookie
+ lock sync.Mutex
}
func (lcj *loggingCookieJar) Cookies(u *url.URL) []*http.Cookie {
@@ -221,9 +248,9 @@ func TestLoadPolicies(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- defer fin.Close()
+ defer fin.Close() //nolint:errcheck
- if _, err := policy.ParseConfig(t.Context(), fin, fname, 4); err != nil {
+ if _, err := policy.ParseConfig(t.Context(), fin, fname, 4, "info", false); err != nil {
t.Fatal(err)
}
})
@@ -244,7 +271,7 @@ func TestCVE2025_24369(t *testing.T) {
cli := httpClient(t)
chall := makeChallenge(t, ts, cli)
- resp := handleChallengeZeroDifficulty(t, ts, cli, chall)
+ resp := handleChallengeInvalidProof(t, ts, cli, chall)
if resp.StatusCode == http.StatusFound {
t.Log("Regression on CVE-2025-24369")
@@ -252,159 +279,205 @@ func TestCVE2025_24369(t *testing.T) {
}
}
-func TestCookieCustomExpiration(t *testing.T) {
- pol := loadPolicies(t, "testdata/zero_difficulty.yaml", 0)
- ckieExpiration := 10 * time.Minute
-
- srv := spawnAnubis(t, Options{
- Next: http.NewServeMux(),
- Policy: pol,
-
- CookieExpiration: ckieExpiration,
- })
-
- ts := httptest.NewServer(internal.RemoteXRealIP(true, "tcp", srv))
- defer ts.Close()
-
- cli := httpClient(t)
- chall := makeChallenge(t, ts, cli)
-
- resp := handleChallengeZeroDifficulty(t, ts, cli, chall)
-
- if resp.StatusCode != http.StatusFound {
- resp.Write(os.Stderr)
- t.Errorf("wanted %d, got: %d", http.StatusFound, resp.StatusCode)
- }
-
- var ckie *http.Cookie
- for _, cookie := range resp.Cookies() {
- t.Logf("%#v", cookie)
- if cookie.Name == anubis.CookieName {
- ckie = cookie
- break
- }
- }
- if ckie == nil {
- t.Errorf("Cookie %q not found", anubis.CookieName)
- return
- }
-}
-
func TestCookieSettings(t *testing.T) {
- pol := loadPolicies(t, "testdata/zero_difficulty.yaml", 0)
+ const cookieDomain = "127.0.0.1"
- srv := spawnAnubis(t, Options{
- Next: http.NewServeMux(),
- Policy: pol,
+ testCases := []struct {
+ name string
+ partitioned bool
+ secure bool
+ httpOnly bool
+ sameSite http.SameSite
+ wantSameSite http.SameSite
+ expiration time.Duration
+ }{
+ {
+ name: "secure samesite none is preserved",
+ partitioned: true,
+ secure: true,
+ httpOnly: false,
+ sameSite: http.SameSiteNoneMode,
+ wantSameSite: http.SameSiteNoneMode,
+ expiration: anubis.CookieDefaultExpirationTime,
+ },
+ {
+ name: "secure samesite none with httponly is preserved",
+ partitioned: true,
+ secure: true,
+ httpOnly: true,
+ sameSite: http.SameSiteNoneMode,
+ wantSameSite: http.SameSiteNoneMode,
+ expiration: anubis.CookieDefaultExpirationTime,
+ },
+ {
+ name: "insecure samesite none downgrades to lax",
+ partitioned: true,
+ secure: false,
+ httpOnly: false,
+ sameSite: http.SameSiteNoneMode,
+ wantSameSite: http.SameSiteLaxMode,
+ expiration: anubis.CookieDefaultExpirationTime,
+ },
+ {
+ name: "insecure samesite lax with httponly is preserved",
+ partitioned: false,
+ secure: false,
+ httpOnly: true,
+ sameSite: http.SameSiteLaxMode,
+ wantSameSite: http.SameSiteLaxMode,
+ expiration: anubis.CookieDefaultExpirationTime,
+ },
+ {
+ name: "custom expiration is honored",
+ partitioned: false,
+ secure: true,
+ httpOnly: false,
+ sameSite: http.SameSiteLaxMode,
+ wantSameSite: http.SameSiteLaxMode,
+ expiration: 10 * time.Minute,
+ },
+ }
- CookieDomain: "127.0.0.1",
- CookiePartitioned: true,
- CookieSecure: true,
- CookieSameSite: http.SameSiteNoneMode,
- CookieExpiration: anubis.CookieDefaultExpirationTime,
- })
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ // load per subtest: New() mutates the policy (appends the
+ // honeypot bot), so sharing one instance leaks state across cases
+ pol := loadPolicies(t, "testdata/zero_difficulty.yaml", 0)
- ts := httptest.NewServer(internal.RemoteXRealIP(true, "tcp", srv))
- defer ts.Close()
+ srv := spawnAnubis(t, Options{
+ Next: http.NewServeMux(),
+ Policy: pol,
+
+ CookieDomain: cookieDomain,
+ CookiePartitioned: tc.partitioned,
+ CookieSecure: tc.secure,
+ CookieHttpOnly: tc.httpOnly,
+ CookieSameSite: tc.sameSite,
+ CookieExpiration: tc.expiration,
+ })
- cli := httpClient(t)
- chall := makeChallenge(t, ts, cli)
+ ts := httptest.NewServer(internal.RemoteXRealIP(true, "tcp", srv))
+ defer ts.Close()
- resp := handleChallengeZeroDifficulty(t, ts, cli, chall)
+ cli := httpClient(t)
+ chall := makeChallenge(t, ts, cli)
- if resp.StatusCode != http.StatusFound {
- resp.Write(os.Stderr)
- t.Errorf("wanted %d, got: %d", http.StatusFound, resp.StatusCode)
- }
+ resp := handleChallengeZeroDifficulty(t, ts, cli, chall)
- var ckie *http.Cookie
- for _, cookie := range resp.Cookies() {
- t.Logf("%#v", cookie)
- if cookie.Name == anubis.CookieName {
- ckie = cookie
- break
- }
- }
- if ckie == nil {
- t.Errorf("Cookie %q not found", anubis.CookieName)
- return
- }
+ if resp.StatusCode != http.StatusFound {
+ _ = resp.Write(os.Stderr) // if this fails we have bigger problems
+ t.Errorf("wanted %d, got: %d", http.StatusFound, resp.StatusCode)
+ }
- if ckie.Domain != "127.0.0.1" {
- t.Errorf("cookie domain is wrong, wanted 127.0.0.1, got: %s", ckie.Domain)
- }
+ wantCookieName := srv.cookieName(anubis.CookieName)
- if ckie.Partitioned != srv.opts.CookiePartitioned {
- t.Errorf("wanted partitioned flag %v, got: %v", srv.opts.CookiePartitioned, ckie.Partitioned)
- }
+ var ckie *http.Cookie
+ for _, cookie := range resp.Cookies() {
+ t.Logf("%#v", cookie)
+ if cookie.Name == wantCookieName {
+ ckie = cookie
+ break
+ }
+ }
+ if ckie == nil {
+ t.Errorf("Cookie %q not found", wantCookieName)
+ return
+ }
- if ckie.Secure != srv.opts.CookieSecure {
- t.Errorf("wanted secure flag %v, got: %v", srv.opts.CookieSecure, ckie.Secure)
- }
- if ckie.SameSite != srv.opts.CookieSameSite {
- t.Errorf("wanted same site option %v, got: %v", srv.opts.CookieSameSite, ckie.SameSite)
+ if ckie.Domain != cookieDomain {
+ t.Errorf("cookie domain is wrong, wanted %s, got: %s", cookieDomain, ckie.Domain)
+ }
+ if ckie.Partitioned != tc.partitioned {
+ t.Errorf("wanted partitioned flag %v, got: %v", tc.partitioned, ckie.Partitioned)
+ }
+ if ckie.HttpOnly != tc.httpOnly {
+ t.Errorf("wanted httponly flag %v, got: %v", tc.httpOnly, ckie.HttpOnly)
+ }
+ if ckie.Secure != tc.secure {
+ t.Errorf("wanted secure flag %v, got: %v", tc.secure, ckie.Secure)
+ }
+ if ckie.SameSite != tc.wantSameSite {
+ t.Errorf("wanted same site option %v, got: %v", tc.wantSameSite, ckie.SameSite)
+ }
+ if got := time.Until(ckie.Expires); (got - tc.expiration).Abs() > time.Minute {
+ t.Errorf("cookie expiry is wrong, wanted ~%s remaining, got: %s", tc.expiration, got)
+ }
+ })
}
}
-func TestCookieSettingsSameSiteNoneModeDowngradedToLaxWhenUnsecure(t *testing.T) {
- pol := loadPolicies(t, "testdata/zero_difficulty.yaml", 0)
-
+// Regression test for https://github.com/TecharoHQ/anubis/issues/1314
+//
+// A request without an Anubis cookie must not answer with a Set-Cookie that
+// clears it. Browsers issue subresource requests in parallel with the
+// challenge, so such a response can land after pass-challenge has already
+// issued a valid cookie and would delete it. A request that does carry a
+// cookie Anubis rejects must still have it cleared.
+func TestChallengeDoesNotClearAbsentCookie(t *testing.T) {
srv := spawnAnubis(t, Options{
Next: http.NewServeMux(),
- Policy: pol,
-
- CookieDomain: "127.0.0.1",
- CookiePartitioned: true,
- CookieSecure: false,
- CookieSameSite: http.SameSiteNoneMode,
- CookieExpiration: anubis.CookieDefaultExpirationTime,
+ Policy: loadPolicies(t, "testdata/zero_difficulty.yaml", 0),
})
ts := httptest.NewServer(internal.RemoteXRealIP(true, "tcp", srv))
- defer ts.Close()
+ t.Cleanup(ts.Close)
- cli := httpClient(t)
- chall := makeChallenge(t, ts, cli)
+ cookieName := srv.cookieName(anubis.CookieName)
- resp := handleChallengeZeroDifficulty(t, ts, cli, chall)
+ for _, tc := range []struct {
+ name string
+ cookie *http.Cookie
+ wantClear bool
+ }{
+ {
+ name: "absent cookie is left alone",
+ cookie: nil,
+ wantClear: false,
+ },
+ {
+ name: "unparseable token is cleared",
+ cookie: &http.Cookie{Name: cookieName, Value: "not-a-jwt"},
+ wantClear: true,
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ req, err := http.NewRequest(http.MethodGet, ts.URL+"/", nil)
+ if err != nil {
+ t.Fatalf("can't make request: %v", err)
+ }
- if resp.StatusCode != http.StatusFound {
- resp.Write(os.Stderr)
- t.Errorf("wanted %d, got: %d", http.StatusFound, resp.StatusCode)
- }
+ if tc.cookie != nil {
+ req.AddCookie(tc.cookie)
+ }
- var ckie *http.Cookie
- for _, cookie := range resp.Cookies() {
- t.Logf("%#v", cookie)
- if cookie.Name == anubis.CookieName {
- ckie = cookie
- break
- }
- }
- if ckie == nil {
- t.Errorf("Cookie %q not found", anubis.CookieName)
- return
- }
+ resp, err := httpClient(t).Do(req)
+ if err != nil {
+ t.Fatalf("can't do request: %v", err)
+ }
+ t.Cleanup(func() { resp.Body.Close() }) //nolint:errcheck
- if ckie.Domain != "127.0.0.1" {
- t.Errorf("cookie domain is wrong, wanted 127.0.0.1, got: %s", ckie.Domain)
- }
+ if resp.StatusCode != http.StatusOK {
+ t.Errorf("wanted the challenge page with status %d, got: %d", http.StatusOK, resp.StatusCode)
+ }
- if ckie.Partitioned != srv.opts.CookiePartitioned {
- t.Errorf("wanted partitioned flag %v, got: %v", srv.opts.CookiePartitioned, ckie.Partitioned)
- }
+ var cleared bool
+ for _, ckie := range resp.Cookies() {
+ t.Logf("%#v", ckie)
+ if ckie.Name == cookieName && ckie.MaxAge < 0 {
+ cleared = true
+ }
+ }
- if ckie.Secure != srv.opts.CookieSecure {
- t.Errorf("wanted secure flag %v, got: %v", srv.opts.CookieSecure, ckie.Secure)
- }
- if ckie.SameSite != http.SameSiteLaxMode {
- t.Errorf("wanted same site Lax option %v, got: %v", http.SameSiteLaxMode, ckie.SameSite)
+ if cleared != tc.wantClear {
+ t.Errorf("wanted cookie %q cleared: %v, got: %v", cookieName, tc.wantClear, cleared)
+ }
+ })
}
}
func TestCheckDefaultDifficultyMatchesPolicy(t *testing.T) {
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- fmt.Fprintln(w, "OK")
+ fmt.Fprintln(w, "OK") //nolint:errcheck
})
for i := 1; i < 10; i++ {
@@ -425,7 +498,7 @@ func TestCheckDefaultDifficultyMatchesPolicy(t *testing.T) {
t.Fatal(err)
}
- req.Header.Add("X-Real-Ip", "127.0.0.1")
+ req.Header.Add("X-Real-IP", "127.0.0.1")
cr, bot, err := s.check(req, s.logger)
if err != nil {
@@ -437,17 +510,13 @@ func TestCheckDefaultDifficultyMatchesPolicy(t *testing.T) {
if bot.Challenge.Difficulty != i {
t.Errorf("Challenge.Difficulty is wrong, wanted %d, got: %d", i, bot.Challenge.Difficulty)
}
-
- if bot.Challenge.ReportAs != i {
- t.Errorf("Challenge.ReportAs is wrong, wanted %d, got: %d", i, bot.Challenge.ReportAs)
- }
})
}
}
func TestBasePrefix(t *testing.T) {
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- fmt.Fprintln(w, "OK")
+ fmt.Fprintln(w, "OK") //nolint:errcheck
})
testCases := []struct {
@@ -514,7 +583,11 @@ func TestBasePrefix(t *testing.T) {
if err != nil {
t.Fatalf("can't request challenge: %v", err)
}
- defer resp.Body.Close()
+ defer func() {
+ if err := resp.Body.Close(); err != nil {
+ t.Fatal(err)
+ }
+ }()
if resp.StatusCode != http.StatusOK {
t.Errorf("expected status code %d, got: %d", http.StatusOK, resp.StatusCode)
@@ -588,15 +661,17 @@ func TestBasePrefix(t *testing.T) {
}
// Check cookie path
+ wantCookieName := srv.cookieName(anubis.CookieName)
+
var ckie *http.Cookie
for _, cookie := range resp.Cookies() {
- if cookie.Name == anubis.CookieName {
+ if cookie.Name == wantCookieName {
ckie = cookie
break
}
}
if ckie == nil {
- t.Errorf("Cookie %q not found", anubis.CookieName)
+ t.Errorf("Cookie %q not found", wantCookieName)
return
}
@@ -616,7 +691,7 @@ func TestCustomStatusCodes(t *testing.T) {
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Log(r.UserAgent())
w.WriteHeader(http.StatusOK)
- fmt.Fprintln(w, "OK")
+ fmt.Fprintln(w, "OK") //nolint:errcheck
})
statusMap := map[string]int{
@@ -656,13 +731,148 @@ func TestCustomStatusCodes(t *testing.T) {
}
}
+func TestNonGzipClientGetsDenyStatus(t *testing.T) {
+ pol := loadPolicies(t, "testdata/aggressive_403.yaml", 0)
+
+ srv := spawnAnubis(t, Options{
+ Next: http.NewServeMux(),
+ Policy: pol,
+ })
+
+ var logs bytes.Buffer
+ srv.logger = slog.New(slog.NewJSONHandler(&logs, &slog.HandlerOptions{Level: slog.LevelInfo}))
+
+ const requests = 4096
+
+ var challenged, denied int
+
+ for i := 0; i < requests; i++ {
+ req := httptest.NewRequest("GET", "http://example.com/", nil)
+ req.Header.Set("X-Real-IP", "127.0.0.1")
+ req.Header.Set("User-Agent", "CHALLENGE")
+ req.Header.Set("Accept-Encoding", "br")
+
+ w := httptest.NewRecorder()
+ srv.maybeReverseProxyOrPage(w, req)
+
+ switch w.Code {
+ case pol.StatusCodes.Challenge:
+ challenged++
+ case pol.StatusCodes.Deny:
+ denied++
+ default:
+ t.Fatalf("request %d: got status %d, wanted %d (challenge) or %d (deny)", i, w.Code, pol.StatusCodes.Challenge, pol.StatusCodes.Deny)
+ }
+ }
+
+ t.Logf("%d of %d requests were challenged, %d were rejected", challenged, requests, denied)
+
+ if denied == 0 {
+ t.Fatalf("no request out of %d was rejected, the one in 64 gzip check never fired", requests)
+ }
+
+ const rejectionMessage = "client was given a challenge but does not in fact support gzip compression"
+
+ var logged int
+
+ for _, line := range strings.Split(logs.String(), "\n") {
+ if !strings.Contains(line, rejectionMessage) {
+ continue
+ }
+
+ logged++
+
+ var record struct {
+ Level string `json:"level"`
+ }
+
+ if err := json.Unmarshal([]byte(line), &record); err != nil {
+ t.Fatalf("can't parse log line %q: %v", line, err)
+ }
+
+ if record.Level != slog.LevelInfo.String() {
+ t.Errorf("rejection was logged at %s, wanted %s", record.Level, slog.LevelInfo)
+ }
+ }
+
+ if logged != denied {
+ t.Errorf("%d requests were rejected but %d rejections were logged", denied, logged)
+ }
+}
+
+func assertHeaderValues(t *testing.T, header http.Header, name string, want ...string) {
+ t.Helper()
+
+ got := header.Values(name)
+ if len(got) != len(want) {
+ t.Fatalf("header %s has values %q, wanted %q", name, got, want)
+ }
+ for i := range want {
+ if got[i] != want[i] {
+ t.Fatalf("header %s has values %q, wanted %q", name, got, want)
+ }
+ }
+}
+
+func TestDownstreamAnubisHeadersAreAuthoritative(t *testing.T) {
+ const attackerValue = "attacker-controlled"
+
+ t.Run("explicit allow", func(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ spoof bool
+ }{
+ {name: "ordinary request"},
+ {name: "spoofed headers", spoof: true},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ var forwarded http.Header
+ srv := spawnAnubis(t, Options{
+ Next: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ forwarded = r.Header.Clone()
+ w.WriteHeader(http.StatusNoContent)
+ }),
+ Policy: loadPolicies(t, "testdata/permissive.yaml", 4),
+ })
+
+ req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
+ req.Header.Set("X-Real-IP", "127.0.0.1")
+ if tc.spoof {
+ req.Header["X-Anubis-Rule"] = []string{attackerValue, "second-attacker-value"}
+ req.Header["X-Anubis-Action"] = []string{attackerValue, "second-attacker-value"}
+ req.Header["X-Anubis-Status"] = []string{attackerValue, "second-attacker-value"}
+ req.Header.Set("Connection", "keep-alive, x-anubis-rule, X-Anubis-Status")
+ }
+
+ cr, _, err := srv.check(req, srv.logger)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ rr := httptest.NewRecorder()
+ srv.maybeReverseProxyOrPage(rr, req)
+
+ if forwarded == nil {
+ t.Fatal("request was not forwarded")
+ }
+ assertHeaderValues(t, forwarded, "X-Anubis-Rule", cr.Name)
+ assertHeaderValues(t, forwarded, "X-Anubis-Action", string(cr.Rule))
+ assertHeaderValues(t, forwarded, "X-Anubis-Status")
+ if tc.spoof {
+ assertHeaderValues(t, forwarded, "Connection", "keep-alive")
+ }
+ })
+ }
+ })
+}
+
func TestCloudflareWorkersRule(t *testing.T) {
for _, variant := range []string{"cel", "header"} {
t.Run(variant, func(t *testing.T) {
pol := loadPolicies(t, "./testdata/cloudflare-workers-"+variant+".yaml", 0)
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- fmt.Fprintln(w, "OK")
+ fmt.Fprintln(w, "OK") //nolint:errcheck
})
s, err := New(Options{
@@ -680,7 +890,7 @@ func TestCloudflareWorkersRule(t *testing.T) {
t.Fatal(err)
}
- req.Header.Add("X-Real-Ip", "127.0.0.1")
+ req.Header.Add("X-Real-IP", "127.0.0.1")
req.Header.Add("Cf-Worker", "true")
cr, _, err := s.check(req, s.logger)
@@ -699,7 +909,7 @@ func TestCloudflareWorkersRule(t *testing.T) {
t.Fatal(err)
}
- req.Header.Add("X-Real-Ip", "127.0.0.1")
+ req.Header.Add("X-Real-IP", "127.0.0.1")
cr, _, err := s.check(req, s.logger)
if err != nil {
@@ -735,7 +945,7 @@ func TestRuleChange(t *testing.T) {
resp := handleChallengeZeroDifficulty(t, ts, cli, chall)
if resp.StatusCode != http.StatusFound {
- resp.Write(os.Stderr)
+ _ = resp.Write(os.Stderr) // if stderr fails, there are bigger problems
t.Errorf("wanted %d, got: %d", http.StatusFound, resp.StatusCode)
}
}
@@ -744,9 +954,9 @@ func TestStripBasePrefixFromRequest(t *testing.T) {
testCases := []struct {
name string
basePrefix string
- stripBasePrefix bool
requestPath string
expectedPath string
+ stripBasePrefix bool
}{
{
name: "strip disabled - no change",
@@ -863,7 +1073,11 @@ func TestChallengeFor_ErrNotFound(t *testing.T) {
srv.maybeReverseProxyOrPage(w, req)
resp := w.Result()
- defer resp.Body.Close()
+ defer func() {
+ if err := resp.Body.Close(); err != nil {
+ t.Fatal(err)
+ }
+ }()
body := new(strings.Builder)
_, err := io.Copy(body, resp.Body)
@@ -890,7 +1104,7 @@ func TestChallengeFor_ErrNotFound(t *testing.T) {
t.Run("make sure new test cookie is issued", func(t *testing.T) {
found := false
for _, cookie := range resp.Cookies() {
- if cookie.Name == anubis.TestCookieName {
+ if cookie.Name == srv.cookieName(anubis.TestCookieName) {
if cookie.Value == wrongCookie {
t.Error("a new challenge cookie should be issued")
}
@@ -1027,10 +1241,348 @@ func TestPassChallengeXSS(t *testing.T) {
})
}
+// Regression test for https://github.com/TecharoHQ/anubis/issues/1596: the Referer
+// header a visitor arrived with (e.g. an external social/referral link) must survive
+// the challenge round trip instead of being replaced by the internal challenge page
+// as the apparent referrer once the request reaches the upstream origin.
+func TestPassChallengeRestoresOriginalReferer(t *testing.T) {
+ const originalReferer = "https://example-external.test/some/post?utm=abc"
+
+ var forwarded []string
+ srv := spawnAnubis(t, Options{
+ Next: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ forwarded = append(forwarded, r.Header.Get("Referer"))
+ w.WriteHeader(http.StatusNoContent)
+ }),
+ Policy: loadPolicies(t, "testdata/zero_difficulty.yaml", 0),
+ CookieExpiration: 10 * time.Minute,
+ })
+
+ ts := httptest.NewServer(internal.RemoteXRealIP(true, "tcp", srv))
+ defer ts.Close()
+
+ cli := &http.Client{
+ CheckRedirect: func(req *http.Request, via []*http.Request) error {
+ return http.ErrUseLastResponse
+ },
+ }
+
+ // The visitor arrives from an external site and gets challenged.
+ req, err := http.NewRequest(http.MethodPost, ts.URL+"/.within.website/x/cmd/anubis/api/make-challenge", nil)
+ if err != nil {
+ t.Fatalf("can't make request: %v", err)
+ }
+ req.Header.Set("Referer", originalReferer)
+ req.Header.Set("User-Agent", "Mozilla/5.0")
+ q := req.URL.Query()
+ q.Set("redir", "/")
+ req.URL.RawQuery = q.Encode()
+
+ resp, err := cli.Do(req)
+ if err != nil {
+ t.Fatalf("can't request challenge: %v", err)
+ }
+ defer resp.Body.Close() //nolint:errcheck
+
+ var chall challengeResp
+ if err := json.NewDecoder(resp.Body).Decode(&chall); err != nil {
+ t.Fatalf("can't decode challenge: %v", err)
+ }
+
+ var testCookie *http.Cookie
+ for _, c := range resp.Cookies() {
+ if c.Name == srv.cookieName(anubis.TestCookieName) {
+ testCookie = c
+ }
+ }
+ if testCookie == nil {
+ t.Fatal("make-challenge did not set the verification cookie")
+ }
+
+ // Solve the (zero-difficulty) challenge and pass it.
+ nonce := 0
+ calculated := internal.SHA256sum(fmt.Sprintf("%s%d", chall.Challenge, nonce))
+
+ passReq, err := http.NewRequest(http.MethodGet, ts.URL+"/.within.website/x/cmd/anubis/api/pass-challenge", nil)
+ if err != nil {
+ t.Fatalf("can't make request: %v", err)
+ }
+ passReq.Header.Set("User-Agent", "Mozilla/5.0")
+ passReq.AddCookie(testCookie)
+ pq := passReq.URL.Query()
+ pq.Set("response", calculated)
+ pq.Set("nonce", fmt.Sprint(nonce))
+ pq.Set("redir", "/")
+ pq.Set("elapsedTime", "420")
+ pq.Set("id", chall.ID)
+ passReq.URL.RawQuery = pq.Encode()
+
+ passResp, err := cli.Do(passReq)
+ if err != nil {
+ t.Fatalf("can't do request: %v", err)
+ }
+ defer passResp.Body.Close() //nolint:errcheck
+
+ if passResp.StatusCode != http.StatusFound {
+ body, _ := io.ReadAll(passResp.Body)
+ t.Fatalf("wanted %d from pass-challenge, got %d: %s", http.StatusFound, passResp.StatusCode, body)
+ }
+
+ var authCookie, relayCookie *http.Cookie
+ for _, c := range passResp.Cookies() {
+ switch c.Name {
+ case srv.cookieName(anubis.CookieName):
+ authCookie = c
+ case srv.cookieName(originalRefererCookieName):
+ relayCookie = c
+ }
+ }
+ if authCookie == nil {
+ t.Fatal("pass-challenge did not set the auth cookie")
+ }
+ if relayCookie == nil {
+ t.Fatal("pass-challenge did not set the original referer relay cookie")
+ }
+ if want := url.QueryEscape(originalReferer); relayCookie.Value != want {
+ t.Errorf("wanted relay cookie value %q, got %q", want, relayCookie.Value)
+ }
+
+ location := passResp.Header.Get("Location")
+ if location == "" {
+ t.Fatal("pass-challenge did not return a redirect Location")
+ }
+
+ // This simulates the browser's follow-up navigation to the real page. A real
+ // browser sends the challenge page itself as Referer here (same-site redirect);
+ // use that misleading value to prove Anubis overrides it with the true original
+ // referrer rather than just forwarding whatever the browser happened to send.
+ followReq, err := http.NewRequest(http.MethodGet, ts.URL+location, nil)
+ if err != nil {
+ t.Fatalf("can't make request: %v", err)
+ }
+ followReq.Header.Set("User-Agent", "Mozilla/5.0")
+ followReq.Header.Set("Referer", ts.URL+"/.within.website/x/cmd/anubis/api/pass-challenge")
+ followReq.AddCookie(authCookie)
+ followReq.AddCookie(relayCookie)
+
+ followResp, err := cli.Do(followReq)
+ if err != nil {
+ t.Fatalf("can't do request: %v", err)
+ }
+ defer followResp.Body.Close() //nolint:errcheck
+
+ if len(forwarded) != 1 {
+ t.Fatalf("wanted exactly one proxied request, got %d", len(forwarded))
+ }
+ if forwarded[0] != originalReferer {
+ t.Errorf("wanted upstream Referer %q, got %q", originalReferer, forwarded[0])
+ }
+
+ var relayCleared bool
+ for _, c := range followResp.Cookies() {
+ if c.Name == srv.cookieName(originalRefererCookieName) && c.MaxAge < 0 {
+ relayCleared = true
+ }
+ }
+ if !relayCleared {
+ t.Error("wanted the original referer relay cookie to be cleared after use")
+ }
+
+ // A later, ordinary navigation doesn't carry the (already consumed) relay cookie,
+ // so its own Referer must pass through untouched.
+ nextReferer := "https://example.test/some-other-internal-page"
+ nextReq, err := http.NewRequest(http.MethodGet, ts.URL+"/", nil)
+ if err != nil {
+ t.Fatalf("can't make request: %v", err)
+ }
+ nextReq.Header.Set("User-Agent", "Mozilla/5.0")
+ nextReq.Header.Set("Referer", nextReferer)
+ nextReq.AddCookie(authCookie)
+
+ nextResp, err := cli.Do(nextReq)
+ if err != nil {
+ t.Fatalf("can't do request: %v", err)
+ }
+ defer nextResp.Body.Close() //nolint:errcheck
+
+ if len(forwarded) != 2 {
+ t.Fatalf("wanted exactly two proxied requests total, got %d", len(forwarded))
+ }
+ if forwarded[1] != nextReferer {
+ t.Errorf("wanted upstream Referer %q on subsequent request, got %q", nextReferer, forwarded[1])
+ }
+}
+
+// document.referrer itself can't be fixed (it's the browser's own record of the
+// challenge-page navigation, not something a proxy or script can override), so
+// withRefererAttributionQuery is the opt-in fallback: it hands the original
+// referrer's host to the destination page via utm_source/utm_medium, which
+// client-side analytics tools such as Plausible read instead of
+// document.referrer when present.
+func TestWithRefererAttributionQuery(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ enabled bool
+ redir string
+ origReferer string
+ host string
+ want string
+ }{
+ {
+ name: "disabled leaves redir untouched",
+ enabled: false,
+ redir: "/page",
+ origReferer: "https://example-external.test/post",
+ host: "site.tld",
+ want: "/page",
+ },
+ {
+ name: "no captured referer leaves redir untouched",
+ enabled: true,
+ redir: "/page",
+ host: "site.tld",
+ want: "/page",
+ },
+ {
+ name: "same-site referer leaves redir untouched",
+ enabled: true,
+ redir: "/page",
+ origReferer: "https://site.tld/other-page",
+ host: "site.tld",
+ want: "/page",
+ },
+ {
+ name: "unparseable referer leaves redir untouched",
+ enabled: true,
+ redir: "/page",
+ origReferer: "://not a url",
+ host: "site.tld",
+ want: "/page",
+ },
+ {
+ name: "external referer with no existing query gets utm params",
+ enabled: true,
+ redir: "/page",
+ origReferer: "https://example-external.test/some/post",
+ host: "site.tld",
+ want: "/page?utm_medium=referral&utm_source=example-external.test",
+ },
+ {
+ name: "existing unrelated query is preserved alongside new params",
+ enabled: true,
+ redir: "/page?foo=bar",
+ origReferer: "https://example-external.test/some/post",
+ host: "site.tld",
+ want: "/page?foo=bar&utm_medium=referral&utm_source=example-external.test",
+ },
+ {
+ name: "existing utm_source is not clobbered",
+ enabled: true,
+ redir: "/page?utm_source=newsletter",
+ origReferer: "https://example-external.test/some/post",
+ host: "site.tld",
+ want: "/page?utm_source=newsletter",
+ },
+ {
+ name: "existing ref param is not clobbered",
+ enabled: true,
+ redir: "/page?ref=already-set",
+ origReferer: "https://example-external.test/some/post",
+ host: "site.tld",
+ want: "/page?ref=already-set",
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ srv := &Server{opts: Options{PreserveRefererQueryParam: tc.enabled}}
+
+ got := srv.withRefererAttributionQuery(tc.redir, tc.origReferer, tc.host)
+ if got != tc.want {
+ t.Errorf("wanted %q, got %q", tc.want, got)
+ }
+ })
+ }
+}
+
+func TestPassChallengeNilRuleChallengeFallback(t *testing.T) {
+ pol := loadPolicies(t, "testdata/zero_difficulty.yaml", 0)
+
+ srv := spawnAnubis(t, Options{
+ Next: http.NewServeMux(),
+ Policy: pol,
+ RedirectDomains: []string{"allowed.example"},
+ })
+
+ allowThreshold, err := policy.ParsedThresholdFromConfig(config.Threshold{
+ Name: "allow-all",
+ Expression: &config.ExpressionOrList{
+ Expression: "true",
+ },
+ Action: config.RuleAllow,
+ })
+ if err != nil {
+ t.Fatalf("can't compile test threshold: %v", err)
+ }
+ srv.policy.Thresholds = []*policy.Threshold{allowThreshold}
+ srv.policy.Bots = nil
+
+ chall := challenge.Challenge{
+ ID: "test-challenge",
+ Method: "metarefresh",
+ RandomData: "apple cider",
+ IssuedAt: time.Now().Add(-5 * time.Second),
+ Difficulty: 1,
+ }
+
+ j := store.JSON[challenge.Challenge]{Underlying: srv.store}
+ if err := j.Set(context.Background(), "challenge:"+chall.ID, chall, time.Minute); err != nil {
+ t.Fatalf("can't insert challenge into store: %v", err)
+ }
+
+ req := httptest.NewRequest(http.MethodGet, "https://example.com"+anubis.APIPrefix+"pass-challenge", nil)
+ q := req.URL.Query()
+ q.Set("redir", "/")
+ q.Set("id", chall.ID)
+ q.Set("challenge", chall.RandomData)
+ req.URL.RawQuery = q.Encode()
+ req.Header.Set("X-Real-IP", "203.0.113.4")
+ req.Header.Set("User-Agent", "NilChallengeTester/1.0")
+ req.AddCookie(&http.Cookie{Name: srv.cookieName(anubis.TestCookieName), Value: chall.ID})
+
+ for _, target := range []string{"https:///evil.com/", "https://evil.com/", "//evil.com", `/\evil.com`} {
+ badReq := req.Clone(req.Context())
+ badQuery := badReq.URL.Query()
+ badQuery.Set("redir", target)
+ badReq.URL.RawQuery = badQuery.Encode()
+ badResponse := httptest.NewRecorder()
+ srv.PassChallenge(badResponse, badReq)
+ if badResponse.Code != http.StatusBadRequest || badResponse.Header().Get("Location") != "" || len(badResponse.Result().Cookies()) != 0 {
+ t.Fatalf("invalid target %q: got %d, %v", target, badResponse.Code, badResponse.Header())
+ }
+ stored, err := j.Get(req.Context(), "challenge:"+chall.ID)
+ if err != nil || stored.Spent {
+ t.Fatalf("invalid redirect changed challenge state: spent=%v, err=%v", stored.Spent, err)
+ }
+ }
+ const target = "https://allowed.example/safe%2Fpath?q=%2F%2Fevil.com#fragment"
+ q.Set("redir", target)
+ req.URL.RawQuery = q.Encode()
+
+ rr := httptest.NewRecorder()
+
+ srv.PassChallenge(rr, req)
+
+ if rr.Code != http.StatusFound {
+ t.Fatalf("expected redirect when validating challenge, got %d", rr.Code)
+ }
+ if rr.Header().Get("Location") != target {
+ t.Fatalf("unexpected Location: %q", rr.Header().Get("Location"))
+ }
+}
+
func TestXForwardedForNoDoubleComma(t *testing.T) {
var h http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Forwarded-For", r.Header.Get("X-Forwarded-For"))
- fmt.Fprintln(w, "OK")
+ fmt.Fprintln(w, "OK") //nolint:errcheck
})
h = internal.XForwardedForToXRealIP(h)
@@ -1050,7 +1602,7 @@ func TestXForwardedForNoDoubleComma(t *testing.T) {
t.Fatal(err)
}
- req.Header.Set("X-Real-Ip", "10.0.0.1")
+ req.Header.Set("X-Real-IP", "10.0.0.1")
resp, err := ts.Client().Do(req)
if err != nil {
diff --git a/lib/challenge/challenge.go b/lib/challenge/challenge.go
index 2553d0cdc..56db6cf26 100644
--- a/lib/challenge/challenge.go
+++ b/lib/challenge/challenge.go
@@ -4,10 +4,13 @@ import "time"
// Challenge is the metadata about a single challenge issuance.
type Challenge struct {
- ID string `json:"id"` // UUID identifying the challenge
- Method string `json:"method"` // Challenge method
- RandomData string `json:"randomData"` // The random data the client processes
- IssuedAt time.Time `json:"issuedAt"` // When the challenge was issued
- Metadata map[string]string `json:"metadata"` // Challenge metadata such as IP address and user agent
- Spent bool `json:"spent"` // Has the challenge already been solved?
+ IssuedAt time.Time `json:"issuedAt"` // When the challenge was issued
+ Metadata map[string]string `json:"metadata"` // Challenge metadata such as IP address and user agent
+ ID string `json:"id"` // UUID identifying the challenge
+ Method string `json:"method"` // Challenge method
+ RandomData string `json:"randomData"` // The random data the client processes
+ PolicyRuleHash string `json:"policyRuleHash,omitempty"` // Hash of the policy rule that issued this challenge
+ Difficulty int `json:"difficulty,omitempty"` // Difficulty that was in effect when issued
+ Spent bool `json:"spent"` // Has the challenge already been solved?
+ Extensions []string `json:"extensions"` // Challenge extensions that must validate before a JWT is issued
}
diff --git a/lib/challenge/challengepage/bootstrap.go b/lib/challenge/challengepage/bootstrap.go
new file mode 100644
index 000000000..5aac4226d
--- /dev/null
+++ b/lib/challenge/challengepage/bootstrap.go
@@ -0,0 +1,28 @@
+// Package challengepage holds the parts of the challenge page that every
+// JavaScript-driven challenge method needs.
+package challengepage
+
+import (
+ "context"
+ _ "embed"
+ "io"
+
+ "github.com/a-h/templ"
+)
+
+//go:generate ./build.sh
+
+//go:embed static/js/bootstrap.mjs
+var scriptBytes string
+
+// Bootstrap renders the inline watchdog script that re-injects main.mjs when
+// it fails to load. It must be rendered after the script tag it watches so
+// that the tag exists by the time this runs during page parse.
+func Bootstrap() templ.Component {
+ return templ.ComponentFunc(func(ctx context.Context, w io.Writer) error {
+ if _, err := io.WriteString(w, ""); err != nil {
+ return err
+ }
+ return nil
+ })
+}
diff --git a/lib/challenge/challengepage/build.sh b/lib/challenge/challengepage/build.sh
new file mode 100755
index 000000000..5bd8904cd
--- /dev/null
+++ b/lib/challenge/challengepage/build.sh
@@ -0,0 +1,64 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+cd "$(dirname "$0")"
+
+LICENSE='/*
+@licstart The following is the entire license notice for the
+JavaScript code in this page.
+
+Copyright (c) 2026 Xe Iaso
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
+@licend The above is the entire license notice
+for the JavaScript code in this page.
+
+## Rationale
+
+This script catches `main.mjs` failing to load and re-injects it with
+jittered exponential backoff.
+
+Without this any failures loading `main.mjs` leaves the challenge page
+sitting on the "Loading..." message forever with no error messaging
+outside of the browser console, which is unavailable on mobile browsers.
+
+Disable loading this script at your peril. You have been warned.
+*/'
+
+mkdir -p static/js
+
+shopt -s nullglob globstar
+
+for file in js/**/*.ts js/**/*.mjs; do
+ # Mirrors web/build.sh: js/lib/ is for shared modules, not entry points.
+ if [[ "$file" == js/lib/* ]]; then
+ continue
+ fi
+
+ out="static/${file}"
+ if [[ "$file" == *.ts ]]; then
+ out="static/${file%.ts}.mjs"
+ fi
+
+ mkdir -p "$(dirname "$out")"
+
+ esbuild "$file" --bundle --minify --target=chrome66 --outfile="$out" --banner:js="$LICENSE"
+done
diff --git a/lib/challenge/challengepage/js/bootstrap.ts b/lib/challenge/challengepage/js/bootstrap.ts
new file mode 100644
index 000000000..5a404853d
--- /dev/null
+++ b/lib/challenge/challengepage/js/bootstrap.ts
@@ -0,0 +1,117 @@
+// Watch for `main.mjs` failing to load and re-injects it with jittered
+// exponential backoff.
+//
+// Without this any failures loading `main.mjs` leaves the challenge page
+// sitting on the "Loading..." message forever with no error messaging
+// outside of the browser console, which is unavailable on mobile browsers.
+//
+// This is the worst way for Anubis to fail because it requires the user
+// to manually reload the webpage, which fails the main design goal of
+// the proof of work challenges: no user interaction is required to pass
+// challenges. Paradoxically, this would increase the level of load to
+// an already overwhelmed server, making the problem much worse.
+//
+// This is injected into the page as a raw constant embedded from this
+// file instead of loaded dynamically so that things are more reliable.
+//
+// A few notes on the implementation of this and why it makes some weird
+// assumptions about the browser environment:
+//
+// - Loading a module script uses a slightly different internal fetch path
+// than loading it as a normal javascript script. As such, if you try to
+// re-load _the exact same_ URL you end up getting a cached fetch
+// failure, which then amplifies the problem and makes the logic _never_
+// load. This is bad, so we have to inject extra URL parameters to the
+// Anubis server.
+// - The canonical way to solve this in a `")
+ if _, err := fmt.Fprint(out, `"); err != nil {
+ return err
+ }
return nil
}
@@ -36,9 +42,13 @@ func init() {
type impl struct{}
-func (i *impl) Setup(mux *http.ServeMux) {}
+func (i *impl) Setup(mux *http.ServeMux) error { return nil }
func (i *impl) Issue(w http.ResponseWriter, r *http.Request, lg *slog.Logger, in *challenge.IssueInput) (templ.Component, error) {
+ if err := in.Valid(); err != nil {
+ return nil, err
+ }
+
u, err := r.URL.Parse(anubis.BasePrefix + "/.within.website/x/cmd/anubis/api/pass-challenge")
if err != nil {
return nil, fmt.Errorf("can't render page: %w", err)
@@ -57,10 +67,14 @@ func (i *impl) Issue(w http.ResponseWriter, r *http.Request, lg *slog.Logger, in
}
func (i *impl) Validate(r *http.Request, lg *slog.Logger, in *challenge.ValidateInput) error {
+ if err := in.Valid(); err != nil {
+ return challenge.NewError("validate", "invalid input", err)
+ }
+
wantTime := in.Challenge.IssuedAt.Add(time.Duration(in.Rule.Challenge.Difficulty) * 80 * time.Millisecond)
if time.Now().Before(wantTime) {
- return challenge.NewError("validate", "insufficent time", fmt.Errorf("%w: wanted user to wait until at least %s", challenge.ErrFailed, wantTime.Format(time.RFC3339)))
+ return challenge.NewError("validate", "insufficient time", fmt.Errorf("%w: wanted user to wait until at least %s", challenge.ErrFailed, wantTime.Format(time.RFC3339)))
}
got := r.FormValue("result")
diff --git a/lib/challenge/preact/preact_templ.go b/lib/challenge/preact/preact_templ.go
index aa78c3c67..53f4156f6 100644
--- a/lib/challenge/preact/preact_templ.go
+++ b/lib/challenge/preact/preact_templ.go
@@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT.
-// templ: version: v0.3.924
+// templ: version: v0.3.1020
package preact
//lint:file-ignore SA4006 This context is only used if a nested component is present.
@@ -39,11 +39,11 @@ func page(redir, challenge string, difficulty int, loc *localization.SimpleLocal
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
- templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(anubis.BasePrefix + "/.within.website/x/cmd/anubis/static/img/pensive.webp?cacheBuster=" + anubis.Version)
+ templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(anubis.BasePrefix + "/.within.website/x/cmd/anubis/static/img/pensive.webp?cacheBuster=" + anubis.Version)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `preact.templ`, Line: 11, Col: 166}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
diff --git a/lib/challenge/proofofwork/proofofwork.go b/lib/challenge/proofofwork/proofofwork.go
index b9be014e6..8d752c7ab 100644
--- a/lib/challenge/proofofwork/proofofwork.go
+++ b/lib/challenge/proofofwork/proofofwork.go
@@ -1,14 +1,15 @@
package proofofwork
import (
+ "crypto/sha256"
"crypto/subtle"
+ "encoding/hex"
"fmt"
"log/slog"
"net/http"
"strconv"
"strings"
- "github.com/TecharoHQ/anubis/internal"
chall "github.com/TecharoHQ/anubis/lib/challenge"
"github.com/TecharoHQ/anubis/lib/localization"
"github.com/a-h/templ"
@@ -25,7 +26,7 @@ type Impl struct {
Algorithm string
}
-func (i *Impl) Setup(mux *http.ServeMux) {}
+func (i *Impl) Setup(mux *http.ServeMux) error { return nil }
func (i *Impl) Issue(w http.ResponseWriter, r *http.Request, lg *slog.Logger, in *chall.IssueInput) (templ.Component, error) {
loc := localization.GetLocalizer(r)
@@ -33,6 +34,10 @@ func (i *Impl) Issue(w http.ResponseWriter, r *http.Request, lg *slog.Logger, in
}
func (i *Impl) Validate(r *http.Request, lg *slog.Logger, in *chall.ValidateInput) error {
+ if err := in.Valid(); err != nil {
+ return chall.NewError("validate", "invalid input", err)
+ }
+
rule := in.Rule
challenge := in.Challenge.RandomData
@@ -41,7 +46,7 @@ func (i *Impl) Validate(r *http.Request, lg *slog.Logger, in *chall.ValidateInpu
return chall.NewError("validate", "invalid response", fmt.Errorf("%w nonce", chall.ErrMissingField))
}
- nonce, err := strconv.Atoi(nonceStr)
+ _, err := strconv.Atoi(nonceStr)
if err != nil {
return chall.NewError("validate", "invalid response", fmt.Errorf("%w: nonce: %w", chall.ErrInvalidFormat, err))
@@ -62,11 +67,20 @@ func (i *Impl) Validate(r *http.Request, lg *slog.Logger, in *chall.ValidateInpu
return chall.NewError("validate", "invalid response", fmt.Errorf("%w response", chall.ErrMissingField))
}
- calcString := fmt.Sprintf("%s%d", challenge, nonce)
- calculated := internal.SHA256sum(calcString)
-
- if subtle.ConstantTimeCompare([]byte(response), []byte(calculated)) != 1 {
- return chall.NewError("validate", "invalid response", fmt.Errorf("%w: wanted response %s but got %s", chall.ErrFailed, calculated, response))
+ // Stream the challenge and nonce into a single sha256 hasher to avoid
+ // the intermediate "challenge + nonceStr" concatenation. Hex-encode
+ // the digest into a stack buffer so the comparison runs without
+ // allocating a heap string.
+ h := sha256.New()
+ h.Write([]byte(challenge))
+ h.Write([]byte(nonceStr))
+ var sumBuf [sha256.Size]byte
+ sum := h.Sum(sumBuf[:0])
+ var hexBuf [sha256.Size * 2]byte
+ hex.Encode(hexBuf[:], sum)
+
+ if subtle.ConstantTimeCompare([]byte(response), hexBuf[:]) != 1 {
+ return chall.NewError("validate", "invalid response", fmt.Errorf("%w: wanted response %s but got %s", chall.ErrFailed, string(hexBuf[:]), response))
}
// compare the leading zeroes
@@ -74,7 +88,7 @@ func (i *Impl) Validate(r *http.Request, lg *slog.Logger, in *chall.ValidateInpu
return chall.NewError("validate", "invalid response", fmt.Errorf("%w: wanted %d leading zeros but got %s", chall.ErrFailed, rule.Challenge.Difficulty, response))
}
- lg.Debug("challenge took", "elapsedTime", elapsedTime)
+ lg.DebugContext(r.Context(), "challenge took", "elapsedTime", elapsedTime)
chall.TimeTaken.WithLabelValues(i.Algorithm).Observe(elapsedTime)
return nil
diff --git a/lib/challenge/proofofwork/proofofwork.templ b/lib/challenge/proofofwork/proofofwork.templ
index d08085abe..3f6a7b254 100644
--- a/lib/challenge/proofofwork/proofofwork.templ
+++ b/lib/challenge/proofofwork/proofofwork.templ
@@ -2,6 +2,7 @@ package proofofwork
import (
"github.com/TecharoHQ/anubis"
+ "github.com/TecharoHQ/anubis/lib/challenge/challengepage"
"github.com/TecharoHQ/anubis/lib/localization"
)
@@ -10,7 +11,9 @@ templ page(localizer *localization.SimpleLocalizer) {
{ localizer.T("loading") }
-
+ { localizer.T("script_load_error") }
+
+ @challengepage.Bootstrap()
diff --git a/lib/challenge/proofofwork/proofofwork_templ.go b/lib/challenge/proofofwork/proofofwork_templ.go
index 30adf742d..dc239afbf 100644
--- a/lib/challenge/proofofwork/proofofwork_templ.go
+++ b/lib/challenge/proofofwork/proofofwork_templ.go
@@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT.
-// templ: version: v0.3.924
+// templ: version: v0.3.1020
package proofofwork
//lint:file-ignore SA4006 This context is only used if a nested component is present.
@@ -10,6 +10,7 @@ import templruntime "github.com/a-h/templ/runtime"
import (
"github.com/TecharoHQ/anubis"
+ "github.com/TecharoHQ/anubis/lib/challenge/challengepage"
"github.com/TecharoHQ/anubis/lib/localization"
)
@@ -39,11 +40,11 @@ func page(localizer *localization.SimpleLocalizer) templ.Component {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
- templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(anubis.BasePrefix + "/.within.website/x/cmd/anubis/static/img/pensive.webp?cacheBuster=" + anubis.Version)
+ templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(anubis.BasePrefix + "/.within.website/x/cmd/anubis/static/img/pensive.webp?cacheBuster=" + anubis.Version)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 10, Col: 165}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 11, Col: 165}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -52,11 +53,11 @@ func page(localizer *localization.SimpleLocalizer) templ.Component {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
- templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(anubis.BasePrefix + "/.within.website/x/cmd/anubis/static/img/happy.webp?cacheBuster=" + anubis.Version)
+ templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(anubis.BasePrefix + "/.within.website/x/cmd/anubis/static/img/happy.webp?cacheBuster=" + anubis.Version)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 11, Col: 174}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 12, Col: 174}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -67,119 +68,140 @@ func page(localizer *localization.SimpleLocalizer) templ.Component {
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T("loading"))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 12, Col: 41}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 13, Col: 41}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = challengepage.Bootstrap().Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if anubis.UseSimplifiedExplanation {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var6 string
- templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T("simplified_explanation"))
+ var templ_7745c5c3_Var7 string
+ templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T("simplified_explanation"))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 20, Col: 44}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 23, Col: 44}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var7 string
- templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T("ai_companies_explanation"))
+ var templ_7745c5c3_Var8 string
+ templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T("ai_companies_explanation"))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 24, Col: 46}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 27, Col: 46}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var8 string
- templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T("anubis_compromise"))
+ var templ_7745c5c3_Var9 string
+ templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T("anubis_compromise"))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 27, Col: 39}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 30, Col: 39}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var9 string
- templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T("hack_purpose"))
+ var templ_7745c5c3_Var10 string
+ templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T("hack_purpose"))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 30, Col: 34}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 33, Col: 34}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var10 string
- templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T("jshelter_note"))
+ var templ_7745c5c3_Var11 string
+ templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T("jshelter_note"))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 33, Col: 35}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 36, Col: 35}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
diff --git a/lib/challenge/proofofwork/proofofwork_test.go b/lib/challenge/proofofwork/proofofwork_test.go
index c0611a531..6b2aee9ea 100644
--- a/lib/challenge/proofofwork/proofofwork_test.go
+++ b/lib/challenge/proofofwork/proofofwork_test.go
@@ -8,8 +8,8 @@ import (
"testing"
"github.com/TecharoHQ/anubis/lib/challenge"
+ "github.com/TecharoHQ/anubis/lib/config"
"github.com/TecharoHQ/anubis/lib/policy"
- "github.com/TecharoHQ/anubis/lib/policy/config"
)
func mkRequest(t *testing.T, values map[string]string) *http.Request {
@@ -30,13 +30,68 @@ func mkRequest(t *testing.T, values map[string]string) *http.Request {
return req
}
+// TestValidateNilRuleChallenge reproduces the panic from
+// https://github.com/TecharoHQ/anubis/issues/1463
+//
+// When a threshold rule matches during PassChallenge, check() can return
+// a policy.Bot with Challenge == nil. After hydrateChallengeRule fails to
+// run (or the error path hits before it), Validate dereferences
+// rule.Challenge.Difficulty and panics.
+func TestValidateNilRuleChallenge(t *testing.T) {
+ i := &Impl{Algorithm: "fast"}
+ lg := slog.With()
+
+ // This is the exact response for SHA256("hunter" + "0") with 0 leading zeros required.
+ const challengeStr = "hunter"
+ const response = "2652bdba8fb4d2ab39ef28d8534d7694c557a4ae146c1e9237bd8d950280500e"
+
+ req := mkRequest(t, map[string]string{
+ "nonce": "0",
+ "elapsedTime": "69",
+ "response": response,
+ })
+
+ for _, tc := range []struct {
+ name string
+ input *challenge.ValidateInput
+ }{
+ {
+ name: "nil-rule-challenge",
+ input: &challenge.ValidateInput{
+ Rule: &policy.Bot{},
+ Challenge: &challenge.Challenge{RandomData: challengeStr},
+ },
+ },
+ {
+ name: "nil-rule",
+ input: &challenge.ValidateInput{
+ Challenge: &challenge.Challenge{RandomData: challengeStr},
+ },
+ },
+ {
+ name: "nil-challenge",
+ input: &challenge.ValidateInput{Rule: &policy.Bot{Challenge: &config.ChallengeRules{Algorithm: "fast"}}},
+ },
+ {
+ name: "nil-input",
+ input: nil,
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ err := i.Validate(req, lg, tc.input)
+ if !errors.Is(err, challenge.ErrInvalidInput) {
+ t.Fatalf("expected ErrInvalidInput, got: %v", err)
+ }
+ })
+ }
+}
+
func TestBasic(t *testing.T) {
i := &Impl{Algorithm: "fast"}
bot := &policy.Bot{
Challenge: &config.ChallengeRules{
Algorithm: "fast",
Difficulty: 0,
- ReportAs: 0,
},
}
const challengeStr = "hunter"
@@ -125,7 +180,9 @@ func TestBasic(t *testing.T) {
t.Run(cs.name, func(t *testing.T) {
lg := slog.With()
- i.Setup(http.NewServeMux())
+ if err := i.Setup(http.NewServeMux()); err != nil {
+ t.Fatalf("can't set up server %v", err)
+ }
inp := &challenge.IssueInput{
Rule: bot,
diff --git a/lib/challenge/proofofwork/static/js/.gitignore b/lib/challenge/proofofwork/static/js/.gitignore
new file mode 100644
index 000000000..d6b7ef32c
--- /dev/null
+++ b/lib/challenge/proofofwork/static/js/.gitignore
@@ -0,0 +1,2 @@
+*
+!.gitignore
diff --git a/lib/challenge/wasm/wasm.go b/lib/challenge/wasm/wasm.go
new file mode 100644
index 000000000..879853b38
--- /dev/null
+++ b/lib/challenge/wasm/wasm.go
@@ -0,0 +1,107 @@
+package wasm
+
+import (
+ "context"
+ "encoding/hex"
+ "fmt"
+ "log/slog"
+ "net/http"
+ "strconv"
+
+ chall "github.com/TecharoHQ/anubis/lib/challenge"
+ "github.com/TecharoHQ/anubis/lib/localization"
+ "github.com/TecharoHQ/anubis/wasm"
+ "github.com/TecharoHQ/anubis/web"
+ "github.com/a-h/templ"
+)
+
+//go:generate go tool github.com/a-h/templ/cmd/templ generate --log-level=error
+
+func init() {
+ chall.Register("argon2id", &Impl{algorithm: "argon2id"})
+ chall.Register("hashx", &Impl{algorithm: "hashx"})
+ chall.Register("sha256", &Impl{algorithm: "sha256"})
+}
+
+type Impl struct {
+ algorithm string
+ runner *wasm.Runner
+}
+
+func (i *Impl) Setup(mux *http.ServeMux) error {
+ fname := fmt.Sprintf("static/wasm/simd128/%s.wasm", i.algorithm)
+ fin, err := web.Static.Open(fname)
+ if err != nil {
+ return err
+ }
+ defer fin.Close() //nolint:errcheck
+
+ i.runner, err = wasm.NewRunner(context.Background(), fname, fin)
+
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func (i *Impl) Issue(w http.ResponseWriter, r *http.Request, lg *slog.Logger, in *chall.IssueInput) (templ.Component, error) {
+ loc := localization.GetLocalizer(r)
+ return page(loc), nil
+}
+
+func (i *Impl) Validate(r *http.Request, lg *slog.Logger, in *chall.ValidateInput) error {
+ if err := in.Valid(); err != nil {
+ return err
+ }
+
+ nonceStr := r.FormValue("nonce")
+ if nonceStr == "" {
+ return chall.NewError("validate", "invalid response", fmt.Errorf("%w nonce", chall.ErrMissingField))
+ }
+
+ nonce, err := strconv.Atoi(nonceStr)
+ if err != nil {
+ return chall.NewError("validate", "invalid response", fmt.Errorf("%w: nonce: %w", chall.ErrInvalidFormat, err))
+
+ }
+
+ elapsedTimeStr := r.FormValue("elapsedTime")
+ if elapsedTimeStr == "" {
+ return chall.NewError("validate", "invalid response", fmt.Errorf("%w elapsedTime", chall.ErrMissingField))
+ }
+
+ elapsedTime, err := strconv.ParseFloat(elapsedTimeStr, 64)
+ if err != nil {
+ return chall.NewError("validate", "invalid response", fmt.Errorf("%w: elapsedTime: %w", chall.ErrInvalidFormat, err))
+ }
+
+ response := r.FormValue("response")
+ if response == "" {
+ return chall.NewError("validate", "invalid response", fmt.Errorf("%w response", chall.ErrMissingField))
+ }
+
+ challengeBytes, err := hex.DecodeString(in.Challenge.RandomData)
+ if err != nil {
+ return chall.NewError("validate", "invalid random data", fmt.Errorf("can't decode random data: %w", err))
+ }
+
+ gotBytes, err := hex.DecodeString(response)
+ if err != nil {
+ return chall.NewError("validate", "invalid client data format", fmt.Errorf("%w response", chall.ErrInvalidFormat))
+ }
+
+ ok, err := i.runner.Verify(r.Context(), challengeBytes, gotBytes, uint32(nonce), uint32(in.Rule.Challenge.Difficulty))
+ if err != nil {
+ return chall.NewError("validate", "internal WASM error", fmt.Errorf("can't run wasm validation logic: %w", err))
+ }
+
+ if !ok {
+ return chall.NewError("verify", "client calculated wrong data", fmt.Errorf("%w: response invalid: %s", chall.ErrFailed, response))
+ }
+
+ lg.DebugContext(r.Context(), "challenge took", "elapsedTime", elapsedTime)
+ chall.TimeTaken.WithLabelValues(i.algorithm).Observe(elapsedTime)
+
+ return nil
+}
diff --git a/lib/challenge/wasm/wasm.templ b/lib/challenge/wasm/wasm.templ
new file mode 100644
index 000000000..ad6637e0d
--- /dev/null
+++ b/lib/challenge/wasm/wasm.templ
@@ -0,0 +1,47 @@
+package wasm
+
+import (
+ "github.com/TecharoHQ/anubis"
+ "github.com/TecharoHQ/anubis/lib/challenge/challengepage"
+ "github.com/TecharoHQ/anubis/lib/localization"
+)
+
+templ page(localizer *localization.SimpleLocalizer) {
+
+

+

+
{ localizer.T("loading") }
+
{ localizer.T("script_load_error") }
+
+ @challengepage.Bootstrap()
+
+
+ if anubis.UseSimplifiedExplanation {
+
+ { localizer.T("simplified_explanation") }
+
+ } else {
+
+ { localizer.T("ai_companies_explanation") }
+
+
+ { localizer.T("anubis_compromise") }
+
+
+ { localizer.T("hack_purpose") }
+
+
+ { localizer.T("jshelter_note") }
+
+ }
+
+
+
+
+}
diff --git a/lib/challenge/wasm/wasm_templ.go b/lib/challenge/wasm/wasm_templ.go
new file mode 100644
index 000000000..d8d77a9d6
--- /dev/null
+++ b/lib/challenge/wasm/wasm_templ.go
@@ -0,0 +1,212 @@
+// Code generated by templ - DO NOT EDIT.
+
+// templ: version: v0.3.1020
+package wasm
+
+//lint:file-ignore SA4006 This context is only used if a nested component is present.
+
+import "github.com/a-h/templ"
+import templruntime "github.com/a-h/templ/runtime"
+
+import (
+ "github.com/TecharoHQ/anubis"
+ "github.com/TecharoHQ/anubis/lib/challenge/challengepage"
+ "github.com/TecharoHQ/anubis/lib/localization"
+)
+
+func page(localizer *localization.SimpleLocalizer) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var1 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var1 == nil {
+ templ_7745c5c3_Var1 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
)
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var4 string
+ templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T("loading"))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `wasm.templ`, Line: 13, Col: 41}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var5 string
+ templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T("script_load_error"))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `wasm.templ`, Line: 14, Col: 86}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = challengepage.Bootstrap().Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if anubis.UseSimplifiedExplanation {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var7 string
+ templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T("simplified_explanation"))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `wasm.templ`, Line: 23, Col: 44}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var8 string
+ templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T("ai_companies_explanation"))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `wasm.templ`, Line: 27, Col: 46}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var9 string
+ templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T("anubis_compromise"))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `wasm.templ`, Line: 30, Col: 39}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var10 string
+ templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T("hack_purpose"))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `wasm.templ`, Line: 33, Col: 34}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var11 string
+ templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T("jshelter_note"))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `wasm.templ`, Line: 36, Col: 35}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
+var _ = templruntime.GeneratedTemplate
diff --git a/lib/challenge/wasm/wasm_test.go b/lib/challenge/wasm/wasm_test.go
new file mode 100644
index 000000000..6c843458b
--- /dev/null
+++ b/lib/challenge/wasm/wasm_test.go
@@ -0,0 +1,365 @@
+package wasm
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "log/slog"
+ "net/http"
+ "runtime"
+ "strconv"
+ "testing"
+
+ "github.com/TecharoHQ/anubis/lib/challenge"
+ "github.com/TecharoHQ/anubis/lib/config"
+ "github.com/TecharoHQ/anubis/lib/policy"
+ "github.com/TecharoHQ/anubis/wasm"
+ "github.com/TecharoHQ/anubis/web"
+)
+
+// difficulty is intentionally tiny so that solving the proof of work in tests
+// is effectively instant.
+const difficulty = 4
+
+// mkRequest builds a GET request whose query parameters become the form values
+// read by Validate via r.FormValue.
+func mkRequest(t *testing.T, values map[string]string) *http.Request {
+ t.Helper()
+
+ req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ q := req.URL.Query()
+ for k, v := range values {
+ q.Set(k, v)
+ }
+ req.URL.RawQuery = q.Encode()
+
+ return req
+}
+
+// solve runs the same WASM module the server uses to compute a genuinely valid
+// (nonce, response) pair for the given challenge data, so the happy-path case
+// exercises the full verification flow rather than a hand-rolled constant.
+func solve(t *testing.T, algorithm string, data []byte) (nonce string, response string) {
+ t.Helper()
+
+ fname := fmt.Sprintf("static/wasm/baseline/%s.wasm", algorithm)
+ fin, err := web.Static.Open(fname)
+ if err != nil {
+ t.Fatalf("can't open %s: %v", fname, err)
+ }
+ defer fin.Close() //nolint:errcheck
+
+ runner, err := wasm.NewRunner(t.Context(), fname, fin)
+ if err != nil {
+ t.Fatalf("can't create runner for %s: %v", algorithm, err)
+ }
+
+ n, hash, err := runner.Run(t.Context(), data, difficulty, 0, 1)
+ if err != nil {
+ t.Fatalf("can't compute valid solution: %v", err)
+ }
+
+ return strconv.Itoa(int(n)), hex.EncodeToString(hash)
+}
+
+// TestValidateAdversarial feeds Validate a matrix of malformed and hostile
+// inputs. Several of these cases are regression guards:
+//
+// - the "nil-*" cases reproduce the #1463-class nil-pointer panic that occurs
+// when Validate dereferences in.Challenge / in.Rule.Challenge without first
+// calling in.Valid().
+// - "response-wrong-length" reproduces the nil api.Module panic in
+// Runner.Verify when writeVerification rejects a correctly-encoded but
+// wrong-sized response.
+//
+// None of these inputs may panic, and each must surface a clean error. The matrix is run
+// against every WASM algorithm so an algorithm-specific validation path can't regress.
+func TestValidateAdversarial(t *testing.T) {
+ for _, algorithm := range []string{"sha256", "hashx", "argon2id"} {
+ t.Run(algorithm, func(t *testing.T) {
+ if runtime.GOARCH == "arm64" && algorithm == "argon2id" {
+ t.Skip("skipping argon2id on aarch64 because validation is slow in the interpreter")
+ }
+ testValidateAdversarial(t, algorithm)
+ })
+ }
+}
+
+func testValidateAdversarial(t *testing.T, algorithm string) {
+ t.Helper()
+
+ i := &Impl{algorithm: algorithm}
+ if err := i.Setup(http.NewServeMux()); err != nil {
+ t.Fatalf("can't set up %s challenge: %v", algorithm, err)
+ }
+
+ lg := slog.New(slog.DiscardHandler)
+
+ // A challenge whose RandomData is valid hex-encoded bytes.
+ challengeBytes := sha256.Sum256([]byte(t.Name()))
+ randomData := hex.EncodeToString(challengeBytes[:])
+
+ validNonce, validResponse := solve(t, algorithm, challengeBytes[:])
+
+ bot := &policy.Bot{
+ Challenge: &config.ChallengeRules{
+ Algorithm: algorithm,
+ Difficulty: difficulty,
+ },
+ }
+
+ // goodInput returns a fresh, fully-populated ValidateInput so individual
+ // cases can override only what they want to break.
+ goodInput := func() *challenge.ValidateInput {
+ return &challenge.ValidateInput{
+ Rule: bot,
+ Challenge: &challenge.Challenge{RandomData: randomData},
+ }
+ }
+
+ // goodValues returns a fresh set of form values for a valid solution.
+ goodValues := func() map[string]string {
+ return map[string]string{
+ "nonce": validNonce,
+ "elapsedTime": "42",
+ "response": validResponse,
+ }
+ }
+
+ // errNotNil asserts only that an error was returned (used where the failure
+ // path does not wrap a package sentinel).
+ errNotNil := func(err error) bool { return err != nil }
+
+ tests := []struct {
+ name string
+ values map[string]string
+ input *challenge.ValidateInput
+ wantErr error // matched with errors.Is; nil means expect success
+ errCheck func(err error) bool // optional override; when set, wantErr is ignored
+ }{
+ {
+ name: "valid-solution-passes",
+ values: goodValues(),
+ input: goodInput(),
+ wantErr: nil,
+ },
+ {
+ name: "no-fields",
+ values: map[string]string{},
+ input: goodInput(),
+ wantErr: challenge.ErrMissingField,
+ },
+ {
+ name: "missing-nonce",
+ values: map[string]string{
+ "elapsedTime": "42",
+ "response": validResponse,
+ },
+ input: goodInput(),
+ wantErr: challenge.ErrMissingField,
+ },
+ {
+ name: "missing-elapsedTime",
+ values: map[string]string{
+ "nonce": validNonce,
+ "response": validResponse,
+ },
+ input: goodInput(),
+ wantErr: challenge.ErrMissingField,
+ },
+ {
+ name: "missing-response",
+ values: map[string]string{
+ "nonce": validNonce,
+ "elapsedTime": "42",
+ },
+ input: goodInput(),
+ wantErr: challenge.ErrMissingField,
+ },
+ {
+ name: "nonce-not-an-integer",
+ values: map[string]string{
+ "nonce": "taco",
+ "elapsedTime": "42",
+ "response": validResponse,
+ },
+ input: goodInput(),
+ wantErr: challenge.ErrInvalidFormat,
+ },
+ {
+ name: "nonce-overflows-int",
+ values: map[string]string{
+ "nonce": "999999999999999999999999999999",
+ "elapsedTime": "42",
+ "response": validResponse,
+ },
+ input: goodInput(),
+ wantErr: challenge.ErrInvalidFormat,
+ },
+ {
+ name: "elapsedTime-not-a-number",
+ values: map[string]string{
+ "nonce": validNonce,
+ "elapsedTime": "taco",
+ "response": validResponse,
+ },
+ input: goodInput(),
+ wantErr: challenge.ErrInvalidFormat,
+ },
+ {
+ name: "response-not-hex",
+ values: map[string]string{
+ "nonce": validNonce,
+ "elapsedTime": "42",
+ "response": "zz",
+ },
+ input: goodInput(),
+ wantErr: challenge.ErrInvalidFormat,
+ },
+ {
+ // Correctly hex-encoded but the wrong number of bytes. This drove a
+ // nil api.Module dereference panic in Runner.Verify.
+ name: "response-wrong-length",
+ values: map[string]string{
+ "nonce": validNonce,
+ "elapsedTime": "42",
+ "response": "abcd",
+ },
+ input: goodInput(),
+ errCheck: errNotNil,
+ },
+ {
+ // Correct length, valid hex, but not the hash we asked for.
+ name: "response-wrong-hash",
+ values: map[string]string{
+ "nonce": "0",
+ "elapsedTime": "42",
+ "response": hex.EncodeToString(make([]byte, sha256.Size)),
+ },
+ input: goodInput(),
+ wantErr: challenge.ErrFailed,
+ },
+ {
+ name: "nil-input",
+ values: goodValues(),
+ input: nil,
+ wantErr: challenge.ErrInvalidInput,
+ },
+ {
+ name: "nil-rule",
+ values: goodValues(),
+ input: &challenge.ValidateInput{Challenge: &challenge.Challenge{RandomData: randomData}},
+ wantErr: challenge.ErrInvalidInput,
+ },
+ {
+ name: "nil-rule-challenge",
+ values: goodValues(),
+ input: &challenge.ValidateInput{Rule: &policy.Bot{}, Challenge: &challenge.Challenge{RandomData: randomData}},
+ wantErr: challenge.ErrInvalidInput,
+ },
+ {
+ name: "nil-challenge",
+ values: goodValues(),
+ input: &challenge.ValidateInput{Rule: bot},
+ wantErr: challenge.ErrInvalidInput,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ req := mkRequest(t, tt.values)
+
+ err := i.Validate(req, lg, tt.input)
+
+ if tt.errCheck != nil {
+ if !tt.errCheck(err) {
+ t.Fatalf("error check failed, got: %v", err)
+ }
+ return
+ }
+
+ if !errors.Is(err, tt.wantErr) {
+ t.Fatalf("got error %v, want %v", err, tt.wantErr)
+ }
+ })
+ }
+}
+
+// TestSolveVerifyManyChallenges runs many independent random challenges through the full
+// solve + Validate path for every algorithm. It guards against rare, seed-dependent
+// failures: most importantly the HashX ProgramConstraints seed rejection, which must be
+// handled deterministically (see make_hashx in wasm/pow/hashx) rather than trapping.
+func TestSolveVerifyManyChallenges(t *testing.T) {
+ counts := map[string]int{
+ "sha256": 200,
+ "hashx": 200,
+ "argon2id": 20, // argon2id is deliberately slow; keep the loop short
+ }
+
+ for _, algorithm := range []string{"sha256", "hashx", "argon2id"} {
+ t.Run(algorithm, func(t *testing.T) {
+ if runtime.GOARCH == "arm64" && algorithm == "argon2id" {
+ t.Skip("skipping argon2id on aarch64 because validation is slow in the interpreter")
+ }
+ n := counts[algorithm]
+ if testing.Short() {
+ n = 5
+ }
+
+ i := &Impl{algorithm: algorithm}
+ if err := i.Setup(http.NewServeMux()); err != nil {
+ t.Fatalf("can't set up %s challenge: %v", algorithm, err)
+ }
+
+ // One runner shared across iterations to avoid recompiling per solve.
+ fname := fmt.Sprintf("static/wasm/simd128/%s.wasm", algorithm)
+ fin, err := web.Static.Open(fname)
+ if err != nil {
+ t.Fatalf("can't open %s: %v", fname, err)
+ }
+ defer fin.Close() //nolint:errcheck
+
+ runner, err := wasm.NewRunner(t.Context(), fname, fin)
+ if err != nil {
+ t.Fatalf("can't create runner for %s: %v", algorithm, err)
+ }
+
+ lg := slog.New(slog.DiscardHandler)
+ bot := &policy.Bot{
+ Challenge: &config.ChallengeRules{
+ Algorithm: algorithm,
+ Difficulty: difficulty,
+ },
+ }
+
+ for j := range n {
+ challengeBytes := sha256.Sum256(fmt.Appendf(nil, "%s-%d", t.Name(), j))
+
+ nonce, hash, err := runner.Run(t.Context(), challengeBytes[:], difficulty, 0, 1)
+ if err != nil {
+ t.Fatalf("challenge %d: can't compute solution: %v", j, err)
+ }
+
+ req := mkRequest(t, map[string]string{
+ "nonce": strconv.Itoa(int(nonce)),
+ "elapsedTime": "42",
+ "response": hex.EncodeToString(hash),
+ })
+
+ in := &challenge.ValidateInput{
+ Rule: bot,
+ Challenge: &challenge.Challenge{RandomData: hex.EncodeToString(challengeBytes[:])},
+ }
+
+ if err := i.Validate(req, lg, in); err != nil {
+ t.Fatalf("challenge %d failed to validate: %v", j, err)
+ }
+ }
+ })
+ }
+}
diff --git a/lib/challenge_page_test.go b/lib/challenge_page_test.go
new file mode 100644
index 000000000..8ab2377c3
--- /dev/null
+++ b/lib/challenge_page_test.go
@@ -0,0 +1,75 @@
+package lib
+
+import (
+ "log/slog"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/TecharoHQ/anubis/lib/challenge"
+ "github.com/TecharoHQ/anubis/lib/challenge/challengetest"
+ "github.com/TecharoHQ/anubis/lib/config"
+ "github.com/TecharoHQ/anubis/lib/policy"
+)
+
+// TestChallengePagesHaveScriptWatchdog makes sure every challenge that hands
+// its work to main.mjs also ships the bootstrap watchdog that re-injects
+// main.mjs when it fails to load.
+func TestChallengePagesHaveScriptWatchdog(t *testing.T) {
+ needsWatchdog := map[string]bool{
+ "argon2id": true,
+ "fast": true,
+ "hashx": true,
+ "metarefresh": false,
+ "preact": false,
+ "sha256": true,
+ "slow": true,
+ }
+
+ methods := challenge.Methods()
+
+ for _, method := range methods {
+ if _, ok := needsWatchdog[method]; !ok {
+ t.Errorf("challenge method %s is not listed in this test: does its page need the bootstrap watchdog?", method)
+ }
+ }
+
+ for _, method := range methods {
+ t.Run(method, func(t *testing.T) {
+ impl, ok := challenge.Get(method)
+ if !ok {
+ t.Fatalf("challenge method %s is registered but can't be fetched", method)
+ }
+
+ in := &challenge.IssueInput{
+ Rule: &policy.Bot{
+ Challenge: &config.ChallengeRules{
+ Algorithm: method,
+ Difficulty: 4,
+ },
+ },
+ Challenge: challengetest.New(t),
+ }
+
+ cmp, err := impl.Issue(httptest.NewRecorder(), httptest.NewRequest("GET", "/", nil), slog.With(), in)
+ if err != nil {
+ t.Fatalf("can't issue challenge: %v", err)
+ }
+
+ var sb strings.Builder
+ if err := cmp.Render(t.Context(), &sb); err != nil {
+ t.Fatalf("can't render challenge page: %v", err)
+ }
+
+ out := sb.String()
+ markers := []string{`id="anubis-main"`, `id="anubis-script-error"`, `id="progress"`}
+
+ for _, marker := range markers {
+ got := strings.Contains(out, marker)
+ if want := needsWatchdog[method]; got != want {
+ t.Errorf("page contains %s: got %v, wanted %v", marker, got, want)
+ }
+ }
+ })
+ }
+}
diff --git a/lib/config.go b/lib/config.go
index 3c220dc5a..133745a68 100644
--- a/lib/config.go
+++ b/lib/config.go
@@ -16,41 +16,49 @@ import (
"github.com/TecharoHQ/anubis"
"github.com/TecharoHQ/anubis/data"
"github.com/TecharoHQ/anubis/internal"
+ "github.com/TecharoHQ/anubis/internal/honeypot/naive"
"github.com/TecharoHQ/anubis/internal/ogtags"
"github.com/TecharoHQ/anubis/lib/challenge"
+ "github.com/TecharoHQ/anubis/lib/challenge/extension"
+ "github.com/TecharoHQ/anubis/lib/config"
"github.com/TecharoHQ/anubis/lib/localization"
"github.com/TecharoHQ/anubis/lib/policy"
- "github.com/TecharoHQ/anubis/lib/policy/config"
"github.com/TecharoHQ/anubis/web"
"github.com/TecharoHQ/anubis/xess"
"github.com/a-h/templ"
)
type Options struct {
- Next http.Handler
- Policy *policy.ParsedConfig
- Target string
- CookieDynamicDomain bool
- CookieDomain string
- CookieExpiration time.Duration
- CookiePartitioned bool
- BasePrefix string
- WebmasterEmail string
- RedirectDomains []string
- ED25519PrivateKey ed25519.PrivateKey
- HS512Secret []byte
- StripBasePrefix bool
- OpenGraph config.OpenGraph
- ServeRobotsTXT bool
- CookieSecure bool
- CookieSameSite http.SameSite
- Logger *slog.Logger
- PublicUrl string
- JWTRestrictionHeader string
- DifficultyInJWT bool
+ Next http.Handler
+ Policy *policy.ParsedConfig
+ Target string
+ TargetHost string
+ TargetSNI string
+ TargetInsecureSkipVerify bool
+ CookieDynamicDomain bool
+ CookieDomain string
+ CookieExpiration time.Duration
+ CookiePartitioned bool
+ BasePrefix string
+ WebmasterEmail string
+ RedirectDomains []string
+ ED25519PrivateKey ed25519.PrivateKey
+ HS512Secret []byte
+ StripBasePrefix bool
+ OpenGraph config.OpenGraph
+ ServeRobotsTXT bool
+ CookieSecure bool
+ CookieHttpOnly bool
+ CookieSameSite http.SameSite
+ Logger *slog.Logger
+ LogLevel string
+ PublicUrl string
+ JWTRestrictionHeader string
+ DifficultyInJWT bool
+ PreserveRefererQueryParam bool
}
-func LoadPoliciesOrDefault(ctx context.Context, fname string, defaultDifficulty int) (*policy.ParsedConfig, error) {
+func LoadPoliciesOrDefault(ctx context.Context, fname string, defaultDifficulty int, logLevel string, subrequestMode bool) (*policy.ParsedConfig, error) {
var fin io.ReadCloser
var err error
@@ -70,11 +78,11 @@ func LoadPoliciesOrDefault(ctx context.Context, fname string, defaultDifficulty
defer func(fin io.ReadCloser) {
err := fin.Close()
if err != nil {
- slog.Error("failed to close policy file", "file", fname, "err", err)
+ slog.ErrorContext(ctx, "failed to close policy file", "file", fname, "err", err)
}
}(fin)
- anubisPolicy, err := policy.ParseConfig(ctx, fin, fname, defaultDifficulty)
+ anubisPolicy, err := policy.ParseConfig(ctx, fin, fname, defaultDifficulty, logLevel, subrequestMode)
if err != nil {
return nil, fmt.Errorf("can't parse policy file %s: %w", fname, err)
}
@@ -84,6 +92,15 @@ func LoadPoliciesOrDefault(ctx context.Context, fname string, defaultDifficulty
if _, ok := challenge.Get(b.Challenge.Algorithm); !ok {
validationErrs = append(validationErrs, fmt.Errorf("%w %s", policy.ErrChallengeRuleHasWrongAlgorithm, b.Challenge.Algorithm))
}
+ if err := checkExtensions(b.Name, b.Challenge); err != nil {
+ validationErrs = append(validationErrs, err)
+ }
+ }
+
+ for _, t := range anubisPolicy.Thresholds {
+ if err := checkExtensions(t.Name, t.Challenge); err != nil {
+ validationErrs = append(validationErrs, err)
+ }
}
if len(validationErrs) != 0 {
@@ -93,6 +110,26 @@ func LoadPoliciesOrDefault(ctx context.Context, fname string, defaultDifficulty
return anubisPolicy, err
}
+func checkExtensions(ruleName string, cr *config.ChallengeRules) error {
+ if cr == nil {
+ return nil
+ }
+
+ var errs []error
+
+ for _, name := range cr.Extensions {
+ if _, ok := extension.Get(name); !ok {
+ errs = append(errs, fmt.Errorf("%w %q in rule %q, known extensions: %v", ErrUnknownChallengeExtension, name, ruleName, extension.Names()))
+ }
+ }
+
+ if len(errs) != 0 {
+ return errors.Join(errs...)
+ }
+
+ return nil
+}
+
func New(opts Options) (*Server, error) {
if opts.Logger == nil {
opts.Logger = slog.With("subsystem", "anubis")
@@ -116,9 +153,13 @@ func New(opts Options) (*Server, error) {
hs512Secret: opts.HS512Secret,
policy: opts.Policy,
opts: opts,
- OGTags: ogtags.NewOGTagCache(opts.Target, opts.Policy.OpenGraph, opts.Policy.Store),
- store: opts.Policy.Store,
- logger: opts.Logger,
+ OGTags: ogtags.NewOGTagCache(opts.Target, opts.Policy.OpenGraph, opts.Policy.Store, ogtags.TargetOptions{
+ Host: opts.TargetHost,
+ SNI: opts.TargetSNI,
+ InsecureSkipVerify: opts.TargetInsecureSkipVerify,
+ }),
+ store: opts.Policy.Store,
+ logger: opts.Logger,
}
mux := http.NewServeMux()
@@ -158,7 +199,7 @@ func New(opts Options) (*Server, error) {
if opts.Policy.Impressum != nil {
registerWithPrefix(anubis.APIPrefix+"imprint", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
templ.Handler(
- web.Base(opts.Policy.Impressum.Page.Title, opts.Policy.Impressum.Page, opts.Policy.Impressum, localization.GetLocalizer(r)),
+ web.Base(opts.Policy.Impressum.Page.Title, opts.Policy.Impressum.Page, opts.Policy.Impressum, opts.Policy.Honeypot, localization.GetLocalizer(r)),
).ServeHTTP(w, r)
}), "GET")
}
@@ -167,15 +208,51 @@ func New(opts Options) (*Server, error) {
registerWithPrefix(anubis.APIPrefix+"check", http.HandlerFunc(result.maybeReverseProxyHttpStatusOnly), "")
registerWithPrefix("/", http.HandlerFunc(result.maybeReverseProxyOrPage), "")
+ if opts.Policy.Honeypot != nil && opts.Policy.Honeypot.Enabled {
+ mazeGen, err := naive.New(opts.Policy.Honeypot, result.store, result.logger)
+ if err == nil {
+ registerWithPrefix(anubis.APIPrefix+"honeypot/{id}/{stage}", mazeGen, http.MethodGet)
+
+ opts.Policy.Bots = append(
+ opts.Policy.Bots,
+ policy.Bot{
+ Rules: mazeGen.CheckNetwork(),
+ Action: config.RuleWeigh,
+ Weight: &config.Weight{
+ Adjust: 30,
+ },
+ Name: "honeypot/network",
+ },
+ )
+ } else {
+ result.logger.Error("can't init honeypot subsystem", "err", err)
+ }
+ }
+
//goland:noinspection GoBoolExpressions
if anubis.Version == "devel" {
// make-challenge is only used in tests. Only enable while version is devel
registerWithPrefix(anubis.APIPrefix+"make-challenge", http.HandlerFunc(result.MakeChallenge), "POST")
}
+ var challSetupErrs []error
+
for _, implKind := range challenge.Methods() {
impl, _ := challenge.Get(implKind)
- impl.Setup(mux)
+ if err := impl.Setup(mux); err != nil {
+ challSetupErrs = append(challSetupErrs, fmt.Errorf("error setting up challenge method %s: %w", implKind, err))
+ }
+ }
+
+ for _, implKind := range extension.Names() {
+ impl, _ := extension.Get(implKind)
+ if err := impl.Setup(mux, result.store); err != nil {
+ challSetupErrs = append(challSetupErrs, fmt.Errorf("error setting up challenge method %s: %w", implKind, err))
+ }
+ }
+
+ if len(challSetupErrs) != 0 {
+ return nil, fmt.Errorf("error setting up challenge methods: %w", errors.Join(challSetupErrs...))
}
result.mux = mux
diff --git a/lib/policy/config/asn.go b/lib/config/asn.go
similarity index 100%
rename from lib/policy/config/asn.go
rename to lib/config/asn.go
diff --git a/lib/policy/config/asn_test.go b/lib/config/asn_test.go
similarity index 100%
rename from lib/policy/config/asn_test.go
rename to lib/config/asn_test.go
index 4967d2c0b..9653a4656 100644
--- a/lib/policy/config/asn_test.go
+++ b/lib/config/asn_test.go
@@ -8,9 +8,9 @@ import (
func TestASNsValid(t *testing.T) {
for _, tt := range []struct {
- name string
- input *ASNs
err error
+ input *ASNs
+ name string
}{
{
name: "basic valid",
diff --git a/lib/policy/config/check.go b/lib/config/check.go
similarity index 100%
rename from lib/policy/config/check.go
rename to lib/config/check.go
diff --git a/lib/policy/config/config.go b/lib/config/config.go
similarity index 80%
rename from lib/policy/config/config.go
rename to lib/config/config.go
index 6b5946ae9..ee96198b7 100644
--- a/lib/policy/config/config.go
+++ b/lib/config/config.go
@@ -4,22 +4,19 @@ import (
"errors"
"fmt"
"io"
- "io/fs"
"net"
"net/http"
- "os"
"regexp"
"strings"
"time"
- "github.com/TecharoHQ/anubis/data"
"k8s.io/apimachinery/pkg/util/yaml"
)
var (
ErrNoBotRulesDefined = errors.New("config: must define at least one (1) bot rule")
ErrBotMustHaveName = errors.New("config.Bot: must set name")
- ErrBotMustHaveUserAgentOrPath = errors.New("config.Bot: must set either user_agent_regex, path_regex, headers_regex, or remote_addresses")
+ ErrBotMustHaveUserAgentOrPath = errors.New("config.Bot: must set one of user_agent_regex, path_regex, headers_regex, remote_addresses, expression, or Thoth keyword")
ErrBotMustHaveUserAgentOrPathNotBoth = errors.New("config.Bot: must set either user_agent_regex, path_regex, and not both")
ErrUnknownAction = errors.New("config.Bot: unknown action")
ErrInvalidUserAgentRegex = errors.New("config.Bot: invalid user agent regex")
@@ -27,7 +24,6 @@ var (
ErrInvalidHeadersRegex = errors.New("config.Bot: invalid headers regex")
ErrInvalidCIDR = errors.New("config.Bot: invalid CIDR")
ErrRegexEndsWithNewline = errors.New("config.Bot: regular expression ends with newline (try >- instead of > in yaml)")
- ErrInvalidImportStatement = errors.New("config.ImportStatement: invalid source file")
ErrCantSetBotAndImportValuesAtOnce = errors.New("config.BotOrImport: can't set bot rules and import values at the same time")
ErrMustSetBotOrImportRules = errors.New("config.BotOrImport: rule definition is invalid, you must set either bot rules or an import statement, not both")
ErrStatusCodeNotValid = errors.New("config.StatusCode: status code not valid, must be between 100 and 599")
@@ -62,13 +58,14 @@ type BotConfig struct {
Expression *ExpressionOrList `json:"expression,omitempty" yaml:"expression,omitempty"`
Challenge *ChallengeRules `json:"challenge,omitempty" yaml:"challenge,omitempty"`
Weight *Weight `json:"weight,omitempty" yaml:"weight,omitempty"`
- Name string `json:"name" yaml:"name"`
- Action Rule `json:"action" yaml:"action"`
- RemoteAddr []string `json:"remote_addresses,omitempty" yaml:"remote_addresses,omitempty"`
// Thoth features
GeoIP *GeoIP `json:"geoip,omitempty"`
ASNs *ASNs `json:"asns,omitempty"`
+
+ Name string `json:"name" yaml:"name"`
+ Action Rule `json:"action" yaml:"action"`
+ RemoteAddr []string `json:"remote_addresses,omitempty" yaml:"remote_addresses,omitempty"`
}
func (b BotConfig) Zero() bool {
@@ -188,15 +185,19 @@ func (b *BotConfig) Valid() error {
}
type ChallengeRules struct {
- Algorithm string `json:"algorithm,omitempty" yaml:"algorithm,omitempty"`
- Difficulty int `json:"difficulty,omitempty" yaml:"difficulty,omitempty"`
- ReportAs int `json:"report_as,omitempty" yaml:"report_as,omitempty"`
+ Algorithm string `json:"algorithm,omitempty" yaml:"algorithm,omitempty"`
+ Difficulty int `json:"difficulty,omitempty" yaml:"difficulty,omitempty"`
+ ReportAs int `json:"report_as,omitempty" yaml:"report_as,omitempty"`
+ Extensions []string `json:"extensions,omitempty" yaml:"extensions,omitempty"`
}
var (
ErrChallengeDifficultyTooLow = errors.New("config.ChallengeRules: difficulty is too low (must be >= 0)")
ErrChallengeDifficultyTooHigh = errors.New("config.ChallengeRules: difficulty is too high (must be <= 64)")
ErrChallengeMustHaveAlgorithm = errors.New("config.ChallengeRules: must have algorithm name set")
+ ErrChallengeExtensionUnknown = errors.New("config.ChallengeRules: unknown challenge extension")
+ ErrChallengeExtensionEmpty = errors.New("config.ChallengeRules: extension name must not be empty")
+ ErrChallengeExtensionRepeated = errors.New("config.ChallengeRules: extension name was repeated")
)
func (cr ChallengeRules) Valid() error {
@@ -214,71 +215,27 @@ func (cr ChallengeRules) Valid() error {
errs = append(errs, fmt.Errorf("%w, got: %d", ErrChallengeDifficultyTooHigh, cr.Difficulty))
}
- if len(errs) != 0 {
- return fmt.Errorf("config: challenge rules entry is not valid:\n%w", errors.Join(errs...))
- }
-
- return nil
-}
-
-type ImportStatement struct {
- Import string `json:"import"`
- Bots []BotConfig
-}
-
-func (is *ImportStatement) open() (fs.File, error) {
- if strings.HasPrefix(is.Import, "(data)/") {
- fname := strings.TrimPrefix(is.Import, "(data)/")
- fin, err := data.BotPolicies.Open(fname)
- return fin, err
- }
-
- return os.Open(is.Import)
-}
-
-func (is *ImportStatement) load() error {
- fin, err := is.open()
- if err != nil {
- return fmt.Errorf("%w: %s: %w", ErrInvalidImportStatement, is.Import, err)
- }
- defer fin.Close()
-
- var imported []BotOrImport
- var result []BotConfig
-
- if err := yaml.NewYAMLToJSONDecoder(fin).Decode(&imported); err != nil {
- return fmt.Errorf("can't parse %s: %w", is.Import, err)
- }
-
- var errs []error
-
- for _, b := range imported {
- if err := b.Valid(); err != nil {
- errs = append(errs, err)
- }
-
- if b.ImportStatement != nil {
- result = append(result, b.ImportStatement.Bots...)
- }
-
- if b.BotConfig != nil {
- result = append(result, *b.BotConfig)
+ if len(cr.Extensions) != 0 {
+ seen := make(map[string]struct{}, len(cr.Extensions))
+ for _, name := range cr.Extensions {
+ _, repeated := seen[name]
+ switch {
+ case name == "":
+ errs = append(errs, ErrChallengeExtensionEmpty)
+ case repeated:
+ errs = append(errs, fmt.Errorf("%w: %q", ErrChallengeExtensionRepeated, name))
+ }
+ seen[name] = struct{}{}
}
}
if len(errs) != 0 {
- return fmt.Errorf("config %s is not valid:\n%w", is.Import, errors.Join(errs...))
+ return fmt.Errorf("config: challenge rules entry is not valid:\n%w", errors.Join(errs...))
}
- is.Bots = result
-
return nil
}
-func (is *ImportStatement) Valid() error {
- return is.load()
-}
-
type BotOrImport struct {
*BotConfig `json:",inline"`
*ImportStatement `json:",inline"`
@@ -324,13 +281,17 @@ func (sc StatusCodes) Valid() error {
}
type fileConfig struct {
- Bots []BotOrImport `json:"bots"`
- DNSBL bool `json:"dnsbl"`
- OpenGraph openGraphFileConfig `json:"openGraph,omitempty"`
+ OpenGraph openGraphFileConfig `json:"openGraph"`
Impressum *Impressum `json:"impressum,omitempty"`
- StatusCodes StatusCodes `json:"status_codes"`
Store *Store `json:"store"`
+ Bots []BotOrImport `json:"bots"`
Thresholds []Threshold `json:"thresholds"`
+ StatusCodes StatusCodes `json:"status_codes"`
+ DNSBL bool `json:"dnsbl"`
+ DNSTTL DnsTTL `json:"dns_ttl"`
+ Logging *Logging `json:"logging"`
+ Metrics *Metrics `json:"metrics,omitempty"`
+ Honeypot *Honeypot `json:"honeypot"`
}
func (c *fileConfig) Valid() error {
@@ -362,12 +323,28 @@ func (c *fileConfig) Valid() error {
}
}
+ if err := c.Logging.Valid(); err != nil {
+ errs = append(errs, err)
+ }
+
if c.Store != nil {
if err := c.Store.Valid(); err != nil {
errs = append(errs, err)
}
}
+ if c.Metrics != nil {
+ if err := c.Metrics.Valid(); err != nil {
+ errs = append(errs, err)
+ }
+ }
+
+ if c.Honeypot != nil {
+ if err := c.Honeypot.Valid(); err != nil {
+ errs = append(errs, err)
+ }
+ }
+
if len(errs) != 0 {
return fmt.Errorf("config is not valid:\n%w", errors.Join(errs...))
}
@@ -381,9 +358,15 @@ func Load(fin io.Reader, fname string) (*Config, error) {
Challenge: http.StatusOK,
Deny: http.StatusOK,
},
+ DNSTTL: DnsTTL{
+ Forward: 300,
+ Reverse: 300,
+ },
Store: &Store{
Backend: "memory",
},
+ Logging: (Logging{}).Default(),
+ Honeypot: new((Honeypot{}).Default()),
}
if err := yaml.NewYAMLToJSONDecoder(fin).Decode(&c); err != nil {
@@ -395,7 +378,8 @@ func Load(fin io.Reader, fname string) (*Config, error) {
}
result := &Config{
- DNSBL: c.DNSBL,
+ DNSBL: c.DNSBL,
+ DNSTTL: c.DNSTTL,
OpenGraph: OpenGraph{
Enabled: c.OpenGraph.Enabled,
ConsiderHost: c.OpenGraph.ConsiderHost,
@@ -403,6 +387,9 @@ func Load(fin io.Reader, fname string) (*Config, error) {
},
StatusCodes: c.StatusCodes,
Store: c.Store,
+ Logging: c.Logging,
+ Metrics: c.Metrics,
+ Honeypot: c.Honeypot,
}
if c.OpenGraph.TimeToLive != "" {
@@ -420,7 +407,7 @@ func Load(fin io.Reader, fname string) (*Config, error) {
continue
}
- result.Bots = append(result.Bots, boi.ImportStatement.Bots...)
+ result.Bots = append(result.Bots, boi.Bots...)
}
if boi.BotConfig != nil {
@@ -461,14 +448,41 @@ func Load(fin io.Reader, fname string) (*Config, error) {
return result, nil
}
+type DnsTTL struct {
+ Forward int `json:"forward"`
+ Reverse int `json:"reverse"`
+}
+
+func (sc DnsTTL) Valid() error {
+ var errs []error
+
+ if sc.Forward < 0 {
+ errs = append(errs, fmt.Errorf("%w: forward TTL is %d", ErrStatusCodeNotValid, sc.Forward))
+ }
+
+ if sc.Reverse < 0 {
+ errs = append(errs, fmt.Errorf("%w: reverse TTL is %d", ErrStatusCodeNotValid, sc.Reverse))
+ }
+
+ if len(errs) != 0 {
+ return fmt.Errorf("dns TTL values not valid:\n%w", errors.Join(errs...))
+ }
+
+ return nil
+}
+
type Config struct {
- Bots []BotConfig
- Thresholds []Threshold
- DNSBL bool
Impressum *Impressum
+ Store *Store
OpenGraph OpenGraph
+ Bots []BotConfig
+ Thresholds []Threshold
StatusCodes StatusCodes
- Store *Store
+ Logging *Logging
+ DNSBL bool
+ DNSTTL DnsTTL
+ Metrics *Metrics
+ Honeypot *Honeypot
}
func (c Config) Valid() error {
diff --git a/lib/policy/config/config_test.go b/lib/config/config_test.go
similarity index 82%
rename from lib/policy/config/config_test.go
rename to lib/config/config_test.go
index 3a96c9c9b..c9da6d140 100644
--- a/lib/policy/config/config_test.go
+++ b/lib/config/config_test.go
@@ -2,22 +2,20 @@ package config_test
import (
"errors"
- "io/fs"
"os"
"path/filepath"
"testing"
- "github.com/TecharoHQ/anubis/data"
- . "github.com/TecharoHQ/anubis/lib/policy/config"
+ . "github.com/TecharoHQ/anubis/lib/config"
)
func p[V any](v V) *V { return &v }
func TestBotValid(t *testing.T) {
var tests = []struct {
+ bot BotConfig
err error
name string
- bot BotConfig
}{
{
name: "simple user agent",
@@ -110,7 +108,6 @@ func TestBotValid(t *testing.T) {
PathRegex: p("Mozilla"),
Challenge: &ChallengeRules{
Difficulty: -1,
- ReportAs: 4,
Algorithm: "fast",
},
},
@@ -124,7 +121,6 @@ func TestBotValid(t *testing.T) {
PathRegex: p("Mozilla"),
Challenge: &ChallengeRules{
Difficulty: 420,
- ReportAs: 4,
Algorithm: "fast",
},
},
@@ -190,7 +186,6 @@ func TestBotValid(t *testing.T) {
}
for _, cs := range tests {
- cs := cs
t.Run(cs.name, func(t *testing.T) {
err := cs.bot.Valid()
if err == nil && cs.err == nil {
@@ -218,13 +213,12 @@ func TestConfigValidKnownGood(t *testing.T) {
}
for _, st := range finfos {
- st := st
t.Run(st.Name(), func(t *testing.T) {
fin, err := os.Open(filepath.Join("testdata", "good", st.Name()))
if err != nil {
t.Fatal(err)
}
- defer fin.Close()
+ defer fin.Close() //nolint:errcheck
c, err := Load(fin, st.Name())
if err != nil {
@@ -242,62 +236,6 @@ func TestConfigValidKnownGood(t *testing.T) {
}
}
-func TestImportStatement(t *testing.T) {
- type testCase struct {
- err error
- name string
- importPath string
- }
-
- var tests []testCase
-
- for _, folderName := range []string{
- "apps",
- "bots",
- "common",
- "crawlers",
- "meta",
- } {
- if err := fs.WalkDir(data.BotPolicies, folderName, func(path string, d fs.DirEntry, err error) error {
- if err != nil {
- return err
- }
- if d.IsDir() {
- return nil
- }
- if d.Name() == "README.md" {
- return nil
- }
-
- tests = append(tests, testCase{
- name: "(data)/" + path,
- importPath: "(data)/" + path,
- err: nil,
- })
-
- return nil
- }); err != nil {
- t.Fatal(err)
- }
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- is := &ImportStatement{
- Import: tt.importPath,
- }
-
- if err := is.Valid(); err != nil {
- t.Errorf("validation error: %v", err)
- }
-
- if len(is.Bots) == 0 {
- t.Error("wanted bot definitions, but got none")
- }
- })
- }
-}
-
func TestConfigValidBad(t *testing.T) {
finfos, err := os.ReadDir("testdata/bad")
if err != nil {
@@ -305,17 +243,16 @@ func TestConfigValidBad(t *testing.T) {
}
for _, st := range finfos {
- st := st
t.Run(st.Name(), func(t *testing.T) {
fin, err := os.Open(filepath.Join("testdata", "bad", st.Name()))
if err != nil {
t.Fatal(err)
}
- defer fin.Close()
+ defer fin.Close() //nolint:errcheck
_, err = Load(fin, filepath.Join("testdata", "bad", st.Name()))
if err == nil {
- t.Fatal("validation should have failed but didn't somehow")
+ t.Fatalf("validation should have failed but didn't somehow: %v", err)
} else {
t.Log(err)
}
@@ -361,7 +298,6 @@ func TestBotConfigZero(t *testing.T) {
b.Challenge = &ChallengeRules{
Difficulty: 4,
- ReportAs: 4,
Algorithm: DefaultAlgorithm,
}
if b.Zero() {
diff --git a/lib/policy/config/expressionorlist.go b/lib/config/expressionorlist.go
similarity index 100%
rename from lib/policy/config/expressionorlist.go
rename to lib/config/expressionorlist.go
diff --git a/lib/policy/config/expressionorlist_test.go b/lib/config/expressionorlist_test.go
similarity index 100%
rename from lib/policy/config/expressionorlist_test.go
rename to lib/config/expressionorlist_test.go
index a09baf3e0..91d467096 100644
--- a/lib/policy/config/expressionorlist_test.go
+++ b/lib/config/expressionorlist_test.go
@@ -11,10 +11,10 @@ import (
func TestExpressionOrListMarshalJSON(t *testing.T) {
for _, tt := range []struct {
- name string
+ err error
input *ExpressionOrList
+ name string
output []byte
- err error
}{
{
name: "single expression",
@@ -74,10 +74,10 @@ func TestExpressionOrListMarshalJSON(t *testing.T) {
func TestExpressionOrListMarshalYAML(t *testing.T) {
for _, tt := range []struct {
- name string
+ err error
input *ExpressionOrList
+ name string
output []byte
- err error
}{
{
name: "single expression",
@@ -217,8 +217,8 @@ func TestExpressionOrListUnmarshalJSON(t *testing.T) {
func TestExpressionOrListString(t *testing.T) {
for _, tt := range []struct {
name string
- in ExpressionOrList
out string
+ in ExpressionOrList
}{
{
name: "single expression",
diff --git a/lib/policy/config/geoip.go b/lib/config/geoip.go
similarity index 100%
rename from lib/policy/config/geoip.go
rename to lib/config/geoip.go
diff --git a/lib/policy/config/geoip_test.go b/lib/config/geoip_test.go
similarity index 100%
rename from lib/policy/config/geoip_test.go
rename to lib/config/geoip_test.go
index c20310fa7..739ada55f 100644
--- a/lib/policy/config/geoip_test.go
+++ b/lib/config/geoip_test.go
@@ -7,9 +7,9 @@ import (
func TestGeoIPValid(t *testing.T) {
for _, tt := range []struct {
- name string
- input *GeoIP
err error
+ input *GeoIP
+ name string
}{
{
name: "basic valid",
diff --git a/lib/config/honeypot.go b/lib/config/honeypot.go
new file mode 100644
index 000000000..f5d4aeba8
--- /dev/null
+++ b/lib/config/honeypot.go
@@ -0,0 +1,44 @@
+package config
+
+import (
+ "errors"
+ "fmt"
+)
+
+var ErrInvalidHoneypotMethod = errors.New("config.Honeypot: invalid implementation selected (try: \"naive\")")
+
+type Honeypot struct {
+ Enabled bool `json:"enabled" yaml:"enabled"`
+ Implementation string `json:"implementation" yaml:"implementation"`
+ IPLogFile string `json:"ip_log_file" yaml:"ip_log_file"`
+}
+
+func (h Honeypot) Valid() error {
+ var errs []error
+
+ // Short-circuit, if disabled then the implementation is moot.
+ if !h.Enabled {
+ return nil
+ }
+
+ if h.Implementation == "" {
+ errs = append(errs, fmt.Errorf("config.Honeypot: missing field \"implementation\": %w", ErrMissingValue))
+ }
+
+ if h.Implementation != "naive" {
+ errs = append(errs, fmt.Errorf("%w: got: %q", ErrInvalidHoneypotMethod, h.Implementation))
+ }
+
+ if len(errs) != 0 {
+ return errors.Join(errs...)
+ }
+
+ return nil
+}
+
+func (h Honeypot) Default() Honeypot {
+ return Honeypot{
+ Enabled: true,
+ Implementation: "naive",
+ }
+}
diff --git a/lib/config/honeypot_test.go b/lib/config/honeypot_test.go
new file mode 100644
index 000000000..efcac6257
--- /dev/null
+++ b/lib/config/honeypot_test.go
@@ -0,0 +1,87 @@
+package config
+
+import (
+ "errors"
+ "testing"
+)
+
+func TestHoneypotValid(t *testing.T) {
+ for _, cs := range []struct {
+ err error
+ name string
+ inp Honeypot
+ }{
+ {
+ name: "naive enabled",
+ inp: Honeypot{
+ Enabled: true,
+ Implementation: "naive",
+ },
+ err: nil,
+ },
+ {
+ name: "naive disabled",
+ inp: Honeypot{
+ Enabled: false,
+ Implementation: "naive",
+ },
+ err: nil,
+ },
+ {
+ name: "empty implementation",
+ inp: Honeypot{
+ Enabled: true,
+ Implementation: "",
+ },
+ err: ErrInvalidHoneypotMethod,
+ },
+ {
+ name: "unknown implementation",
+ inp: Honeypot{
+ Enabled: true,
+ Implementation: "sophisticated",
+ },
+ err: ErrInvalidHoneypotMethod,
+ },
+ {
+ name: "wrong case implementation",
+ inp: Honeypot{
+ Enabled: true,
+ Implementation: "Naive",
+ },
+ err: ErrInvalidHoneypotMethod,
+ },
+ {
+ name: "zero value",
+ inp: Honeypot{},
+ err: nil,
+ },
+ } {
+ t.Run(cs.name, func(t *testing.T) {
+ if err := cs.inp.Valid(); !errors.Is(err, cs.err) {
+ t.Logf("want: %v", cs.err)
+ t.Logf("got: %v", err)
+ t.Error("validation returned an unexpected error")
+ }
+ })
+ }
+}
+
+func TestHoneypotDefault(t *testing.T) {
+ want := Honeypot{
+ Enabled: true,
+ Implementation: "naive",
+ }
+
+ got := (Honeypot{}).Default()
+ if got != want {
+ t.Errorf("Default() = %+v, want %+v", got, want)
+ }
+}
+
+func TestHoneypotDefaultIsValid(t *testing.T) {
+ h := (Honeypot{}).Default()
+ if err := h.Valid(); err != nil {
+ t.Fatal(err)
+ }
+}
diff --git a/lib/config/import.go b/lib/config/import.go
new file mode 100644
index 000000000..b8c054b26
--- /dev/null
+++ b/lib/config/import.go
@@ -0,0 +1,155 @@
+package config
+
+import (
+ "errors"
+ "fmt"
+ "io"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+
+ "github.com/TecharoHQ/anubis/data"
+ "github.com/TecharoHQ/anubis/internal/multifile"
+ "github.com/fvbommel/sortorder"
+ "github.com/goreleaser/fileglob"
+ "k8s.io/apimachinery/pkg/util/yaml"
+)
+
+var (
+ ErrInvalidImportStatement = errors.New("config.ImportStatement: invalid source file")
+ ErrCantReadImportedFile = errors.New("config.ImportStatement: can't read imported file")
+ ErrGlobMatchedNothing = errors.New("config.ImportStatement: glob pattern matched no files")
+)
+
+type ImportStatement struct {
+ Import string `json:"import"`
+ Bots []BotConfig
+}
+
+const globHints = `recursive wildcards ("**") and alternation ("{yaml,yml}") are not supported`
+
+func globMatch(globbedPath string) (fs.FS, []string, error) {
+ var fsys fs.FS = nil
+
+ if after, ok := strings.CutPrefix(globbedPath, "(data)/"); ok {
+ globbedPath = after
+ fsys = data.BotPolicies
+ }
+
+ var matches []string
+ var err error
+
+ switch {
+ case fsys == nil:
+ matches, err = filepath.Glob(globbedPath)
+ case fsys != nil:
+ matches, err = fs.Glob(fsys, globbedPath)
+ }
+
+ if err != nil {
+ return nil, nil, fmt.Errorf("%w: %w", ErrInvalidImportStatement, err)
+ }
+
+ if len(matches) == 0 {
+ if strings.Contains(globbedPath, "**") || strings.Contains(globbedPath, "{") {
+ return nil, nil, fmt.Errorf("%w: note that %s", ErrGlobMatchedNothing, globHints)
+ }
+ return nil, nil, ErrGlobMatchedNothing
+ }
+
+ sort.Sort(sortorder.Natural(matches))
+
+ var errs []error
+
+ for _, fname := range matches {
+ switch {
+ case fsys == nil:
+ if _, err := os.Stat(fname); err != nil {
+ errs = append(errs, fmt.Errorf("%w: %q", ErrCantReadImportedFile, fname))
+ }
+ case fsys != nil:
+ if _, err := fs.Stat(fsys, fname); err != nil {
+ errs = append(errs, fmt.Errorf("%w: %q", ErrCantReadImportedFile, fname))
+ }
+ }
+ }
+
+ if len(errs) != 0 {
+ return nil, nil, fmt.Errorf("%w: %w", ErrInvalidImportStatement, errors.Join(errs...))
+ }
+
+ return fsys, matches, nil
+}
+
+func (is *ImportStatement) open() (fs.File, error) {
+ if fileglob.ContainsMatchers(is.Import) {
+ fsys, fnames, err := globMatch(is.Import)
+ if err != nil {
+ return nil, err
+ }
+
+ return multifile.YAMLList(fsys, fnames)
+ }
+
+ if after, ok := strings.CutPrefix(is.Import, "(data)/"); ok {
+ fname := after
+ fin, err := data.BotPolicies.Open(fname)
+ return fin, err
+ }
+
+ return os.Open(is.Import)
+}
+
+func (is *ImportStatement) load() error {
+ fin, err := is.open()
+ if err != nil {
+ return fmt.Errorf("%w: %s: %w", ErrInvalidImportStatement, is.Import, err)
+ }
+ defer fin.Close() //nolint:errcheck
+
+ var imported []BotOrImport
+ var result []BotConfig
+
+ dec := yaml.NewYAMLToJSONDecoder(fin)
+ for {
+ var doc []BotOrImport
+ if err := dec.Decode(&doc); err != nil {
+ if errors.Is(err, io.EOF) {
+ break
+ }
+ return fmt.Errorf("%w: can't parse %s: %w", ErrInvalidImportStatement, is.Import, err)
+ }
+
+ imported = append(imported, doc...)
+ }
+
+ var errs []error
+
+ for _, b := range imported {
+ if err := b.Valid(); err != nil {
+ errs = append(errs, err)
+ }
+
+ if b.ImportStatement != nil {
+ result = append(result, b.Bots...)
+ }
+
+ if b.BotConfig != nil {
+ result = append(result, *b.BotConfig)
+ }
+ }
+
+ if len(errs) != 0 {
+ return fmt.Errorf("config %s is not valid:\n%w", is.Import, errors.Join(errs...))
+ }
+
+ is.Bots = result
+
+ return nil
+}
+
+func (is *ImportStatement) Valid() error {
+ return is.load()
+}
diff --git a/lib/config/import_test.go b/lib/config/import_test.go
new file mode 100644
index 000000000..c83ea7d93
--- /dev/null
+++ b/lib/config/import_test.go
@@ -0,0 +1,146 @@
+package config
+
+import (
+ "errors"
+ "io/fs"
+ "path/filepath"
+ "slices"
+ "testing"
+
+ "github.com/TecharoHQ/anubis/data"
+)
+
+func TestGlobMatch(t *testing.T) {
+ for _, tt := range []struct {
+ globbedPath string
+ matches []string
+ err error
+ }{
+ {
+ globbedPath: "(data)/apps/allow-api-routes*.yaml",
+ matches: []string{"apps/allow-api-routes.yaml"},
+ },
+ {
+ globbedPath: "./testdata/hack-test.*",
+ matches: []string{"testdata/hack-test.json", "testdata/hack-test.yaml"},
+ },
+ {
+ globbedPath: "/does/not/exist/*",
+ err: ErrGlobMatchedNothing,
+ },
+ {
+ // filepath.Match has no alternation syntax, so the braces are
+ // matched literally and nothing is found.
+ globbedPath: "(data)/bots/*.{yaml,yml}",
+ err: ErrGlobMatchedNothing,
+ },
+ } {
+ t.Run(tt.globbedPath, func(t *testing.T) {
+ _, matches, err := globMatch(tt.globbedPath)
+ if !errors.Is(err, tt.err) {
+ t.Logf("wanted error: %v", tt.err)
+ t.Logf(" got error: %v", err)
+ t.Error("unexpected error received")
+ }
+
+ if !slices.Equal(tt.matches, matches) {
+ t.Logf("wanted files: %#v", tt.matches)
+ t.Logf(" got files: %#v", matches)
+ t.Error("unexpected file matches")
+ }
+ })
+ }
+}
+
+func TestImportMultiDocument(t *testing.T) {
+ for _, tt := range []struct {
+ name string
+ globbedPath string
+ botCount int
+ }{
+ {"multi-document", "./testdata/multi-document/*.yaml", 3},
+ {"single-file", "./testdata/multi-document/small-internet-browsers.yaml", 2},
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ is := &ImportStatement{Import: tt.globbedPath}
+
+ if err := is.Valid(); err != nil {
+ t.Fatal(err)
+ }
+
+ if len(is.Bots) != tt.botCount {
+ t.Errorf("wanted bot count: %d, got: %d", tt.botCount, len(is.Bots))
+ }
+ })
+ }
+}
+
+// TestImportStatementGlobMatchedNothing asserts that an import statement whose
+// glob pattern matches no files fails with ErrGlobMatchedNothing instead of the
+// io.EOF that the YAML decoder would return for an empty document.
+func TestImportStatementGlobMatchedNothing(t *testing.T) {
+ for _, globbedPath := range []string{
+ "(data)/bots/*.yml",
+ "(data)/does-not-exist/*.yaml",
+ "./testdata/does-not-exist/*.yaml",
+ } {
+ t.Run(globbedPath, func(t *testing.T) {
+ is := &ImportStatement{Import: globbedPath}
+
+ err := is.Valid()
+ if !errors.Is(err, ErrGlobMatchedNothing) {
+ t.Logf("wanted error: %v", ErrGlobMatchedNothing)
+ t.Logf(" got error: %v", err)
+ t.Error("unexpected error received")
+ }
+ })
+ }
+}
+
+// TestImportStatement asserts that every policy snippet in the data folder can
+// be imported and is valid. The list of files is discovered by walking the
+// embedded filesystem so that new folders and new snippets are covered without
+// having to remember to add them here.
+func TestImportStatement(t *testing.T) {
+ var importPaths []string
+
+ if err := fs.WalkDir(data.BotPolicies, ".", func(path string, d fs.DirEntry, err error) error {
+ switch {
+ case err != nil:
+ return err
+ case d.IsDir():
+ return nil
+ case path == "botPolicies.yaml": // an entire config, not a list of bots
+ return nil
+ }
+
+ switch filepath.Ext(path) {
+ case ".yaml", ".yml":
+ importPaths = append(importPaths, "(data)/"+path)
+ }
+
+ return nil
+ }); err != nil {
+ t.Fatal(err)
+ }
+
+ if len(importPaths) == 0 {
+ t.Fatal("no policy snippets found in the embedded data folder")
+ }
+
+ for _, importPath := range importPaths {
+ t.Run(importPath, func(t *testing.T) {
+ is := &ImportStatement{
+ Import: importPath,
+ }
+
+ if err := is.Valid(); err != nil {
+ t.Errorf("validation error: %v", err)
+ }
+
+ if len(is.Bots) == 0 {
+ t.Error("wanted bot definitions, but got none")
+ }
+ })
+ }
+}
diff --git a/lib/policy/config/impressum.go b/lib/config/impressum.go
similarity index 100%
rename from lib/policy/config/impressum.go
rename to lib/config/impressum.go
diff --git a/lib/policy/config/impressum_test.go b/lib/config/impressum_test.go
similarity index 100%
rename from lib/policy/config/impressum_test.go
rename to lib/config/impressum_test.go
index 91424b520..1844ce532 100644
--- a/lib/policy/config/impressum_test.go
+++ b/lib/config/impressum_test.go
@@ -8,9 +8,9 @@ import (
func TestImpressumValid(t *testing.T) {
for _, cs := range []struct {
- name string
- inp Impressum
err error
+ inp Impressum
+ name string
}{
{
name: "basic happy path",
diff --git a/lib/config/logging.go b/lib/config/logging.go
new file mode 100644
index 000000000..a64996726
--- /dev/null
+++ b/lib/config/logging.go
@@ -0,0 +1,125 @@
+package config
+
+import (
+ "errors"
+ "fmt"
+ "log/slog"
+)
+
+var (
+ ErrMissingLoggingFileConfig = errors.New("config.Logging: missing value parameters in logging block")
+ ErrInvalidLoggingSink = errors.New("config.Logging: invalid sink")
+ ErrInvalidLoggingFileConfig = errors.New("config.LoggingFileConfig: invalid parameters")
+ ErrOutOfRange = errors.New("config: error out of range")
+)
+
+type Logging struct {
+ Sink string `json:"sink"` // Logging sink, either "stdio" or "file"
+ Level *slog.Level `json:"level"` // Log level, if set supersedes the level in flags
+ Parameters *LoggingFileConfig `json:"parameters"` // Logging parameters, to be dynamic in the future
+ LogASN bool `json:"asn" yaml:"asn"`
+}
+
+const (
+ LogSinkStdio = "stdio"
+ LogSinkFile = "file"
+)
+
+func (l *Logging) Valid() error {
+ var errs []error
+
+ switch l.Sink {
+ case LogSinkStdio:
+ // no validation needed
+ case LogSinkFile:
+ if l.Parameters == nil {
+ errs = append(errs, ErrMissingLoggingFileConfig)
+ }
+
+ if err := l.Parameters.Valid(); err != nil {
+ errs = append(errs, err)
+ }
+ default:
+ errs = append(errs, fmt.Errorf("%w: sink %s is unknown to me", ErrInvalidLoggingSink, l.Sink))
+ }
+
+ if len(errs) != 0 {
+ return errors.Join(errs...)
+ }
+
+ return nil
+}
+
+func (Logging) Default() *Logging {
+ return &Logging{
+ Sink: "stdio",
+ }
+}
+
+type LoggingFileConfig struct {
+ Filename string `json:"file"`
+ MaxBackups int `json:"maxBackups"`
+ MaxBytes int64 `json:"maxBytes"`
+ MaxAge int `json:"maxAge"`
+ Compress bool `json:"compress"`
+ UseLocalTime bool `json:"useLocalTime"`
+}
+
+func (lfc *LoggingFileConfig) Valid() error {
+ if lfc == nil {
+ return fmt.Errorf("logging file config is nil, why are you calling this?")
+ }
+
+ var errs []error
+
+ if lfc.Zero() {
+ errs = append(errs, ErrMissingValue)
+ }
+
+ if lfc.Filename == "" {
+ errs = append(errs, fmt.Errorf("%w: filename", ErrMissingValue))
+ }
+
+ if lfc.MaxBackups < 0 {
+ errs = append(errs, fmt.Errorf("%w: max backup count %d is not greater than or equal to zero", ErrOutOfRange, lfc.MaxBackups))
+ }
+
+ if lfc.MaxAge < 0 {
+ errs = append(errs, fmt.Errorf("%w: max backup count %d is not greater than or equal to zero", ErrOutOfRange, lfc.MaxAge))
+ }
+
+ if len(errs) != 0 {
+ errs = append([]error{ErrInvalidLoggingFileConfig}, errs...)
+ return errors.Join(errs...)
+ }
+
+ return nil
+}
+
+func (lfc LoggingFileConfig) Zero() bool {
+ for _, cond := range []bool{
+ lfc.Filename != "",
+ lfc.MaxBackups != 0,
+ lfc.MaxBytes != 0,
+ lfc.MaxAge != 0,
+ lfc.Compress,
+ lfc.UseLocalTime,
+ } {
+ if cond {
+ return false
+ }
+ }
+
+ return true
+}
+
+func (LoggingFileConfig) Default() *LoggingFileConfig {
+ return &LoggingFileConfig{
+ Filename: "./var/anubis.log",
+ MaxBackups: 3,
+ MaxBytes: 104857600, // 100 Mi
+ MaxAge: 7, // 7 days
+ Compress: true,
+ UseLocalTime: false,
+ }
+}
diff --git a/lib/config/logging_test.go b/lib/config/logging_test.go
new file mode 100644
index 000000000..c850cbd9b
--- /dev/null
+++ b/lib/config/logging_test.go
@@ -0,0 +1,103 @@
+package config
+
+import (
+ "errors"
+ "testing"
+)
+
+func TestLoggingValid(t *testing.T) {
+ for _, tt := range []struct {
+ name string
+ input *Logging
+ want error
+ }{
+ {
+ name: "simple happy",
+ input: (Logging{}).Default(),
+ },
+ {
+ name: "default file config",
+ input: &Logging{
+ Sink: LogSinkFile,
+ Parameters: (&LoggingFileConfig{}).Default(),
+ },
+ },
+ {
+ name: "invalid sink",
+ input: &Logging{
+ Sink: "taco invalid",
+ },
+ want: ErrInvalidLoggingSink,
+ },
+ {
+ name: "missing parameters",
+ input: &Logging{
+ Sink: LogSinkFile,
+ },
+ want: ErrMissingLoggingFileConfig,
+ },
+ {
+ name: "invalid parameters",
+ input: &Logging{
+ Sink: LogSinkFile,
+ Parameters: &LoggingFileConfig{},
+ },
+ want: ErrInvalidLoggingFileConfig,
+ },
+ {
+ name: "file sink with no filename",
+ input: &Logging{
+ Sink: LogSinkFile,
+ Parameters: &LoggingFileConfig{
+ Filename: "",
+ MaxBackups: 3,
+ MaxBytes: 104857600, // 100 Mi
+ MaxAge: 7, // 7 days
+ Compress: true,
+ UseLocalTime: false,
+ },
+ },
+ want: ErrMissingValue,
+ },
+ {
+ name: "file sink with negative max backups",
+ input: &Logging{
+ Sink: LogSinkFile,
+ Parameters: &LoggingFileConfig{
+ Filename: "./var/anubis.log",
+ MaxBackups: -3,
+ MaxBytes: 104857600, // 100 Mi
+ MaxAge: 7, // 7 days
+ Compress: true,
+ UseLocalTime: false,
+ },
+ },
+ want: ErrOutOfRange,
+ },
+ {
+ name: "file sink with negative max age",
+ input: &Logging{
+ Sink: LogSinkFile,
+ Parameters: &LoggingFileConfig{
+ Filename: "./var/anubis.log",
+ MaxBackups: 3,
+ MaxBytes: 104857600, // 100 Mi
+ MaxAge: -7, // 7 days
+ Compress: true,
+ UseLocalTime: false,
+ },
+ },
+ want: ErrOutOfRange,
+ },
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ err := tt.input.Valid()
+
+ if !errors.Is(err, tt.want) {
+ t.Logf("wanted error: %v", tt.want)
+ t.Logf(" got error: %v", err)
+ t.Fatal("got wrong error")
+ }
+ })
+ }
+}
diff --git a/lib/config/metrics.go b/lib/config/metrics.go
new file mode 100644
index 000000000..d7aee2c00
--- /dev/null
+++ b/lib/config/metrics.go
@@ -0,0 +1,170 @@
+package config
+
+import (
+ "crypto/tls"
+ "crypto/x509"
+ "errors"
+ "fmt"
+ "os"
+ "strconv"
+)
+
+var (
+ ErrInvalidMetricsConfig = errors.New("config: invalid metrics configuration")
+ ErrInvalidMetricsTLSConfig = errors.New("config: invalid metrics TLS configuration")
+ ErrInvalidMetricsBasicAuthConfig = errors.New("config: invalid metrics basic auth configuration")
+ ErrNoMetricsBind = errors.New("config.Metrics: must define bind")
+ ErrNoMetricsNetwork = errors.New("config.Metrics: must define network")
+ ErrNoMetricsSocketMode = errors.New("config.Metrics: must define socket mode when using unix sockets")
+ ErrInvalidMetricsSocketMode = errors.New("config.Metrics: invalid unix socket mode")
+ ErrInvalidMetricsNetwork = errors.New("config.Metrics: invalid metrics network")
+ ErrNoMetricsTLSCertificate = errors.New("config.Metrics.TLS: must define certificate file")
+ ErrNoMetricsTLSKey = errors.New("config.Metrics.TLS: must define key file")
+ ErrInvalidMetricsTLSKeypair = errors.New("config.Metrics.TLS: keypair is invalid")
+ ErrInvalidMetricsCACertificate = errors.New("config.Metrics.TLS: invalid CA certificate")
+ ErrCantReadFile = errors.New("config: can't read required file")
+ ErrNoMetricsBasicAuthUsername = errors.New("config.Metrics.BasicAuth: must define username")
+ ErrNoMetricsBasicAuthPassword = errors.New("config.Metrics.BasicAuth: must define password")
+)
+
+type Metrics struct {
+ Bind string `json:"bind" yaml:"bind"`
+ Network string `json:"network" yaml:"network"`
+ SocketMode string `json:"socketMode" yaml:"socketMode"`
+ TLS *MetricsTLS `json:"tls" yaml:"tls"`
+ Debug bool `json:"debug" yaml:"debug"`
+ BasicAuth *MetricsBasicAuth `json:"basicAuth" yaml:"basicAuth"`
+}
+
+func (m *Metrics) Valid() error {
+ var errs []error
+
+ if m.Bind == "" {
+ errs = append(errs, ErrNoMetricsBind)
+ }
+
+ if m.Network == "" {
+ errs = append(errs, ErrNoMetricsNetwork)
+ }
+
+ switch m.Network {
+ case "tcp", "tcp4", "tcp6": // https://pkg.go.dev/net#Listen
+ case "unix":
+ if m.SocketMode == "" {
+ errs = append(errs, ErrNoMetricsSocketMode)
+ }
+
+ if _, err := strconv.ParseUint(m.SocketMode, 8, 0); err != nil {
+ errs = append(errs, fmt.Errorf("%w: %w", ErrInvalidMetricsSocketMode, err))
+ }
+ default:
+ errs = append(errs, ErrInvalidMetricsNetwork)
+ }
+
+ if m.TLS != nil {
+ if err := m.TLS.Valid(); err != nil {
+ errs = append(errs, err)
+ }
+ }
+
+ if m.BasicAuth != nil {
+ if err := m.BasicAuth.Valid(); err != nil {
+ errs = append(errs, err)
+ }
+ }
+
+ if len(errs) != 0 {
+ return errors.Join(ErrInvalidMetricsConfig, errors.Join(errs...))
+ }
+
+ return nil
+}
+
+type MetricsTLS struct {
+ Certificate string `json:"certificate" yaml:"certificate"`
+ Key string `json:"key" yaml:"key"`
+ CA string `json:"ca" yaml:"ca"`
+}
+
+func (mt *MetricsTLS) Valid() error {
+ var errs []error
+
+ if mt.Certificate == "" {
+ errs = append(errs, ErrNoMetricsTLSCertificate)
+ }
+
+ if err := canReadFile(mt.Certificate); err != nil {
+ errs = append(errs, fmt.Errorf("%w %s: %w", ErrCantReadFile, mt.Certificate, err))
+ }
+
+ if mt.Key == "" {
+ errs = append(errs, ErrNoMetricsTLSKey)
+ }
+
+ if err := canReadFile(mt.Key); err != nil {
+ errs = append(errs, fmt.Errorf("%w %s: %w", ErrCantReadFile, mt.Key, err))
+ }
+
+ if _, err := tls.LoadX509KeyPair(mt.Certificate, mt.Key); err != nil {
+ errs = append(errs, fmt.Errorf("%w: %w", ErrInvalidMetricsTLSKeypair, err))
+ }
+
+ if mt.CA != "" {
+ caCert, err := os.ReadFile(mt.CA)
+ if err != nil {
+ errs = append(errs, fmt.Errorf("%w %s: %w", ErrCantReadFile, mt.CA, err))
+ }
+
+ certPool := x509.NewCertPool()
+ if !certPool.AppendCertsFromPEM(caCert) {
+ errs = append(errs, fmt.Errorf("%w %s", ErrInvalidMetricsCACertificate, mt.CA))
+ }
+ }
+
+ if len(errs) != 0 {
+ return errors.Join(ErrInvalidMetricsTLSConfig, errors.Join(errs...))
+ }
+
+ return nil
+}
+
+func canReadFile(fname string) error {
+ fin, err := os.Open(fname)
+ if err != nil {
+ return err
+ }
+
+ data := make([]byte, 64)
+ if _, err := fin.Read(data); err != nil {
+ return fmt.Errorf("can't read %s: %w", fname, err)
+ }
+
+ if err := fin.Close(); err != nil {
+ return err
+ }
+
+ return nil
+}
+
+type MetricsBasicAuth struct {
+ Username string `json:"username" yaml:"username"`
+ Password string `json:"password" yaml:"password"`
+}
+
+func (mba *MetricsBasicAuth) Valid() error {
+ var errs []error
+
+ if mba.Username == "" {
+ errs = append(errs, ErrNoMetricsBasicAuthUsername)
+ }
+
+ if mba.Password == "" {
+ errs = append(errs, ErrNoMetricsBasicAuthPassword)
+ }
+
+ if len(errs) != 0 {
+ return errors.Join(ErrInvalidMetricsBasicAuthConfig, errors.Join(errs...))
+ }
+
+ return nil
+}
diff --git a/lib/config/metrics_test.go b/lib/config/metrics_test.go
new file mode 100644
index 000000000..c4876caaa
--- /dev/null
+++ b/lib/config/metrics_test.go
@@ -0,0 +1,242 @@
+package config
+
+import (
+ "errors"
+ "testing"
+)
+
+func TestMetricsValid(t *testing.T) {
+ for _, tt := range []struct {
+ name string
+ input *Metrics
+ err error
+ }{
+ {
+ name: "basic TCP",
+ input: &Metrics{
+ Bind: ":9090",
+ Network: "tcp",
+ },
+ },
+ {
+ name: "basic TCP4",
+ input: &Metrics{
+ Bind: ":9090",
+ Network: "tcp4",
+ },
+ },
+ {
+ name: "basic TCP6",
+ input: &Metrics{
+ Bind: ":9090",
+ Network: "tcp6",
+ },
+ },
+ {
+ name: "basic unix",
+ input: &Metrics{
+ Bind: "/tmp/anubis-metrics.sock",
+ Network: "unix",
+ SocketMode: "0770",
+ },
+ },
+ {
+ name: "no bind",
+ input: &Metrics{},
+ err: ErrNoMetricsBind,
+ },
+ {
+ name: "no network",
+ input: &Metrics{},
+ err: ErrNoMetricsNetwork,
+ },
+ {
+ name: "no unix socket mode",
+ input: &Metrics{
+ Bind: "/tmp/anubis-metrics.sock",
+ Network: "unix",
+ },
+ err: ErrNoMetricsSocketMode,
+ },
+ {
+ name: "invalid unix socket mode",
+ input: &Metrics{
+ Bind: "/tmp/anubis-metrics.sock",
+ Network: "unix",
+ SocketMode: "taco bell",
+ },
+ err: ErrInvalidMetricsSocketMode,
+ },
+ {
+ name: "invalid network",
+ input: &Metrics{
+ Bind: ":9090",
+ Network: "taco",
+ },
+ err: ErrInvalidMetricsNetwork,
+ },
+ {
+ name: "invalid TLS config",
+ input: &Metrics{
+ Bind: ":9090",
+ Network: "tcp",
+ TLS: &MetricsTLS{},
+ },
+ err: ErrInvalidMetricsTLSConfig,
+ },
+ {
+ name: "selfsigned TLS cert",
+ input: &Metrics{
+ Bind: ":9090",
+ Network: "tcp",
+ TLS: &MetricsTLS{
+ Certificate: "./testdata/tls/selfsigned.crt",
+ Key: "./testdata/tls/selfsigned.key",
+ },
+ },
+ },
+ {
+ name: "wrong path to selfsigned TLS cert",
+ input: &Metrics{
+ Bind: ":9090",
+ Network: "tcp",
+ TLS: &MetricsTLS{
+ Certificate: "./testdata/tls2/selfsigned.crt",
+ Key: "./testdata/tls2/selfsigned.key",
+ },
+ },
+ err: ErrCantReadFile,
+ },
+ {
+ name: "unparseable TLS cert",
+ input: &Metrics{
+ Bind: ":9090",
+ Network: "tcp",
+ TLS: &MetricsTLS{
+ Certificate: "./testdata/tls/invalid.crt",
+ Key: "./testdata/tls/invalid.key",
+ },
+ },
+ err: ErrInvalidMetricsTLSKeypair,
+ },
+ {
+ name: "mTLS with CA",
+ input: &Metrics{
+ Bind: ":9090",
+ Network: "tcp",
+ TLS: &MetricsTLS{
+ Certificate: "./testdata/tls/selfsigned.crt",
+ Key: "./testdata/tls/selfsigned.key",
+ CA: "./testdata/tls/minica.pem",
+ },
+ },
+ },
+ {
+ name: "mTLS with nonexistent CA",
+ input: &Metrics{
+ Bind: ":9090",
+ Network: "tcp",
+ TLS: &MetricsTLS{
+ Certificate: "./testdata/tls/selfsigned.crt",
+ Key: "./testdata/tls/selfsigned.key",
+ CA: "./testdata/tls/nonexistent.crt",
+ },
+ },
+ err: ErrCantReadFile,
+ },
+ {
+ name: "mTLS with invalid CA",
+ input: &Metrics{
+ Bind: ":9090",
+ Network: "tcp",
+ TLS: &MetricsTLS{
+ Certificate: "./testdata/tls/selfsigned.crt",
+ Key: "./testdata/tls/selfsigned.key",
+ CA: "./testdata/tls/invalid.crt",
+ },
+ },
+ err: ErrInvalidMetricsCACertificate,
+ },
+ {
+ name: "basic auth credentials set",
+ input: &Metrics{
+ Bind: ":9090",
+ Network: "tcp",
+ BasicAuth: &MetricsBasicAuth{
+ Username: "admin",
+ Password: "hunter2",
+ },
+ },
+ },
+ {
+ name: "invalid basic auth config",
+ input: &Metrics{
+ Bind: ":9090",
+ Network: "tcp",
+ BasicAuth: &MetricsBasicAuth{},
+ },
+ err: ErrInvalidMetricsBasicAuthConfig,
+ },
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ if err := tt.input.Valid(); !errors.Is(err, tt.err) {
+ t.Logf("wanted error: %v", tt.err)
+ t.Logf("got error: %v", err)
+ t.Error("validation failed")
+ }
+ })
+ }
+}
+
+func TestMetricsBasicAuthValid(t *testing.T) {
+ for _, tt := range []struct {
+ name string
+ input *MetricsBasicAuth
+ err error
+ }{
+ {
+ name: "both set",
+ input: &MetricsBasicAuth{
+ Username: "admin",
+ Password: "hunter2",
+ },
+ },
+ {
+ name: "empty username and password",
+ input: &MetricsBasicAuth{},
+ err: ErrInvalidMetricsBasicAuthConfig,
+ },
+ {
+ name: "missing username",
+ input: &MetricsBasicAuth{
+ Password: "hunter2",
+ },
+ err: ErrNoMetricsBasicAuthUsername,
+ },
+ {
+ name: "missing password",
+ input: &MetricsBasicAuth{
+ Username: "admin",
+ },
+ err: ErrNoMetricsBasicAuthPassword,
+ },
+ {
+ name: "missing both surfaces wrapper error",
+ input: &MetricsBasicAuth{},
+ err: ErrNoMetricsBasicAuthUsername,
+ },
+ {
+ name: "missing both surfaces password error",
+ input: &MetricsBasicAuth{},
+ err: ErrNoMetricsBasicAuthPassword,
+ },
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ if err := tt.input.Valid(); !errors.Is(err, tt.err) {
+ t.Logf("wanted error: %v", tt.err)
+ t.Logf("got error: %v", err)
+ t.Error("validation failed")
+ }
+ })
+ }
+}
diff --git a/lib/policy/config/opengraph.go b/lib/config/opengraph.go
similarity index 100%
rename from lib/policy/config/opengraph.go
rename to lib/config/opengraph.go
index 544647ecc..087d046c5 100644
--- a/lib/policy/config/opengraph.go
+++ b/lib/config/opengraph.go
@@ -13,17 +13,17 @@ var (
)
type openGraphFileConfig struct {
+ Override map[string]string `json:"override,omitempty" yaml:"override,omitempty"`
+ TimeToLive string `json:"ttl" yaml:"ttl"`
Enabled bool `json:"enabled" yaml:"enabled"`
ConsiderHost bool `json:"considerHost" yaml:"enabled"`
- TimeToLive string `json:"ttl" yaml:"ttl"`
- Override map[string]string `json:"override,omitempty" yaml:"override,omitempty"`
}
type OpenGraph struct {
- Enabled bool `json:"enabled" yaml:"enabled"`
- ConsiderHost bool `json:"considerHost" yaml:"enabled"`
Override map[string]string `json:"override,omitempty" yaml:"override,omitempty"`
TimeToLive time.Duration `json:"ttl" yaml:"ttl"`
+ Enabled bool `json:"enabled" yaml:"enabled"`
+ ConsiderHost bool `json:"considerHost" yaml:"enabled"`
}
func (og *openGraphFileConfig) Valid() error {
diff --git a/lib/policy/config/opengraph_test.go b/lib/config/opengraph_test.go
similarity index 100%
rename from lib/policy/config/opengraph_test.go
rename to lib/config/opengraph_test.go
index 639549ad8..27250ff68 100644
--- a/lib/policy/config/opengraph_test.go
+++ b/lib/config/opengraph_test.go
@@ -7,9 +7,9 @@ import (
func TestOpenGraphFileConfigValid(t *testing.T) {
for _, tt := range []struct {
- name string
- input *openGraphFileConfig
err error
+ input *openGraphFileConfig
+ name string
}{
{
name: "basic happy path",
diff --git a/lib/policy/config/store.go b/lib/config/store.go
similarity index 100%
rename from lib/policy/config/store.go
rename to lib/config/store.go
diff --git a/lib/policy/config/store_test.go b/lib/config/store_test.go
similarity index 97%
rename from lib/policy/config/store_test.go
rename to lib/config/store_test.go
index 1c1556e71..407268543 100644
--- a/lib/policy/config/store_test.go
+++ b/lib/config/store_test.go
@@ -5,16 +5,16 @@ import (
"errors"
"testing"
- "github.com/TecharoHQ/anubis/lib/policy/config"
+ "github.com/TecharoHQ/anubis/lib/config"
"github.com/TecharoHQ/anubis/lib/store/bbolt"
"github.com/TecharoHQ/anubis/lib/store/valkey"
)
func TestStoreValid(t *testing.T) {
for _, tt := range []struct {
+ err error
name string
input config.Store
- err error
}{
{
name: "no backend",
diff --git a/lib/policy/config/testdata/bad/badregexes.json b/lib/config/testdata/bad/badregexes.json
similarity index 99%
rename from lib/policy/config/testdata/bad/badregexes.json
rename to lib/config/testdata/bad/badregexes.json
index db371b0fa..436f768d8 100644
--- a/lib/policy/config/testdata/bad/badregexes.json
+++ b/lib/config/testdata/bad/badregexes.json
@@ -18,4 +18,4 @@
"action": "DENY"
}
]
-}
\ No newline at end of file
+}
diff --git a/lib/config/testdata/bad/badregexes.yaml b/lib/config/testdata/bad/badregexes.yaml
new file mode 100644
index 000000000..c6caedbdd
--- /dev/null
+++ b/lib/config/testdata/bad/badregexes.yaml
@@ -0,0 +1,7 @@
+bots:
+ - name: path-bad
+ path_regex: "a(b"
+ action: DENY
+ - name: user-agent-bad
+ user_agent_regex: "a(b"
+ action: DENY
diff --git a/lib/config/testdata/bad/challenge-extension-empty.yaml b/lib/config/testdata/bad/challenge-extension-empty.yaml
new file mode 100644
index 000000000..a53890bd5
--- /dev/null
+++ b/lib/config/testdata/bad/challenge-extension-empty.yaml
@@ -0,0 +1,9 @@
+bots:
+ - name: everyone
+ user_agent_regex: .
+ action: CHALLENGE
+ challenge:
+ algorithm: metarefresh
+ difficulty: 1
+ extensions:
+ - ""
\ No newline at end of file
diff --git a/lib/config/testdata/bad/challenge-extension-repeated.yaml b/lib/config/testdata/bad/challenge-extension-repeated.yaml
new file mode 100644
index 000000000..529efc479
--- /dev/null
+++ b/lib/config/testdata/bad/challenge-extension-repeated.yaml
@@ -0,0 +1,10 @@
+bots:
+ - name: everyone
+ user_agent_regex: .
+ action: CHALLENGE
+ challenge:
+ algorithm: metarefresh
+ difficulty: 1
+ extensions:
+ - test-extension
+ - test-extension
\ No newline at end of file
diff --git a/lib/config/testdata/bad/dns-ttl-custom.yaml b/lib/config/testdata/bad/dns-ttl-custom.yaml
new file mode 100644
index 000000000..1d23d3692
--- /dev/null
+++ b/lib/config/testdata/bad/dns-ttl-custom.yaml
@@ -0,0 +1,8 @@
+dns_ttl:
+ forward: 60.0
+ reverse: "600"
+
+bots:
+ - name: "test"
+ user_agent_regex: ".*"
+ action: "DENY"
diff --git a/lib/config/testdata/bad/honeypot-empty-implementation.yaml b/lib/config/testdata/bad/honeypot-empty-implementation.yaml
new file mode 100644
index 000000000..a138b7e29
--- /dev/null
+++ b/lib/config/testdata/bad/honeypot-empty-implementation.yaml
@@ -0,0 +1,8 @@
+bots:
+ - name: everything
+ user_agent_regex: .*
+ action: DENY
+
+honeypot:
+ enabled: true
+ implementation: ""
diff --git a/lib/config/testdata/bad/honeypot-invalid-implementation.json b/lib/config/testdata/bad/honeypot-invalid-implementation.json
new file mode 100644
index 000000000..70430a236
--- /dev/null
+++ b/lib/config/testdata/bad/honeypot-invalid-implementation.json
@@ -0,0 +1,13 @@
+{
+ "bots": [
+ {
+ "name": "everything",
+ "user_agent_regex": ".*",
+ "action": "DENY"
+ }
+ ],
+ "honeypot": {
+ "enabled": true,
+ "implementation": "sophisticated"
+ }
+}
diff --git a/lib/config/testdata/bad/honeypot-invalid-implementation.yaml b/lib/config/testdata/bad/honeypot-invalid-implementation.yaml
new file mode 100644
index 000000000..6ba2c9858
--- /dev/null
+++ b/lib/config/testdata/bad/honeypot-invalid-implementation.yaml
@@ -0,0 +1,8 @@
+bots:
+ - name: everything
+ user_agent_regex: .*
+ action: DENY
+
+honeypot:
+ enabled: true
+ implementation: sophisticated
diff --git a/lib/config/testdata/bad/import-invalid-pattern.yaml b/lib/config/testdata/bad/import-invalid-pattern.yaml
new file mode 100644
index 000000000..0ada681d6
--- /dev/null
+++ b/lib/config/testdata/bad/import-invalid-pattern.yaml
@@ -0,0 +1,2 @@
+bots:
+ - import: (data)/bots/never-matches/*[abc]abc]\.yaml
diff --git a/lib/config/testdata/bad/import-no-match.yaml b/lib/config/testdata/bad/import-no-match.yaml
new file mode 100644
index 000000000..eaec79dd5
--- /dev/null
+++ b/lib/config/testdata/bad/import-no-match.yaml
@@ -0,0 +1,2 @@
+bots:
+ - import: (data)/bots/never-matches/*.yaml
diff --git a/lib/policy/config/testdata/bad/import_and_bot.json b/lib/config/testdata/bad/import_and_bot.json
similarity index 98%
rename from lib/policy/config/testdata/bad/import_and_bot.json
rename to lib/config/testdata/bad/import_and_bot.json
index 3d0519bc0..ef9778cd2 100644
--- a/lib/policy/config/testdata/bad/import_and_bot.json
+++ b/lib/config/testdata/bad/import_and_bot.json
@@ -7,4 +7,4 @@
"action": "CHALLENGE"
}
]
-}
\ No newline at end of file
+}
diff --git a/lib/config/testdata/bad/import_and_bot.yaml b/lib/config/testdata/bad/import_and_bot.yaml
new file mode 100644
index 000000000..cc9142db7
--- /dev/null
+++ b/lib/config/testdata/bad/import_and_bot.yaml
@@ -0,0 +1,6 @@
+bots:
+ - import: (data)/bots/ai-catchall.yaml
+ name: generic-browser
+ user_agent_regex: >
+ Mozilla|Opera
+ action: CHALLENGE
diff --git a/lib/policy/config/testdata/bad/import_invalid_file.json b/lib/config/testdata/bad/import_invalid_file.json
similarity index 97%
rename from lib/policy/config/testdata/bad/import_invalid_file.json
rename to lib/config/testdata/bad/import_invalid_file.json
index c7546c09d..26669635b 100644
--- a/lib/policy/config/testdata/bad/import_invalid_file.json
+++ b/lib/config/testdata/bad/import_invalid_file.json
@@ -4,4 +4,4 @@
"import": "(data)/does-not-exist-fake-file.yaml"
}
]
-}
\ No newline at end of file
+}
diff --git a/lib/config/testdata/bad/import_invalid_file.yaml b/lib/config/testdata/bad/import_invalid_file.yaml
new file mode 100644
index 000000000..c82b45330
--- /dev/null
+++ b/lib/config/testdata/bad/import_invalid_file.yaml
@@ -0,0 +1,2 @@
+bots:
+ - import: (data)/does-not-exist-fake-file.yaml
diff --git a/lib/policy/config/testdata/bad/impressum-no-footer.yaml b/lib/config/testdata/bad/impressum-no-footer.yaml
similarity index 100%
rename from lib/policy/config/testdata/bad/impressum-no-footer.yaml
rename to lib/config/testdata/bad/impressum-no-footer.yaml
diff --git a/lib/policy/config/testdata/bad/impressum-no-page-contents.yaml b/lib/config/testdata/bad/impressum-no-page-contents.yaml
similarity index 100%
rename from lib/policy/config/testdata/bad/impressum-no-page-contents.yaml
rename to lib/config/testdata/bad/impressum-no-page-contents.yaml
diff --git a/lib/config/testdata/bad/invalid.json b/lib/config/testdata/bad/invalid.json
new file mode 100644
index 000000000..334e5db72
--- /dev/null
+++ b/lib/config/testdata/bad/invalid.json
@@ -0,0 +1,3 @@
+{
+ "bots": [{}]
+}
diff --git a/lib/config/testdata/bad/invalid.yaml b/lib/config/testdata/bad/invalid.yaml
new file mode 100644
index 000000000..b78eb9205
--- /dev/null
+++ b/lib/config/testdata/bad/invalid.yaml
@@ -0,0 +1 @@
+bots: []
diff --git a/lib/config/testdata/bad/logging-invalid-sink.yaml b/lib/config/testdata/bad/logging-invalid-sink.yaml
new file mode 100644
index 000000000..eadde4c36
--- /dev/null
+++ b/lib/config/testdata/bad/logging-invalid-sink.yaml
@@ -0,0 +1,2 @@
+logging:
+ sink: "nope"
diff --git a/lib/config/testdata/bad/logging-no-parameters.yaml b/lib/config/testdata/bad/logging-no-parameters.yaml
new file mode 100644
index 000000000..0cfea0314
--- /dev/null
+++ b/lib/config/testdata/bad/logging-no-parameters.yaml
@@ -0,0 +1,2 @@
+logging:
+ sink: "file"
diff --git a/lib/config/testdata/bad/metrics-invalid-net.yaml b/lib/config/testdata/bad/metrics-invalid-net.yaml
new file mode 100644
index 000000000..3d3dfc711
--- /dev/null
+++ b/lib/config/testdata/bad/metrics-invalid-net.yaml
@@ -0,0 +1,3 @@
+metrics:
+ bind: ":9090"
+ network: taco
\ No newline at end of file
diff --git a/lib/policy/config/testdata/bad/multiple_expression_types.json b/lib/config/testdata/bad/multiple_expression_types.json
similarity index 81%
rename from lib/policy/config/testdata/bad/multiple_expression_types.json
rename to lib/config/testdata/bad/multiple_expression_types.json
index 8b8527683..0bdf5bfc7 100644
--- a/lib/policy/config/testdata/bad/multiple_expression_types.json
+++ b/lib/config/testdata/bad/multiple_expression_types.json
@@ -8,10 +8,8 @@
"userAgent.startsWith(\"git/\") || userAgent.contains(\"libgit\")",
"\"Git-Protocol\" in headers && headers[\"Git-Protocol\"] == \"version=2\"\n"
],
- "any": [
- "userAgent.startsWith(\"evilbot/\")"
- ]
+ "any": ["userAgent.startsWith(\"evilbot/\")"]
}
}
]
-}
\ No newline at end of file
+}
diff --git a/lib/config/testdata/bad/multiple_expression_types.yaml b/lib/config/testdata/bad/multiple_expression_types.yaml
new file mode 100644
index 000000000..da97d454c
--- /dev/null
+++ b/lib/config/testdata/bad/multiple_expression_types.yaml
@@ -0,0 +1,10 @@
+bots:
+ - name: multiple-expression-types
+ action: ALLOW
+ expression:
+ all:
+ - userAgent.startsWith("git/") || userAgent.contains("libgit")
+ - >
+ "Git-Protocol" in headers && headers["Git-Protocol"] == "version=2"
+ any:
+ - userAgent.startsWith("evilbot/")
diff --git a/lib/config/testdata/bad/nobots.json b/lib/config/testdata/bad/nobots.json
new file mode 100644
index 000000000..0967ef424
--- /dev/null
+++ b/lib/config/testdata/bad/nobots.json
@@ -0,0 +1 @@
+{}
diff --git a/lib/config/testdata/bad/nobots.yaml b/lib/config/testdata/bad/nobots.yaml
new file mode 100644
index 000000000..0967ef424
--- /dev/null
+++ b/lib/config/testdata/bad/nobots.yaml
@@ -0,0 +1 @@
+{}
diff --git a/lib/policy/config/testdata/bad/opengraph_bad_ttl.yaml b/lib/config/testdata/bad/opengraph_bad_ttl.yaml
similarity index 100%
rename from lib/policy/config/testdata/bad/opengraph_bad_ttl.yaml
rename to lib/config/testdata/bad/opengraph_bad_ttl.yaml
diff --git a/lib/policy/config/testdata/bad/regex_ends_newline.json b/lib/config/testdata/bad/regex_ends_newline.json
similarity index 100%
rename from lib/policy/config/testdata/bad/regex_ends_newline.json
rename to lib/config/testdata/bad/regex_ends_newline.json
diff --git a/lib/config/testdata/bad/regex_ends_newline.yaml b/lib/config/testdata/bad/regex_ends_newline.yaml
new file mode 100644
index 000000000..7400016b5
--- /dev/null
+++ b/lib/config/testdata/bad/regex_ends_newline.yaml
@@ -0,0 +1,17 @@
+bots:
+ - name: user-agent-ends-newline
+ # Subtle bug: this ends with a newline
+ user_agent_regex: >
+ Mozilla
+ action: CHALLENGE
+ - name: path-ends-newline
+ # Subtle bug: this ends with a newline
+ path_regex: >
+ ^/evil/.*$
+ action: CHALLENGE
+ - name: headers-ends-newline
+ # Subtle bug: this ends with a newline
+ headers_regex:
+ CF-Worker: >
+ .*
+ action: CHALLENGE
diff --git a/lib/policy/config/testdata/bad/status-codes-0.json b/lib/config/testdata/bad/status-codes-0.json
similarity index 100%
rename from lib/policy/config/testdata/bad/status-codes-0.json
rename to lib/config/testdata/bad/status-codes-0.json
diff --git a/lib/config/testdata/bad/status-codes-0.yaml b/lib/config/testdata/bad/status-codes-0.yaml
new file mode 100644
index 000000000..96df3d1d7
--- /dev/null
+++ b/lib/config/testdata/bad/status-codes-0.yaml
@@ -0,0 +1,8 @@
+bots:
+ - name: everything
+ user_agent_regex: .*
+ action: DENY
+
+status_codes:
+ CHALLENGE: 0
+ DENY: 0
diff --git a/lib/policy/config/testdata/bad/threshold-challenge-without-challenge.yaml b/lib/config/testdata/bad/threshold-challenge-without-challenge.yaml
similarity index 100%
rename from lib/policy/config/testdata/bad/threshold-challenge-without-challenge.yaml
rename to lib/config/testdata/bad/threshold-challenge-without-challenge.yaml
diff --git a/lib/policy/config/testdata/bad/thresholds.yaml b/lib/config/testdata/bad/thresholds.yaml
similarity index 100%
rename from lib/policy/config/testdata/bad/thresholds.yaml
rename to lib/config/testdata/bad/thresholds.yaml
diff --git a/lib/policy/config/testdata/bad/unparseable.json b/lib/config/testdata/bad/unparseable.json
similarity index 100%
rename from lib/policy/config/testdata/bad/unparseable.json
rename to lib/config/testdata/bad/unparseable.json
diff --git a/lib/policy/config/testdata/bad/unparseable.yaml b/lib/config/testdata/bad/unparseable.yaml
similarity index 100%
rename from lib/policy/config/testdata/bad/unparseable.yaml
rename to lib/config/testdata/bad/unparseable.yaml
diff --git a/lib/config/testdata/good/allow_everyone.json b/lib/config/testdata/good/allow_everyone.json
new file mode 100644
index 000000000..b1d6eb0ae
--- /dev/null
+++ b/lib/config/testdata/good/allow_everyone.json
@@ -0,0 +1,13 @@
+{
+ "bots": [
+ {
+ "name": "everyones-invited",
+ "remote_addresses": ["0.0.0.0/0", "::/0"],
+ "action": "ALLOW"
+ }
+ ],
+ "metrics": {
+ "bind": ":9090",
+ "network": "tcp"
+ }
+}
diff --git a/lib/config/testdata/good/allow_everyone.yaml b/lib/config/testdata/good/allow_everyone.yaml
new file mode 100644
index 000000000..085ca7758
--- /dev/null
+++ b/lib/config/testdata/good/allow_everyone.yaml
@@ -0,0 +1,10 @@
+bots:
+ - name: everyones-invited
+ remote_addresses:
+ - "0.0.0.0/0"
+ - "::/0"
+ action: ALLOW
+
+metrics:
+ bind: ":9090"
+ network: "tcp"
diff --git a/lib/policy/config/testdata/good/block_cf_workers.json b/lib/config/testdata/good/block_cf_workers.json
similarity index 64%
rename from lib/policy/config/testdata/good/block_cf_workers.json
rename to lib/config/testdata/good/block_cf_workers.json
index b84f1e0d6..867533ee1 100644
--- a/lib/policy/config/testdata/good/block_cf_workers.json
+++ b/lib/config/testdata/good/block_cf_workers.json
@@ -8,5 +8,9 @@
"action": "DENY"
}
],
- "dnsbl": false
-}
\ No newline at end of file
+ "dnsbl": false,
+ "metrics": {
+ "bind": ":9090",
+ "network": "tcp"
+ }
+}
diff --git a/lib/policy/config/testdata/good/block_cf_workers.yaml b/lib/config/testdata/good/block_cf_workers.yaml
similarity index 55%
rename from lib/policy/config/testdata/good/block_cf_workers.yaml
rename to lib/config/testdata/good/block_cf_workers.yaml
index c66badef3..d539be953 100644
--- a/lib/policy/config/testdata/good/block_cf_workers.yaml
+++ b/lib/config/testdata/good/block_cf_workers.yaml
@@ -2,4 +2,8 @@ bots:
- name: cloudflare-workers
headers_regex:
CF-Worker: .*
- action: DENY
\ No newline at end of file
+ action: DENY
+
+metrics:
+ bind: ":9090"
+ network: "tcp"
diff --git a/lib/config/testdata/good/challenge-extensions.yaml b/lib/config/testdata/good/challenge-extensions.yaml
new file mode 100644
index 000000000..3a3175e86
--- /dev/null
+++ b/lib/config/testdata/good/challenge-extensions.yaml
@@ -0,0 +1,19 @@
+bots:
+ - name: everyone
+ user_agent_regex: .
+ action: CHALLENGE
+ challenge:
+ algorithm: fast
+ difficulty: 2
+ extensions:
+ - test-extension
+
+thresholds:
+ - name: extreme-suspicion
+ expression: weight >= 20
+ action: CHALLENGE
+ challenge:
+ algorithm: preact
+ difficulty: 1
+ extensions:
+ - test-extension
diff --git a/lib/policy/config/testdata/good/challenge_cloudflare.yaml b/lib/config/testdata/good/challenge_cloudflare.yaml
similarity index 72%
rename from lib/policy/config/testdata/good/challenge_cloudflare.yaml
rename to lib/config/testdata/good/challenge_cloudflare.yaml
index 1c728cba9..31de33338 100644
--- a/lib/policy/config/testdata/good/challenge_cloudflare.yaml
+++ b/lib/config/testdata/good/challenge_cloudflare.yaml
@@ -4,3 +4,7 @@ bots:
asns:
match:
- 13335 # Cloudflare
+
+metrics:
+ bind: ":9090"
+ network: "tcp"
diff --git a/lib/policy/config/testdata/good/challengemozilla.json b/lib/config/testdata/good/challengemozilla.json
similarity index 64%
rename from lib/policy/config/testdata/good/challengemozilla.json
rename to lib/config/testdata/good/challengemozilla.json
index e9d34eeba..30b60b92c 100644
--- a/lib/policy/config/testdata/good/challengemozilla.json
+++ b/lib/config/testdata/good/challengemozilla.json
@@ -5,5 +5,9 @@
"user_agent_regex": "Mozilla",
"action": "CHALLENGE"
}
- ]
-}
\ No newline at end of file
+ ],
+ "metrics": {
+ "bind": ":9090",
+ "network": "tcp"
+ }
+}
diff --git a/lib/config/testdata/good/challengemozilla.yaml b/lib/config/testdata/good/challengemozilla.yaml
new file mode 100644
index 000000000..eb87a4f02
--- /dev/null
+++ b/lib/config/testdata/good/challengemozilla.yaml
@@ -0,0 +1,8 @@
+bots:
+ - name: generic-browser
+ user_agent_regex: Mozilla
+ action: CHALLENGE
+
+metrics:
+ bind: ":9090"
+ network: "tcp"
diff --git a/lib/config/testdata/good/dns-ttl-custom.yaml b/lib/config/testdata/good/dns-ttl-custom.yaml
new file mode 100644
index 000000000..66fa30e24
--- /dev/null
+++ b/lib/config/testdata/good/dns-ttl-custom.yaml
@@ -0,0 +1,12 @@
+dns_ttl:
+ forward: 600
+ reverse: 600
+
+bots:
+ - name: "test"
+ user_agent_regex: ".*"
+ action: "DENY"
+
+metrics:
+ bind: ":9090"
+ network: "tcp"
diff --git a/lib/policy/config/testdata/good/entropy.yaml b/lib/config/testdata/good/entropy.yaml
similarity index 81%
rename from lib/policy/config/testdata/good/entropy.yaml
rename to lib/config/testdata/good/entropy.yaml
index 80110c137..47be9d482 100644
--- a/lib/policy/config/testdata/good/entropy.yaml
+++ b/lib/config/testdata/good/entropy.yaml
@@ -6,3 +6,7 @@ bots:
- '"Accept" in headers'
- headers["Accept"].contains("text/html")
- randInt(1) == 0
+
+metrics:
+ bind: ":9090"
+ network: "tcp"
diff --git a/lib/config/testdata/good/everything_blocked.json b/lib/config/testdata/good/everything_blocked.json
new file mode 100644
index 000000000..98b35eaf6
--- /dev/null
+++ b/lib/config/testdata/good/everything_blocked.json
@@ -0,0 +1,14 @@
+{
+ "bots": [
+ {
+ "name": "everything",
+ "user_agent_regex": ".*",
+ "action": "DENY"
+ }
+ ],
+ "dnsbl": false,
+ "metrics": {
+ "bind": ":9090",
+ "network": "tcp"
+ }
+}
diff --git a/lib/config/testdata/good/everything_blocked.yaml b/lib/config/testdata/good/everything_blocked.yaml
new file mode 100644
index 000000000..3f5e1dbe3
--- /dev/null
+++ b/lib/config/testdata/good/everything_blocked.yaml
@@ -0,0 +1,8 @@
+bots:
+ - name: everything
+ user_agent_regex: .*
+ action: DENY
+
+metrics:
+ bind: ":9090"
+ network: "tcp"
diff --git a/lib/policy/config/testdata/good/geoip_us.yaml b/lib/config/testdata/good/geoip_us.yaml
similarity index 69%
rename from lib/policy/config/testdata/good/geoip_us.yaml
rename to lib/config/testdata/good/geoip_us.yaml
index b5e42804e..6c3c6b7a3 100644
--- a/lib/policy/config/testdata/good/geoip_us.yaml
+++ b/lib/config/testdata/good/geoip_us.yaml
@@ -4,3 +4,7 @@ bots:
geoip:
countries:
- US
+
+metrics:
+ bind: ":9090"
+ network: "tcp"
diff --git a/lib/policy/config/testdata/good/git_client.json b/lib/config/testdata/good/git_client.json
similarity index 81%
rename from lib/policy/config/testdata/good/git_client.json
rename to lib/config/testdata/good/git_client.json
index 68a2b3e8d..b1be750f7 100644
--- a/lib/policy/config/testdata/good/git_client.json
+++ b/lib/config/testdata/good/git_client.json
@@ -10,5 +10,9 @@
]
}
}
- ]
-}
\ No newline at end of file
+ ],
+ "metrics": {
+ "bind": ":9090",
+ "network": "tcp"
+ }
+}
diff --git a/lib/config/testdata/good/git_client.yaml b/lib/config/testdata/good/git_client.yaml
new file mode 100644
index 000000000..6620b673a
--- /dev/null
+++ b/lib/config/testdata/good/git_client.yaml
@@ -0,0 +1,12 @@
+bots:
+ - name: allow-git-clients
+ action: ALLOW
+ expression:
+ all:
+ - userAgent.startsWith("git/") || userAgent.contains("libgit")
+ - >
+ "Git-Protocol" in headers && headers["Git-Protocol"] == "version=2"
+
+metrics:
+ bind: ":9090"
+ network: "tcp"
diff --git a/lib/config/testdata/good/honeypot-disabled.yaml b/lib/config/testdata/good/honeypot-disabled.yaml
new file mode 100644
index 000000000..799c18f95
--- /dev/null
+++ b/lib/config/testdata/good/honeypot-disabled.yaml
@@ -0,0 +1,7 @@
+bots:
+ - name: everything
+ user_agent_regex: .*
+ action: DENY
+
+honeypot:
+ enabled: false
diff --git a/lib/policy/config/testdata/good/everything_blocked.json b/lib/config/testdata/good/honeypot.json
similarity index 60%
rename from lib/policy/config/testdata/good/everything_blocked.json
rename to lib/config/testdata/good/honeypot.json
index e1763e456..2cfcd9da0 100644
--- a/lib/policy/config/testdata/good/everything_blocked.json
+++ b/lib/config/testdata/good/honeypot.json
@@ -6,5 +6,8 @@
"action": "DENY"
}
],
- "dnsbl": false
-}
\ No newline at end of file
+ "honeypot": {
+ "enabled": true,
+ "implementation": "naive"
+ }
+}
diff --git a/lib/config/testdata/good/honeypot.yaml b/lib/config/testdata/good/honeypot.yaml
new file mode 100644
index 000000000..1852f560c
--- /dev/null
+++ b/lib/config/testdata/good/honeypot.yaml
@@ -0,0 +1,8 @@
+bots:
+ - name: everything
+ user_agent_regex: .*
+ action: DENY
+
+honeypot:
+ enabled: true
+ implementation: naive
diff --git a/lib/config/testdata/good/import-all-bots.yaml b/lib/config/testdata/good/import-all-bots.yaml
new file mode 100644
index 000000000..c20a24dd5
--- /dev/null
+++ b/lib/config/testdata/good/import-all-bots.yaml
@@ -0,0 +1,2 @@
+bots:
+ - import: (data)/bots/*.yaml
diff --git a/lib/policy/config/testdata/good/import_filesystem.json b/lib/config/testdata/good/import_filesystem.json
similarity index 50%
rename from lib/policy/config/testdata/good/import_filesystem.json
rename to lib/config/testdata/good/import_filesystem.json
index 23480c90b..a45881d4e 100644
--- a/lib/policy/config/testdata/good/import_filesystem.json
+++ b/lib/config/testdata/good/import_filesystem.json
@@ -3,5 +3,9 @@
{
"import": "./testdata/hack-test.json"
}
- ]
-}
\ No newline at end of file
+ ],
+ "metrics": {
+ "bind": ":9090",
+ "network": "tcp"
+ }
+}
diff --git a/lib/config/testdata/good/import_filesystem.yaml b/lib/config/testdata/good/import_filesystem.yaml
new file mode 100644
index 000000000..cac730961
--- /dev/null
+++ b/lib/config/testdata/good/import_filesystem.yaml
@@ -0,0 +1,6 @@
+bots:
+ - import: ./testdata/hack-test.yaml
+
+metrics:
+ bind: ":9090"
+ network: "tcp"
diff --git a/lib/config/testdata/good/import_google_user_triggered_fetchers.yaml b/lib/config/testdata/good/import_google_user_triggered_fetchers.yaml
new file mode 100644
index 000000000..f9ecce406
--- /dev/null
+++ b/lib/config/testdata/good/import_google_user_triggered_fetchers.yaml
@@ -0,0 +1,6 @@
+bots:
+ - import: (data)/clients/google-user-triggered-fetchers.yaml
+
+metrics:
+ bind: ":9090"
+ network: "tcp"
diff --git a/lib/policy/config/testdata/good/import_keep_internet_working.json b/lib/config/testdata/good/import_keep_internet_working.json
similarity index 55%
rename from lib/policy/config/testdata/good/import_keep_internet_working.json
rename to lib/config/testdata/good/import_keep_internet_working.json
index 68ff2dbc3..53d6743e2 100644
--- a/lib/policy/config/testdata/good/import_keep_internet_working.json
+++ b/lib/config/testdata/good/import_keep_internet_working.json
@@ -3,5 +3,9 @@
{
"import": "(data)/common/keep-internet-working.yaml"
}
- ]
-}
\ No newline at end of file
+ ],
+ "metrics": {
+ "bind": ":9090",
+ "network": "tcp"
+ }
+}
diff --git a/lib/config/testdata/good/import_keep_internet_working.yaml b/lib/config/testdata/good/import_keep_internet_working.yaml
new file mode 100644
index 000000000..dbb191e60
--- /dev/null
+++ b/lib/config/testdata/good/import_keep_internet_working.yaml
@@ -0,0 +1,6 @@
+bots:
+ - import: (data)/common/keep-internet-working.yaml
+
+metrics:
+ bind: ":9090"
+ network: "tcp"
diff --git a/lib/policy/config/testdata/good/impressum.yaml b/lib/config/testdata/good/impressum.yaml
similarity index 81%
rename from lib/policy/config/testdata/good/impressum.yaml
rename to lib/config/testdata/good/impressum.yaml
index 9a1a03fc6..460e26d1b 100644
--- a/lib/policy/config/testdata/good/impressum.yaml
+++ b/lib/config/testdata/good/impressum.yaml
@@ -8,3 +8,7 @@ impressum:
page:
title: Test
body:
This is a test
+
+metrics:
+ bind: ":9090"
+ network: "tcp"
diff --git a/lib/config/testdata/good/logging-file.yaml b/lib/config/testdata/good/logging-file.yaml
new file mode 100644
index 000000000..a2a63ede8
--- /dev/null
+++ b/lib/config/testdata/good/logging-file.yaml
@@ -0,0 +1,19 @@
+bots:
+ - name: simple
+ action: CHALLENGE
+ user_agent_regex: Mozilla
+
+logs:
+ sink: "file"
+ parameters:
+ file: "/var/log/botstopper/default.log"
+ maxBackups: 3 # keep at least 3 old copies
+ maxBytes: 67108864 # each file can have up to 64 MB of logs
+ maxAge: 7 # rotate files out every n days
+ oldFileTimeFormat: 2006-01-02T15-04-05 # RFC 3339-ish
+ compress: true
+ useLocalTime: false # timezone for rotated files is UTC
+
+metrics:
+ bind: ":9090"
+ network: "tcp"
diff --git a/lib/config/testdata/good/logging-stdio.yaml b/lib/config/testdata/good/logging-stdio.yaml
new file mode 100644
index 000000000..26c94037a
--- /dev/null
+++ b/lib/config/testdata/good/logging-stdio.yaml
@@ -0,0 +1,11 @@
+bots:
+ - name: simple
+ action: CHALLENGE
+ user_agent_regex: Mozilla
+
+logging:
+ sink: "stdio"
+
+metrics:
+ bind: ":9090"
+ network: "tcp"
diff --git a/lib/policy/config/testdata/good/no-thresholds.yaml b/lib/config/testdata/good/no-thresholds.yaml
similarity index 75%
rename from lib/policy/config/testdata/good/no-thresholds.yaml
rename to lib/config/testdata/good/no-thresholds.yaml
index bf981fdde..66ec9905d 100644
--- a/lib/policy/config/testdata/good/no-thresholds.yaml
+++ b/lib/config/testdata/good/no-thresholds.yaml
@@ -6,3 +6,7 @@ bots:
adjust: 5
thresholds: []
+
+metrics:
+ bind: ":9090"
+ network: "tcp"
diff --git a/lib/policy/config/testdata/good/old_xesite.json b/lib/config/testdata/good/old_xesite.json
similarity index 96%
rename from lib/policy/config/testdata/good/old_xesite.json
rename to lib/config/testdata/good/old_xesite.json
index 21816bddc..39c2fbe15 100644
--- a/lib/policy/config/testdata/good/old_xesite.json
+++ b/lib/config/testdata/good/old_xesite.json
@@ -75,5 +75,9 @@
"user_agent_regex": "Mozilla",
"action": "CHALLENGE"
}
- ]
-}
\ No newline at end of file
+ ],
+ "metrics": {
+ "bind": ":9090",
+ "network": "tcp"
+ }
+}
diff --git a/lib/policy/config/testdata/good/opengraph_all_good.yaml b/lib/config/testdata/good/opengraph_all_good.yaml
similarity index 85%
rename from lib/policy/config/testdata/good/opengraph_all_good.yaml
rename to lib/config/testdata/good/opengraph_all_good.yaml
index ae26d99ba..f514c39c6 100644
--- a/lib/policy/config/testdata/good/opengraph_all_good.yaml
+++ b/lib/config/testdata/good/opengraph_all_good.yaml
@@ -10,3 +10,7 @@ openGraph:
default:
"og:title": "Xe's magic land of fun"
"og:description": "We're no strangers to love, you know the rules and so do I"
+
+metrics:
+ bind: ":9090"
+ network: "tcp"
diff --git a/lib/policy/config/testdata/good/simple-weight.yaml b/lib/config/testdata/good/simple-weight.yaml
similarity index 72%
rename from lib/policy/config/testdata/good/simple-weight.yaml
rename to lib/config/testdata/good/simple-weight.yaml
index ec7a92e95..3130f1828 100644
--- a/lib/policy/config/testdata/good/simple-weight.yaml
+++ b/lib/config/testdata/good/simple-weight.yaml
@@ -4,3 +4,7 @@ bots:
user_agent_regex: Mozilla
weight:
adjust: 5
+
+metrics:
+ bind: ":9090"
+ network: "tcp"
diff --git a/lib/policy/config/testdata/good/status-codes-paranoid.json b/lib/config/testdata/good/status-codes-paranoid.json
similarity index 74%
rename from lib/policy/config/testdata/good/status-codes-paranoid.json
rename to lib/config/testdata/good/status-codes-paranoid.json
index f84dde91d..716a19229 100644
--- a/lib/policy/config/testdata/good/status-codes-paranoid.json
+++ b/lib/config/testdata/good/status-codes-paranoid.json
@@ -9,5 +9,9 @@
"status_codes": {
"CHALLENGE": 200,
"DENY": 200
+ },
+ "metrics": {
+ "bind": ":9090",
+ "network": "tcp"
}
}
diff --git a/lib/config/testdata/good/status-codes-paranoid.yaml b/lib/config/testdata/good/status-codes-paranoid.yaml
new file mode 100644
index 000000000..783482b79
--- /dev/null
+++ b/lib/config/testdata/good/status-codes-paranoid.yaml
@@ -0,0 +1,12 @@
+bots:
+ - name: everything
+ user_agent_regex: .*
+ action: DENY
+
+status_codes:
+ CHALLENGE: 200
+ DENY: 200
+
+metrics:
+ bind: ":9090"
+ network: "tcp"
diff --git a/lib/policy/config/testdata/good/status-codes-rfc.json b/lib/config/testdata/good/status-codes-rfc.json
similarity index 74%
rename from lib/policy/config/testdata/good/status-codes-rfc.json
rename to lib/config/testdata/good/status-codes-rfc.json
index 2fdaac05b..da169a1c7 100644
--- a/lib/policy/config/testdata/good/status-codes-rfc.json
+++ b/lib/config/testdata/good/status-codes-rfc.json
@@ -9,5 +9,9 @@
"status_codes": {
"CHALLENGE": 403,
"DENY": 403
+ },
+ "metrics": {
+ "bind": ":9090",
+ "network": "tcp"
}
}
diff --git a/lib/config/testdata/good/status-codes-rfc.yaml b/lib/config/testdata/good/status-codes-rfc.yaml
new file mode 100644
index 000000000..26c35791b
--- /dev/null
+++ b/lib/config/testdata/good/status-codes-rfc.yaml
@@ -0,0 +1,12 @@
+bots:
+ - name: everything
+ user_agent_regex: .*
+ action: DENY
+
+status_codes:
+ CHALLENGE: 403
+ DENY: 403
+
+metrics:
+ bind: ":9090"
+ network: "tcp"
diff --git a/lib/policy/config/testdata/good/thresholds.yaml b/lib/config/testdata/good/thresholds.yaml
similarity index 92%
rename from lib/policy/config/testdata/good/thresholds.yaml
rename to lib/config/testdata/good/thresholds.yaml
index 75e17883a..06966e807 100644
--- a/lib/policy/config/testdata/good/thresholds.yaml
+++ b/lib/config/testdata/good/thresholds.yaml
@@ -18,7 +18,6 @@ thresholds:
challenge:
algorithm: metarefresh
difficulty: 1
- report_as: 1
- name: moderate-suspicion
expression:
all:
@@ -28,11 +27,13 @@ thresholds:
challenge:
algorithm: fast
difficulty: 2
- report_as: 2
- name: extreme-suspicion
expression: weight >= 20
action: CHALLENGE
challenge:
algorithm: fast
difficulty: 4
- report_as: 4
+
+metrics:
+ bind: ":9090"
+ network: "tcp"
diff --git a/lib/policy/config/testdata/good/weight-no-weight.yaml b/lib/config/testdata/good/weight-no-weight.yaml
similarity index 62%
rename from lib/policy/config/testdata/good/weight-no-weight.yaml
rename to lib/config/testdata/good/weight-no-weight.yaml
index f137f8aca..d88bd7851 100644
--- a/lib/policy/config/testdata/good/weight-no-weight.yaml
+++ b/lib/config/testdata/good/weight-no-weight.yaml
@@ -2,3 +2,7 @@ bots:
- name: weight
action: WEIGH
user_agent_regex: Mozilla
+
+metrics:
+ bind: ":9090"
+ network: "tcp"
diff --git a/lib/policy/config/testdata/hack-test.json b/lib/config/testdata/hack-test.json
similarity index 53%
rename from lib/policy/config/testdata/hack-test.json
rename to lib/config/testdata/hack-test.json
index 652dcd879..a173bb18b 100644
--- a/lib/policy/config/testdata/hack-test.json
+++ b/lib/config/testdata/hack-test.json
@@ -2,8 +2,6 @@
{
"name": "ipv6-ula",
"action": "ALLOW",
- "remote_addresses": [
- "fc00::/7"
- ]
+ "remote_addresses": ["fc00::/7"]
}
-]
\ No newline at end of file
+]
diff --git a/lib/policy/config/testdata/hack-test.yaml b/lib/config/testdata/hack-test.yaml
similarity index 76%
rename from lib/policy/config/testdata/hack-test.yaml
rename to lib/config/testdata/hack-test.yaml
index cd4d7d054..29263e969 100644
--- a/lib/policy/config/testdata/hack-test.yaml
+++ b/lib/config/testdata/hack-test.yaml
@@ -1,3 +1,3 @@
- name: well-known
path_regex: ^/.well-known/.*$
- action: ALLOW
\ No newline at end of file
+ action: ALLOW
diff --git a/lib/config/testdata/multi-document/git.yaml b/lib/config/testdata/multi-document/git.yaml
new file mode 100644
index 000000000..45739454e
--- /dev/null
+++ b/lib/config/testdata/multi-document/git.yaml
@@ -0,0 +1,21 @@
+---
+- name: allow-git-clients
+ action: ALLOW
+ expression:
+ all:
+ - >
+ (
+ userAgent.startsWith("git/") ||
+ userAgent.contains("libgit") ||
+ userAgent.startsWith("go-git") ||
+ userAgent.startsWith("JGit/") ||
+ userAgent.startsWith("JGit-")
+ )
+ - '"Accept" in headers'
+ - headers["Accept"] == "*/*"
+ - '"Cache-Control" in headers'
+ - headers["Cache-Control"] == "no-cache"
+ - '"Pragma" in headers'
+ - headers["Pragma"] == "no-cache"
+ - '"Accept-Encoding" in headers'
+ - headers["Accept-Encoding"].contains("gzip")
diff --git a/lib/config/testdata/multi-document/small-internet-browsers.yaml b/lib/config/testdata/multi-document/small-internet-browsers.yaml
new file mode 100644
index 000000000..3b220709a
--- /dev/null
+++ b/lib/config/testdata/multi-document/small-internet-browsers.yaml
@@ -0,0 +1,11 @@
+- name: "reduce-weight-palemoon"
+ user_agent_regex: "PaleMoon"
+ action: WEIGH
+ weight:
+ adjust: -5
+---
+- name: "reduce-weight-dillo"
+ user_agent_regex: "Dillo"
+ action: WEIGH
+ weight:
+ adjust: -5
diff --git a/lib/config/testdata/tls/1.1.1.1/cert.pem b/lib/config/testdata/tls/1.1.1.1/cert.pem
new file mode 100644
index 000000000..ef50a00a5
--- /dev/null
+++ b/lib/config/testdata/tls/1.1.1.1/cert.pem
@@ -0,0 +1,12 @@
+-----BEGIN CERTIFICATE-----
+MIIB1zCCAVygAwIBAgIIYO0SAFtXlVgwCgYIKoZIzj0EAwMwIDEeMBwGA1UEAxMV
+bWluaWNhIHJvb3QgY2EgNDE2MmMwMB4XDTI2MDQyMjIzMjUwMVoXDTI4MDUyMjIz
+MjUwMVowEjEQMA4GA1UEAxMHMS4xLjEuMTB2MBAGByqGSM49AgEGBSuBBAAiA2IA
+BLsuA2LKGbEBuSA4LTm1KaKc7/QCkUOsipXR4+D5/3sWBZiAH7iWUgHwpx5YZf2q
+kZn6oRda+ks0JLTQ6VhteQedmb7l86bMeDMR8p4Lg2b38l/xEr7S25UfUDKudXrO
+AqNxMG8wDgYDVR0PAQH/BAQDAgWgMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEF
+BQcDAjAMBgNVHRMBAf8EAjAAMB8GA1UdIwQYMBaAFE/7VDxF2+cUs9bu0pJM3xoC
+L1TSMA8GA1UdEQQIMAaHBAEBAQEwCgYIKoZIzj0EAwMDaQAwZgIxAPLXds9MMH4K
+F5FxTf9i0PKPsLQARsABVTgwB94hMR70rqW8Pwbjl7ZGNaYlaeRHUwIxAPMQ8zoF
+nim+YS1xLqQek/LXuJto8jxcfkQQBsboVzcTa5uaNRhNd5YwrpomGl3lKA==
+-----END CERTIFICATE-----
diff --git a/lib/config/testdata/tls/1.1.1.1/key.pem b/lib/config/testdata/tls/1.1.1.1/key.pem
new file mode 100644
index 000000000..b41e05ad5
--- /dev/null
+++ b/lib/config/testdata/tls/1.1.1.1/key.pem
@@ -0,0 +1,6 @@
+-----BEGIN PRIVATE KEY-----
+MIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDBN8QsHxxHGJpStu8K7
+D/FmaBBNo6c514KGFSIfqGFuREF5aOL3gN/W11yk2OIibdWhZANiAAS7LgNiyhmx
+AbkgOC05tSminO/0ApFDrIqV0ePg+f97FgWYgB+4llIB8KceWGX9qpGZ+qEXWvpL
+NCS00OlYbXkHnZm+5fOmzHgzEfKeC4Nm9/Jf8RK+0tuVH1AyrnV6zgI=
+-----END PRIVATE KEY-----
diff --git a/lib/config/testdata/tls/invalid.crt b/lib/config/testdata/tls/invalid.crt
new file mode 100644
index 000000000..e69de29bb
diff --git a/lib/config/testdata/tls/invalid.key b/lib/config/testdata/tls/invalid.key
new file mode 100644
index 000000000..e69de29bb
diff --git a/lib/config/testdata/tls/minica-key.pem b/lib/config/testdata/tls/minica-key.pem
new file mode 100644
index 000000000..2110df56f
--- /dev/null
+++ b/lib/config/testdata/tls/minica-key.pem
@@ -0,0 +1,6 @@
+-----BEGIN PRIVATE KEY-----
+MIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDDr9QQo7ZaTgUL6d73G
+2BG7+YRTFJHAZa0FogRglfc+jYttL1J4/xTig3RmHoqSgrehZANiAASDhijM9Xe0
+G9Vam6AJMeKC6aWDNSLwrxNVmPxemsY/yJ1urBgnxRd9GFH6YW1ki/B8rS+Xl1UX
+NnhBrukLaXvgAQQq782/5IUYGsvK5jw8+dSscYVMCQJwGfmQuaNeczQ=
+-----END PRIVATE KEY-----
diff --git a/lib/config/testdata/tls/minica.pem b/lib/config/testdata/tls/minica.pem
new file mode 100644
index 000000000..c8acb8abd
--- /dev/null
+++ b/lib/config/testdata/tls/minica.pem
@@ -0,0 +1,13 @@
+-----BEGIN CERTIFICATE-----
+MIIB+zCCAYKgAwIBAgIIQWLAtv4ijQ0wCgYIKoZIzj0EAwMwIDEeMBwGA1UEAxMV
+bWluaWNhIHJvb3QgY2EgNDE2MmMwMCAXDTI2MDQyMjIzMjUwMVoYDzIxMjYwNDIy
+MjMyNTAxWjAgMR4wHAYDVQQDExVtaW5pY2Egcm9vdCBjYSA0MTYyYzAwdjAQBgcq
+hkjOPQIBBgUrgQQAIgNiAASDhijM9Xe0G9Vam6AJMeKC6aWDNSLwrxNVmPxemsY/
+yJ1urBgnxRd9GFH6YW1ki/B8rS+Xl1UXNnhBrukLaXvgAQQq782/5IUYGsvK5jw8
++dSscYVMCQJwGfmQuaNeczSjgYYwgYMwDgYDVR0PAQH/BAQDAgKEMB0GA1UdJQQW
+MBQGCCsGAQUFBwMBBggrBgEFBQcDAjASBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1Ud
+DgQWBBRP+1Q8RdvnFLPW7tKSTN8aAi9U0jAfBgNVHSMEGDAWgBRP+1Q8RdvnFLPW
+7tKSTN8aAi9U0jAKBggqhkjOPQQDAwNnADBkAjBfY7vb7cuLTjg7uoe+kl07FMYT
+BGMSnWdhN3yXqMUS3A6XZxD/LntXT6V7yFOlAJYCMH7w8/ATYaTqbk2jBRyQt9/x
+ajN+kZ6ZK+fKttqE8CD62mbHg09xoNxRq+K2I3PVyQ==
+-----END CERTIFICATE-----
diff --git a/lib/config/testdata/tls/selfsigned.crt b/lib/config/testdata/tls/selfsigned.crt
new file mode 100644
index 000000000..a431395cd
--- /dev/null
+++ b/lib/config/testdata/tls/selfsigned.crt
@@ -0,0 +1,11 @@
+-----BEGIN CERTIFICATE-----
+MIIBnzCCAVGgAwIBAgIUK39B3Ft+kU5o81IuISs79O4u1ncwBQYDK2VwMEUxCzAJ
+BgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5l
+dCBXaWRnaXRzIFB0eSBMdGQwHhcNMjYwNDIyMTQyNjE4WhcNMjYwNTIyMTQyNjE4
+WjBFMQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwY
+SW50ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMCowBQYDK2VwAyEAfgpAUpp8MIOOdQpH
+fxaw3R7mFKQRMR6Kmxzk1Xn/2VujUzBRMB0GA1UdDgQWBBSmkBmzo0RiZ2iocMR8
+uIIpz9cZyTAfBgNVHSMEGDAWgBSmkBmzo0RiZ2iocMR8uIIpz9cZyTAPBgNVHRMB
+Af8EBTADAQH/MAUGAytlcANBAG37XXZrVUUzGyy3T9qsPIzvJQAGpGhdjJ7bt9O6
+sBhzrliTONPrudYuyUggWsHgFb0JlN2xs4/2HhKU+PY7AAQ=
+-----END CERTIFICATE-----
diff --git a/lib/config/testdata/tls/selfsigned.key b/lib/config/testdata/tls/selfsigned.key
new file mode 100644
index 000000000..73e732178
--- /dev/null
+++ b/lib/config/testdata/tls/selfsigned.key
@@ -0,0 +1,3 @@
+-----BEGIN PRIVATE KEY-----
+MC4CAQAwBQYDK2VwBCIEIL0HxjjfVlg6zQPB9/zTLq0IBzfp8gEoifEYzQZYIj+T
+-----END PRIVATE KEY-----
diff --git a/lib/policy/config/threshold.go b/lib/config/threshold.go
similarity index 97%
rename from lib/policy/config/threshold.go
rename to lib/config/threshold.go
index d9a0ed057..5b8555dd4 100644
--- a/lib/policy/config/threshold.go
+++ b/lib/config/threshold.go
@@ -24,17 +24,16 @@ var (
Challenge: &ChallengeRules{
Algorithm: "fast",
Difficulty: anubis.DefaultDifficulty,
- ReportAs: anubis.DefaultDifficulty,
},
},
}
)
type Threshold struct {
- Name string `json:"name" yaml:"name"`
Expression *ExpressionOrList `json:"expression" yaml:"expression"`
- Action Rule `json:"action" yaml:"action"`
Challenge *ChallengeRules `json:"challenge" yaml:"challenge"`
+ Name string `json:"name" yaml:"name"`
+ Action Rule `json:"action" yaml:"action"`
}
func (t Threshold) Valid() error {
diff --git a/lib/policy/config/threshold_test.go b/lib/config/threshold_test.go
similarity index 98%
rename from lib/policy/config/threshold_test.go
rename to lib/config/threshold_test.go
index 1c668b2be..44f846bf6 100644
--- a/lib/policy/config/threshold_test.go
+++ b/lib/config/threshold_test.go
@@ -10,9 +10,9 @@ import (
func TestThresholdValid(t *testing.T) {
for _, tt := range []struct {
- name string
- input *Threshold
err error
+ input *Threshold
+ name string
}{
{
name: "basic allow",
@@ -32,7 +32,6 @@ func TestThresholdValid(t *testing.T) {
Challenge: &ChallengeRules{
Algorithm: "fast",
Difficulty: 1,
- ReportAs: 1,
},
},
err: nil,
@@ -98,7 +97,7 @@ func TestLoadActuallyLoadsThresholds(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- defer fin.Close()
+ defer fin.Close() //nolint:errcheck
c, err := Load(fin, fin.Name())
if err != nil {
diff --git a/lib/policy/config/weight.go b/lib/config/weight.go
similarity index 100%
rename from lib/policy/config/weight.go
rename to lib/config/weight.go
diff --git a/lib/config_test.go b/lib/config_test.go
index af31ca6b2..3fdc32594 100644
--- a/lib/config_test.go
+++ b/lib/config_test.go
@@ -12,21 +12,20 @@ import (
)
func TestInvalidChallengeMethod(t *testing.T) {
- if _, err := LoadPoliciesOrDefault(t.Context(), "testdata/invalid-challenge-method.yaml", 4); !errors.Is(err, policy.ErrChallengeRuleHasWrongAlgorithm) {
+ if _, err := LoadPoliciesOrDefault(t.Context(), "testdata/invalid-challenge-method.yaml", 4, "info", false); !errors.Is(err, policy.ErrChallengeRuleHasWrongAlgorithm) {
t.Fatalf("wanted error %v but got %v", policy.ErrChallengeRuleHasWrongAlgorithm, err)
}
}
func TestBadConfigs(t *testing.T) {
- finfos, err := os.ReadDir("policy/config/testdata/bad")
+ finfos, err := os.ReadDir("config/testdata/bad")
if err != nil {
t.Fatal(err)
}
for _, st := range finfos {
- st := st
t.Run(st.Name(), func(t *testing.T) {
- if _, err := LoadPoliciesOrDefault(t.Context(), filepath.Join("policy", "config", "testdata", "bad", st.Name()), anubis.DefaultDifficulty); err == nil {
+ if _, err := LoadPoliciesOrDefault(t.Context(), filepath.Join("config", "testdata", "bad", st.Name()), anubis.DefaultDifficulty, "info", false); err == nil {
t.Fatal(err)
} else {
t.Log(err)
@@ -36,23 +35,22 @@ func TestBadConfigs(t *testing.T) {
}
func TestGoodConfigs(t *testing.T) {
- finfos, err := os.ReadDir("policy/config/testdata/good")
+ finfos, err := os.ReadDir("config/testdata/good")
if err != nil {
t.Fatal(err)
}
for _, st := range finfos {
- st := st
t.Run(st.Name(), func(t *testing.T) {
t.Run("with-thoth", func(t *testing.T) {
ctx := thothmock.WithMockThoth(t)
- if _, err := LoadPoliciesOrDefault(ctx, filepath.Join("policy", "config", "testdata", "good", st.Name()), anubis.DefaultDifficulty); err != nil {
+ if _, err := LoadPoliciesOrDefault(ctx, filepath.Join("config", "testdata", "good", st.Name()), anubis.DefaultDifficulty, "info", false); err != nil {
t.Fatal(err)
}
})
t.Run("without-thoth", func(t *testing.T) {
- if _, err := LoadPoliciesOrDefault(t.Context(), filepath.Join("policy", "config", "testdata", "good", st.Name()), anubis.DefaultDifficulty); err != nil {
+ if _, err := LoadPoliciesOrDefault(t.Context(), filepath.Join("config", "testdata", "good", st.Name()), anubis.DefaultDifficulty, "info", false); err != nil {
t.Fatal(err)
}
})
diff --git a/lib/extension_test.go b/lib/extension_test.go
new file mode 100644
index 000000000..d75c9ca1e
--- /dev/null
+++ b/lib/extension_test.go
@@ -0,0 +1,199 @@
+package lib
+
+import (
+ "compress/gzip"
+ "context"
+ "errors"
+ "io"
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync"
+ "testing"
+
+ "github.com/TecharoHQ/anubis"
+ "github.com/TecharoHQ/anubis/internal"
+ "github.com/TecharoHQ/anubis/lib/challenge"
+ "github.com/TecharoHQ/anubis/lib/challenge/extension"
+ "github.com/TecharoHQ/anubis/lib/store"
+ "github.com/a-h/templ"
+)
+
+// XXX(Xe): This is the only real place this test can run because anywhere
+// else incurs wicked import loops that I really don't want to trace down
+// because that means a lot of drift across a lot of the program.
+
+const testExtensionMarker = `
`
+
+// testExtension is a test fixture extension that tests the extension
+// loading and dispatch logic.
+type testExtension struct {
+ lock sync.Mutex
+ validateErr error
+ seenIDs []string
+}
+
+func (te *testExtension) Setup(mux *http.ServeMux, st store.Interface) error { return nil }
+
+func (te *testExtension) Head(r *http.Request, chall *challenge.Challenge) templ.Component {
+ return templ.Raw(testExtensionMarker)
+}
+
+func (te *testExtension) Validate(r *http.Request, lg *slog.Logger, in *challenge.ValidateInput) error {
+ te.lock.Lock()
+ defer te.lock.Unlock()
+ te.seenIDs = append(te.seenIDs, in.Challenge.ID)
+ return te.validateErr
+}
+
+func (te *testExtension) reset(validateErr error) {
+ te.lock.Lock()
+ defer te.lock.Unlock()
+ te.validateErr = validateErr
+ te.seenIDs = nil
+}
+
+func (te *testExtension) validatedIDs() []string {
+ te.lock.Lock()
+ defer te.lock.Unlock()
+ return append([]string(nil), te.seenIDs...)
+}
+
+var theTestExtension = &testExtension{}
+
+func init() {
+ extension.Register("test-extension", theTestExtension)
+}
+
+func readChallengePageBody(t *testing.T, resp *http.Response) string {
+ t.Helper()
+
+ var body io.Reader = resp.Body
+
+ switch resp.Header.Get("Content-Encoding") {
+ case "gzip":
+ gz, err := gzip.NewReader(body)
+ if err != nil {
+ t.Fatalf("can't read gzipped response: %v", err)
+ }
+ defer gz.Close() //nolint:errcheck
+ body = gz
+ }
+
+ data, err := io.ReadAll(body)
+ if err != nil {
+ t.Fatalf("can't read challenge page: %v", err)
+ }
+ return string(data)
+}
+
+func authCookie(srv *Server, resp *http.Response) *http.Cookie {
+ name := srv.cookieName(anubis.CookieName)
+ for _, ckie := range resp.Cookies() {
+ if ckie.Name == name && ckie.Value != "" {
+ return ckie
+ }
+ }
+ return nil
+}
+
+func TestChallengeExtensionValidate(t *testing.T) {
+ for _, tt := range []struct {
+ name string
+ validateErr error
+ wantStatus int
+ wantCookie bool
+ }{
+ {
+ name: "accepted",
+ wantStatus: http.StatusFound,
+ wantCookie: true,
+ },
+ {
+ name: "refused with challenge error",
+ validateErr: challenge.NewError("validate", "browser failed", challenge.ErrFailed),
+ wantStatus: http.StatusForbidden,
+ },
+ {
+ name: "refused with plain error",
+ validateErr: errors.New("store is down"),
+ wantStatus: http.StatusInternalServerError,
+ },
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ theTestExtension.reset(tt.validateErr)
+
+ srv := spawnAnubis(t, Options{
+ Next: http.NewServeMux(),
+ Policy: loadPolicies(t, "./testdata/challenge-extension.yaml", 0),
+ })
+ ts := httptest.NewServer(internal.RemoteXRealIP(true, "tcp", srv))
+ defer ts.Close()
+
+ cli := httpClient(t)
+ chall := makeChallenge(t, ts, cli)
+ resp := handleChallengeZeroDifficulty(t, ts, cli, chall)
+ defer resp.Body.Close() //nolint:errcheck
+
+ if resp.StatusCode != tt.wantStatus {
+ t.Errorf("wanted status %d, got: %d", tt.wantStatus, resp.StatusCode)
+ }
+
+ if got := authCookie(srv, resp) != nil; got != tt.wantCookie {
+ t.Errorf("wanted auth cookie set: %v, got: %v", tt.wantCookie, got)
+ }
+
+ if got := theTestExtension.validatedIDs(); len(got) != 1 || got[0] != chall.ID {
+ t.Errorf("wanted extension to validate challenge %q once, got: %q", chall.ID, got)
+ }
+ })
+ }
+}
+
+func TestChallengeExtensionHeadIsRendered(t *testing.T) {
+ theTestExtension.reset(nil)
+
+ srv := spawnAnubis(t, Options{
+ Next: http.NewServeMux(),
+ Policy: loadPolicies(t, "./testdata/challenge-extension.yaml", 0),
+ })
+ ts := httptest.NewServer(internal.RemoteXRealIP(true, "tcp", srv))
+ defer ts.Close()
+
+ cli := httpClient(t)
+ resp, err := cli.Get(ts.URL + "/")
+ if err != nil {
+ t.Fatalf("can't get challenge page: %v", err)
+ }
+ defer resp.Body.Close() //nolint:errcheck
+
+ body := readChallengePageBody(t, resp)
+ headMarker := strings.Index(body, testExtensionMarker)
+ if headMarker == -1 {
+ t.Fatalf("challenge page does not contain the extension head %q", testExtensionMarker)
+ }
+
+ // Extension scripts must run before the challenge script.
+ mainAt := strings.Index(body, "anubis-main")
+ if mainAt == -1 {
+ t.Fatal("challenge page does not contain the main script")
+ }
+ if headMarker > mainAt {
+ t.Error("extension head is rendered after the challenge script")
+ }
+}
+
+func TestChallengeKnownExtensionMakesPolicyLoad(t *testing.T) {
+ _, err := LoadPoliciesOrDefault(context.Background(), "./testdata/challenge-extension.yaml", 0, "info", false)
+ if err != nil {
+ t.Fatalf("unexpected config parsing failure: %v", err)
+ }
+}
+
+func TestChallengeExtensionUnknownFailsPolicyLoad(t *testing.T) {
+ _, err := LoadPoliciesOrDefault(context.Background(), "./testdata/challenge-extension-unknown.yaml", 0, "info", false)
+ if !errors.Is(err, ErrUnknownChallengeExtension) {
+ t.Fatalf("wanted %v, got: %v", ErrUnknownChallengeExtension, err)
+ }
+}
diff --git a/lib/http.go b/lib/http.go
index 0f63ba74c..a8f249679 100644
--- a/lib/http.go
+++ b/lib/http.go
@@ -1,6 +1,11 @@
package lib
import (
+ "bytes"
+ "compress/gzip"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/hex"
"errors"
"fmt"
"math/rand"
@@ -14,6 +19,7 @@ import (
"github.com/TecharoHQ/anubis/internal"
"github.com/TecharoHQ/anubis/internal/glob"
"github.com/TecharoHQ/anubis/lib/challenge"
+ "github.com/TecharoHQ/anubis/lib/challenge/extension"
"github.com/TecharoHQ/anubis/lib/localization"
"github.com/TecharoHQ/anubis/lib/policy"
"github.com/TecharoHQ/anubis/web"
@@ -25,6 +31,10 @@ import (
var domainMatchRegexp = regexp.MustCompile(`^((xn--)?[a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$`)
+var (
+ ErrActualAnubisBug = errors.New("this is an actual bug in Anubis, please file an issue with the magic string 'taco bell'")
+)
+
// matchRedirectDomain returns true if host matches any of the allowed redirect
// domain patterns. Patterns may contain '*' which are matched using the
// internal glob matcher. Matching is case-insensitive on hostnames.
@@ -45,6 +55,58 @@ func matchRedirectDomain(allowed []string, host string) bool {
return false
}
+var (
+ ErrInvalidRedirect = errors.New("invalid redirect")
+ ErrRedirectDomainNotAllowed = errors.New("redirect domain not allowed")
+ ErrUnknownChallengeExtension = errors.New("unknown challenge extension")
+)
+
+// validateRedirect validates the form-decoded target without changing its escaping.
+func (s *Server) validateRedirect(redir string) (*url.URL, error) {
+ if strings.HasPrefix(redir, " ") || strings.ContainsAny(redir, "\\") {
+ return nil, ErrInvalidRedirect
+ }
+ for _, c := range redir {
+ if c < 0x20 || c == 0x7f {
+ return nil, ErrInvalidRedirect
+ }
+ }
+ u, err := url.Parse(redir)
+ if err != nil {
+ return nil, fmt.Errorf("%w: %w", ErrInvalidRedirect, err)
+ }
+ if u.Opaque != "" {
+ return nil, ErrInvalidRedirect
+ }
+ switch u.Scheme {
+ case "":
+ if u.Host != "" || strings.HasPrefix(u.Path, "//") || strings.ContainsAny(u.Path, "\\") {
+ return nil, ErrInvalidRedirect
+ }
+ case "http", "https":
+ if u.Hostname() == "" {
+ return nil, ErrInvalidRedirect
+ }
+ default:
+ return nil, ErrInvalidRedirect
+ }
+ if u.Host != "" && len(s.opts.RedirectDomains) != 0 && !matchRedirectDomain(s.opts.RedirectDomains, u.Host) {
+ return nil, ErrRedirectDomainNotAllowed
+ }
+ return u, nil
+}
+
+func (s *Server) rejectRedirect(w http.ResponseWriter, r *http.Request, err error) {
+ lg, _ := s.getRequestLogger(r)
+ localizer := localization.GetLocalizer(r)
+ message := "invalid_redirect"
+ if errors.Is(err, ErrRedirectDomainNotAllowed) {
+ message = "redirect_domain_not_allowed"
+ }
+ lg.DebugContext(r.Context(), "invalid redirect", "err", err)
+ s.respondWithStatus(w, r, localizer.T(message), "", http.StatusBadRequest)
+}
+
type CookieOpts struct {
Value string
Host string
@@ -53,6 +115,21 @@ type CookieOpts struct {
Expiry time.Duration
}
+func (s *Server) cookieName(base string) string {
+ h := sha256.Sum256(fmt.Appendf(nil, "%t|%t|%t|%d|%s|%t|%s",
+ s.opts.CookieHttpOnly, s.opts.CookieSecure, s.opts.CookiePartitioned,
+ s.opts.CookieSameSite, s.opts.CookieDomain, s.opts.CookieDynamicDomain,
+ anubis.BasePrefix))
+ return base + "-" + hex.EncodeToString(h[:4])
+}
+
+// getCookie reads the cookie named base from the request. It applies the same
+// settings suffix that SetCookie applies, so that cookies written with other
+// settings are ignored.
+func (s *Server) getCookie(r *http.Request, base string) (*http.Cookie, error) {
+ return r.Cookie(s.cookieName(base))
+}
+
func (s *Server) SetCookie(w http.ResponseWriter, cookieOpts CookieOpts) {
var domain = s.opts.CookieDomain
var name = anubis.CookieName
@@ -80,11 +157,12 @@ func (s *Server) SetCookie(w http.ResponseWriter, cookieOpts CookieOpts) {
}
http.SetCookie(w, &http.Cookie{
- Name: name,
+ Name: s.cookieName(name),
Value: cookieOpts.Value,
Expires: time.Now().Add(cookieOpts.Expiry),
SameSite: sameSite,
Domain: domain,
+ HttpOnly: s.opts.CookieHttpOnly,
Secure: s.opts.CookieSecure,
Partitioned: s.opts.CookiePartitioned,
Path: path,
@@ -113,13 +191,14 @@ func (s *Server) ClearCookie(w http.ResponseWriter, cookieOpts CookieOpts) {
}
http.SetCookie(w, &http.Cookie{
- Name: name,
+ Name: s.cookieName(name),
Value: "",
MaxAge: -1,
Expires: time.Now().Add(-1 * time.Minute),
SameSite: sameSite,
Partitioned: s.opts.CookiePartitioned,
Domain: domain,
+ HttpOnly: s.opts.CookieHttpOnly,
Secure: s.opts.CookieSecure,
Path: path,
})
@@ -145,17 +224,72 @@ func randomChance(n int) bool {
return rand.Intn(n) == 0
}
+// XXX(Xe): generated by ChatGPT
+func rot13(s string) string {
+ rotated := make([]rune, len(s))
+ for i, c := range s {
+ switch {
+ case c >= 'A' && c <= 'Z':
+ rotated[i] = 'A' + ((c - 'A' + 13) % 26)
+ case c >= 'a' && c <= 'z':
+ rotated[i] = 'a' + ((c - 'a' + 13) % 26)
+ default:
+ rotated[i] = c
+ }
+ }
+ return string(rotated)
+}
+
+func makeCode(err error) string {
+ var buf bytes.Buffer
+ gzw := gzip.NewWriter(&buf)
+ errStr := fmt.Sprintf("internal error: %v", err)
+
+ fmt.Fprintln(gzw, rot13(errStr)) //nolint:errcheck
+ if err := gzw.Close(); err != nil {
+ panic("can't write to gzip in ram buffer")
+ }
+ const width = 16
+
+ enc := base64.StdEncoding.EncodeToString(buf.Bytes())
+ var builder strings.Builder
+ for i := 0; i < len(enc); i += width {
+ end := min(i+width, len(enc))
+ builder.WriteString(enc[i:end])
+ builder.WriteByte('\n')
+ }
+ return builder.String()
+}
+
+func mergeExtensions(r *http.Request, chall *challenge.Challenge, component templ.Component) (templ.Component, error) {
+ if len(chall.Extensions) == 0 {
+ return component, nil
+ }
+
+ result := make([]templ.Component, 0, len(chall.Extensions)+1) // add a part for the base template
+ for _, name := range chall.Extensions {
+ ext, ok := extension.Get(name)
+ if !ok {
+ return nil, fmt.Errorf("%w: %q", ErrUnknownChallengeExtension, name)
+ }
+ result = append(result, ext.Head(r, chall))
+ }
+ result = append(result, component)
+
+ return templ.Join(result...), nil
+}
+
func (s *Server) RenderIndex(w http.ResponseWriter, r *http.Request, cr policy.CheckResult, rule *policy.Bot, returnHTTPStatusOnly bool) {
localizer := localization.GetLocalizer(r)
if returnHTTPStatusOnly {
if s.opts.PublicUrl == "" {
w.WriteHeader(http.StatusUnauthorized)
- w.Write([]byte(localizer.T("authorization_required")))
+ w.Write([]byte(localizer.T("authorization_required"))) //nolint:errcheck
} else {
redirectURL, err := s.constructRedirectURL(r)
if err != nil {
- s.respondWithStatus(w, r, err.Error(), http.StatusBadRequest)
+ s.respondWithStatus(w, r, err.Error(), "", http.StatusBadRequest)
return
}
http.Redirect(w, r, redirectURL, http.StatusTemporaryRedirect)
@@ -163,20 +297,27 @@ func (s *Server) RenderIndex(w http.ResponseWriter, r *http.Request, cr policy.C
return
}
- lg := internal.GetRequestLogger(s.logger, r)
+ lg, r := s.getRequestLogger(r)
if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") && randomChance(64) {
- lg.Error("client was given a challenge but does not in fact support gzip compression")
- s.respondWithError(w, r, localizer.T("client_error_browser"))
+ lg.InfoContext(r.Context(), "client was given a challenge but does not in fact support gzip compression")
+ s.respondWithStatus(w, r, localizer.T("client_error_browser"), "", s.policy.StatusCodes.Deny)
return
}
- challengesIssued.WithLabelValues("embedded").Add(1)
+ {
+ asn, asnDesc := asnFromContext(r.Context())
+ challengesIssued.WithLabelValues("embedded", asn, asnDesc).Add(1)
+ }
chall, err := s.issueChallenge(r.Context(), r, lg, cr, rule)
if err != nil {
- lg.Error("can't get challenge", "err", err)
+ lg.ErrorContext(r.Context(), "can't get challenge", "err", err)
+ algorithm := "unknown"
+ if rule.Challenge != nil {
+ algorithm = rule.Challenge.Algorithm
+ }
s.ClearCookie(w, CookieOpts{Name: anubis.TestCookieName, Host: r.Host})
- s.respondWithError(w, r, fmt.Sprintf("%s: %s", localizer.T("internal_server_error"), rule.Challenge.Algorithm))
+ s.respondWithError(w, r, fmt.Sprintf("%s: %s", localizer.T("internal_server_error"), algorithm), makeCode(err))
return
}
@@ -187,7 +328,7 @@ func (s *Server) RenderIndex(w http.ResponseWriter, r *http.Request, cr policy.C
var err error
ogTags, err = s.OGTags.GetOGTags(r.Context(), r.URL, r.Host)
if err != nil {
- lg.Error("failed to get OG tags", "err", err)
+ lg.ErrorContext(r.Context(), "failed to get OG tags", "err", err)
}
}
@@ -201,9 +342,13 @@ func (s *Server) RenderIndex(w http.ResponseWriter, r *http.Request, cr policy.C
impl, ok := challenge.Get(chall.Method)
if !ok {
- lg.Error("check failed", "err", "can't get algorithm", "algorithm", rule.Challenge.Algorithm)
+ algorithm := "unknown"
+ if rule.Challenge != nil {
+ algorithm = rule.Challenge.Algorithm
+ }
+ lg.ErrorContext(r.Context(), "check failed", "err", "can't get algorithm", "algorithm", algorithm)
s.ClearCookie(w, CookieOpts{Name: anubis.TestCookieName, Host: r.Host})
- s.respondWithError(w, r, fmt.Sprintf("%s: %s", localizer.T("internal_server_error"), rule.Challenge.Algorithm))
+ s.respondWithError(w, r, fmt.Sprintf("%s: %s", localizer.T("internal_server_error"), algorithm), makeCode(err))
return
}
@@ -217,8 +362,15 @@ func (s *Server) RenderIndex(w http.ResponseWriter, r *http.Request, cr policy.C
component, err := impl.Issue(w, r, lg, in)
if err != nil {
- lg.Error("[unexpected] challenge component render failed, please open an issue", "err", err) // This is likely a bug in the template. Should never be triggered as CI tests for this.
- s.respondWithError(w, r, fmt.Sprintf("%s \"RenderIndex\"", localizer.T("internal_server_error")))
+ lg.ErrorContext(r.Context(), "[unexpected] challenge component render failed, please open an issue", "err", err) // This is likely a bug in the template. Should never be triggered as CI tests for this.
+ s.respondWithError(w, r, fmt.Sprintf("%s \"RenderIndex\"", localizer.T("internal_server_error")), makeCode(err))
+ return
+ }
+
+ component, err = mergeExtensions(r, chall, component)
+ if err != nil {
+ lg.ErrorContext(r.Context(), "can't render challenge extensions", "err", err, "note", ErrActualAnubisBug)
+ s.respondWithError(w, r, fmt.Sprintf(`%s "RenderIndex"`, localizer.T("internal_server_error")), makeCode(err))
return
}
@@ -226,6 +378,7 @@ func (s *Server) RenderIndex(w http.ResponseWriter, r *http.Request, cr policy.C
localizer.T("making_sure_not_bot"),
component,
s.policy.Impressum,
+ s.policy.Honeypot,
chall,
in.Rule.Challenge,
in.OGTags,
@@ -249,10 +402,20 @@ func (s *Server) constructRedirectURL(r *http.Request) (string, error) {
if proto == "" || host == "" || uri == "" {
return "", errors.New(localizer.T("missing_required_forwarded_headers"))
}
+
+ switch proto {
+ case "http", "https":
+ // allowed
+ default:
+ lg, _ := s.getRequestLogger(r)
+ lg.WarnContext(r.Context(), "invalid protocol in X-Forwarded-Proto", "proto", proto)
+ return "", errors.New(localizer.T("invalid_redirect"))
+ }
+
// Check if host is allowed in RedirectDomains (supports '*' via glob)
if len(s.opts.RedirectDomains) > 0 && !matchRedirectDomain(s.opts.RedirectDomains, host) {
- lg := internal.GetRequestLogger(s.logger, r)
- lg.Debug("domain not allowed", "domain", host)
+ lg, _ := s.getRequestLogger(r)
+ lg.DebugContext(r.Context(), "domain not allowed", "domain", host)
return "", errors.New(localizer.T("redirect_domain_not_allowed"))
}
@@ -265,18 +428,26 @@ func (s *Server) RenderBench(w http.ResponseWriter, r *http.Request) {
localizer := localization.GetLocalizer(r)
templ.Handler(
- web.Base(localizer.T("benchmarking_anubis"), web.Bench(localizer), s.policy.Impressum, localizer),
+ web.Base(localizer.T("benchmarking_anubis"), web.Bench(localizer), s.policy.Impressum, s.policy.Honeypot, localizer),
).ServeHTTP(w, r)
}
-func (s *Server) respondWithError(w http.ResponseWriter, r *http.Request, message string) {
- s.respondWithStatus(w, r, message, http.StatusInternalServerError)
+func (s *Server) respondWithError(w http.ResponseWriter, r *http.Request, message, code string) {
+ s.respondWithStatus(w, r, message, code, http.StatusInternalServerError)
}
-func (s *Server) respondWithStatus(w http.ResponseWriter, r *http.Request, msg string, status int) {
+func (s *Server) respondWithStatus(w http.ResponseWriter, r *http.Request, msg, code string, status int) {
localizer := localization.GetLocalizer(r)
- templ.Handler(web.Base(localizer.T("oh_noes"), web.ErrorPage(msg, s.opts.WebmasterEmail, localizer), s.policy.Impressum, localizer), templ.WithStatus(status)).ServeHTTP(w, r)
+ component := web.Base(
+ localizer.T("oh_noes"),
+ web.ErrorPage(msg, s.opts.WebmasterEmail, code, localizer),
+ s.policy.Impressum,
+ s.policy.Honeypot,
+ localizer,
+ )
+ handler := internal.NoStoreCache(templ.Handler(component, templ.WithStatus(status)))
+ handler.ServeHTTP(w, r)
}
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -288,6 +459,15 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
+ // Forward robots.txt requests to mux when ServeRobotsTXT is enabled
+ if s.opts.ServeRobotsTXT {
+ path := strings.TrimPrefix(r.URL.Path, anubis.BasePrefix)
+ if path == "/robots.txt" || path == "/.well-known/robots.txt" {
+ s.mux.ServeHTTP(w, r)
+ return
+ }
+ }
+
s.maybeReverseProxyOrPage(w, r)
}
@@ -322,21 +502,8 @@ func (s *Server) ServeHTTPNext(w http.ResponseWriter, r *http.Request) {
localizer := localization.GetLocalizer(r)
redir := r.FormValue("redir")
- urlParsed, err := r.URL.Parse(redir)
- if err != nil {
- s.respondWithStatus(w, r, localizer.T("redirect_not_parseable"), http.StatusBadRequest)
- return
- }
-
- hostNotAllowed := len(urlParsed.Host) > 0 &&
- len(s.opts.RedirectDomains) != 0 &&
- !matchRedirectDomain(s.opts.RedirectDomains, urlParsed.Host)
- hostMismatch := r.URL.Host != "" && urlParsed.Host != r.URL.Host
-
- if hostNotAllowed || hostMismatch {
- lg := internal.GetRequestLogger(s.logger, r)
- lg.Debug("domain not allowed", "domain", urlParsed.Host)
- s.respondWithStatus(w, r, localizer.T("redirect_domain_not_allowed"), http.StatusBadRequest)
+ if _, err := s.validateRedirect(redir); err != nil {
+ s.rejectRedirect(w, r, err)
return
}
@@ -346,10 +513,11 @@ func (s *Server) ServeHTTPNext(w http.ResponseWriter, r *http.Request) {
}
templ.Handler(
- web.Base(localizer.T("you_are_not_a_bot"), web.StaticHappy(localizer), s.policy.Impressum, localizer),
+ web.Base(localizer.T("you_are_not_a_bot"), web.StaticHappy(localizer), s.policy.Impressum, s.policy.Honeypot, localizer),
).ServeHTTP(w, r)
} else {
- requestsProxied.WithLabelValues(r.Host).Inc()
+ asn, asnDesc := asnFromContext(r.Context())
+ requestsProxied.WithLabelValues(r.Host, asn, asnDesc).Inc()
r = s.stripBasePrefixFromRequest(r)
s.next.ServeHTTP(w, r)
}
diff --git a/lib/http_test.go b/lib/http_test.go
index 4bd353e7b..836a8883f 100644
--- a/lib/http_test.go
+++ b/lib/http_test.go
@@ -7,15 +7,16 @@ import (
"testing"
"github.com/TecharoHQ/anubis"
+ "github.com/TecharoHQ/anubis/internal"
"github.com/TecharoHQ/anubis/lib/policy"
)
func TestSetCookie(t *testing.T) {
for _, tt := range []struct {
name string
- options Options
host string
cookieName string
+ options Options
}{
{
name: "basic",
@@ -40,6 +41,8 @@ func TestSetCookie(t *testing.T) {
srv := spawnAnubis(t, tt.options)
rw := httptest.NewRecorder()
+ tt.cookieName = srv.cookieName(tt.cookieName)
+
srv.SetCookie(rw, CookieOpts{Value: "test", Host: tt.host})
resp := rw.Result()
@@ -70,8 +73,8 @@ func TestClearCookie(t *testing.T) {
ckie := cookies[0]
- if ckie.Name != anubis.CookieName {
- t.Errorf("wanted cookie named %q, got cookie named %q", anubis.CookieName, ckie.Name)
+ if want := srv.cookieName(anubis.CookieName); ckie.Name != want {
+ t.Errorf("wanted cookie named %q, got cookie named %q", want, ckie.Name)
}
if ckie.MaxAge != -1 {
@@ -95,8 +98,8 @@ func TestClearCookieWithDomain(t *testing.T) {
ckie := cookies[0]
- if ckie.Name != anubis.CookieName {
- t.Errorf("wanted cookie named %q, got cookie named %q", anubis.CookieName, ckie.Name)
+ if want := srv.cookieName(anubis.CookieName); ckie.Name != want {
+ t.Errorf("wanted cookie named %q, got cookie named %q", want, ckie.Name)
}
if ckie.MaxAge != -1 {
@@ -120,8 +123,8 @@ func TestClearCookieWithDynamicDomain(t *testing.T) {
ckie := cookies[0]
- if ckie.Name != anubis.CookieName {
- t.Errorf("wanted cookie named %q, got cookie named %q", anubis.CookieName, ckie.Name)
+ if want := srv.cookieName(anubis.CookieName); ckie.Name != want {
+ t.Errorf("wanted cookie named %q, got cookie named %q", want, ckie.Name)
}
if ckie.Domain != "xeiaso.net" {
@@ -191,3 +194,48 @@ func TestRenderIndexUnauthorized(t *testing.T) {
t.Errorf("expected body %q, got %q", "Authorization required", body)
}
}
+
+func TestNoCacheOnError(t *testing.T) {
+ pol := loadPolicies(t, "testdata/useragent.yaml", 0)
+ srv := spawnAnubis(t, Options{Policy: pol})
+ ts := httptest.NewServer(internal.RemoteXRealIP(true, "tcp", srv))
+ defer ts.Close()
+
+ for userAgent, expectedCacheControl := range map[string]string{
+ "DENY": "no-store",
+ "CHALLENGE": "no-store",
+ "ALLOW": "",
+ } {
+ t.Run(userAgent, func(t *testing.T) {
+ req, err := http.NewRequest(http.MethodGet, ts.URL, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ req.Header.Set("User-Agent", userAgent)
+
+ resp, err := ts.Client().Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if resp.Header.Get("Cache-Control") != expectedCacheControl {
+ t.Errorf("wanted Cache-Control header %q, got %q", expectedCacheControl, resp.Header.Get("Cache-Control"))
+ }
+ })
+ }
+}
+
+func TestRejectsHostlessRedirect(t *testing.T) {
+ pol := loadPolicies(t, "testdata/useragent.yaml", 0)
+ srv := spawnAnubis(t, Options{Policy: pol, RedirectDomains: []string{"allowed.example"}})
+ req := httptest.NewRequest(http.MethodGet, "https://anubis.example/.within.website/?redir=%2f%2fevil.example%2fphish", nil)
+ rr := httptest.NewRecorder()
+ srv.ServeHTTPNext(rr, req)
+ if rr.Code != http.StatusBadRequest {
+ t.Fatalf("expected hostless redirect to be rejected, got HTTP %d body %q", rr.Code, rr.Body.String())
+ }
+ if got := rr.Header().Get("Location"); got != "" {
+ t.Fatalf("expected no Location header on rejected redirect, got %q", got)
+ }
+}
diff --git a/lib/localization/locales/bg.json b/lib/localization/locales/bg.json
new file mode 100644
index 000000000..5fdfa80af
--- /dev/null
+++ b/lib/localization/locales/bg.json
@@ -0,0 +1,67 @@
+{
+ "loading": "Зареждане...",
+ "why_am_i_seeing": "Защо виждам това?",
+ "protected_by": "Защитено от",
+ "protected_from": "От",
+ "made_with": "Направено с ❤️ в 🇨🇦",
+ "mascot_design": "Дизайн на талисмана от",
+ "ai_companies_explanation": "Виждате това, защото администраторът на този уебсайт е kонфигурирал Anubis, за да защити сървъра от агресивното събиране на данни от компании, занимаващи се с изкуствен интелект. Това може и причинява прекъсвания на уебсайтовете, което прави техните ресурси недостъпни за всички.",
+ "anubis_compromise": "Anubis е компромис. Anubis използва схема за доказателство-за-работа по подобие на Hashcash, предложена схема за доказателство-за-работа за намаляване на спама в имейлите. Идеята е, че при индивидуални мащаби допълнителното натоварване е пренебрежимо, но при масово ниво на събиране на данни то се натрупва и прави събирането на данни много по-скъпо.",
+ "hack_purpose": "В крайна сметка, това е временно решение, за да се отдели повече време за идентифициране и разпознаване на безглави браузъри (например чрез това как те интерпретират шрифтовете), така че страницата за доказателство-за-работа да не се налага да се показва на потребители, които е по-вероятно да са легитимни.",
+ "simplified_explanation": "Това е мярка срещу ботове и злонамерени заявки, подобна на CAPTCHA. Вместо да трябва да правите нещо сами, браузърът ви получава задача за изчисление, която трябва да реши, за да се увери, че е валиден клиент. Тази концепция се нарича схема доказателство-за-работа. Задачата се изчислява за няколко секунди и ви се дава достъп до уебсайта. Благодаря ви за разбирането и търпението.",
+ "jshelter_note": "Моля, имайте предвид, че Anubis изисква използването на модерни функции на JavaScript, които добавки като JShelter ще блокират. Моля, деактивирайте JShelter или други подобни добавки за този домейн.",
+ "version_info": "Този уебсайт използва версия на Anubis",
+ "try_again": "Опитайте отново",
+ "go_home": "Отидете на началната страница",
+ "contact_webmaster": "или ако смятате, че не трябва да бъдете блокирани, моля свържете се с уебмастъра на",
+ "connection_security": "Моля, изчакайте, докато се уверим в сигурността на връзката ви",
+ "javascript_required": "За съжаление, трябва да включите JavaScript, за да минете през това предизвикателство. Това е необходимо, защото компаниите за изкуствен интелект промениха социалния договор около начина на хостинг на уебсайтове. Решение без JavaScript е в процес на разработка.",
+ "benchmark_requires_js": "За да използвате инструмента за тестване, е необходимо да включите JavaScript.",
+ "difficulty": "Трудност:",
+ "algorithm": "Алгоритъм:",
+ "compare": "Сравни:",
+ "time": "Време",
+ "iters": "Итерации",
+ "time_a": "Време А",
+ "iters_a": "Итерации А",
+ "time_b": "Време Б",
+ "iters_b": "Итерации Б",
+ "static_check_endpoint": "Това е просто краен пункт за проверка, който да използва обратният ви прокси.",
+ "authorization_required": "Изисква се удостоверяване",
+ "cookies_disabled": "Браузърът ви е настроен да деактивира бисквитките. Anubis изисква бисквитки за законния интерес да се увери, че сте валиден клиент. Моля, включете бисквитките за този домейн",
+ "access_denied": "Достъпът е отказан: код на грешка",
+ "dronebl_entry": "DroneBL докладва запис",
+ "see_dronebl_lookup": "вижте",
+ "internal_server_error": "Вътрешна сървърна грешка: администраторът е конфигурирал грешно Anubis. Моля, свържете се с администратора и ги помолете да проверят логовете около",
+ "invalid_redirect": "Невалидно пренасочване",
+ "redirect_not_parseable": "URL адресът за пренасочване не може да бъде разпознат",
+ "redirect_domain_not_allowed": "Домейнът за пренасочване не е позволен",
+ "missing_required_forwarded_headers": "Липсват необходимите X-Forwarded-* заглавия",
+ "failed_to_sign_jwt": "неуспешно подписване на JWT",
+ "invalid_invocation": "Невалидно извикване на MakeChallenge",
+ "client_error_browser": "Грешка в клиента: Моля, уверете се, че браузърът ви е актуализиран и опитайте отново по-късно.",
+ "oh_noes": "О, не!",
+ "benchmarking_anubis": "Тестване на Anubis!",
+ "you_are_not_a_bot": "Ти не си бот!",
+ "making_sure_not_bot": "Уверяваме се, че не си бот!",
+ "celphase": "CELPHASE",
+ "js_web_crypto_error": "Браузърът ви няма функциониращ web.crypto елемент. Гледате ли това през сигурен контекст?",
+ "js_web_workers_error": "Браузърът ви не поддържа уеб работници (Anubis използва това, за да избегне замръзване на браузъра ви). Имате ли инсталирана добавка като JShelter?",
+ "js_cookies_error": "Браузърът ви не съхранява бисквитки. Anubis използва бисквитки, за да определи кои клиенти са преминали задачите, като съхранява подписан токен в бисквитка. Моля, включете съхраняването на бисквитки за този домейн. Имената на бисквитките, съхранени от Anubis, могат да се променят без предварително уведомление. Имената и стойностите на бисквитките не са част от публичния API.",
+ "js_context_not_secure": "Вашият контекст не е защитен!",
+ "js_context_not_secure_msg": "Опитайте да се свържете чрез HTTPS или уведомете администратора да kонфигурира HTTPS. За повече информация вижте MDN.",
+ "js_calculating": "Изчисляване...",
+ "js_missing_feature": "Липсваща функция",
+ "js_challenge_error": "Грешка при задачата!",
+ "js_challenge_error_msg": "Неуспешно разрешаване на алгоритъма за проверка. Може би искате да презаредите страницата.",
+ "js_calculating_difficulty": "Изчисляване... Трудност:",
+ "js_speed": "Скорост:",
+ "js_verification_longer": "Проверката отнема повече време от очакваното. Моля, не презареждайте страницата.",
+ "js_success": "Успех!",
+ "js_done_took": "Готово! Отне",
+ "js_iterations": "итерации",
+ "js_finished_reading": "Приключих с четенето, продължете →",
+ "js_calculation_error": "Грешка при изчислението!",
+ "js_calculation_error_msg": "Неуспешно изчисление на задачата:",
+ "script_load_error": "Anubis не можа да зареди своя JavaScript. Сървърът може да е претоварен. Моля, презаредете страницата, за да опитате отново."
+}
diff --git a/lib/localization/locales/cs.json b/lib/localization/locales/cs.json
index db4eded34..3b92332a1 100644
--- a/lib/localization/locales/cs.json
+++ b/lib/localization/locales/cs.json
@@ -62,5 +62,6 @@
"js_finished_reading": "Čtení dokončeno, pokračovat →",
"js_calculation_error": "Chyba výpočtu!",
"js_calculation_error_msg": "Nepodařilo se vypočítat výzvu:",
- "missing_required_forwarded_headers": "Chybějící požadované hlavičky X-Forwarded-*"
-}
\ No newline at end of file
+ "missing_required_forwarded_headers": "Chybějící požadované hlavičky X-Forwarded-*",
+ "script_load_error": "Anubis nemohl načíst svůj JavaScript. Server může být přetížený. Prosím obnovte stránku a zkuste to znovu."
+}
diff --git a/lib/localization/locales/de.json b/lib/localization/locales/de.json
index cf47ae35a..75d82b87d 100644
--- a/lib/localization/locales/de.json
+++ b/lib/localization/locales/de.json
@@ -1,66 +1,67 @@
{
- "loading": "Ladevorgang...",
+ "loading": "Wird geladen …",
"why_am_i_seeing": "Warum sehe ich diese Seite?",
"protected_by": "Geschützt durch",
- "protected_from": "From",
- "made_with": "Mit ❤️ gemacht in 🇨🇦",
- "mascot_design": "Maskottchen erstellt von",
- "ai_companies_explanation": "Diese Seite wird angezeigt, da der Betreiber der Webseite Anubis eingerichtet hat, um sie vor aggressiven KI-Website-Scrapern zu schützen. Diese können Ausfälle der Webseite verursachen, wodurch die Webseite für niemanden erreichbar ist.",
- "anubis_compromise": "Anubis stellt einen Kompromiss dar. Es verwendet eine Proof-of-Work-Methode nach Hashcash, die ursprünglich zur Bekämpfung von E-Mail-Spam entwickelt wurde. Die Idee dahinter ist, dass ein legitimer Besucher die Webseite mit einer vernachlässigbaren Verzögerung erreichen kann. Massenhaftes Scraping wird dadurch jedoch aufwändig und teuer.",
- "hack_purpose": "Letztendlich ist dies eine Platzhalterlösung, damit mehr Zeit für die Fingerabdruckerkennung und Identifizierung von Headless-Browsern (z. B. anhand ihrer Schriftwiedergabe) aufgewendet werden kann, sodass die Proof-of-Work-Seite nicht Benutzern präsentiert werden muss, die viel wahrscheinlicher legitim sind.",
- "jshelter_note": "Anubis benötigt moderne JavaScript-Features, welche von Plugins wie JShelter deaktiviert werden. Bitte deaktiviere JShelter oder ähnliche Plugins für diese Domain.",
- "version_info": "Diese Webseite läuft mit der Anubis-Version",
+ "protected_from": "Von",
+ "made_with": "Mit ❤️ entwickelt in 🇨🇦",
+ "mascot_design": "Maskottchen entworfen von",
+ "ai_companies_explanation": "Diese Seite wird angezeigt, weil der Betreiber dieser Website Anubis eingerichtet hat, um den Server vor aggressivem Scraping durch KI-Unternehmen zu schützen. Dieses Scraping kann Ausfälle verursachen, wodurch die Website für niemanden erreichbar ist.",
+ "anubis_compromise": "Anubis ist ein Kompromiss. Es verwendet ein Proof-of-Work-Verfahren nach dem Vorbild von Hashcash, das ursprünglich zur Reduzierung von E-Mail-Spam entwickelt wurde. Die Idee dahinter ist, dass die zusätzliche Last für einzelne Nutzer vernachlässigbar ist, sich aber auf der Ebene von Massen-Scrapern summiert und das Scraping deutlich teurer macht.",
+ "hack_purpose": "Letztlich ist dies eine Übergangslösung, damit mehr Zeit in das Fingerprinting und die Erkennung von Headless-Browsern investiert werden kann (z. B. anhand ihrer Schriftart-Darstellung), sodass die Proof-of-Work-Seite Nutzern, die mit hoher Wahrscheinlichkeit legitim sind, nicht mehr angezeigt werden muss.",
+ "simplified_explanation": "Dies ist eine Schutzmaßnahme gegen Bots und schädliche Anfragen, ähnlich einem CAPTCHA. Anstatt selbst eine Aufgabe lösen zu müssen, bekommt dein Browser eine Rechenaufgabe, die er lösen muss, um sicherzustellen, dass es sich um einen gültigen Client handelt. Dieses Konzept nennt sich
Proof of Work. Die Aufgabe wird innerhalb weniger Sekunden berechnet und du erhältst Zugang zur Website. Danke für dein Verständnis und deine Geduld.",
+ "jshelter_note": "Anubis benötigt moderne JavaScript-Funktionen, die von Plugins wie JShelter deaktiviert werden. Bitte deaktiviere JShelter oder ähnliche Plugins für diese Domain.",
+ "version_info": "Diese Website nutzt Anubis Version",
"try_again": "Erneut versuchen",
- "go_home": "Zur Hauptseite",
- "contact_webmaster": "oder wenn Du glaubst, dass es sich hierbei um einen Fehler handelt, kontaktiere bitte den Administrator der Webseite unter",
- "connection_security": "Bitte warte einen Moment, während wir sicherstellen, dass eine sichere Verbindung verwendet wird.",
- "javascript_required": "Du musst JavaScript aktivieren, um diese Prüfung durchführen zu können. Dies ist notwendig, da KI-Unternehmen den sozialen Vertrag bezüglich des Hostings von Webseiten gebrochen haben. Eine Lösung ohne JavaScript ist in Entwicklung.",
- "benchmark_requires_js": "Für die Nutzung des Benchmark-Tools muss JavaScript aktiviert werden.",
+ "go_home": "Zur Startseite",
+ "contact_webmaster": "oder kontaktiere den Webmaster unter, falls du glaubst, dass du nicht blockiert werden solltest:",
+ "connection_security": "Bitte warte einen Moment, während wir die Sicherheit deiner Verbindung überprüfen.",
+ "javascript_required": "Du musst JavaScript aktivieren, um diese Prüfung zu bestehen. Dies ist notwendig, da KI-Unternehmen den Gesellschaftsvertrag rund um Webhosting verändert haben. Eine Lösung ohne JavaScript ist in Arbeit.",
+ "benchmark_requires_js": "Für das Benchmark-Tool muss JavaScript aktiviert sein.",
"difficulty": "Schwierigkeit:",
"algorithm": "Algorithmus:",
- "compare": "Vergleich:",
+ "compare": "Vergleichen:",
"time": "Zeit",
"iters": "Iterationen",
"time_a": "Zeit A",
"iters_a": "Iterationen A",
"time_b": "Zeit B",
"iters_b": "Iterationen B",
- "static_check_endpoint": "Dies ist nur ein Endpunkt, der von einem Reverse-Proxy geprüft werden kann.",
- "authorization_required": "Zugriffserlaubnis benötigt",
- "cookies_disabled": "Cookies sind in deinem Browser deaktiviert. Anubis benötigt Cookies, um sicherzustellen, dass es sich hierbei um einen legitimen Zugriff handelt. Bitte aktiviere Cookies für diese Domain.",
+ "static_check_endpoint": "Dies ist nur ein Prüf-Endpunkt für deinen Reverse-Proxy.",
+ "authorization_required": "Autorisierung erforderlich",
+ "cookies_disabled": "Cookies sind in deinem Browser deaktiviert. Anubis benötigt Cookies im berechtigten Interesse, sicherzustellen, dass es sich um einen gültigen Client handelt. Bitte aktiviere Cookies für diese Domain.",
"access_denied": "Zugriff verweigert: Fehlercode",
- "dronebl_entry": "Eintrag in DroneBL",
+ "dronebl_entry": "DroneBL hat einen Eintrag gemeldet",
"see_dronebl_lookup": "anzeigen",
- "internal_server_error": "Interner Server-Fehler: Der Administrator hat Anubis fehlerhaft konfiguriert. Bitte kontaktiere den Administrator und bitte ihn, die Logs zu prüfen.",
+ "internal_server_error": "Interner Serverfehler: Der Administrator hat Anubis fehlerhaft konfiguriert. Bitte kontaktiere den Administrator und bitte ihn, die Logs im Zeitraum um folgenden Zeitpunkt zu prüfen:",
"invalid_redirect": "Ungültige Weiterleitung",
- "redirect_not_parseable": "URL der Weiterleitung kann nicht verarbeitet werden",
- "redirect_domain_not_allowed": "Domain der Weiterleitung nicht erlaubt",
+ "redirect_not_parseable": "Weiterleitungs-URL kann nicht verarbeitet werden",
+ "redirect_domain_not_allowed": "Weiterleitungs-Domain nicht erlaubt",
+ "missing_required_forwarded_headers": "Erforderliche X-Forwarded-*-Header fehlen",
"failed_to_sign_jwt": "JWT konnte nicht signiert werden",
"invalid_invocation": "Ungültiger Aufruf von MakeChallenge",
- "client_error_browser": "Client-Fehler: Bitte stelle sicher, dass dein Browser aktuell ist und versuche es später erneut.",
+ "client_error_browser": "Client-Fehler: Bitte stelle sicher, dass dein Browser aktuell ist, und versuche es später erneut.",
"oh_noes": "Oh nein!",
- "benchmarking_anubis": "Benchmark wird durchgeführt!",
+ "benchmarking_anubis": "Anubis-Benchmark wird durchgeführt!",
"you_are_not_a_bot": "Du bist kein Bot!",
"making_sure_not_bot": "Dein Browser wird geprüft!",
"celphase": "CELPHASE",
- "js_web_crypto_error": "Dein Browser hat kein funktionierendes web.crypto Element. Wird eine sichere Verbindung verwendet?",
- "js_web_workers_error": "Dein Browser unterstützt keine Web-Worker (Anubis verwendet diese, damit der Browser nicht einfriert). Ist ein Plugin wie JShelter installiert?",
- "js_cookies_error": "Dein Browser speichert keine Cookies. Anubis verwendet Cookies, um nach bestandener Prüfung ein signiertes Token abzulegen. Bitte aktiviere Cookies für diese Domain. Die Cookie-Namen von Anubis könnten sich jederzeit ändern. Cookie-Namen und die gespeicherten Werte sind kein Teil der öffentlichen API.",
+ "js_web_crypto_error": "Dein Browser verfügt nicht über ein funktionierendes web.crypto-Element. Wird diese Seite in einem sicheren Kontext angezeigt?",
+ "js_web_workers_error": "Dein Browser unterstützt keine Web Workers (Anubis verwendet diese, damit dein Browser nicht einfriert). Hast du ein Plugin wie JShelter installiert?",
+ "js_cookies_error": "Dein Browser speichert keine Cookies. Anubis verwendet Cookies, um nach bestandener Prüfung ein signiertes Token abzulegen. Bitte aktiviere Cookies für diese Domain. Die Cookie-Namen von Anubis können sich jederzeit ohne Vorankündigung ändern. Cookie-Namen und -Werte sind nicht Teil der öffentlichen API.",
"js_context_not_secure": "Diese Verbindung ist nicht sicher!",
- "js_context_not_secure_msg": "Bitte versuche, dich via HTTPS zu verbinden oder weise den Administrator darauf hin, HTTPS einzurichten. Mehr Informationen unter:
MDN.",
- "js_calculating": "Berechnung wird durchgeführt...",
+ "js_context_not_secure_msg": "Versuche, dich über HTTPS zu verbinden, oder informiere den Administrator, HTTPS einzurichten. Weitere Informationen unter
MDN.",
+ "js_calculating": "Berechnung läuft …",
"js_missing_feature": "Fehlendes Feature",
"js_challenge_error": "Prüfung fehlgeschlagen!",
- "js_challenge_error_msg": "Der Prüf-Algorithmus konnte nicht geladen werden. Bitte lade diese Seite erneut.",
- "js_calculating_difficulty": "Berechnung wird durchgeführt...
Schwierigkeit:",
+ "js_challenge_error_msg": "Der Prüfalgorithmus konnte nicht aufgelöst werden. Bitte lade die Seite neu.",
+ "js_calculating_difficulty": "Berechnung läuft …
Schwierigkeit:",
"js_speed": "Geschwindigkeit:",
- "js_verification_longer": "Die Prüfung benötigt länger als erwartet. Bitte bleibe auf der Seite und lade diese nicht neu.",
- "js_success": "Erfolgreich!",
+ "js_verification_longer": "Die Verifizierung dauert länger als erwartet. Bitte bleibe auf der Seite und lade sie nicht neu.",
+ "js_success": "Geschafft!",
"js_done_took": "Fertig! Dauer:",
"js_iterations": "Iterationen",
"js_finished_reading": "Fertig gelesen, weiter zur Seite →",
- "js_calculation_error": "Berechnung fehlgeschlagen!",
+ "js_calculation_error": "Berechnungsfehler!",
"js_calculation_error_msg": "Fehler bei der Berechnung der Prüfung:",
- "missing_required_forwarded_headers": "Fehlende erforderliche X-Forwarded-* Header",
- "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 Ihr Browser eine Berechnungsaufgabe, die er lösen muss, um sicherzustellen, dass es sich um einen gültigen Client handelt. Dieses Konzept wird als
Proof of Work bezeichnet. Die Aufgabe wird in wenigen Sekunden berechnet und Sie erhalten Zugriff auf die Website. Vielen Dank für Ihr Verständnis und Ihre Geduld."
-}
\ No newline at end of file
+ "script_load_error": "Anubis konnte sein JavaScript nicht laden. Der Server ist möglicherweise überlastet. Bitte lade die Seite neu, um es erneut zu versuchen."
+}
diff --git a/lib/localization/locales/en.json b/lib/localization/locales/en.json
index 0280b975a..47c9c7b79 100644
--- a/lib/localization/locales/en.json
+++ b/lib/localization/locales/en.json
@@ -62,5 +62,6 @@
"js_iterations": "iterations",
"js_finished_reading": "I've finished reading, continue →",
"js_calculation_error": "Calculation error!",
- "js_calculation_error_msg": "Failed to calculate challenge:"
-}
\ No newline at end of file
+ "js_calculation_error_msg": "Failed to calculate challenge:",
+ "script_load_error": "Anubis could not load its JavaScript. The server may be overloaded. Please reload the page to try again."
+}
diff --git a/lib/localization/locales/es.json b/lib/localization/locales/es.json
index 8beb3bf82..878f3dbf2 100644
--- a/lib/localization/locales/es.json
+++ b/lib/localization/locales/es.json
@@ -62,5 +62,6 @@
"js_calculation_error": "¡Error de cálculo!",
"js_calculation_error_msg": "Falló al calcular el desafío:",
"missing_required_forwarded_headers": "Faltan los encabezados X-Forwarded-* requeridos",
- "simplified_explanation": "Esta es una medida contra bots y solicitudes maliciosas similar a un CAPTCHA. Sin embargo, en lugar de tener que hacer el trabajo usted mismo, a su navegador se le asigna una tarea de cálculo que debe resolver para garantizar que es un cliente válido. Este concepto se llama
Prueba de trabajo. La tarea se calcula en unos segundos y se le concede acceso al sitio web. Gracias por su comprensión y paciencia."
-}
\ No newline at end of file
+ "simplified_explanation": "Esta es una medida contra bots y solicitudes maliciosas similar a un CAPTCHA. Sin embargo, en lugar de tener que hacer el trabajo usted mismo, a su navegador se le asigna una tarea de cálculo que debe resolver para garantizar que es un cliente válido. Este concepto se llama
Prueba de trabajo. La tarea se calcula en unos segundos y se le concede acceso al sitio web. Gracias por su comprensión y paciencia.",
+ "script_load_error": "No se pudo cargar el JavaScript de Anubis. Es posible que el servidor esté sobrecargado. Por favor recarga la página para intentarlo de nuevo."
+}
diff --git a/lib/localization/locales/et.json b/lib/localization/locales/et.json
index 09125b9cc..8c3cf3904 100644
--- a/lib/localization/locales/et.json
+++ b/lib/localization/locales/et.json
@@ -62,5 +62,6 @@
"js_calculation_error": "Arvutamise viga!",
"js_calculation_error_msg": "Ei suutnud kontrolli arvutada:",
"missing_required_forwarded_headers": "Puuduvad nõutud X-Forwarded-* päised",
- "simplified_explanation": "See on meede robotite ja pahatahtlike päringute vastu, mis sarnaneb CAPTCHA-le. Kuid selle asemel, et peaksite ise tööd tegema, antakse teie brauserile arvutusülesanne, mille see peab lahendama, et tagada selle kehtivus kliendina. Seda kontseptsiooni nimetatakse
Töötõendiks. Ülesanne arvutatakse mõne sekundiga ja teile antakse juurdepääs veebisaidile. Täname teid mõistva suhtumise ja kannatlikkuse eest."
-}
\ No newline at end of file
+ "simplified_explanation": "See on meede robotite ja pahatahtlike päringute vastu, mis sarnaneb CAPTCHA-le. Kuid selle asemel, et peaksite ise tööd tegema, antakse teie brauserile arvutusülesanne, mille see peab lahendama, et tagada selle kehtivus kliendina. Seda kontseptsiooni nimetatakse
Töötõendiks. Ülesanne arvutatakse mõne sekundiga ja teile antakse juurdepääs veebisaidile. Täname teid mõistva suhtumise ja kannatlikkuse eest.",
+ "script_load_error": "Anubis ei suutnud oma JavaScripti laadida. Server võib olla ülekoormatud. Palun lae leht uuesti ja proovi uuesti."
+}
diff --git a/lib/localization/locales/eu.json b/lib/localization/locales/eu.json
new file mode 100644
index 000000000..0599a611e
--- /dev/null
+++ b/lib/localization/locales/eu.json
@@ -0,0 +1,67 @@
+{
+ "loading": "Kargatzen...",
+ "why_am_i_seeing": "Zergatik ikusten dut hau?",
+ "protected_by": "Honek babestuta",
+ "protected_from": "From",
+ "made_with": "🇨🇦-n ❤️-z eginda",
+ "mascot_design": "Maskotaren diseinua",
+ "ai_companies_explanation": "Webgune honen kudeatzaileak Anubis konfiguratu du zerbitzaria webguneak oldarkorki arakatzen dituzten AA enpresen izurritearen aurka babesteko, horregatik arai zara hau ikusten. Honek webguneen geldialdiak eragin ditzake eta eragiten ditu, eta horrek haien baliabideak guztion eskuraezin bihurtzen ditu.",
+ "anubis_compromise": "Anubis konpromiso bat da. Anubisek Hashcash-en ildotik datorren Proof-of-Work eskema bat erabiltzen du, posta elektroniko bidezko spama murrizteko proposatutako proof-of-work eskema bat. Ideia da eskala indibidualetan karga gehigarria ezin dela alde batera utzi, baina scraper masiboen mailetan metatu egiten da eta scraping-a askoz garestiagoa egiten du.",
+ "hack_purpose": "Azken finean, hau leku-markatzaileen irtenbide bat da, hatz-markak hartzen eta bururik gabeko arakatzaileak identifikatzen denbora gehiago eman ahal izateko (adibidez: letra-tipoak nola erakusten dituzten begiratuz), lanaren froga orria zilegi izateko aukera gehiago duten erabiltzaileei aurkeztu beharrik ez izateko.",
+ "simplified_explanation": "Hau bot eta eskaera gaiztoen aurkako neurri bat da, CAPTCHA baten antzekoa. Hala ere, zuk zeuk lana egin beharrean, zure arakatzaileari kalkulu-zeregin bat ematen zaio, bezero baliozkoa dela ziurtatzeko ebatzi behar duena. Kontzeptu honen izena
Proof of Work da. Zeregina segundo gutxitan kalkulatzen da eta webgunerako sarbidea ematen zaizu. Eskerrik asko zure ulermenagatik eta pazientziagatik..",
+ "jshelter_note": "Kontuan izan Anubisek JShelter bezalako pluginek desgaituko dituzten JavaScript funtzio modernoak behar dituela. Desgaitu JShelter edo antzeko beste plugin batzuk domeinu honetarako.",
+ "version_info": "Webgune honek Anubis bertsio hau darabil",
+ "try_again": "Saiatu berriz",
+ "go_home": "Joan hasierara",
+ "contact_webmaster": "edo blokeatu behar ez zaituztela uste baduzu, jarri harremanetan webmasterrarekin helbide honetan:",
+ "connection_security": "Itxaron une batez zure konexioaren segurtasuna bermatzen dugun bitartean.",
+ "javascript_required": "Zoritxarrez, JavaScript gaituta izan behar duzu erronka hau gainditzeko. Hau beharrezkoa da adimen artifizialeko enpresek webguneen ostatuak nola funtzionatzen duen inguruko kontratu soziala aldatu dutelako. JS gabeko irtenbidea lanean ari den lana da.",
+ "benchmark_requires_js": "Erreferentziazko tresna exekutatzeko JavaScript gaituta egon behar da.",
+ "difficulty": "Zailtasuna:",
+ "algorithm": "Algoritmoa:",
+ "compare": "Alderatu:",
+ "time": "Denbora",
+ "iters": "Iterazioak",
+ "time_a": "A denbora",
+ "iters_a": "A iterazioak",
+ "time_b": "B denbora",
+ "iters_b": "B iterazioak",
+ "static_check_endpoint": "Hau zure alderantzizko proxyak erabiltzeko egiaztapen-mutur bat besterik ez da..",
+ "authorization_required": "Baimena beharrezkoa da",
+ "cookies_disabled": "Zure nabigatzailea cookieak desgaitzeko konfiguratuta dago. Anubisek cookieak behar ditu bezero baliozkoa zarela ziurtatzeko interes legitimorako. Gaitu cookieak domeinu honetarako mesedez.",
+ "access_denied": "Sarrera ukatu egin da: errore kodea",
+ "dronebl_entry": "DroneBL-ek sarrera bat jakinarazi du",
+ "see_dronebl_lookup": "ikusi",
+ "internal_server_error": "Zerbitzariaren barneko errorea: webgunearen kudeatzaileak Anubis gaizki konfiguratu du. Jarri harremanetan kudeatzailearekin eta eskatu erregistroak bilatzeko.",
+ "invalid_redirect": "Berbideraketa okerra",
+ "redirect_not_parseable": "Berbideraketaren URLa ezin da parseatu",
+ "redirect_domain_not_allowed": "Berbideraketa domeinua ez da onartzen",
+ "missing_required_forwarded_headers": "Beharrezko X-Forwarded-* goiburukoak falta dira",
+ "failed_to_sign_jwt": "JWTa sinatzeak huts egin du",
+ "invalid_invocation": "Ezin izan da MakeChallenge era egokian deitu",
+ "client_error_browser": "Bezeroaren errorea: ziurtatu zure nabigatzailea eguneratuta duzula eta saiatu berriz beranduago.",
+ "oh_noes": "Oh ez!",
+ "benchmarking_anubis": "Anubis egiaztatzen!",
+ "you_are_not_a_bot": "Ez zara robota!",
+ "making_sure_not_bot": "Robota ez zarela ziurtatzen!",
+ "celphase": "CELPHASE",
+ "js_web_crypto_error": "Zure arakatzaileak ez du web.crypto elementu funtzionalik. Testuinguru seguru batean ikusten ari zara hau?",
+ "js_web_workers_error": "Zure nabigatzaileak ez ditu web langileak onartzen (Anubisek hau erabiltzen du zure nabigatzailea blokeatzea saihesteko). JShelter bezalako plugin bat instalatuta duzu?",
+ "js_cookies_error": "Zure nabigatzaileak ez ditu cookieak gordetzen. Anubisek cookieak erabiltzen ditu erronkak zein bezerok gainditu dituzten zehazteko, sinatutako token bat cookie batean gordez. Gaitu cookieak gordetzeko aukera domeinu honetarako. Anubisek gordetzen dituen cookien izenak alda daitezke abisurik gabe. Cookien izenak eta balioak ez dira API publikoaren parte.",
+ "js_context_not_secure": "Zure kontestua ez da segurua!",
+ "js_context_not_secure_msg": "Saiatu HTTPSrekin konektatzen edo eskatu kudeatzaileari HTTPS konfiguratzeko. Informazio gehiagorako irakurri
MDN.",
+ "js_calculating": "Kalkulatzen...",
+ "js_missing_feature": "Ezaugarria falta da",
+ "js_challenge_error": "Erronkaren errorea!",
+ "js_challenge_error_msg": "Egiaztapen algoritmoa ebaztea huts egin da. Orria berriro kargatu nahi izango duzu agian.",
+ "js_calculating_difficulty": "Kalkulatzen...
Zailtasuna:",
+ "js_speed": "Abiadura",
+ "js_verification_longer": "Egiaztapenak espero baino denbora gehiago behar du. Mesedez, ez freskatu orria.",
+ "js_success": "Ondo!",
+ "js_done_took": "Eginda!",
+ "js_iterations": "iterazio",
+ "js_finished_reading": "Irakurtzen amaitu dut, jarraitu →",
+ "js_calculation_error": "Kalkulu-errorea!",
+ "js_calculation_error_msg": "Huts egin du erronka kalkulatzeak:",
+ "script_load_error": "Anubisek ezin izan du bere JavaScripta kargatu. Zerbitzaria gainkargatuta egon daiteke. Berriro kargatu orria berriro saiatzeko."
+}
diff --git a/lib/localization/locales/fi.json b/lib/localization/locales/fi.json
index bd06d3a74..9bd046dfd 100644
--- a/lib/localization/locales/fi.json
+++ b/lib/localization/locales/fi.json
@@ -62,5 +62,6 @@
"js_calculation_error": "Laskentavirhe!",
"js_calculation_error_msg": "Haasteen laskenta ei onnistunut:",
"missing_required_forwarded_headers": "Puuttuvat vaaditut X-Forwarded-* otsikot",
- "simplified_explanation": "Tämä on toimenpide botteja ja haitallisia pyyntöjä vastaan, joka on samanlainen kuin CAPTCHA. Sen sijaan, että joutuisit tekemään työtä itse, selaimesi saa laskentatehtävän, joka sen on ratkaistava varmistaakseen, että se on kelvollinen asiakas. Tätä käsitettä kutsutaan nimellä
Työtodistus. Tehtävä lasketaan muutamassa sekunnissa ja saat pääsyn verkkosivustolle. Kiitos ymmärryksestäsi ja kärsivällisyydestäsi."
-}
\ No newline at end of file
+ "simplified_explanation": "Tämä on toimenpide botteja ja haitallisia pyyntöjä vastaan, joka on samanlainen kuin CAPTCHA. Sen sijaan, että joutuisit tekemään työtä itse, selaimesi saa laskentatehtävän, joka sen on ratkaistava varmistaakseen, että se on kelvollinen asiakas. Tätä käsitettä kutsutaan nimellä
Työtodistus. Tehtävä lasketaan muutamassa sekunnissa ja saat pääsyn verkkosivustolle. Kiitos ymmärryksestäsi ja kärsivällisyydestäsi.",
+ "script_load_error": "Anubis ei voinut ladata JavaScript-koodiaan. Palvelin saattaa olla ylikuormittunut. Lataathan sivun uudelleen yrittääksesi uudestaan."
+}
diff --git a/lib/localization/locales/fil.json b/lib/localization/locales/fil.json
index ab89d034f..670a588b9 100644
--- a/lib/localization/locales/fil.json
+++ b/lib/localization/locales/fil.json
@@ -62,5 +62,6 @@
"js_calculation_error": "Error sa pagkalkula!",
"js_calculation_error_msg": "Nabigong ikalkula ang hamon:",
"missing_required_forwarded_headers": "Nawawala ang kinakailangang X-Forwarded-* na mga header",
- "simplified_explanation": "Ito ay isang panukala laban sa mga bot at malisyosong mga kahilingan na katulad ng isang CAPTCHA. Gayunpaman, sa halip na ikaw mismo ang gumawa ng trabaho, binibigyan ang iyong browser ng isang gawain sa pagkalkula na kailangan nitong lutasin upang matiyak na ito ay isang wastong kliyente. Ang konseptong ito ay tinatawag na
Proof of Work. Ang gawain ay kinakalkula sa loob ng ilang segundo at binibigyan ka ng access sa website. Salamat sa iyong pag-unawa at pasensya."
+ "simplified_explanation": "Ito ay isang panukala laban sa mga bot at malisyosong mga kahilingan na katulad ng isang CAPTCHA. Gayunpaman, sa halip na ikaw mismo ang gumawa ng trabaho, binibigyan ang iyong browser ng isang gawain sa pagkalkula na kailangan nitong lutasin upang matiyak na ito ay isang wastong kliyente. Ang konseptong ito ay tinatawag na
Proof of Work. Ang gawain ay kinakalkula sa loob ng ilang segundo at binibigyan ka ng access sa website. Salamat sa iyong pag-unawa at pasensya.",
+ "script_load_error": "Hindi ma-load ng Anubis ang JavaScript nito. Maaaring sobra ang load ng server. Mangyaring i-reload ang pahina upang subukang muli."
}
diff --git a/lib/localization/locales/fr.json b/lib/localization/locales/fr.json
index b696e04c1..73968aef4 100644
--- a/lib/localization/locales/fr.json
+++ b/lib/localization/locales/fr.json
@@ -1,66 +1,67 @@
{
"loading": "Chargement...",
- "why_am_i_seeing": "Pourquoi je vois ceci ?",
+ "why_am_i_seeing": "Comment suis-je arrivé·e ici ?",
"protected_by": "Protégé par",
- "protected_from": "From",
+ "protected_from": "de",
"made_with": "Fait avec ❤️ au 🇨🇦",
"mascot_design": "Design de la mascotte par",
- "ai_companies_explanation": "Vous voyez ceci car l'administrateur de ce site web a configuré Anubis pour protéger le serveur contre le fléau des entreprises d'IA qui scrapent agressivement les sites web. Cela peut et cause des temps d'arrêt pour les sites web, ce qui rend leurs ressources inaccessibles pour tout le monde.",
- "anubis_compromise": "Anubis est un compromis. Anubis utilise un schéma de Preuve de Travail dans la veine de Hashcash, un schéma de preuve de travail proposé pour réduire le spam par email. L'idée est qu'à l'échelle individuelle, la charge supplémentaire est négligeable, mais à l'échelle des scrapers de masse, cela s'accumule et rend le scraping beaucoup plus coûteux.",
- "hack_purpose": "En fin de compte, il s'agit d'une solution de substitution afin de consacrer plus de temps à l'identification et à l'empreinte digitale des navigateurs sans tête (par exemple, via leur rendu de police) afin que la page de preuve de travail du défi n'ait pas besoin d'être présentée aux utilisateurs qui sont beaucoup plus susceptibles d'être légitimes.",
- "jshelter_note": "Veuillez noter qu'Anubis nécessite l'utilisation de fonctionnalités JavaScript modernes que des plugins comme JShelter désactiveront. Veuillez désactiver JShelter ou d'autres plugins similaires pour ce domaine.",
- "version_info": "Ce site web utilise Anubis version",
+ "ai_companies_explanation": "Vous voyez cette page car l'administrateur·rice de ce site Web a configuré Anubis pour protéger le serveur contre le fléau des entreprises d'IA qui récupèrent agressivement le contenu des sites Web. Cela perturbe leur fonctionnement et rend leurs ressources inaccessibles pour tout le monde.",
+ "anubis_compromise": "Anubis est un compromis. Anubis utilise un procédé de preuve de travail similaire à Hashcash, un procédé de preuve de travail proposé pour réduire le spam par e-mail. L'idée est qu'à l'échelle individuelle, la charge supplémentaire est négligeable, mais à l'échelle des scrapers de masse, la charge s'accumule et le scraping devient beaucoup plus coûteux.",
+ "hack_purpose": "En fin de compte, il s'agit d'une solution de substitution permettant de consacrer plus de temps à l'identification et à la prise d'empreintes des navigateurs headless (par exemple, en reconnaissant leur rendu des polices), pour que, à terme, la page de défi utilisant la preuve de travail n'ait plus besoin d'être présentée aux utilisateur·rices qui sont beaucoup plus susceptibles d'être légitimes.",
+ "jshelter_note": "Veuillez noter qu'Anubis nécessite l'utilisation de fonctionnalités JavaScript modernes qui peuvent être désactivées par des plugins comme JShelter. Veuillez désactiver JShelter ou tout autre plugin similaire pour ce domaine.",
+ "version_info": "Ce site Web utilise Anubis version",
"try_again": "Réessayer",
"go_home": "Accueil",
- "contact_webmaster": "ou si vous pensez que vous ne devriez pas être bloqué, veuillez contacter le webmaster à",
+ "contact_webmaster": "ou si vous pensez que vous ne devriez pas être bloqué, veuillez contacter le webmaster à l'adresse",
"connection_security": "Veuillez patienter un instant pendant que nous assurons la sécurité de votre connexion.",
- "javascript_required": "Malheureusement, vous devez activer JavaScript pour passer ce défi. Ceci est requis car les entreprises d'IA ont changé le contrat social autour du fonctionnement de l'hébergement de sites web. Une solution sans JS est en cours de développement.",
+ "javascript_required": "Malheureusement, vous devez activer JavaScript pour passer cette page de défi. Cette obligation est imposée par les entreprises d'IA, qui ont décidé de modifier unilatéralement les termes du contrat social régissant l'hébergement de sites Web. Une solution sans JavaScript est en cours de développement.",
"benchmark_requires_js": "L'exécution de l'outil de benchmark nécessite l'activation de JavaScript.",
- "difficulty": "Difficulté :",
- "algorithm": "Algorithme :",
- "compare": "Comparer :",
+ "difficulty": "Difficulté :",
+ "algorithm": "Algorithme :",
+ "compare": "Comparer :",
"time": "Temps",
"iters": "Itérations",
"time_a": "Temps A",
"iters_a": "Itér. A",
"time_b": "Temps B",
"iters_b": "Itér. B",
- "static_check_endpoint": "Ceci est juste un point de terminaison de vérification pour votre proxy inverse à utiliser.",
+ "static_check_endpoint": "Ceci est juste un point de terminaison de vérification à utiliser par votre proxy inverse.",
"authorization_required": "Autorisation requise",
- "cookies_disabled": "Votre navigateur est configuré pour désactiver les cookies. Anubis nécessite des cookies pour l'intérêt légitime de s'assurer que vous êtes un client valide. Veuillez activer les cookies pour ce domaine",
- "access_denied": "Accès refusé : code d'erreur",
- "dronebl_entry": "DroneBL a signalé une entrée",
+ "cookies_disabled": "Les cookies sont désactivés dans votre navigateur. Anubis a recours aux cookies pour l'intérêt légitime de s'assurer que vous êtes un client valide. Veuillez activer les cookies pour ce domaine.",
+ "access_denied": "Accès refusé : code d'erreur",
+ "dronebl_entry": "DroneBL a rapporté une entrée",
"see_dronebl_lookup": "voir",
- "internal_server_error": "Erreur interne du serveur : l'administrateur a mal configuré Anubis. Veuillez contacter l'administrateur et lui demander de consulter les logs autour de",
+ "internal_server_error": "Erreur interne du serveur : l'administrateur·rice a mal configuré Anubis. Veuillez contacter l'administrateur·rice et lui demander de consulter les logs autour de",
"invalid_redirect": "Redirection invalide",
"redirect_not_parseable": "URL de redirection non analysable",
"redirect_domain_not_allowed": "Domaine de redirection non autorisé",
- "failed_to_sign_jwt": "échec de la signature JWT",
+ "failed_to_sign_jwt": "échec de la signature du JWT",
"invalid_invocation": "Invocation invalide de MakeChallenge",
- "client_error_browser": "Erreur client : Veuillez vous assurer que votre navigateur est à jour et réessayez plus tard.",
- "oh_noes": "Oh non !",
- "benchmarking_anubis": "Test de performance d'Anubis !",
- "you_are_not_a_bot": "Vous n'êtes pas un robot !",
- "making_sure_not_bot": "Vérification que vous n'êtes pas un robot !",
- "celphase": "PHASE de CEL",
- "js_web_crypto_error": "Votre navigateur n'a pas d'élément web.crypto fonctionnel. Consultez-vous cette page dans un contexte sécurisé ?",
- "js_web_workers_error": "Votre navigateur ne prend pas en charge les web workers (Anubis les utilise pour éviter de bloquer votre navigateur). Avez-vous un plugin comme JShelter installé ?",
- "js_cookies_error": "Votre navigateur ne stocke pas les cookies. Anubis utilise des 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. Les noms des cookies qu'Anubis stocke peuvent varier sans préavis. Les noms et valeurs des cookies ne font pas partie de l'API publique.",
- "js_context_not_secure": "Votre contexte n'est pas sécurisé !",
- "js_context_not_secure_msg": "Essayez de vous connecter via HTTPS ou informez l'administrateur de configurer HTTPS. Pour plus d'informations, voir
MDN.",
+ "client_error_browser": "Erreur client : Veuillez vous assurer que votre navigateur est à jour et réessayez plus tard.",
+ "oh_noes": "Oh non !",
+ "benchmarking_anubis": "Je vérifie les performances d'Anubis !",
+ "you_are_not_a_bot": "Vous n'êtes pas un robot !",
+ "making_sure_not_bot": "Je m'assure que vous n'êtes pas un robot !",
+ "celphase": "CELPHASE",
+ "js_web_crypto_error": "L'élément web.crypto de votre navigateur n'est pas fonctionnel. Consultez-vous bien cette page dans un contexte sécurisé ?",
+ "js_web_workers_error": "Votre navigateur ne prend pas en charge les web workers (Anubis les utilise pour éviter de bloquer votre navigateur). Avez-vous installé un plugin comme JShelter ?",
+ "js_cookies_error": "Votre navigateur ne stocke pas les cookies. Anubis a recours aux cookies pour déterminer quels clients ont réussi les défis en stockant un jeton signé dans un cookie. Veuillez activer le stockage des cookies pour ce domaine. Le nom des cookies stockés par Anubis peut varier à tout moment. Le nom et la valeur des cookies ne font pas partie de l'API publique.",
+ "js_context_not_secure": "Votre contexte n'est pas sécurisé !",
+ "js_context_not_secure_msg": "Essayez de vous connecter via HTTPS ou demandez à l'administrateur·rice de configurer HTTPS. Pour plus d'informations, consultez
MDN.",
"js_calculating": "Calcul en cours...",
"js_missing_feature": "Fonctionnalité manquante",
- "js_challenge_error": "Erreur de défi !",
+ "js_challenge_error": "Erreur de défi !",
"js_challenge_error_msg": "Échec de la résolution de l'algorithme de vérification. Vous pouvez essayer de recharger la page.",
- "js_calculating_difficulty": "Calcul en cours...
Difficulté :",
- "js_speed": "Vitesse :",
+ "js_calculating_difficulty": "Calcul en cours...
Difficulté :",
+ "js_speed": "Vitesse :",
"js_verification_longer": "La vérification prend plus de temps que prévu. Veuillez ne pas actualiser la page.",
- "js_success": "Succès !",
- "js_done_took": "Terminé ! A pris",
+ "js_success": "Vérification réussie !",
+ "js_done_took": "Terminé ! Cela aura nécessité",
"js_iterations": "itérations",
- "js_finished_reading": "J'ai fini de lire, continuer →",
- "js_calculation_error": "Erreur de calcul !",
- "js_calculation_error_msg": "Échec du calcul du défi :",
- "missing_required_forwarded_headers": "En-têtes X-Forwarded-* requis manquants",
- "simplified_explanation": "Il s'agit d'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 s'assurer qu'il est un client valide. Ce concept s'appelle
Preuve de travail. La tâche est calculée en quelques secondes et vous avez accès au site Web. Merci de votre compréhension et de votre patience."
-}
\ No newline at end of file
+ "js_finished_reading": "J'ai fini de lire, continuer →",
+ "js_calculation_error": "Erreur de calcul !",
+ "js_calculation_error_msg": "Échec du calcul du défi :",
+ "missing_required_forwarded_headers": "En-têtes X-Forwarded-* manquants",
+ "simplified_explanation": "Ceci est une mesure contre les robots et les requêtes malveillantes, similaire à un CAPTCHA. Cependant, au lieu d'avoir à faire le travail vous-même, votre navigateur se voit confier une tâche de calcul qu'il doit résoudre pour confirmer qu'il est un client valide. Ce concept est nommé
Preuve de travail. La tâche s'effectue en quelques secondes, puis vous avez accès au site Web. Merci pour votre compréhension et votre patience.",
+ "script_load_error": "Anubis n'a pas réussi à charger son code JavaScript. Le serveur est peut-être surchargé. Veuillez recharger la page pour réessayer."
+}
diff --git a/lib/localization/locales/hr.json b/lib/localization/locales/hr.json
new file mode 100644
index 000000000..f1a3d8650
--- /dev/null
+++ b/lib/localization/locales/hr.json
@@ -0,0 +1,67 @@
+{
+ "loading": "Učitavanje...",
+ "why_am_i_seeing": "Zašto vidim ovo?",
+ "protected_by": "Ovu stranicu štiti",
+ "protected_from": "tvrtke",
+ "made_with": "Izrađeno sa ❤️ u 🇨🇦",
+ "mascot_design": "Dizajn maskote:",
+ "ai_companies_explanation": "Ovu stranicu vidite jer je administrator ove web-lokacije postavio Anubis kako bi zaštitio poslužitelj od agresivnog automatiziranog prikupljanja podataka (scrapinga) koje provode tvrtke koje se bave umjetnom inteligencijom. Takvo ponašanje može uzrokovati prekide u radu web-lokacije i učiniti njezine resurse nedostupnima svim korisnicima.",
+ "anubis_compromise": "Anubis je kompromisno rješenje. Koristi sustav dokaza rada (Proof-of-Work) po uzoru na Hashcash, koji je izvorno osmišljen za smanjenje neželjene e-pošte (spama). Ideja je da je za pojedinačnog korisnika dodatno računalno opterećenje zanemarivo, ali se pri masovnom automatiziranom prikupljanju podataka ono zbraja i takvo prikupljanje čini znatno skupljim.",
+ "hack_purpose": "U konačnici, ovo je privremeno rješenje kojim se dobiva na vremenu za razvoj naprednijih metoda prepoznavanja preglednika bez grafičkog sučelja (headless browsers), primjerice prema načinu na koji prikazuju fontove. Cilj je da se stranica s provjerom uopće ne prikazuje legitimnim korisnicima.",
+ "simplified_explanation": "Ovo je mjera protiv botova i zlonamjernih zahtjeva slična CAPTCHA provjeri. Međutim, umjesto da vi rješavate zadatak, vaš preglednik automatski obavlja kratak izračun kojim potvrđuje da pristup stranici dolazi od stvarnog korisnika. Taj se koncept naziva dokaz rada (Proof of Work). Izračun traje svega nekoliko sekundi, nakon čega dobivate pristup web-stranici. Hvala vam na razumijevanju i strpljenju.",
+ "jshelter_note": "Imajte na umu da Anubis koristi moderne JavaScript značajke koje dodaci poput JSheltera mogu blokirati. Molimo onemogućite JShelter ili slične dodatke za ovu domenu.",
+ "version_info": "Ova web-stranica koristi Anubis verziju",
+ "try_again": "Pokušaj ponovno",
+ "go_home": "Idi na naslovnicu",
+ "contact_webmaster": "ili ako smatrate da ne biste trebali biti blokirani, obratite se administratoru na",
+ "connection_security": "Molimo pričekajte trenutak dok provjeravamo sigurnost vaše veze.",
+ "javascript_required": "Nažalost, morate omogućiti JavaScript kako biste prošli ovu provjeru. To je potrebno zbog sve agresivnijeg automatiziranog prikupljanja podataka koje provode AI tvrtke. Rješenje koje ne ovisi o JavaScriptu je u izradi.",
+ "benchmark_requires_js": "Za mjerenje performansi potrebno je omogućiti JavaScript.",
+ "difficulty": "Težina:",
+ "algorithm": "Algoritam:",
+ "compare": "Usporedi:",
+ "time": "Vrijeme",
+ "iters": "Iteracije",
+ "time_a": "Vrijeme A",
+ "iters_a": "Iteracije A",
+ "time_b": "Vrijeme B",
+ "iters_b": "Iteracije B",
+ "static_check_endpoint": "Ovo je samo krajnja točka za provjeru koju koristi vaš obrnuti proxy.",
+ "authorization_required": "Potrebna je autorizacija",
+ "cookies_disabled": "Vaš je preglednik postavljen tako da blokira kolačiće. Anubis koristi kolačiće kako bi potvrdio da ste stvarni posjetitelj. Molimo omogućite kolačiće za ovu domenu",
+ "access_denied": "Pristup odbijen: kod pogreške",
+ "dronebl_entry": "DroneBL je prijavio unos",
+ "see_dronebl_lookup": "vidi",
+ "internal_server_error": "Interna pogreška poslužitelja: administrator je pogrešno konfigurirao Anubis. Molimo kontaktirajte administratora i zamolite ga da provjeri zapise (logove) oko",
+ "invalid_redirect": "Nevažeće preusmjeravanje",
+ "redirect_not_parseable": "URL preusmjeravanja nije moguće obraditi",
+ "redirect_domain_not_allowed": "Domena preusmjeravanja nije dopuštena",
+ "missing_required_forwarded_headers": "Nedostaju obavezna X-Forwarded-* zaglavlja",
+ "failed_to_sign_jwt": "Neuspješno potpisivanje JWT tokena",
+ "invalid_invocation": "Nevažeći poziv MakeChallenge funkcije",
+ "client_error_browser": "Pogreška preglednika: Molimo provjerite je li vaš preglednik ažuriran i pokušajte ponovno kasnije.",
+ "oh_noes": "O, ne!",
+ "benchmarking_anubis": "Testiranje performansi Anubisa!",
+ "you_are_not_a_bot": "Niste bot!",
+ "making_sure_not_bot": "Provjeravamo da niste bot!",
+ "celphase": "CELPHASE",
+ "js_web_crypto_error": "Vaš preglednik nema funkcionalan Web Crypto API. Pregledavate li ovo preko sigurne veze (HTTPS)?",
+ "js_web_workers_error": "Vaš preglednik ne podržava Web Workers, koje Anubis koristi kako bi spriječio zamrzavanje stranice. Imate li instaliran dodatak poput JSheltera?",
+ "js_cookies_error": "Vaš preglednik ne pohranjuje kolačiće. Anubis koristi kolačiće kako bi spremio potpisani token i prepoznao klijente koji su prošli provjeru. Molimo omogućite pohranu kolačića za ovu domenu. Nazivi kolačića koje Anubis koristi mogu se promijeniti bez prethodne obavijesti i nisu dio javnog API-ja.",
+ "js_context_not_secure": "Vaša veza nije sigurna!",
+ "js_context_not_secure_msg": "Pokušajte se povezati putem HTTPS-a ili obavijestite administratora da postavi HTTPS. Za više informacija pogledajte MDN.",
+ "js_calculating": "Izračunavanje...",
+ "js_missing_feature": "Nedostaje značajka",
+ "js_challenge_error": "Pogreška prilikom provjere!",
+ "js_challenge_error_msg": "Rješavanje algoritma provjere nije uspjelo. Pokušajte osvježiti stranicu.",
+ "js_calculating_difficulty": "Izračunavanje...
Težina:",
+ "js_speed": "Brzina:",
+ "js_verification_longer": "Verifikacija traje dulje nego što je očekivano. Molimo nemojte osvježavati stranicu.",
+ "js_success": "Uspjeh!",
+ "js_done_took": "Gotovo! Trajalo je",
+ "js_iterations": "iteracija",
+ "js_finished_reading": "Pročitao/la sam, nastavi →",
+ "js_calculation_error": "Provjera nije uspjela!",
+ "js_calculation_error_msg": "Došlo je do pogreške tijekom provjere:",
+ "script_load_error": "Anubis nije mogao učitati svoj JavaScript. Poslužitelj je možda preopterećen. Molimo ponovno učitajte stranicu za novi pokušaj."
+}
diff --git a/lib/localization/locales/is.json b/lib/localization/locales/is.json
index b4f624b90..ec550a1bb 100644
--- a/lib/localization/locales/is.json
+++ b/lib/localization/locales/is.json
@@ -8,7 +8,7 @@
"ai_companies_explanation": "Þú ert að sjá þetta vegna þess að kerfisstjóri þessa vefsvæðis hefur sett upp Anubis til að vernda vefþjóninn fyrir holskeflu beiðna frá svokölluðum gervigreindarfyrirtækjum sem samviskulaust eru að skrapa upplýsingar af vefsvæðum annarra. Þetta getur valdið og veldur töfum og truflunum á þessum vefsvæðum, sem aftur veldur því að efni þeirra verður öllum óaðgengilegt.",
"anubis_compromise": "Anubis er millivegur. Anubis notar sönnun-á-vinnu (Proof-of-Work) skema í líkingu við Hashcash, sem er viðlíka skema til að minnka ruslpóst. Hugmyndin er að fyrir almennar heimsóknir verði viðbótarálagið vegna þessa ásættanlegt og valdi litlum truflunum, en við massaskröpun verði samlegðaráhrifin veruleg og geri slíka skröpun upplýsinga of dýra hvað varðar afköst og reiknigetu.",
"hack_purpose": "Að lokum er þetta staðgengilslausn svo hægt sé að eyða meiri tíma í fingraför og auðkenningu höfuðlausra vafra (t.d. með því hvernig þeir birta leturgerðir) svo að áskorunarprófunarsíðan þurfi ekki að birtast notendum sem eru mun líklegri til að vera lögmætir.",
- "jshelter_note": "Athugaðu að Anubis krefst notkunar á ýmsum nútímalegum eiginleikum JavaScript sem viðbætur á borð við JShelter munu gera ávirka. Endilega gerðu JShelter eða álíka viðbætur óvirkar fyrir þetta lén.",
+ "jshelter_note": "Athugaðu að Anubis krefst notkunar á ýmsum nútímalegum eiginleikum JavaScript sem viðbætur á borð við JShelter munu gera óvirka. Endilega gerðu JShelter eða álíka viðbætur óvirkar fyrir þetta lén.",
"version_info": "Þetta vefsvæði er að keyra Anubis útgáfu",
"try_again": "Prófaðu aftur",
"go_home": "Farðu aftur heim til þín",
@@ -62,5 +62,6 @@
"js_calculation_error": "Reiknivilla!",
"js_calculation_error_msg": "Mistókst að reikna áskorun:",
"missing_required_forwarded_headers": "Vantar nauðsynleg X-Forwarded-* hausar",
- "simplified_explanation": "Þetta er ráðstöfun gegn vélmennum og illgjarnum beiðnum svipað og CAPTCHA. Hins vegar, í stað þess að þurfa að vinna sjálfur, fær vafrinn þinn útreikningsverkefni sem hann þarf að leysa til að tryggja að hann sé gildur biðlari. Þetta hugtak er kallað
Sönnun-á-vinnu. Verkefnið er reiknað á nokkrum sekúndum og þú færð aðgang að vefsíðunni. Takk fyrir skilninginn og þolinmæðina."
+ "simplified_explanation": "Þetta er ráðstöfun gegn vélmennum og illa meinandi beiðnum, sem virkar svipað og CAPTCHA-mennskupróf. Hins vegar; í stað þess að þurfa að vinna sjálfur, fær vafrinn þinn útreikningsverkefni sem hann þarf að leysa til að tryggja að hann sé gildur biðlari. Þetta hugtak er kallað
Sönnun-á-vinnu. Verkefnið er reiknað á nokkrum sekúndum og þú færð aðgang að vefsíðunni. Takk fyrir skilninginn og þolinmæðina.",
+ "script_load_error": "Anubis gat ekki hlaðið inn JavaScript-kóðanum sínum. Vefþjónninn gæti verið undir of miklu álagi. Endurlestu síðuna til að reyna aftur."
}
diff --git a/lib/localization/locales/it.json b/lib/localization/locales/it.json
index e2ecc11b3..79ea526fa 100644
--- a/lib/localization/locales/it.json
+++ b/lib/localization/locales/it.json
@@ -62,5 +62,6 @@
"js_calculation_error": "Errore nel calcolo!",
"js_calculation_error_msg": "Impossibile superare il test:",
"missing_required_forwarded_headers": "Mancano gli header X-Forwarded-* richiesti",
- "simplified_explanation": "Questa è una misura contro bot e richieste dannose simile a un CAPTCHA. Tuttavia, invece di dover lavorare tu stesso, al tuo browser viene assegnato un compito di calcolo che deve risolvere per garantire che sia un client valido. Questo concetto è chiamato
Proof of Work. Il compito viene calcolato in pochi secondi e ti viene concesso l'accesso al sito web. Grazie per la tua comprensione e pazienza."
-}
\ No newline at end of file
+ "simplified_explanation": "Questa è una misura contro bot e richieste dannose simile a un CAPTCHA. Tuttavia, invece di dover lavorare tu stesso, al tuo browser viene assegnato un compito di calcolo che deve risolvere per garantire che sia un client valido. Questo concetto è chiamato
Proof of Work. Il compito viene calcolato in pochi secondi e ti viene concesso l'accesso al sito web. Grazie per la tua comprensione e pazienza.",
+ "script_load_error": "Anubis non è riuscito a caricare il suo JavaScript. Il server potrebbe essere sovraccarico. Ricarica la pagina per riprovare."
+}
diff --git a/lib/localization/locales/ja.json b/lib/localization/locales/ja.json
index 3042173df..e5f81bc08 100644
--- a/lib/localization/locales/ja.json
+++ b/lib/localization/locales/ja.json
@@ -9,7 +9,7 @@
"anubis_compromise": "Anubisは妥協策です。AnubisはHashcashのようなProof-of-Work方式を採用しており、これは元々メールスパムを減らすために提案された仕組みです。個人レベルでは追加の負荷は無視できる程度ですが、大規模なスクレイピングでは負荷が積み重なり、スクレイピングのコストが大幅に増加します。",
"hack_purpose": "最終的に、これはヘッドレスブラウザのフィンガープリントと識別に時間を費やすためのプレースホルダーソリューションです(例:フォントレンダリングの方法による)。これにより、正当なユーザーにはチャレンジのプルーフオブワークページを提示する必要がなくなります。",
"jshelter_note": "Anubisは、JShelterのようなプラグインが無効化する最新のJavaScript機能を必要とします。このドメインではJShelterや同様のプラグインを無効にしてください。",
- "version_info": "このウェブサイトはAnubisバージョンで動作しています",
+ "version_info": "このウェブサイトはAnubisで動作しています バージョン",
"try_again": "再試行",
"go_home": "ホームに戻る",
"contact_webmaster": "もしブロックされるべきでないと思われる場合は、ウェブマスターにご連絡ください:",
@@ -62,5 +62,6 @@
"js_calculation_error": "計算エラー!",
"js_calculation_error_msg": "チャレンジの計算に失敗しました:",
"missing_required_forwarded_headers": "必要な X-Forwarded-* ヘッダーがありません",
- "simplified_explanation": "これは、CAPTCHAと同様の、ボットや悪意のあるリクエストに対する対策です。ただし、自分で作業する代わりに、ブラウザに計算タスクが与えられ、それを解決して有効なクライアントであることを確認する必要があります。この概念は
Proof of Workと呼ばれます。タスクは数秒で計算され、ウェブサイトへのアクセスが許可されます。ご理解とご協力をお願いいたします。"
-}
\ No newline at end of file
+ "simplified_explanation": "これは、CAPTCHAと同様の、ボットや悪意のあるリクエストに対する対策です。ただし、自分で作業する代わりに、ブラウザに計算タスクが与えられ、それを解決して有効なクライアントであることを確認する必要があります。この概念は
Proof of Workと呼ばれます。タスクは数秒で計算され、ウェブサイトへのアクセスが許可されます。ご理解とご協力をお願いいたします。",
+ "script_load_error": "AnubisのJavaScriptを読み込めませんでした。サーバーが混雑している可能性があります。ページを再読み込みして、もう一度お試しください。"
+}
diff --git a/lib/localization/locales/lt.json b/lib/localization/locales/lt.json
index 8887ad6e3..4d6ef3771 100644
--- a/lib/localization/locales/lt.json
+++ b/lib/localization/locales/lt.json
@@ -63,5 +63,6 @@
"js_finished_reading": "Viską perskaičiau, tęskime →",
"js_calculation_error": "Skaičiavimo klaida!",
"js_calculation_error_msg": "Nepavyko įveikti iššūkio:",
- "missing_required_forwarded_headers": "Trūksta privalomų X-Forwarded-* antraščių"
+ "missing_required_forwarded_headers": "Trūksta privalomų X-Forwarded-* antraščių",
+ "script_load_error": "Nepavyko įkelti „Anubis“ naudojamo „JavaScript“ kodo. Tikėtina, jog serveris yra perkrautas. Prašom įkelti tinklalapį iš naujo ir bandyti dar kartą."
}
diff --git a/lib/localization/locales/manifest.json b/lib/localization/locales/manifest.json
index 6fff78556..f65e87ce8 100644
--- a/lib/localization/locales/manifest.json
+++ b/lib/localization/locales/manifest.json
@@ -8,6 +8,7 @@
"fi",
"fil",
"fr",
+ "hr",
"is",
"it",
"ja",
@@ -15,12 +16,15 @@
"nb",
"nl",
"nn",
+ "pl",
"pt-BR",
"ru",
"tr",
+ "uk",
"vi",
"zh-CN",
"zh-TW",
- "sv"
+ "sv",
+ "bg"
]
}
diff --git a/lib/localization/locales/nb.json b/lib/localization/locales/nb.json
index 0b8b6fcc9..1a4c116b4 100644
--- a/lib/localization/locales/nb.json
+++ b/lib/localization/locales/nb.json
@@ -62,5 +62,6 @@
"js_calculation_error": "Beregningsfeil!",
"js_calculation_error_msg": "Mislyktes i å beregne utfordring:",
"missing_required_forwarded_headers": "Mangler nødvendige X-Forwarded-* header",
- "simplified_explanation": "Dette er et tiltak mot roboter og ondsinnede forespørsler som ligner på en CAPTCHA. Men i stedet for å måtte gjøre arbeidet selv, får nettleseren din en beregningsoppgave som den må løse for å sikre at den er en gyldig klient. Dette konseptet kalles
Proof of Work. Oppgaven beregnes på noen få sekunder, og du får tilgang til nettstedet. Takk for din forståelse og tålmodighet."
-}
\ No newline at end of file
+ "simplified_explanation": "Dette er et tiltak mot roboter og ondsinnede forespørsler som ligner på en CAPTCHA. Men i stedet for å måtte gjøre arbeidet selv, får nettleseren din en beregningsoppgave som den må løse for å sikre at den er en gyldig klient. Dette konseptet kalles
Proof of Work. Oppgaven beregnes på noen få sekunder, og du får tilgang til nettstedet. Takk for din forståelse og tålmodighet.",
+ "script_load_error": "Anubis kunne ikke laste inn JavaScript-en sin. Sørveren er kanskje overbelastet. Vennligst last inn siden på nytt for å prøve igjen."
+}
diff --git a/lib/localization/locales/nl.json b/lib/localization/locales/nl.json
index 20710847e..c35bc9461 100644
--- a/lib/localization/locales/nl.json
+++ b/lib/localization/locales/nl.json
@@ -4,18 +4,18 @@
"protected_by": "Beschermd door",
"protected_from": "Van",
"made_with": "Gemaakt met ❤️ in 🇨🇦",
- "mascot_design": "Mascotte ontwerp door",
- "ai_companies_explanation": "Je ziet dit omdat de beheerder van deze website Anubis heeft ingesteld om de server te beschermen tegen de plaag van AI-bedrijven die agressief websites scrapen. Dit kan downtime veroorzaken voor de websites, waardoor hun bronnen voor iedereen ontoegankelijk worden.",
- "anubis_compromise": "Anubis is een compromis. Anubis gebruikt een Proef-van-Taak schema in de geest van Hashcash, een voorgesteld proef-van-taak schema voor het verminderen van e-mailspam. Het idee is dat op individuele schalen de extra belasting niet waarneembaar is, maar op het niveau van massascrapers telt het op en maakt het scrapen veel duurder.",
+ "mascot_design": "Mascotte-ontwerp door",
+ "ai_companies_explanation": "Je ziet dit omdat de beheerder van deze website Anubis heeft ingesteld om de server te beschermen tegen de plaag van AI-bedrijven die agressief websites scrapen. Dit kan downtime veroorzaken voor de websites, waardoor de website voor iedereen ontoegankelijk wordt.",
+ "anubis_compromise": "Anubis is een compromis. Anubis gebruikt een proof-of-work-algoritme in de geest van Hashcash, een proof-of-work-algoritme voor het verminderen van e-mailspam. Het idee is dat voor individuen minimaal is, maar het voor scrapers veel duurder wordt.",
"hack_purpose": "Uiteindelijk is dit een tijdelijke oplossing, zodat er meer tijd kan worden besteed aan het identificeren en herkennen van headless browsers (bijv. via de manier waarop ze lettertypen renderen), zodat de proof-of-work-pagina niet hoeft te worden gepresenteerd aan gebruikers die veel waarschijnlijker legitiem zijn.",
- "jshelter_note": "Anubis vereist het gebruik van moderne JavaScript-functies die worden uitgeschakeld door plugins zoals JShelter. Schakel JShelter of andere dergelijke plugins uit voor dit domein.",
+ "jshelter_note": "Anubis vereist het gebruik van moderne JavaScript-functies die worden uitgeschakeld door plugins zoals JShelter. Schakel JShelter of soortgelijke plugins uit voor dit domein.",
"version_info": "Deze website draait op de Anubis-versie",
"try_again": "Probeer opnieuw",
- "go_home": "Naar de thuispagina",
- "contact_webmaster": "of als u denkt dat u niet geblokkeerd zou moeten worden, neem dan contact op met de webmaster op",
- "connection_security": "Wacht even terwijl we de veiligheid van uw verbinding waarborgen.",
+ "go_home": "Naar de hoofdpagina",
+ "contact_webmaster": "of als je denkt dat je niet geblokkeerd had moeten worden, neem contact op met de webmaster op",
+ "connection_security": "Wacht even terwijl we de veiligheid van je verbinding waarborgen.",
"javascript_required": "Helaas moet je JavaScript inschakelen om voorbij deze uitdaging te komen. Dit is nodig omdat AI-bedrijven het sociale contract rond de werking van websitehosting hebben veranderd. Een oplossing zonder JavaScript is nog in ontwikkeling.",
- "benchmark_requires_js": "Voor het uitvoeren van de vergelijkingsinstrument moet JavaScript zijn ingeschakeld.",
+ "benchmark_requires_js": "Voor het uitvoeren van de check moet JavaScript zijn ingeschakeld.",
"difficulty": "Moeilijkheidsgraad:",
"algorithm": "Algoritme:",
"compare": "Vergelijken:",
@@ -25,36 +25,36 @@
"iters_a": "Iters A",
"time_b": "Tijd B",
"iters_b": "Iters B",
- "static_check_endpoint": "Dit is gewoon een controle-eindpunt voor uw reverse proxy om te gebruiken.",
+ "static_check_endpoint": "Dit is gewoon een controle-eindpunt voor je reverse proxy om te gebruiken.",
"authorization_required": "Autorisatie vereist",
- "cookies_disabled": "Uw browser is geconfigureerd om cookies uit te schakelen. Anubis heeft cookies nodig om er zeker van te zijn dat u een geldige klant bent. Schakel cookies in voor dit domein",
+ "cookies_disabled": "Cookies zijn uitgeschakeld in je browser. Anubis heeft cookies nodig om er zeker van te zijn dat je een echt persoon bent. Schakel cookies in voor dit domein",
"access_denied": "Toegang geweigerd: foutcode",
"dronebl_entry": "DroneBL meldde een item",
"see_dronebl_lookup": "zie",
- "internal_server_error": "Interne Serverfout: beheerder heeft Anubis verkeerd geconfigureerd. Neem contact op met de beheerder en vraag of hij/zij de logs rond kan kijken",
+ "internal_server_error": "Interne Serverfout: beheerder heeft Anubis verkeerd geconfigureerd. Vraag de beheerder om de logs te bekijken.",
"invalid_redirect": "Ongeldige omleiding",
- "redirect_not_parseable": "Redirect URL niet parseerbaar",
+ "redirect_not_parseable": "Redirect-URL kan niet verwerkt worden",
"redirect_domain_not_allowed": "Redirect-domein niet toegestaan",
- "failed_to_sign_jwt": "jWT niet ondertekend",
+ "failed_to_sign_jwt": "JWT niet ondertekend",
"invalid_invocation": "Ongeldige aanroep van MakeChallenge",
- "client_error_browser": "Fout bij klant: Controleer of uw browser bijgewerkt is en probeer het later opnieuw.",
+ "client_error_browser": "Fout bij client: Controleer of je browser bijgewerkt is en probeer het later opnieuw.",
"oh_noes": "Oh nee-tjes!",
"benchmarking_anubis": "Anubis benchmarken!",
"you_are_not_a_bot": "Je bent geen bot!",
- "making_sure_not_bot": "Ervoor zorgen dat je geen bot bent!",
+ "making_sure_not_bot": "Even checken of je een bot bent!",
"celphase": "CELPHASE",
- "js_web_crypto_error": "Uw browser heeft geen werkend web.crypto-element. Bekijkt u dit via een beveiligde context?",
+ "js_web_crypto_error": "Je browser heeft geen werkend web.crypto-element. Bekijkt u dit via een beveiligde context?",
"js_web_workers_error": "Je browser ondersteunt geen web-takers (Anubis gebruikt dit om te voorkomen dat je browser bevriest). Heb je een plugin zoals JShelter geïnstalleerd?",
- "js_cookies_error": "Uw browser slaat geen cookies op. Anubis gebruikt cookies om te bepalen welke klanten geslaagd zijn voor uitdagingen door een ondertekend token in een cookie op te slaan. Schakel het opslaan van cookies voor dit domein in. De namen van de cookies die Anubis opslaat, kunnen zonder voorafgaande kennisgeving variëren. cookiesnamen en -waarden maken geen deel uit van de openbare API.",
+ "js_cookies_error": "Je browser slaat geen cookies op. Anubis gebruikt cookies om te bepalen welke bezoekers echte personen zijn. Schakel het opslaan van cookies voor dit domein in. De namen van de cookies die Anubis opslaat, kunnen in de toekomst veranderen. De namen en waarden van cookies maken geen deel uit van de openbare API.",
"js_context_not_secure": "Je context is niet veilig!",
"js_context_not_secure_msg": "Probeer verbinding te maken via HTTPS of laat de beheerder weten dat HTTPS moet worden ingesteld. Zie
MDN voor meer informatie.",
"js_calculating": "Berekenen...",
"js_missing_feature": "Ontbrekende functie",
"js_challenge_error": "Uitdagingsfout!",
- "js_challenge_error_msg": "Mislukt bij het oplossen van het controlealgoritme. Misschien wilt u de pagina opnieuw laden.",
+ "js_challenge_error_msg": "De check is gefaald. Misschien wil je de pagina opnieuw laden.",
"js_calculating_difficulty": "Rekenen...
Moeilijkheidsgraad:",
"js_speed": "Snelheid:",
- "js_verification_longer": "Verificatie duurt langer dan verwacht. Vernieuw de pagina niet a.u.b.",
+ "js_verification_longer": "Verificatie duurt langer dan verwacht. Ververs de pagina niet.",
"js_success": "Gelukt!",
"js_done_took": "Klaar! Nam",
"js_iterations": "iteraties",
@@ -62,5 +62,6 @@
"js_calculation_error": "Rekenfout!",
"js_calculation_error_msg": "Uitdaging niet berekend:",
"missing_required_forwarded_headers": "Ontbrekende vereiste X-Forwarded-* headers",
- "simplified_explanation": "Dit is een maatregel tegen bots en kwaadwillende verzoeken, vergelijkbaar met een CAPTCHA. In plaats van dat u zelf werk moet verrichten, krijgt uw browser een rekentaak die hij moet oplossen om ervoor te zorgen dat het een geldige client is. Dit concept wordt
Proof of Work genoemd. De taak wordt in een paar seconden berekend en u krijgt toegang tot de website. Bedankt voor uw begrip en geduld."
+ "simplified_explanation": "Dit is een maatregel tegen bots en kwaadwillende verzoeken, vergelijkbaar met een CAPTCHA. In plaats van dat je zelf werk moet verrichten, krijgt je browser een rekentaak die moet worden opgelost om ervoor te zorgen dat het een geldige client is. Dit concept wordt
Proof of Work genoemd. De taak wordt in een paar seconden berekend en u krijgt toegang tot de website. Bedankt voor je begrip en geduld.",
+ "script_load_error": "Anubis kon zijn JavaScript niet laden. De server is mogelijk overbelast. Laad de pagina opnieuw om het nog eens te proberen."
}
diff --git a/lib/localization/locales/nn.json b/lib/localization/locales/nn.json
index 0b0d6eaf1..6fa7f908d 100644
--- a/lib/localization/locales/nn.json
+++ b/lib/localization/locales/nn.json
@@ -2,21 +2,22 @@
"loading": "Lastar inn...",
"why_am_i_seeing": "Kvifor ser eg dette?",
"protected_by": "Verna av",
- "protected_from": "frå",
+ "protected_from": "Frå",
"made_with": "Laga med ❤️ i 🇨🇦",
- "mascot_design": "Maskotdesign av",
- "ai_companies_explanation": "Du ser dette av di administratoren av denne nettstaden har sett opp Anubis for å verne sørvaren mot plaga av KI-selskap som aggressivt skrapar nettstader. Dette kan, og held frem med å, forårsake driftstans for nettstadene, som gjer ressursane deira utilgjengelege for alle.",
- "anubis_compromise": "Anubis er eit kompromiss. Anubis brukar eit «Proof-of-Work»-skjema som liknar på Hashcash, eit liknande skjema for å redusere søppel-e-post. Idéen er at ved småstilte tilfelle er den ytterlegare lastinga ignorerbar, men ved storstilt skraping samlar ho på seg fart og gjer det å skrapa mykje meir dyrt.",
- "hack_purpose": "Til sjuande og sist er dette ei plasshaldarløysing slik at meir tid kan brukast på fingeravtrykk og identifisering av hovudlause nettlesarar (t.d. via korleis dei attgjev skrifttypar) slik at utfordringssida for arbeidsprosessen ikkje treng å presenterast for brukarar som er mykje meir sannsynleg å vera legitime.",
- "jshelter_note": "NB: Anubis krev bruk av moderne JavaScript-funksjonar som tillegg som JShelter slår av. Venlegast slå av JShelter eller liknande tillegg for dette domenet.",
- "version_info": "Denne nettstaden køyrer Anubis-utgåve",
- "try_again": "Prøv att",
- "go_home": "Gå heim",
- "contact_webmaster": "eller om du synest at du ikkje burde vera blokkert, venlegast tak kontakt med administratoren på",
- "connection_security": "Venlegast vent medan vi stadfester tryggleiken av tilkoplinga di.",
- "javascript_required": "Du lyt diverre slå på JavaScript for å koma deg forbi denne utfordringa. Dette krevst av di KI-selskap har endra sosialkontrakten om korleis nettstadsverting fungerer. Ei ikkje-JS-løysing er i gang med å skapast.",
- "benchmark_requires_js": "JavaScript må vera slegen på for å køyre samanlikningsverktøyet.",
- "difficulty": "Vanskenivå:",
+ "mascot_design": "Maskot laga av",
+ "ai_companies_explanation": "Du ser dette av di nettstadadministratoren har sett Anubis til å verna tenaren mot KI-selskap som skrapar seg gjennom internettet. Skrapinga kan gjera at nettstader går ned og blir ubrukande.",
+ "anubis_compromise": "Anubis er eit kompromiss. Anubis nyttar eit arbeidsprov som liknar på Hashcash, eit arbeidsprovsystem som blir brukt mot epostspam. Tanken er at arbeidsmengda skal vera lita nok til å ikkje koma i vegen for personleg bruk, medan ho vert stor nok til å gjera at nettskraping ikkje løner seg.",
+ "hack_purpose": "Løysinga er meint til å vera mellombels fram til ein får funne ut av fingerprinting og å kjenna att hovudlause nettlesarar (t.d. gjenom å sjå korleis dei syner skriftypar) slik at brukarar som mest sannsynleg er ekte folk slepp å sjå arbeidsprovsida.",
+ "simplified_explanation": "Det er eit vern mot botar og vondsinna spurnader i same stil som CAPTCHA, berre at det er nettlesaren din og ikkje du som må løysa oppgåva. Han får eit mattestykke som må gjerast for å stadfesta at han er ekte brukar. Konseptet heiter
arbeidsprov. Oppgåva tek berre nokre sekund og so får du åtgang til nettstaden. Takk for tolsemda.",
+ "jshelter_note":"Merk at Anubis krev at ein brukar moderne JavaScript-funksjonar som tillegg som JShelter skrur av. Om du har JShelter eller dilikt er du nøydd til å skru det av på domenet.",
+ "version_info": "Nettstaden køyrer Anubis utgåve",
+ "try_again": "Freist omatt",
+ "go_home": "Far heim",
+ "contact_webmaster": "eller om du meiner du ikkje lyt stengjast ute, tak kontakt med nettmeisteren på",
+ "connection_security": "Vent ein augneblink medan me stadfester at sambandet er trygt",
+ "javascript_required": "Diverre er du nøydd til å skru på JavaScript for å ta utfordringa. Det er fordi KI-selskapa har endra den sosiale kontrakta bak drift av nettstader. Me jobbar med ei løysing utan JavaScript",
+ "benchmark_requires_js": "JavaScript må vera på for å køyra ytingstest-verktyet.",
+ "difficulty": "Vanskegrad:",
"algorithm": "Algoritme:",
"compare": "Jamfør:",
"time": "Tid",
@@ -25,42 +26,42 @@
"iters_a": "Oppattakingar A",
"time_b": "Tid B",
"iters_b": "Oppattakingar B",
- "static_check_endpoint": "Dette er berre eit sjekkeendepunkt for din omvende proxy å bruke.",
- "authorization_required": "Legitimasjon krevst",
- "cookies_disabled": "Nettlesaren din er konfigurert for å avslå informasjonskapslar. Anubis krev informasjonskapslar for å stadfeste at du er ein ekte brukar. Venlegast slå på informasjonskapslar på dette domenet.",
- "access_denied": "Tilgang nekta: feilkode",
- "dronebl_entry": "DroneBL rapporterte ei oppføring.",
+ "static_check_endpoint": "Dette er berre eit helsesjekk-endepunkt som revers-proxyen din kan nytta.",
+ "authorization_required": "Stadfesting er påkravt",
+ "cookies_disabled": "Nettlesaren din tillet ikkje infokapslar. Anubis treng infokapslar for å sjå om du er ein ekte brukar. Skru på infokapslar på dette domenet.",
+ "access_denied": "Åtgang Forbode: feilmelding",
+ "dronebl_entry": "oppførd i DroneBL",
"see_dronebl_lookup": "sjå",
- "internal_server_error": "Intern serverfeil: administratoren har feilkonfigurert Anubis. Venlegast tak kontakt med hen og spør hen om å sjå gjennom loggane om",
- "invalid_redirect": "Ugyldig omdirigering",
- "redirect_not_parseable": "Omdirigerings-URL-en kunne ikkje tolkast",
- "redirect_domain_not_allowed": "Omdirigeringsdomenet er ikkje tillate",
- "failed_to_sign_jwt": "mislukkast i å signere JWT",
- "invalid_invocation": "Ugyldig framkalling av MakeChallenge",
- "client_error_browser": "Klientfeil: Venlegast stadfest at nettlesaren din er oppdatert og prøv att seinare.",
- "oh_noes": "Å nei!",
- "benchmarking_anubis": "Samanliknar Anubis!",
- "you_are_not_a_bot": "Du er ikkje ein bot!",
- "making_sure_not_bot": "Stadfester at du ikkje er ein bot!",
+ "internal_server_error": "Intern Tenarfeil: administratoren har sett opp Anubis feil. Tak kontakt med administratoren og sei at dei skal sjå på loggane kring",
+ "invalid_redirect": "Ugild vidaresending",
+ "redirect_not_parseable": "Fekk ikkje tolka vidaresendings-URLen",
+ "redirect_domain_not_allowed": "Vidaresendingsdomenet er ikkje tillate",
+ "missing_required_forwarded_headers": "Manglar påkravde X-Forwarded-* headers",
+ "failed_to_sign_jwt": "fekk ikkje signert JWT",
+ "invalid_invocation": "Ugildt bruk av MakeChallenge",
+ "client_error_browser": "Klientfeil: Sjekk at nettlesaren er oppdatert og prøv omatt seinare.",
+ "oh_noes": "Oidå!",
+ "benchmarking_anubis": "Køyrer ytingstest på Anubis!",
+ "you_are_not_a_bot": "Du er ikkje bot!",
+ "making_sure_not_bot": "Stadfester at du ikkje er bot!",
"celphase": "CELPHASE",
- "js_web_crypto_error": "Nettlesaren din har ikkje eit fungerande web.crypto-element. Ser du dette med ei sikker tilkopling?",
- "js_web_workers_error": "Nettlesaren din støttar ikkje nettarbeidarar (Anubis brukar dette for å unngå å fryse nettlesaren din). Har du eit tillegg som JShelter installert?",
- "js_cookies_error": "Nettlesaren lagrar ikkje informasjonskapslar. Anubis brukar informasjonskapslar for å avgjera kva klientar har lukkast i utfordringa ved å lagra ein signert token i ein informasjonskapsel. Venlegast slå på informasjonskapslar på dette domenet. Namna på informasjonskapslane Anubis lagrar, kan variere utan varsel. Informasjonskapselnamn og -verdiar er ikkje ein del av det offentlege API-et.",
- "js_context_not_secure": "Du brukar ikkje ei sikker tilkopling!",
- "js_context_not_secure_msg": "Prøv å kople til over HTTPS eller fortel administratoren å opprette HTTPS. Sjå
MDN for fleire opplysingar.",
- "js_calculating": "Reknar…",
- "js_missing_feature": "Manglar funksjon",
- "js_challenge_error": "Utfordringsfeil!",
- "js_challenge_error_msg": "Mislukkast i å tolke sjekkalgoritmen. Du burde laste inn denne sida på nytt.",
- "js_calculating_difficulty": "Reknar…
Vanskenivå:",
- "js_speed": "fart:",
- "js_verification_longer": "Verifisering tek lengre enn forventa. Venlegast ikkje last inn denne sida på nytt.",
- "js_success": "Vellykka!",
+ "js_web_crypto_error": "Nettlesaren din har ikkje noko verkande web.crypto-element. Er du tilkopla med trygt samband?",
+ "js_web_workers_error": "Nettlesaren din stør ikkje web workers (som Anubis brukar for å hindra at nettlesaren frys). Brukar du JShelter eller liknande tillegg?",
+ "js_cookies_error": "Nettlesaren din lagrar ikkje infokapslar. Anubis treng infokapslar for å sjå kva brukarar som har greitt utfordringa, sidan det ligg i infokapslane. Skru på infokapslar på dette domenet. Infokapslane Anubis lagrar kan ha fleire ulike namn. Namna og verdiane på infokapslane er ikkje del av den offentlege APIen.",
+ "js_context_not_secure": "Sambandet ditt er ikkje trygt!",
+ "js_context_not_secure_msg": "Freist å kopla til med HTTPS, eller sei med administrator at han må setja opp HTTPS. Les meir om det her:
MDN.",
+ "js_calculating": "Reknar ut...",
+ "js_missing_feature": "Funskjon manglar",
+ "js_challenge_error": "Feil med utfordringa!",
+ "js_challenge_error_msg": "Fekk ikkje løyst sjekkealgoritmen. Du burde nok lasta inn sida på nytt.",
+ "js_calculating_difficulty": "Reknar ut...
Vanskegrad:",
+ "js_speed": "Snøggleik:",
+ "js_verification_longer": "Stadfestinga tek lenger enn venta. Ikkje last inn sida på nytt.",
+ "js_success": "Vellukka!",
"js_done_took": "Ferdig! Tok",
"js_iterations": "oppattakingar",
- "js_finished_reading": "Eg har slutta å lesa, hald fram →",
- "js_calculation_error": "Rekningsfeil!",
- "js_calculation_error_msg": "Mislukkast i å rekne utfordring:",
- "missing_required_forwarded_headers": "Manglende nødvendige X-Forwarded-* headers",
- "simplified_explanation": "Dette er eit tiltak mot robotar og vondsinna førespurnader som liknar på ein CAPTCHA. Men i staden for å måtte gjere arbeidet sjølv, får nettlesaren din ei utrekningsoppgåve som han må løyse for å sikre at han er ein gyldig klient. Dette konseptet blir kalla
Arbeidsbevis. Oppgåva blir rekna ut på nokre få sekund, og du får tilgang til nettstaden. Takk for di forståing og tålmod."
+ "js_finished_reading": "Eg har lese ferdig, haldt fram →",
+ "js_calculation_error": "Reknefeil!",
+ "js_calculation_error_msg": "Fekk ikkje rekna ut utfordringa:",
+ "script_load_error": "Anubis fekk ikkje lasta inn JavaScriptet sitt. Det kan henda tenaren har for mykje å gjera. Last inn sida på nytt og freist omatt."
}
diff --git a/lib/localization/locales/pl.json b/lib/localization/locales/pl.json
new file mode 100644
index 000000000..af2fed03e
--- /dev/null
+++ b/lib/localization/locales/pl.json
@@ -0,0 +1,67 @@
+{
+ "loading": "Ładowanie...",
+ "why_am_i_seeing": "Dlaczego to widzę?",
+ "protected_by": "Chronione przez",
+ "protected_from": "Przed",
+ "made_with": "Stworzone z ❤️ w 🇨🇦",
+ "mascot_design": "Projekt maskotki:",
+ "ai_companies_explanation": "Widzisz to, ponieważ administrator tej strony skonfigurował Anubisa, aby chronić serwer przed masowym skanowaniem treści przez firmy tworzące AI. Powoduje to obciążenie i przestoje, przez co zasoby strony stają się niedostępne dla wszystkich.",
+ "anubis_compromise": "Anubis jest kompromisem. Używa mechanizmu Proof-of-Work w stylu Hashcash — proponowanego systemu ograniczania spamu e-mail. Pomysł polega na tym, że dla indywidualnych użytkowników dodatkowe obciążenie jest niezauważalne, ale w skali masowego skanowania koszt szybko rośnie.",
+ "hack_purpose": "Docelowo jest to rozwiązanie tymczasowe, aby zyskać czas na ulepszenie metod identyfikacji przeglądarek bez interfejsu graficznego (np. poprzez analizę renderowania czcionek), by w przyszłości nie musieć wyświetlać strony z zadaniem Proof-of-Work użytkownikom, którzy najprawdopodobniej są prawidłowi.",
+ "simplified_explanation": "To zabezpieczenie przed botami i złośliwymi żądaniami, podobne do CAPTCHA. Jednak zamiast wykonywać zadanie samodzielnie, przeglądarka otrzymuje obliczenie do wykonania, aby potwierdzić, że jest prawidłowym klientem. Ten mechanizm to
Proof of Work. Zadanie trwa kilka sekund i uzyskujesz dostęp do strony. Dziękujemy za cierpliwość.",
+ "jshelter_note": "Uwaga: Anubis wymaga nowoczesnych funkcji JavaScript, które wtyczki typu JShelter mogą blokować. Wyłącz JShelter lub podobne dodatki dla tej domeny.",
+ "version_info": "Ta strona działa na Anubis w wersji",
+ "try_again": "Spróbuj ponownie",
+ "go_home": "Wróć na stronę główną",
+ "contact_webmaster": "lub jeśli uważasz, że nie powinieneś być blokowany, skontaktuj się z administratorem pod adresem",
+ "connection_security": "Poczekaj chwilę, sprawdzamy bezpieczeństwo Twojego połączenia.",
+ "javascript_required": "Niestety, aby przejść tę próbę, musisz włączyć obsługę JavaScript. Jest to konieczne, ponieważ firmy zajmujące się sztuczną inteligencją zmieniły umowę społeczną dotyczącą funkcjonowania hostingu stron internetowych. Rozwiązanie bez obsługi JavaScript jest w trakcie opracowywania.",
+ "benchmark_requires_js": "Uruchomienie narzędzia testowego wymaga włączonego JavaScript.",
+ "difficulty": "Trudność:",
+ "algorithm": "Algorytm:",
+ "compare": "Porównaj:",
+ "time": "Czas",
+ "iters": "Iteracje",
+ "time_a": "Czas A",
+ "iters_a": "Iteracje A",
+ "time_b": "Czas B",
+ "iters_b": "Iteracje B",
+ "static_check_endpoint": "To jedynie punkt kontrolny do użytku przez Twój reverse proxy.",
+ "authorization_required": "Wymagane uwierzytelnienie",
+ "cookies_disabled": "Twoja przeglądarka blokuje ciasteczka. Anubis wymaga ich, aby potwierdzić, że jesteś prawidłowym klientem. Włącz ciasteczka dla tej domeny.",
+ "access_denied": "Brak dostępu: kod błędu",
+ "dronebl_entry": "DroneBL zgłosił wpis",
+ "see_dronebl_lookup": "zobacz",
+ "internal_server_error": "Błąd wewnętrzny serwera: administrator błędnie skonfigurował Anubis. Skontaktuj się z administratorem i poproś o sprawdzenie logów",
+ "invalid_redirect": "Nieprawidłowe przekierowanie",
+ "redirect_not_parseable": "Nie można odczytać adresu przekierowania",
+ "redirect_domain_not_allowed": "Domena przekierowania niedozwolona",
+ "missing_required_forwarded_headers": "Brak wymaganych nagłówków X-Forwarded-*",
+ "failed_to_sign_jwt": "Nie udało się podpisać JWT",
+ "invalid_invocation": "Nieprawidłowe wywołanie MakeChallenge",
+ "client_error_browser": "Błąd klienta: upewnij się, że Twoja przeglądarka jest aktualna i spróbuj ponownie później.",
+ "oh_noes": "O nie!",
+ "benchmarking_anubis": "Testowanie wydajności Anubis!",
+ "you_are_not_a_bot": "Nie jesteś botem!",
+ "making_sure_not_bot": "Sprawdzamy, czy nie jesteś botem!",
+ "celphase": "CELPHASE",
+ "js_web_crypto_error": "Twoja przeglądarka nie obsługuje web.crypto. Czy korzystasz z bezpiecznego połączenia?",
+ "js_web_workers_error": "Twoja przeglądarka nie obsługuje web workers (Anubis ich używa, by nie zawieszać przeglądarki). Czy masz zainstalowaną wtyczkę typu JShelter?",
+ "js_cookies_error": "Twoja przeglądarka nie zapisuje ciasteczek. Anubis używa ich do przechowywania podpisanego tokenu potwierdzającego przejście zabezpieczenia. Włącz zapis ciasteczek dla tej domeny. Nazwy ciasteczek mogą zmieniać się bez zapowiedzi. Nazwy oraz zawartość ciasteczek nie są cześcią publicznego API.",
+ "js_context_not_secure": "Kontekst nie jest bezpieczny!",
+ "js_context_not_secure_msg": "Spróbuj połączyć się przez HTTPS lub poinformuj administratora, by skonfigurował HTTPS. Więcej informacji na
MDN.",
+ "js_calculating": "Obliczanie...",
+ "js_missing_feature": "Brakująca funkcja",
+ "js_challenge_error": "Błąd wyzwania!",
+ "js_challenge_error_msg": "Nie udało się ustalić algorytmu sprawdzającego. Możesz spróbować odświeżyć stronę.",
+ "js_calculating_difficulty": "Obliczanie...
Trudność:",
+ "js_speed": "Prędkość:",
+ "js_verification_longer": "Weryfikacja trwa dłużej niż zwykle. Proszę nie odświeżać strony.",
+ "js_success": "Sukces!",
+ "js_done_took": "Gotowe! Zajęło to",
+ "js_iterations": "iteracji",
+ "js_finished_reading": "Skończyłem czytać, kontynuuj →",
+ "js_calculation_error": "Błąd obliczeń!",
+ "js_calculation_error_msg": "Nie udało się obliczyć zadania:",
+ "script_load_error": "Anubis nie mógł wczytać swojego kodu JavaScript. Serwer może być przeciążony. Odśwież stronę, aby spróbować ponownie."
+}
diff --git a/lib/localization/locales/pt-BR.json b/lib/localization/locales/pt-BR.json
index 617696897..d54dbd8b9 100644
--- a/lib/localization/locales/pt-BR.json
+++ b/lib/localization/locales/pt-BR.json
@@ -62,5 +62,6 @@
"js_calculation_error": "Erro de cálculo!",
"js_calculation_error_msg": "Falha ao calcular a validação:",
"missing_required_forwarded_headers": "Faltam os cabeçalhos X-Forwarded-* obrigatórios",
- "simplified_explanation": "Esta é uma medida contra bots e solicitações maliciosas, semelhante a um CAPTCHA. No entanto, em vez de você mesmo ter que fazer o trabalho, seu navegador recebe uma tarefa de cálculo que ele deve resolver para garantir que seja um cliente válido. Esse conceito é chamado de
Prova de Trabalho. A tarefa é calculada em poucos segundos e você tem acesso ao site. Obrigado pela sua compreensão e paciência."
+ "simplified_explanation": "Esta é uma medida contra bots e solicitações maliciosas, semelhante a um CAPTCHA. No entanto, em vez de você mesmo ter que fazer o trabalho, seu navegador recebe uma tarefa de cálculo que ele deve resolver para garantir que seja um cliente válido. Esse conceito é chamado de
Prova de Trabalho. A tarefa é calculada em poucos segundos e você tem acesso ao site. Obrigado pela sua compreensão e paciência.",
+ "script_load_error": "O Anubis não conseguiu carregar seu JavaScript. O servidor pode estar sobrecarregado. Por favor, recarregue a página para tentar novamente."
}
diff --git a/lib/localization/locales/ru.json b/lib/localization/locales/ru.json
index 632efd3d4..f9b643671 100644
--- a/lib/localization/locales/ru.json
+++ b/lib/localization/locales/ru.json
@@ -62,5 +62,6 @@
"js_calculation_error": "Ошибка расчёта!",
"js_calculation_error_msg": "Не удалось рассчитать задачу:",
"missing_required_forwarded_headers": "Отсутствуют требуемые заголовки X-Forwarded-*",
- "simplified_explanation": "Это мера против ботов и вредоносных запросов, аналогичная CAPTCHA. Однако вместо того, чтобы вам приходилось работать самостоятельно, вашему браузеру дается задача вычисления, которую он должен решить, чтобы убедиться, что он является действительным клиентом. Эта концепция называется
Доказательство выполнения работы. Задача рассчитывается за несколько секунд, и вам предоставляется доступ к веб-сайту. Спасибо за понимание и терпение."
-}
\ No newline at end of file
+ "simplified_explanation": "Это мера против ботов и вредоносных запросов, аналогичная CAPTCHA. Однако вместо того, чтобы вам приходилось работать самостоятельно, вашему браузеру дается задача вычисления, которую он должен решить, чтобы убедиться, что он является действительным клиентом. Эта концепция называется
Доказательство выполнения работы. Задача рассчитывается за несколько секунд, и вам предоставляется доступ к веб-сайту. Спасибо за понимание и терпение.",
+ "script_load_error": "Anubis не смог загрузить свой JavaScript. Возможно, сервер перегружен. Пожалуйста, перезагрузите страницу, чтобы попробовать снова."
+}
diff --git a/lib/localization/locales/sv.json b/lib/localization/locales/sv.json
index dbb18cca3..ca1a6eee2 100644
--- a/lib/localization/locales/sv.json
+++ b/lib/localization/locales/sv.json
@@ -62,5 +62,6 @@
"js_calculation_error": "Beräkningsfel!",
"js_calculation_error_msg": "Misslyckades att kalkylera utmaning:",
"missing_required_forwarded_headers": "Saknar nödvändiga X-Forwarded-* headers",
- "simplified_explanation": "Detta är en åtgärd mot botar och skadliga förfrågningar som liknar en CAPTCHA. Men i stället för att du själv måste göra jobbet får din webbläsare en beräkningsuppgift som den måste lösa för att säkerställa att den är en giltig klient. Detta koncept kallas
Arbetsbevis. Uppgiften beräknas på några sekunder och du beviljas tillgång till webbplatsen. Tack för din förståelse och ditt tålamod."
-}
\ No newline at end of file
+ "simplified_explanation": "Detta är en åtgärd mot botar och skadliga förfrågningar som liknar en CAPTCHA. Men i stället för att du själv måste göra jobbet får din webbläsare en beräkningsuppgift som den måste lösa för att säkerställa att den är en giltig klient. Detta koncept kallas
Arbetsbevis. Uppgiften beräknas på några sekunder och du beviljas tillgång till webbplatsen. Tack för din förståelse och ditt tålamod.",
+ "script_load_error": "Anubis kunde inte ladda sin JavaScript-kod. Servern kan vara överbelastad. Var vänlig och ladda om sidan för att försöka igen."
+}
diff --git a/lib/localization/locales/th.json b/lib/localization/locales/th.json
new file mode 100644
index 000000000..548cfa945
--- /dev/null
+++ b/lib/localization/locales/th.json
@@ -0,0 +1,65 @@
+{
+ "loading": "กำลังโหลด...",
+ "why_am_i_seeing": "ทำไมถึงเห็นสิ่งนี้?",
+ "protected_by": "ปกป้องโดย",
+ "protected_from": "จาก",
+ "made_with": "สร้างด้วย ❤️ ใน 🇨🇦",
+ "mascot_design": "ออกแบบมาสค็อตโดย",
+ "ai_companies_explanation": "คุณเห็นสิ่งนี้เพราะผู้ดูแลเว็บไซต์ได้ตั้งค่า Anubis เพื่อป้องกันเซิร์ฟเวอร์จากบริษัท AI ที่ทำการขูดข้อมูลเว็บไซต์อย่างก้าวร้าว ซึ่งสามารถทำให้เว็บไซต์ล่ม และทำให้ทรัพยากรของเว็บไซต์ไม่สามารถเข้าถึงได้สำหรับทุกคน",
+ "anubis_compromise": "Anubis คือการประนีประนอม โดยใช้ระบบ Proof-of-Work คล้ายกับ Hashcash ซึ่งเป็นแนวคิดสำหรับลดสแปมอีเมล แนวคิดคือ การโหลดเพิ่มเติมในระดับผู้ใช้รายบุคคลสามารถละเลยได้ แต่ในระดับการขูดข้อมูลจำนวนมาก มันจะสะสมและทำให้การขูดแพงขึ้น",
+ "hack_purpose": "ท้ายที่สุดแล้ว นี่คือการแฮ็กที่มีวัตถุประสงค์หลักเพื่อเป็นโซลูชันชั่วคราวที่ 'เพียงพอ' เพื่อให้มีเวลาในการสร้างการตรวจจับตัวตนของเบราว์เซอร์แบบไม่มีกล่องข้อความ (เช่น ผ่านการเรนเดอร์ฟอนต์) เพื่อไม่ต้องแสดงหน้า Proof-of-Work แก่ผู้ใช้ที่มีแนวโน้มว่าจะเป็นผู้ใช้จริง",
+ "jshelter_note": "โปรดทราบว่า Anubis ต้องการใช้คุณสมบัติ JavaScript สมัยใหม่ที่ปลั๊กอินอย่าง JShelter จะปิดใช้งาน กรุณาปิด JShelter หรือปลั๊กอินลักษณะคล้ายกันสำหรับโดเมนนี้",
+ "version_info": "เว็บไซต์นี้กำลังใช้ Anubis เวอร์ชัน",
+ "try_again": "ลองอีกครั้ง",
+ "go_home": "กลับหน้าหลัก",
+ "contact_webmaster": "หากคุณเชื่อว่าไม่ควรถูกบล็อก กรุณาติดต่อผู้ดูแลเว็บไซต์ที่",
+ "connection_security": "กรุณารอสักครู่ในขณะที่เราตรวจสอบความปลอดภัยของการเชื่อมต่อของคุณ",
+ "javascript_required": "น่าเสียดายที่คุณต้องเปิดใช้ JavaScript เพื่อผ่านการทดสอบนี้ เนื่องจากบริษัท AI ได้เปลี่ยนข้อตกลงทางสังคมเกี่ยวกับการโฮสต์เว็บไซต์ ทางเลือกแบบ 'ไม่มี JS' กำลังอยู่ระหว่างการพัฒนา",
+ "benchmark_requires_js": "เครื่องมือวัดประสิทธิภาพต้องใช้ JavaScript",
+ "difficulty": "ความยาก:",
+ "algorithm": "อัลกอริธึม:",
+ "compare": "เปรียบเทียบ:",
+ "time": "เวลา",
+ "iters": "จำนวนรอบ",
+ "time_a": "เวลา A",
+ "iters_a": "รอบ A",
+ "time_b": "เวลา B",
+ "iters_b": "รอบ B",
+ "static_check_endpoint": "นี่เป็นเพียง endpoint ตรวจสอบสำหรับ reverse proxy ของคุณ",
+ "authorization_required": "ต้องมีการยืนยันตัวตน",
+ "cookies_disabled": "เบราว์เซอร์ของคุณปิดการใช้งานคุกกี้ Anubis ต้องใช้คุกกี้เพื่อตรวจสอบว่าเป็นผู้ใช้ที่แท้จริง กรุณาเปิดใช้งานคุกกี้สำหรับโดเมนนี้",
+ "access_denied": "การเข้าถึงถูกปฏิเสธ: รหัสข้อผิดพลาด",
+ "dronebl_entry": "DroneBL รายงานรายการนี้",
+ "see_dronebl_lookup": "ดู",
+ "internal_server_error": "เกิดข้อผิดพลาดในเซิร์ฟเวอร์: ผู้ดูแลระบบได้กำหนดค่า Anubis อย่างไม่ถูกต้อง กรุณาติดต่อผู้ดูแลระบบและให้เขาตรวจสอบบันทึกใกล้กับ",
+ "invalid_redirect": "การเปลี่ยนเส้นทางไม่ถูกต้อง",
+ "redirect_not_parseable": "ไม่สามารถแยกวิเคราะห์ URL สำหรับเปลี่ยนเส้นทาง",
+ "redirect_domain_not_allowed": "ไม่อนุญาตให้เปลี่ยนเส้นทางไปยังโดเมนนี้",
+ "failed_to_sign_jwt": "ไม่สามารถเซ็น JWT ได้",
+ "invalid_invocation": "เรียกใช้ MakeChallenge อย่างไม่ถูกต้อง",
+ "client_error_browser": "ข้อผิดพลาดของไคลเอนต์: กรุณาตรวจสอบว่าเบราว์เซอร์ของคุณเป็นเวอร์ชันล่าสุด และลองใหม่ในภายหลัง",
+ "oh_noes": "โอ้ ไม่!",
+ "benchmarking_anubis": "กำลังวัดประสิทธิภาพ Anubis!",
+ "you_are_not_a_bot": "คุณไม่ใช่บอท!",
+ "making_sure_not_bot": "ตรวจสอบให้แน่ใจว่าคุณไม่ใช่บอท!",
+ "celphase": "CELPHASE",
+ "js_web_crypto_error": "เบราว์เซอร์ของคุณไม่มีฟีเจอร์ web.crypto ที่ใช้งานได้ คุณกำลังดูผ่านบริบทที่ปลอดภัยหรือไม่?",
+ "js_web_workers_error": "เบราว์เซอร์ของคุณไม่รองรับ web workers (Anubis ใช้เพื่อลดการค้างของเบราว์เซอร์) คุณใช้ปลั๊กอินเช่น JShelter หรือไม่?",
+ "js_cookies_error": "เบราว์เซอร์ของคุณไม่เก็บคุกกี้ Anubis ใช้คุกกี้เพื่อเก็บโทเค็นที่เซ็นแล้วสำหรับไคลเอนต์ที่ผ่านการท้าทาย กรุณาเปิดใช้งานการเก็บคุกกี้สำหรับโดเมนนี้ ชื่อคุกกี้อาจเปลี่ยนแปลงได้โดยไม่แจ้งล่วงหน้า",
+ "js_context_not_secure": "บริบทของคุณไม่ปลอดภัย!",
+ "js_context_not_secure_msg": "ลองเชื่อมต่อผ่าน HTTPS หรือแจ้งผู้ดูแลระบบให้ตั้งค่า HTTPS สำหรับข้อมูลเพิ่มเติมดูที่
MDN",
+ "js_calculating": "กำลังคำนวณ...",
+ "js_missing_feature": "ไม่มีคุณลักษณะนี้",
+ "js_challenge_error": "เกิดข้อผิดพลาดในการท้าทาย!",
+ "js_challenge_error_msg": "ไม่สามารถแก้ไขอัลกอริธึมการตรวจสอบ อาจต้องโหลดหน้าใหม่",
+ "js_calculating_difficulty": "กำลังคำนวณ...
ความยาก:",
+ "js_speed": "ความเร็ว:",
+ "js_verification_longer": "การตรวจสอบใช้เวลานานกว่าที่คาดไว้ กรุณาอย่ารีเฟรชหน้านี้",
+ "js_success": "สำเร็จ!",
+ "js_done_took": "เสร็จแล้ว! ใช้เวลา",
+ "js_iterations": "รอบ",
+ "js_finished_reading": "อ่านจบแล้ว ดำเนินการต่อ →",
+ "js_calculation_error": "เกิดข้อผิดพลาดในการคำนวณ!",
+ "js_calculation_error_msg": "ไม่สามารถคำนวณการท้าทายได้:",
+ "script_load_error": "Anubis ไม่สามารถโหลด JavaScript ได้ เซิร์ฟเวอร์อาจมีภาระงานหนักเกินไป กรุณาโหลดหน้านี้ใหม่เพื่อลองอีกครั้ง"
+}
diff --git a/lib/localization/locales/tr.json b/lib/localization/locales/tr.json
index 79773b5b7..d7af2ab91 100644
--- a/lib/localization/locales/tr.json
+++ b/lib/localization/locales/tr.json
@@ -62,5 +62,6 @@
"js_calculation_error": "Hesaplama hatası!",
"js_calculation_error_msg": "Zorluk hesaplaması başarısız oldu:",
"missing_required_forwarded_headers": "Gerekli X-Forwarded-* başlıkları eksik",
- "simplified_explanation": "Bu, botlara ve kötü niyetli isteklere karşı CAPTCHA'ya benzer bir önlemdir. Ancak, kendiniz çalışmak yerine, tarayıcınıza geçerli bir istemci olduğundan emin olmak için çözmesi gereken bir hesaplama görevi verilir. Bu kavrama
İş Kanıtı denir. Görev birkaç saniye içinde hesaplanır ve web sitesine erişim hakkı kazanırsınız. Anlayışınız ve sabrınız için teşekkür ederiz."
+ "simplified_explanation": "Bu, botlara ve kötü niyetli isteklere karşı CAPTCHA'ya benzer bir önlemdir. Ancak, kendiniz çalışmak yerine, tarayıcınıza geçerli bir istemci olduğundan emin olmak için çözmesi gereken bir hesaplama görevi verilir. Bu kavrama
İş Kanıtı denir. Görev birkaç saniye içinde hesaplanır ve web sitesine erişim hakkı kazanırsınız. Anlayışınız ve sabrınız için teşekkür ederiz.",
+ "script_load_error": "Anubis, JavaScript dosyasını yükleyemedi. Sunucu aşırı yüklenmiş olabilir. Lütfen tekrar denemek için sayfayı yeniden yükleyin."
}
diff --git a/lib/localization/locales/uk.json b/lib/localization/locales/uk.json
new file mode 100644
index 000000000..04e0236e3
--- /dev/null
+++ b/lib/localization/locales/uk.json
@@ -0,0 +1,67 @@
+{
+ "loading": "Завантаження...",
+ "why_am_i_seeing": "Чому я це бачу?",
+ "protected_by": "Захищено засобами",
+ "protected_from": "за авторством",
+ "made_with": "Зроблено з ❤️ у 🇨🇦",
+ "mascot_design": "Дизайн персонажа від",
+ "ai_companies_explanation": "Ви це бачите, оскільки адміністрація сайту налаштувала Anubis, щоб захистити сервер від тиску ШІ-компаній, які агресивно сканують вебсайти. Їхня діяльність спричиняє перебої в роботі вебсайтів, що робить матеріали недоступними для всіх.",
+ "anubis_compromise": "Anubis — це компроміс. Anubis втілює схему доказу виконаної роботи подібно до Hashcash — засобу боротьби зі спамом. По ідеї, додаткове навантаження не обтяжує справжню людину, котра робить небагато запитів, а от масове сканування таким чином стає суттєво дорожчим.",
+ "hack_purpose": "Це тимчасове рішення, котре дозволяє приділити більше часу розпізнанню й виокремленню автоматизованих браузерів (наприклад, за тим, як вони промальовують шрифти), щоб сторінку перевірки доказу виконаної роботи не доводилося показувати ймовірно справжнім користувачам.",
+ "simplified_explanation": "Це засіб боротьби з ботами й зловмисними запитами, подібний до капчі. Проте замість того, щоб просити вас щось зробити, він пропонує вашому браузеру розв'язати обчислювальне завдання. Ця концепція називається
доказом виконаної роботи. Завдання обчислюється кілька секунд, після чого вам надається доступ до сайту. Дякуємо за розуміння й терплячість.",
+ "jshelter_note": "Зауважте, Anubis потребує сучасного JavaScript-функціоналу, котрий може бути недоступним при використанні розширень на зразок JShelter. Будь ласка, вимкніть JShelter чи інші подібні розширення для цього домену.",
+ "version_info": "Цей вебсайт застосовує Anubis версії",
+ "try_again": "Повторіть спробу",
+ "go_home": "Перейдіть на головну сторінку",
+ "contact_webmaster": "або, якщо ви певні в помилковості блокування, сконтактуйте з адміністрацією за адресою",
+ "connection_security": "Зачекайте хвилинку, поки ми перевіримо безпеку вашого з'єднання.",
+ "javascript_required": "На жаль, вам потрібно ввімкнути JavaScript, щоб пройти цю перевірку. Це необхідно, оскільки ШІ-компанії нехтують суспільним договором, завдяки якому можливо утримувати вебсайти. Робота над рішенням без використання JS триває.",
+ "benchmark_requires_js": "Щоб запустити тестування продуктивності, ввімкніть JavaScript.",
+ "difficulty": "Складність:",
+ "algorithm": "Алгоритм:",
+ "compare": "Порівняти:",
+ "time": "Час",
+ "iters": "Ітерації",
+ "time_a": "Час A",
+ "iters_a": "Ітерації A",
+ "time_b": "Час B",
+ "iters_b": "Ітерації B",
+ "static_check_endpoint": "Це просто сторінка перевірки для вашого зворотного проксі.",
+ "authorization_required": "Необхідно авторизуватися",
+ "cookies_disabled": "У вашому браузері вимкнено кукі. Anubis використовує кукі, щоб упевнитись, що ви дійсно людина. Це законний інтерес. Будь ласка, ввімкніть кукі для цього домену",
+ "access_denied": "Доступ заборонено: код помилки",
+ "dronebl_entry": "DroneBL містить пункт",
+ "see_dronebl_lookup": "див.",
+ "internal_server_error": "Внутрішня помилка сервера: адміністрація хибно налаштувала Anubis. Будь ласка, сконтактуйте з адміністрацією й попросіть глянути логи довкола",
+ "invalid_redirect": "Хибне переспрямування",
+ "redirect_not_parseable": "Не вдається розпізнати URL-адресу переспрямування",
+ "redirect_domain_not_allowed": "Заборонений домен переспрямування",
+ "missing_required_forwarded_headers": "Бракує обов'язкових заголовків X-Forwarded-*",
+ "failed_to_sign_jwt": "не вдається підписати JWT",
+ "invalid_invocation": "Хибний виклик MakeChallenge",
+ "client_error_browser": "Помилка клієнта: переконайтесь, що використовуєте браузер актуальної версії, й повторіть спробу.",
+ "oh_noes": "Йой!",
+ "benchmarking_anubis": "Тестування продуктивності Anubis!",
+ "you_are_not_a_bot": "Ви не бот!",
+ "making_sure_not_bot": "Перевірка, чи ви не бот!",
+ "celphase": "CELPHASE",
+ "js_web_crypto_error": "Ваш браузер не надає web.crypto. Ви певні, що дивитесь це через захищений контекст?",
+ "js_web_workers_error": "Ваш браузер не підтримує Web Workers (Anubis використовує їх, щоб ваш браузер не зависав на час перевірки). Можливо, у вас встановлено розширення на зразок JShelter?",
+ "js_cookies_error": "Ваш браузер не зберігає кукі. Anubis записує підписаний токен до кукі, щоб занотувати, що клієнт пройшов перевірку. Будь ласка, ввімкніть збереження кукі для цього домену. Назви кукі, які записує Anubis, можуть змінюватися без попередження. Назви й значення кукі не є частиною публічного API.",
+ "js_context_not_secure": "Ваш контекст незахищений!",
+ "js_context_not_secure_msg": "Спробуйте з'єднатися через HTTPS або попросіть адміністрацію налаштувати HTTPS. Докладніше — в
MDN.",
+ "js_calculating": "Обчислення...",
+ "js_missing_feature": "Бракує функціоналу",
+ "js_challenge_error": "Помилка перевірки!",
+ "js_challenge_error_msg": "Не вдалося визначити алгоритм перевірки. Спробуйте оновити сторінку.",
+ "js_calculating_difficulty": "Обчислення...
Складність:",
+ "js_speed": "Швидкість:",
+ "js_verification_longer": "Перевірка триває довше, ніж очікувалося. Будь ласка, не оновлюйте сторінку.",
+ "js_success": "Успіх!",
+ "js_done_took": "Готово! Знадобилося",
+ "js_iterations": "ітерацій",
+ "js_finished_reading": "Читання завершено, продовжити →",
+ "js_calculation_error": "Помилка обчислення!",
+ "js_calculation_error_msg": "Не вдалося обчислити перевірку:",
+ "script_load_error": "Anubis не зміг завантажити свій JavaScript. Можливо, сервер перевантажено. Будь ласка, оновіть сторінку, щоб спробувати ще раз."
+}
diff --git a/lib/localization/locales/vi.json b/lib/localization/locales/vi.json
index edaf20fa2..d8bb49498 100644
--- a/lib/localization/locales/vi.json
+++ b/lib/localization/locales/vi.json
@@ -1,20 +1,20 @@
{
- "loading": "Đang nạp...",
- "why_am_i_seeing": "Tại sao tôi đang thấy trang này?",
+ "loading": "Đang tải...",
+ "why_am_i_seeing": "Tại sao tôi thấy trang này?",
"protected_by": "Bảo vệ bởi",
"protected_from": "từ",
- "made_with": "Tạo ra bằng ❤️ tại 🇨🇦",
- "mascot_design": "Thiết kế mascot bởi",
- "ai_companies_explanation": "Bạn đang thấy trang này do quản trị viên của trang web này đã thiết lập Anubis để bảo vệ máy chủ của họ khỏi quấy rầy từ những công ty AI hung hãn cóp nhặt nội dung khắp Internet. Điều này có thể và đã dẫn tới tình trạng gián đoạn hoạt động trên nhiều trang web, khiến tài nguyên tại đó nằm ngoài tầm với của mọi người.",
+ "made_with": "Làm bằng ❤️ tại 🇨🇦",
+ "mascot_design": "Mascot bởi",
+ "ai_companies_explanation": "Bạn 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 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 ở đó nằm ngoài tầm với của mọi người.",
"anubis_compromise": "Anubis là giải pháp thỏa hiệp. Anubis sử dụng cơ chế Proof-of-Work dựa trên Hashcash, được thiết kế ban đầu để giảm bớt email spam. Ý tưởng đằng sau đó là với người dùng cá nhân phần nạp thêm sẽ không đáng kể, nhưng ở tầm mức quy mô lớn sẽ cộng dồn và dẫn tới chi phí tiêu hao hơn rất nhiều.",
- "hack_purpose": "Chốt lại, đây cũng chỉ là giải pháp \"tạm ổn\" với mục đích thực sự là để giành thêm thời gian nhận diện và fingerprint những trình duyệt headless (VD: cách dựng font ra sao), sao cho hạn chế tối đa các yêu cầu tính toán trang thử thách Proof-of-Work tới nhóm người dùng có khả năng cao là con người hơn.",
+ "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à truy vết những trình duyệt headless (VD: cách dựng font ra sao), sao cho hạn chế tối đa các yêu cầu tính toán trang thử thách Proof-of-Work tới nhóm người dùng có khả năng cao là con người hơn.",
"simplified_explanation": "Đây là một biện pháp chống lại bot và các yêu cầu độc hại tương tự như CAPTCHA. Tuy nhiên, thay vì bạn phải tự mình thực hiện, trình duyệt của bạn sẽ được giao một nhiệm vụ tính toán mà nó phải giải quyết để đảm bảo rằng nó là một máy khách hợp lệ. Khái niệm này được gọi là
Bằng chứng Công việc. Nhiệm vụ được tính toán trong vài giây và bạn được cấp quyền truy cập vào trang web. Cảm ơn sự thông cảm và kiên nhẫn của bạn.",
"jshelter_note": "Vui lòng lưu ý Anubis cần sử dụng những tính năng JavaScript hiện đại mà một số phần mở rộng như JShelter sẽ tắt. Vui lòng vô hiệu hóa JShelter hoặc những phần mở rộng tương tự cho tên miền này.",
- "version_info": "Trang web này đang chạy Anubis phiên bản",
+ "version_info": "Trang web này dùng Anubis",
"try_again": "Thử lại",
"go_home": "Về trang chủ",
"contact_webmaster": "hoặc nếu bạn tin rằng mình không nên bị chặn, vui lòng liên hệ chủ trang web tại",
- "connection_security": "Vui lòng chờ một chút trong khi chúng tôi kiểm tra an ninh kết nối của bạn.",
+ "connection_security": "Vui lòng chờ một chút trong lúc chúng tôi kiểm tra kết nối của bạn.",
"javascript_required": "Rất tiếc, bạn phải bật JavaScript để vượt qua thử thách này. Điều này bắt buộc do những công ty AI đã thay đổi luật ngầm quanh việc hoạt động máy chủ web ra sao. Giải pháp không có JavaScript đang được phát triển.",
"benchmark_requires_js": "Bắt buộc phải bật JavaScript để chạy công cụ benchmark.",
"difficulty": "Độ khó:",
@@ -28,14 +28,14 @@
"iters_b": "Lặp lại kiểu B",
"static_check_endpoint": "Đây là điểm cuối cho reverse proxy của bạn sử dụng.",
"authorization_required": "Bắt buộc xác thực",
- "cookies_disabled": "Trình duyệt của bạn được thiết lập để vô hiệu hóa cookie. Anubis cần cookie cho mục đích chính đáng để kiểm tra chắc chắn bạn là người dùng hợp lệ. Vui lòng bật cookie cho tên miền này",
+ "cookies_disabled": "Trình duyệt của bạn đã vô hiệu hóa cookie. Anubis cần cookie cho mục đích chính đáng để kiểm tra chắc chắn bạn là người dùng hợp lệ. Vui lòng bật cookie cho tên miền này",
"access_denied": "Truy cập bị từ chối: mã lỗi",
"dronebl_entry": "DroneBL báo cáo truy cập",
"see_dronebl_lookup": "xem",
- "internal_server_error": "Lỗi máy chủ nội bộ: quản trị viên đã thiết lập sai Anubis. Vui lòng liên hệ quản trị viên và yêu cầu họ kiểm tra log",
+ "internal_server_error": "Lỗi nội bộ máy chủ: quản trị viên đã thiết lập sai Anubis. Vui lòng liên hệ quản trị viên và yêu cầu họ kiểm tra log",
"invalid_redirect": "Điều hướng không hợp lệ",
- "redirect_not_parseable": "Liên kết điều hướng không thể xử lý",
- "redirect_domain_not_allowed": "Tên miền điều hướng không được phép",
+ "redirect_not_parseable": "Không thể xử lý liên kết điều hướng",
+ "redirect_domain_not_allowed": "Không được phép điều hướng tên miền",
"missing_required_forwarded_headers": "Thiếu các tiêu đề X-Forwarded-* bắt buộc",
"failed_to_sign_jwt": "không thể ký JWT",
"invalid_invocation": "Gọi hàm MakeChallenge không hợp lệ",
@@ -43,7 +43,7 @@
"oh_noes": "Ôi không!",
"benchmarking_anubis": "Đang benchmark Anubis!",
"you_are_not_a_bot": "Bạn không phải là bot!",
- "making_sure_not_bot": "Đang kiểm tra bạn không phải là bot!",
+ "making_sure_not_bot": "Đảm bảo bạn không phải là bot!",
"celphase": "CELPHASE",
"js_web_crypto_error": "Trình duyệt của bạn không có web.crypto hoạt động. Liệu bạn có đang xem trang này với kết nối bảo mật không?",
"js_web_workers_error": "Trình duyệt của bạn không hỗ trợ tính năng web worker (Anubis sử dụng để trình duyệt của bạn bị đơ). Bạn có cài đặt và sử dụng phần mở rộng như JShelter hay không?",
@@ -53,14 +53,15 @@
"js_calculating": "Đang tính toán...",
"js_missing_feature": "Thiếu tính năng",
"js_challenge_error": "Lỗi thử thách!",
- "js_challenge_error_msg": "Không thể xử lý thuật toán kiểm tra. Bạn nên nạp lại trang này.",
- "js_calculating_difficulty": "Đang tính...
Độ khó:",
+ "js_challenge_error_msg": "Không thể xử lý thuật toán kiểm tra. Bạn nên tải lại trang này.",
+ "js_calculating_difficulty": "Đang tính toán...
Độ khó:",
"js_speed": "Tốc độ:",
- "js_verification_longer": "Quá trình kiểm tra đang kéo dài lâu hơn dự kiến. Vui lòng không nạp lại trang này.",
+ "js_verification_longer": "Quá trình kiểm tra hơi lâu hơn dự kiến. Vui lòng không tải lại trang này.",
"js_success": "Thành công!",
- "js_done_took": "Hoàn tất! Mất",
+ "js_done_took": "Xong rồi! Vào",
"js_iterations": "lần lặp lại",
"js_finished_reading": "Tôi đã đọc xong, tiếp tục →",
"js_calculation_error": "Lỗi tính toán!",
- "js_calculation_error_msg": "Không thể tính toán thử thách:"
-}
\ No newline at end of file
+ "js_calculation_error_msg": "Không thể tính toán thử thách:",
+ "script_load_error": "Anubis không thể tải JavaScript của mình. Máy chủ có thể đang quá tải. Vui lòng tải lại trang để thử lại."
+}
diff --git a/lib/localization/locales/zh-CN.json b/lib/localization/locales/zh-CN.json
index 195296074..fee98b82c 100644
--- a/lib/localization/locales/zh-CN.json
+++ b/lib/localization/locales/zh-CN.json
@@ -62,5 +62,6 @@
"js_calculation_error": "计算错误!",
"js_calculation_error_msg": "计算挑战失败:",
"missing_required_forwarded_headers": "缺少必要的 X-Forwarded-* 头",
- "simplified_explanation": "这是一种类似于验证码的措施,用于防止机器人和恶意请求。但是,您无需自己动手,您的浏览器会收到一个计算任务,必须解决该任务以确保它是有效的客户端。这个概念称为
工作量证明。该任务在几秒钟内计算完毕,您将被授予访问网站的权限。感谢您的理解和耐心。"
+ "simplified_explanation": "这是一种类似于验证码的措施,用于防止机器人和恶意请求。但是,您无需自己动手,您的浏览器会收到一个计算任务,必须解决该任务以确保它是有效的客户端。这个概念称为
工作量证明。该任务在几秒钟内计算完毕,您将被授予访问网站的权限。感谢您的理解和耐心。",
+ "script_load_error": "Anubis 无法载入所需的 JavaScript。服务器可能负载过高。请重新加载页面再试一次。"
}
diff --git a/lib/localization/locales/zh-TW.json b/lib/localization/locales/zh-TW.json
index 4e7da25c0..4a622eb6d 100644
--- a/lib/localization/locales/zh-TW.json
+++ b/lib/localization/locales/zh-TW.json
@@ -62,5 +62,6 @@
"js_calculation_error": "計算錯誤!",
"js_calculation_error_msg": "計算挑戰失敗:",
"missing_required_forwarded_headers": "缺少必要的 X-Forwarded-* 標頭",
- "simplified_explanation": "這是一種類似於驗證碼的措施,用於防止機器人和惡意請求。但是,您無需自己動手,您的瀏覽器會收到一個計算任務,必須解決該任務以確保它是有效的客戶端。這個概念稱為
工作量證明。該任務在幾秒鐘內計算完畢,您將被授予訪問網站的權限。感謝您的理解和耐心。"
-}
\ No newline at end of file
+ "simplified_explanation": "這是一種類似於驗證碼的措施,用於防止機器人和惡意請求。但是,您無需自己動手,您的瀏覽器會收到一個計算任務,必須解決該任務以確保它是有效的客戶端。這個概念稱為
工作量證明。該任務在幾秒鐘內計算完畢,您將被授予訪問網站的權限。感謝您的理解和耐心。",
+ "script_load_error": "Anubis 無法載入所需的 JavaScript。伺服器可能負載過高。請重新載入頁面再試一次。"
+}
diff --git a/lib/localization/localization.go b/lib/localization/localization.go
index 6b8d2a6f8..f608fc8b5 100644
--- a/lib/localization/localization.go
+++ b/lib/localization/localization.go
@@ -15,6 +15,12 @@ import (
//go:embed locales/*.json
var localeFS embed.FS
+// manifestFile lists the supported languages. It is metadata, not a translation
+// catalog: go-i18n derives a locale from the file name, and "manifest" registers
+// under language.Und, which then shadows the English fallback for requests that
+// ask for "und".
+const manifestFile = "manifest.json"
+
type LocalizationService struct {
bundle *i18n.Bundle
}
@@ -39,7 +45,7 @@ func NewLocalizationService() *LocalizationService {
loadedAny := false
for _, entry := range entries {
- if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".json") {
+ if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".json") && entry.Name() != manifestFile {
filePath := "locales/" + entry.Name()
_, err := bundle.LoadMessageFileFS(localeFS, filePath)
if err != nil {
@@ -81,7 +87,28 @@ func (ls *LocalizationService) GetLocalizerFromRequest(r *http.Request) *i18n.Lo
return i18n.NewLocalizer(bundle, "en")
}
acceptLanguage := r.Header.Get("Accept-Language")
- return i18n.NewLocalizer(ls.bundle, acceptLanguage, "en")
+
+ // Parse Accept-Language header to properly handle quality factors
+ // The language.ParseAcceptLanguage function returns tags sorted by quality
+ tags, _, err := language.ParseAcceptLanguage(acceptLanguage)
+ if err != nil || len(tags) == 0 {
+ return i18n.NewLocalizer(ls.bundle, "en")
+ }
+
+ // Convert parsed tags to strings for the localizer
+ // We include both the full tag and base language to ensure proper matching
+ langs := make([]string, 0, len(tags)*2+1)
+ for _, tag := range tags {
+ langs = append(langs, tag.String())
+ // Also add base language (e.g., "en" for "en-GB") to help matching
+ base, _ := tag.Base()
+ if base.String() != tag.String() {
+ langs = append(langs, base.String())
+ }
+ }
+ langs = append(langs, "en") // Always include English as fallback
+
+ return i18n.NewLocalizer(ls.bundle, langs...)
}
// SimpleLocalizer wraps i18n.Localizer with a more convenient API
diff --git a/lib/localization/localization_test.go b/lib/localization/localization_test.go
index 63929163a..1030625fa 100644
--- a/lib/localization/localization_test.go
+++ b/lib/localization/localization_test.go
@@ -3,6 +3,7 @@ package localization
import (
"encoding/json"
"fmt"
+ "net/http/httptest"
"sort"
"testing"
@@ -13,24 +14,28 @@ func TestLocalizationService(t *testing.T) {
service := NewLocalizationService()
loadingStrMap := map[string]string{
- "de": "Ladevorgang...",
+ "de": "Wird geladen …",
"en": "Loading...",
"es": "Cargando...",
"et": "Laadin...",
"fil": "Naglo-load...",
"fr": "Chargement...",
"ja": "ロード中...",
+ "hr": "Učitavanje...",
"is": "Hleður...",
"nb": "Laster inn...",
"nl": "Laden...",
"nn": "Lastar inn...",
+ "pl": "Ładowanie...",
"pt-BR": "Carregando...",
"tr": "Yükleniyor...",
"ru": "Загрузка...",
- "vi": "Đang nạp...",
+ "uk": "Завантаження...",
+ "vi": "Đang tải...",
"zh-CN": "加载中...",
"zh-TW": "載入中...",
- "sv" : "Laddar...",
+ "sv": "Laddar...",
+ "bg": "Зареждане...",
}
var keys []string
@@ -82,7 +87,7 @@ func loadManifest(t *testing.T) manifest {
if err != nil {
t.Fatal(err)
}
- defer fin.Close()
+ defer fin.Close() //nolint:errcheck
var result manifest
if err := json.NewDecoder(fin).Decode(&result); err != nil {
@@ -100,7 +105,7 @@ func TestComprehensiveTranslations(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- defer fin.Close()
+ defer fin.Close() //nolint:errcheck
if err := json.NewDecoder(fin).Decode(&translations); err != nil {
t.Fatal(err)
@@ -136,3 +141,74 @@ func TestComprehensiveTranslations(t *testing.T) {
})
}
}
+
+func TestAcceptLanguageQualityFactors(t *testing.T) {
+ service := NewLocalizationService()
+
+ testCases := []struct {
+ name string
+ acceptLanguage string
+ expectedLang string
+ }{
+ {"simple_en", "en", "en"},
+ {"simple_de", "de", "de"},
+ {"en_GB_with_lower_priority_de", "en-GB,de-DE;q=0.5", "en"},
+ {"en_GB_only", "en-GB", "en"},
+ {"de_with_lower_priority_en", "de,en;q=0.5", "de"},
+ {"de_DE_with_lower_priority_en", "de-DE,en;q=0.5", "de"},
+ {"fr_with_lower_priority_de", "fr,de;q=0.5", "fr"},
+ {"zh_CN_regional", "zh-CN", "zh-CN"},
+ {"zh_TW_regional", "zh-TW", "zh-TW"},
+ {"pt_BR_regional", "pt-BR", "pt-BR"},
+ {"complex_header", "fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7,de;q=0.5", "fr"},
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ req := httptest.NewRequest("GET", "/", nil)
+ req.Header.Set("Accept-Language", tc.acceptLanguage)
+
+ localizer := service.GetLocalizerFromRequest(req)
+ sl := &SimpleLocalizer{Localizer: localizer}
+
+ gotLang := sl.GetLang()
+ if gotLang != tc.expectedLang {
+ t.Errorf("Accept-Language %q: expected %s, got %s", tc.acceptLanguage, tc.expectedLang, gotLang)
+ }
+ })
+ }
+}
+
+func TestAcceptLanguageUndetermined(t *testing.T) {
+ service := NewLocalizationService()
+
+ testCases := []struct {
+ name string
+ acceptLanguage string
+ expectedLang string
+ }{
+ {"undetermined", "und", "en"},
+ {"undetermined_with_quality", "und;q=0.9", "en"},
+ {"undetermined_below_german", "de,und;q=0.9", "de"},
+ {"unknown_subtag", "xx", "en"},
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ req := httptest.NewRequest("GET", "/", nil)
+ req.Header.Set("Accept-Language", tc.acceptLanguage)
+
+ sl := &SimpleLocalizer{Localizer: service.GetLocalizerFromRequest(req)}
+
+ // T uses MustLocalize, so an unresolved message panics and serving the
+ // challenge page fails. GetLang alone cannot catch that: it swallows the
+ // lookup error and reports "en" either way.
+ if got := sl.T("making_sure_not_bot"); got == "" {
+ t.Errorf("Accept-Language %q: empty translation", tc.acceptLanguage)
+ }
+ if got := sl.GetLang(); got != tc.expectedLang {
+ t.Errorf("Accept-Language %q: expected %s, got %s", tc.acceptLanguage, tc.expectedLang, got)
+ }
+ })
+ }
+}
diff --git a/lib/metrics/keypairreloader.go b/lib/metrics/keypairreloader.go
new file mode 100644
index 000000000..be0a61ca2
--- /dev/null
+++ b/lib/metrics/keypairreloader.go
@@ -0,0 +1,81 @@
+package metrics
+
+import (
+ "crypto/tls"
+ "fmt"
+ "log/slog"
+ "os"
+ "sync"
+ "time"
+)
+
+type KeypairReloader struct {
+ certMu sync.RWMutex
+ cert *tls.Certificate
+ certPath string
+ keyPath string
+ modTime time.Time
+ lg *slog.Logger
+}
+
+func NewKeypairReloader(certPath, keyPath string, lg *slog.Logger) (*KeypairReloader, error) {
+ result := &KeypairReloader{
+ certPath: certPath,
+ keyPath: keyPath,
+ lg: lg,
+ }
+ cert, err := tls.LoadX509KeyPair(certPath, keyPath)
+ if err != nil {
+ return nil, err
+ }
+ result.cert = &cert
+
+ st, err := os.Stat(certPath)
+ if err != nil {
+ return nil, err
+ }
+ result.modTime = st.ModTime()
+
+ return result, nil
+}
+
+func (kpr *KeypairReloader) maybeReload() error {
+ kpr.lg.Debug("loading new keypair", "cert", kpr.certPath, "key", kpr.keyPath)
+ newCert, err := tls.LoadX509KeyPair(kpr.certPath, kpr.keyPath)
+ if err != nil {
+ return err
+ }
+
+ st, err := os.Stat(kpr.certPath)
+ if err != nil {
+ return err
+ }
+
+ kpr.certMu.Lock()
+ defer kpr.certMu.Unlock()
+ kpr.cert = &newCert
+ kpr.modTime = st.ModTime()
+
+ return nil
+}
+
+func (kpr *KeypairReloader) GetCertificate(*tls.ClientHelloInfo) (*tls.Certificate, error) {
+ st, err := os.Stat(kpr.certPath)
+ if err != nil {
+ return nil, fmt.Errorf("stat(%q): %w", kpr.certPath, err)
+ }
+
+ kpr.certMu.RLock()
+ needsReload := st.ModTime().After(kpr.modTime)
+ kpr.certMu.RUnlock()
+
+ if needsReload {
+ if err := kpr.maybeReload(); err != nil {
+ return nil, fmt.Errorf("reload cert: %w", err)
+ }
+ }
+
+ kpr.certMu.RLock()
+ defer kpr.certMu.RUnlock()
+ return kpr.cert, nil
+}
diff --git a/lib/metrics/keypairreloader_test.go b/lib/metrics/keypairreloader_test.go
new file mode 100644
index 000000000..2ec514e4b
--- /dev/null
+++ b/lib/metrics/keypairreloader_test.go
@@ -0,0 +1,265 @@
+package metrics
+
+import (
+ "bytes"
+ "crypto/rand"
+ "crypto/rsa"
+ "crypto/x509"
+ "crypto/x509/pkix"
+ "encoding/pem"
+ "errors"
+ "log/slog"
+ "math/big"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+)
+
+func discardLogger() *slog.Logger {
+ return slog.New(slog.DiscardHandler)
+}
+
+// writeKeypair generates a fresh self-signed cert + RSA key and writes them
+// as PEM files in dir. Returns the paths and the cert's DER bytes so callers
+// can identify which pair was loaded.
+func writeKeypair(t *testing.T, dir, prefix string) (certPath, keyPath string, certDER []byte) {
+ t.Helper()
+
+ priv, err := rsa.GenerateKey(rand.Reader, 2048)
+ if err != nil {
+ t.Fatalf("rsa.GenerateKey: %v", err)
+ }
+
+ tmpl := &x509.Certificate{
+ SerialNumber: big.NewInt(time.Now().UnixNano()),
+ Subject: pkix.Name{CommonName: "keypairreloader-test"},
+ NotBefore: time.Now().Add(-time.Hour),
+ NotAfter: time.Now().Add(time.Hour),
+ KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
+ ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
+ BasicConstraintsValid: true,
+ DNSNames: []string{"keypairreloader-test"},
+ }
+
+ der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &priv.PublicKey, priv)
+ if err != nil {
+ t.Fatalf("x509.CreateCertificate: %v", err)
+ }
+
+ certPath = filepath.Join(dir, prefix+"cert.pem")
+ keyPath = filepath.Join(dir, prefix+"key.pem")
+
+ certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
+ keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)})
+
+ if err := os.WriteFile(certPath, certPEM, 0o600); err != nil {
+ t.Fatalf("write cert: %v", err)
+ }
+ if err := os.WriteFile(keyPath, keyPEM, 0o600); err != nil {
+ t.Fatalf("write key: %v", err)
+ }
+
+ return certPath, keyPath, der
+}
+
+func TestNewKeypairReloader(t *testing.T) {
+ dir := t.TempDir()
+ goodCert, goodKey, _ := writeKeypair(t, dir, "good-")
+
+ garbagePath := filepath.Join(dir, "garbage.pem")
+ if err := os.WriteFile(garbagePath, []byte("not a pem file"), 0o600); err != nil {
+ t.Fatalf("write garbage: %v", err)
+ }
+
+ tests := []struct {
+ name string
+ certPath string
+ keyPath string
+ wantErr error
+ wantNil bool
+ }{
+ {
+ name: "valid cert and key",
+ certPath: goodCert,
+ keyPath: goodKey,
+ },
+ {
+ name: "missing cert file",
+ certPath: filepath.Join(dir, "does-not-exist.pem"),
+ keyPath: goodKey,
+ wantErr: os.ErrNotExist,
+ wantNil: true,
+ },
+ {
+ name: "missing key file",
+ certPath: goodCert,
+ keyPath: filepath.Join(dir, "does-not-exist-key.pem"),
+ wantErr: os.ErrNotExist,
+ wantNil: true,
+ },
+ {
+ name: "cert file is garbage",
+ certPath: garbagePath,
+ keyPath: goodKey,
+ wantNil: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ kpr, err := NewKeypairReloader(tt.certPath, tt.keyPath, discardLogger())
+
+ if tt.wantErr != nil && !errors.Is(err, tt.wantErr) {
+ t.Errorf("err = %v, want errors.Is(..., %v)", err, tt.wantErr)
+ }
+ if tt.wantErr == nil && !tt.wantNil && err != nil {
+ t.Errorf("unexpected err: %v", err)
+ }
+ if tt.wantNil && kpr != nil {
+ t.Errorf("kpr = %+v, want nil", kpr)
+ }
+ if !tt.wantNil && kpr == nil {
+ t.Errorf("kpr is nil, want non-nil")
+ }
+ })
+ }
+}
+
+func TestKeypairReloader_GetCertificate(t *testing.T) {
+ tests := []struct {
+ name string
+ run func(t *testing.T)
+ }{
+ {
+ name: "returns loaded cert",
+ run: func(t *testing.T) {
+ dir := t.TempDir()
+ certPath, keyPath, wantDER := writeKeypair(t, dir, "a-")
+
+ kpr, err := NewKeypairReloader(certPath, keyPath, discardLogger())
+ if err != nil {
+ t.Fatalf("NewKeypairReloader: %v", err)
+ }
+
+ got, err := kpr.GetCertificate(nil)
+ if err != nil {
+ t.Fatalf("GetCertificate: %v", err)
+ }
+ if len(got.Certificate) == 0 || !bytes.Equal(got.Certificate[0], wantDER) {
+ t.Errorf("GetCertificate returned wrong cert bytes")
+ }
+ },
+ },
+ {
+ name: "reloads when mtime advances",
+ run: func(t *testing.T) {
+ dir := t.TempDir()
+ certPath, keyPath, _ := writeKeypair(t, dir, "a-")
+
+ kpr, err := NewKeypairReloader(certPath, keyPath, discardLogger())
+ if err != nil {
+ t.Fatalf("NewKeypairReloader: %v", err)
+ }
+
+ // Overwrite with a new pair at the same paths and bump mtime.
+ newCertPath, newKeyPath, newDER := writeKeypair(t, dir, "b-")
+ mustRename(t, newCertPath, certPath)
+ mustRename(t, newKeyPath, keyPath)
+ future := time.Now().Add(time.Hour)
+ if err := os.Chtimes(certPath, future, future); err != nil {
+ t.Fatalf("Chtimes: %v", err)
+ }
+
+ got, err := kpr.GetCertificate(nil)
+ if err != nil {
+ t.Fatalf("GetCertificate: %v", err)
+ }
+ if len(got.Certificate) == 0 || !bytes.Equal(got.Certificate[0], newDER) {
+ t.Errorf("GetCertificate did not return reloaded cert")
+ }
+ },
+ },
+ {
+ name: "does not reload when mtime unchanged",
+ run: func(t *testing.T) {
+ dir := t.TempDir()
+ certPath, keyPath, originalDER := writeKeypair(t, dir, "a-")
+
+ kpr, err := NewKeypairReloader(certPath, keyPath, discardLogger())
+ if err != nil {
+ t.Fatalf("NewKeypairReloader: %v", err)
+ }
+
+ // Overwrite the cert/key files with a *different* keypair, then
+ // rewind mtime so the reloader must not pick up the change.
+ newCertPath, newKeyPath, newDER := writeKeypair(t, dir, "b-")
+ mustRename(t, newCertPath, certPath)
+ mustRename(t, newKeyPath, keyPath)
+ past := time.Unix(0, 0)
+ if err := os.Chtimes(certPath, past, past); err != nil {
+ t.Fatalf("Chtimes: %v", err)
+ }
+
+ got, err := kpr.GetCertificate(nil)
+ if err != nil {
+ t.Fatalf("GetCertificate: %v", err)
+ }
+ if len(got.Certificate) == 0 {
+ t.Fatal("empty cert chain")
+ }
+ if bytes.Equal(got.Certificate[0], newDER) {
+ t.Errorf("GetCertificate reloaded despite unchanged mtime")
+ }
+ if !bytes.Equal(got.Certificate[0], originalDER) {
+ t.Errorf("GetCertificate did not return original cert")
+ }
+ },
+ },
+ {
+ name: "does not panic when reload fails after mtime bump",
+ run: func(t *testing.T) {
+ dir := t.TempDir()
+ certPath, keyPath, _ := writeKeypair(t, dir, "a-")
+
+ kpr, err := NewKeypairReloader(certPath, keyPath, discardLogger())
+ if err != nil {
+ t.Fatalf("NewKeypairReloader: %v", err)
+ }
+
+ // Corrupt the cert file and bump mtime. maybeReload will fail.
+ if err := os.WriteFile(certPath, []byte("not a pem file"), 0o600); err != nil {
+ t.Fatalf("corrupt cert: %v", err)
+ }
+ future := time.Now().Add(time.Hour)
+ if err := os.Chtimes(certPath, future, future); err != nil {
+ t.Fatalf("Chtimes: %v", err)
+ }
+
+ defer func() {
+ if r := recover(); r != nil {
+ t.Fatalf("GetCertificate panicked on reload failure: %v", r)
+ }
+ }()
+
+ got, err := kpr.GetCertificate(nil)
+ if err == nil {
+ t.Errorf("GetCertificate returned nil err for corrupt cert; got %+v", got)
+ }
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ tt.run(t)
+ })
+ }
+}
+
+func mustRename(t *testing.T, from, to string) {
+ t.Helper()
+ if err := os.Rename(from, to); err != nil {
+ t.Fatalf("rename %q -> %q: %v", from, to, err)
+ }
+}
diff --git a/lib/metrics/metrics.go b/lib/metrics/metrics.go
new file mode 100644
index 000000000..81e98240a
--- /dev/null
+++ b/lib/metrics/metrics.go
@@ -0,0 +1,134 @@
+package metrics
+
+import (
+ "context"
+ "crypto/tls"
+ "crypto/x509"
+ "errors"
+ "fmt"
+ "log/slog"
+ "net/http"
+ "net/http/pprof"
+ "os"
+ "time"
+
+ "github.com/TecharoHQ/anubis/internal"
+ "github.com/TecharoHQ/anubis/lib/config"
+ "github.com/prometheus/client_golang/prometheus/promhttp"
+ healthv1 "google.golang.org/grpc/health/grpc_health_v1"
+)
+
+type Server struct {
+ Config *config.Metrics
+ Log *slog.Logger
+}
+
+func (s *Server) Run(ctx context.Context, done func()) {
+ defer done()
+ lg := s.Log.With("subsystem", "metrics")
+
+ if err := s.run(ctx, lg); err != nil {
+ lg.ErrorContext(ctx, "can't serve metrics server", "err", err)
+ }
+}
+
+func (s *Server) run(ctx context.Context, lg *slog.Logger) error {
+ mux := http.NewServeMux()
+
+ if s.Config.Debug {
+ mux.HandleFunc("GET /debug/pprof/", pprof.Index)
+ mux.HandleFunc("GET /debug/pprof/cmdline", pprof.Cmdline)
+ mux.HandleFunc("GET /debug/pprof/profile", pprof.Profile)
+ mux.HandleFunc("GET /debug/pprof/symbol", pprof.Symbol)
+ mux.HandleFunc("GET /debug/pprof/trace", pprof.Trace)
+ }
+
+ mux.Handle("/metrics", promhttp.Handler())
+ mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
+ st, ok := internal.GetHealth("anubis")
+ if !ok {
+ slog.ErrorContext(r.Context(), "health service anubis does not exist, file a bug")
+ }
+
+ switch st {
+ case healthv1.HealthCheckResponse_NOT_SERVING:
+ http.Error(w, "NOT OK", http.StatusInternalServerError)
+ return
+ case healthv1.HealthCheckResponse_SERVING:
+ fmt.Fprintln(w, "OK") //nolint:errcheck
+ return
+ default:
+ http.Error(w, "UNKNOWN", http.StatusFailedDependency)
+ return
+ }
+ })
+
+ srv := http.Server{
+ Handler: mux,
+ ErrorLog: internal.GetFilteredHTTPLogger(),
+ }
+
+ ln, metricsURL, err := internal.SetupListener(s.Config.Network, s.Config.Bind, s.Config.SocketMode)
+ if err != nil {
+ return fmt.Errorf("can't setup listener: %w", err)
+ }
+
+ defer ln.Close() //nolint:errcheck
+
+ if s.Config.TLS != nil {
+ kpr, err := NewKeypairReloader(s.Config.TLS.Certificate, s.Config.TLS.Key, lg)
+ if err != nil {
+ return fmt.Errorf("can't setup keypair reloader: %w", err)
+ }
+
+ srv.TLSConfig = &tls.Config{
+ GetCertificate: kpr.GetCertificate,
+ }
+
+ if s.Config.TLS.CA != "" {
+ caCert, err := os.ReadFile(s.Config.TLS.CA)
+ if err != nil {
+ return fmt.Errorf("%w %s: %w", config.ErrCantReadFile, s.Config.TLS.CA, err)
+ }
+
+ certPool := x509.NewCertPool()
+ if !certPool.AppendCertsFromPEM(caCert) {
+ return fmt.Errorf("%w %s", config.ErrInvalidMetricsCACertificate, s.Config.TLS.CA)
+ }
+
+ srv.TLSConfig.ClientCAs = certPool
+ srv.TLSConfig.ClientAuth = tls.RequireAndVerifyClientCert
+ }
+ }
+
+ if s.Config.BasicAuth != nil {
+ var h http.Handler = mux
+ h = internal.BasicAuth("anubis-metrics", s.Config.BasicAuth.Username, s.Config.BasicAuth.Password, h)
+
+ srv.Handler = h
+ }
+
+ lg.DebugContext(ctx, "listening for metrics", "url", metricsURL)
+
+ go func() {
+ <-ctx.Done()
+ c, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ if err := srv.Shutdown(c); err != nil {
+ lg.ErrorContext(ctx, "can't shut down metrics server", "err", err)
+ }
+ }()
+
+ switch s.Config.TLS != nil {
+ case true:
+ if err := srv.ServeTLS(ln, "", ""); !errors.Is(err, http.ErrServerClosed) {
+ return fmt.Errorf("can't serve TLS metrics server: %w", err)
+ }
+ case false:
+ if err := srv.Serve(ln); !errors.Is(err, http.ErrServerClosed) {
+ return fmt.Errorf("can't serve metrics server: %w", err)
+ }
+ }
+
+ return nil
+}
diff --git a/lib/metrics/metrics_test.go b/lib/metrics/metrics_test.go
new file mode 100644
index 000000000..8d5c6cac1
--- /dev/null
+++ b/lib/metrics/metrics_test.go
@@ -0,0 +1,49 @@
+package metrics
+
+import (
+ "context"
+ "io"
+ "log/slog"
+ "net"
+ "net/http"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/TecharoHQ/anubis/lib/config"
+)
+
+func TestMetricsPprofCmdlineExposedWithoutAuthentication(t *testing.T) {
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ addr := ln.Addr().String()
+ _ = ln.Close()
+
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ done := make(chan struct{})
+ srv := &Server{
+ Config: &config.Metrics{Network: "tcp", Bind: addr},
+ Log: slog.Default(),
+ }
+ go srv.Run(ctx, func() { close(done) })
+
+ url := "http://" + addr + "/debug/pprof/cmdline"
+ var body []byte
+ resp, err := http.Get(url)
+ if err == nil {
+ body, err = io.ReadAll(resp.Body)
+ if err != nil {
+ t.Fatalf("can't read body: %v", err)
+ }
+ defer resp.Body.Close() //nolint:errcheck
+ }
+ time.Sleep(50 * time.Millisecond)
+ if strings.Contains(string(body), "metrics.test") {
+ t.Fatalf("pprof is enabled by default, cmdline process arguments: %q", string(body))
+ }
+ cancel()
+ <-done
+}
diff --git a/lib/policy/bot.go b/lib/policy/bot.go
index 479bccc3a..2cf965b13 100644
--- a/lib/policy/bot.go
+++ b/lib/policy/bot.go
@@ -1,11 +1,9 @@
package policy
import (
- "fmt"
-
"github.com/TecharoHQ/anubis/internal"
+ "github.com/TecharoHQ/anubis/lib/config"
"github.com/TecharoHQ/anubis/lib/policy/checker"
- "github.com/TecharoHQ/anubis/lib/policy/config"
)
type Bot struct {
@@ -13,9 +11,22 @@ type Bot struct {
Challenge *config.ChallengeRules
Weight *config.Weight
Name string
- Action config.Rule
+ // hash caches the result of Hash() when populated at parse time, see ParseConfig
+ hash string
+ Action config.Rule
}
+// Hash returns a stable identifier for this Bot derived from its Name
+// and Rules. When the cached value is present (populated by
+// ParseConfig) it is returned directly; otherwise the hash is
+// recomputed on demand so callers do not have to know about the cache.
func (b Bot) Hash() string {
- return internal.FastHash(fmt.Sprintf("%s::%s", b.Name, b.Rules.Hash()))
+ if b.hash != "" {
+ return b.hash
+ }
+ var rulesHash string
+ if b.Rules != nil { // defensive, should never happen
+ rulesHash = b.Rules.Hash()
+ }
+ return internal.FastHash(b.Name + "::" + rulesHash)
}
diff --git a/lib/policy/celchecker.go b/lib/policy/celchecker.go
index 2b06392a9..aab41a470 100644
--- a/lib/policy/celchecker.go
+++ b/lib/policy/celchecker.go
@@ -5,19 +5,21 @@ import (
"net/http"
"github.com/TecharoHQ/anubis/internal"
- "github.com/TecharoHQ/anubis/lib/policy/config"
+ "github.com/TecharoHQ/anubis/internal/dns"
+ "github.com/TecharoHQ/anubis/lib/config"
"github.com/TecharoHQ/anubis/lib/policy/expressions"
"github.com/google/cel-go/cel"
"github.com/google/cel-go/common/types"
)
type CELChecker struct {
- program cel.Program
- src string
+ program cel.Program
+ src string
+ subRequestMode bool
}
-func NewCELChecker(cfg *config.ExpressionOrList) (*CELChecker, error) {
- env, err := expressions.BotEnvironment()
+func NewCELChecker(cfg *config.ExpressionOrList, dnsObj *dns.Dns, subRequestMode bool) (*CELChecker, error) {
+ env, err := expressions.BotEnvironment(dnsObj)
if err != nil {
return nil, err
}
@@ -28,8 +30,9 @@ func NewCELChecker(cfg *config.ExpressionOrList) (*CELChecker, error) {
}
return &CELChecker{
- src: cfg.String(),
- program: program,
+ src: cfg.String(),
+ program: program,
+ subRequestMode: subRequestMode,
}, nil
}
@@ -38,7 +41,7 @@ func (cc *CELChecker) Hash() string {
}
func (cc *CELChecker) Check(r *http.Request) (bool, error) {
- result, _, err := cc.program.ContextEval(r.Context(), &CELRequest{r})
+ result, _, err := cc.program.ContextEval(r.Context(), &CELRequest{r, cc.subRequestMode})
if err != nil {
return false, err
@@ -53,6 +56,7 @@ func (cc *CELChecker) Check(r *http.Request) (bool, error) {
type CELRequest struct {
*http.Request
+ subRequestMode bool
}
func (cr *CELRequest) Parent() cel.Activation { return nil }
@@ -60,7 +64,7 @@ func (cr *CELRequest) Parent() cel.Activation { return nil }
func (cr *CELRequest) ResolveName(name string) (any, bool) {
switch name {
case "remoteAddress":
- return cr.Header.Get("X-Real-Ip"), true
+ return cr.Header.Get("X-Real-IP"), true
case "contentLength":
return cr.ContentLength, true
case "host":
@@ -70,6 +74,14 @@ func (cr *CELRequest) ResolveName(name string) (any, bool) {
case "userAgent":
return cr.UserAgent(), true
case "path":
+ if cr.subRequestMode {
+ if xou := cr.Header.Get("X-Original-Uri"); xou != "" {
+ return xou, true
+ }
+ if xfu := cr.Header.Get("X-Forwarded-Uri"); xfu != "" {
+ return xfu, true
+ }
+ }
return cr.URL.Path, true
case "query":
return expressions.URLValues{Values: cr.URL.Query()}, true
@@ -82,6 +94,6 @@ func (cr *CELRequest) ResolveName(name string) (any, bool) {
case "load_15m":
return expressions.Load15(), true
default:
- return nil, false
+ return expressions.ResolveBotVariable(name, cr.Request)
}
}
diff --git a/lib/policy/celchecker_test.go b/lib/policy/celchecker_test.go
new file mode 100644
index 000000000..a9cabdefb
--- /dev/null
+++ b/lib/policy/celchecker_test.go
@@ -0,0 +1,118 @@
+package policy
+
+import (
+ "net/http"
+ "testing"
+
+ "github.com/TecharoHQ/anubis/internal/dns"
+ "github.com/TecharoHQ/anubis/lib/config"
+ "github.com/TecharoHQ/anubis/lib/store/memory"
+)
+
+func newTestDNS(t *testing.T) *dns.Dns {
+ t.Helper()
+
+ ctx := t.Context()
+ memStore := memory.New(ctx)
+ cache := dns.NewDNSCache(300, 300, memStore)
+ return dns.New(ctx, cache)
+}
+
+func TestCELChecker_MapIterationWrappers(t *testing.T) {
+ cfg := &config.ExpressionOrList{
+ Expression: `headers.exists(k, k == "Accept") && query.exists(k, k == "format")`,
+ }
+
+ checker, err := NewCELChecker(cfg, newTestDNS(t), false)
+ if err != nil {
+ t.Fatalf("creating CEL checker failed: %v", err)
+ }
+
+ req, err := http.NewRequest(http.MethodGet, "https://example.com/?format=json", nil)
+ if err != nil {
+ t.Fatalf("making request failed: %v", err)
+ }
+ req.Header.Set("Accept", "application/json")
+
+ got, err := checker.Check(req)
+ if err != nil {
+ t.Fatalf("checking expression failed: %v", err)
+ }
+ if !got {
+ t.Fatal("expected expression to evaluate true")
+ }
+}
+
+func TestCELChecker_PathWithForwardedUri(t *testing.T) {
+ tests := []struct {
+ name string
+ expression string
+ xForwardedUri string
+ urlPath string
+ subRequestMode bool
+ want bool
+ }{
+ {
+ name: "path matches X-Forwarded-Uri in subrequest mode",
+ expression: `path.startsWith("/admin")`,
+ xForwardedUri: "/admin/secret",
+ urlPath: "/.within.website/x/cmd/anubis/api/check",
+ subRequestMode: true,
+ want: true,
+ },
+ {
+ name: "path with query string",
+ expression: `path.startsWith("/api/secret")`,
+ xForwardedUri: "/api/secret?token=abc",
+ urlPath: "/.within.website/x/cmd/anubis/api/check",
+ subRequestMode: true,
+ want: true,
+ },
+ {
+ name: "path falls back to url path when no header",
+ expression: `path == "/public/page"`,
+ urlPath: "/public/page",
+ subRequestMode: true,
+ want: true,
+ },
+ {
+ name: "non-subrequest mode ignores X-Forwarded-Uri",
+ expression: `path.startsWith("/admin")`,
+ xForwardedUri: "/admin/secret",
+ urlPath: "/public/page",
+ subRequestMode: false,
+ want: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ cfg := &config.ExpressionOrList{
+ Expression: tt.expression,
+ }
+ checker, err := NewCELChecker(cfg, newTestDNS(t), tt.subRequestMode)
+ if err != nil {
+ t.Fatalf("NewCELChecker() error: %v", err)
+ }
+
+ req, err := http.NewRequest(http.MethodGet, "http://example.com"+tt.urlPath, nil)
+ if err != nil {
+ t.Fatalf("http.NewRequest: %v", err)
+ }
+
+ if tt.xForwardedUri != "" {
+ req.Header.Set("X-Forwarded-Uri", tt.xForwardedUri)
+ }
+
+ got, err := checker.Check(req)
+ if err != nil {
+ t.Fatalf("Check() error: %v", err)
+ }
+
+ if got != tt.want {
+ t.Errorf("Check() = %v, want %v (subRequestMode=%v, urlPath=%q, X-Forwarded-Uri=%q)",
+ got, tt.want, tt.subRequestMode, tt.urlPath, tt.xForwardedUri)
+ }
+ })
+ }
+}
diff --git a/lib/policy/checker.go b/lib/policy/checker.go
index 4f5ad5567..1c4f2934f 100644
--- a/lib/policy/checker.go
+++ b/lib/policy/checker.go
@@ -5,6 +5,7 @@ import (
"fmt"
"net/http"
"net/netip"
+ "net/url"
"regexp"
"strings"
@@ -41,9 +42,9 @@ func NewRemoteAddrChecker(cidrs []string) (checker.Impl, error) {
}
func (rac *RemoteAddrChecker) Check(r *http.Request) (bool, error) {
- host := r.Header.Get("X-Real-Ip")
+ host := r.Header.Get("X-Real-IP")
if host == "" {
- return false, fmt.Errorf("%w: header X-Real-Ip is not set", ErrMisconfiguration)
+ return false, fmt.Errorf("%w: header X-Real-IP is not set", ErrMisconfiguration)
}
addr, err := netip.ParseAddr(host)
@@ -51,6 +52,11 @@ func (rac *RemoteAddrChecker) Check(r *http.Request) (bool, error) {
return false, fmt.Errorf("%w: %s is not an IP address: %w", ErrMisconfiguration, host, err)
}
+ // Convert IPv4-mapped IPv6 addresses to IPv4
+ if addr.Is6() && addr.Is4In6() {
+ addr = addr.Unmap()
+ }
+
return rac.prefixTable.Contains(addr), nil
}
@@ -89,23 +95,32 @@ func (hmc *HeaderMatchesChecker) Hash() string {
}
type PathChecker struct {
- regexp *regexp.Regexp
- hash string
+ regexp *regexp.Regexp
+ hash string
+ subRequestMode bool
}
-func NewPathChecker(rexStr string) (checker.Impl, error) {
+func NewPathChecker(rexStr string, subrequestMode bool) (checker.Impl, error) {
rex, err := regexp.Compile(strings.TrimSpace(rexStr))
if err != nil {
return nil, fmt.Errorf("%w: regex %s failed parse: %w", ErrMisconfiguration, rexStr, err)
}
- return &PathChecker{rex, internal.FastHash(rexStr)}, nil
+ return &PathChecker{rex, internal.FastHash(rexStr), subrequestMode}, nil
}
func (pc *PathChecker) Check(r *http.Request) (bool, error) {
- originalUrl := r.Header.Get("X-Original-URI")
- if originalUrl != "" {
- if pc.regexp.MatchString(originalUrl) {
- return true, nil
+ if pc.subRequestMode {
+ originalUrl := r.Header.Get("X-Original-Uri")
+ if originalUrl == "" {
+ originalUrl = r.Header.Get("X-Forwarded-Uri")
+ }
+ if originalUrl != "" {
+ if parsed, err := url.ParseRequestURI(originalUrl); err == nil {
+ originalUrl = parsed.Path
+ }
+ if pc.regexp.MatchString(originalUrl) {
+ return true, nil
+ }
}
}
diff --git a/lib/policy/checker/checker.go b/lib/policy/checker/checker.go
index 1ee276aac..8163b96ae 100644
--- a/lib/policy/checker/checker.go
+++ b/lib/policy/checker/checker.go
@@ -14,20 +14,34 @@ type Impl interface {
Hash() string
}
+type Func func(*http.Request) (bool, error)
+
+func (f Func) Check(r *http.Request) (bool, error) {
+ return f(r)
+}
+
+func (f Func) Hash() string { return internal.FastHash(fmt.Sprintf("%#v", f)) }
+
type List []Impl
+// Check runs each checker in the list against the request.
+// It returns true only if *all* checkers return true (AND semantics).
+// If any checker returns an error, the function returns false and the error.
func (l List) Check(r *http.Request) (bool, error) {
for _, c := range l {
ok, err := c.Check(r)
if err != nil {
- return ok, err
+ // Propagate the error; overall result is false.
+ return false, err
}
- if ok {
- return ok, nil
+ if !ok {
+ // One false means the combined result is false. Short-circuit
+ // so we don't waste time.
+ return false, err
}
}
-
- return false, nil
+ // Assume success until a checker says otherwise.
+ return true, nil
}
func (l List) Hash() string {
diff --git a/lib/policy/checker/checker_test.go b/lib/policy/checker/checker_test.go
new file mode 100644
index 000000000..22dd1cf3d
--- /dev/null
+++ b/lib/policy/checker/checker_test.go
@@ -0,0 +1,57 @@
+package checker
+
+import (
+ "errors"
+ "net/http"
+ "testing"
+)
+
+// Mock implements the Impl interface for testing.
+type Mock struct {
+ result bool
+ err error
+ hash string
+}
+
+func (m Mock) Check(r *http.Request) (bool, error) { return m.result, m.err }
+func (m Mock) Hash() string { return m.hash }
+
+func TestListCheck_AndSemantics(t *testing.T) {
+ req, _ := http.NewRequest(http.MethodGet, "http://example.com", nil)
+
+ tests := []struct {
+ name string
+ list List
+ want bool
+ wantErr bool
+ }{
+ {
+ name: "all true",
+ list: List{Mock{true, nil, "a"}, Mock{true, nil, "b"}},
+ want: true,
+ },
+ {
+ name: "one false",
+ list: List{Mock{true, nil, "a"}, Mock{false, nil, "b"}},
+ want: false,
+ },
+ {
+ name: "error propagates",
+ list: List{Mock{true, nil, "a"}, Mock{true, errors.New("boom"), "b"}},
+ want: false,
+ wantErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := tt.list.Check(req)
+ if (err != nil) != tt.wantErr {
+ t.Fatalf("unexpected error state: %v", err)
+ }
+ if got != tt.want {
+ t.Fatalf("expected %v, got %v", tt.want, got)
+ }
+ })
+ }
+}
diff --git a/lib/policy/checker_test.go b/lib/policy/checker_test.go
index 6cc3e0d72..35c32b6db 100644
--- a/lib/policy/checker_test.go
+++ b/lib/policy/checker_test.go
@@ -21,6 +21,20 @@ func TestRemoteAddrChecker(t *testing.T) {
ok: true,
err: nil,
},
+ {
+ name: "match_ipv4_in_ipv6",
+ cidrs: []string{"0.0.0.0/0"},
+ ip: "::ffff:1.1.1.1",
+ ok: true,
+ err: nil,
+ },
+ {
+ name: "match_ipv4_in_ipv6_hex",
+ cidrs: []string{"0.0.0.0/0"},
+ ip: "::ffff:101:101",
+ ok: true,
+ err: nil,
+ },
{
name: "match_ipv6",
cidrs: []string{"::/0"},
@@ -68,7 +82,7 @@ func TestRemoteAddrChecker(t *testing.T) {
}
if tt.ip != "" {
- r.Header.Add("X-Real-Ip", tt.ip)
+ r.Header.Add("X-Real-IP", tt.ip)
}
ok, err := rac.Check(r)
@@ -258,8 +272,8 @@ func TestPathChecker_XOriginalURI(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- // Create the PathChecker
- pc, err := NewPathChecker(tt.regex)
+ // Create the PathChecker in subrequest mode so X-Original-URI is honored.
+ pc, err := NewPathChecker(tt.regex, true)
if err != nil {
if !tt.expectError {
t.Fatalf("NewPathChecker() unexpected error: %v", err)
@@ -291,3 +305,224 @@ func TestPathChecker_XOriginalURI(t *testing.T) {
})
}
}
+
+// TestPathChecker_GHSA_6wcg_mqvh_fcvg is a regression test for
+// https://github.com/TecharoHQ/anubis/security/advisories/GHSA-6wcg-mqvh-fcvg.
+//
+// PR https://github.com/TecharoHQ/anubis/pull/1015 added the ability for
+// reverse proxies using Anubis in subrequest auth mode to look at the path
+// of a request as there are many rules in the wild that rely on checking
+// the path. This is how access to things like robots.txt or anything in the
+// .well-known directory is unaffected by Anubis.
+//
+// However this logic was also enabled for non-subrequest deployments of Anubis,
+// meaning that a specially crafted request could include a /.well-known/
+// path in it and then get around Anubis with little effort.
+//
+// This fix gates the logic behind a new plumbed variable named subrequestMode
+// that only fires when Anubis is running in subrequest auth mode. This
+// properly contains that workaround so that the logic does not fire in
+// most deployments.
+func TestPathChecker_GHSA_6wcg_mqvh_fcvg(t *testing.T) {
+ tests := []struct {
+ name string
+ regex string
+ urlPath string
+ xOriginalURI string
+ subRequestMode bool
+ want bool
+ }{
+ {
+ name: "default mode ignores spoofed X-Original-URI when real path matches",
+ regex: "^/admin/.*",
+ urlPath: "/admin/secret",
+ xOriginalURI: "/public/index",
+ subRequestMode: false,
+ want: true,
+ },
+ {
+ name: "default mode ignores spoofed X-Original-URI when real path does not match",
+ regex: "^/admin/.*",
+ urlPath: "/public/index",
+ xOriginalURI: "/admin/secret",
+ subRequestMode: false,
+ want: false,
+ },
+ {
+ name: "default mode without X-Original-URI matches real path",
+ regex: "^/admin/.*",
+ urlPath: "/admin/dashboard",
+ xOriginalURI: "",
+ subRequestMode: false,
+ want: true,
+ },
+ {
+ name: "subrequest mode honors X-Original-URI",
+ regex: "^/admin/.*",
+ urlPath: "/auth",
+ xOriginalURI: "/admin/secret",
+ subRequestMode: true,
+ want: true,
+ },
+ {
+ name: "subrequest mode falls back to URL.Path when X-Original-URI does not match",
+ regex: "^/admin/.*",
+ urlPath: "/admin/dashboard",
+ xOriginalURI: "/public/index",
+ subRequestMode: true,
+ want: true,
+ },
+ {
+ name: "subrequest mode with empty X-Original-URI uses URL.Path",
+ regex: "^/admin/.*",
+ urlPath: "/admin/dashboard",
+ xOriginalURI: "",
+ subRequestMode: true,
+ want: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ pc, err := NewPathChecker(tt.regex, tt.subRequestMode)
+ if err != nil {
+ t.Fatalf("NewPathChecker(%q, %v) returned error: %v", tt.regex, tt.subRequestMode, err)
+ }
+
+ req, err := http.NewRequest(http.MethodGet, "http://example.com"+tt.urlPath, nil)
+ if err != nil {
+ t.Fatalf("http.NewRequest: %v", err)
+ }
+
+ if tt.xOriginalURI != "" {
+ req.Header.Set("X-Original-Uri", tt.xOriginalURI)
+ }
+
+ got, err := pc.Check(req)
+ if err != nil {
+ t.Fatalf("Check() unexpected error: %v", err)
+ }
+
+ if got != tt.want {
+ t.Errorf("Check() = %v, want %v (subRequestMode=%v, urlPath=%q, X-Original-URI=%q)",
+ got, tt.want, tt.subRequestMode, tt.urlPath, tt.xOriginalURI)
+ }
+ })
+ }
+}
+
+func TestPathChecker_XForwardedUri(t *testing.T) {
+ tests := []struct {
+ name string
+ regex string
+ xForwardedUri string
+ xOriginalURI string
+ urlPath string
+ subRequestMode bool
+ want bool
+ }{
+ {
+ name: "X-Forwarded-Uri matches regex in subrequest mode",
+ regex: "^/admin/.*",
+ xForwardedUri: "/admin/users",
+ urlPath: "/.within.website/x/cmd/anubis/api/check",
+ subRequestMode: true,
+ want: true,
+ },
+ {
+ name: "X-Forwarded-Uri with query string",
+ regex: "^/admin/.*",
+ xForwardedUri: "/admin/users?page=1",
+ urlPath: "/.within.website/x/cmd/anubis/api/check",
+ subRequestMode: true,
+ want: true,
+ },
+ {
+ name: "X-Original-URI takes priority over X-Forwarded-Uri",
+ regex: "^/admin/.*",
+ xForwardedUri: "/public/page",
+ xOriginalURI: "/admin/users",
+ urlPath: "/.within.website/x/cmd/anubis/api/check",
+ subRequestMode: true,
+ want: true,
+ },
+ {
+ name: "falls back to X-Forwarded-Uri when no X-Original-URI",
+ regex: "^/admin/.*",
+ xForwardedUri: "/admin/dashboard",
+ urlPath: "/.within.website/x/cmd/anubis/api/check",
+ subRequestMode: true,
+ want: true,
+ },
+ {
+ name: "neither header matches, url path matches",
+ regex: "^/public/.*",
+ xForwardedUri: "/admin/users",
+ urlPath: "/public/page",
+ subRequestMode: true,
+ want: true,
+ },
+ {
+ name: "nothing matches",
+ regex: "^/admin/.*",
+ xForwardedUri: "/public/page",
+ urlPath: "/.within.website/x/cmd/anubis/api/check",
+ subRequestMode: true,
+ want: false,
+ },
+ {
+ name: "non-subrequest mode ignores X-Forwarded-Uri",
+ regex: "^/admin/.*",
+ xForwardedUri: "/admin/users",
+ urlPath: "/public/page",
+ subRequestMode: false,
+ want: false,
+ },
+ {
+ name: "non-subrequest mode uses url path",
+ regex: "^/admin/.*",
+ xForwardedUri: "/public/page",
+ urlPath: "/admin/secret",
+ subRequestMode: false,
+ want: true,
+ },
+ {
+ name: "empty X-Forwarded-Uri falls back to url path",
+ regex: "^/check$",
+ urlPath: "/check",
+ subRequestMode: true,
+ want: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ pc, err := NewPathChecker(tt.regex, tt.subRequestMode)
+ if err != nil {
+ t.Fatalf("NewPathChecker(%q, %v) returned error: %v", tt.regex, tt.subRequestMode, err)
+ }
+
+ req, err := http.NewRequest(http.MethodGet, "http://example.com"+tt.urlPath, nil)
+ if err != nil {
+ t.Fatalf("http.NewRequest: %v", err)
+ }
+
+ if tt.xForwardedUri != "" {
+ req.Header.Set("X-Forwarded-Uri", tt.xForwardedUri)
+ }
+ if tt.xOriginalURI != "" {
+ req.Header.Set("X-Original-Uri", tt.xOriginalURI)
+ }
+
+ got, err := pc.Check(req)
+ if err != nil {
+ t.Fatalf("Check() unexpected error: %v", err)
+ }
+
+ if got != tt.want {
+ t.Errorf("Check() = %v, want %v (subRequestMode=%v, urlPath=%q, X-Forwarded-Uri=%q, X-Original-URI=%q)",
+ got, tt.want, tt.subRequestMode, tt.urlPath, tt.xForwardedUri, tt.xOriginalURI)
+ }
+ })
+ }
+}
diff --git a/lib/policy/checkresult.go b/lib/policy/checkresult.go
index 31737dda5..c75ccb36a 100644
--- a/lib/policy/checkresult.go
+++ b/lib/policy/checkresult.go
@@ -3,7 +3,7 @@ package policy
import (
"log/slog"
- "github.com/TecharoHQ/anubis/lib/policy/config"
+ "github.com/TecharoHQ/anubis/lib/config"
)
type CheckResult struct {
diff --git a/lib/policy/config/testdata/bad/badregexes.yaml b/lib/policy/config/testdata/bad/badregexes.yaml
deleted file mode 100644
index 3880e4076..000000000
--- a/lib/policy/config/testdata/bad/badregexes.yaml
+++ /dev/null
@@ -1,7 +0,0 @@
-bots:
-- name: path-bad
- path_regex: "a(b"
- action: DENY
-- name: user-agent-bad
- user_agent_regex: "a(b"
- action: DENY
\ No newline at end of file
diff --git a/lib/policy/config/testdata/bad/import_and_bot.yaml b/lib/policy/config/testdata/bad/import_and_bot.yaml
deleted file mode 100644
index fdfaa43ae..000000000
--- a/lib/policy/config/testdata/bad/import_and_bot.yaml
+++ /dev/null
@@ -1,6 +0,0 @@
-bots:
-- import: (data)/bots/ai-catchall.yaml
- name: generic-browser
- user_agent_regex: >
- Mozilla|Opera
- action: CHALLENGE
\ No newline at end of file
diff --git a/lib/policy/config/testdata/bad/import_invalid_file.yaml b/lib/policy/config/testdata/bad/import_invalid_file.yaml
deleted file mode 100644
index df78c0673..000000000
--- a/lib/policy/config/testdata/bad/import_invalid_file.yaml
+++ /dev/null
@@ -1,2 +0,0 @@
-bots:
-- import: (data)/does-not-exist-fake-file.yaml
\ No newline at end of file
diff --git a/lib/policy/config/testdata/bad/invalid.json b/lib/policy/config/testdata/bad/invalid.json
deleted file mode 100644
index c5d1ff6c6..000000000
--- a/lib/policy/config/testdata/bad/invalid.json
+++ /dev/null
@@ -1,5 +0,0 @@
-{
- "bots": [
- {}
- ]
-}
\ No newline at end of file
diff --git a/lib/policy/config/testdata/bad/invalid.yaml b/lib/policy/config/testdata/bad/invalid.yaml
deleted file mode 100644
index 18625b619..000000000
--- a/lib/policy/config/testdata/bad/invalid.yaml
+++ /dev/null
@@ -1 +0,0 @@
-bots: []
\ No newline at end of file
diff --git a/lib/policy/config/testdata/bad/multiple_expression_types.yaml b/lib/policy/config/testdata/bad/multiple_expression_types.yaml
deleted file mode 100644
index f7aa54637..000000000
--- a/lib/policy/config/testdata/bad/multiple_expression_types.yaml
+++ /dev/null
@@ -1,10 +0,0 @@
-bots:
-- name: multiple-expression-types
- action: ALLOW
- expression:
- all:
- - userAgent.startsWith("git/") || userAgent.contains("libgit")
- - >
- "Git-Protocol" in headers && headers["Git-Protocol"] == "version=2"
- any:
- - userAgent.startsWith("evilbot/")
diff --git a/lib/policy/config/testdata/bad/nobots.json b/lib/policy/config/testdata/bad/nobots.json
deleted file mode 100644
index 9e26dfeeb..000000000
--- a/lib/policy/config/testdata/bad/nobots.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
\ No newline at end of file
diff --git a/lib/policy/config/testdata/bad/nobots.yaml b/lib/policy/config/testdata/bad/nobots.yaml
deleted file mode 100644
index 9e26dfeeb..000000000
--- a/lib/policy/config/testdata/bad/nobots.yaml
+++ /dev/null
@@ -1 +0,0 @@
-{}
\ No newline at end of file
diff --git a/lib/policy/config/testdata/bad/regex_ends_newline.yaml b/lib/policy/config/testdata/bad/regex_ends_newline.yaml
deleted file mode 100644
index 1f0ae85bc..000000000
--- a/lib/policy/config/testdata/bad/regex_ends_newline.yaml
+++ /dev/null
@@ -1,17 +0,0 @@
-bots:
-- name: user-agent-ends-newline
- # Subtle bug: this ends with a newline
- user_agent_regex: >
- Mozilla
- action: CHALLENGE
-- name: path-ends-newline
- # Subtle bug: this ends with a newline
- path_regex: >
- ^/evil/.*$
- action: CHALLENGE
-- name: headers-ends-newline
- # Subtle bug: this ends with a newline
- headers_regex:
- CF-Worker: >
- .*
- action: CHALLENGE
\ No newline at end of file
diff --git a/lib/policy/config/testdata/bad/status-codes-0.yaml b/lib/policy/config/testdata/bad/status-codes-0.yaml
deleted file mode 100644
index 0d08322e5..000000000
--- a/lib/policy/config/testdata/bad/status-codes-0.yaml
+++ /dev/null
@@ -1,8 +0,0 @@
-bots:
-- name: everything
- user_agent_regex: .*
- action: DENY
-
-status_codes:
- CHALLENGE: 0
- DENY: 0
\ No newline at end of file
diff --git a/lib/policy/config/testdata/good/allow_everyone.json b/lib/policy/config/testdata/good/allow_everyone.json
deleted file mode 100644
index a7e1af76e..000000000
--- a/lib/policy/config/testdata/good/allow_everyone.json
+++ /dev/null
@@ -1,12 +0,0 @@
-{
- "bots": [
- {
- "name": "everyones-invited",
- "remote_addresses": [
- "0.0.0.0/0",
- "::/0"
- ],
- "action": "ALLOW"
- }
- ]
-}
\ No newline at end of file
diff --git a/lib/policy/config/testdata/good/allow_everyone.yaml b/lib/policy/config/testdata/good/allow_everyone.yaml
deleted file mode 100644
index 5c4953450..000000000
--- a/lib/policy/config/testdata/good/allow_everyone.yaml
+++ /dev/null
@@ -1,6 +0,0 @@
-bots:
-- name: everyones-invited
- remote_addresses:
- - "0.0.0.0/0"
- - "::/0"
- action: ALLOW
\ No newline at end of file
diff --git a/lib/policy/config/testdata/good/challengemozilla.yaml b/lib/policy/config/testdata/good/challengemozilla.yaml
deleted file mode 100644
index 15922b0a0..000000000
--- a/lib/policy/config/testdata/good/challengemozilla.yaml
+++ /dev/null
@@ -1,4 +0,0 @@
-bots:
-- name: generic-browser
- user_agent_regex: Mozilla
- action: CHALLENGE
\ No newline at end of file
diff --git a/lib/policy/config/testdata/good/everything_blocked.yaml b/lib/policy/config/testdata/good/everything_blocked.yaml
deleted file mode 100644
index 323c5969b..000000000
--- a/lib/policy/config/testdata/good/everything_blocked.yaml
+++ /dev/null
@@ -1,4 +0,0 @@
-bots:
-- name: everything
- user_agent_regex: .*
- action: DENY
diff --git a/lib/policy/config/testdata/good/git_client.yaml b/lib/policy/config/testdata/good/git_client.yaml
deleted file mode 100644
index 44aa2da38..000000000
--- a/lib/policy/config/testdata/good/git_client.yaml
+++ /dev/null
@@ -1,8 +0,0 @@
-bots:
-- name: allow-git-clients
- action: ALLOW
- expression:
- all:
- - userAgent.startsWith("git/") || userAgent.contains("libgit")
- - >
- "Git-Protocol" in headers && headers["Git-Protocol"] == "version=2"
diff --git a/lib/policy/config/testdata/good/import_filesystem.yaml b/lib/policy/config/testdata/good/import_filesystem.yaml
deleted file mode 100644
index 422ccc4eb..000000000
--- a/lib/policy/config/testdata/good/import_filesystem.yaml
+++ /dev/null
@@ -1,2 +0,0 @@
-bots:
-- import: ./testdata/hack-test.yaml
\ No newline at end of file
diff --git a/lib/policy/config/testdata/good/import_keep_internet_working.yaml b/lib/policy/config/testdata/good/import_keep_internet_working.yaml
deleted file mode 100644
index 923ffe321..000000000
--- a/lib/policy/config/testdata/good/import_keep_internet_working.yaml
+++ /dev/null
@@ -1,2 +0,0 @@
-bots:
-- import: (data)/common/keep-internet-working.yaml
\ No newline at end of file
diff --git a/lib/policy/config/testdata/good/status-codes-paranoid.yaml b/lib/policy/config/testdata/good/status-codes-paranoid.yaml
deleted file mode 100644
index 89655a3dc..000000000
--- a/lib/policy/config/testdata/good/status-codes-paranoid.yaml
+++ /dev/null
@@ -1,8 +0,0 @@
-bots:
-- name: everything
- user_agent_regex: .*
- action: DENY
-
-status_codes:
- CHALLENGE: 200
- DENY: 200
\ No newline at end of file
diff --git a/lib/policy/config/testdata/good/status-codes-rfc.yaml b/lib/policy/config/testdata/good/status-codes-rfc.yaml
deleted file mode 100644
index 4e4e6d8dc..000000000
--- a/lib/policy/config/testdata/good/status-codes-rfc.yaml
+++ /dev/null
@@ -1,8 +0,0 @@
-bots:
-- name: everything
- user_agent_regex: .*
- action: DENY
-
-status_codes:
- CHALLENGE: 403
- DENY: 403
\ No newline at end of file
diff --git a/lib/policy/expressions/environment.go b/lib/policy/expressions/environment.go
index 0d5022afe..80982e2f3 100644
--- a/lib/policy/expressions/environment.go
+++ b/lib/policy/expressions/environment.go
@@ -4,6 +4,7 @@ import (
"math/rand/v2"
"strings"
+ "github.com/TecharoHQ/anubis/internal/dns"
"github.com/google/cel-go/cel"
"github.com/google/cel-go/common/types"
"github.com/google/cel-go/common/types/ref"
@@ -15,8 +16,8 @@ import (
// variables and functions that are passed into the CEL scope so that
// Anubis can fail loudly and early when something is invalid instead
// of blowing up at runtime.
-func BotEnvironment() (*cel.Env, error) {
- return New(
+func BotEnvironment(dnsObj *dns.Dns) (*cel.Env, error) {
+ opts := []cel.EnvOption{
// Variables exposed to CEL programs:
cel.Variable("remoteAddress", cel.StringType),
cel.Variable("contentLength", cel.IntType),
@@ -57,6 +58,118 @@ func BotEnvironment() (*cel.Env, error) {
),
),
+ cel.Function("reverseDNS",
+ cel.Overload("reverseDNS_string_list_string",
+ []*cel.Type{cel.StringType},
+ cel.ListType(cel.StringType),
+ cel.UnaryBinding(func(addr ref.Val) ref.Val {
+ addrStr, ok := addr.(types.String)
+ if !ok {
+ return types.ValOrErr(addr, "addr is not a string, but is %T", addr)
+ }
+
+ names, err := dnsObj.ReverseDNS(string(addrStr))
+ if err != nil {
+ return types.NewStringList(types.DefaultTypeAdapter, []string{})
+ }
+ return types.NewStringList(types.DefaultTypeAdapter, names)
+ }),
+ ),
+ ),
+
+ cel.Function("lookupHost",
+ cel.Overload("lookupHost_string_list_string",
+ []*cel.Type{cel.StringType},
+ cel.ListType(cel.StringType),
+ cel.UnaryBinding(func(host ref.Val) ref.Val {
+ hostStr, ok := host.(types.String)
+ if !ok {
+ return types.ValOrErr(host, "host is not a string, but is %T", host)
+ }
+
+ addrs, err := dnsObj.LookupHost(string(hostStr))
+ if err != nil {
+ return types.NewStringList(types.DefaultTypeAdapter, []string{})
+ }
+ return types.NewStringList(types.DefaultTypeAdapter, addrs)
+ }),
+ ),
+ ),
+
+ cel.Function("verifyFCrDNS",
+ cel.Overload("verifyFCrDNS_string_bool",
+ []*cel.Type{cel.StringType},
+ cel.BoolType,
+ cel.UnaryBinding(func(addr ref.Val) ref.Val {
+ addrStr, ok := addr.(types.String)
+ if !ok {
+ return types.ValOrErr(addr, "addr is not a string")
+ }
+ return types.Bool(dnsObj.VerifyFCrDNS(string(addrStr), nil))
+ }),
+ ),
+ cel.Overload("verifyFCrDNS_string_string_bool",
+ []*cel.Type{cel.StringType, cel.StringType},
+ cel.BoolType,
+ cel.BinaryBinding(func(addr, pattern ref.Val) ref.Val {
+ addrStr, ok := addr.(types.String)
+ if !ok {
+ return types.ValOrErr(addr, "addr is not a string")
+ }
+ patternStr, ok := pattern.(types.String)
+ if !ok {
+ return types.ValOrErr(pattern, "pattern is not a string")
+ }
+ p := string(patternStr)
+ return types.Bool(dnsObj.VerifyFCrDNS(string(addrStr), &p))
+ }),
+ ),
+ ),
+
+ // arpaReverseIP transforms ip into arpa reverse notation like this
+ // 1.2.3.4 -> 4.3.2.1
+ // 2001:db8::1 -> 1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2
+ cel.Function("arpaReverseIP",
+ cel.Overload("arpaReverseIP_string_string",
+ []*cel.Type{cel.StringType},
+ cel.StringType,
+ cel.UnaryBinding(func(addr ref.Val) ref.Val {
+ s, ok := addr.(types.String)
+ if !ok {
+ return types.ValOrErr(addr, "addr is not a string")
+ }
+
+ reversedIp, err := dnsObj.ArpaReverseIP(string(s))
+ if err != nil {
+ return types.ValOrErr(addr, "%s", err.Error())
+ }
+ return types.String(reversedIp)
+ }),
+ ),
+ ),
+
+ // regexSafe escapes a string for insertion into a regular expression
+ cel.Function("regexSafe",
+ cel.Overload("regexSafe_string_string",
+ []*cel.Type{cel.StringType},
+ cel.StringType,
+ cel.UnaryBinding(func(str ref.Val) ref.Val {
+ s, ok := str.(types.String)
+ if !ok {
+ return types.ValOrErr(str, "addr is not a string")
+ }
+
+ escapes := []string{"\\", ".", ":", "*", "?", "-", "[", "]", "(", ")", "+", "{", "}", "|", "^", "$"}
+ r := string(s)
+
+ for _, escape := range escapes {
+ r = strings.ReplaceAll(r, escape, "\\"+escape)
+ }
+ return types.String(r)
+ }),
+ ),
+ ),
+
cel.Function("segments",
cel.Overload("segments_string_list_string",
[]*cel.Type{cel.StringType},
@@ -78,7 +191,9 @@ func BotEnvironment() (*cel.Env, error) {
}),
),
),
- )
+ }
+
+ return New(append(opts, getBotVariables()...)...)
}
// NewThreshold creates a new CEL environment for threshold checking.
@@ -109,7 +224,16 @@ func New(opts ...cel.EnvOption) (*cel.Env, error) {
return types.ValOrErr(val, "value is not an integer, but is %T", val)
}
- return types.Int(rand.IntN(int(n)))
+ if n <= 0 {
+ return types.NewErr("randInt bound must be positive, got %d", int64(n))
+ }
+
+ bound := int(n)
+ if types.Int(bound) != n {
+ return types.NewErr("randInt bound %d overflows platform int", int64(n))
+ }
+
+ return types.Int(rand.IntN(bound))
}),
),
),
diff --git a/lib/policy/expressions/environment_test.go b/lib/policy/expressions/environment_test.go
index 4e7d79603..c814fd0e8 100644
--- a/lib/policy/expressions/environment_test.go
+++ b/lib/policy/expressions/environment_test.go
@@ -1,24 +1,41 @@
package expressions
import (
+ "context"
+ "errors"
+ "net"
+ "strings"
"testing"
+ "github.com/TecharoHQ/anubis/internal/dns"
+ "github.com/TecharoHQ/anubis/lib/store/memory"
+ "github.com/google/cel-go/cel"
"github.com/google/cel-go/common/types"
+ "github.com/google/cel-go/common/types/ref"
)
+// newTestDNS is a helper function to create a new Dns object with an in-memory cache for testing.
+func newTestDNS(forwardTTL int, reverseTTL int) *dns.Dns {
+ ctx := context.Background()
+ memStore := memory.New(ctx)
+ cache := dns.NewDNSCache(forwardTTL, reverseTTL, memStore)
+ return dns.New(ctx, cache)
+}
+
func TestBotEnvironment(t *testing.T) {
- env, err := BotEnvironment()
+ dnsObj := newTestDNS(300, 300)
+ env, err := BotEnvironment(dnsObj)
if err != nil {
t.Fatalf("failed to create bot environment: %v", err)
}
t.Run("missingHeader", func(t *testing.T) {
tests := []struct {
+ headers map[string]string
name string
expression string
- headers map[string]string
- expected types.Bool
description string
+ expected types.Bool
}{
{
name: "missing-header",
@@ -87,7 +104,7 @@ func TestBotEnvironment(t *testing.T) {
t.Fatalf("failed to compile expression %q: %v", tt.expression, err)
}
- result, _, err := prog.Eval(map[string]interface{}{
+ result, _, err := prog.Eval(map[string]any{
"headers": tt.headers,
})
if err != nil {
@@ -152,7 +169,7 @@ func TestBotEnvironment(t *testing.T) {
t.Fatalf("failed to compile expression %q: %v", tt.expression, err)
}
- result, _, err := prog.Eval(map[string]interface{}{
+ result, _, err := prog.Eval(map[string]any{
"path": tt.path,
})
if err != nil {
@@ -167,10 +184,10 @@ func TestBotEnvironment(t *testing.T) {
t.Run("invalid", func(t *testing.T) {
for _, tt := range []struct {
+ env any
name string
description string
expression string
- env any
wantFailCompile bool
wantFailEval bool
}{
@@ -235,6 +252,344 @@ func TestBotEnvironment(t *testing.T) {
}
})
})
+
+ t.Run("regexSafe", func(t *testing.T) {
+ tests := []struct {
+ name string
+ expression string
+ expected types.String
+ description string
+ }{
+ {
+ name: "complex-test",
+ expression: `regexSafe("^(test1|test2|)[a-z]+$")`,
+ expected: types.String("\\^\\(test1\\|test2\\|\\)\\[a\\-z\\]\\+\\$"),
+ description: "should escape all reserved regex characters",
+ },
+ {
+ name: "backslash-test",
+ expression: `regexSafe("use \\\\ for special characters escaping\t, one/\"\\\"/for/cel and one/for/regex")`,
+ expected: types.String("use \\\\\\\\ for special characters escaping\t, one/\"\\\\\"/for/cel and one/for/regex"),
+ description: "should escape double-backslashes as double-double-backslashes and ignore cel escaping and forward slashes",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ prog, err := Compile(env, tt.expression)
+ if err != nil {
+ t.Fatalf("failed to compile expression %q: %v", tt.expression, err)
+ }
+
+ result, _, err := prog.Eval(map[string]any{})
+ if err != nil {
+ t.Fatalf("failed to evaluate expression %q: %v", tt.expression, err)
+ }
+
+ if result != tt.expected {
+ t.Errorf("%s: expected %v, got %v", tt.description, tt.expected, result)
+ }
+ })
+ }
+
+ t.Run("function-compilation", func(t *testing.T) {
+ src := `regexSafe(".*")`
+ _, err := Compile(env, src)
+ if err != nil {
+ t.Fatalf("failed to compile regexSafe expression: %v", err)
+ }
+ })
+ })
+
+ t.Run("dnsFunctions", func(t *testing.T) {
+ originalDNSLookupAddr := dns.DNSLookupAddr
+ originalDNSLookupHost := dns.DNSLookupHost
+ defer func() {
+ dns.DNSLookupAddr = originalDNSLookupAddr
+ dns.DNSLookupHost = originalDNSLookupHost
+ }()
+
+ t.Run("reverseDNS", func(t *testing.T) {
+ tests := []struct {
+ name string
+ addr string
+ mockReturn []string
+ mockError error
+ expression string
+ expected ref.Val
+ description string
+ }{
+ {
+ name: "success",
+ addr: "8.8.8.8",
+ mockReturn: []string{"dns.google."},
+ expression: `reverseDNS("8.8.8.8")`,
+ expected: types.NewStringList(types.DefaultTypeAdapter, []string{"dns.google"}),
+ description: "should return domain names for an IP",
+ },
+ {
+ name: "not-found",
+ addr: "127.0.0.1",
+ mockReturn: []string{},
+ mockError: &net.DNSError{IsNotFound: true},
+ expression: `reverseDNS("127.0.0.1")`,
+ expected: types.NewStringList(types.DefaultTypeAdapter, []string{}),
+ description: "should return an empty list when not found",
+ },
+ {
+ name: "error",
+ addr: "error-addr",
+ mockError: errors.New("some dns error"),
+ expression: `reverseDNS("error-addr")`,
+ expected: types.NewStringList(types.DefaultTypeAdapter, []string{}),
+ description: "should return empty list on error",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ dns.DNSLookupAddr = func(addr string) ([]string, error) {
+ if addr == tt.addr {
+ return tt.mockReturn, tt.mockError
+ }
+ return nil, errors.New("unexpected address for reverse lookup")
+ }
+
+ prog, err := Compile(env, tt.expression)
+ if err != nil {
+ t.Fatalf("failed to compile expression %q: %v", tt.expression, err)
+ }
+
+ result, _, err := prog.Eval(map[string]any{})
+ if err != nil {
+ t.Fatalf("failed to evaluate expression %q: %v", tt.expression, err)
+ }
+ if result.Equal(tt.expected) != types.True {
+ t.Errorf("%s: expected %v, got %v", tt.description, tt.expected, result)
+ }
+ })
+ }
+ })
+
+ t.Run("lookupHost", func(t *testing.T) {
+ tests := []struct {
+ name string
+ host string
+ mockReturn []string
+ mockError error
+ expression string
+ expected ref.Val
+ description string
+ }{
+ {
+ name: "success",
+ host: "dns.google",
+ mockReturn: []string{"8.8.8.8", "8.8.4.4"},
+ expression: `lookupHost("dns.google")`,
+ expected: types.NewStringList(types.DefaultTypeAdapter, []string{"8.8.8.8", "8.8.4.4"}),
+ description: "should return IPs for a domain name",
+ },
+ {
+ name: "not-found",
+ host: "nonexistent.domain.example.com",
+ mockReturn: []string{},
+ mockError: &net.DNSError{IsNotFound: true},
+ expression: `lookupHost("nonexistent.domain.example.com")`,
+ expected: types.NewStringList(types.DefaultTypeAdapter, []string{}),
+ description: "should return an empty list when not found",
+ },
+ {
+ name: "error",
+ host: "error-host",
+ mockError: errors.New("some dns error"),
+ expression: `lookupHost("error-host")`,
+ expected: types.NewStringList(types.DefaultTypeAdapter, []string{}),
+ description: "should return empty list on error",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ dns.DNSLookupHost = func(host string) ([]string, error) {
+ if host == tt.host {
+ return tt.mockReturn, tt.mockError
+ }
+ return nil, errors.New("unexpected host for forward lookup")
+ }
+
+ prog, err := Compile(env, tt.expression)
+ if err != nil {
+ t.Fatalf("failed to compile expression %q: %v", tt.expression, err)
+ }
+
+ result, _, err := prog.Eval(map[string]any{})
+ if err != nil {
+ t.Fatalf("failed to evaluate expression %q: %v", tt.expression, err)
+ }
+ if result.Equal(tt.expected) != types.True {
+ t.Errorf("%s: expected %v, got %v", tt.description, tt.expected, result)
+ }
+ })
+ }
+ })
+
+ t.Run("verifyFCrDNS", func(t *testing.T) {
+ tests := []struct {
+ name string
+ addr string
+ reverseMockReturn []string
+ reverseMockError error
+ forwardMockReturn map[string][]string // name -> ips
+ forwardMockError map[string]error
+ expression string
+ expected types.Bool
+ description string
+ }{
+ {
+ name: "success",
+ addr: "8.8.8.8",
+ reverseMockReturn: []string{"dns.google."},
+ forwardMockReturn: map[string][]string{"dns.google": {"8.8.8.8", "8.8.4.4"}},
+ expression: `verifyFCrDNS("8.8.8.8")`,
+ expected: types.Bool(true),
+ description: "should return true for valid FCrDNS",
+ },
+ {
+ name: "failure",
+ addr: "1.2.3.4",
+ reverseMockReturn: []string{"spoofed.example.com."},
+ forwardMockReturn: map[string][]string{"spoofed.example.com": {"5.6.7.8"}},
+ expression: `verifyFCrDNS("1.2.3.4")`,
+ expected: types.Bool(false),
+ description: "should return false for invalid FCrDNS",
+ },
+ {
+ name: "reverse-lookup-fails",
+ addr: "1.1.1.1",
+ reverseMockError: errors.New("reverse lookup failed"),
+ expression: `verifyFCrDNS("1.1.1.1")`,
+ expected: types.Bool(false),
+ description: "should return false if reverse lookup fails",
+ },
+ {
+ name: "success-with-pattern",
+ addr: "8.8.8.8",
+ reverseMockReturn: []string{"dns.google."},
+ forwardMockReturn: map[string][]string{"dns.google": {"8.8.8.8"}},
+ expression: `verifyFCrDNS("8.8.8.8", "dns.google")`,
+ expected: types.Bool(true),
+ description: "should return true for valid FCrDNS with matching pattern",
+ },
+ {
+ name: "failure-with-pattern",
+ addr: "8.8.8.8",
+ reverseMockReturn: []string{"dns.google."},
+ forwardMockReturn: map[string][]string{"dns.google": {"8.8.8.8"}},
+ expression: `verifyFCrDNS("8.8.8.8", "wrong.pattern")`,
+ expected: types.Bool(false),
+ description: "should return false for FCrDNS with non-matching pattern",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ dns.DNSLookupAddr = func(addr string) ([]string, error) {
+ if addr == tt.addr {
+ return tt.reverseMockReturn, tt.reverseMockError
+ }
+ return nil, errors.New("unexpected address for reverse lookup")
+ }
+ dns.DNSLookupHost = func(host string) ([]string, error) {
+ host = strings.TrimSuffix(host, ".")
+ if ips, ok := tt.forwardMockReturn[host]; ok {
+ return ips, nil
+ }
+ if err, ok := tt.forwardMockError[host]; ok {
+ return nil, err
+ }
+ return nil, &net.DNSError{IsNotFound: true}
+ }
+
+ prog, err := Compile(env, tt.expression)
+ if err != nil {
+ t.Fatalf("failed to compile expression %q: %v", tt.expression, err)
+ }
+
+ result, _, err := prog.Eval(map[string]any{})
+ if err != nil {
+ t.Fatalf("failed to evaluate expression %q: %v", tt.expression, err)
+ }
+ if result.Equal(tt.expected) != types.True {
+ t.Errorf("%s: expected %v, got %v", tt.description, tt.expected, result)
+ }
+ })
+ }
+ })
+
+ t.Run("arpaReverseIP", func(t *testing.T) {
+ tests := []struct {
+ name string
+ expression string
+ expected types.String
+ description string
+ evalError bool
+ }{
+ {
+ name: "ipv4",
+ expression: `arpaReverseIP("1.2.3.4")`,
+ expected: types.String("4.3.2.1"),
+ description: "should correctly reverse an IPv4 address",
+ },
+ {
+ name: "ipv6",
+ expression: `arpaReverseIP("2001:db8::1")`,
+ expected: types.String("1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2"),
+ description: "should correctly reverse an IPv6 address",
+ },
+ {
+ name: "ipv6-full",
+ expression: `arpaReverseIP("2001:0db8:85a3:0000:0000:8a2e:0370:7334")`,
+ expected: types.String("4.3.3.7.0.7.3.0.e.2.a.8.0.0.0.0.0.0.0.0.3.a.5.8.8.b.d.0.1.0.0.2"),
+ description: "should correctly reverse a fully expanded IPv6 address",
+ },
+ {
+ name: "ipv6-loopback",
+ expression: `arpaReverseIP("::1")`,
+ expected: types.String("1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0"),
+ description: "should correctly reverse the IPv6 loopback address",
+ },
+ {
+ name: "invalid-ip",
+ expression: `arpaReverseIP("not-an-ip")`,
+ evalError: true,
+ description: "should error on an invalid IP",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ prog, err := Compile(env, tt.expression)
+ if err != nil {
+ t.Fatalf("failed to compile expression %q: %v", tt.expression, err)
+ }
+
+ result, _, err := prog.Eval(map[string]any{})
+ if tt.evalError {
+ if err == nil {
+ t.Errorf("%s: expected an evaluation error, but got none", tt.description)
+ }
+ return
+ }
+ if err != nil {
+ t.Fatalf("failed to evaluate expression %q: %v", tt.expression, err)
+ }
+ if result.Equal(tt.expected) != types.True {
+ t.Errorf("%s: expected %v, got %v", tt.description, tt.expected, result)
+ }
+ })
+ }
+ })
+ })
}
func TestThresholdEnvironment(t *testing.T) {
@@ -244,17 +599,17 @@ func TestThresholdEnvironment(t *testing.T) {
}
tests := []struct {
+ variables map[string]any
name string
expression string
- variables map[string]interface{}
- expected types.Bool
description string
+ expected types.Bool
shouldCompile bool
}{
{
name: "weight-variable-available",
expression: `weight > 100`,
- variables: map[string]interface{}{"weight": 150},
+ variables: map[string]any{"weight": 150},
expected: types.Bool(true),
description: "should support weight variable in expressions",
shouldCompile: true,
@@ -262,7 +617,7 @@ func TestThresholdEnvironment(t *testing.T) {
{
name: "weight-variable-false-case",
expression: `weight > 100`,
- variables: map[string]interface{}{"weight": 50},
+ variables: map[string]any{"weight": 50},
expected: types.Bool(false),
description: "should correctly evaluate weight comparisons",
shouldCompile: true,
@@ -270,7 +625,7 @@ func TestThresholdEnvironment(t *testing.T) {
{
name: "missingHeader-not-available",
expression: `missingHeader(headers, "Test")`,
- variables: map[string]interface{}{},
+ variables: map[string]any{},
expected: types.Bool(false), // not used
description: "should not have missingHeader function available",
shouldCompile: false,
@@ -313,7 +668,7 @@ func TestNewEnvironment(t *testing.T) {
tests := []struct {
name string
expression string
- variables map[string]interface{}
+ variables map[string]any
expectBool *bool // nil if we just want to test compilation or non-bool result
description string
shouldCompile bool
@@ -321,7 +676,7 @@ func TestNewEnvironment(t *testing.T) {
{
name: "randInt-function-compilation",
expression: `randInt(10)`,
- variables: map[string]interface{}{},
+ variables: map[string]any{},
expectBool: nil, // Don't check result, just compilation
description: "should compile randInt function",
shouldCompile: true,
@@ -329,15 +684,23 @@ func TestNewEnvironment(t *testing.T) {
{
name: "randInt-range-validation",
expression: `randInt(10) >= 0 && randInt(10) < 10`,
- variables: map[string]interface{}{},
+ variables: map[string]any{},
expectBool: boolPtr(true),
description: "should return values in correct range",
shouldCompile: true,
},
+ {
+ name: "randInt-large-bound",
+ expression: `randInt(2147483647) >= 0`,
+ variables: map[string]any{},
+ expectBool: boolPtr(true),
+ description: "should accept int32-max bounds without overflow",
+ shouldCompile: true,
+ },
{
name: "strings-extension-size",
expression: `"hello".size() == 5`,
- variables: map[string]interface{}{},
+ variables: map[string]any{},
expectBool: boolPtr(true),
description: "should support string extension functions",
shouldCompile: true,
@@ -345,7 +708,7 @@ func TestNewEnvironment(t *testing.T) {
{
name: "strings-extension-contains",
expression: `"hello world".contains("world")`,
- variables: map[string]interface{}{},
+ variables: map[string]any{},
expectBool: boolPtr(true),
description: "should support string contains function",
shouldCompile: true,
@@ -353,7 +716,7 @@ func TestNewEnvironment(t *testing.T) {
{
name: "strings-extension-startsWith",
expression: `"hello world".startsWith("hello")`,
- variables: map[string]interface{}{},
+ variables: map[string]any{},
expectBool: boolPtr(true),
description: "should support string startsWith function",
shouldCompile: true,
@@ -396,3 +759,65 @@ func TestNewEnvironment(t *testing.T) {
func boolPtr(b bool) *bool {
return &b
}
+
+func TestRandIntInvalidBounds(t *testing.T) {
+ env, err := New(cel.Variable("contentLength", cel.IntType))
+ if err != nil {
+ t.Fatalf("failed to create environment: %v", err)
+ }
+
+ tests := []struct {
+ name string
+ expression string
+ variables map[string]any
+ wantErrText string
+ description string
+ }{
+ {
+ name: "zero-bound-literal",
+ expression: `randInt(0)`,
+ variables: map[string]any{},
+ wantErrText: "randInt bound must be positive",
+ description: "randInt(0) should return a CEL error, not panic",
+ },
+ {
+ name: "negative-bound-literal",
+ expression: `randInt(-5)`,
+ variables: map[string]any{},
+ wantErrText: "randInt bound must be positive",
+ description: "randInt(-5) should return a CEL error, not panic",
+ },
+ {
+ name: "zero-bound-from-variable",
+ expression: `randInt(contentLength)`,
+ variables: map[string]any{"contentLength": 0},
+ wantErrText: "randInt bound must be positive",
+ description: "attacker-controlled zero contentLength should error gracefully",
+ },
+ {
+ name: "negative-bound-from-variable",
+ expression: `randInt(contentLength)`,
+ variables: map[string]any{"contentLength": -1},
+ wantErrText: "randInt bound must be positive",
+ description: "attacker-controlled negative contentLength should error gracefully",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ prog, err := Compile(env, tt.expression)
+ if err != nil {
+ t.Fatalf("failed to compile expression %q: %v", tt.expression, err)
+ }
+
+ result, _, err := prog.Eval(tt.variables)
+ if err == nil {
+ t.Fatalf("%s: expected an evaluation error, got result %v", tt.description, result)
+ }
+
+ if !strings.Contains(err.Error(), tt.wantErrText) {
+ t.Errorf("%s: expected error containing %q, got %q", tt.description, tt.wantErrText, err.Error())
+ }
+ })
+ }
+}
diff --git a/lib/policy/expressions/http_headers.go b/lib/policy/expressions/http_headers.go
index 57fcc8417..d3a0dabee 100644
--- a/lib/policy/expressions/http_headers.go
+++ b/lib/policy/expressions/http_headers.go
@@ -50,7 +50,7 @@ func (h HTTPHeaders) Find(key ref.Val) (ref.Val, bool) {
return nil, false
}
- return types.String(strings.Join(h.Header.Values(string(k)), ",")), true
+ return types.String(strings.Join(h.Values(string(k)), ",")), true
}
func (h HTTPHeaders) Contains(key ref.Val) ref.Val {
@@ -66,7 +66,9 @@ func (h HTTPHeaders) Get(key ref.Val) ref.Val {
return result
}
-func (h HTTPHeaders) Iterator() traits.Iterator { panic("TODO(Xe): implement me") }
+func (h HTTPHeaders) Iterator() traits.Iterator {
+ return newMapIterator(h.Header)
+}
func (h HTTPHeaders) IsZeroValue() bool {
return len(h.Header) == 0
diff --git a/lib/policy/expressions/loadavg.go b/lib/policy/expressions/loadavg.go
index 72b087887..a459a1859 100644
--- a/lib/policy/expressions/loadavg.go
+++ b/lib/policy/expressions/loadavg.go
@@ -10,8 +10,8 @@ import (
)
type loadAvg struct {
- lock sync.RWMutex
data *load.AvgStat
+ lock sync.RWMutex
}
func (l *loadAvg) updateThread(ctx context.Context) {
@@ -46,7 +46,7 @@ var (
)
func init() {
- globalLoadAvg = &loadAvg{}
+ globalLoadAvg = &loadAvg{data: &load.AvgStat{}}
go globalLoadAvg.updateThread(context.Background())
}
diff --git a/lib/policy/expressions/map_iterator.go b/lib/policy/expressions/map_iterator.go
new file mode 100644
index 000000000..4a86269f3
--- /dev/null
+++ b/lib/policy/expressions/map_iterator.go
@@ -0,0 +1,60 @@
+package expressions
+
+import (
+ "errors"
+ "maps"
+ "reflect"
+ "slices"
+
+ "github.com/google/cel-go/common/types"
+ "github.com/google/cel-go/common/types/ref"
+ "github.com/google/cel-go/common/types/traits"
+)
+
+var ErrNotImplemented = errors.New("expressions: not implemented")
+
+type stringSliceIterator struct {
+ keys []string
+ idx int
+}
+
+func (s *stringSliceIterator) Value() any {
+ return s
+}
+
+func (s *stringSliceIterator) ConvertToNative(typeDesc reflect.Type) (any, error) {
+ return nil, ErrNotImplemented
+}
+
+func (s *stringSliceIterator) ConvertToType(typeValue ref.Type) ref.Val {
+ return types.NewErr("can't convert from %q to %q", types.IteratorType, typeValue)
+}
+
+func (s *stringSliceIterator) Equal(other ref.Val) ref.Val {
+ return types.NewErr("can't compare %q to %q", types.IteratorType, other.Type())
+}
+
+func (s *stringSliceIterator) Type() ref.Type {
+ return types.IteratorType
+}
+
+func (s *stringSliceIterator) HasNext() ref.Val {
+ return types.Bool(s.idx < len(s.keys))
+}
+
+func (s *stringSliceIterator) Next() ref.Val {
+ if s.HasNext() != types.True {
+ return nil
+ }
+
+ val := s.keys[s.idx]
+ s.idx++
+ return types.String(val)
+}
+
+func newMapIterator(m map[string][]string) traits.Iterator {
+ return &stringSliceIterator{
+ keys: slices.Collect(maps.Keys(m)),
+ idx: 0,
+ }
+}
diff --git a/lib/policy/expressions/registry.go b/lib/policy/expressions/registry.go
new file mode 100644
index 000000000..bbf29aac4
--- /dev/null
+++ b/lib/policy/expressions/registry.go
@@ -0,0 +1,86 @@
+package expressions
+
+import (
+ "log"
+ "net/http"
+
+ "github.com/google/cel-go/cel"
+)
+
+// BotVariableResolver returns the value of a registered bot variable for
+// a request. This is how extensions can add additional variables to bot
+// contexts.
+type BotVariableResolver func(r *http.Request) (any, bool)
+
+type botVariable struct {
+ name string
+ typ *cel.Type
+ resolve BotVariableResolver
+}
+
+var (
+ botVariables = map[string]botVariable{}
+
+ // builtinBotVariables is the list of variables that the CEL environment
+ // already defines. This is here so that extensions can't add a variable
+ // that already exists.
+ builtinBotVariables = map[string]struct{}{
+ "remoteAddress": {},
+ "contentLength": {},
+ "host": {},
+ "method": {},
+ "userAgent": {},
+ "path": {},
+ "query": {},
+ "headers": {},
+ "load_1m": {},
+ "load_5m": {},
+ "load_15m": {},
+ }
+)
+
+// RegisterBotVariable adds a variable to the bot rule environment.
+//
+// This function assumes it's being called from an init() hook at the beginning
+// of program execution. Any attempt to call it after Anubis has started can
+// and will cause undefined behaviour up to and including a crash.
+//
+// If the bot variable being registered conflicts with a built-in bot variable,
+// this function will panic and take out the program early in its lifecycle. The
+// hope is that this will fire when tests are run, which should prevent errant
+// unfeatures from being shipped.
+func RegisterBotVariable(name string, typ *cel.Type, resolve BotVariableResolver) {
+ if _, ok := builtinBotVariables[name]; ok {
+ log.Panicf("cannot register bot variable %q, it's built in", name)
+ }
+
+ if _, ok := botVariables[name]; ok {
+ log.Panicf("cannot register bot variable %q, it's already registered", name)
+ }
+
+ botVariables[name] = botVariable{name: name, typ: typ, resolve: resolve}
+}
+
+// ResolveBotVariable resolves a bot variable that was registered with
+// [RegisterBotVariable], calling the arbitrary [BotVariableResolver] and
+// returning the values to the caller.
+func ResolveBotVariable(name string, r *http.Request) (any, bool) {
+ v, ok := botVariables[name]
+ if !ok {
+ return nil, false
+ }
+
+ return v.resolve(r)
+}
+
+// getBotVariables returns the bot variables as a []cel.EnvOption for building
+// CEL checkers.
+func getBotVariables() []cel.EnvOption {
+ result := make([]cel.EnvOption, 0, len(botVariables))
+
+ for _, v := range botVariables {
+ result = append(result, cel.Variable(v.name, v.typ))
+ }
+
+ return result
+}
diff --git a/lib/policy/expressions/registry_test.go b/lib/policy/expressions/registry_test.go
new file mode 100644
index 000000000..58e44ba85
--- /dev/null
+++ b/lib/policy/expressions/registry_test.go
@@ -0,0 +1,104 @@
+package expressions
+
+import (
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "strconv"
+ "testing"
+
+ "github.com/google/cel-go/cel"
+ "github.com/google/cel-go/common/types"
+)
+
+type registryResolver struct{ r *http.Request }
+
+func (rr registryResolver) ResolveName(name string) (any, bool) {
+ return ResolveBotVariable(name, rr.r)
+}
+
+func (ra registryResolver) Parent() cel.Activation { return nil }
+
+func TestRegisterBotVariable(t *testing.T) {
+ RegisterBotVariable("registryTestScore", cel.IntType, func(r *http.Request) (any, bool) {
+ sc := r.Header.Get("X-Score")
+ if sc == "" {
+ return nil, false
+ }
+
+ result, err := strconv.Atoi(sc)
+ if err != nil {
+ panic("score is not a number")
+ }
+
+ return result, true
+ })
+
+ env, err := BotEnvironment(newTestDNS(300, 300))
+ if err != nil {
+ t.Fatalf("can't create bot environment: %v", err)
+ }
+
+ t.Run("variable exists in compile", func(t *testing.T) {
+ if _, err := Compile(env, `registryTestScore >= 3`); err != nil {
+ t.Fatalf("cannot compile environment with added variable (is it not an int?): %v", err)
+ }
+ })
+
+ t.Run("variable is not a string", func(t *testing.T) {
+ if _, err := Compile(env, `registryTestScore == "taco bell"`); err == nil {
+ t.Fatalf("cannot compile program treating this as a string (is the type wrong?)")
+ }
+ })
+
+ for _, tt := range []struct {
+ name string
+ score int
+ want bool
+ wantErr bool
+ }{
+ {"matches", 3, true, false},
+ {"does not match", 2, false, false},
+ {"no value", 0, false, true},
+ } {
+ t.Run("case/"+tt.name, func(t *testing.T) {
+ const src = `registryTestScore >= 3`
+ prog, err := Compile(env, src)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ r := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
+ if tt.score != 0 {
+ r.Header.Set("X-Score", fmt.Sprint(tt.score))
+ }
+
+ got, _, err := prog.ContextEval(t.Context(), registryResolver{r})
+ if (err != nil) != tt.wantErr {
+ t.Errorf("wanted error: %v, got error: %v", tt.wantErr, err)
+ }
+ if tt.wantErr {
+ return
+ }
+
+ if got != types.Bool(tt.want) {
+ t.Errorf("wanted %v, got: %v", tt.want, got)
+ }
+ })
+ }
+
+ t.Run("duplicate reg panics", func(t *testing.T) {
+ defer func() {
+ if recover() == nil {
+ t.Error("second registration did not panic")
+ }
+ }()
+ RegisterBotVariable("registryTestScore", cel.IntType, nil)
+ })
+
+ t.Run("unknown variable does not resolve", func(t *testing.T) {
+ if _, ok := ResolveBotVariable("registryTestMissing", httptest.NewRequest(http.MethodGet, "/", nil)); ok {
+ t.Error("unregistered variable resolved")
+ }
+ })
+}
diff --git a/lib/policy/expressions/url_values.go b/lib/policy/expressions/url_values.go
index a4c635193..21d25d5b4 100644
--- a/lib/policy/expressions/url_values.go
+++ b/lib/policy/expressions/url_values.go
@@ -1,7 +1,6 @@
package expressions
import (
- "errors"
"net/url"
"reflect"
"strings"
@@ -11,8 +10,6 @@ import (
"github.com/google/cel-go/common/types/traits"
)
-var ErrNotImplemented = errors.New("expressions: not implemented")
-
// URLValues is a type wrapper to expose url.Values into CEL programs.
type URLValues struct {
url.Values
@@ -69,7 +66,9 @@ func (u URLValues) Get(key ref.Val) ref.Val {
return result
}
-func (u URLValues) Iterator() traits.Iterator { panic("TODO(Xe): implement me") }
+func (u URLValues) Iterator() traits.Iterator {
+ return newMapIterator(u.Values)
+}
func (u URLValues) IsZeroValue() bool {
return len(u.Values) == 0
diff --git a/lib/policy/policy.go b/lib/policy/policy.go
index 5493d8dd2..ab6e15608 100644
--- a/lib/policy/policy.go
+++ b/lib/policy/policy.go
@@ -6,39 +6,65 @@ import (
"fmt"
"io"
"log/slog"
+ "os"
+ "strings"
"sync/atomic"
+ "github.com/TecharoHQ/anubis/internal"
+ "github.com/TecharoHQ/anubis/internal/dns"
+ "github.com/TecharoHQ/anubis/lib/config"
"github.com/TecharoHQ/anubis/lib/policy/checker"
- "github.com/TecharoHQ/anubis/lib/policy/config"
"github.com/TecharoHQ/anubis/lib/store"
"github.com/TecharoHQ/anubis/lib/thoth"
+ "github.com/fahedouch/go-logrotate"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
_ "github.com/TecharoHQ/anubis/lib/store/all"
)
+// LogRotateTimeFormat is the timestamp a rotated log file is named after.
+//
+// It is RFC 3339 with the colons taken out. Windows reads a colon in a path
+// as the separator before an alternate data stream, so a name containing one
+// cannot be created: rotation would fail with ERROR_INVALID_NAME every time,
+// and because the rotating logger has already closed the file by then, every
+// subsequent line would cost another close, open and doomed rename while the
+// log grew past its limit without bound.
+//
+// The other characters Windows forbids in a name are < > " / \ | ? *, none of
+// which a time format produces. A rotated log file is a backup rather than an
+// interchange format, so nothing parses this.
+const LogRotateTimeFormat = "2006-01-02T15-04-05Z0700"
+
var (
Applications = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "anubis_policy_results",
Help: "The results of each policy rule",
- }, []string{"rule", "action"})
+ }, []string{"rule", "action", "asn", "asn_description"})
ErrChallengeRuleHasWrongAlgorithm = errors.New("config.Bot.ChallengeRules: algorithm is invalid")
warnedAboutThresholds = &atomic.Bool{}
)
type ParsedConfig struct {
- orig *config.Config
-
- Bots []Bot
- Thresholds []*Threshold
- DNSBL bool
+ Store store.Interface
+ orig *config.Config
Impressum *config.Impressum
+ Honeypot *config.Honeypot
OpenGraph config.OpenGraph
- DefaultDifficulty int
+ Bots []Bot
+ Thresholds []*Threshold
StatusCodes config.StatusCodes
- Store store.Interface
+ DefaultDifficulty int
+ DNSBL bool
+ DnsCache *dns.DnsCache
+ Dns *dns.Dns
+ Logger *slog.Logger
+ Metrics *config.Metrics
+ ThothClient *thoth.Client
+ LogASN bool
+ NeedJA4H bool
}
func newParsedConfig(orig *config.Config) *ParsedConfig {
@@ -46,10 +72,11 @@ func newParsedConfig(orig *config.Config) *ParsedConfig {
orig: orig,
OpenGraph: orig.OpenGraph,
StatusCodes: orig.StatusCodes,
+ Metrics: orig.Metrics,
}
}
-func ParseConfig(ctx context.Context, fin io.Reader, fname string, defaultDifficulty int) (*ParsedConfig, error) {
+func ParseConfig(ctx context.Context, fin io.Reader, fname string, defaultDifficulty int, logLevel string, subrequestMode bool) (*ParsedConfig, error) {
c, err := config.Load(fin, fname)
if err != nil {
return nil, err
@@ -61,6 +88,53 @@ func ParseConfig(ctx context.Context, fin io.Reader, fname string, defaultDiffic
result := newParsedConfig(c)
result.DefaultDifficulty = defaultDifficulty
+ result.LogASN = c.Logging.LogASN
+ if hasThothClient {
+ result.ThothClient = tc
+ }
+
+ if c.Logging.Level != nil {
+ logLevel = c.Logging.Level.String()
+ }
+
+ switch c.Logging.Sink {
+ case config.LogSinkStdio:
+ result.Logger = internal.InitSlog(logLevel, os.Stderr)
+ case config.LogSinkFile:
+ out := &logrotate.Logger{
+ Filename: c.Logging.Parameters.Filename,
+ FilenameTimeFormat: LogRotateTimeFormat,
+ MaxBytes: c.Logging.Parameters.MaxBytes,
+ MaxAge: c.Logging.Parameters.MaxAge,
+ MaxBackups: c.Logging.Parameters.MaxBackups,
+ LocalTime: c.Logging.Parameters.UseLocalTime,
+ Compress: c.Logging.Parameters.Compress,
+ }
+
+ result.Logger = internal.InitSlog(logLevel, out)
+ }
+
+ lg := result.Logger.With("at", "config-validate")
+
+ if result.LogASN && !hasThothClient {
+ lg.WarnContext(ctx, "logging.asn is enabled but no Thoth client is configured; ASN logging and metrics will be skipped. Please read https://anubis.techaro.lol/docs/admin/thoth for more information")
+ }
+
+ stFac, ok := store.Get(c.Store.Backend)
+ switch ok {
+ case true:
+ store, err := stFac.Build(ctx, c.Store.Parameters)
+ if err != nil {
+ validationErrs = append(validationErrs, err)
+ } else {
+ result.Store = store
+ }
+ case false:
+ validationErrs = append(validationErrs, config.ErrUnknownStoreBackend)
+ }
+
+ result.DnsCache = dns.NewDNSCache(result.orig.DNSTTL.Forward, result.orig.DNSTTL.Reverse, result.Store)
+ result.Dns = dns.New(ctx, result.DnsCache)
for _, b := range c.Bots {
if berr := b.Valid(); berr != nil {
@@ -94,7 +168,7 @@ func ParseConfig(ctx context.Context, fin io.Reader, fname string, defaultDiffic
}
if b.PathRegex != nil {
- c, err := NewPathChecker(*b.PathRegex)
+ c, err := NewPathChecker(*b.PathRegex, subrequestMode)
if err != nil {
validationErrs = append(validationErrs, fmt.Errorf("while processing rule %s path regex: %w", b.Name, err))
} else {
@@ -112,7 +186,7 @@ func ParseConfig(ctx context.Context, fin io.Reader, fname string, defaultDiffic
}
if b.Expression != nil {
- c, err := NewCELChecker(b.Expression)
+ c, err := NewCELChecker(b.Expression, result.Dns, subrequestMode)
if err != nil {
validationErrs = append(validationErrs, fmt.Errorf("while processing rule %s expressions: %w", b.Name, err))
} else {
@@ -122,7 +196,7 @@ func ParseConfig(ctx context.Context, fin io.Reader, fname string, defaultDiffic
if b.ASNs != nil {
if !hasThothClient {
- slog.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)
+ lg.WarnContext(ctx, "You have specified a Thoth specific check but you have no Thoth client configured. Please read https://anubis.techaro.lol/docs/admin/thoth for more information", "check", "asn", "settings", b.ASNs)
continue
}
@@ -131,7 +205,7 @@ func ParseConfig(ctx context.Context, fin io.Reader, fname string, defaultDiffic
if b.GeoIP != nil {
if !hasThothClient {
- slog.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)
+ lg.WarnContext(ctx, "You have specified a Thoth specific check but you have no Thoth client configured. Please read https://anubis.techaro.lol/docs/admin/thoth for more information", "check", "geoip", "settings", b.GeoIP)
continue
}
@@ -141,7 +215,6 @@ func ParseConfig(ctx context.Context, fin io.Reader, fname string, defaultDiffic
if b.Challenge == nil {
parsedBot.Challenge = &config.ChallengeRules{
Difficulty: defaultDifficulty,
- ReportAs: defaultDifficulty,
Algorithm: "fast",
}
} else {
@@ -151,7 +224,7 @@ func ParseConfig(ctx context.Context, fin io.Reader, fname string, defaultDiffic
}
if parsedBot.Challenge.Algorithm == "slow" {
- slog.Warn("use of deprecated algorithm \"slow\" detected, please update this to \"fast\" when possible", "name", parsedBot.Name)
+ lg.WarnContext(ctx, "use of deprecated algorithm \"slow\" detected, please update this to \"fast\" when possible", "name", parsedBot.Name)
}
}
@@ -160,25 +233,30 @@ func ParseConfig(ctx context.Context, fin io.Reader, fname string, defaultDiffic
}
result.Impressum = c.Impressum
+ result.Honeypot = c.Honeypot
parsedBot.Rules = cl
+ parsedBot.hash = parsedBot.Hash()
result.Bots = append(result.Bots, parsedBot)
}
for _, t := range c.Thresholds {
if t.Challenge != nil && t.Challenge.Algorithm == "slow" {
- slog.Warn("use of deprecated algorithm \"slow\" detected, please update this to \"fast\" when possible", "name", t.Name)
+ lg.WarnContext(ctx, "use of deprecated algorithm \"slow\" detected, please update this to \"fast\" when possible", "name", t.Name)
+ }
+
+ if t.Challenge != nil && t.Challenge.ReportAs != 0 {
+ lg.WarnContext(ctx, "use of deprecated report_as setting detected, please remove this from your policy file when possible", "name", t.Name)
}
if t.Name == "legacy-anubis-behaviour" && t.Expression.String() == "true" {
if !warnedAboutThresholds.Load() {
- slog.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/")
+ lg.WarnContext(ctx, "configuration file does not contain thresholds, see docs for details on how to upgrade", "fname", fname, "docs_url", "https://anubis.techaro.lol/docs/admin/configuration/thresholds/")
warnedAboutThresholds.Store(true)
}
t.Challenge.Difficulty = defaultDifficulty
- t.Challenge.ReportAs = defaultDifficulty
}
threshold, err := ParsedThresholdFromConfig(t)
@@ -190,24 +268,36 @@ func ParseConfig(ctx context.Context, fin io.Reader, fname string, defaultDiffic
result.Thresholds = append(result.Thresholds, threshold)
}
- stFac, ok := store.Get(c.Store.Backend)
- switch ok {
- case true:
- store, err := stFac.Build(ctx, c.Store.Parameters)
- if err != nil {
- validationErrs = append(validationErrs, err)
- } else {
- result.Store = store
- }
- case false:
- validationErrs = append(validationErrs, config.ErrUnknownStoreBackend)
- }
-
if len(validationErrs) > 0 {
return nil, fmt.Errorf("errors validating policy config JSON %s: %w", fname, errors.Join(validationErrs...))
}
result.DNSBL = c.DNSBL
+ result.NeedJA4H = configReferencesJA4H(c.Bots)
return result, nil
}
+
+// configReferencesJA4H reports whether any bot rule references the JA4H
+// fingerprint header, either through a headers_regex key or a CEL expression.
+// Computing the JA4H fingerprint for every request is relatively expensive, so
+// when no rule needs it the middleware that adds the header can be skipped
+// entirely. Threshold expressions can't access request headers, so only bot
+// rules are considered.
+func configReferencesJA4H(bots []config.BotConfig) bool {
+ needle := strings.ToLower(internal.JA4HHeaderName)
+
+ for _, b := range bots {
+ for key := range b.HeadersRegex {
+ if strings.EqualFold(key, internal.JA4HHeaderName) {
+ return true
+ }
+ }
+
+ if b.Expression != nil && strings.Contains(strings.ToLower(b.Expression.String()), needle) {
+ return true
+ }
+ }
+
+ return false
+}
diff --git a/lib/policy/policy_test.go b/lib/policy/policy_test.go
index 103728ac0..d339679a2 100644
--- a/lib/policy/policy_test.go
+++ b/lib/policy/policy_test.go
@@ -1,12 +1,16 @@
package policy
import (
+ "net/http"
+ "net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/TecharoHQ/anubis"
"github.com/TecharoHQ/anubis/data"
+ "github.com/TecharoHQ/anubis/internal"
+ "github.com/TecharoHQ/anubis/lib/config"
"github.com/TecharoHQ/anubis/lib/thoth/thothmock"
)
@@ -17,44 +21,43 @@ func TestDefaultPolicyMustParse(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- defer fin.Close()
+ defer fin.Close() //nolint:errcheck
- if _, err := ParseConfig(ctx, fin, "botPolicies.yaml", anubis.DefaultDifficulty); err != nil {
+ if _, err := ParseConfig(ctx, fin, "botPolicies.yaml", anubis.DefaultDifficulty, "info", false); err != nil {
t.Fatalf("can't parse config: %v", err)
}
}
func TestGoodConfigs(t *testing.T) {
- finfos, err := os.ReadDir("config/testdata/good")
+ finfos, err := os.ReadDir("../config/testdata/good")
if err != nil {
t.Fatal(err)
}
for _, st := range finfos {
- st := st
t.Run(st.Name(), func(t *testing.T) {
t.Run("with-thoth", func(t *testing.T) {
- fin, err := os.Open(filepath.Join("config", "testdata", "good", st.Name()))
+ fin, err := os.Open(filepath.Join("..", "config", "testdata", "good", st.Name()))
if err != nil {
t.Fatal(err)
}
- defer fin.Close()
+ defer fin.Close() //nolint:errcheck
ctx := thothmock.WithMockThoth(t)
- if _, err := ParseConfig(ctx, fin, fin.Name(), anubis.DefaultDifficulty); err != nil {
+ if _, err := ParseConfig(ctx, fin, fin.Name(), anubis.DefaultDifficulty, "info", false); err != nil {
t.Fatal(err)
}
})
t.Run("without-thoth", func(t *testing.T) {
- fin, err := os.Open(filepath.Join("config", "testdata", "good", st.Name()))
+ fin, err := os.Open(filepath.Join("..", "config", "testdata", "good", st.Name()))
if err != nil {
t.Fatal(err)
}
- defer fin.Close()
+ defer fin.Close() //nolint:errcheck
- if _, err := ParseConfig(t.Context(), fin, fin.Name(), anubis.DefaultDifficulty); err != nil {
+ if _, err := ParseConfig(t.Context(), fin, fin.Name(), anubis.DefaultDifficulty, "info", false); err != nil {
t.Fatal(err)
}
})
@@ -65,21 +68,20 @@ func TestGoodConfigs(t *testing.T) {
func TestBadConfigs(t *testing.T) {
ctx := thothmock.WithMockThoth(t)
- finfos, err := os.ReadDir("config/testdata/bad")
+ finfos, err := os.ReadDir("../config/testdata/bad")
if err != nil {
t.Fatal(err)
}
for _, st := range finfos {
- st := st
t.Run(st.Name(), func(t *testing.T) {
- fin, err := os.Open(filepath.Join("config", "testdata", "bad", st.Name()))
+ fin, err := os.Open(filepath.Join("..", "config", "testdata", "bad", st.Name()))
if err != nil {
t.Fatal(err)
}
- defer fin.Close()
+ defer fin.Close() //nolint:errcheck
- if _, err := ParseConfig(ctx, fin, fin.Name(), anubis.DefaultDifficulty); err == nil {
+ if _, err := ParseConfig(ctx, fin, fin.Name(), anubis.DefaultDifficulty, "info", false); err == nil {
t.Fatal(err)
} else {
t.Log(err)
@@ -87,3 +89,93 @@ func TestBadConfigs(t *testing.T) {
})
}
}
+
+func TestPathCheckerStripsForwardedURIQuery(t *testing.T) {
+ checker, err := NewPathChecker("^/admin$", true)
+ if err != nil {
+ t.Fatal(err)
+ }
+ req := httptest.NewRequest(http.MethodGet, "https://anubis.local/.within.website/x/cmd/anubis/api/check", nil)
+ req.Header.Set("X-Forwarded-Uri", "/admin?x=1")
+ matched, err := checker.Check(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !matched {
+ t.Fatalf("expected exact path checker to match forwarded URI when query string is appended")
+ }
+ req.Header.Set("X-Forwarded-Uri", "/admin")
+ matched, err = checker.Check(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !matched {
+ t.Fatalf("expected exact path checker to match forwarded URI without query string")
+ }
+}
+
+func TestConfigReferencesJA4H(t *testing.T) {
+ for _, tt := range []struct {
+ name string
+ bots []config.BotConfig
+ want bool
+ }{
+ {
+ name: "no bots",
+ bots: nil,
+ want: false,
+ },
+ {
+ name: "unrelated rules",
+ bots: []config.BotConfig{
+ {Name: "ua", HeadersRegex: map[string]string{"User-Agent": "curl"}},
+ {Name: "expr", Expression: &config.ExpressionOrList{Expression: `userAgent.contains("bot")`}},
+ },
+ want: false,
+ },
+ {
+ name: "headers_regex exact match",
+ bots: []config.BotConfig{
+ {Name: "ja4h", HeadersRegex: map[string]string{internal.JA4HHeaderName: "t13d.*"}},
+ },
+ want: true,
+ },
+ {
+ name: "headers_regex case-insensitive match",
+ bots: []config.BotConfig{
+ {Name: "ja4h", HeadersRegex: map[string]string{"x-http-fingerprint-ja4h": ".*"}},
+ },
+ want: true,
+ },
+ {
+ name: "expression references header",
+ bots: []config.BotConfig{
+ {Name: "ja4h", Expression: &config.ExpressionOrList{Expression: `headers["X-Http-Fingerprint-Ja4h"] == "t13d"`}},
+ },
+ want: true,
+ },
+ {
+ name: "expression list references header",
+ bots: []config.BotConfig{
+ {Name: "ja4h", Expression: &config.ExpressionOrList{Any: []string{
+ `userAgent.contains("bot")`,
+ `headers["X-Http-Fingerprint-Ja4h"] == "t13d"`,
+ }}},
+ },
+ want: true,
+ },
+ {
+ name: "expression missingHeader references header",
+ bots: []config.BotConfig{
+ {Name: "ja4h", Expression: &config.ExpressionOrList{Expression: `!missingHeader(headers, "X-Http-Fingerprint-Ja4h")`}},
+ },
+ want: true,
+ },
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := configReferencesJA4H(tt.bots); got != tt.want {
+ t.Errorf("configReferencesJA4H() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
diff --git a/lib/policy/testdata/hack-test.json b/lib/policy/testdata/hack-test.json
index 652dcd879..a173bb18b 100644
--- a/lib/policy/testdata/hack-test.json
+++ b/lib/policy/testdata/hack-test.json
@@ -2,8 +2,6 @@
{
"name": "ipv6-ula",
"action": "ALLOW",
- "remote_addresses": [
- "fc00::/7"
- ]
+ "remote_addresses": ["fc00::/7"]
}
-]
\ No newline at end of file
+]
diff --git a/lib/policy/testdata/hack-test.yaml b/lib/policy/testdata/hack-test.yaml
index cd4d7d054..29263e969 100644
--- a/lib/policy/testdata/hack-test.yaml
+++ b/lib/policy/testdata/hack-test.yaml
@@ -1,3 +1,3 @@
- name: well-known
path_regex: ^/.well-known/.*$
- action: ALLOW
\ No newline at end of file
+ action: ALLOW
diff --git a/lib/policy/thresholds.go b/lib/policy/thresholds.go
index 1f77f6311..7e79ac51f 100644
--- a/lib/policy/thresholds.go
+++ b/lib/policy/thresholds.go
@@ -1,7 +1,7 @@
package policy
import (
- "github.com/TecharoHQ/anubis/lib/policy/config"
+ "github.com/TecharoHQ/anubis/lib/config"
"github.com/TecharoHQ/anubis/lib/policy/expressions"
"github.com/google/cel-go/cel"
)
diff --git a/lib/redirect_security_test.go b/lib/redirect_security_test.go
new file mode 100644
index 000000000..bdab4d9fe
--- /dev/null
+++ b/lib/redirect_security_test.go
@@ -0,0 +1,388 @@
+package lib
+
+// URL-encoded separators adjacent to the test hostname produce these tokens.
+// cspell:ignore Fevil Cevil
+
+import (
+ "errors"
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+
+ "github.com/TecharoHQ/anubis"
+ "github.com/TecharoHQ/anubis/lib/policy"
+)
+
+func TestRedirectSecurity(t *testing.T) {
+ tests := []struct {
+ reqHost string
+ testType string // "constructRedirectURL", "serveHTTPNext", "renderIndex"
+
+ // For constructRedirectURL tests
+ xForwardedProto string
+ xForwardedHost string
+ xForwardedUri string
+
+ // For serveHTTPNext tests
+ redirParam string
+ name string
+
+ errorContains string
+ expectedStatus int
+
+ // For renderIndex tests
+ returnHTTPStatusOnly bool
+ shouldError bool
+ shouldNotRedirect bool
+ shouldBlock bool
+ }{
+ // constructRedirectURL tests - X-Forwarded-Proto validation
+ {
+ name: "constructRedirectURL: javascript protocol should be rejected",
+ testType: "constructRedirectURL",
+ xForwardedProto: "javascript",
+ xForwardedHost: "example.com",
+ xForwardedUri: "alert(1)",
+ shouldError: true,
+ errorContains: "invalid",
+ },
+ {
+ name: "constructRedirectURL: data protocol should be rejected",
+ testType: "constructRedirectURL",
+ xForwardedProto: "data",
+ xForwardedHost: "text/html",
+ xForwardedUri: ",",
+ shouldError: true,
+ errorContains: "invalid",
+ },
+ {
+ name: "constructRedirectURL: file protocol should be rejected",
+ testType: "constructRedirectURL",
+ xForwardedProto: "file",
+ xForwardedHost: "",
+ xForwardedUri: "/etc/passwd",
+ shouldError: true,
+ errorContains: "invalid",
+ },
+ {
+ name: "constructRedirectURL: ftp protocol should be rejected",
+ testType: "constructRedirectURL",
+ xForwardedProto: "ftp",
+ xForwardedHost: "example.com",
+ xForwardedUri: "/file.txt",
+ shouldError: true,
+ errorContains: "invalid",
+ },
+ {
+ name: "constructRedirectURL: https protocol should be allowed",
+ testType: "constructRedirectURL",
+ xForwardedProto: "https",
+ xForwardedHost: "example.com",
+ xForwardedUri: "/foo",
+ shouldError: false,
+ },
+ {
+ name: "constructRedirectURL: http protocol should be allowed",
+ testType: "constructRedirectURL",
+ xForwardedProto: "http",
+ xForwardedHost: "example.com",
+ xForwardedUri: "/bar",
+ shouldError: false,
+ },
+
+ // serveHTTPNext tests - redir parameter validation
+ {
+ name: "serveHTTPNext: javascript: URL should be rejected",
+ testType: "serveHTTPNext",
+ redirParam: "javascript:alert(1)",
+ reqHost: "example.com",
+ expectedStatus: http.StatusBadRequest,
+ shouldNotRedirect: true,
+ },
+ {
+ name: "serveHTTPNext: data: URL should be rejected",
+ testType: "serveHTTPNext",
+ redirParam: "data:text/html,",
+ reqHost: "example.com",
+ expectedStatus: http.StatusBadRequest,
+ shouldNotRedirect: true,
+ },
+ {
+ name: "serveHTTPNext: file: URL should be rejected",
+ testType: "serveHTTPNext",
+ redirParam: "file:///etc/passwd",
+ reqHost: "example.com",
+ expectedStatus: http.StatusBadRequest,
+ shouldNotRedirect: true,
+ },
+ {
+ name: "serveHTTPNext: vbscript: URL should be rejected",
+ testType: "serveHTTPNext",
+ redirParam: "vbscript:msgbox(1)",
+ reqHost: "example.com",
+ expectedStatus: http.StatusBadRequest,
+ shouldNotRedirect: true,
+ },
+ {
+ name: "serveHTTPNext: valid https URL should work",
+ testType: "serveHTTPNext",
+ redirParam: "https://example.com/foo",
+ reqHost: "example.com",
+ expectedStatus: http.StatusFound,
+ },
+ {
+ name: "serveHTTPNext: valid relative URL should work",
+ testType: "serveHTTPNext",
+ redirParam: "/foo/bar",
+ reqHost: "example.com",
+ expectedStatus: http.StatusFound,
+ },
+ {
+ name: "serveHTTPNext: external domain should be blocked",
+ testType: "serveHTTPNext",
+ redirParam: "https://evil.com/phishing",
+ reqHost: "example.com",
+ expectedStatus: http.StatusBadRequest,
+ shouldBlock: true,
+ },
+ {
+ name: "serveHTTPNext: relative path should work",
+ testType: "serveHTTPNext",
+ redirParam: "/safe/path",
+ reqHost: "example.com",
+ expectedStatus: http.StatusFound,
+ },
+ {
+ name: "serveHTTPNext: empty redir should show success page",
+ testType: "serveHTTPNext",
+ redirParam: "",
+ reqHost: "example.com",
+ expectedStatus: http.StatusOK,
+ },
+
+ // renderIndex tests - full subrequest auth flow
+ {
+ name: "renderIndex: javascript protocol in X-Forwarded-Proto",
+ testType: "renderIndex",
+ xForwardedProto: "javascript",
+ xForwardedHost: "example.com",
+ xForwardedUri: "alert(1)",
+ returnHTTPStatusOnly: true,
+ expectedStatus: http.StatusBadRequest,
+ },
+ {
+ name: "renderIndex: data protocol in X-Forwarded-Proto",
+ testType: "renderIndex",
+ xForwardedProto: "data",
+ xForwardedHost: "example.com",
+ xForwardedUri: "text/html,",
+ returnHTTPStatusOnly: true,
+ expectedStatus: http.StatusBadRequest,
+ },
+ {
+ name: "renderIndex: valid https redirect",
+ testType: "renderIndex",
+ xForwardedProto: "https",
+ xForwardedHost: "example.com",
+ xForwardedUri: "/protected/page",
+ returnHTTPStatusOnly: true,
+ expectedStatus: http.StatusTemporaryRedirect,
+ },
+ }
+
+ s := &Server{
+ opts: Options{
+ PublicUrl: "https://anubis.example.com",
+ RedirectDomains: []string{"example.com"},
+ },
+ logger: slog.Default(),
+ policy: &policy.ParsedConfig{},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ switch tt.testType {
+ case "constructRedirectURL":
+ req := httptest.NewRequest("GET", "/", nil)
+ req.Header.Set("X-Forwarded-Proto", tt.xForwardedProto)
+ req.Header.Set("X-Forwarded-Host", tt.xForwardedHost)
+ req.Header.Set("X-Forwarded-Uri", tt.xForwardedUri)
+
+ redirectURL, err := s.constructRedirectURL(req)
+
+ if tt.shouldError {
+ if err == nil {
+ t.Errorf("expected error containing %q, got nil", tt.errorContains)
+ t.Logf("got redirect URL: %s", redirectURL)
+ } else if tt.errorContains != "" && !strings.Contains(err.Error(), tt.errorContains) {
+ t.Logf("expected error containing %q, got: %v", tt.errorContains, err)
+ }
+ } else {
+ if err != nil {
+ t.Errorf("expected no error, got: %v", err)
+ }
+ // Verify the redirect URL is safe
+ if redirectURL != "" {
+ parsed, err := url.Parse(redirectURL)
+ if err != nil {
+ t.Errorf("failed to parse redirect URL: %v", err)
+ }
+ redirParam := parsed.Query().Get("redir")
+ if redirParam != "" {
+ redirParsed, err := url.Parse(redirParam)
+ if err != nil {
+ t.Errorf("failed to parse redir parameter: %v", err)
+ }
+ if redirParsed.Scheme != "http" && redirParsed.Scheme != "https" {
+ t.Errorf("redir parameter has unsafe scheme: %s", redirParsed.Scheme)
+ }
+ }
+ }
+ }
+
+ case "serveHTTPNext":
+ req := httptest.NewRequest("GET", "/.within.website/?redir="+url.QueryEscape(tt.redirParam), nil)
+ req.Host = tt.reqHost
+ rr := httptest.NewRecorder()
+
+ s.ServeHTTPNext(rr, req)
+
+ if rr.Code != tt.expectedStatus {
+ t.Errorf("expected status %d, got %d", tt.expectedStatus, rr.Code)
+ t.Logf("body: %s", rr.Body.String())
+ }
+
+ if tt.shouldNotRedirect {
+ location := rr.Header().Get("Location")
+ if location != "" {
+ t.Errorf("expected no redirect, but got Location header: %s", location)
+ }
+ }
+
+ if tt.shouldBlock {
+ location := rr.Header().Get("Location")
+ if location != "" && strings.Contains(location, "evil.com") {
+ t.Errorf("redirect to evil.com was not blocked: %s", location)
+ }
+ }
+
+ case "renderIndex":
+ req := httptest.NewRequest("GET", "/", nil)
+ req.Header.Set("X-Forwarded-Proto", tt.xForwardedProto)
+ req.Header.Set("X-Forwarded-Host", tt.xForwardedHost)
+ req.Header.Set("X-Forwarded-Uri", tt.xForwardedUri)
+
+ rr := httptest.NewRecorder()
+ s.RenderIndex(rr, req, policy.CheckResult{}, nil, tt.returnHTTPStatusOnly)
+
+ if rr.Code != tt.expectedStatus {
+ t.Errorf("expected status %d, got %d", tt.expectedStatus, rr.Code)
+ }
+
+ if tt.expectedStatus == http.StatusTemporaryRedirect {
+ location := rr.Header().Get("Location")
+ if location == "" {
+ t.Error("expected Location header, got none")
+ } else {
+ // Verify the location doesn't contain javascript:
+ if strings.Contains(location, "javascript") {
+ t.Errorf("Location header contains 'javascript': %s", location)
+ }
+ }
+ }
+ }
+ })
+ }
+}
+
+func TestValidateRedirect(t *testing.T) {
+ s := &Server{opts: Options{RedirectDomains: []string{"allowed.example", "*.allowed.example", "allowed.example:8443"}}}
+ for _, tt := range []struct {
+ target string
+ want error
+ }{
+ {"/safe/path", nil},
+ {"relative/path", nil},
+ {"/safe%2Fpath?q=%2F%2Fevil.com#fragment", nil},
+ {"https://ALLOWED.example/path", nil},
+ {"http://sub.allowed.example/", nil},
+ {"https://allowed.example:8443/", nil},
+ {"https://allowed.example:9443/", ErrRedirectDomainNotAllowed},
+ {"https://evil.com/", ErrRedirectDomainNotAllowed},
+ {"https://allowed.example@evil.com/", ErrRedirectDomainNotAllowed},
+ {"https:///evil.com/", ErrInvalidRedirect},
+ {"http:/evil.com/", ErrInvalidRedirect},
+ {"https:evil.com", ErrInvalidRedirect},
+ {"//evil.com", ErrInvalidRedirect},
+ {"//allowed.example", ErrInvalidRedirect},
+ {"///evil.com", ErrInvalidRedirect},
+ {"////evil.com", ErrInvalidRedirect},
+ {"/%2Fevil.com", ErrInvalidRedirect},
+ {"/%2F%2Fevil.com", ErrInvalidRedirect},
+ {"/%5Cevil.com", ErrInvalidRedirect},
+ {`/\evil.com`, ErrInvalidRedirect},
+ {`https://allowed.example\@evil.com/`, ErrInvalidRedirect},
+ {" https://evil.com/", ErrInvalidRedirect},
+ {"/\tevil.com", ErrInvalidRedirect},
+ {"javascript:alert(1)", ErrInvalidRedirect},
+ {"data:text/plain,test", ErrInvalidRedirect},
+ {"file:///etc/passwd", ErrInvalidRedirect},
+ {"/%zz", ErrInvalidRedirect},
+ } {
+ t.Run(tt.target, func(t *testing.T) {
+ _, err := s.validateRedirect(tt.target)
+ if !errors.Is(err, tt.want) {
+ t.Fatalf("validateRedirect(%q) = %v, want %v", tt.target, err, tt.want)
+ }
+ })
+ }
+}
+
+func TestRedirectHandlersRejectAmbiguousTargets(t *testing.T) {
+ // These are wire query strings, not already decoded form values.
+ for _, query := range []string{
+ "https:///evil.com/", "http:/evil.com/", "https:evil.com",
+ "//evil.com", "/%2Fevil.com", `/\evil.com`, "///evil.com",
+ "////evil.com", "/%2F%2Fevil.com", "/%252Fevil.com",
+ "/%255Cevil.com", "%20https://evil.com", "/%09/evil.com",
+ "javascript:alert(1)", "//allowed.example",
+ } {
+ t.Run(query, func(t *testing.T) {
+ for _, domains := range [][]string{nil, {"allowed.example"}} {
+ s := &Server{opts: Options{RedirectDomains: domains}, logger: slog.Default(), policy: &policy.ParsedConfig{}}
+ for name, handler := range map[string]http.HandlerFunc{"next": s.ServeHTTPNext, "pass": s.PassChallenge} {
+ for _, cookie := range []bool{false, true} {
+ req := httptest.NewRequest(http.MethodGet, "/.within.website/?redir="+query, nil)
+ if cookie {
+ req.AddCookie(&http.Cookie{Name: s.cookieName(anubis.TestCookieName), Value: "test"})
+ }
+ rr := httptest.NewRecorder()
+ handler(rr, req)
+ if rr.Code != http.StatusBadRequest || rr.Header().Get("Location") != "" || len(rr.Result().Cookies()) != 0 {
+ t.Errorf("%s: expected 400 without redirect or cookies, got %d, %v", name, rr.Code, rr.Header())
+ }
+ }
+ }
+ }
+ })
+ }
+}
+
+func TestRedirectAllowedDestination(t *testing.T) {
+ for _, domains := range [][]string{nil, {"allowed.example"}} {
+ s := &Server{opts: Options{RedirectDomains: domains}, logger: slog.Default(), policy: &policy.ParsedConfig{}}
+ for _, base := range []string{"", "https://auth.example"} {
+ for _, target := range []string{"https://allowed.example/safe%2Fpath?q=%2F%2Fevil.com#fragment", "/safe%2Fpath?q=%2F%2Fevil.com#fragment"} {
+ req := httptest.NewRequest(http.MethodGet, base+"/.within.website/?redir="+url.QueryEscape(target), nil)
+ rr := httptest.NewRecorder()
+ s.ServeHTTPNext(rr, req)
+ if rr.Code != http.StatusFound || rr.Header().Get("Location") != target {
+ t.Errorf("target %q: got %d, Location %q", target, rr.Code, rr.Header().Get("Location"))
+ }
+ }
+ }
+ }
+}
diff --git a/lib/store/bbolt/bbolt.go b/lib/store/bbolt/bbolt.go
index eb7b3401b..6f39a0911 100644
--- a/lib/store/bbolt/bbolt.go
+++ b/lib/store/bbolt/bbolt.go
@@ -50,6 +50,33 @@ func (s *Store) Delete(ctx context.Context, key string) error {
})
}
+// deleteIfExpired removes key only if it still carries the exact expiry that an
+// expired Get observed and that expiry is still in the past.
+//
+// Get runs in a read-only transaction, so it can only schedule cleanup
+// asynchronously. Between observing the expiry and this delete running, another
+// request may Set a fresh value for the same key. Re-reading and matching the
+// observed expiry inside the write transaction makes the timestamp act as a
+// generation token: a refreshed value carries a different, future expiry and is
+// therefore left untouched (see AWOO-015).
+func (s *Store) deleteIfExpired(ctx context.Context, key string, observed time.Time) error {
+ return s.bdb.Update(func(tx *bbolt.Tx) error {
+ valueBkt := tx.Bucket([]byte(key))
+ if valueBkt == nil {
+ return nil
+ }
+
+ expiry, err := time.Parse(time.RFC3339Nano, string(valueBkt.Get([]byte("expiry"))))
+ if err != nil || !expiry.Equal(observed) || !time.Now().After(expiry) {
+ // Unparseable, refreshed to a different generation, or no longer
+ // expired: leave it for cleanup or a later Get to handle.
+ return nil
+ }
+
+ return tx.DeleteBucket([]byte(key))
+ })
+}
+
// Get a value from the datastore.
//
// Because each value is stored in its own bucket with data and expiry keys,
@@ -77,7 +104,7 @@ func (s *Store) Get(ctx context.Context, key string) ([]byte, error) {
}
if time.Now().After(expiry) {
- go s.Delete(context.Background(), key)
+ go s.deleteIfExpired(context.Background(), key, expiry) //nolint:errcheck
return fmt.Errorf("%w: %q", store.ErrNotFound, key)
}
@@ -131,7 +158,7 @@ func (s *Store) cleanup(ctx context.Context) error {
expiryStr := valueBkt.Get([]byte("expiry"))
if expiryStr == nil {
- slog.Warn("while running cleanup, expiry is not set somehow, file a bug?", "key", string(key))
+ slog.WarnContext(ctx, "while running cleanup, expiry is not set somehow, file a bug?", "key", string(key))
return nil
}
@@ -163,7 +190,7 @@ func (s *Store) cleanupThread(ctx context.Context) {
return
case <-t.C:
if err := s.cleanup(ctx); err != nil {
- slog.Error("error during bbolt cleanup", "err", err)
+ slog.ErrorContext(ctx, "error during bbolt cleanup", "err", err)
}
}
}
diff --git a/lib/store/bbolt/bbolt_test.go b/lib/store/bbolt/bbolt_test.go
index 2e67b6304..e14682e3b 100644
--- a/lib/store/bbolt/bbolt_test.go
+++ b/lib/store/bbolt/bbolt_test.go
@@ -4,8 +4,10 @@ import (
"encoding/json"
"path/filepath"
"testing"
+ "time"
"github.com/TecharoHQ/anubis/lib/store/storetest"
+ "go.etcd.io/bbolt"
)
func TestImpl(t *testing.T) {
@@ -20,3 +22,154 @@ func TestImpl(t *testing.T) {
storetest.Common(t, Factory{}, json.RawMessage(data))
}
+
+// newTestStore returns a Store backed by a throwaway bbolt database that is
+// closed when the test finishes.
+func newTestStore(t *testing.T) *Store {
+ t.Helper()
+
+ db, err := bbolt.Open(filepath.Join(t.TempDir(), "db"), 0600, nil)
+ if err != nil {
+ t.Fatalf("can't open bbolt database: %v", err)
+ }
+ t.Cleanup(func() { db.Close() }) //nolint:errcheck
+
+ return &Store{bdb: db}
+}
+
+// mustSet writes a value with the given relative expiry, failing the test on error.
+func mustSet(t *testing.T, s *Store, key, value string, expiry time.Duration) {
+ t.Helper()
+
+ if err := s.Set(t.Context(), key, []byte(value), expiry); err != nil {
+ t.Fatalf("Set(%q): %v", key, err)
+ }
+}
+
+// readExpiry returns the expiry timestamp currently stored for key, as a Get
+// would parse it. It fails the test if the bucket or expiry is missing.
+func readExpiry(t *testing.T, s *Store, key string) time.Time {
+ t.Helper()
+
+ var out time.Time
+ if err := s.bdb.View(func(tx *bbolt.Tx) error {
+ b := tx.Bucket([]byte(key))
+ if b == nil {
+ t.Fatalf("bucket %q missing", key)
+ }
+
+ expiry, err := time.Parse(time.RFC3339Nano, string(b.Get([]byte("expiry"))))
+ if err != nil {
+ return err
+ }
+ out = expiry
+ return nil
+ }); err != nil {
+ t.Fatalf("reading expiry for %q: %v", key, err)
+ }
+
+ return out
+}
+
+// rawData reads the raw data value for key directly, bypassing the expiry check
+// in Get so tests can observe whether a bucket physically exists. It returns nil
+// when the bucket is absent.
+func rawData(t *testing.T, s *Store, key string) []byte {
+ t.Helper()
+
+ var out []byte
+ if err := s.bdb.View(func(tx *bbolt.Tx) error {
+ b := tx.Bucket([]byte(key))
+ if b == nil {
+ return nil
+ }
+ data := b.Get([]byte("data"))
+ out = make([]byte, len(data))
+ copy(out, data)
+ return nil
+ }); err != nil {
+ t.Fatalf("reading data for %q: %v", key, err)
+ }
+
+ return out
+}
+
+// TestDeleteIfExpired guards against AWOO-015: a stale async delete scheduled by
+// an expired Get must not erase a value that was refreshed (or otherwise differs
+// from) the generation it observed.
+func TestDeleteIfExpired(t *testing.T) {
+ const key = "challenge"
+
+ for _, tt := range []struct {
+ setup func(t *testing.T, s *Store) time.Time
+ name string
+ wantValue string
+ wantPresent bool
+ }{
+ {
+ name: "deletes the observed expired generation",
+ setup: func(t *testing.T, s *Store) time.Time {
+ mustSet(t, s, key, "old", -time.Minute)
+ return readExpiry(t, s, key)
+ },
+ wantPresent: false,
+ },
+ {
+ name: "preserves a refreshed generation",
+ setup: func(t *testing.T, s *Store) time.Time {
+ mustSet(t, s, key, "old", -time.Minute)
+ observed := readExpiry(t, s, key)
+ mustSet(t, s, key, "fresh", time.Hour)
+ return observed
+ },
+ wantPresent: true,
+ wantValue: "fresh",
+ },
+ {
+ name: "skips on generation mismatch",
+ setup: func(t *testing.T, s *Store) time.Time {
+ mustSet(t, s, key, "old", -time.Minute)
+ // An expiry we never wrote: even though the stored value is
+ // expired, it is a different generation and must be left alone.
+ return time.Now().Add(-2 * time.Hour)
+ },
+ wantPresent: true,
+ wantValue: "old",
+ },
+ {
+ name: "skips a non-expired observation",
+ setup: func(t *testing.T, s *Store) time.Time {
+ mustSet(t, s, key, "live", time.Hour)
+ return readExpiry(t, s, key)
+ },
+ wantPresent: true,
+ wantValue: "live",
+ },
+ {
+ name: "no-op when bucket is absent",
+ setup: func(t *testing.T, s *Store) time.Time {
+ return time.Now().Add(-time.Hour)
+ },
+ wantPresent: false,
+ },
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ s := newTestStore(t)
+ observed := tt.setup(t, s)
+
+ if err := s.deleteIfExpired(t.Context(), key, observed); err != nil {
+ t.Fatalf("deleteIfExpired(%q): %v", key, err)
+ }
+
+ got := rawData(t, s, key)
+ switch {
+ case tt.wantPresent && got == nil:
+ t.Fatalf("key %q: want present with value %q, got deleted", key, tt.wantValue)
+ case tt.wantPresent && string(got) != tt.wantValue:
+ t.Errorf("key %q: want value %q, got %q", key, tt.wantValue, string(got))
+ case !tt.wantPresent && got != nil:
+ t.Errorf("key %q: want deleted, got value %q", key, string(got))
+ }
+ })
+ }
+}
diff --git a/lib/store/bbolt/factory.go b/lib/store/bbolt/factory.go
index 04879b7e3..bae016647 100644
--- a/lib/store/bbolt/factory.go
+++ b/lib/store/bbolt/factory.go
@@ -48,7 +48,7 @@ func (Factory) Build(ctx context.Context, data json.RawMessage) (store.Interface
go result.cleanupThread(ctx)
- return store.NewActorifiedStore(result), nil
+ return result, nil
}
// Valid parses and validates the bbolt store Config or returns
@@ -83,7 +83,7 @@ func (c Config) Valid() error {
if err := os.WriteFile(filepath.Join(dir, ".test-file"), []byte(""), 0600); err != nil {
errs = append(errs, ErrCantWriteToPath)
}
- os.Remove(filepath.Join(dir, ".test-file"))
+ os.Remove(filepath.Join(dir, ".test-file")) //nolint:errcheck
}
if len(errs) != 0 {
diff --git a/lib/store/bbolt/factory_test.go b/lib/store/bbolt/factory_test.go
index babc9f2b4..ac90e5a60 100644
--- a/lib/store/bbolt/factory_test.go
+++ b/lib/store/bbolt/factory_test.go
@@ -17,9 +17,9 @@ func TestFactoryValid(t *testing.T) {
t.Run("invalid config", func(t *testing.T) {
for _, tt := range []struct {
+ err error
name string
cfg Config
- err error
}{
{
name: "missing path",
diff --git a/lib/store/s3api/factory.go b/lib/store/s3api/factory.go
index 34ac10bb9..cfed53504 100644
--- a/lib/store/s3api/factory.go
+++ b/lib/store/s3api/factory.go
@@ -88,8 +88,8 @@ func (Factory) Valid(data json.RawMessage) error {
}
type Config struct {
- PathStyle bool `json:"pathStyle"`
BucketName string `json:"bucketName"`
+ PathStyle bool `json:"pathStyle"`
}
func (c Config) Valid() error {
diff --git a/lib/store/s3api/s3api.go b/lib/store/s3api/s3api.go
index 757a06f63..4dedd71fb 100644
--- a/lib/store/s3api/s3api.go
+++ b/lib/store/s3api/s3api.go
@@ -39,7 +39,7 @@ func (s *Store) Get(ctx context.Context, key string) ([]byte, error) {
if err != nil {
return nil, fmt.Errorf("%w: %w", store.ErrNotFound, err)
}
- defer out.Body.Close()
+ defer out.Body.Close() //nolint:errcheck
if msStr, ok := out.Metadata["x-anubis-expiry-ms"]; ok && msStr != "" {
if ms, err := strconv.ParseInt(msStr, 10, 64); err == nil {
if time.Now().UnixMilli() >= ms {
diff --git a/lib/store/s3api/s3api_test.go b/lib/store/s3api/s3api_test.go
index 80309dc42..37d58a760 100644
--- a/lib/store/s3api/s3api_test.go
+++ b/lib/store/s3api/s3api_test.go
@@ -6,6 +6,7 @@ import (
"encoding/json"
"fmt"
"io"
+ "maps"
"sync"
"testing"
"time"
@@ -17,10 +18,10 @@ import (
// mockS3 is an in-memory mock of the methods we use.
type mockS3 struct {
- mu sync.RWMutex
- bucket string
data map[string][]byte
meta map[string]map[string]string
+ bucket string
+ mu sync.RWMutex
}
func (m *mockS3) PutObject(ctx context.Context, in *s3.PutObjectInput, _ ...func(*s3.Options)) (*s3.PutObjectOutput, error) {
@@ -36,9 +37,7 @@ func (m *mockS3) PutObject(ctx context.Context, in *s3.PutObjectInput, _ ...func
m.data[aws.ToString(in.Key)] = bytes.Clone(b)
if in.Metadata != nil {
m.meta[aws.ToString(in.Key)] = map[string]string{}
- for k, v := range in.Metadata {
- m.meta[aws.ToString(in.Key)][k] = v
- }
+ maps.Copy(m.meta[aws.ToString(in.Key)], in.Metadata)
}
m.bucket = aws.ToString(in.Bucket)
return &s3.PutObjectOutput{}, nil
diff --git a/lib/store/storetest/storetest.go b/lib/store/storetest/storetest.go
index f04ce8a90..db7da90bd 100644
--- a/lib/store/storetest/storetest.go
+++ b/lib/store/storetest/storetest.go
@@ -21,9 +21,9 @@ func Common(t *testing.T, f store.Factory, config json.RawMessage) {
}
for _, tt := range []struct {
- name string
- doer func(t *testing.T, s store.Interface) error
err error
+ doer func(t *testing.T, s store.Interface) error
+ name string
}{
{
name: "basic get set delete",
diff --git a/lib/store/valkey/factory.go b/lib/store/valkey/factory.go
index d26c1c723..49a63469e 100644
--- a/lib/store/valkey/factory.go
+++ b/lib/store/valkey/factory.go
@@ -5,80 +5,174 @@ import (
"encoding/json"
"errors"
"fmt"
+ "time"
+ "github.com/TecharoHQ/anubis/internal"
"github.com/TecharoHQ/anubis/lib/store"
valkey "github.com/redis/go-redis/v9"
+ "github.com/redis/go-redis/v9/maintnotifications"
)
+func init() {
+ store.Register("valkey", Factory{})
+}
+
var (
ErrNoURL = errors.New("valkey.Config: no URL defined")
ErrBadURL = errors.New("valkey.Config: URL is invalid")
+
+ // Sentinel validation errors
+ ErrSentinelMasterNameRequired = errors.New("valkey.Sentinel: masterName is required")
+ ErrSentinelAddrRequired = errors.New("valkey.Sentinel: addr is required")
+ ErrSentinelAddrEmpty = errors.New("valkey.Sentinel: addr cannot be empty")
)
-func init() {
- store.Register("valkey", Factory{})
-}
+// Config is what Anubis unmarshals from the "parameters" JSON.
+type Config struct {
+ URL string `json:"url"`
+ Cluster bool `json:"cluster,omitempty"`
-type Factory struct{}
+ Sentinel *Sentinel `json:"sentinel,omitempty"`
+}
-func (Factory) Build(ctx context.Context, data json.RawMessage) (store.Interface, error) {
- var config Config
+func (c Config) Valid() error {
+ var errs []error
- if err := json.Unmarshal([]byte(data), &config); err != nil {
- return nil, fmt.Errorf("%w: %w", store.ErrBadConfig, err)
+ if c.URL == "" && c.Sentinel == nil {
+ errs = append(errs, ErrNoURL)
}
- if err := config.Valid(); err != nil {
- return nil, fmt.Errorf("%w: %w", store.ErrBadConfig, err)
+ // Validate URL only if provided
+ if c.URL != "" {
+ if _, err := valkey.ParseURL(c.URL); err != nil {
+ errs = append(errs, fmt.Errorf("%w: %v", ErrBadURL, err))
+ }
}
- opts, err := valkey.ParseURL(config.URL)
- if err != nil {
- return nil, fmt.Errorf("%w: %w", store.ErrBadConfig, err)
+ if c.Sentinel != nil {
+ if err := c.Sentinel.Valid(); err != nil {
+ errs = append(errs, err)
+ }
}
- rdb := valkey.NewClient(opts)
-
- if _, err := rdb.Ping(ctx).Result(); err != nil {
- return nil, fmt.Errorf("can't ping valkey instance: %w", err)
+ if len(errs) > 0 {
+ return errors.Join(errs...)
}
- return &Store{
- rdb: rdb,
- }, nil
+ return nil
}
-func (Factory) Valid(data json.RawMessage) error {
- var config Config
- if err := json.Unmarshal([]byte(data), &config); err != nil {
- return fmt.Errorf("%w: %w", store.ErrBadConfig, err)
+type Sentinel struct {
+ MasterName string `json:"masterName"`
+ Addr internal.ListOr[string] `json:"addr"`
+ ClientName string `json:"clientName,omitempty"`
+ Username string `json:"username,omitempty"`
+ Password string `json:"password,omitempty"`
+}
+
+func (s Sentinel) Valid() error {
+ var errs []error
+
+ if s.MasterName == "" {
+ errs = append(errs, ErrSentinelMasterNameRequired)
}
- if err := config.Valid(); err != nil {
- return fmt.Errorf("%w: %w", store.ErrBadConfig, err)
+ if len(s.Addr) == 0 {
+ errs = append(errs, ErrSentinelAddrRequired)
+ } else {
+ // Check if all addresses in the list are empty
+ allEmpty := true
+ for _, addr := range s.Addr {
+ if addr != "" {
+ allEmpty = false
+ break
+ }
+ }
+ if allEmpty {
+ errs = append(errs, ErrSentinelAddrEmpty)
+ }
+ }
+
+ if len(errs) > 0 {
+ return errors.Join(errs...)
}
return nil
}
-type Config struct {
- URL string `json:"url"`
+// redisClient is satisfied by *valkey.Client and *valkey.ClusterClient.
+type redisClient interface {
+ Get(ctx context.Context, key string) *valkey.StringCmd
+ Set(ctx context.Context, key string, value any, expiration time.Duration) *valkey.StatusCmd
+ Del(ctx context.Context, keys ...string) *valkey.IntCmd
+ Ping(ctx context.Context) *valkey.StatusCmd
}
-func (c Config) Valid() error {
- var errs []error
+type Factory struct{}
- if c.URL == "" {
- errs = append(errs, ErrNoURL)
+func (Factory) Valid(data json.RawMessage) error {
+ var cfg Config
+ if err := json.Unmarshal(data, &cfg); err != nil {
+ return err
}
+ return cfg.Valid()
+}
- if _, err := valkey.ParseURL(c.URL); err != nil {
- errs = append(errs, ErrBadURL)
+func (Factory) Build(ctx context.Context, data json.RawMessage) (store.Interface, error) {
+ var cfg Config
+ if err := json.Unmarshal(data, &cfg); err != nil {
+ return nil, err
+ }
+ if err := cfg.Valid(); err != nil {
+ return nil, err
}
- if len(errs) != 0 {
- return fmt.Errorf("valkey.Config: invalid config: %w", errors.Join(errs...))
+ var client redisClient
+
+ switch {
+ case cfg.Cluster:
+ opts, err := valkey.ParseURL(cfg.URL)
+ if err != nil {
+ return nil, fmt.Errorf("valkey.Factory: %w", err)
+ }
+
+ // Cluster mode: use the parsed Addr as the seed node.
+ clusterOpts := &valkey.ClusterOptions{
+ Addrs: []string{opts.Addr},
+ // Explicitly disable maintenance notifications
+ // This prevents the client from sending CLIENT MAINT_NOTIFICATIONS ON
+ MaintNotificationsConfig: &maintnotifications.Config{
+ Mode: maintnotifications.ModeDisabled,
+ },
+ }
+ client = valkey.NewClusterClient(clusterOpts)
+ case cfg.Sentinel != nil:
+ opts := &valkey.FailoverOptions{
+ MasterName: cfg.Sentinel.MasterName,
+ SentinelAddrs: cfg.Sentinel.Addr,
+ SentinelUsername: cfg.Sentinel.Username,
+ SentinelPassword: cfg.Sentinel.Password,
+ Username: cfg.Sentinel.Username,
+ Password: cfg.Sentinel.Password,
+ ClientName: cfg.Sentinel.ClientName,
+ }
+ client = valkey.NewFailoverClusterClient(opts)
+ default:
+ opts, err := valkey.ParseURL(cfg.URL)
+ if err != nil {
+ return nil, fmt.Errorf("valkey.Factory: %w", err)
+ }
+
+ opts.MaintNotificationsConfig = &maintnotifications.Config{
+ Mode: maintnotifications.ModeDisabled,
+ }
+ client = valkey.NewClient(opts)
}
- return nil
+ // Optional but nice: fail fast if the cluster/single node is unreachable.
+ if err := client.Ping(ctx).Err(); err != nil {
+ return nil, fmt.Errorf("valkey.Factory: ping failed: %w", err)
+ }
+
+ return &Store{client: client}, nil
}
diff --git a/lib/store/valkey/valkey.go b/lib/store/valkey/valkey.go
index 11f43740f..4a2882245 100644
--- a/lib/store/valkey/valkey.go
+++ b/lib/store/valkey/valkey.go
@@ -2,52 +2,46 @@ package valkey
import (
"context"
- "fmt"
"time"
"github.com/TecharoHQ/anubis/lib/store"
valkey "github.com/redis/go-redis/v9"
)
+// Store implements store.Interface on top of Redis/Valkey.
type Store struct {
- rdb *valkey.Client
+ client redisClient
}
-func (s *Store) Delete(ctx context.Context, key string) error {
- n, err := s.rdb.Del(ctx, key).Result()
- if err != nil {
- return fmt.Errorf("can't delete from valkey: %w", err)
- }
-
- switch n {
- case 0:
- return fmt.Errorf("%w: %d key(s) deleted", store.ErrNotFound, n)
- default:
- return nil
- }
-}
+var _ store.Interface = (*Store)(nil)
func (s *Store) Get(ctx context.Context, key string) ([]byte, error) {
- result, err := s.rdb.Get(ctx, key).Result()
- if err != nil {
- if valkey.HasErrorPrefix(err, "redis: nil") {
- return nil, fmt.Errorf("%w: %w", store.ErrNotFound, err)
+ cmd := s.client.Get(ctx, key)
+ if err := cmd.Err(); err != nil {
+ if err == valkey.Nil {
+ return nil, store.ErrNotFound
}
-
- return nil, fmt.Errorf("can't fetch from valkey: %w", err)
+ return nil, err
}
-
- return []byte(result), nil
+ return cmd.Bytes()
}
func (s *Store) Set(ctx context.Context, key string, value []byte, expiry time.Duration) error {
- if _, err := s.rdb.Set(ctx, key, string(value), expiry).Result(); err != nil {
- return fmt.Errorf("can't set %q in valkey: %w", key, err)
- }
+ return s.client.Set(ctx, key, value, expiry).Err()
+}
+func (s *Store) Delete(ctx context.Context, key string) error {
+ res := s.client.Del(ctx, key)
+ if err := res.Err(); err != nil {
+ return err
+ }
+ if n, _ := res.Result(); n == 0 {
+ return store.ErrNotFound
+ }
return nil
}
+// IsPersistent tells Anubis this backend is “real” storage, not in-memory.
func (s *Store) IsPersistent() bool {
return true
}
diff --git a/lib/store/valkey/valkey_test.go b/lib/store/valkey/valkey_test.go
index eb1b88113..a509e1c6b 100644
--- a/lib/store/valkey/valkey_test.go
+++ b/lib/store/valkey/valkey_test.go
@@ -2,20 +2,15 @@ package valkey
import (
"encoding/json"
- "fmt"
+ "errors"
"os"
"testing"
- "github.com/TecharoHQ/anubis/internal"
"github.com/TecharoHQ/anubis/lib/store/storetest"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/wait"
)
-func init() {
- internal.UnbreakDocker()
-}
-
func TestImpl(t *testing.T) {
if os.Getenv("DONT_USE_NETWORK") != "" {
t.Skip("test requires network egress")
@@ -24,26 +19,26 @@ func TestImpl(t *testing.T) {
testcontainers.SkipIfProviderIsNotHealthy(t)
- req := testcontainers.ContainerRequest{
- Image: "valkey/valkey:8",
- WaitingFor: wait.ForLog("Ready to accept connections"),
- }
- valkeyC, err := testcontainers.GenericContainer(t.Context(), testcontainers.GenericContainerRequest{
- ContainerRequest: req,
- Started: true,
- })
+ valkeyC, err := testcontainers.Run(
+ t.Context(), "valkey/valkey:8",
+ testcontainers.WithExposedPorts("6379/tcp"),
+ testcontainers.WithWaitStrategy(
+ wait.ForListeningPort("6379/tcp"),
+ wait.ForLog("Ready to accept connections"),
+ ),
+ )
testcontainers.CleanupContainer(t, valkeyC)
if err != nil {
t.Fatal(err)
}
- containerIP, err := valkeyC.ContainerIP(t.Context())
+ endpoint, err := valkeyC.PortEndpoint(t.Context(), "6379/tcp", "redis")
if err != nil {
t.Fatal(err)
}
data, err := json.Marshal(Config{
- URL: fmt.Sprintf("redis://%s:6379/0", containerIP),
+ URL: endpoint,
})
if err != nil {
t.Fatal(err)
@@ -51,3 +46,86 @@ func TestImpl(t *testing.T) {
storetest.Common(t, Factory{}, json.RawMessage(data))
}
+
+func TestFactoryValid(t *testing.T) {
+ tests := []struct {
+ name string
+ jsonData string
+ expectError error
+ }{
+ {
+ name: "empty config",
+ jsonData: `{}`,
+ expectError: ErrNoURL,
+ },
+ {
+ name: "valid URL only",
+ jsonData: `{"url": "redis://localhost:6379"}`,
+ expectError: nil,
+ },
+ {
+ name: "invalid URL",
+ jsonData: `{"url": "invalid-url"}`,
+ expectError: ErrBadURL,
+ },
+ {
+ name: "valid sentinel config",
+ jsonData: `{"sentinel": {"masterName": "mymaster", "addr": ["localhost:26379"], "password": "mypass"}}`,
+ expectError: nil,
+ },
+ {
+ name: "sentinel missing masterName",
+ jsonData: `{"sentinel": {"addr": ["localhost:26379"], "password": "mypass"}}`,
+ expectError: ErrSentinelMasterNameRequired,
+ },
+ {
+ name: "sentinel missing addr",
+ jsonData: `{"sentinel": {"masterName": "mymaster", "password": "mypass"}}`,
+ expectError: ErrSentinelAddrRequired,
+ },
+ {
+ name: "sentinel empty addr",
+ jsonData: `{"sentinel": {"masterName": "mymaster", "addr": [""], "password": "mypass"}}`,
+ expectError: ErrSentinelAddrEmpty,
+ },
+ {
+ name: "sentinel missing password",
+ jsonData: `{"sentinel": {"masterName": "mymaster", "addr": ["localhost:26379"]}}`,
+ expectError: nil,
+ },
+ {
+ name: "sentinel with optional fields",
+ jsonData: `{"sentinel": {"masterName": "mymaster", "addr": ["localhost:26379"], "password": "mypass", "clientName": "myclient", "username": "myuser"}}`,
+ expectError: nil,
+ },
+ {
+ name: "sentinel single address (not array)",
+ jsonData: `{"sentinel": {"masterName": "mymaster", "addr": "localhost:26379", "password": "mypass"}}`,
+ expectError: nil,
+ },
+ {
+ name: "sentinel mixed empty and valid addresses",
+ jsonData: `{"sentinel": {"masterName": "mymaster", "addr": ["", "localhost:26379", ""], "password": "mypass"}}`,
+ expectError: nil,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ factory := Factory{}
+ err := factory.Valid(json.RawMessage(tt.jsonData))
+
+ if tt.expectError == nil {
+ if err != nil {
+ t.Errorf("expected no error, got: %v", err)
+ }
+ } else {
+ if err == nil {
+ t.Errorf("expected error %v, got nil", tt.expectError)
+ } else if !errors.Is(err, tt.expectError) {
+ t.Errorf("expected error %v, got: %v", tt.expectError, err)
+ }
+ }
+ })
+ }
+}
diff --git a/lib/testdata/challenge-extension-unknown.yaml b/lib/testdata/challenge-extension-unknown.yaml
new file mode 100644
index 000000000..02284f060
--- /dev/null
+++ b/lib/testdata/challenge-extension-unknown.yaml
@@ -0,0 +1,11 @@
+bots:
+ - name: everyone
+ user_agent_regex: .
+ action: CHALLENGE
+ challenge:
+ algorithm: fast
+ difficulty: 0
+ extensions:
+ - does-not-exist
+
+dnsbl: false
diff --git a/lib/testdata/challenge-extension.yaml b/lib/testdata/challenge-extension.yaml
new file mode 100644
index 000000000..16455ebbc
--- /dev/null
+++ b/lib/testdata/challenge-extension.yaml
@@ -0,0 +1,11 @@
+bots:
+ - name: everyone
+ user_agent_regex: .
+ action: CHALLENGE
+ challenge:
+ algorithm: fast
+ difficulty: 0
+ extensions:
+ - test-extension
+
+dnsbl: false
diff --git a/lib/testdata/cloudflare-workers-cel.yaml b/lib/testdata/cloudflare-workers-cel.yaml
index b2d6de5a8..b165d5032 100644
--- a/lib/testdata/cloudflare-workers-cel.yaml
+++ b/lib/testdata/cloudflare-workers-cel.yaml
@@ -1,8 +1,8 @@
bots:
-- name: cloudflare-workers
- expression: '"Cf-Worker" in headers'
- action: DENY
+ - name: cloudflare-workers
+ expression: '"Cf-Worker" in headers'
+ action: DENY
status_codes:
CHALLENGE: 401
- DENY: 403
\ No newline at end of file
+ DENY: 403
diff --git a/lib/testdata/cloudflare-workers-header.yaml b/lib/testdata/cloudflare-workers-header.yaml
index 4fa9671b0..203064458 100644
--- a/lib/testdata/cloudflare-workers-header.yaml
+++ b/lib/testdata/cloudflare-workers-header.yaml
@@ -1,9 +1,9 @@
bots:
-- name: cloudflare-workers
- headers_regex:
- CF-Worker: .*
- action: DENY
+ - name: cloudflare-workers
+ headers_regex:
+ CF-Worker: .*
+ action: DENY
status_codes:
CHALLENGE: 401
- DENY: 403
\ No newline at end of file
+ DENY: 403
diff --git a/lib/testdata/hack-test.json b/lib/testdata/hack-test.json
index 652dcd879..a173bb18b 100644
--- a/lib/testdata/hack-test.json
+++ b/lib/testdata/hack-test.json
@@ -2,8 +2,6 @@
{
"name": "ipv6-ula",
"action": "ALLOW",
- "remote_addresses": [
- "fc00::/7"
- ]
+ "remote_addresses": ["fc00::/7"]
}
-]
\ No newline at end of file
+]
diff --git a/lib/testdata/hack-test.yaml b/lib/testdata/hack-test.yaml
index cd4d7d054..29263e969 100644
--- a/lib/testdata/hack-test.yaml
+++ b/lib/testdata/hack-test.yaml
@@ -1,3 +1,3 @@
- name: well-known
path_regex: ^/.well-known/.*$
- action: ALLOW
\ No newline at end of file
+ action: ALLOW
diff --git a/lib/testdata/invalid-challenge-method.yaml b/lib/testdata/invalid-challenge-method.yaml
index 24eccf7ab..a75dd7fa3 100644
--- a/lib/testdata/invalid-challenge-method.yaml
+++ b/lib/testdata/invalid-challenge-method.yaml
@@ -4,5 +4,4 @@ bots:
action: CHALLENGE
challenge:
difficulty: 16
- report_as: 4
algorithm: hunter2 # invalid algorithm
diff --git a/lib/testdata/rule_change.yaml b/lib/testdata/rule_change.yaml
index c4fe462d7..737500e7e 100644
--- a/lib/testdata/rule_change.yaml
+++ b/lib/testdata/rule_change.yaml
@@ -1,12 +1,12 @@
bots:
-- name: old-rule
- path_regex: ^/old$
- action: CHALLENGE
+ - name: old-rule
+ path_regex: ^/old$
+ action: CHALLENGE
-- name: new-rule
- path_regex: ^/new$
- action: CHALLENGE
+ - name: new-rule
+ path_regex: ^/new$
+ action: CHALLENGE
status_codes:
CHALLENGE: 401
- DENY: 403
\ No newline at end of file
+ DENY: 403
diff --git a/lib/testdata/test_config.yaml b/lib/testdata/test_config.yaml
index 9047dcbf5..cb8cd48f2 100644
--- a/lib/testdata/test_config.yaml
+++ b/lib/testdata/test_config.yaml
@@ -42,4 +42,3 @@ thresholds:
challenge:
algorithm: fast
difficulty: 1
- report_as: 1
diff --git a/lib/testdata/useragent.yaml b/lib/testdata/useragent.yaml
new file mode 100644
index 000000000..85cf73e22
--- /dev/null
+++ b/lib/testdata/useragent.yaml
@@ -0,0 +1,12 @@
+bots:
+ - name: deny
+ user_agent_regex: DENY
+ action: DENY
+
+ - name: challenge
+ user_agent_regex: CHALLENGE
+ action: CHALLENGE
+
+ - name: allow
+ user_agent_regex: ALLOW
+ action: ALLOW
diff --git a/lib/testdata/zero_difficulty.yaml b/lib/testdata/zero_difficulty.yaml
index 75382dbed..43ba4ec17 100644
--- a/lib/testdata/zero_difficulty.yaml
+++ b/lib/testdata/zero_difficulty.yaml
@@ -42,4 +42,3 @@ thresholds:
challenge:
algorithm: fast
difficulty: 0
- report_as: 0
diff --git a/lib/thoth/asnchecker.go b/lib/thoth/asnchecker.go
index 548765c2a..aef007486 100644
--- a/lib/thoth/asnchecker.go
+++ b/lib/thoth/asnchecker.go
@@ -41,15 +41,15 @@ func (asnc *ASNChecker) Check(r *http.Request) (bool, error) {
defer cancel()
ipInfo, err := asnc.iptoasn.Lookup(ctx, &iptoasnv1.LookupRequest{
- IpAddress: r.Header.Get("X-Real-Ip"),
+ IpAddress: r.Header.Get("X-Real-IP"),
})
if err != nil {
switch {
case errors.Is(err, context.DeadlineExceeded):
- slog.Debug("error contacting thoth", "err", err, "actionable", false)
+ slog.DebugContext(r.Context(), "error contacting thoth", "err", err, "actionable", false)
return false, nil
default:
- slog.Error("error contacting thoth, please contact support", "err", err, "actionable", true)
+ slog.ErrorContext(r.Context(), "error contacting thoth, please contact support", "err", err, "actionable", true)
return false, nil
}
}
diff --git a/lib/thoth/asnchecker_test.go b/lib/thoth/asnchecker_test.go
index 787cdb425..74d535bcf 100644
--- a/lib/thoth/asnchecker_test.go
+++ b/lib/thoth/asnchecker_test.go
@@ -45,7 +45,7 @@ func TestASNChecker(t *testing.T) {
} {
t.Run(fmt.Sprintf("%v", cs), func(t *testing.T) {
req := httptest.NewRequest("GET", "/", nil)
- req.Header.Set("X-Real-Ip", cs.ipAddress)
+ req.Header.Set("X-Real-IP", cs.ipAddress)
match, err := asnc.Check(req)
diff --git a/lib/thoth/auth.go b/lib/thoth/auth.go
index f4d52c6db..5f7d0c91b 100644
--- a/lib/thoth/auth.go
+++ b/lib/thoth/auth.go
@@ -11,8 +11,8 @@ func authUnaryClientInterceptor(token string) grpc.UnaryClientInterceptor {
return func(
ctx context.Context,
method string,
- req interface{},
- reply interface{},
+ req any,
+ reply any,
cc *grpc.ClientConn,
invoker grpc.UnaryInvoker,
opts ...grpc.CallOption,
diff --git a/lib/thoth/cachediptoasn.go b/lib/thoth/cachediptoasn.go
index c10fbae84..d739f71da 100644
--- a/lib/thoth/cachediptoasn.go
+++ b/lib/thoth/cachediptoasn.go
@@ -77,7 +77,7 @@ func (ip2asn *IPToASNWithCache) Lookup(ctx context.Context, lr *iptoasnv1.Lookup
}
if len(errs) != 0 {
- slog.Error("errors parsing IP prefixes", "err", errors.Join(errs...))
+ slog.ErrorContext(ctx, "errors parsing IP prefixes", "err", errors.Join(errs...))
}
return resp, nil
diff --git a/lib/thoth/geoipchecker.go b/lib/thoth/geoipchecker.go
index ef6dcb882..5e0cab5fa 100644
--- a/lib/thoth/geoipchecker.go
+++ b/lib/thoth/geoipchecker.go
@@ -18,7 +18,7 @@ func (c *Client) GeoIPCheckerFor(countries []string) checker.Impl {
var sb strings.Builder
fmt.Fprintln(&sb, "GeoIPChecker")
for _, cc := range countries {
- countryMap[cc] = struct{}{}
+ countryMap[strings.ToLower(cc)] = struct{}{}
fmt.Fprintln(&sb, cc)
}
@@ -40,15 +40,15 @@ func (gipc *GeoIPChecker) Check(r *http.Request) (bool, error) {
defer cancel()
ipInfo, err := gipc.IPToASN.Lookup(ctx, &iptoasnv1.LookupRequest{
- IpAddress: r.Header.Get("X-Real-Ip"),
+ IpAddress: r.Header.Get("X-Real-IP"),
})
if err != nil {
switch {
case errors.Is(err, context.DeadlineExceeded):
- slog.Debug("error contacting thoth", "err", err, "actionable", false)
+ slog.DebugContext(r.Context(), "error contacting thoth", "err", err, "actionable", false)
return false, nil
default:
- slog.Error("error contacting thoth, please contact support", "err", err, "actionable", true)
+ slog.ErrorContext(r.Context(), "error contacting thoth, please contact support", "err", err, "actionable", true)
return false, nil
}
}
diff --git a/lib/thoth/geoipchecker_test.go b/lib/thoth/geoipchecker_test.go
index 9826282fe..cf37a6fd9 100644
--- a/lib/thoth/geoipchecker_test.go
+++ b/lib/thoth/geoipchecker_test.go
@@ -44,7 +44,7 @@ func TestGeoIPChecker(t *testing.T) {
} {
t.Run(fmt.Sprintf("%v", cs), func(t *testing.T) {
req := httptest.NewRequest("GET", "/", nil)
- req.Header.Set("X-Real-Ip", cs.ipAddress)
+ req.Header.Set("X-Real-IP", cs.ipAddress)
match, err := asnc.Check(req)
diff --git a/lib/thoth/thoth.go b/lib/thoth/thoth.go
index 7a06b5ed8..96b650632 100644
--- a/lib/thoth/thoth.go
+++ b/lib/thoth/thoth.go
@@ -29,7 +29,7 @@ func New(ctx context.Context, thothURL, apiToken string, plaintext bool) (*Clien
grpcprom.WithHistogramBuckets([]float64{0.001, 0.01, 0.1, 0.3, 0.6, 1, 3, 6, 9, 20, 30, 60, 90, 120}),
),
)
- prometheus.DefaultRegisterer.Register(clMetrics)
+ prometheus.DefaultRegisterer.Register(clMetrics) //nolint:errcheck
do := []grpc.DialOption{
grpc.WithChainUnaryInterceptor(
diff --git a/package-lock.json b/package-lock.json
index c13982717..09ae53c9a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,26 +1,33 @@
{
"name": "@techaro/anubis",
- "version": "1.22.0",
+ "version": "1.28.0-pre2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@techaro/anubis",
- "version": "1.22.0",
+ "version": "1.28.0-pre2",
"license": "ISC",
"dependencies": {
"@aws-crypto/sha256-js": "^5.2.0",
- "preact": "^10.27.2"
+ "preact": "^10.29.8",
+ "wasm-feature-detect": "^1.9.0"
},
"devDependencies": {
- "cssnano": "^7.1.1",
- "cssnano-preset-advanced": "^7.0.9",
- "esbuild": "^0.25.10",
- "playwright": "^1.52.0",
+ "@commitlint/cli": "^21.2.2",
+ "@commitlint/config-conventional": "^21.0.2",
+ "baseline-browser-mapping": "^2.11.20",
+ "cspell": "^10.1.1",
+ "cssnano": "^9.0.1",
+ "cssnano-preset-advanced": "^9.0.1",
+ "esbuild": "^0.28.2",
+ "husky": "^9.1.7",
+ "playwright": "^1.61.1",
"postcss-cli": "^11.0.1",
- "postcss-import": "^16.1.1",
+ "postcss-import": "^17.0.0",
"postcss-import-url": "^7.2.0",
- "postcss-url": "^10.1.3"
+ "postcss-url": "^10.1.4",
+ "prettier": "^3.9.6"
}
},
"node_modules/@aws-crypto/sha256-js": {
@@ -61,10 +68,1377 @@
"node": ">=18.0.0"
}
},
+ "node_modules/@babel/code-frame": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
+ "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
+ "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@colordx/core": {
+ "version": "5.6.0",
+ "resolved": "https://registry.npmjs.org/@colordx/core/-/core-5.6.0.tgz",
+ "integrity": "sha512-EDlcg/Hmlj1WuFh/Os9YhA+A2aasB2XlgwFfGePUDuW7fVHC07HBi5AFIMx0Ct1eM8kHM9NRyOtZ0MuVYa8xvg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@commitlint/cli": {
+ "version": "21.2.2",
+ "resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-21.2.2.tgz",
+ "integrity": "sha512-a+6hQxIxnpdvSvS2apvttPNbEliYsVC3PqFYDiiB2kjbwIsQsj1urvQ4Tkf70pKYozPalKAuRQmm/GHwndduqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@commitlint/config-conventional": "^21.2.2",
+ "@commitlint/format": "^21.2.2",
+ "@commitlint/lint": "^21.2.2",
+ "@commitlint/load": "^21.2.2",
+ "@commitlint/read": "^21.2.1",
+ "@commitlint/types": "^21.2.0",
+ "tinyexec": "^1.0.0",
+ "yargs": "^18.0.0"
+ },
+ "bin": {
+ "commitlint": "cli.js"
+ },
+ "engines": {
+ "node": ">=22.12.0"
+ }
+ },
+ "node_modules/@commitlint/cli/node_modules/ansi-regex": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
+ "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-regex?sponsor=1"
+ }
+ },
+ "node_modules/@commitlint/cli/node_modules/ansi-styles": {
+ "version": "6.2.3",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
+ "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/@commitlint/cli/node_modules/cliui": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz",
+ "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^7.2.0",
+ "strip-ansi": "^7.1.0",
+ "wrap-ansi": "^9.0.0"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/@commitlint/cli/node_modules/emoji-regex": {
+ "version": "10.6.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
+ "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@commitlint/cli/node_modules/string-width": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
+ "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^10.3.0",
+ "get-east-asian-width": "^1.0.0",
+ "strip-ansi": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@commitlint/cli/node_modules/strip-ansi": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
+ "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^6.2.2"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/strip-ansi?sponsor=1"
+ }
+ },
+ "node_modules/@commitlint/cli/node_modules/wrap-ansi": {
+ "version": "9.0.2",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
+ "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^6.2.1",
+ "string-width": "^7.0.0",
+ "strip-ansi": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/@commitlint/cli/node_modules/yargs": {
+ "version": "18.0.0",
+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz",
+ "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cliui": "^9.0.1",
+ "escalade": "^3.1.1",
+ "get-caller-file": "^2.0.5",
+ "string-width": "^7.2.0",
+ "y18n": "^5.0.5",
+ "yargs-parser": "^22.0.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=23"
+ }
+ },
+ "node_modules/@commitlint/cli/node_modules/yargs-parser": {
+ "version": "22.0.0",
+ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz",
+ "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=23"
+ }
+ },
+ "node_modules/@commitlint/config-conventional": {
+ "version": "21.2.2",
+ "resolved": "https://registry.npmjs.org/@commitlint/config-conventional/-/config-conventional-21.2.2.tgz",
+ "integrity": "sha512-NxA37SZviusFUEYOQZ5hNnZ1h7O/KiemPkxjOlpzKJNnWxThiwc6/SaZhaPa8fyLvfRBAywhQhJJk8XESHWlpQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@commitlint/types": "^21.2.0",
+ "conventional-changelog-conventionalcommits": "^10.0.0"
+ },
+ "engines": {
+ "node": ">=22.12.0"
+ }
+ },
+ "node_modules/@commitlint/config-validator": {
+ "version": "21.2.0",
+ "resolved": "https://registry.npmjs.org/@commitlint/config-validator/-/config-validator-21.2.0.tgz",
+ "integrity": "sha512-t7AzNHAKeIdo/3NRGwzpufKHsKkPHmFs/56N2Fnsh0/r0rGtnQzTxk6vnFgjaGr4hdSQKNB50/KAhR9Yk4LJKA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@commitlint/types": "^21.2.0",
+ "ajv": "^8.11.0"
+ },
+ "engines": {
+ "node": ">=22.12.0"
+ }
+ },
+ "node_modules/@commitlint/ensure": {
+ "version": "21.2.0",
+ "resolved": "https://registry.npmjs.org/@commitlint/ensure/-/ensure-21.2.0.tgz",
+ "integrity": "sha512-76IF9vDNS13lAzEEik9eKwzt8f9hYhWiwVXZ2AnyLCz5/f511FsEQ3pw1X3/zSQpdRLQU7i5qDMVKyXi1GWjSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@commitlint/types": "^21.2.0",
+ "es-toolkit": "^1.46.0"
+ },
+ "engines": {
+ "node": ">=22.12.0"
+ }
+ },
+ "node_modules/@commitlint/execute-rule": {
+ "version": "21.0.1",
+ "resolved": "https://registry.npmjs.org/@commitlint/execute-rule/-/execute-rule-21.0.1.tgz",
+ "integrity": "sha512-RifH+FmImozKBE6mozhF4K3r2RRKP7SMi/Q/zLCmExtp5e05lhHOUYqGBlFBAGNHaZxU/WYw1XuugYK9jQzqnA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=22.12.0"
+ }
+ },
+ "node_modules/@commitlint/format": {
+ "version": "21.2.2",
+ "resolved": "https://registry.npmjs.org/@commitlint/format/-/format-21.2.2.tgz",
+ "integrity": "sha512-v6fvxZSc/AvVMROlr3H34+1766bZSYApRUSCAMjWamStPjKMvZ8GdvVA5YW/VQNgbFTmcMz6OYmSTJEvIjPrfA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@commitlint/types": "^21.2.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=22.12.0"
+ }
+ },
+ "node_modules/@commitlint/is-ignored": {
+ "version": "21.2.2",
+ "resolved": "https://registry.npmjs.org/@commitlint/is-ignored/-/is-ignored-21.2.2.tgz",
+ "integrity": "sha512-9UoKNgfFE3LU7FrzierCvk3CdDfMDeVGC86qZiT/n0TIjfq/dmZ9MHuXd45OTNRa26ZanmJRxEtmiXk/lEJihg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@commitlint/types": "^21.2.0",
+ "semver": "^7.6.0"
+ },
+ "engines": {
+ "node": ">=22.12.0"
+ }
+ },
+ "node_modules/@commitlint/is-ignored/node_modules/semver": {
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@commitlint/lint": {
+ "version": "21.2.2",
+ "resolved": "https://registry.npmjs.org/@commitlint/lint/-/lint-21.2.2.tgz",
+ "integrity": "sha512-Fy8JxEBzdmsYWFude/61GxXu5O+wEymwiRK2z9GL9R8mCsXphCoGxAFc5iHn5mjlfcSrhiiONE+ksf4KOjnaPg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@commitlint/is-ignored": "^21.2.2",
+ "@commitlint/parse": "^21.2.2",
+ "@commitlint/rules": "^21.2.2",
+ "@commitlint/types": "^21.2.0"
+ },
+ "engines": {
+ "node": ">=22.12.0"
+ }
+ },
+ "node_modules/@commitlint/load": {
+ "version": "21.2.2",
+ "resolved": "https://registry.npmjs.org/@commitlint/load/-/load-21.2.2.tgz",
+ "integrity": "sha512-0Tt6wDPX167cjKC5D4zhm0+20wJJG+TN/TKovMOspfSe78rOnKX+MNzlVNiu6HyQPZChPJ8QBH31MVt6Bb8fCg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@commitlint/config-validator": "^21.2.0",
+ "@commitlint/execute-rule": "^21.0.1",
+ "@commitlint/resolve-extends": "^21.2.2",
+ "@commitlint/types": "^21.2.0",
+ "cosmiconfig": "^9.0.1",
+ "cosmiconfig-typescript-loader": "^6.1.0",
+ "es-toolkit": "^1.46.0",
+ "is-plain-obj": "^4.1.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=22.12.0"
+ }
+ },
+ "node_modules/@commitlint/message": {
+ "version": "21.2.0",
+ "resolved": "https://registry.npmjs.org/@commitlint/message/-/message-21.2.0.tgz",
+ "integrity": "sha512-YxGoiXD/HXNXLJPrQwE5poXa+XH0CBEm+mdvbHQP0g6MV/dmJyUFCzPNzZbxL93GvZ70TmtTK0Z0/IBpAqHv8g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=22.12.0"
+ }
+ },
+ "node_modules/@commitlint/parse": {
+ "version": "21.2.2",
+ "resolved": "https://registry.npmjs.org/@commitlint/parse/-/parse-21.2.2.tgz",
+ "integrity": "sha512-MEkobPfvRp+z06Wro8HMG1BDGHzZmj82A1LH1nWeG3ipHpg/x4m6v3wEDvMBIKjRFUnfR3nBeFs3MVCr7UdAmg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@commitlint/types": "^21.2.0",
+ "conventional-changelog-angular": "^9.0.0",
+ "conventional-commits-parser": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=22.12.0"
+ }
+ },
+ "node_modules/@commitlint/read": {
+ "version": "21.2.1",
+ "resolved": "https://registry.npmjs.org/@commitlint/read/-/read-21.2.1.tgz",
+ "integrity": "sha512-hUW7EJQnNTL0vPOmVMNK4CrnrNBN0nN+JJHReFkdHO5y4iyHeEmTBwuC15OCqUTjxWo7idnH1LftfpWVIaPWIA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@commitlint/top-level": "^21.2.0",
+ "@commitlint/types": "^21.2.0",
+ "@conventional-changelog/git-client": "^3.0.0",
+ "tinyexec": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=22.12.0"
+ }
+ },
+ "node_modules/@commitlint/resolve-extends": {
+ "version": "21.2.2",
+ "resolved": "https://registry.npmjs.org/@commitlint/resolve-extends/-/resolve-extends-21.2.2.tgz",
+ "integrity": "sha512-RPkJ/IFi7sMUUVbZLqwWFtWw/zRDcfFsmrPSiTMrt5wb7AdxOr86EGQFvmGzef5QKV5IPBWWCujqVTw1RWX44A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@commitlint/config-validator": "^21.2.0",
+ "@commitlint/types": "^21.2.0",
+ "es-toolkit": "^1.46.0",
+ "global-directory": "^5.0.0",
+ "resolve-from": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=22.12.0"
+ }
+ },
+ "node_modules/@commitlint/rules": {
+ "version": "21.2.2",
+ "resolved": "https://registry.npmjs.org/@commitlint/rules/-/rules-21.2.2.tgz",
+ "integrity": "sha512-eplQzyYkBjYB1HyyRj8hkcK11Y9DU9nuBz7uOKEd6NpE9NGDytLFCAnlRE+OoiK/5sHEJsaz2RGhuWBvYzIbNA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@commitlint/ensure": "^21.2.0",
+ "@commitlint/message": "^21.2.0",
+ "@commitlint/to-lines": "^21.0.1",
+ "@commitlint/types": "^21.2.0"
+ },
+ "engines": {
+ "node": ">=22.12.0"
+ }
+ },
+ "node_modules/@commitlint/to-lines": {
+ "version": "21.0.1",
+ "resolved": "https://registry.npmjs.org/@commitlint/to-lines/-/to-lines-21.0.1.tgz",
+ "integrity": "sha512-bd1BFII7p1EQZre9Kaj+kKaMFP3cFCdt21K7DItVux9XP5WjLgJ0/Uy1pJJh9aPwVJ6SKg62PxqlZaHI8hQAXw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=22.12.0"
+ }
+ },
+ "node_modules/@commitlint/top-level": {
+ "version": "21.2.0",
+ "resolved": "https://registry.npmjs.org/@commitlint/top-level/-/top-level-21.2.0.tgz",
+ "integrity": "sha512-Y5gmQ+KxzqCrBFJfLvFEPvvwD3LDiNZoTT2yeFBm96M8qhmqSzQc5DvX3rheAaAMjyIvMXOCLS/mWfdpONsjyQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "escalade": "^3.2.0"
+ },
+ "engines": {
+ "node": ">=22.12.0"
+ }
+ },
+ "node_modules/@commitlint/types": {
+ "version": "21.2.0",
+ "resolved": "https://registry.npmjs.org/@commitlint/types/-/types-21.2.0.tgz",
+ "integrity": "sha512-7zVFCDB2reMvJH5dmbKnOQPjZEvjdJTH8jc0U/PIPU1r3/+vf5pD1HlfitV2MWsWXrvu7u39iY1lyLUPOaN0Gw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "conventional-commits-parser": "^7.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=22.12.0"
+ }
+ },
+ "node_modules/@conventional-changelog/git-client": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@conventional-changelog/git-client/-/git-client-3.1.2.tgz",
+ "integrity": "sha512-jZqwnJwf7nboIlAcw/mkOjVa6DexCcUOgT2oOQgkoi3z9vR8tGFkcMy2BFcYwjhL9sYcDDXkRQDayiDieCoW7A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@simple-libs/child-process-utils": "^2.0.0",
+ "@simple-libs/stream-utils": "^2.0.0",
+ "semver": "^7.5.2"
+ },
+ "engines": {
+ "node": ">=22"
+ },
+ "peerDependencies": {
+ "conventional-commits-filter": "^6.0.1",
+ "conventional-commits-parser": "^7.1.2"
+ },
+ "peerDependenciesMeta": {
+ "conventional-commits-filter": {
+ "optional": true
+ },
+ "conventional-commits-parser": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@conventional-changelog/git-client/node_modules/semver": {
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@conventional-changelog/template": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/@conventional-changelog/template/-/template-1.4.0.tgz",
+ "integrity": "sha512-aalGyl7dbB5PArRebDIX43ZvBlXrYm9uWzGJ26t+4SzJVPsOuvfILGGbw5X4yX7i50YEmJ8zvbiWnqH/AAnZqg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=22"
+ }
+ },
+ "node_modules/@cspell/cspell-bundled-dicts": {
+ "version": "10.1.1",
+ "resolved": "https://registry.npmjs.org/@cspell/cspell-bundled-dicts/-/cspell-bundled-dicts-10.1.1.tgz",
+ "integrity": "sha512-EvPFPWwEPbPn2pIAD4D6PRRFbuM5OCiYfY0GhmpQyWWteCwR4gRmRs06UOWqfSRKHYBL/rUqcr3OAfiF5ctAjw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@cspell/dict-ada": "^4.1.1",
+ "@cspell/dict-al": "^1.1.1",
+ "@cspell/dict-aws": "^4.0.17",
+ "@cspell/dict-bash": "^4.2.3",
+ "@cspell/dict-companies": "^3.2.12",
+ "@cspell/dict-cpp": "^7.1.0",
+ "@cspell/dict-cryptocurrencies": "^5.0.5",
+ "@cspell/dict-csharp": "^4.0.8",
+ "@cspell/dict-css": "^4.1.2",
+ "@cspell/dict-dart": "^2.3.2",
+ "@cspell/dict-data-science": "^2.0.16",
+ "@cspell/dict-django": "^4.1.6",
+ "@cspell/dict-docker": "^1.1.17",
+ "@cspell/dict-dotnet": "^5.0.13",
+ "@cspell/dict-elixir": "^4.0.8",
+ "@cspell/dict-en_us": "^4.4.36",
+ "@cspell/dict-en-common-misspellings": "^2.2.0",
+ "@cspell/dict-en-gb-mit": "^3.1.25",
+ "@cspell/dict-filetypes": "^3.0.18",
+ "@cspell/dict-flutter": "^1.1.1",
+ "@cspell/dict-fonts": "^4.0.6",
+ "@cspell/dict-fsharp": "^1.1.1",
+ "@cspell/dict-fullstack": "^3.2.9",
+ "@cspell/dict-gaming-terms": "^1.1.2",
+ "@cspell/dict-git": "^3.1.0",
+ "@cspell/dict-golang": "^6.0.27",
+ "@cspell/dict-google": "^1.0.9",
+ "@cspell/dict-haskell": "^4.0.6",
+ "@cspell/dict-html": "^4.0.16",
+ "@cspell/dict-html-symbol-entities": "^4.0.5",
+ "@cspell/dict-java": "^5.0.12",
+ "@cspell/dict-julia": "^1.1.1",
+ "@cspell/dict-k8s": "^1.0.13",
+ "@cspell/dict-kotlin": "^1.1.1",
+ "@cspell/dict-latex": "^5.1.0",
+ "@cspell/dict-lorem-ipsum": "^4.0.5",
+ "@cspell/dict-lua": "^4.0.8",
+ "@cspell/dict-makefile": "^1.0.5",
+ "@cspell/dict-markdown": "^2.0.18",
+ "@cspell/dict-monkeyc": "^1.1.0",
+ "@cspell/dict-node": "^5.0.9",
+ "@cspell/dict-npm": "^5.2.47",
+ "@cspell/dict-php": "^4.1.1",
+ "@cspell/dict-powershell": "^5.0.15",
+ "@cspell/dict-public-licenses": "^2.0.16",
+ "@cspell/dict-python": "^4.4.0",
+ "@cspell/dict-r": "^2.1.1",
+ "@cspell/dict-ruby": "^5.1.2",
+ "@cspell/dict-rust": "^4.1.2",
+ "@cspell/dict-scala": "^5.0.9",
+ "@cspell/dict-shell": "^1.2.0",
+ "@cspell/dict-software-terms": "^5.4.1",
+ "@cspell/dict-sql": "^2.2.1",
+ "@cspell/dict-svelte": "^1.0.7",
+ "@cspell/dict-swift": "^2.0.6",
+ "@cspell/dict-terraform": "^1.1.4",
+ "@cspell/dict-typescript": "^3.2.3",
+ "@cspell/dict-vue": "^3.0.5",
+ "@cspell/dict-zig": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=22.18.0"
+ }
+ },
+ "node_modules/@cspell/cspell-json-reporter": {
+ "version": "10.1.1",
+ "resolved": "https://registry.npmjs.org/@cspell/cspell-json-reporter/-/cspell-json-reporter-10.1.1.tgz",
+ "integrity": "sha512-6makYERzgDCBGeOG7jSgZBOWxJKxBV6k0UQ60xDBZNrccACP45q8zO9YIXdHAYLmHB52l3Iz2vifeG7zhue7jA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@cspell/cspell-types": "10.1.1"
+ },
+ "engines": {
+ "node": ">=22.18.0"
+ }
+ },
+ "node_modules/@cspell/cspell-performance-monitor": {
+ "version": "10.1.1",
+ "resolved": "https://registry.npmjs.org/@cspell/cspell-performance-monitor/-/cspell-performance-monitor-10.1.1.tgz",
+ "integrity": "sha512-M3Bq+K0jmbnAE2dWB8vG/TljagcKWuILTpcf6sIMB/jTaT7QH9FyzVp0Q6srlN3GEdSBwFBqrfWCorkjRUd0cQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=22.18.0"
+ }
+ },
+ "node_modules/@cspell/cspell-pipe": {
+ "version": "10.1.1",
+ "resolved": "https://registry.npmjs.org/@cspell/cspell-pipe/-/cspell-pipe-10.1.1.tgz",
+ "integrity": "sha512-WOuL9zdcl38b9P5Kpco3c1vg5v82bSWZ7LVeARbyrOmW60HxS8uP4nkbCGeU6pINvJCs0JUQxEZCRMHuEF0dfQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=22.18.0"
+ }
+ },
+ "node_modules/@cspell/cspell-resolver": {
+ "version": "10.1.1",
+ "resolved": "https://registry.npmjs.org/@cspell/cspell-resolver/-/cspell-resolver-10.1.1.tgz",
+ "integrity": "sha512-bbsykc5c7dDdPTDFdsY0GKwXlflDDaTUycAegziFLCcp8b/q1txQmqBxx95ok1pjpU10sWxiJztZZRayFcM5sg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "global-directory": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=22.18.0"
+ }
+ },
+ "node_modules/@cspell/cspell-service-bus": {
+ "version": "10.1.1",
+ "resolved": "https://registry.npmjs.org/@cspell/cspell-service-bus/-/cspell-service-bus-10.1.1.tgz",
+ "integrity": "sha512-Z3BUsVllqyAoJSGXcyFp0hXtBVKEfrxVcSy3UPoSoLW9gZZjXmFwOorhga3+fMseC9VRK3L6mKSccn9YGAjsnA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=22.18.0"
+ }
+ },
+ "node_modules/@cspell/cspell-types": {
+ "version": "10.1.1",
+ "resolved": "https://registry.npmjs.org/@cspell/cspell-types/-/cspell-types-10.1.1.tgz",
+ "integrity": "sha512-lsc+/pazJQ47Zy7qyuFiQRZzco+kBSUmAtCzbmT96pFpAVxR+b4A8qRBoTdiq4OXBRXRT+CqWMNynSiwQJWM6g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=22.18.0"
+ }
+ },
+ "node_modules/@cspell/cspell-worker": {
+ "version": "10.1.1",
+ "resolved": "https://registry.npmjs.org/@cspell/cspell-worker/-/cspell-worker-10.1.1.tgz",
+ "integrity": "sha512-X9Ehif6c/9BRIODBjWMr0paHQrY3Jw7q0FWeSdxPZrnKhgJ+cX4vtvqlSD1dGXMV2/fGh10p1HznuDhNlQ4tjg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cspell-lib": "10.1.1"
+ },
+ "engines": {
+ "node": ">=22.18.0"
+ }
+ },
+ "node_modules/@cspell/dict-ada": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/@cspell/dict-ada/-/dict-ada-4.1.1.tgz",
+ "integrity": "sha512-E+0YW9RhZod/9Qy2gxfNZiHJjCYFlCdI69br1eviQQWB8yOTJX0JHXLs79kOYhSW0kINPVUdvddEBe6Lu6CjGQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@cspell/dict-al": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@cspell/dict-al/-/dict-al-1.1.1.tgz",
+ "integrity": "sha512-sD8GCaZetgQL4+MaJLXqbzWcRjfKVp8x+px3HuCaaiATAAtvjwUQ5/Iubiqwfd1boIh2Y1/3EgM3TLQ7Q8e0wQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@cspell/dict-aws": {
+ "version": "4.0.17",
+ "resolved": "https://registry.npmjs.org/@cspell/dict-aws/-/dict-aws-4.0.17.tgz",
+ "integrity": "sha512-ORcblTWcdlGjIbWrgKF+8CNEBQiLVKdUOFoTn0KPNkAYnFcdPP0muT4892h7H4Xafh3j72wqB4/loQ6Nti9E/w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@cspell/dict-bash": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/@cspell/dict-bash/-/dict-bash-4.2.3.tgz",
+ "integrity": "sha512-ljUZoKHbDqw5Sx0qpL2qTUlmkmr+vhZH/sCNrNaBZKTbdgiswErSnIF1jRbGmEitJNxHRHWsuZyVgnTGfVO1Yw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@cspell/dict-shell": "1.2.0"
+ }
+ },
+ "node_modules/@cspell/dict-companies": {
+ "version": "3.2.12",
+ "resolved": "https://registry.npmjs.org/@cspell/dict-companies/-/dict-companies-3.2.12.tgz",
+ "integrity": "sha512-mjiz/N3zWOCsz5VfwMUydSl7uW0OU9H2PnbCNc3RV44Vj6Q59CSp6EYGSGZQxrXU1gpsuZUrwr6QCjNjFOOg5A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@cspell/dict-cpp": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/@cspell/dict-cpp/-/dict-cpp-7.1.0.tgz",
+ "integrity": "sha512-rcjycobioQUd9Jm/Y1n8U+sq6ytpZ0iV8AYIo+vyEFfutC5x7g6hL6Fh3bCdh6IporGHRqfEutGK/4sfopD2ZA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@cspell/dict-cryptocurrencies": {
+ "version": "5.0.5",
+ "resolved": "https://registry.npmjs.org/@cspell/dict-cryptocurrencies/-/dict-cryptocurrencies-5.0.5.tgz",
+ "integrity": "sha512-R68hYYF/rtlE6T/dsObStzN5QZw+0aQBinAXuWCVqwdS7YZo0X33vGMfChkHaiCo3Z2+bkegqHlqxZF4TD3rUA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@cspell/dict-csharp": {
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/@cspell/dict-csharp/-/dict-csharp-4.0.8.tgz",
+ "integrity": "sha512-qmk45pKFHSxckl5mSlbHxmDitSsGMlk/XzFgt7emeTJWLNSTUK//MbYAkBNRtfzB4uD7pAFiKgpKgtJrTMRnrQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@cspell/dict-css": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/@cspell/dict-css/-/dict-css-4.1.2.tgz",
+ "integrity": "sha512-+ylGoKdwZ2sVOCOnU2Eq5wDZx+RaVX3HoKyNHGGsFvhSw6IidQ6tH/mAPKBDofViHJoWCPNlklE0lTr6MDG3QA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@cspell/dict-dart": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/@cspell/dict-dart/-/dict-dart-2.3.2.tgz",
+ "integrity": "sha512-sUiLW56t9gfZcu8iR/5EUg+KYyRD83Cjl3yjDEA2ApVuJvK1HhX+vn4e4k4YfjpUQMag8XO2AaRhARE09+/rqw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@cspell/dict-data-science": {
+ "version": "2.0.16",
+ "resolved": "https://registry.npmjs.org/@cspell/dict-data-science/-/dict-data-science-2.0.16.tgz",
+ "integrity": "sha512-M72mxv5asuAnORurz4iXRJ+Tw9XBq6eu7D2Ne7biP0Z1RciKGNxXWu9JycA/KlVvK1hAlKj/fANlXhuEWpXKFg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@cspell/dict-django": {
+ "version": "4.1.6",
+ "resolved": "https://registry.npmjs.org/@cspell/dict-django/-/dict-django-4.1.6.tgz",
+ "integrity": "sha512-SdbSFDGy9ulETqNz15oWv2+kpWLlk8DJYd573xhIkeRdcXOjskRuxjSZPKfW7O3NxN/KEf3gm3IevVOiNuFS+w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@cspell/dict-docker": {
+ "version": "1.1.17",
+ "resolved": "https://registry.npmjs.org/@cspell/dict-docker/-/dict-docker-1.1.17.tgz",
+ "integrity": "sha512-OcnVTIpHIYYKhztNTyK8ShAnXTfnqs43hVH6p0py0wlcwRIXe5uj4f12n7zPf2CeBI7JAlPjEsV0Rlf4hbz/xQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@cspell/dict-dotnet": {
+ "version": "5.0.13",
+ "resolved": "https://registry.npmjs.org/@cspell/dict-dotnet/-/dict-dotnet-5.0.13.tgz",
+ "integrity": "sha512-xPp7jMnFpOri7tzmqmm/dXMolXz1t2bhNqxYkOyMqXhvs08oc7BFs+EsbDY0X7hqiISgeFZGNqn0dOCr+ncPYw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@cspell/dict-elixir": {
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/@cspell/dict-elixir/-/dict-elixir-4.0.8.tgz",
+ "integrity": "sha512-CyfphrbMyl4Ms55Vzuj+mNmd693HjBFr9hvU+B2YbFEZprE5AG+EXLYTMRWrXbpds4AuZcvN3deM2XVB80BN/Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@cspell/dict-en_us": {
+ "version": "4.4.37",
+ "resolved": "https://registry.npmjs.org/@cspell/dict-en_us/-/dict-en_us-4.4.37.tgz",
+ "integrity": "sha512-rMKyPrk2SKmDU+Bj0U0ixsv4Du93oijwovc8XlUin0zwKOJZb0/PFG5Df4P8CrM2CrLeovpaFvhoI98DigxBLw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@cspell/dict-en-common-misspellings": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@cspell/dict-en-common-misspellings/-/dict-en-common-misspellings-2.2.0.tgz",
+ "integrity": "sha512-5PmCHv+AhY0LVNo3bE1FdRKMmw1esKj83GPwLymnVPYNtlI2Jf6y27EjC0azg4zny5keWmku0GQiMf55Fi+qeA==",
+ "dev": true,
+ "license": "CC BY-SA 4.0"
+ },
+ "node_modules/@cspell/dict-en-gb-mit": {
+ "version": "3.1.26",
+ "resolved": "https://registry.npmjs.org/@cspell/dict-en-gb-mit/-/dict-en-gb-mit-3.1.26.tgz",
+ "integrity": "sha512-F9Y/45+1oIPzfL/pyWLp4kzX+vddHPAYpIpIF+45bdc65AebeEiQHKzSmKgGGTd3qbQc3fATw2kAFdLryiQpjQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@cspell/dict-filetypes": {
+ "version": "3.0.18",
+ "resolved": "https://registry.npmjs.org/@cspell/dict-filetypes/-/dict-filetypes-3.0.18.tgz",
+ "integrity": "sha512-yU7RKD/x1IWmDLzWeiItMwgV+6bUcU/af23uS0+uGiFUbsY1qWV/D4rxlAAO6Z7no3J2z8aZOkYIOvUrJq0Rcw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@cspell/dict-flutter": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@cspell/dict-flutter/-/dict-flutter-1.1.1.tgz",
+ "integrity": "sha512-UlOzRcH2tNbFhZmHJN48Za/2/MEdRHl2BMkCWZBYs+30b91mWvBfzaN4IJQU7dUZtowKayVIF9FzvLZtZokc5A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@cspell/dict-fonts": {
+ "version": "4.0.6",
+ "resolved": "https://registry.npmjs.org/@cspell/dict-fonts/-/dict-fonts-4.0.6.tgz",
+ "integrity": "sha512-aR/0csY01dNb0A1tw/UmN9rKgHruUxsYsvXu6YlSBJFu60s26SKr/k1o4LavpHTQ+lznlYMqAvuxGkE4Flliqw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@cspell/dict-fsharp": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@cspell/dict-fsharp/-/dict-fsharp-1.1.1.tgz",
+ "integrity": "sha512-imhs0u87wEA4/cYjgzS0tAyaJpwG7vwtC8UyMFbwpmtw+/bgss+osNfyqhYRyS/ehVCWL17Ewx2UPkexjKyaBA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@cspell/dict-fullstack": {
+ "version": "3.2.9",
+ "resolved": "https://registry.npmjs.org/@cspell/dict-fullstack/-/dict-fullstack-3.2.9.tgz",
+ "integrity": "sha512-diZX+usW5aZ4/b2T0QM/H/Wl9aNMbdODa1Jq0ReBr/jazmNeWjd+PyqeVgzd1joEaHY+SAnjrf/i9CwKd2ZtWQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@cspell/dict-gaming-terms": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@cspell/dict-gaming-terms/-/dict-gaming-terms-1.1.2.tgz",
+ "integrity": "sha512-9XnOvaoTBscq0xuD6KTEIkk9hhdfBkkvJAIsvw3JMcnp1214OCGW8+kako5RqQ2vTZR3Tnf3pc57o7VgkM0q1Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@cspell/dict-git": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@cspell/dict-git/-/dict-git-3.1.0.tgz",
+ "integrity": "sha512-KEt9zGkxqGy2q1nwH4CbyqTSv5nadpn8BAlDnzlRcnL0Xb3LX9xTgSGShKvzb0bw35lHoYyLWN2ZKAqbC4pgGQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@cspell/dict-golang": {
+ "version": "6.0.27",
+ "resolved": "https://registry.npmjs.org/@cspell/dict-golang/-/dict-golang-6.0.27.tgz",
+ "integrity": "sha512-rRDb2JL8EefaezHGTEclKXCs4eMOS0q/datk94Lm7KQOhlGlzS9FDVZiR1/guh0o+iJCmejQuf5NR2FQeQSWsg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@cspell/dict-google": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@cspell/dict-google/-/dict-google-1.0.9.tgz",
+ "integrity": "sha512-biL65POqialY0i4g6crj7pR6JnBkbsPovB2WDYkj3H4TuC/QXv7Pu5pdPxeUJA6TSCHI7T5twsO4VSVyRxD9CA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@cspell/dict-haskell": {
+ "version": "4.0.6",
+ "resolved": "https://registry.npmjs.org/@cspell/dict-haskell/-/dict-haskell-4.0.6.tgz",
+ "integrity": "sha512-ib8SA5qgftExpYNjWhpYIgvDsZ/0wvKKxSP+kuSkkak520iPvTJumEpIE+qPcmJQo4NzdKMN8nEfaeci4OcFAQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@cspell/dict-html": {
+ "version": "4.0.16",
+ "resolved": "https://registry.npmjs.org/@cspell/dict-html/-/dict-html-4.0.16.tgz",
+ "integrity": "sha512-9B6/Cpb5YVcYgsEAlKudun1yd4ONllYS/ZibKLYea2apPR+l2vQdARnPrP9kTqa7NUNfRKl7m9Fb6k9rjimnow==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@cspell/dict-html-symbol-entities": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/@cspell/dict-html-symbol-entities/-/dict-html-symbol-entities-4.0.5.tgz",
+ "integrity": "sha512-429alTD4cE0FIwpMucvSN35Ld87HCyuM8mF731KU5Rm4Je2SG6hmVx7nkBsLyrmH3sQukTcr1GaiZsiEg8svPA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@cspell/dict-java": {
+ "version": "5.0.12",
+ "resolved": "https://registry.npmjs.org/@cspell/dict-java/-/dict-java-5.0.12.tgz",
+ "integrity": "sha512-qPSNhTcl7LGJ5Qp6VN71H8zqvRQK04S08T67knMq9hTA8U7G1sTKzLmBaDOFhq17vNX/+rT+rbRYp+B5Nwza1A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@cspell/dict-julia": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@cspell/dict-julia/-/dict-julia-1.1.1.tgz",
+ "integrity": "sha512-WylJR9TQ2cgwd5BWEOfdO3zvDB+L7kYFm0I9u0s9jKHWQ6yKmfKeMjU9oXxTBxIufhCXm92SKwwVNAC7gjv+yA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@cspell/dict-k8s": {
+ "version": "1.0.13",
+ "resolved": "https://registry.npmjs.org/@cspell/dict-k8s/-/dict-k8s-1.0.13.tgz",
+ "integrity": "sha512-ELGkS13k7K/NEfVimBSrxVTfqXvOF/Kvxj4I62YxRm8bvHbfoXgrGaOx28lPiNRz+dmu+yYtvuXbnURKtYbC6g==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@cspell/dict-kotlin": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@cspell/dict-kotlin/-/dict-kotlin-1.1.1.tgz",
+ "integrity": "sha512-J3NzzfgmxRvEeOe3qUXnSJQCd38i/dpF9/t3quuWh6gXM+krsAXP75dY1CzDmS8mrJAlBdVBeAW5eAZTD8g86Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@cspell/dict-latex": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/@cspell/dict-latex/-/dict-latex-5.1.0.tgz",
+ "integrity": "sha512-qxT4guhysyBt0gzoliXYEBYinkAdEtR2M7goRaUH0a7ltCsoqqAeEV8aXYRIdZGcV77gYSobvu3jJL038tlPAw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@cspell/dict-lorem-ipsum": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/@cspell/dict-lorem-ipsum/-/dict-lorem-ipsum-4.0.5.tgz",
+ "integrity": "sha512-9a4TJYRcPWPBKkQAJ/whCu4uCAEgv/O2xAaZEI0n4y1/l18Yyx8pBKoIX5QuVXjjmKEkK7hi5SxyIsH7pFEK9Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@cspell/dict-lua": {
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/@cspell/dict-lua/-/dict-lua-4.0.8.tgz",
+ "integrity": "sha512-N4PkgNDMu9JVsRu7JBS/3E/dvfItRgk9w5ga2dKq+JupP2Y3lojNaAVFhXISh4Y0a6qXDn2clA6nvnavQ/jjLA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@cspell/dict-makefile": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/@cspell/dict-makefile/-/dict-makefile-1.0.5.tgz",
+ "integrity": "sha512-4vrVt7bGiK8Rx98tfRbYo42Xo2IstJkAF4tLLDMNQLkQ86msDlYSKG1ZCk8Abg+EdNcFAjNhXIiNO+w4KflGAQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@cspell/dict-markdown": {
+ "version": "2.0.18",
+ "resolved": "https://registry.npmjs.org/@cspell/dict-markdown/-/dict-markdown-2.0.18.tgz",
+ "integrity": "sha512-KLRSwVrwKDz4n7bl2XQjzvaBZbGgx5QKkP5Ok1b24xaO3TpXsLhAbAn1mItvFlI2tF6Rkt83iYjQcYppywuf5Q==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@cspell/dict-css": "^4.1.2",
+ "@cspell/dict-html": "^4.0.16",
+ "@cspell/dict-html-symbol-entities": "^4.0.5",
+ "@cspell/dict-typescript": "^3.2.3"
+ }
+ },
+ "node_modules/@cspell/dict-monkeyc": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@cspell/dict-monkeyc/-/dict-monkeyc-1.1.0.tgz",
+ "integrity": "sha512-mc/hgvSy/emOIYtc8kuVEaLUlnaERunfAubfJ5plUXq6s0A69bcukJzUzSt9C0UTCwS28641ILRPv80m3L9QbA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@cspell/dict-node": {
+ "version": "5.0.9",
+ "resolved": "https://registry.npmjs.org/@cspell/dict-node/-/dict-node-5.0.9.tgz",
+ "integrity": "sha512-hO+ga+uYZ/WA4OtiMEyKt5rDUlUyu3nXMf8KVEeqq2msYvAPdldKBGH7lGONg6R/rPhv53Rb+0Y1SLdoK1+7wQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@cspell/dict-npm": {
+ "version": "5.2.47",
+ "resolved": "https://registry.npmjs.org/@cspell/dict-npm/-/dict-npm-5.2.47.tgz",
+ "integrity": "sha512-r5ePbkmRtpMwiv+MHd6be1xIRLuXoEwaSLIZLu3mY+qtPVo0LUj/kVl4YeIxHONiwyef75zqS3gnT+s5UF7diQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@cspell/dict-php": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/@cspell/dict-php/-/dict-php-4.1.1.tgz",
+ "integrity": "sha512-EXelI+4AftmdIGtA8HL8kr4WlUE11OqCSVlnIgZekmTkEGSZdYnkFdiJ5IANSALtlQ1mghKjz+OFqVs6yowgWA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@cspell/dict-powershell": {
+ "version": "5.0.15",
+ "resolved": "https://registry.npmjs.org/@cspell/dict-powershell/-/dict-powershell-5.0.15.tgz",
+ "integrity": "sha512-l4S5PAcvCFcVDMJShrYD0X6Huv9dcsQPlsVsBGbH38wvuN7gS7+GxZFAjTNxDmTY1wrNi1cCatSg6Pu2BW4rgg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@cspell/dict-public-licenses": {
+ "version": "2.0.16",
+ "resolved": "https://registry.npmjs.org/@cspell/dict-public-licenses/-/dict-public-licenses-2.0.16.tgz",
+ "integrity": "sha512-EQRrPvEOmwhwWezV+W7LjXbIBjiy6y/shrET6Qcpnk3XANTzfvWflf9PnJ5kId/oKWvihFy0za0AV1JHd03pSQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@cspell/dict-python": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/@cspell/dict-python/-/dict-python-4.5.0.tgz",
+ "integrity": "sha512-O1lSuFXoX8p+4hvJMbqvg3blbn0Zx8LHqdPQSNaOxFZGf7hmq6pc8sfta+98E7Q/WtDooAnU3XpBxBXcn0r94g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@cspell/dict-data-science": "^2.0.16"
+ }
+ },
+ "node_modules/@cspell/dict-r": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/@cspell/dict-r/-/dict-r-2.1.1.tgz",
+ "integrity": "sha512-71Ka+yKfG4ZHEMEmDxc6+blFkeTTvgKbKAbwiwQAuKl3zpqs1Y0vUtwW2N4b3LgmSPhV3ODVY0y4m5ofqDuKMw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@cspell/dict-ruby": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/@cspell/dict-ruby/-/dict-ruby-5.1.2.tgz",
+ "integrity": "sha512-f6Slf11N91ittf71AWlfVVG9GZPezVRBfcMPCTNTWhUsY/VEspkBRZYDhPXjV4df3jqHy5YJs8nlsFcFVF/bMA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@cspell/dict-rust": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/@cspell/dict-rust/-/dict-rust-4.1.2.tgz",
+ "integrity": "sha512-O1FHrumYcO+HZti3dHfBPUdnDFkI+nbYK3pxYmiM1sr+G0ebOd6qchmswS0Wsc6ZdEVNiPYJY/gZQR6jfW3uOg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@cspell/dict-scala": {
+ "version": "5.0.9",
+ "resolved": "https://registry.npmjs.org/@cspell/dict-scala/-/dict-scala-5.0.9.tgz",
+ "integrity": "sha512-AjVcVAELgllybr1zk93CJ5wSUNu/Zb5kIubymR/GAYkMyBdYFCZ3Zbwn4Zz8GJlFFAbazABGOu0JPVbeY59vGg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@cspell/dict-shell": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@cspell/dict-shell/-/dict-shell-1.2.0.tgz",
+ "integrity": "sha512-PVctvT22lJ49niMiakO8xieY7ELCAzjSqhejWR7bAMb5AZ9F4WDEs+XdGMnoVHWeXq7K5rcepLPmEJb+37zzIw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@cspell/dict-software-terms": {
+ "version": "5.4.2",
+ "resolved": "https://registry.npmjs.org/@cspell/dict-software-terms/-/dict-software-terms-5.4.2.tgz",
+ "integrity": "sha512-C2tS1JjxqHef9TpPRm08a6n+PJCrKrD6FsmPEyXb2J6TFef4FFnKRcGOf3pFnd+a6/oyQgXZWeKiECH6lBFjQA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@cspell/dict-sql": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/@cspell/dict-sql/-/dict-sql-2.2.1.tgz",
+ "integrity": "sha512-qDHF8MpAYCf4pWU8NKbnVGzkoxMNrFqBHyG/dgrlic5EQiKANCLELYtGlX5auIMDLmTf1inA0eNtv74tyRJ/vg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@cspell/dict-svelte": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/@cspell/dict-svelte/-/dict-svelte-1.0.7.tgz",
+ "integrity": "sha512-hGZsGqP0WdzKkdpeVLBivRuSNzOTvN036EBmpOwxH+FTY2DuUH7ecW+cSaMwOgmq5JFSdTcbTNFlNC8HN8lhaQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@cspell/dict-swift": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/@cspell/dict-swift/-/dict-swift-2.0.6.tgz",
+ "integrity": "sha512-PnpNbrIbex2aqU1kMgwEKvCzgbkHtj3dlFLPMqW1vSniop7YxaDTtvTUO4zA++ugYAEL+UK8vYrBwDPTjjvSnA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@cspell/dict-terraform": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@cspell/dict-terraform/-/dict-terraform-1.1.4.tgz",
+ "integrity": "sha512-Ere42ilvMFvQA4GlcN0OKlruMPR6EsvaB+iTHzj2xc+NJGRK64V7yApUcWrOrSgTiM/vhWXPIsK3OMfiAiNdmA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@cspell/dict-typescript": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/@cspell/dict-typescript/-/dict-typescript-3.2.3.tgz",
+ "integrity": "sha512-zXh1wYsNljQZfWWdSPYwQhpwiuW0KPW1dSd8idjMRvSD0aSvWWHoWlrMsmZeRl4qM4QCEAjua8+cjflm41cQBg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@cspell/dict-vue": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/@cspell/dict-vue/-/dict-vue-3.0.5.tgz",
+ "integrity": "sha512-Mqutb8jbM+kIcywuPQCCaK5qQHTdaByoEO2J9LKFy3sqAdiBogNkrplqUK0HyyRFgCfbJUgjz3N85iCMcWH0JA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@cspell/dict-zig": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@cspell/dict-zig/-/dict-zig-1.0.0.tgz",
+ "integrity": "sha512-XibBIxBlVosU06+M6uHWkFeT0/pW5WajDRYdXG2CgHnq85b0TI/Ks0FuBJykmsgi2CAD3Qtx8UHFEtl/DSFnAQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@cspell/dynamic-import": {
+ "version": "10.1.1",
+ "resolved": "https://registry.npmjs.org/@cspell/dynamic-import/-/dynamic-import-10.1.1.tgz",
+ "integrity": "sha512-bNI+Gp0Rnx/WyOvni4TksvNWeQvCfyQAhZRIGMt+9bbLyniVagnftnoahV/yoF9h3WWxU3tL2eaVZx9B5dijuQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@cspell/url": "10.1.1",
+ "import-meta-resolve": "^4.2.0"
+ },
+ "engines": {
+ "node": ">=22.18.0"
+ }
+ },
+ "node_modules/@cspell/filetypes": {
+ "version": "10.1.1",
+ "resolved": "https://registry.npmjs.org/@cspell/filetypes/-/filetypes-10.1.1.tgz",
+ "integrity": "sha512-sSREdKF4roC4QM2n0qUgv10W74nUO7CUhDTCz3Sd3mHlUM46WI8QqbAIZFNTm5U4SuVaVAmXzsZvM69O/rOjvQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=22.18.0"
+ }
+ },
+ "node_modules/@cspell/rpc": {
+ "version": "10.1.1",
+ "resolved": "https://registry.npmjs.org/@cspell/rpc/-/rpc-10.1.1.tgz",
+ "integrity": "sha512-sWQ++D8++6pAv8FSCfJOpc/t/yoK1r0xwcBb23SenOA9nvcmI7CwAp2FK2oByLnDTe+Bh6mQWtRRZSr3gfoGhQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=22.18.0"
+ }
+ },
+ "node_modules/@cspell/strong-weak-map": {
+ "version": "10.1.1",
+ "resolved": "https://registry.npmjs.org/@cspell/strong-weak-map/-/strong-weak-map-10.1.1.tgz",
+ "integrity": "sha512-s7PsRDfZO5J1dB0gtlaeNxwaibDUCYIFlbdzMawlUejR/UNrhDgDIr48OScoOt5SaUHijEVpFDkrWVyCs5R7oQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=22.18.0"
+ }
+ },
+ "node_modules/@cspell/url": {
+ "version": "10.1.1",
+ "resolved": "https://registry.npmjs.org/@cspell/url/-/url-10.1.1.tgz",
+ "integrity": "sha512-5O2bZ5H/zPomc4qshTpfCbIltallsdQNwlw6j/Gl+eVElrlFsgc0kzuYJdk1BMnpA0ZXPXYH7XVKCGIKzLcr2g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=22.18.0"
+ }
+ },
+ "node_modules/@csstools/css-parser-algorithms": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
+ "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-tokenizer": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
+ "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
"node_modules/@esbuild/aix-ppc64": {
- "version": "0.25.10",
- "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.10.tgz",
- "integrity": "sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw==",
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
+ "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
+ "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
+ "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
+ "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
+ "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
+ "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
+ "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
+ "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
+ "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
+ "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
+ "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
+ "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
+ "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
+ "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
"cpu": [
"ppc64"
],
@@ -72,33 +1446,67 @@
"license": "MIT",
"optional": true,
"os": [
- "aix"
+ "linux"
],
"engines": {
"node": ">=18"
}
},
- "node_modules/@esbuild/android-arm": {
- "version": "0.25.10",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.10.tgz",
- "integrity": "sha512-dQAxF1dW1C3zpeCDc5KqIYuZ1tgAdRXNoZP7vkBIRtKZPYe2xVr/d3SkirklCHudW1B45tGiUlz2pUWDfbDD4w==",
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
+ "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
"cpu": [
- "arm"
+ "riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
- "android"
+ "linux"
],
"engines": {
"node": ">=18"
}
},
- "node_modules/@esbuild/android-arm64": {
- "version": "0.25.10",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.10.tgz",
- "integrity": "sha512-LSQa7eDahypv/VO6WKohZGPSJDq5OVOo3UoFR1E4t4Gj1W7zEQMUhI+lo81H+DtB+kP+tDgBp+M4oNCwp6kffg==",
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
+ "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
+ "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
+ "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
"cpu": [
"arm64"
],
@@ -106,451 +1514,613 @@
"license": "MIT",
"optional": true,
"os": [
- "android"
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
+ "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
+ "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
+ "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
+ "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
+ "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
+ "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
+ "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
+ "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
],
"engines": {
"node": ">=18"
}
},
- "node_modules/@esbuild/android-x64": {
- "version": "0.25.10",
- "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.10.tgz",
- "integrity": "sha512-MiC9CWdPrfhibcXwr39p9ha1x0lZJ9KaVfvzA0Wxwz9ETX4v5CHfF09bx935nHlhi+MxhA63dKRRQLiVgSUtEg==",
+ "node_modules/@simple-libs/child-process-utils": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/@simple-libs/child-process-utils/-/child-process-utils-2.0.0.tgz",
+ "integrity": "sha512-dvNoRKLijXnD0XoJAz94pbNuB5GQgDr55UhpSPhffDkTT0Cmcqh9jSCOtwfT2d4H6MI9E7c4SgtMuJXZ6F3c6A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@simple-libs/stream-utils": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=22"
+ },
+ "funding": {
+ "url": "https://ko-fi.com/dangreen"
+ }
+ },
+ "node_modules/@simple-libs/stream-utils": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/@simple-libs/stream-utils/-/stream-utils-2.0.0.tgz",
+ "integrity": "sha512-fCTuZK4QBa+39Oz9l4OGfJfz+GpwCp3AqO7Zch3to99xHPgstVsRFpeQ8LNd2o1Gv8raL2mCFwiaHh7bFSp5DQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=22"
+ },
+ "funding": {
+ "url": "https://ko-fi.com/dangreen"
+ }
+ },
+ "node_modules/@smithy/is-array-buffer": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz",
+ "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@smithy/types": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.3.1.tgz",
+ "integrity": "sha512-UqKOQBL2x6+HWl3P+3QqFD4ncKq0I8Nuz9QItGv5WuKuMHuuwlhvqcZCoXGfc+P1QmfJE7VieykoYYmrOoFJxA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/util-buffer-from": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz",
+ "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/is-array-buffer": "^2.2.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@smithy/util-utf8": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz",
+ "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/util-buffer-from": "^2.2.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@types/node": {
+ "version": "26.2.0",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz",
+ "integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "undici-types": "~8.3.0"
+ }
+ },
+ "node_modules/@typescript/typescript-aix-ppc64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
+ "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==",
"cpu": [
- "x64"
+ "ppc64"
],
"dev": true,
- "license": "MIT",
+ "license": "Apache-2.0",
"optional": true,
"os": [
- "android"
+ "aix"
],
+ "peer": true,
"engines": {
- "node": ">=18"
+ "node": ">=16.20.0"
}
},
- "node_modules/@esbuild/darwin-arm64": {
- "version": "0.25.10",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.10.tgz",
- "integrity": "sha512-JC74bdXcQEpW9KkV326WpZZjLguSZ3DfS8wrrvPMHgQOIEIG/sPXEN/V8IssoJhbefLRcRqw6RQH2NnpdprtMA==",
+ "node_modules/@typescript/typescript-darwin-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz",
+ "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==",
"cpu": [
"arm64"
],
"dev": true,
- "license": "MIT",
+ "license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
+ "peer": true,
"engines": {
- "node": ">=18"
+ "node": ">=16.20.0"
}
},
- "node_modules/@esbuild/darwin-x64": {
- "version": "0.25.10",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.10.tgz",
- "integrity": "sha512-tguWg1olF6DGqzws97pKZ8G2L7Ig1vjDmGTwcTuYHbuU6TTjJe5FXbgs5C1BBzHbJ2bo1m3WkQDbWO2PvamRcg==",
+ "node_modules/@typescript/typescript-darwin-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz",
+ "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==",
"cpu": [
"x64"
],
"dev": true,
- "license": "MIT",
+ "license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
+ "peer": true,
"engines": {
- "node": ">=18"
+ "node": ">=16.20.0"
}
},
- "node_modules/@esbuild/freebsd-arm64": {
- "version": "0.25.10",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.10.tgz",
- "integrity": "sha512-3ZioSQSg1HT2N05YxeJWYR+Libe3bREVSdWhEEgExWaDtyFbbXWb49QgPvFH8u03vUPX10JhJPcz7s9t9+boWg==",
+ "node_modules/@typescript/typescript-freebsd-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz",
+ "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==",
"cpu": [
"arm64"
],
"dev": true,
- "license": "MIT",
+ "license": "Apache-2.0",
"optional": true,
"os": [
"freebsd"
],
+ "peer": true,
"engines": {
- "node": ">=18"
+ "node": ">=16.20.0"
}
},
- "node_modules/@esbuild/freebsd-x64": {
- "version": "0.25.10",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.10.tgz",
- "integrity": "sha512-LLgJfHJk014Aa4anGDbh8bmI5Lk+QidDmGzuC2D+vP7mv/GeSN+H39zOf7pN5N8p059FcOfs2bVlrRr4SK9WxA==",
+ "node_modules/@typescript/typescript-freebsd-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz",
+ "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==",
"cpu": [
"x64"
],
"dev": true,
- "license": "MIT",
+ "license": "Apache-2.0",
"optional": true,
"os": [
"freebsd"
],
+ "peer": true,
"engines": {
- "node": ">=18"
+ "node": ">=16.20.0"
}
},
- "node_modules/@esbuild/linux-arm": {
- "version": "0.25.10",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.10.tgz",
- "integrity": "sha512-oR31GtBTFYCqEBALI9r6WxoU/ZofZl962pouZRTEYECvNF/dtXKku8YXcJkhgK/beU+zedXfIzHijSRapJY3vg==",
+ "node_modules/@typescript/typescript-linux-arm": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz",
+ "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==",
"cpu": [
"arm"
],
"dev": true,
- "license": "MIT",
+ "license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
+ "peer": true,
"engines": {
- "node": ">=18"
+ "node": ">=16.20.0"
}
},
- "node_modules/@esbuild/linux-arm64": {
- "version": "0.25.10",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.10.tgz",
- "integrity": "sha512-5luJWN6YKBsawd5f9i4+c+geYiVEw20FVW5x0v1kEMWNq8UctFjDiMATBxLvmmHA4bf7F6hTRaJgtghFr9iziQ==",
+ "node_modules/@typescript/typescript-linux-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz",
+ "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==",
"cpu": [
"arm64"
],
"dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-ia32": {
- "version": "0.25.10",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.10.tgz",
- "integrity": "sha512-NrSCx2Kim3EnnWgS4Txn0QGt0Xipoumb6z6sUtl5bOEZIVKhzfyp/Lyw4C1DIYvzeW/5mWYPBFJU3a/8Yr75DQ==",
- "cpu": [
- "ia32"
- ],
- "dev": true,
- "license": "MIT",
+ "license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
+ "peer": true,
"engines": {
- "node": ">=18"
+ "node": ">=16.20.0"
}
},
- "node_modules/@esbuild/linux-loong64": {
- "version": "0.25.10",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.10.tgz",
- "integrity": "sha512-xoSphrd4AZda8+rUDDfD9J6FUMjrkTz8itpTITM4/xgerAZZcFW7Dv+sun7333IfKxGG8gAq+3NbfEMJfiY+Eg==",
+ "node_modules/@typescript/typescript-linux-loong64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz",
+ "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==",
"cpu": [
"loong64"
],
"dev": true,
- "license": "MIT",
+ "license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
+ "peer": true,
"engines": {
- "node": ">=18"
+ "node": ">=16.20.0"
}
},
- "node_modules/@esbuild/linux-mips64el": {
- "version": "0.25.10",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.10.tgz",
- "integrity": "sha512-ab6eiuCwoMmYDyTnyptoKkVS3k8fy/1Uvq7Dj5czXI6DF2GqD2ToInBI0SHOp5/X1BdZ26RKc5+qjQNGRBelRA==",
+ "node_modules/@typescript/typescript-linux-mips64el": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz",
+ "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==",
"cpu": [
"mips64el"
],
"dev": true,
- "license": "MIT",
+ "license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
+ "peer": true,
"engines": {
- "node": ">=18"
+ "node": ">=16.20.0"
}
},
- "node_modules/@esbuild/linux-ppc64": {
- "version": "0.25.10",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.10.tgz",
- "integrity": "sha512-NLinzzOgZQsGpsTkEbdJTCanwA5/wozN9dSgEl12haXJBzMTpssebuXR42bthOF3z7zXFWH1AmvWunUCkBE4EA==",
+ "node_modules/@typescript/typescript-linux-ppc64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz",
+ "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==",
"cpu": [
"ppc64"
],
"dev": true,
- "license": "MIT",
+ "license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
+ "peer": true,
"engines": {
- "node": ">=18"
+ "node": ">=16.20.0"
}
},
- "node_modules/@esbuild/linux-riscv64": {
- "version": "0.25.10",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.10.tgz",
- "integrity": "sha512-FE557XdZDrtX8NMIeA8LBJX3dC2M8VGXwfrQWU7LB5SLOajfJIxmSdyL/gU1m64Zs9CBKvm4UAuBp5aJ8OgnrA==",
+ "node_modules/@typescript/typescript-linux-riscv64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz",
+ "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==",
"cpu": [
"riscv64"
],
"dev": true,
- "license": "MIT",
+ "license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
+ "peer": true,
"engines": {
- "node": ">=18"
+ "node": ">=16.20.0"
}
},
- "node_modules/@esbuild/linux-s390x": {
- "version": "0.25.10",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.10.tgz",
- "integrity": "sha512-3BBSbgzuB9ajLoVZk0mGu+EHlBwkusRmeNYdqmznmMc9zGASFjSsxgkNsqmXugpPk00gJ0JNKh/97nxmjctdew==",
+ "node_modules/@typescript/typescript-linux-s390x": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz",
+ "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==",
"cpu": [
"s390x"
],
"dev": true,
- "license": "MIT",
+ "license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
+ "peer": true,
"engines": {
- "node": ">=18"
+ "node": ">=16.20.0"
}
},
- "node_modules/@esbuild/linux-x64": {
- "version": "0.25.10",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.10.tgz",
- "integrity": "sha512-QSX81KhFoZGwenVyPoberggdW1nrQZSvfVDAIUXr3WqLRZGZqWk/P4T8p2SP+de2Sr5HPcvjhcJzEiulKgnxtA==",
+ "node_modules/@typescript/typescript-linux-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz",
+ "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==",
"cpu": [
"x64"
],
"dev": true,
- "license": "MIT",
+ "license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
+ "peer": true,
"engines": {
- "node": ">=18"
+ "node": ">=16.20.0"
}
},
- "node_modules/@esbuild/netbsd-arm64": {
- "version": "0.25.10",
- "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.10.tgz",
- "integrity": "sha512-AKQM3gfYfSW8XRk8DdMCzaLUFB15dTrZfnX8WXQoOUpUBQ+NaAFCP1kPS/ykbbGYz7rxn0WS48/81l9hFl3u4A==",
+ "node_modules/@typescript/typescript-netbsd-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz",
+ "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==",
"cpu": [
"arm64"
],
"dev": true,
- "license": "MIT",
+ "license": "Apache-2.0",
"optional": true,
"os": [
"netbsd"
],
+ "peer": true,
"engines": {
- "node": ">=18"
+ "node": ">=16.20.0"
}
},
- "node_modules/@esbuild/netbsd-x64": {
- "version": "0.25.10",
- "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.10.tgz",
- "integrity": "sha512-7RTytDPGU6fek/hWuN9qQpeGPBZFfB4zZgcz2VK2Z5VpdUxEI8JKYsg3JfO0n/Z1E/6l05n0unDCNc4HnhQGig==",
+ "node_modules/@typescript/typescript-netbsd-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz",
+ "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==",
"cpu": [
"x64"
],
"dev": true,
- "license": "MIT",
+ "license": "Apache-2.0",
"optional": true,
"os": [
"netbsd"
],
+ "peer": true,
"engines": {
- "node": ">=18"
+ "node": ">=16.20.0"
}
},
- "node_modules/@esbuild/openbsd-arm64": {
- "version": "0.25.10",
- "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.10.tgz",
- "integrity": "sha512-5Se0VM9Wtq797YFn+dLimf2Zx6McttsH2olUBsDml+lm0GOCRVebRWUvDtkY4BWYv/3NgzS8b/UM3jQNh5hYyw==",
+ "node_modules/@typescript/typescript-openbsd-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz",
+ "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==",
"cpu": [
"arm64"
],
"dev": true,
- "license": "MIT",
+ "license": "Apache-2.0",
"optional": true,
"os": [
"openbsd"
],
+ "peer": true,
"engines": {
- "node": ">=18"
+ "node": ">=16.20.0"
}
},
- "node_modules/@esbuild/openbsd-x64": {
- "version": "0.25.10",
- "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.10.tgz",
- "integrity": "sha512-XkA4frq1TLj4bEMB+2HnI0+4RnjbuGZfet2gs/LNs5Hc7D89ZQBHQ0gL2ND6Lzu1+QVkjp3x1gIcPKzRNP8bXw==",
+ "node_modules/@typescript/typescript-openbsd-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz",
+ "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==",
"cpu": [
"x64"
],
"dev": true,
- "license": "MIT",
+ "license": "Apache-2.0",
"optional": true,
"os": [
"openbsd"
],
+ "peer": true,
"engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/openharmony-arm64": {
- "version": "0.25.10",
- "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.10.tgz",
- "integrity": "sha512-AVTSBhTX8Y/Fz6OmIVBip9tJzZEUcY8WLh7I59+upa5/GPhh2/aM6bvOMQySspnCCHvFi79kMtdJS1w0DXAeag==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "openharmony"
- ],
- "engines": {
- "node": ">=18"
+ "node": ">=16.20.0"
}
},
- "node_modules/@esbuild/sunos-x64": {
- "version": "0.25.10",
- "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.10.tgz",
- "integrity": "sha512-fswk3XT0Uf2pGJmOpDB7yknqhVkJQkAQOcW/ccVOtfx05LkbWOaRAtn5SaqXypeKQra1QaEa841PgrSL9ubSPQ==",
+ "node_modules/@typescript/typescript-sunos-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz",
+ "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==",
"cpu": [
"x64"
],
"dev": true,
- "license": "MIT",
+ "license": "Apache-2.0",
"optional": true,
"os": [
"sunos"
],
+ "peer": true,
"engines": {
- "node": ">=18"
+ "node": ">=16.20.0"
}
},
- "node_modules/@esbuild/win32-arm64": {
- "version": "0.25.10",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.10.tgz",
- "integrity": "sha512-ah+9b59KDTSfpaCg6VdJoOQvKjI33nTaQr4UluQwW7aEwZQsbMCfTmfEO4VyewOxx4RaDT/xCy9ra2GPWmO7Kw==",
+ "node_modules/@typescript/typescript-win32-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz",
+ "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==",
"cpu": [
"arm64"
],
"dev": true,
- "license": "MIT",
+ "license": "Apache-2.0",
"optional": true,
"os": [
"win32"
],
+ "peer": true,
"engines": {
- "node": ">=18"
+ "node": ">=16.20.0"
}
},
- "node_modules/@esbuild/win32-ia32": {
- "version": "0.25.10",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.10.tgz",
- "integrity": "sha512-QHPDbKkrGO8/cz9LKVnJU22HOi4pxZnZhhA2HYHez5Pz4JeffhDjf85E57Oyco163GnzNCVkZK0b/n4Y0UHcSw==",
+ "node_modules/@typescript/typescript-win32-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz",
+ "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==",
"cpu": [
- "ia32"
+ "x64"
],
"dev": true,
- "license": "MIT",
+ "license": "Apache-2.0",
"optional": true,
"os": [
"win32"
],
+ "peer": true,
"engines": {
- "node": ">=18"
+ "node": ">=16.20.0"
}
},
- "node_modules/@esbuild/win32-x64": {
- "version": "0.25.10",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.10.tgz",
- "integrity": "sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw==",
- "cpu": [
- "x64"
- ],
+ "node_modules/ajv": {
+ "version": "8.20.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
+ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
"dev": true,
"license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@smithy/is-array-buffer": {
- "version": "2.2.0",
- "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz",
- "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==",
- "license": "Apache-2.0",
- "dependencies": {
- "tslib": "^2.6.2"
- },
- "engines": {
- "node": ">=14.0.0"
- }
- },
- "node_modules/@smithy/types": {
- "version": "4.3.1",
- "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.3.1.tgz",
- "integrity": "sha512-UqKOQBL2x6+HWl3P+3QqFD4ncKq0I8Nuz9QItGv5WuKuMHuuwlhvqcZCoXGfc+P1QmfJE7VieykoYYmrOoFJxA==",
- "license": "Apache-2.0",
- "dependencies": {
- "tslib": "^2.6.2"
- },
- "engines": {
- "node": ">=18.0.0"
- }
- },
- "node_modules/@smithy/util-buffer-from": {
- "version": "2.2.0",
- "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz",
- "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==",
- "license": "Apache-2.0",
- "dependencies": {
- "@smithy/is-array-buffer": "^2.2.0",
- "tslib": "^2.6.2"
- },
- "engines": {
- "node": ">=14.0.0"
- }
- },
- "node_modules/@smithy/util-utf8": {
- "version": "2.3.0",
- "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz",
- "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==",
- "license": "Apache-2.0",
"dependencies": {
- "@smithy/util-buffer-from": "^2.2.0",
- "tslib": "^2.6.2"
+ "fast-deep-equal": "^3.1.3",
+ "fast-uri": "^3.0.1",
+ "json-schema-traverse": "^1.0.0",
+ "require-from-string": "^2.0.2"
},
- "engines": {
- "node": ">=14.0.0"
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/ansi-regex": {
@@ -593,10 +2163,37 @@
"node": ">= 8"
}
},
+ "node_modules/argparse": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+ "dev": true,
+ "license": "Python-2.0"
+ },
+ "node_modules/argue-cli": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/argue-cli/-/argue-cli-3.1.0.tgz",
+ "integrity": "sha512-DhBpBfXL4SS2uC0N922MMajKR3CdrTG0u2or1PNYgXMsrSzViJrbtvT0nCLlLGUI0plam/ZZCs7aAauHtW9thw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=22"
+ },
+ "funding": {
+ "url": "https://ko-fi.com/dangreen"
+ }
+ },
+ "node_modules/array-timsort": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/array-timsort/-/array-timsort-1.0.3.tgz",
+ "integrity": "sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/autoprefixer": {
- "version": "10.4.21",
- "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz",
- "integrity": "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==",
+ "version": "10.5.4",
+ "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.4.tgz",
+ "integrity": "sha512-MaU0U/za7N3r6brxD4YB/l4NSrFzLPlANv6wEuQVaIPlD3L4W9rFcQPbL/EilY9BHhHvhfcz3gInDLrEtWT4EA==",
"dev": true,
"funding": [
{
@@ -614,10 +2211,9 @@
],
"license": "MIT",
"dependencies": {
- "browserslist": "^4.24.4",
- "caniuse-lite": "^1.0.30001702",
- "fraction.js": "^4.3.7",
- "normalize-range": "^0.1.2",
+ "browserslist": "^4.28.6",
+ "caniuse-lite": "^1.0.30001806",
+ "fraction.js": "^5.3.4",
"picocolors": "^1.1.1",
"postcss-value-parser": "^4.2.0"
},
@@ -638,6 +2234,19 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/baseline-browser-mapping": {
+ "version": "2.11.20",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.20.tgz",
+ "integrity": "sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "baseline-browser-mapping": "dist/cli.cjs"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@@ -659,9 +2268,9 @@
"license": "ISC"
},
"node_modules/brace-expansion": {
- "version": "1.1.12",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
- "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
+ "version": "1.1.15",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz",
+ "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -683,9 +2292,9 @@
}
},
"node_modules/browserslist": {
- "version": "4.25.1",
- "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz",
- "integrity": "sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==",
+ "version": "4.28.8",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz",
+ "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==",
"dev": true,
"funding": [
{
@@ -703,10 +2312,11 @@
],
"license": "MIT",
"dependencies": {
- "caniuse-lite": "^1.0.30001726",
- "electron-to-chromium": "^1.5.173",
- "node-releases": "^2.0.19",
- "update-browserslist-db": "^1.1.3"
+ "baseline-browser-mapping": "^2.11.12",
+ "caniuse-lite": "^1.0.30001809",
+ "electron-to-chromium": "^1.5.402",
+ "node-releases": "^2.0.53",
+ "update-browserslist-db": "^1.3.0"
},
"bin": {
"browserslist": "cli.js"
@@ -715,23 +2325,31 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
+ "node_modules/callsites": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
+ "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/caniuse-api": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/caniuse-api/-/caniuse-api-3.0.0.tgz",
- "integrity": "sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==",
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/caniuse-api/-/caniuse-api-4.0.0.tgz",
+ "integrity": "sha512-B0hQ1OLyJuHTQSOWXvwibWqM6DCoqJdvBA6X1S/53bd4XU7LJ1yurIPlrsouol3mw1jh9pGI4ivubSpmJeIqCA==",
"dev": true,
"license": "MIT",
"dependencies": {
"browserslist": "^4.0.0",
- "caniuse-lite": "^1.0.0",
- "lodash.memoize": "^4.1.2",
- "lodash.uniq": "^4.5.0"
+ "caniuse-lite": "^1.0.0"
}
},
"node_modules/caniuse-lite": {
- "version": "1.0.30001727",
- "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001727.tgz",
- "integrity": "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==",
+ "version": "1.0.30001809",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz",
+ "integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==",
"dev": true,
"funding": [
{
@@ -749,6 +2367,35 @@
],
"license": "CC-BY-4.0"
},
+ "node_modules/chalk": {
+ "version": "5.6.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
+ "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^12.17.0 || ^14.13 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/chalk-template": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/chalk-template/-/chalk-template-1.1.2.tgz",
+ "integrity": "sha512-2bxTP2yUH7AJj/VAXfcA+4IcWGdQ87HwBANLt5XxGTeomo8yG0y95N1um9i5StvhT/Bl0/2cARA5v1PpPXUxUA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "chalk": "^5.2.0"
+ },
+ "engines": {
+ "node": ">=14.16"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk-template?sponsor=1"
+ }
+ },
"node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
@@ -809,13 +2456,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/colord": {
- "version": "2.9.3",
- "resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz",
- "integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/commander": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz",
@@ -826,6 +2466,20 @@
"node": ">=16"
}
},
+ "node_modules/comment-json": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/comment-json/-/comment-json-5.0.0.tgz",
+ "integrity": "sha512-uiqLcOiVDJtBP8WGkZHEP+FZIhTzP1dxvn59EfoYUi9gqupjrBWVQkO2atDrbnKPwLeotFYDsuNb26uBMqB+hw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "array-timsort": "^1.0.3",
+ "esprima": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -833,10 +2487,373 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/conventional-changelog-angular": {
+ "version": "9.4.0",
+ "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-9.4.0.tgz",
+ "integrity": "sha512-HdxRxuS8bBXVIuo4V82gvSwAXT0vYQUizrjs/izmPg5JdDstr8v8I5hduGL3iQbG+o310dUDxC4+LetuS5hu9w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "@conventional-changelog/template": "^1.4.0"
+ },
+ "engines": {
+ "node": ">=22"
+ }
+ },
+ "node_modules/conventional-changelog-conventionalcommits": {
+ "version": "10.2.0",
+ "resolved": "https://registry.npmjs.org/conventional-changelog-conventionalcommits/-/conventional-changelog-conventionalcommits-10.2.0.tgz",
+ "integrity": "sha512-UtlM9GqolY7OmlQh5L/UEVoKsTUpTgUVy1PU8JN5gl5Ydaejb7WRklGliG1SKPxxj7hzA173eG3Kt5fYWE2pmg==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "@conventional-changelog/template": "^1.2.0"
+ },
+ "engines": {
+ "node": ">=22"
+ }
+ },
+ "node_modules/conventional-commits-parser": {
+ "version": "7.1.2",
+ "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-7.1.2.tgz",
+ "integrity": "sha512-O+x4N2yH+ijvqWlIyTHsXTAP+algNWgGbjY2duCe8w2vUMvUB95cLRslCPfTMQyLAKlet3bhZTdu6ozn4M+QJQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@simple-libs/stream-utils": "^2.0.0",
+ "argue-cli": "^3.1.0"
+ },
+ "bin": {
+ "conventional-commits-parser": "dist/cli/index.js"
+ },
+ "engines": {
+ "node": ">=22"
+ }
+ },
+ "node_modules/cosmiconfig": {
+ "version": "9.0.2",
+ "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.2.tgz",
+ "integrity": "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "env-paths": "^2.2.1",
+ "import-fresh": "^3.3.0",
+ "js-yaml": "^4.1.0",
+ "parse-json": "^5.2.0"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/d-fischer"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.9.5"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/cosmiconfig-typescript-loader": {
+ "version": "6.3.0",
+ "resolved": "https://registry.npmjs.org/cosmiconfig-typescript-loader/-/cosmiconfig-typescript-loader-6.3.0.tgz",
+ "integrity": "sha512-Akr82WH1Wfqatyiqpj8HDkO2o2KmJRu1FhKfSNJP3K4IdXwHfEyL7MOb62i1AGQVLtIQM+iCE9CGOtrfhR+mmA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "jiti": "2.6.1"
+ },
+ "engines": {
+ "node": ">=v18"
+ },
+ "peerDependencies": {
+ "@types/node": "*",
+ "cosmiconfig": ">=9",
+ "typescript": ">=5"
+ }
+ },
+ "node_modules/cspell": {
+ "version": "10.1.1",
+ "resolved": "https://registry.npmjs.org/cspell/-/cspell-10.1.1.tgz",
+ "integrity": "sha512-imoZaB1+9gHMyFFMDBY7VbtIdDwkRJXQjs3bIfo1H+AUJnw/vMxLwXk8mnPmH7ziw2m2kyGF+rPAVUE93VEAAA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@cspell/cspell-json-reporter": "10.1.1",
+ "@cspell/cspell-performance-monitor": "10.1.1",
+ "@cspell/cspell-pipe": "10.1.1",
+ "@cspell/cspell-types": "10.1.1",
+ "@cspell/cspell-worker": "10.1.1",
+ "@cspell/dynamic-import": "10.1.1",
+ "@cspell/url": "10.1.1",
+ "ansi-regex": "^6.3.0",
+ "chalk": "^6.0.0",
+ "chalk-template": "^1.1.2",
+ "commander": "^15.0.0",
+ "cspell-config-lib": "10.1.1",
+ "cspell-dictionary": "10.1.1",
+ "cspell-gitignore": "10.1.1",
+ "cspell-glob": "10.1.1",
+ "cspell-io": "10.1.1",
+ "cspell-lib": "10.1.1",
+ "fast-json-stable-stringify": "^2.1.0",
+ "flatted": "^3.4.4",
+ "semver": "^7.8.5",
+ "tinyglobby": "^0.2.17"
+ },
+ "bin": {
+ "cspell": "bin.mjs",
+ "cspell-esm": "bin.mjs"
+ },
+ "engines": {
+ "node": ">=22.18.0"
+ },
+ "funding": {
+ "url": "https://github.com/streetsidesoftware/cspell?sponsor=1"
+ }
+ },
+ "node_modules/cspell-config-lib": {
+ "version": "10.1.1",
+ "resolved": "https://registry.npmjs.org/cspell-config-lib/-/cspell-config-lib-10.1.1.tgz",
+ "integrity": "sha512-/AgOcOxO0jmAJCkShhaPQqSNrx+QUkEOncTf2uFnIx4/gRxFwiRZ0Zrv6PyHNBTC5vY82+qB6scT5Pj30n174A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@cspell/cspell-types": "10.1.1",
+ "comment-json": "^5.0.0",
+ "smol-toml": "^1.8.0",
+ "yaml": "^2.9.0"
+ },
+ "engines": {
+ "node": ">=22.18.0"
+ }
+ },
+ "node_modules/cspell-dictionary": {
+ "version": "10.1.1",
+ "resolved": "https://registry.npmjs.org/cspell-dictionary/-/cspell-dictionary-10.1.1.tgz",
+ "integrity": "sha512-94eW96hjAa3ASAjtmWkC1h1S7GLNHEoDftViilZ/nQzHxeYHp8kU73/0Jqjawr3tzfNvdKV0Bq/8zgeEp1LNrA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@cspell/cspell-performance-monitor": "10.1.1",
+ "@cspell/cspell-pipe": "10.1.1",
+ "@cspell/cspell-types": "10.1.1",
+ "cspell-trie-lib": "10.1.1",
+ "fast-equals": "^6.0.2"
+ },
+ "engines": {
+ "node": ">=22.18.0"
+ }
+ },
+ "node_modules/cspell-gitignore": {
+ "version": "10.1.1",
+ "resolved": "https://registry.npmjs.org/cspell-gitignore/-/cspell-gitignore-10.1.1.tgz",
+ "integrity": "sha512-vYQjziKDH8qQzj/gHHJPKwmAqwfQIHBvyqukR0Uf1WzY1X08Ns9jMl5JDRUL7ffcosDYsReIhLxy2KJrxRkrng==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@cspell/url": "10.1.1",
+ "cspell-glob": "10.1.1",
+ "cspell-io": "10.1.1"
+ },
+ "bin": {
+ "cspell-gitignore": "bin.mjs"
+ },
+ "engines": {
+ "node": ">=22.18.0"
+ }
+ },
+ "node_modules/cspell-glob": {
+ "version": "10.1.1",
+ "resolved": "https://registry.npmjs.org/cspell-glob/-/cspell-glob-10.1.1.tgz",
+ "integrity": "sha512-+xRfPD/KHU3JCMPK3Blt3AchW6KAzsyrNUdNns9mmkDRiej5Vl19kdN3eKh7B/0XoAKnX/v67tucCu/jr8MTug==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@cspell/url": "10.1.1",
+ "picomatch": "^4.0.5"
+ },
+ "engines": {
+ "node": ">=22.18.0"
+ }
+ },
+ "node_modules/cspell-glob/node_modules/picomatch": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
+ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/cspell-grammar": {
+ "version": "10.1.1",
+ "resolved": "https://registry.npmjs.org/cspell-grammar/-/cspell-grammar-10.1.1.tgz",
+ "integrity": "sha512-mOHufusJEld/q8ZfC+AhequR515QIOHC0LdD1z6PU+Mpn3GRBGJBWK56iUwHH4XX935GsUMJFIQuOoMXXcbAsw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@cspell/cspell-pipe": "10.1.1",
+ "@cspell/cspell-types": "10.1.1"
+ },
+ "bin": {
+ "cspell-grammar": "bin.mjs"
+ },
+ "engines": {
+ "node": ">=22.18.0"
+ }
+ },
+ "node_modules/cspell-io": {
+ "version": "10.1.1",
+ "resolved": "https://registry.npmjs.org/cspell-io/-/cspell-io-10.1.1.tgz",
+ "integrity": "sha512-Gcl2B2hKOJBqi+nf1ZnZ2XZj+VWN+AZLfQZR5dxxny3Ggy51vW8AfJX8lF8Ev/c559AkC5G7KkO2jkVuZ4SVsA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@cspell/cspell-service-bus": "10.1.1",
+ "@cspell/url": "10.1.1"
+ },
+ "engines": {
+ "node": ">=22.18.0"
+ }
+ },
+ "node_modules/cspell-lib": {
+ "version": "10.1.1",
+ "resolved": "https://registry.npmjs.org/cspell-lib/-/cspell-lib-10.1.1.tgz",
+ "integrity": "sha512-jckdiCU/3pkvJ21G77R2fl93R6JjpYwP+wj8qQNUewYlTl9bdhMJi8xgLb4os7CJnHD5kA59ow8tNvh45d3zDw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@cspell/cspell-bundled-dicts": "10.1.1",
+ "@cspell/cspell-performance-monitor": "10.1.1",
+ "@cspell/cspell-pipe": "10.1.1",
+ "@cspell/cspell-resolver": "10.1.1",
+ "@cspell/cspell-types": "10.1.1",
+ "@cspell/dynamic-import": "10.1.1",
+ "@cspell/filetypes": "10.1.1",
+ "@cspell/rpc": "10.1.1",
+ "@cspell/strong-weak-map": "10.1.1",
+ "@cspell/url": "10.1.1",
+ "cspell-config-lib": "10.1.1",
+ "cspell-dictionary": "10.1.1",
+ "cspell-glob": "10.1.1",
+ "cspell-grammar": "10.1.1",
+ "cspell-io": "10.1.1",
+ "cspell-trie-lib": "10.1.1",
+ "env-paths": "^4.0.0",
+ "gensequence": "^8.0.8",
+ "import-fresh": "^4.0.0",
+ "resolve-from": "^5.0.0",
+ "vscode-languageserver-textdocument": "^1.0.12",
+ "vscode-uri": "^3.1.0",
+ "xdg-basedir": "^5.1.0"
+ },
+ "engines": {
+ "node": ">=22.18.0"
+ }
+ },
+ "node_modules/cspell-lib/node_modules/env-paths": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-4.0.0.tgz",
+ "integrity": "sha512-pxP8eL2SwwaTRi/KHYwLYXinDs7gL3jxFcBYmEdYfZmZXbaVDvdppd0XBU8qVz03rDfKZMXg1omHCbsJjZrMsw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-safe-filename": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/cspell-lib/node_modules/import-fresh": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-4.0.0.tgz",
+ "integrity": "sha512-Fpi660c7VPDM3fPKYovStd9IP1CPOikf6v/dGxJJMmHPcwYQIMJ4W7kO1avBYEpMqkCh+Dx3Ln6H7VYqgztLjw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=22.15"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/cspell-trie-lib": {
+ "version": "10.1.1",
+ "resolved": "https://registry.npmjs.org/cspell-trie-lib/-/cspell-trie-lib-10.1.1.tgz",
+ "integrity": "sha512-4ico1ebFl9lvP1Yr4pD+FxOYRS7Ct+ImV8v0KcEclxMixqNku4NoVP/CvmE1AyyVB3FN16fITN0vo3ZAR79PFw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=22.18.0"
+ },
+ "peerDependencies": {
+ "@cspell/cspell-types": "10.1.1"
+ }
+ },
+ "node_modules/cspell/node_modules/ansi-regex": {
+ "version": "6.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz",
+ "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-regex?sponsor=1"
+ }
+ },
+ "node_modules/cspell/node_modules/chalk": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-6.0.0.tgz",
+ "integrity": "sha512-2uNTXIuTTxk7ciZgAU1BQcgnchcG0xXnrs6jzkQfj9SsRa9M2s5zE8WT96hS6KmG4MzWHSrvH43DF1m4XRkrFg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=22"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/cspell/node_modules/commander": {
+ "version": "15.0.0",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz",
+ "integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=22.12.0"
+ }
+ },
+ "node_modules/cspell/node_modules/semver": {
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/css-declaration-sorter": {
- "version": "7.2.0",
- "resolved": "https://registry.npmjs.org/css-declaration-sorter/-/css-declaration-sorter-7.2.0.tgz",
- "integrity": "sha512-h70rUM+3PNFuaBDTLe8wF/cdWu+dOZmb7pJt8Z2sedYbAcQVQV/tEchueg3GWxwqS0cxtbxmaHEdkNACqcvsow==",
+ "version": "7.4.0",
+ "resolved": "https://registry.npmjs.org/css-declaration-sorter/-/css-declaration-sorter-7.4.0.tgz",
+ "integrity": "sha512-LTuzjPoyA2vMGKKcaOqKSp7Ub2eGrNfKiZH4LpezxpNrsICGCSFvsQOI29psISxNZtaXibkC2CXzrQ5enMeGGw==",
"dev": true,
"license": "ISC",
"engines": {
@@ -847,40 +2864,40 @@
}
},
"node_modules/css-select": {
- "version": "5.2.2",
- "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
- "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/css-select/-/css-select-6.0.0.tgz",
+ "integrity": "sha512-rZZVSLle8v0+EY8QAkDWrKhpgt6SA5OtHsgBnsj6ZaLb5dmDVOWUDtQitd9ydxxvEjhewNudS6eTVU7uOyzvXw==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0",
- "css-what": "^6.1.0",
- "domhandler": "^5.0.2",
- "domutils": "^3.0.1",
- "nth-check": "^2.0.1"
+ "css-what": "^7.0.0",
+ "domhandler": "^5.0.3",
+ "domutils": "^3.2.2",
+ "nth-check": "^2.1.1"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/css-tree": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz",
- "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==",
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
+ "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "mdn-data": "2.12.2",
- "source-map-js": "^1.0.1"
+ "mdn-data": "2.27.1",
+ "source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
}
},
"node_modules/css-what": {
- "version": "6.2.2",
- "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
- "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/css-what/-/css-what-7.0.0.tgz",
+ "integrity": "sha512-wD5oz5xibMOPHzy13CyGmogB3phdvcDaB5t0W/Nr5Z2O/agcB8YwOz6e2Lsp10pNDzBoDO9nVa3RGs/2BttpHQ==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
@@ -904,104 +2921,102 @@
}
},
"node_modules/cssnano": {
- "version": "7.1.1",
- "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-7.1.1.tgz",
- "integrity": "sha512-fm4D8ti0dQmFPeF8DXSAA//btEmqCOgAc/9Oa3C1LW94h5usNrJEfrON7b4FkPZgnDEn6OUs5NdxiJZmAtGOpQ==",
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-9.0.1.tgz",
+ "integrity": "sha512-nTcVXlnodhnQZQlH+8/NdpExRTJ4AEBTcV/sx4Xx1X0UdbrSSw4523RpE3bojlRzZdmdEy9n8F9XeipjUKJ3MQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "cssnano-preset-default": "^7.0.9",
- "lilconfig": "^3.1.3"
+ "cssnano-preset-default": "^9.0.1"
},
"engines": {
- "node": "^18.12.0 || ^20.9.0 || >=22.0"
+ "node": "^22.22.3 || ^24.15.0 || >=26.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/cssnano"
},
"peerDependencies": {
- "postcss": "^8.4.32"
+ "postcss": "^8.5.26"
}
},
"node_modules/cssnano-preset-advanced": {
- "version": "7.0.9",
- "resolved": "https://registry.npmjs.org/cssnano-preset-advanced/-/cssnano-preset-advanced-7.0.9.tgz",
- "integrity": "sha512-nczUlcRbMuupYDIjlYTQ6nqWh2AjV1omdf5OkUEKpOqPuRb8CZp/KgpZjD2eelX/sfFh6V/aybfgu7lVJH7Z/w==",
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/cssnano-preset-advanced/-/cssnano-preset-advanced-9.0.1.tgz",
+ "integrity": "sha512-3PixinUkBelX+E54zfgiqsgrhAu935ZqjrgSBHkTdMwrf1nRODtg5eVU890sIcGr+FZ8TudWafxq1NFgXcVkvw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "autoprefixer": "^10.4.21",
- "browserslist": "^4.25.1",
- "cssnano-preset-default": "^7.0.9",
- "postcss-discard-unused": "^7.0.4",
- "postcss-merge-idents": "^7.0.1",
- "postcss-reduce-idents": "^7.0.1",
- "postcss-zindex": "^7.0.1"
+ "autoprefixer": "^10.5.4",
+ "css-declaration-sorter": "^7.4.0",
+ "cssnano-preset-default": "^9.0.1",
+ "postcss-discard-unused": "^9.0.0",
+ "postcss-merge-idents": "^9.0.0",
+ "postcss-reduce-idents": "^9.0.0",
+ "postcss-zindex": "^9.0.0"
},
"engines": {
- "node": "^18.12.0 || ^20.9.0 || >=22.0"
+ "node": "^22.22.3 || ^24.15.0 || >=26.0"
},
"peerDependencies": {
- "postcss": "^8.4.32"
+ "postcss": "^8.5.26"
}
},
"node_modules/cssnano-preset-default": {
- "version": "7.0.9",
- "resolved": "https://registry.npmjs.org/cssnano-preset-default/-/cssnano-preset-default-7.0.9.tgz",
- "integrity": "sha512-tCD6AAFgYBOVpMBX41KjbvRh9c2uUjLXRyV7KHSIrwHiq5Z9o0TFfUCoM3TwVrRsRteN3sVXGNvjVNxYzkpTsA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "browserslist": "^4.25.1",
- "css-declaration-sorter": "^7.2.0",
- "cssnano-utils": "^5.0.1",
- "postcss-calc": "^10.1.1",
- "postcss-colormin": "^7.0.4",
- "postcss-convert-values": "^7.0.7",
- "postcss-discard-comments": "^7.0.4",
- "postcss-discard-duplicates": "^7.0.2",
- "postcss-discard-empty": "^7.0.1",
- "postcss-discard-overridden": "^7.0.1",
- "postcss-merge-longhand": "^7.0.5",
- "postcss-merge-rules": "^7.0.6",
- "postcss-minify-font-values": "^7.0.1",
- "postcss-minify-gradients": "^7.0.1",
- "postcss-minify-params": "^7.0.4",
- "postcss-minify-selectors": "^7.0.5",
- "postcss-normalize-charset": "^7.0.1",
- "postcss-normalize-display-values": "^7.0.1",
- "postcss-normalize-positions": "^7.0.1",
- "postcss-normalize-repeat-style": "^7.0.1",
- "postcss-normalize-string": "^7.0.1",
- "postcss-normalize-timing-functions": "^7.0.1",
- "postcss-normalize-unicode": "^7.0.4",
- "postcss-normalize-url": "^7.0.1",
- "postcss-normalize-whitespace": "^7.0.1",
- "postcss-ordered-values": "^7.0.2",
- "postcss-reduce-initial": "^7.0.4",
- "postcss-reduce-transforms": "^7.0.1",
- "postcss-svgo": "^7.1.0",
- "postcss-unique-selectors": "^7.0.4"
- },
- "engines": {
- "node": "^18.12.0 || ^20.9.0 || >=22.0"
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/cssnano-preset-default/-/cssnano-preset-default-9.0.1.tgz",
+ "integrity": "sha512-ijTL+PbY00iPaPIBya1V5UBegqzAdCkdqzxKQWjb8oFan3dZ7fKRXDeD5AW0BFYDzvAt95i9xdDqaof8IEoG4g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "browserslist": "^4.28.8",
+ "cssnano-utils": "^7.0.0",
+ "postcss-calc": "^11.0.1",
+ "postcss-colormin": "^9.0.0",
+ "postcss-convert-values": "^9.0.0",
+ "postcss-discard-comments": "^9.0.0",
+ "postcss-discard-duplicates": "^9.0.0",
+ "postcss-discard-empty": "^9.0.0",
+ "postcss-discard-overridden": "^9.0.0",
+ "postcss-merge-longhand": "^9.0.1",
+ "postcss-merge-rules": "^9.0.1",
+ "postcss-minify-font-values": "^9.0.0",
+ "postcss-minify-gradients": "^9.0.0",
+ "postcss-minify-params": "^9.0.0",
+ "postcss-minify-selectors": "^9.0.1",
+ "postcss-normalize-charset": "^9.0.0",
+ "postcss-normalize-display-values": "^9.0.0",
+ "postcss-normalize-positions": "^9.0.0",
+ "postcss-normalize-repeat-style": "^9.0.0",
+ "postcss-normalize-string": "^9.0.0",
+ "postcss-normalize-timing-functions": "^9.0.0",
+ "postcss-normalize-unicode": "^9.0.0",
+ "postcss-normalize-url": "^9.0.0",
+ "postcss-normalize-whitespace": "^9.0.0",
+ "postcss-ordered-values": "^9.0.0",
+ "postcss-reduce-initial": "^9.0.0",
+ "postcss-reduce-transforms": "^9.0.0",
+ "postcss-svgo": "^9.0.0",
+ "postcss-unique-selectors": "^9.0.0"
+ },
+ "engines": {
+ "node": "^22.22.3 || ^24.15.0 || >=26.0"
},
"peerDependencies": {
- "postcss": "^8.4.32"
+ "postcss": "^8.5.26"
}
},
"node_modules/cssnano-utils": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/cssnano-utils/-/cssnano-utils-5.0.1.tgz",
- "integrity": "sha512-ZIP71eQgG9JwjVZsTPSqhc6GHgEr53uJ7tK5///VfyWj6Xp2DBmixWHqJgPno+PqATzn48pL42ww9x5SSGmhZg==",
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/cssnano-utils/-/cssnano-utils-7.0.0.tgz",
+ "integrity": "sha512-DAV5AOg6sh/NBke5YF2kK4Oinlu6yIrOHdChIkazKfsLph0bwLWLlBNxdRHsVZJmZXVRuRzKYQQ8bDWPXRuwhw==",
"dev": true,
"license": "MIT",
"engines": {
- "node": "^18.12.0 || ^20.9.0 || >=22.0"
+ "node": "^22.22.3 || ^24.15.0 || >=26.0"
},
"peerDependencies": {
- "postcss": "^8.4.32"
+ "postcss": "^8.5.26"
}
},
"node_modules/csso": {
@@ -1117,9 +3132,9 @@
}
},
"node_modules/electron-to-chromium": {
- "version": "1.5.187",
- "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.187.tgz",
- "integrity": "sha512-cl5Jc9I0KGUoOoSbxvTywTa40uspGJt/BDBoDLoxJRSBpWh4FFXBsjNRHfQrONsV/OoEjDfHUmZQa2d6Ze4YgA==",
+ "version": "1.5.412",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.412.tgz",
+ "integrity": "sha512-z4rMe3esBzlzovKHj4gxJnsCGZRK5l4baUvm+gCGJBPE+gsyUMKsuU9tnEUtI1dOebXz1ytAPGjvXhmQ7rIPwA==",
"dev": true,
"license": "ISC"
},
@@ -1143,10 +3158,43 @@
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
+ "node_modules/env-paths": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
+ "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/error-ex": {
+ "version": "1.3.4",
+ "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz",
+ "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-arrayish": "^0.2.1"
+ }
+ },
+ "node_modules/es-toolkit": {
+ "version": "1.51.0",
+ "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.51.0.tgz",
+ "integrity": "sha512-zC2lQGkM7QX+Gm6iM3+WIdZJzthsEd14LvRNJneSO2hzyz/zNBENR8+YXWo1cKxgPBtV6ksPYHELbcwBRzmdCw==",
+ "dev": true,
+ "license": "MIT",
+ "workspaces": [
+ "docs",
+ "benchmarks",
+ "tests/types",
+ "tests/browser-compat"
+ ]
+ },
"node_modules/esbuild": {
- "version": "0.25.10",
- "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.10.tgz",
- "integrity": "sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ==",
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
+ "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
@@ -1157,32 +3205,32 @@
"node": ">=18"
},
"optionalDependencies": {
- "@esbuild/aix-ppc64": "0.25.10",
- "@esbuild/android-arm": "0.25.10",
- "@esbuild/android-arm64": "0.25.10",
- "@esbuild/android-x64": "0.25.10",
- "@esbuild/darwin-arm64": "0.25.10",
- "@esbuild/darwin-x64": "0.25.10",
- "@esbuild/freebsd-arm64": "0.25.10",
- "@esbuild/freebsd-x64": "0.25.10",
- "@esbuild/linux-arm": "0.25.10",
- "@esbuild/linux-arm64": "0.25.10",
- "@esbuild/linux-ia32": "0.25.10",
- "@esbuild/linux-loong64": "0.25.10",
- "@esbuild/linux-mips64el": "0.25.10",
- "@esbuild/linux-ppc64": "0.25.10",
- "@esbuild/linux-riscv64": "0.25.10",
- "@esbuild/linux-s390x": "0.25.10",
- "@esbuild/linux-x64": "0.25.10",
- "@esbuild/netbsd-arm64": "0.25.10",
- "@esbuild/netbsd-x64": "0.25.10",
- "@esbuild/openbsd-arm64": "0.25.10",
- "@esbuild/openbsd-x64": "0.25.10",
- "@esbuild/openharmony-arm64": "0.25.10",
- "@esbuild/sunos-x64": "0.25.10",
- "@esbuild/win32-arm64": "0.25.10",
- "@esbuild/win32-ia32": "0.25.10",
- "@esbuild/win32-x64": "0.25.10"
+ "@esbuild/aix-ppc64": "0.28.2",
+ "@esbuild/android-arm": "0.28.2",
+ "@esbuild/android-arm64": "0.28.2",
+ "@esbuild/android-x64": "0.28.2",
+ "@esbuild/darwin-arm64": "0.28.2",
+ "@esbuild/darwin-x64": "0.28.2",
+ "@esbuild/freebsd-arm64": "0.28.2",
+ "@esbuild/freebsd-x64": "0.28.2",
+ "@esbuild/linux-arm": "0.28.2",
+ "@esbuild/linux-arm64": "0.28.2",
+ "@esbuild/linux-ia32": "0.28.2",
+ "@esbuild/linux-loong64": "0.28.2",
+ "@esbuild/linux-mips64el": "0.28.2",
+ "@esbuild/linux-ppc64": "0.28.2",
+ "@esbuild/linux-riscv64": "0.28.2",
+ "@esbuild/linux-s390x": "0.28.2",
+ "@esbuild/linux-x64": "0.28.2",
+ "@esbuild/netbsd-arm64": "0.28.2",
+ "@esbuild/netbsd-x64": "0.28.2",
+ "@esbuild/openbsd-arm64": "0.28.2",
+ "@esbuild/openbsd-x64": "0.28.2",
+ "@esbuild/openharmony-arm64": "0.28.2",
+ "@esbuild/sunos-x64": "0.28.2",
+ "@esbuild/win32-arm64": "0.28.2",
+ "@esbuild/win32-ia32": "0.28.2",
+ "@esbuild/win32-x64": "0.28.2"
}
},
"node_modules/escalade": {
@@ -1195,6 +3243,61 @@
"node": ">=6"
}
},
+ "node_modules/esprima": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
+ "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "bin": {
+ "esparse": "bin/esparse.js",
+ "esvalidate": "bin/esvalidate.js"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-equals": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-6.0.2.tgz",
+ "integrity": "sha512-sAjhj9ZhOxYCGiNMnZLaucOqf5ZeFnHNoKoAZiD9thhJ0N8RP85qJK759/97C/3L7NzzmGVB5uiX9AUpySZmUQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/fast-json-stable-stringify": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
+ "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-uri": {
+ "version": "3.1.6",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.6.tgz",
+ "integrity": "sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -1208,17 +3311,24 @@
"node": ">=8"
}
},
+ "node_modules/flatted": {
+ "version": "3.4.4",
+ "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz",
+ "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/fraction.js": {
- "version": "4.3.7",
- "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
- "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==",
+ "version": "5.3.4",
+ "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
+ "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": "*"
},
"funding": {
- "type": "patreon",
+ "type": "github",
"url": "https://github.com/sponsors/rawify"
}
},
@@ -1262,6 +3372,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/gensequence": {
+ "version": "8.0.8",
+ "resolved": "https://registry.npmjs.org/gensequence/-/gensequence-8.0.8.tgz",
+ "integrity": "sha512-omMVniXEXpdx/vKxGnPRoO2394Otlze28TyxECbFVyoSpZ9H3EO7lemjcB12OpQJzRW4e5tt/dL1rOxry6aMHg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
@@ -1272,6 +3392,19 @@
"node": "6.* || 8.* || >= 10.*"
}
},
+ "node_modules/get-east-asian-width": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz",
+ "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
@@ -1285,6 +3418,22 @@
"node": ">= 6"
}
},
+ "node_modules/global-directory": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/global-directory/-/global-directory-5.0.0.tgz",
+ "integrity": "sha512-1pgFdhK3J2LeM+dVf2Pd424yHx2ou338lC0ErNP2hPx4j8eW1Sp0XqSjNxtk6Tc4Kr5wlWtSvz8cn2yb7/SG/w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ini": "6.0.0"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
@@ -1312,6 +3461,77 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/husky": {
+ "version": "9.1.7",
+ "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz",
+ "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "husky": "bin.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/typicode"
+ }
+ },
+ "node_modules/import-fresh": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
+ "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "parent-module": "^1.0.0",
+ "resolve-from": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/import-fresh/node_modules/resolve-from": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
+ "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/import-meta-resolve": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz",
+ "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/ini": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz",
+ "integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/is-arrayish": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
+ "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/is-binary-path": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
@@ -1384,6 +3604,32 @@
"node": ">=0.12.0"
}
},
+ "node_modules/is-plain-obj": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz",
+ "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/is-safe-filename": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/is-safe-filename/-/is-safe-filename-0.1.1.tgz",
+ "integrity": "sha512-4SrR7AdnY11LHfDKTZY1u6Ga3RuxZdl3YKWWShO5iyuG5h8QS4GD2tOb04peBJ5I7pXbR+CGBNEhTcwK+FzN3g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/is-url": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz",
@@ -1391,6 +3637,60 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/jiti": {
+ "version": "2.6.1",
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
+ "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jiti": "lib/jiti-cli.mjs"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/js-yaml": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
+ "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/puzrin"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/nodeca"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "argparse": "^2.0.1"
+ },
+ "bin": {
+ "js-yaml": "bin/js-yaml.js"
+ }
+ },
+ "node_modules/json-parse-even-better-errors": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
+ "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json-schema-traverse": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/jsonfile": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz",
@@ -1417,6 +3717,13 @@
"url": "https://github.com/sponsors/antonk52"
}
},
+ "node_modules/lines-and-columns": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
+ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/lodash.assign": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/lodash.assign/-/lodash.assign-4.2.0.tgz",
@@ -1424,13 +3731,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/lodash.memoize": {
- "version": "4.1.2",
- "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz",
- "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/lodash.trim": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/lodash.trim/-/lodash.trim-4.5.1.tgz",
@@ -1438,13 +3738,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/lodash.uniq": {
- "version": "4.5.0",
- "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz",
- "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/make-dir": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
@@ -1462,9 +3755,9 @@
}
},
"node_modules/mdn-data": {
- "version": "2.12.2",
- "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz",
- "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==",
+ "version": "2.27.1",
+ "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
+ "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
"dev": true,
"license": "CC0-1.0"
},
@@ -1482,9 +3775,9 @@
}
},
"node_modules/minimatch": {
- "version": "3.0.8",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.8.tgz",
- "integrity": "sha512-6FsRAQsxQ61mw+qP1ZzbL9Bc78x2p5OqNgNpnoAFLTrX8n5Kxph0CsnhmKKNXTWjXqU5L0pGPR7hYk+XWZr60Q==",
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
+ "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"dependencies": {
@@ -1495,9 +3788,9 @@
}
},
"node_modules/nanoid": {
- "version": "3.3.11",
- "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
- "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+ "version": "3.3.18",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
+ "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
"dev": true,
"funding": [
{
@@ -1515,11 +3808,14 @@
}
},
"node_modules/node-releases": {
- "version": "2.0.19",
- "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz",
- "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==",
+ "version": "2.0.53",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz",
+ "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==",
"dev": true,
- "license": "MIT"
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
},
"node_modules/normalize-path": {
"version": "3.0.0",
@@ -1531,16 +3827,6 @@
"node": ">=0.10.0"
}
},
- "node_modules/normalize-range": {
- "version": "0.1.2",
- "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz",
- "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=0.10.0"
- }
- },
"node_modules/nth-check": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
@@ -1554,6 +3840,38 @@
"url": "https://github.com/fb55/nth-check?sponsor=1"
}
},
+ "node_modules/parent-module": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
+ "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "callsites": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/parse-json": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
+ "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.0.0",
+ "error-ex": "^1.3.1",
+ "json-parse-even-better-errors": "^2.3.0",
+ "lines-and-columns": "^1.1.6"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/path-parse": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
@@ -1592,13 +3910,13 @@
}
},
"node_modules/playwright": {
- "version": "1.52.0",
- "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.52.0.tgz",
- "integrity": "sha512-JAwMNMBlxJ2oD1kce4KPtMkDeKGHQstdpFPcPH3maElAXon/QZeTvtsfXmTMRyO9TslfoYOXkSsvao2nE1ilTw==",
+ "version": "1.61.1",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
+ "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
- "playwright-core": "1.52.0"
+ "playwright-core": "1.61.1"
},
"bin": {
"playwright": "cli.js"
@@ -1611,9 +3929,9 @@
}
},
"node_modules/playwright-core": {
- "version": "1.52.0",
- "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.52.0.tgz",
- "integrity": "sha512-l2osTgLXSMeuLZOML9qYODUQoPPnUsKsb5/P6LJ2e6uPKXUdPK5WYhN4z03G+YNbWmGDY4YENauNu4ZKczreHg==",
+ "version": "1.61.1",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
+ "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -1624,9 +3942,9 @@
}
},
"node_modules/postcss": {
- "version": "8.5.6",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
- "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
+ "version": "8.5.26",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
+ "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
"dev": true,
"funding": [
{
@@ -1645,7 +3963,7 @@
"license": "MIT",
"peer": true,
"dependencies": {
- "nanoid": "^3.3.11",
+ "nanoid": "^3.3.17",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
@@ -1654,20 +3972,20 @@
}
},
"node_modules/postcss-calc": {
- "version": "10.1.1",
- "resolved": "https://registry.npmjs.org/postcss-calc/-/postcss-calc-10.1.1.tgz",
- "integrity": "sha512-NYEsLHh8DgG/PRH2+G9BTuUdtf9ViS+vdoQ0YA5OQdGsfN4ztiwtDWNtBl9EKeqNMFnIu8IKZ0cLxEQ5r5KVMw==",
+ "version": "11.0.2",
+ "resolved": "https://registry.npmjs.org/postcss-calc/-/postcss-calc-11.0.2.tgz",
+ "integrity": "sha512-3gRujDcef82cgs/4aMWfKal/obVXfRlh6tMIZpDgWt6E6fMYTLbtodBtVXv7h3YSFB9SKOObaPBrPum7+Lv6lA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "postcss-selector-parser": "^7.0.0",
- "postcss-value-parser": "^4.2.0"
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0"
},
"engines": {
- "node": "^18.12 || ^20.9 || >=22.0"
+ "node": "^22.22.3 || ^24.15 || >=26.0"
},
"peerDependencies": {
- "postcss": "^8.4.38"
+ "postcss": "^8.5.25"
}
},
"node_modules/postcss-cli": {
@@ -1700,116 +4018,116 @@
}
},
"node_modules/postcss-colormin": {
- "version": "7.0.4",
- "resolved": "https://registry.npmjs.org/postcss-colormin/-/postcss-colormin-7.0.4.tgz",
- "integrity": "sha512-ziQuVzQZBROpKpfeDwmrG+Vvlr0YWmY/ZAk99XD+mGEBuEojoFekL41NCsdhyNUtZI7DPOoIWIR7vQQK9xwluw==",
+ "version": "9.0.0",
+ "resolved": "https://registry.npmjs.org/postcss-colormin/-/postcss-colormin-9.0.0.tgz",
+ "integrity": "sha512-y3XO60M/BsrDMWyp1/yRVQGHEFTuYL+NW9u6QpV07z9tGRDSKfgfKYcysQXy8lZzWtoDXrNegtjSo2kMnWPNQg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "browserslist": "^4.25.1",
- "caniuse-api": "^3.0.0",
- "colord": "^2.9.3",
+ "@colordx/core": "^5.6.0",
+ "browserslist": "^4.28.8",
+ "caniuse-api": "^4.0.0",
"postcss-value-parser": "^4.2.0"
},
"engines": {
- "node": "^18.12.0 || ^20.9.0 || >=22.0"
+ "node": "^22.22.3 || ^24.15.0 || >=26.0"
},
"peerDependencies": {
- "postcss": "^8.4.32"
+ "postcss": "^8.5.26"
}
},
"node_modules/postcss-convert-values": {
- "version": "7.0.7",
- "resolved": "https://registry.npmjs.org/postcss-convert-values/-/postcss-convert-values-7.0.7.tgz",
- "integrity": "sha512-HR9DZLN04Xbe6xugRH6lS4ZQH2zm/bFh/ZyRkpedZozhvh+awAfbA0P36InO4fZfDhvYfNJeNvlTf1sjwGbw/A==",
+ "version": "9.0.0",
+ "resolved": "https://registry.npmjs.org/postcss-convert-values/-/postcss-convert-values-9.0.0.tgz",
+ "integrity": "sha512-ldw3Id2YN2zJTD9b07hOQBxb9lRrFSMmKsiMcTr0I5PC2EQWZDpkkahS3XibRiWO5DICjVh8nw+lQHJjBngtGQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "browserslist": "^4.25.1",
+ "browserslist": "^4.28.8",
"postcss-value-parser": "^4.2.0"
},
"engines": {
- "node": "^18.12.0 || ^20.9.0 || >=22.0"
+ "node": "^22.22.3 || ^24.15.0 || >=26.0"
},
"peerDependencies": {
- "postcss": "^8.4.32"
+ "postcss": "^8.5.26"
}
},
"node_modules/postcss-discard-comments": {
- "version": "7.0.4",
- "resolved": "https://registry.npmjs.org/postcss-discard-comments/-/postcss-discard-comments-7.0.4.tgz",
- "integrity": "sha512-6tCUoql/ipWwKtVP/xYiFf1U9QgJ0PUvxN7pTcsQ8Ns3Fnwq1pU5D5s1MhT/XySeLq6GXNvn37U46Ded0TckWg==",
+ "version": "9.0.0",
+ "resolved": "https://registry.npmjs.org/postcss-discard-comments/-/postcss-discard-comments-9.0.0.tgz",
+ "integrity": "sha512-tL1KrBiRT2sgZzjJf938xfxTowSg8S/33XvB5T5LttCispCldC6XOUX8Xtvne8TlkeeBypUEFXTX2rE+n8iCaw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "postcss-selector-parser": "^7.1.0"
+ "postcss-selector-parser": "^7.1.5"
},
"engines": {
- "node": "^18.12.0 || ^20.9.0 || >=22.0"
+ "node": "^22.22.3 || ^24.15.0 || >=26.0"
},
"peerDependencies": {
- "postcss": "^8.4.32"
+ "postcss": "^8.5.26"
}
},
"node_modules/postcss-discard-duplicates": {
- "version": "7.0.2",
- "resolved": "https://registry.npmjs.org/postcss-discard-duplicates/-/postcss-discard-duplicates-7.0.2.tgz",
- "integrity": "sha512-eTonaQvPZ/3i1ASDHOKkYwAybiM45zFIc7KXils4mQmHLqIswXD9XNOKEVxtTFnsmwYzF66u4LMgSr0abDlh5w==",
+ "version": "9.0.0",
+ "resolved": "https://registry.npmjs.org/postcss-discard-duplicates/-/postcss-discard-duplicates-9.0.0.tgz",
+ "integrity": "sha512-APvRj9LcRCB1WQsOKHqp+5GJiMK/eoDjy1heQSob91gSWR5uAG6wwNxFGfAuuwGUBQ7f6uX09nSZFtiXQ+IwPg==",
"dev": true,
"license": "MIT",
"engines": {
- "node": "^18.12.0 || ^20.9.0 || >=22.0"
+ "node": "^22.22.3 || ^24.15.0 || >=26.0"
},
"peerDependencies": {
- "postcss": "^8.4.32"
+ "postcss": "^8.5.26"
}
},
"node_modules/postcss-discard-empty": {
- "version": "7.0.1",
- "resolved": "https://registry.npmjs.org/postcss-discard-empty/-/postcss-discard-empty-7.0.1.tgz",
- "integrity": "sha512-cFrJKZvcg/uxB6Ijr4l6qmn3pXQBna9zyrPC+sK0zjbkDUZew+6xDltSF7OeB7rAtzaaMVYSdbod+sZOCWnMOg==",
+ "version": "9.0.0",
+ "resolved": "https://registry.npmjs.org/postcss-discard-empty/-/postcss-discard-empty-9.0.0.tgz",
+ "integrity": "sha512-F6bpkTTYJgWQy0OlJODCa/odCzP/kTnTZ70zroX/zS+EEhRLhtdKDlvMOmylV0jzFEkT1ZjFvWupDblWijxvrQ==",
"dev": true,
"license": "MIT",
"engines": {
- "node": "^18.12.0 || ^20.9.0 || >=22.0"
+ "node": "^22.22.3 || ^24.15.0 || >=26.0"
},
"peerDependencies": {
- "postcss": "^8.4.32"
+ "postcss": "^8.5.26"
}
},
"node_modules/postcss-discard-overridden": {
- "version": "7.0.1",
- "resolved": "https://registry.npmjs.org/postcss-discard-overridden/-/postcss-discard-overridden-7.0.1.tgz",
- "integrity": "sha512-7c3MMjjSZ/qYrx3uc1940GSOzN1Iqjtlqe8uoSg+qdVPYyRb0TILSqqmtlSFuE4mTDECwsm397Ya7iXGzfF7lg==",
+ "version": "9.0.0",
+ "resolved": "https://registry.npmjs.org/postcss-discard-overridden/-/postcss-discard-overridden-9.0.0.tgz",
+ "integrity": "sha512-QpfO7CswjgCXynrHfrfc56AbIpwHEzaoGYbJiEZwZi2etBK4EJ6vfDgym/EULV58n/nqwfuCnpB1itpXvSWqbg==",
"dev": true,
"license": "MIT",
"engines": {
- "node": "^18.12.0 || ^20.9.0 || >=22.0"
+ "node": "^22.22.3 || ^24.15.0 || >=26.0"
},
"peerDependencies": {
- "postcss": "^8.4.32"
+ "postcss": "^8.5.26"
}
},
"node_modules/postcss-discard-unused": {
- "version": "7.0.4",
- "resolved": "https://registry.npmjs.org/postcss-discard-unused/-/postcss-discard-unused-7.0.4.tgz",
- "integrity": "sha512-/d6sIm8SSJbDDzdHyt/BWZ5upC6Dtn6JIL0uQts+AuvA5ddVmkw/3H4NtDv7DybGzCA1o3Q9R6kt4qsnS2mCSQ==",
+ "version": "9.0.0",
+ "resolved": "https://registry.npmjs.org/postcss-discard-unused/-/postcss-discard-unused-9.0.0.tgz",
+ "integrity": "sha512-wNHVZrSmZH+vUpzvq/7n22xn7k2IMcv3OfM9GsgcK6YYYR5SVHtyyQIKpQ33ERo8fF8q0lFs2o6K8X7UArvBcQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "postcss-selector-parser": "^7.1.0"
+ "postcss-selector-parser": "^7.1.5"
},
"engines": {
- "node": "^18.12.0 || ^20.9.0 || >=22.0"
+ "node": "^22.22.3 || ^24.15.0 || >=26.0"
},
"peerDependencies": {
- "postcss": "^8.4.32"
+ "postcss": "^8.5.26"
}
},
"node_modules/postcss-import": {
- "version": "16.1.1",
- "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-16.1.1.tgz",
- "integrity": "sha512-2xVS1NCZAfjtVdvXiyegxzJ447GyqCeEI5V7ApgQVOWnros1p5lGNovJNapwPpMombyFBfqDwt7AD3n2l0KOfQ==",
+ "version": "17.0.0",
+ "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-17.0.0.tgz",
+ "integrity": "sha512-qzVMJhcq39KweT7Fp0HcjE5YxL5yI854Q3CtufxDuZKO9HLQUp2rptB9+U0MIj3pT1ZEVXoqnpeC1mSkfYuz4Q==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1818,7 +4136,7 @@
"resolve": "^1.1.7"
},
"engines": {
- "node": ">=18.0.0"
+ "node": ">=22.0.0"
},
"peerDependencies": {
"postcss": "^8.0.0"
@@ -1885,333 +4203,335 @@
}
},
"node_modules/postcss-merge-idents": {
- "version": "7.0.1",
- "resolved": "https://registry.npmjs.org/postcss-merge-idents/-/postcss-merge-idents-7.0.1.tgz",
- "integrity": "sha512-2KaHTdWvoxMnNn7/aBhS1fnjdMBXHtT9tbW0wwH6/pWeMnIllb3wJ/iy5y67C7+uyW9gIOL7VM4XtvkRI6+ZXQ==",
+ "version": "9.0.0",
+ "resolved": "https://registry.npmjs.org/postcss-merge-idents/-/postcss-merge-idents-9.0.0.tgz",
+ "integrity": "sha512-oOvQHqpjf/+CpCjQ1i8Bza1nX7b2lCycK3lppe1LmF/hX8urkYMI/ajMTPwXZJsfCUZMqqZKvMg0qt9YJiZ4lQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "cssnano-utils": "^5.0.1",
+ "cssnano-utils": "^7.0.0",
"postcss-value-parser": "^4.2.0"
},
"engines": {
- "node": "^18.12.0 || ^20.9.0 || >=22.0"
+ "node": "^22.22.3 || ^24.15.0 || >=26.0"
},
"peerDependencies": {
- "postcss": "^8.4.32"
+ "postcss": "^8.5.26"
}
},
"node_modules/postcss-merge-longhand": {
- "version": "7.0.5",
- "resolved": "https://registry.npmjs.org/postcss-merge-longhand/-/postcss-merge-longhand-7.0.5.tgz",
- "integrity": "sha512-Kpu5v4Ys6QI59FxmxtNB/iHUVDn9Y9sYw66D6+SZoIk4QTz1prC4aYkhIESu+ieG1iylod1f8MILMs1Em3mmIw==",
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/postcss-merge-longhand/-/postcss-merge-longhand-9.0.1.tgz",
+ "integrity": "sha512-SC93bxL+wBkxmGtYg7R085PUclZyz/C2npIj25EbNr4DFU3IwDGj6+SVomdhd90xD4rPDEVGTOGYHvcTq5K8ZQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"postcss-value-parser": "^4.2.0",
- "stylehacks": "^7.0.5"
+ "stylehacks": "^9.0.1"
},
"engines": {
- "node": "^18.12.0 || ^20.9.0 || >=22.0"
+ "node": "^22.22.3 || ^24.15.0 || >=26.0"
},
"peerDependencies": {
- "postcss": "^8.4.32"
+ "postcss": "^8.5.26"
}
},
"node_modules/postcss-merge-rules": {
- "version": "7.0.6",
- "resolved": "https://registry.npmjs.org/postcss-merge-rules/-/postcss-merge-rules-7.0.6.tgz",
- "integrity": "sha512-2jIPT4Tzs8K87tvgCpSukRQ2jjd+hH6Bb8rEEOUDmmhOeTcqDg5fEFK8uKIu+Pvc3//sm3Uu6FRqfyv7YF7+BQ==",
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/postcss-merge-rules/-/postcss-merge-rules-9.0.1.tgz",
+ "integrity": "sha512-3YM3QdJvGPxTozNEyRM+bxh52n8bxC5tu/M7sJNiof+20fWmZutPk1a77D8dJt9k8HiUgZFsdbpvNDwWtCsy5Q==",
"dev": true,
"license": "MIT",
"dependencies": {
- "browserslist": "^4.25.1",
- "caniuse-api": "^3.0.0",
- "cssnano-utils": "^5.0.1",
- "postcss-selector-parser": "^7.1.0"
+ "browserslist": "^4.28.8",
+ "caniuse-api": "^4.0.0",
+ "cssnano-utils": "^7.0.0",
+ "postcss-selector-parser": "^7.1.5"
},
"engines": {
- "node": "^18.12.0 || ^20.9.0 || >=22.0"
+ "node": "^22.22.3 || ^24.15.0 || >=26.0"
},
"peerDependencies": {
- "postcss": "^8.4.32"
+ "postcss": "^8.5.26"
}
},
"node_modules/postcss-minify-font-values": {
- "version": "7.0.1",
- "resolved": "https://registry.npmjs.org/postcss-minify-font-values/-/postcss-minify-font-values-7.0.1.tgz",
- "integrity": "sha512-2m1uiuJeTplll+tq4ENOQSzB8LRnSUChBv7oSyFLsJRtUgAAJGP6LLz0/8lkinTgxrmJSPOEhgY1bMXOQ4ZXhQ==",
+ "version": "9.0.0",
+ "resolved": "https://registry.npmjs.org/postcss-minify-font-values/-/postcss-minify-font-values-9.0.0.tgz",
+ "integrity": "sha512-S6aq3wVJL1+6wgNR58bnARNHC+4Q3ZZpEjvjMEbFtpb+4cOoYJOywJLOQ+bl9bQmYdeN/2l4PpHIBKra+I1tfg==",
"dev": true,
"license": "MIT",
"dependencies": {
"postcss-value-parser": "^4.2.0"
},
"engines": {
- "node": "^18.12.0 || ^20.9.0 || >=22.0"
+ "node": "^22.22.3 || ^24.15.0 || >=26.0"
},
"peerDependencies": {
- "postcss": "^8.4.32"
+ "postcss": "^8.5.26"
}
},
"node_modules/postcss-minify-gradients": {
- "version": "7.0.1",
- "resolved": "https://registry.npmjs.org/postcss-minify-gradients/-/postcss-minify-gradients-7.0.1.tgz",
- "integrity": "sha512-X9JjaysZJwlqNkJbUDgOclyG3jZEpAMOfof6PUZjPnPrePnPG62pS17CjdM32uT1Uq1jFvNSff9l7kNbmMSL2A==",
+ "version": "9.0.0",
+ "resolved": "https://registry.npmjs.org/postcss-minify-gradients/-/postcss-minify-gradients-9.0.0.tgz",
+ "integrity": "sha512-kg7oZHz7zfiCk8ZkBT9P2peBM6LGcGIXocY0v3iOVoR3CqUKpFeYVMlTjGUM6Qf+6d9hcRZV+BU/LeYR2BnsMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "colord": "^2.9.3",
- "cssnano-utils": "^5.0.1",
+ "@colordx/core": "^5.6.0",
+ "cssnano-utils": "^7.0.0",
"postcss-value-parser": "^4.2.0"
},
"engines": {
- "node": "^18.12.0 || ^20.9.0 || >=22.0"
+ "node": "^22.22.3 || ^24.15.0 || >=26.0"
},
"peerDependencies": {
- "postcss": "^8.4.32"
+ "postcss": "^8.5.26"
}
},
"node_modules/postcss-minify-params": {
- "version": "7.0.4",
- "resolved": "https://registry.npmjs.org/postcss-minify-params/-/postcss-minify-params-7.0.4.tgz",
- "integrity": "sha512-3OqqUddfH8c2e7M35W6zIwv7jssM/3miF9cbCSb1iJiWvtguQjlxZGIHK9JRmc8XAKmE2PFGtHSM7g/VcW97sw==",
+ "version": "9.0.0",
+ "resolved": "https://registry.npmjs.org/postcss-minify-params/-/postcss-minify-params-9.0.0.tgz",
+ "integrity": "sha512-VxVNdPemBrmk51TRgxGu3QcUvWmCaDEJ/rCIICwfB1cESkjwC/h76zTwGXgDsfbXaQ+T4M+7tmVN/vc/ZgZPjw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "browserslist": "^4.25.1",
- "cssnano-utils": "^5.0.1",
+ "browserslist": "^4.28.8",
+ "cssnano-utils": "^7.0.0",
"postcss-value-parser": "^4.2.0"
},
"engines": {
- "node": "^18.12.0 || ^20.9.0 || >=22.0"
+ "node": "^22.22.3 || ^24.15.0 || >=26.0"
},
"peerDependencies": {
- "postcss": "^8.4.32"
+ "postcss": "^8.5.26"
}
},
"node_modules/postcss-minify-selectors": {
- "version": "7.0.5",
- "resolved": "https://registry.npmjs.org/postcss-minify-selectors/-/postcss-minify-selectors-7.0.5.tgz",
- "integrity": "sha512-x2/IvofHcdIrAm9Q+p06ZD1h6FPcQ32WtCRVodJLDR+WMn8EVHI1kvLxZuGKz/9EY5nAmI6lIQIrpo4tBy5+ug==",
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/postcss-minify-selectors/-/postcss-minify-selectors-9.0.1.tgz",
+ "integrity": "sha512-O2iEU97c+zJYlaIQ3WLNrbWfR1oEzem0Lm6CmepwdLVxYiKz24f6EwWbtJkdjYpxaw9OnD9hOC3IyLYXFVykTg==",
"dev": true,
"license": "MIT",
"dependencies": {
+ "browserslist": "^4.28.8",
+ "caniuse-api": "^4.0.0",
"cssesc": "^3.0.0",
- "postcss-selector-parser": "^7.1.0"
+ "postcss-selector-parser": "^7.1.5"
},
"engines": {
- "node": "^18.12.0 || ^20.9.0 || >=22.0"
+ "node": "^22.22.3 || ^24.15.0 || >=26.0"
},
"peerDependencies": {
- "postcss": "^8.4.32"
+ "postcss": "^8.5.26"
}
},
"node_modules/postcss-normalize-charset": {
- "version": "7.0.1",
- "resolved": "https://registry.npmjs.org/postcss-normalize-charset/-/postcss-normalize-charset-7.0.1.tgz",
- "integrity": "sha512-sn413ofhSQHlZFae//m9FTOfkmiZ+YQXsbosqOWRiVQncU2BA3daX3n0VF3cG6rGLSFVc5Di/yns0dFfh8NFgQ==",
+ "version": "9.0.0",
+ "resolved": "https://registry.npmjs.org/postcss-normalize-charset/-/postcss-normalize-charset-9.0.0.tgz",
+ "integrity": "sha512-5KiD7x3KE/uxuhrNHIh8hJ0sOvsFimfNJh6Lgp1MxxirHC6aGZisH00kup5ShLsoAHpUXZO90X6UntyQvJrVEA==",
"dev": true,
"license": "MIT",
"engines": {
- "node": "^18.12.0 || ^20.9.0 || >=22.0"
+ "node": "^22.22.3 || ^24.15.0 || >=26.0"
},
"peerDependencies": {
- "postcss": "^8.4.32"
+ "postcss": "^8.5.26"
}
},
"node_modules/postcss-normalize-display-values": {
- "version": "7.0.1",
- "resolved": "https://registry.npmjs.org/postcss-normalize-display-values/-/postcss-normalize-display-values-7.0.1.tgz",
- "integrity": "sha512-E5nnB26XjSYz/mGITm6JgiDpAbVuAkzXwLzRZtts19jHDUBFxZ0BkXAehy0uimrOjYJbocby4FVswA/5noOxrQ==",
+ "version": "9.0.0",
+ "resolved": "https://registry.npmjs.org/postcss-normalize-display-values/-/postcss-normalize-display-values-9.0.0.tgz",
+ "integrity": "sha512-EUeiB3MGJeMsLHHOZADPYLE6N2pabRRwHnr6basV0iK9fbsSKJB6zP3y9agp7HtGZsnCqnlqAC8ZPrenBdxoHA==",
"dev": true,
"license": "MIT",
"dependencies": {
"postcss-value-parser": "^4.2.0"
},
"engines": {
- "node": "^18.12.0 || ^20.9.0 || >=22.0"
+ "node": "^22.22.3 || ^24.15.0 || >=26.0"
},
"peerDependencies": {
- "postcss": "^8.4.32"
+ "postcss": "^8.5.26"
}
},
"node_modules/postcss-normalize-positions": {
- "version": "7.0.1",
- "resolved": "https://registry.npmjs.org/postcss-normalize-positions/-/postcss-normalize-positions-7.0.1.tgz",
- "integrity": "sha512-pB/SzrIP2l50ZIYu+yQZyMNmnAcwyYb9R1fVWPRxm4zcUFCY2ign7rcntGFuMXDdd9L2pPNUgoODDk91PzRZuQ==",
+ "version": "9.0.0",
+ "resolved": "https://registry.npmjs.org/postcss-normalize-positions/-/postcss-normalize-positions-9.0.0.tgz",
+ "integrity": "sha512-Qen36tMiWwNpC+zD0oYM+9R1ojajbID4zsWWSCzZj9G5raSm4o2hgyE/IEQloKWPJNgOWMZsb8PmXSxGmPZAgA==",
"dev": true,
"license": "MIT",
"dependencies": {
"postcss-value-parser": "^4.2.0"
},
"engines": {
- "node": "^18.12.0 || ^20.9.0 || >=22.0"
+ "node": "^22.22.3 || ^24.15.0 || >=26.0"
},
"peerDependencies": {
- "postcss": "^8.4.32"
+ "postcss": "^8.5.26"
}
},
"node_modules/postcss-normalize-repeat-style": {
- "version": "7.0.1",
- "resolved": "https://registry.npmjs.org/postcss-normalize-repeat-style/-/postcss-normalize-repeat-style-7.0.1.tgz",
- "integrity": "sha512-NsSQJ8zj8TIDiF0ig44Byo3Jk9e4gNt9x2VIlJudnQQ5DhWAHJPF4Tr1ITwyHio2BUi/I6Iv0HRO7beHYOloYQ==",
+ "version": "9.0.0",
+ "resolved": "https://registry.npmjs.org/postcss-normalize-repeat-style/-/postcss-normalize-repeat-style-9.0.0.tgz",
+ "integrity": "sha512-KV5pQse31Qn/nezj5PmKHgV5oD4a3Tb8wMMxJ6/61Wifp9RzIkaovmHqfUD28KNbhsHs/B71TxnohaTvEZK/UA==",
"dev": true,
"license": "MIT",
"dependencies": {
"postcss-value-parser": "^4.2.0"
},
"engines": {
- "node": "^18.12.0 || ^20.9.0 || >=22.0"
+ "node": "^22.22.3 || ^24.15.0 || >=26.0"
},
"peerDependencies": {
- "postcss": "^8.4.32"
+ "postcss": "^8.5.26"
}
},
"node_modules/postcss-normalize-string": {
- "version": "7.0.1",
- "resolved": "https://registry.npmjs.org/postcss-normalize-string/-/postcss-normalize-string-7.0.1.tgz",
- "integrity": "sha512-QByrI7hAhsoze992kpbMlJSbZ8FuCEc1OT9EFbZ6HldXNpsdpZr+YXC5di3UEv0+jeZlHbZcoCADgb7a+lPmmQ==",
+ "version": "9.0.0",
+ "resolved": "https://registry.npmjs.org/postcss-normalize-string/-/postcss-normalize-string-9.0.0.tgz",
+ "integrity": "sha512-htylsPopjm/LiUheas9j7B1en+Pc97ANIYyN/+aj+ruStU6J35YYwo5b5QD/v01JRD61nwzK+aKhoXeKVXW4kA==",
"dev": true,
"license": "MIT",
"dependencies": {
"postcss-value-parser": "^4.2.0"
},
"engines": {
- "node": "^18.12.0 || ^20.9.0 || >=22.0"
+ "node": "^22.22.3 || ^24.15.0 || >=26.0"
},
"peerDependencies": {
- "postcss": "^8.4.32"
+ "postcss": "^8.5.26"
}
},
"node_modules/postcss-normalize-timing-functions": {
- "version": "7.0.1",
- "resolved": "https://registry.npmjs.org/postcss-normalize-timing-functions/-/postcss-normalize-timing-functions-7.0.1.tgz",
- "integrity": "sha512-bHifyuuSNdKKsnNJ0s8fmfLMlvsQwYVxIoUBnowIVl2ZAdrkYQNGVB4RxjfpvkMjipqvbz0u7feBZybkl/6NJg==",
+ "version": "9.0.0",
+ "resolved": "https://registry.npmjs.org/postcss-normalize-timing-functions/-/postcss-normalize-timing-functions-9.0.0.tgz",
+ "integrity": "sha512-yhg0N68ALfNPsmAX2vHH/ZZrYziINbF+K/1Hp7Itqli2B84eX0zdcb7EbK0rrmDgYkuzE1pBc11C2codw7eXtg==",
"dev": true,
"license": "MIT",
"dependencies": {
"postcss-value-parser": "^4.2.0"
},
"engines": {
- "node": "^18.12.0 || ^20.9.0 || >=22.0"
+ "node": "^22.22.3 || ^24.15.0 || >=26.0"
},
"peerDependencies": {
- "postcss": "^8.4.32"
+ "postcss": "^8.5.26"
}
},
"node_modules/postcss-normalize-unicode": {
- "version": "7.0.4",
- "resolved": "https://registry.npmjs.org/postcss-normalize-unicode/-/postcss-normalize-unicode-7.0.4.tgz",
- "integrity": "sha512-LvIURTi1sQoZqj8mEIE8R15yvM+OhbR1avynMtI9bUzj5gGKR/gfZFd8O7VMj0QgJaIFzxDwxGl/ASMYAkqO8g==",
+ "version": "9.0.0",
+ "resolved": "https://registry.npmjs.org/postcss-normalize-unicode/-/postcss-normalize-unicode-9.0.0.tgz",
+ "integrity": "sha512-sJ/STfFE+8Fh8GP0KGcZvt/Fv5+XsvrsLDvjlJsqQ5tl5hW+ptHCZigorpoV6BhsvTXm8qircCGAR78IArwHkA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "browserslist": "^4.25.1",
+ "browserslist": "^4.28.8",
"postcss-value-parser": "^4.2.0"
},
"engines": {
- "node": "^18.12.0 || ^20.9.0 || >=22.0"
+ "node": "^22.22.3 || ^24.15.0 || >=26.0"
},
"peerDependencies": {
- "postcss": "^8.4.32"
+ "postcss": "^8.5.26"
}
},
"node_modules/postcss-normalize-url": {
- "version": "7.0.1",
- "resolved": "https://registry.npmjs.org/postcss-normalize-url/-/postcss-normalize-url-7.0.1.tgz",
- "integrity": "sha512-sUcD2cWtyK1AOL/82Fwy1aIVm/wwj5SdZkgZ3QiUzSzQQofrbq15jWJ3BA7Z+yVRwamCjJgZJN0I9IS7c6tgeQ==",
+ "version": "9.0.0",
+ "resolved": "https://registry.npmjs.org/postcss-normalize-url/-/postcss-normalize-url-9.0.0.tgz",
+ "integrity": "sha512-kj2QJovR0DWhCiF1XiS3EXCykt+JHEhUHzoWFxXspz0eZ8pJMkAhSbqNoeVCk7koL8P+l9sRrVKybV0ZpqZuqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"postcss-value-parser": "^4.2.0"
},
"engines": {
- "node": "^18.12.0 || ^20.9.0 || >=22.0"
+ "node": "^22.22.3 || ^24.15.0 || >=26.0"
},
"peerDependencies": {
- "postcss": "^8.4.32"
+ "postcss": "^8.5.26"
}
},
"node_modules/postcss-normalize-whitespace": {
- "version": "7.0.1",
- "resolved": "https://registry.npmjs.org/postcss-normalize-whitespace/-/postcss-normalize-whitespace-7.0.1.tgz",
- "integrity": "sha512-vsbgFHMFQrJBJKrUFJNZ2pgBeBkC2IvvoHjz1to0/0Xk7sII24T0qFOiJzG6Fu3zJoq/0yI4rKWi7WhApW+EFA==",
+ "version": "9.0.0",
+ "resolved": "https://registry.npmjs.org/postcss-normalize-whitespace/-/postcss-normalize-whitespace-9.0.0.tgz",
+ "integrity": "sha512-L08dL2ANyDGSijArZUBIbeQntB5jMCZ9U8jx+KVmjz436zq1zCKBYDjmXLxNDVDfwZEoXTienU8RHSiM86I3GQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"postcss-value-parser": "^4.2.0"
},
"engines": {
- "node": "^18.12.0 || ^20.9.0 || >=22.0"
+ "node": "^22.22.3 || ^24.15.0 || >=26.0"
},
"peerDependencies": {
- "postcss": "^8.4.32"
+ "postcss": "^8.5.26"
}
},
"node_modules/postcss-ordered-values": {
- "version": "7.0.2",
- "resolved": "https://registry.npmjs.org/postcss-ordered-values/-/postcss-ordered-values-7.0.2.tgz",
- "integrity": "sha512-AMJjt1ECBffF7CEON/Y0rekRLS6KsePU6PRP08UqYW4UGFRnTXNrByUzYK1h8AC7UWTZdQ9O3Oq9kFIhm0SFEw==",
+ "version": "9.0.0",
+ "resolved": "https://registry.npmjs.org/postcss-ordered-values/-/postcss-ordered-values-9.0.0.tgz",
+ "integrity": "sha512-LTayHrel3G8pwgrsu6Sk5V+9MkAPfOLNoPD7kvH/xTCpNhCf8jSSHRdwpUPtOvX0mMN7BETpTyhMSEjiJEUZ7g==",
"dev": true,
"license": "MIT",
"dependencies": {
- "cssnano-utils": "^5.0.1",
+ "cssnano-utils": "^7.0.0",
"postcss-value-parser": "^4.2.0"
},
"engines": {
- "node": "^18.12.0 || ^20.9.0 || >=22.0"
+ "node": "^22.22.3 || ^24.15.0 || >=26.0"
},
"peerDependencies": {
- "postcss": "^8.4.32"
+ "postcss": "^8.5.26"
}
},
"node_modules/postcss-reduce-idents": {
- "version": "7.0.1",
- "resolved": "https://registry.npmjs.org/postcss-reduce-idents/-/postcss-reduce-idents-7.0.1.tgz",
- "integrity": "sha512-CHwIHGaPitJUWY/LLz/jKNI/Zq+KWhH1kfj0SDCTrSQQmcO4fwJ/vkifLTsRhWP6/256MvCHY+RJR3sPwtgA/g==",
+ "version": "9.0.0",
+ "resolved": "https://registry.npmjs.org/postcss-reduce-idents/-/postcss-reduce-idents-9.0.0.tgz",
+ "integrity": "sha512-8gmK/Lrz8+63wnWL9nIUyQmJAwQaEYhfPUYuCzRamkWl47XWArSwOM7UXVmDl+jvKaxOW4KfIZCSGIL1ut0Mag==",
"dev": true,
"license": "MIT",
"dependencies": {
"postcss-value-parser": "^4.2.0"
},
"engines": {
- "node": "^18.12.0 || ^20.9.0 || >=22.0"
+ "node": "^22.22.3 || ^24.15.0 || >=26.0"
},
"peerDependencies": {
- "postcss": "^8.4.32"
+ "postcss": "^8.5.26"
}
},
"node_modules/postcss-reduce-initial": {
- "version": "7.0.4",
- "resolved": "https://registry.npmjs.org/postcss-reduce-initial/-/postcss-reduce-initial-7.0.4.tgz",
- "integrity": "sha512-rdIC9IlMBn7zJo6puim58Xd++0HdbvHeHaPgXsimMfG1ijC5A9ULvNLSE0rUKVJOvNMcwewW4Ga21ngyJjY/+Q==",
+ "version": "9.0.0",
+ "resolved": "https://registry.npmjs.org/postcss-reduce-initial/-/postcss-reduce-initial-9.0.0.tgz",
+ "integrity": "sha512-rwjqY5Ahht8xx41ChB/s90DYia6Wg2fwBwt2Uv1X/hp5+Ok1OCxWoDaBNgcLoO5r57RWHscO0L4nQdPKjZnghw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "browserslist": "^4.25.1",
- "caniuse-api": "^3.0.0"
+ "browserslist": "^4.28.8",
+ "caniuse-api": "^4.0.0"
},
"engines": {
- "node": "^18.12.0 || ^20.9.0 || >=22.0"
+ "node": "^22.22.3 || ^24.15.0 || >=26.0"
},
"peerDependencies": {
- "postcss": "^8.4.32"
+ "postcss": "^8.5.26"
}
},
"node_modules/postcss-reduce-transforms": {
- "version": "7.0.1",
- "resolved": "https://registry.npmjs.org/postcss-reduce-transforms/-/postcss-reduce-transforms-7.0.1.tgz",
- "integrity": "sha512-MhyEbfrm+Mlp/36hvZ9mT9DaO7dbncU0CvWI8V93LRkY6IYlu38OPg3FObnuKTUxJ4qA8HpurdQOo5CyqqO76g==",
+ "version": "9.0.0",
+ "resolved": "https://registry.npmjs.org/postcss-reduce-transforms/-/postcss-reduce-transforms-9.0.0.tgz",
+ "integrity": "sha512-wuCHGvWYKKbHmURHSv1a3XkZuE/EX4oWh7DO+qWpL5dFN7K5cJ9frRyFyJtNIp+YZb5JOnRyEGF40ocdGypPTg==",
"dev": true,
"license": "MIT",
"dependencies": {
"postcss-value-parser": "^4.2.0"
},
"engines": {
- "node": "^18.12.0 || ^20.9.0 || >=22.0"
+ "node": "^22.22.3 || ^24.15.0 || >=26.0"
},
"peerDependencies": {
- "postcss": "^8.4.32"
+ "postcss": "^8.5.26"
}
},
"node_modules/postcss-reporter": {
@@ -2242,9 +4562,9 @@
}
},
"node_modules/postcss-selector-parser": {
- "version": "7.1.0",
- "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz",
- "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==",
+ "version": "7.1.5",
+ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.5.tgz",
+ "integrity": "sha512-KvvtD7SrlBP7dlgkBghEE3r84CABm5SmV2aNcG4oCA+qDnJ/tvKonFVvwWAyyWUEwxuNawdfEAZKP9zM3oZ2Uw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2256,48 +4576,48 @@
}
},
"node_modules/postcss-svgo": {
- "version": "7.1.0",
- "resolved": "https://registry.npmjs.org/postcss-svgo/-/postcss-svgo-7.1.0.tgz",
- "integrity": "sha512-KnAlfmhtoLz6IuU3Sij2ycusNs4jPW+QoFE5kuuUOK8awR6tMxZQrs5Ey3BUz7nFCzT3eqyFgqkyrHiaU2xx3w==",
+ "version": "9.0.0",
+ "resolved": "https://registry.npmjs.org/postcss-svgo/-/postcss-svgo-9.0.0.tgz",
+ "integrity": "sha512-G2TCltJV1UB0SkQzZ8Q0sQpjP42zcJ5EplnDYglab6lVmhrBZvCNVJEOyczwMBvCTYGESrV94KWNcmlDz+ezpA==",
"dev": true,
"license": "MIT",
"dependencies": {
"postcss-value-parser": "^4.2.0",
- "svgo": "^4.0.0"
+ "svgo": "^4.1.0"
},
"engines": {
- "node": "^18.12.0 || ^20.9.0 || >= 18"
+ "node": "^22.22.3 || ^24.15.0 || >=26.0"
},
"peerDependencies": {
- "postcss": "^8.4.32"
+ "postcss": "^8.5.26"
}
},
"node_modules/postcss-unique-selectors": {
- "version": "7.0.4",
- "resolved": "https://registry.npmjs.org/postcss-unique-selectors/-/postcss-unique-selectors-7.0.4.tgz",
- "integrity": "sha512-pmlZjsmEAG7cHd7uK3ZiNSW6otSZ13RHuZ/4cDN/bVglS5EpF2r2oxY99SuOHa8m7AWoBCelTS3JPpzsIs8skQ==",
+ "version": "9.0.0",
+ "resolved": "https://registry.npmjs.org/postcss-unique-selectors/-/postcss-unique-selectors-9.0.0.tgz",
+ "integrity": "sha512-jxfneNnWS/8gDY8FCRh/+HdB/H7otsKFu97NRiFK6ZkolpNtMVHCClTy7h+GgQpd/OA6e9Oi3YIkwE+8Dn44dA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "postcss-selector-parser": "^7.1.0"
+ "postcss-selector-parser": "^7.1.5"
},
"engines": {
- "node": "^18.12.0 || ^20.9.0 || >=22.0"
+ "node": "^22.22.3 || ^24.15.0 || >=26.0"
},
"peerDependencies": {
- "postcss": "^8.4.32"
+ "postcss": "^8.5.26"
}
},
"node_modules/postcss-url": {
- "version": "10.1.3",
- "resolved": "https://registry.npmjs.org/postcss-url/-/postcss-url-10.1.3.tgz",
- "integrity": "sha512-FUzyxfI5l2tKmXdYc6VTu3TWZsInayEKPbiyW+P6vmmIrrb4I6CGX0BFoewgYHLK+oIL5FECEK02REYRpBvUCw==",
+ "version": "10.1.4",
+ "resolved": "https://registry.npmjs.org/postcss-url/-/postcss-url-10.1.4.tgz",
+ "integrity": "sha512-/oBzyLOHQvXvVr/7bzZOFD5lYTy1nomVE4aMA9eY5KQsHfWLDIzb86q8XoUsmrj2xKoGYMAvd884EzJEQzuXIw==",
"dev": true,
"license": "MIT",
"dependencies": {
"make-dir": "~3.1.0",
"mime": "~2.5.2",
- "minimatch": "~3.0.4",
+ "minimatch": "^3.1.5",
"xxhashjs": "~0.2.2"
},
"engines": {
@@ -2315,26 +4635,50 @@
"license": "MIT"
},
"node_modules/postcss-zindex": {
- "version": "7.0.1",
- "resolved": "https://registry.npmjs.org/postcss-zindex/-/postcss-zindex-7.0.1.tgz",
- "integrity": "sha512-bZEfMUhaxNiXC8tw1qoFeYVCusQHlPJS5iqvuzePQjXBGIkyyCeGbqsyeyoODIuLmNE7Wc8GdTIhXpaaWbTX8Q==",
+ "version": "9.0.0",
+ "resolved": "https://registry.npmjs.org/postcss-zindex/-/postcss-zindex-9.0.0.tgz",
+ "integrity": "sha512-QHfTYkllrNc5RBEMeO5Glb1wk4WJW/nh0DeSd8ZZxogdRd2gqCf4EyUP3ByiR1+F1peGOG9dlJHlpdryQZEpMg==",
"dev": true,
"license": "MIT",
"engines": {
- "node": "^18.12.0 || ^20.9.0 || >=22.0"
+ "node": "^22.22.3 || ^24.15.0 || >=26.0"
},
"peerDependencies": {
- "postcss": "^8.4.32"
+ "postcss": "^8.5.26"
}
},
"node_modules/preact": {
- "version": "10.27.2",
- "resolved": "https://registry.npmjs.org/preact/-/preact-10.27.2.tgz",
- "integrity": "sha512-5SYSgFKSyhCbk6SrXyMpqjb5+MQBgfvEKE/OC+PujcY34sOpqtr+0AZQtPYx5IA6VxynQ7rUPCtKzyovpj9Bpg==",
+ "version": "10.29.8",
+ "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.8.tgz",
+ "integrity": "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
+ },
+ "peerDependencies": {
+ "preact-render-to-string": ">=5"
+ },
+ "peerDependenciesMeta": {
+ "preact-render-to-string": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/prettier": {
+ "version": "3.9.6",
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz",
+ "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "prettier": "bin/prettier.cjs"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/pretty-hrtime": {
@@ -2397,6 +4741,16 @@
"node": ">=0.10.0"
}
},
+ "node_modules/require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/resolve": {
"version": "1.22.10",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
@@ -2418,6 +4772,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/resolve-from": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz",
+ "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/resolve-relative-url": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-relative-url/-/resolve-relative-url-1.0.0.tgz",
@@ -2429,11 +4793,14 @@
}
},
"node_modules/sax": {
- "version": "1.4.1",
- "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz",
- "integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==",
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz",
+ "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==",
"dev": true,
- "license": "ISC"
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": ">=11.0.0"
+ }
},
"node_modules/semver": {
"version": "6.3.1",
@@ -2458,6 +4825,19 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/smol-toml": {
+ "version": "1.8.0",
+ "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.8.0.tgz",
+ "integrity": "sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/cyyynthia"
+ }
+ },
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -2497,20 +4877,20 @@
}
},
"node_modules/stylehacks": {
- "version": "7.0.5",
- "resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-7.0.5.tgz",
- "integrity": "sha512-5kNb7V37BNf0Q3w+1pxfa+oiNPS++/b4Jil9e/kPDgrk1zjEd6uR7SZeJiYaLYH6RRSC1XX2/37OTeU/4FvuIA==",
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-9.0.1.tgz",
+ "integrity": "sha512-4UVg0FQWpKpTUTNshZmtwbyg9xXavQe27F2ihQDCsjgvnKvP37RswqklL03tSk3JSFjk8h4Emeil4OVWHGugBA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "browserslist": "^4.24.5",
- "postcss-selector-parser": "^7.1.0"
+ "browserslist": "^4.28.8",
+ "postcss-selector-parser": "^7.1.5"
},
"engines": {
- "node": "^18.12.0 || ^20.9.0 || >=22.0"
+ "node": "^22.22.3 || ^24.15.0 || >=26.0"
},
"peerDependencies": {
- "postcss": "^8.4.32"
+ "postcss": "^8.5.26"
}
},
"node_modules/supports-preserve-symlinks-flag": {
@@ -2527,19 +4907,19 @@
}
},
"node_modules/svgo": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/svgo/-/svgo-4.0.0.tgz",
- "integrity": "sha512-VvrHQ+9uniE+Mvx3+C9IEe/lWasXCU0nXMY2kZeLrHNICuRiC8uMPyM14UEaMOFA5mhyQqEkB02VoQ16n3DLaw==",
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/svgo/-/svgo-4.1.0.tgz",
+ "integrity": "sha512-bkxnTg1kSU0guhIBmibA6UUhrQmPVA1XsQLN+ylCd+UWzbnLkySOcXpyk1mrl05f+pcaCx2eHb+sp6BgMZWX+Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"commander": "^11.1.0",
- "css-select": "^5.1.0",
+ "css-select": "^6.0.0",
"css-tree": "^3.0.1",
- "css-what": "^6.1.0",
+ "css-what": "^7.0.0",
"csso": "^5.0.5",
"picocolors": "^1.1.1",
- "sax": "^1.4.1"
+ "sax": "1.6.1"
},
"bin": {
"svgo": "bin/svgo.js"
@@ -2559,15 +4939,25 @@
"dev": true,
"license": "Apache-2.0"
},
+ "node_modules/tinyexec": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz",
+ "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/tinyglobby": {
- "version": "0.2.15",
- "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
- "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
"dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
- "picomatch": "^4.0.3"
+ "picomatch": "^4.0.4"
},
"engines": {
"node": ">=12.0.0"
@@ -2595,9 +4985,9 @@
}
},
"node_modules/tinyglobby/node_modules/picomatch": {
- "version": "4.0.3",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
- "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
+ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
"dev": true,
"license": "MIT",
"engines": {
@@ -2626,6 +5016,50 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
+ "node_modules/typescript": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
+ "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "peer": true,
+ "bin": {
+ "tsc": "bin/tsc"
+ },
+ "engines": {
+ "node": ">=16.20.0"
+ },
+ "optionalDependencies": {
+ "@typescript/typescript-aix-ppc64": "7.0.2",
+ "@typescript/typescript-darwin-arm64": "7.0.2",
+ "@typescript/typescript-darwin-x64": "7.0.2",
+ "@typescript/typescript-freebsd-arm64": "7.0.2",
+ "@typescript/typescript-freebsd-x64": "7.0.2",
+ "@typescript/typescript-linux-arm": "7.0.2",
+ "@typescript/typescript-linux-arm64": "7.0.2",
+ "@typescript/typescript-linux-loong64": "7.0.2",
+ "@typescript/typescript-linux-mips64el": "7.0.2",
+ "@typescript/typescript-linux-ppc64": "7.0.2",
+ "@typescript/typescript-linux-riscv64": "7.0.2",
+ "@typescript/typescript-linux-s390x": "7.0.2",
+ "@typescript/typescript-linux-x64": "7.0.2",
+ "@typescript/typescript-netbsd-arm64": "7.0.2",
+ "@typescript/typescript-netbsd-x64": "7.0.2",
+ "@typescript/typescript-openbsd-arm64": "7.0.2",
+ "@typescript/typescript-openbsd-x64": "7.0.2",
+ "@typescript/typescript-sunos-x64": "7.0.2",
+ "@typescript/typescript-win32-arm64": "7.0.2",
+ "@typescript/typescript-win32-x64": "7.0.2"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "8.3.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
+ "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
"node_modules/universalify": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
@@ -2637,9 +5071,9 @@
}
},
"node_modules/update-browserslist-db": {
- "version": "1.1.3",
- "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz",
- "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==",
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz",
+ "integrity": "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==",
"dev": true,
"funding": [
{
@@ -2685,6 +5119,26 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/vscode-languageserver-textdocument": {
+ "version": "1.0.14",
+ "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.14.tgz",
+ "integrity": "sha512-EQyqJMi552E4ZTf46izQ4Fj6XquqxCySR3J5ZSD1SisMf6RfpeOWHxGBE8Gr6V0/3GHIGdAzDn8F8+1nTGCnoQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/vscode-uri": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.2.0.tgz",
+ "integrity": "sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/wasm-feature-detect": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/wasm-feature-detect/-/wasm-feature-detect-1.9.0.tgz",
+ "integrity": "sha512-zonE+xlIIYtxPy++L24ow0hAD8CICb4+FgPyROd3buyXIqsJvUEDkBgfCCoXOd1Hu3DUr0GOfnPIdcGV+YpNaA==",
+ "license": "Apache-2.0"
+ },
"node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
@@ -2703,6 +5157,19 @@
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
+ "node_modules/xdg-basedir": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-5.1.0.tgz",
+ "integrity": "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/xxhashjs": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/xxhashjs/-/xxhashjs-0.2.2.tgz",
@@ -2724,9 +5191,9 @@
}
},
"node_modules/yaml": {
- "version": "2.8.1",
- "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz",
- "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==",
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
+ "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
"dev": true,
"license": "ISC",
"bin": {
@@ -2734,6 +5201,9 @@
},
"engines": {
"node": ">= 14.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/yargs": {
diff --git a/package.json b/package.json
index 6ed650722..7fdc2c442 100644
--- a/package.json
+++ b/package.json
@@ -1,34 +1,80 @@
{
"name": "@techaro/anubis",
- "version": "1.22.0",
+ "version": "1.28.0-pre2",
"description": "",
"main": "index.js",
"scripts": {
- "test": "npm run assets && go test ./...",
+ "test": "npm run assets && SKIP_INTEGRATION=1 go test ./...",
+ "test:spelling": "git diff --name-only HEAD | npx cspell --file-list stdin",
+ "test:spelling:all:": "cspell .",
+ "test:spelling:sort": "node .vscode/scripts/sort-project-words.mjs",
"test:integration": "npm run assets && go test -v ./internal/test",
"test:integration:podman": "npm run assets && go test -v ./internal/test --playwright-runner=podman",
"test:integration:docker": "npm run assets && go test -v ./internal/test --playwright-runner=docker",
- "assets": "go generate ./... && ./web/build.sh && ./xess/build.sh",
+ "assets:generate": "go generate ./...",
+ "assets:wasm": "bash ./wasm/scripts/build_wasm.sh",
+ "assets:wasm2js": "bash ./wasm/scripts/build_wasm2js.sh",
+ "assets:web": "./web/build.sh",
+ "assets:xess": "./xess/build.sh",
+ "assets": "npm run assets:generate && npm run assets:wasm && npm run assets:wasm2js && npm run assets:web && npm run assets:xess && echo Done",
"build": "npm run assets && go build -o ./var/anubis ./cmd/anubis",
"dev": "npm run assets && go run ./cmd/anubis --use-remote-address --target http://localhost:3000",
"container": "npm run assets && go run ./cmd/containerbuild",
- "package": "yeet",
- "lint": "make lint"
+ "package": "go tool yeet",
+ "lint": "make lint",
+ "spellcheck": "cspell --no-progress --config .vscode/cspell.json \"**\"",
+ "prepare": "husky && go mod download && cargo fetch",
+ "format": "prettier -w . 2>&1 >/dev/null && go tool goimports -w ."
},
"author": "",
"license": "ISC",
"devDependencies": {
- "cssnano": "^7.1.1",
- "cssnano-preset-advanced": "^7.0.9",
- "esbuild": "^0.25.10",
- "playwright": "^1.52.0",
+ "@commitlint/cli": "^21.2.2",
+ "@commitlint/config-conventional": "^21.0.2",
+ "baseline-browser-mapping": "^2.11.20",
+ "cspell": "^10.1.1",
+ "cssnano": "^9.0.1",
+ "cssnano-preset-advanced": "^9.0.1",
+ "esbuild": "^0.28.2",
+ "husky": "^9.1.7",
+ "playwright": "^1.61.1",
"postcss-cli": "^11.0.1",
- "postcss-import": "^16.1.1",
+ "postcss-import": "^17.0.0",
"postcss-import-url": "^7.2.0",
- "postcss-url": "^10.1.3"
+ "postcss-url": "^10.1.4",
+ "prettier": "^3.9.6"
},
"dependencies": {
"@aws-crypto/sha256-js": "^5.2.0",
- "preact": "^10.27.2"
+ "preact": "^10.29.8",
+ "wasm-feature-detect": "^1.9.0"
+ },
+ "commitlint": {
+ "extends": [
+ "@commitlint/config-conventional"
+ ],
+ "rules": {
+ "body-max-line-length": [
+ 2,
+ "always",
+ 99999
+ ],
+ "footer-max-line-length": [
+ 2,
+ "always",
+ 99999
+ ],
+ "signed-off-by": [
+ 2,
+ "always"
+ ]
+ }
+ },
+ "prettier": {
+ "singleQuote": false,
+ "tabWidth": 2,
+ "semi": true,
+ "trailingComma": "all",
+ "printWidth": 80
}
}
\ No newline at end of file
diff --git a/run/openrc/anubis.initd b/run/openrc/anubis.initd
old mode 100644
new mode 100755
diff --git a/run/windows/License.rtf b/run/windows/License.rtf
new file mode 100644
index 000000000..e213bbc1d
--- /dev/null
+++ b/run/windows/License.rtf
@@ -0,0 +1,11 @@
+{\rtf1\ansi\ansicpg1252\deff0\deflang1033
+{\fonttbl{\f0\fswiss\fcharset0 Helvetica;}}
+\f0\fs20
+Copyright (c) 2025 Xe Iaso
\par
+\par
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:\par
+\par
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\par
+\par
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\par
+}
diff --git a/run/windows/anubis.env b/run/windows/anubis.env
new file mode 100644
index 000000000..cc735f91a
--- /dev/null
+++ b/run/windows/anubis.env
@@ -0,0 +1,35 @@
+# Anubis service configuration.
+#
+# Edit this file, then start the service:
+# sc start anubis
+#
+# To start Anubis automatically at boot:
+# sc config anubis start= auto
+#
+# Every setting here matches a command line flag. See
+# https://anubis.techaro.lol/docs/admin/installation for the full list.
+
+# Address Anubis listens on.
+BIND=:8923
+
+# The server Anubis protects. Requests that pass the challenge are proxied here.
+TARGET=http://localhost:3923
+
+# Policy file. Edit it to change which bots are challenged, denied or allowed.
+# The installer writes the real directory here. It is normally the Techaro
+# folder under C:\ProgramData, but %ProgramData% can be moved to another
+# drive, so the shipped template does not name one.
+POLICY_FNAME=__ANUBIS_DATA_DIR__\anubis.yaml
+
+# Log level: DEBUG, INFO, WARN or ERROR.
+SLOG_LEVEL=INFO
+
+# Signing key, exactly 64 hexadecimal characters. If this is not set, Anubis
+# generates a random key at every start, which invalidates every outstanding
+# challenge whenever the service restarts. Set it before going to production.
+# ED25519_PRIVATE_KEY_HEX=
+
+# Comma separated domains Anubis may redirect to. Leaving this unset allows any
+# domain. See
+# https://anubis.techaro.lol/docs/admin/configuration/redirect-domains
+# REDIRECT_DOMAINS=
diff --git a/run/windows/anubis.wxs b/run/windows/anubis.wxs
new file mode 100644
index 000000000..ac97296ed
--- /dev/null
+++ b/run/windows/anubis.wxs
@@ -0,0 +1,129 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ WIX_UPGRADE_DETECTED
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ NOT REMOVE
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/run/windows/banner.bmp b/run/windows/banner.bmp
new file mode 100644
index 000000000..19e08a27d
Binary files /dev/null and b/run/windows/banner.bmp differ
diff --git a/run/windows/dialog.bmp b/run/windows/dialog.bmp
new file mode 100644
index 000000000..3ece0b8f9
Binary files /dev/null and b/run/windows/dialog.bmp differ
diff --git a/run/windows/logging.yaml b/run/windows/logging.yaml
new file mode 100644
index 000000000..2cad12af1
--- /dev/null
+++ b/run/windows/logging.yaml
@@ -0,0 +1,13 @@
+# Anubis writes its log here and rotates it. Startup errors that happen before
+# this file is read go to anubis-startup.log in the same directory instead.
+logging:
+ sink: file
+ parameters:
+ # The installer writes the real directory here. It is normally the Techaro
+ # folder under C:\ProgramData, but %ProgramData% can be moved to another
+ # drive, so the shipped template does not name one.
+ file: __ANUBIS_DATA_DIR__\anubis.log
+ maxBackups: 3
+ maxBytes: 104857600
+ maxAge: 7
+ compress: true
diff --git a/test/README.md b/test/README.md
new file mode 100644
index 000000000..b2cfcb1f5
--- /dev/null
+++ b/test/README.md
@@ -0,0 +1,19 @@
+# Functional and smoke testing for Anubis
+
+This directory contains all the functional testing infrastructure for Anubis.
+
+## Smoke tests
+
+A smoke test is a folder with an executable file named `test.sh`. This `test.sh` file is responsible for starting up Anubis, running some logic [ideally with `backoff-retry`](../utils/cmd/backoff-retry/), and then returning 0 for success or anything else for failure. CI will `backoff-retry` smoke tests to ensure they aren't flaky.
+
+### Best practices for smoke tests
+
+- Source `lib/lib.sh`. It is there to save you time.
+- If you are starting services with `docker compose`, use `build_anubis_ko` and load anubis from `ko.local/anubis`.
+- Make tests as deterministic as possible.
+- Use the default configuration as much as possible.
+- Make tests run as fast as possible.
+- If you need to do anything involving reading response metadata, write your logic in JavaScript and put it in `test.mjs`.
+- If your smoke test exists because of an event, horrifying discovery, or other tale of woe, record it in `README.md` as a tale of woe. See [the gitweb smoke test](./gitweb/README.md) for an example.
+
+Make sure to add smoke tests to [the smoke testing GitHub Actions workflow](../.github/workflows/smoke-tests.yml).
diff --git a/test/anubis_configs/aggressive_403.yaml b/test/anubis_configs/aggressive_403.yaml
index facafd6f4..ff966517b 100644
--- a/test/anubis_configs/aggressive_403.yaml
+++ b/test/anubis_configs/aggressive_403.yaml
@@ -1,12 +1,12 @@
bots:
-- name: deny
- user_agent_regex: DENY
- action: DENY
+ - name: deny
+ user_agent_regex: DENY
+ action: DENY
-- name: challenge
- user_agent_regex: CHALLENGE
- action: CHALLENGE
+ - name: challenge
+ user_agent_regex: CHALLENGE
+ action: CHALLENGE
status_codes:
CHALLENGE: 401
- DENY: 403
\ No newline at end of file
+ DENY: 403
diff --git a/test/caddy/Caddyfile b/test/caddy/Caddyfile
index a80a93a65..db31e0cd4 100644
--- a/test/caddy/Caddyfile
+++ b/test/caddy/Caddyfile
@@ -1,6 +1,6 @@
:80 {
reverse_proxy http://anubis:3000 {
- header_up X-Real-Ip {remote_host}
+ header_up X-Real-IP {remote_host}
header_up X-Http-Version {http.request.proto}
}
}
@@ -9,7 +9,7 @@
tls /etc/techaro/pki/caddy.local.cetacean.club/cert.pem /etc/techaro/pki/caddy.local.cetacean.club/key.pem
reverse_proxy http://anubis:3000 {
- header_up X-Real-Ip {remote_host}
+ header_up X-Real-IP {remote_host}
header_up X-Http-Version {http.request.proto}
header_up X-Tls-Version {http.request.tls.version}
}
diff --git a/test/cmd/relayd/main.go b/test/cmd/relayd/main.go
index 26700d781..880d5ef98 100644
--- a/test/cmd/relayd/main.go
+++ b/test/cmd/relayd/main.go
@@ -32,7 +32,7 @@ func main() {
flagenv.Parse()
flag.Parse()
- internal.InitSlog(*slogLevel)
+ internal.InitSlog(*slogLevel, os.Stderr)
slog.Info("starting",
"bind", *bind,
@@ -102,7 +102,7 @@ func main() {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err == nil {
- r.Header.Set("X-Real-Ip", host)
+ r.Header.Set("X-Real-IP", host)
}
},
Transport: &http.Transport{
diff --git a/test/cmd/unixhttpd/main.go b/test/cmd/unixhttpd/main.go
index a57c202b4..bee4a9e11 100644
--- a/test/cmd/unixhttpd/main.go
+++ b/test/cmd/unixhttpd/main.go
@@ -34,7 +34,7 @@ func main() {
flagenv.Parse()
flag.Parse()
- internal.InitSlog(*slogLevel)
+ internal.InitSlog(*slogLevel, os.Stderr)
if *dir == "" && *socketPath == "" {
flag.Usage()
diff --git a/test/docker-registry/anubis.yaml b/test/docker-registry/anubis.yaml
new file mode 100644
index 000000000..f352c7010
--- /dev/null
+++ b/test/docker-registry/anubis.yaml
@@ -0,0 +1,7 @@
+bots:
+ - import: (data)/meta/default-config.yaml
+ - import: (data)/clients/docker-client.yaml
+
+status_codes:
+ CHALLENGE: 200
+ DENY: 403
diff --git a/test/docker-registry/docker-compose.yaml b/test/docker-registry/docker-compose.yaml
new file mode 100644
index 000000000..963fcea8e
--- /dev/null
+++ b/test/docker-registry/docker-compose.yaml
@@ -0,0 +1,30 @@
+services:
+ registry:
+ image: distribution/distribution:edge
+ restart: always
+
+ relayd:
+ image: ghcr.io/xe/x/relayd
+ pull_policy: always
+ environment:
+ CERT_DIR: /etc/techaro/pki/registry.local.cetacean.club
+ CERT_FNAME: cert.pem
+ KEY_FNAME: key.pem
+ PROXY_TO: http://anubis:3000
+ ports:
+ - 3004:3004
+ volumes:
+ - ./pki/registry.local.cetacean.club:/etc/techaro/pki/registry.local.cetacean.club
+
+ anubis:
+ image: ko.local/anubis
+ restart: always
+ environment:
+ BIND: ":3000"
+ TARGET: http://registry:5000
+ POLICY_FNAME: /etc/techaro/anubis.yaml
+ USE_REMOTE_ADDRESS: "true"
+ ports:
+ - 3000
+ volumes:
+ - ./anubis.yaml:/etc/techaro/anubis.yaml
diff --git a/test/docker-registry/test.sh b/test/docker-registry/test.sh
new file mode 100755
index 000000000..c2b1eeb4c
--- /dev/null
+++ b/test/docker-registry/test.sh
@@ -0,0 +1,29 @@
+#!/usr/bin/env bash
+
+set -eo pipefail
+
+export VERSION=${GITHUB_SHA}-test
+export KO_DOCKER_REPO=ko.local
+
+set -u
+
+source ../lib/lib.sh
+
+build_anubis_ko
+
+function cleanup() {
+ docker compose down
+}
+
+trap cleanup EXIT SIGINT
+
+mint_cert registry.local.cetacean.club
+
+docker compose up -d
+
+backoff-retry skopeo \
+ --insecure-policy \
+ copy \
+ --dest-tls-verify=false \
+ docker://hello-world \
+ docker://registry.local.cetacean.club:3004/hello-world
diff --git a/test/docker-registry/var/.gitignore b/test/docker-registry/var/.gitignore
new file mode 100644
index 000000000..c96a04f00
--- /dev/null
+++ b/test/docker-registry/var/.gitignore
@@ -0,0 +1,2 @@
+*
+!.gitignore
\ No newline at end of file
diff --git a/test/double_slash/README.md b/test/double_slash/README.md
new file mode 100644
index 000000000..c25ca6165
--- /dev/null
+++ b/test/double_slash/README.md
@@ -0,0 +1,11 @@
+# Allow double-slashes in paths
+
+This test ensures that paths of the form `//foo` get passed to the target server as `//foo`.
+
+## Tale of woe
+
+Gentoo's wiki has pages named after configuration files. Prior to this fix, Anubis passed everything through [net/http#ServeMux](https://pkg.go.dev/net/http#ServeMux). ServeMux cleans paths to remove common client errors like two slashes in a row. This is a problem for servers that actually want it.
+
+To fix it, Anubis needed to use some prefix comparison logic to dispatch to ServeMux and then otherwise reverse proxy to the target service. This preserves the double slashes.
+
+This smoke test reads [input.txt](./input.txt) and passes every path to httpdebug, making sure the path comes out right on the other end.
diff --git a/test/double_slash/test.mjs b/test/double_slash/test.mjs
index 7ae9c5a8c..15a54eb92 100644
--- a/test/double_slash/test.mjs
+++ b/test/double_slash/test.mjs
@@ -3,13 +3,13 @@ import { createInterface } from "readline";
async function getPage(path) {
return fetch(`http://localhost:8923${path}`)
- .then(resp => {
+ .then((resp) => {
if (resp.status !== 200) {
throw new Error(`wanted status 200, got status: ${resp.status}`);
}
return resp;
})
- .then(resp => resp.text());
+ .then((resp) => resp.text());
}
(async () => {
@@ -42,4 +42,4 @@ async function getPage(path) {
}
process.exit(failed ? 1 : 0);
-})();
\ No newline at end of file
+})();
diff --git a/test/forced-language/test.mjs b/test/forced-language/test.mjs
index e64237efd..7fab657e8 100644
--- a/test/forced-language/test.mjs
+++ b/test/forced-language/test.mjs
@@ -3,25 +3,25 @@ async function getChallengePage() {
headers: {
"Accept-Language": "en",
"User-Agent": "CHALLENGE",
- }
+ },
})
- .then(resp => {
+ .then((resp) => {
if (resp.status !== 200) {
throw new Error(`wanted status 200, got status: ${resp.status}`);
}
return resp;
})
- .then(resp => resp.text());
+ .then((resp) => resp.text());
}
(async () => {
const page = await getChallengePage();
if (!page.includes(``)) {
- console.log(page)
+ console.log(page);
throw new Error("force language smoke test failed");
}
console.log("FORCED_LANGUAGE=de caused a page to be rendered in german");
process.exit(0);
-})();
\ No newline at end of file
+})();
diff --git a/test/git-clone/README.md b/test/git-clone/README.md
new file mode 100644
index 000000000..af0f1aea1
--- /dev/null
+++ b/test/git-clone/README.md
@@ -0,0 +1,3 @@
+# git-clone
+
+Tests the end to end git repository cloning process with a real git server and a real git client.
diff --git a/test/git-push/README.md b/test/git-push/README.md
new file mode 100644
index 000000000..789aefbbb
--- /dev/null
+++ b/test/git-push/README.md
@@ -0,0 +1,3 @@
+# git-push
+
+Tests the end to end git repository pushing process with a real git server and a real git client.
diff --git a/test/git-push/test.sh b/test/git-push/test.sh
index a6d5f97c8..832b882b1 100755
--- a/test/git-push/test.sh
+++ b/test/git-push/test.sh
@@ -2,7 +2,7 @@
set -eo pipefail
-export VERSION=$GITHUB_COMMIT-test
+export VERSION=${GITHUB_SHA}-test
export KO_DOCKER_REPO=ko.local
set -u
@@ -21,16 +21,16 @@ docker compose up -d
sleep 2
(
- cd var && \
- mkdir foo && \
- cd foo && \
- git init && \
- touch README && \
- git add . && \
- git config user.name "Anubis CI" && \
- git config user.email "social+anubis-ci@techaro.lol" && \
- git commit -sm "initial commit" && \
- git push -u http://localhost:3000/git/foo.git master
+ cd var &&
+ mkdir foo &&
+ cd foo &&
+ git init &&
+ touch README &&
+ git add . &&
+ git config user.name "Anubis CI" &&
+ git config user.email "social+anubis-ci@techaro.lol" &&
+ git commit -sm "initial commit" &&
+ git push -u http://localhost:3000/git/foo.git master
)
-exit 0
\ No newline at end of file
+exit 0
diff --git a/test/gitweb/README.md b/test/gitweb/README.md
new file mode 100644
index 000000000..c69a1fb0f
--- /dev/null
+++ b/test/gitweb/README.md
@@ -0,0 +1,11 @@
+# gitweb
+
+Tests [gitweb](https://git-scm.com/docs/gitweb), a Perl CGI script that gives web browsers an interface to git repositories.
+
+## Tale of woe
+
+Gitweb is a CGI script from before the turn of the century. As a result it has the unique legacy behaviour of [using semicolons as parameter separators](https://stackoverflow.com/questions/3481664/semicolon-as-url-query-separator). This is notably a WAF bypass technique in the hacker underground, as some poorly configured web applications will accept semicolons to separate URL parameters but the WAF only looks for ampersands.
+
+Either way, when [v1.26.0](https://github.com/TecharoHQ/anubis/releases/tag/v1.26.0) was released, Anubis switched to using `httputil.ReverseProxy.Rewrite` instead of the deprecated `Director`. According to the [`Rewrite` documentation comment](https://pkg.go.dev/net/http/httputil#ReverseProxy), it's a lot more militant about correctness and conforming to what HTTP does in practice today, not what it did years ago.
+
+This smoke test will make sure that using semicolons as URL query parameters continues working.
diff --git a/test/gitweb/anubis.yaml b/test/gitweb/anubis.yaml
new file mode 100644
index 000000000..9880a86c5
--- /dev/null
+++ b/test/gitweb/anubis.yaml
@@ -0,0 +1,8 @@
+bots:
+ - name: challenge
+ user_agent_regex: CHALLENGE
+ action: CHALLENGE
+
+status_codes:
+ CHALLENGE: 200
+ DENY: 403
diff --git a/test/gitweb/docker-compose.yaml b/test/gitweb/docker-compose.yaml
new file mode 100644
index 000000000..f8d3c2320
--- /dev/null
+++ b/test/gitweb/docker-compose.yaml
@@ -0,0 +1,35 @@
+services:
+ anubis:
+ image: ko.local/anubis
+ environment:
+ BIND: ":8005"
+ TARGET: http://gitweb:80
+ USE_REMOTE_ADDRESS: "true"
+ POLICY_FNAME: /etc/techaro/anubis.yaml
+ ports:
+ - 8005:8005
+ volumes:
+ - ./anubis.yaml:/etc/techaro/anubis.yaml
+
+ repo:
+ image: jgiannuzzi/gitolite
+ ports:
+ - "2232:22"
+ volumes:
+ - repo-data:/var/lib/git
+ - ./secrets:/etc/techaro/secrets
+ environment:
+ - SSH_KEY=ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCdAjNHstv/WUCG/7SLyD8gUugEYdcqcHKr3h+2iOE/zsN4Q0+xGr6eV+qkPKDRMLowYBivsvVkZrkBjnAauDL6vbXGUld4cDBR7i4MhUoVBQgtTpkPnH86Ei+bomyiU8S51Lrq0WAW3s7ek8VOAi3hbjGVYhDzOeZX7N+S/PtKQKyJHz0AkE0fkXxGHf9BsYvnFvw95ZaYB9y8IKE0jsnBgCYq5PX29gyYKBlhaMR6AyixcyNZDEDGlcPOCy79CuWeLxB5LjkVyHI9i5dIkdW345oTOPHETWm3r00P+McsIkF666QYAK+wo9iL4/DdOQc9ZQG36ouvuKBe0PyBukMCGFFDjNplit9X0ondBKm73WQw+zItA3HBh/FY+uKVN6ImYfCVac5LGiis2AqMsond+3uwLzb48xis9w2k8AaxhRkIurPuRnT7H65HiaiYBX6ZeL+1+JFy1OE+B/P/6yox1UF5ZNHyVZiLzXTFN1rAT4LF21NwuskQ7T5iKuMzcuk= xe@zohar
+ - SSH_KEY_NAME=hunter2
+
+ gitweb:
+ image: mlan/gitweb
+ depends_on:
+ - repo
+ volumes:
+ - repo-data:/var/lib/git:ro
+ ports:
+ - 8083:80
+
+volumes:
+ repo-data:
diff --git a/test/gitweb/secrets/id_rsa b/test/gitweb/secrets/id_rsa
new file mode 100644
index 000000000..c36eea9e5
--- /dev/null
+++ b/test/gitweb/secrets/id_rsa
@@ -0,0 +1,38 @@
+-----BEGIN OPENSSH PRIVATE KEY-----
+b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABlwAAAAdzc2gtcn
+NhAAAAAwEAAQAAAYEAnQIzR7Lb/1lAhv+0i8g/IFLoBGHXKnByq94ftojhP87DeENPsRq+
+nlfqpDyg0TC6MGAYr7L1ZGa5AY5wGrgy+r21xlJXeHAwUe4uDIVKFQUILU6ZD5x/OhIvm6
+JsolPEudS66tFgFt7O3pPFTgIt4W4xlWIQ8znmV+zfkvz7SkCsiR89AJBNH5F8Rh3/QbGL
+5xb8PeWWmAfcvCChNI7JwYAmKuT19vYMmCgZYWjEegMosXMjWQxAxpXDzgsu/Qrlni8QeS
+45FchyPYuXSJHVt+OaEzjxxE1pt69ND/jHLCJBeuukGACvsKPYi+Pw3TkHPWUBt+qLr7ig
+XtD8gbpDAhhRQ4zaZYrfV9KJ3QSpu91kMPsyLQNxwYfxWPrilTeiJmHwlWnOSxoorNgKjL
+KJ3ft7sC82+PMYrPcNpPAGsYUZCLqz7kZ0+x+uR4momAV+mXi/tfiRctThPgfz/+sqMdVB
+eWTR8lWYi810xTdawE+CxdtTcLrJEO0+YirjM3LpAAAFgFjMlNlYzJTZAAAAB3NzaC1yc2
+EAAAGBAJ0CM0ey2/9ZQIb/tIvIPyBS6ARh1ypwcqveH7aI4T/Ow3hDT7Eavp5X6qQ8oNEw
+ujBgGK+y9WRmuQGOcBq4Mvq9tcZSV3hwMFHuLgyFShUFCC1OmQ+cfzoSL5uibKJTxLnUuu
+rRYBbezt6TxU4CLeFuMZViEPM55lfs35L8+0pArIkfPQCQTR+RfEYd/0Gxi+cW/D3llpgH
+3LwgoTSOycGAJirk9fb2DJgoGWFoxHoDKLFzI1kMQMaVw84LLv0K5Z4vEHkuORXIcj2Ll0
+iR1bfjmhM48cRNabevTQ/4xywiQXrrpBgAr7Cj2Ivj8N05Bz1lAbfqi6+4oF7Q/IG6QwIY
+UUOM2mWK31fSid0EqbvdZDD7Mi0DccGH8Vj64pU3oiZh8JVpzksaKKzYCoyyid37e7AvNv
+jzGKz3DaTwBrGFGQi6s+5GdPsfrkeJqJgFfpl4v7X4kXLU4T4H8//rKjHVQXlk0fJVmIvN
+dMU3WsBPgsXbU3C6yRDtPmIq4zNy6QAAAAMBAAEAAAGAD2vzEn9NyZKAL+JiNE/6bK0yrz
++BorWsexd/FkRH76cE9G/pc3QKNhkHlJMvQ+zdKjmTeH/Y8kApZYQ9RvqXE0wv/xIBF8ou
+jNWavfhs8uHtetnGAfkKrBmzhb4Z8oXM2b3nOtT5nbV2gfk8v4afmrgNSbwkqGdO/zg71j
+8KsFvoNohpdaqMJhDPBpTACOQCDF7pxBX5c+cNy4gJnX/K7w/XGo57tFgC8QJl9SHqboZB
+6HrD2uVvtVxqK6JV6V04/Md8KU94U3+4rwmZecHeW0lH96B7NJ7qAjLkldeqqicHFFi3On
+kXnpjSVe17mqnAAHN9sg9jvzLl3TrxX4GZ4TNT32HA4AVSUZBEj6oii2TWx8Mg7R1flDNK
+UV3NNK/YFBqvX8O2YARQqKk9edW7cL9nOddgDuDKpSTPGA7KXuf4fa4ugl3/UWA5prkpOh
+QK7rTCKup0ZG3jtEizLacMgj0M6JCbmIauRsE29bYq4RDDbiaD4NMfT9mBXKMRMlJzAAAA
+wQCNi5jqS/hWJYAoCnoijpiJF19DL+FUZqygw/AEhgyEHOzQ1p08nzOGKWjPbXdqLfpfea
+gOI7GTjF+JnvTKimYBV+/thOcIcK5tgMIo1wCHwfWuZwMmpHAKTZ0+G08O71+hJmjhoKmV
+zSCmH0I+OIF5NNvr2AHBhNBqli0qsW8nHqs9nGLkaL1CYpLDzdN4/bKlIDgyQ5Ssw89mAy
+SiDfGVEiTF5QvFqUwWe2lRLSqgdOtNA/iudU6hBkKmFnJ705IAAADBANRGIEsaU57sqmlo
+X2bt1KdXLmAncnR8PulMV4MTEnVxLiKyAp5lld0Xf0pb5BdyGwOrEvZIattEcIeOu82YiF
+7X3+4ZjgRkyhFeQDJlId8RfW5YS9d22PBtotA/7nDGBkWBCUDaceqxPU6rETJOGdG33DNr
+fyZJIU7XBxAw6bYi+QIuxQuPoGNBhGumQ961+4CraoKimAPw4FtE+aFxPapi7A20JFnRAC
+lbNyuuuUXW2EmJT0szpzKfMNLOokYydwAAAMEAvVnCPChJKqHy+XMWV4dn2tdxCfBzAzDL
+Gqd9ZhKa2d9/KCpt65I6jHu4o4Xhl0DIJZ+SvELXt7VVLBm5VqRypMti4EQP+P/CPfQYCZ
+PFSN3sUTtcMzCpv7WBOFviBXmXiEu1/f/NYm41VSbxji02y2aCFOkGglfSh+XOwCcE8m9Y
+hKQJ9bJva0+/3PuU5rPvZeMEwrz4dqQaQGUhlKBEpVgNwkX6XVZU8ghjAxh4yRAFP6ek+p
++oYbWHvqe8On2fAAAACHhlQHpvaGFyAQI=
+-----END OPENSSH PRIVATE KEY-----
diff --git a/test/gitweb/secrets/id_rsa.pub b/test/gitweb/secrets/id_rsa.pub
new file mode 100644
index 000000000..b1e4dead7
--- /dev/null
+++ b/test/gitweb/secrets/id_rsa.pub
@@ -0,0 +1 @@
+ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCdAjNHstv/WUCG/7SLyD8gUugEYdcqcHKr3h+2iOE/zsN4Q0+xGr6eV+qkPKDRMLowYBivsvVkZrkBjnAauDL6vbXGUld4cDBR7i4MhUoVBQgtTpkPnH86Ei+bomyiU8S51Lrq0WAW3s7ek8VOAi3hbjGVYhDzOeZX7N+S/PtKQKyJHz0AkE0fkXxGHf9BsYvnFvw95ZaYB9y8IKE0jsnBgCYq5PX29gyYKBlhaMR6AyixcyNZDEDGlcPOCy79CuWeLxB5LjkVyHI9i5dIkdW345oTOPHETWm3r00P+McsIkF666QYAK+wo9iL4/DdOQc9ZQG36ouvuKBe0PyBukMCGFFDjNplit9X0ondBKm73WQw+zItA3HBh/FY+uKVN6ImYfCVac5LGiis2AqMsond+3uwLzb48xis9w2k8AaxhRkIurPuRnT7H65HiaiYBX6ZeL+1+JFy1OE+B/P/6yox1UF5ZNHyVZiLzXTFN1rAT4LF21NwuskQ7T5iKuMzcuk= xe@zohar
diff --git a/test/gitweb/test.mjs b/test/gitweb/test.mjs
new file mode 100644
index 000000000..d27a20f04
--- /dev/null
+++ b/test/gitweb/test.mjs
@@ -0,0 +1,54 @@
+// Smoke test that Anubis correctly proxies gitweb's semicolon-delimited query
+// strings to the backend.
+//
+// gitweb uses ';' as a query separator, e.g. /?p=testing.git;a=summary. If
+// Anubis drops or re-encodes the query string (as httputil.ReverseProxy's
+// Rewrite mode does by default), gitweb never sees p=testing.git and falls back
+// to serving the project-list page at /. In that failure mode the "summary"
+// response is byte-for-byte identical to the front page, so this test both
+// asserts the summary page loads AND that it differs from the front page.
+
+const BASE = "http://localhost:8005";
+const UA = "Mozilla/5.0 (compatible; AnubisGitwebSmoke/1.0)";
+
+async function get(path) {
+ const resp = await fetch(`${BASE}${path}`, {
+ headers: { "User-Agent": UA },
+ redirect: "manual",
+ });
+ if (resp.status !== 200) {
+ throw new Error(`GET ${path}: wanted status 200, got ${resp.status}`);
+ }
+ return resp.text();
+}
+
+const frontPage = await get("/");
+const summaryPage = await get("/?p=testing.git;a=summary");
+
+let failed = false;
+
+// The summary page must actually be the repo summary, not the project list
+// that gitweb serves when the query string is lost.
+if (summaryPage === frontPage) {
+ console.log(
+ "FAIL: /?p=testing.git;a=summary returned the same body as / " +
+ "(the ';'-delimited query was dropped before reaching gitweb)",
+ );
+ failed = true;
+} else {
+ console.log("PASS: summary page differs from the front page");
+}
+
+// And it must link onward to the commit view, proving gitweb rendered the repo.
+const fragment = "/?p=testing.git;a=commit";
+if (!summaryPage.includes(fragment)) {
+ console.log(
+ `FAIL: summary page did not contain expected fragment: ${fragment}`,
+ );
+ console.log(summaryPage);
+ failed = true;
+} else {
+ console.log(`PASS: summary page contains ${fragment}`);
+}
+
+process.exit(failed ? 1 : 0);
diff --git a/test/gitweb/test.sh b/test/gitweb/test.sh
new file mode 100755
index 000000000..b0195c5fb
--- /dev/null
+++ b/test/gitweb/test.sh
@@ -0,0 +1,16 @@
+#!/usr/bin/env bash
+
+set -eo pipefail
+
+export VERSION=$GITHUB_COMMIT-test
+export KO_DOCKER_REPO=ko.local
+
+set -u
+
+source ../lib/lib.sh
+
+build_anubis_ko
+
+docker compose up -d
+
+backoff-retry node ./test.mjs
diff --git a/test/gitweb/var/.gitignore b/test/gitweb/var/.gitignore
new file mode 100644
index 000000000..c96a04f00
--- /dev/null
+++ b/test/gitweb/var/.gitignore
@@ -0,0 +1,2 @@
+*
+!.gitignore
\ No newline at end of file
diff --git a/test/go.mod b/test/go.mod
index 752b32802..a0f47329c 100644
--- a/test/go.mod
+++ b/test/go.mod
@@ -1,101 +1,109 @@
module github.com/TecharoHQ/anubis/test
-go 1.24.5
+go 1.26.4
replace github.com/TecharoHQ/anubis => ..
require (
- github.com/TecharoHQ/anubis v1.22.0
- github.com/docker/docker v28.3.3+incompatible
+ github.com/TecharoHQ/anubis v1.23.1
+ github.com/docker/docker v28.5.2+incompatible
github.com/facebookgo/flagenv v0.0.0-20160425205200-fcd59fca7456
github.com/google/uuid v1.6.0
)
require (
- buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.6-20250717185734-6c6e0d3c608e.1 // indirect
- cel.dev/expr v0.24.0 // indirect
+ buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20251209175733-2a1774d88802.1 // indirect
+ cel.dev/expr v0.25.2 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
- github.com/TecharoHQ/thoth-proto v0.4.0 // indirect
- github.com/a-h/templ v0.3.924 // indirect
+ github.com/TecharoHQ/thoth-proto v0.5.0 // indirect
+ github.com/a-h/templ v0.3.1020 // indirect
github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
- github.com/aws/aws-sdk-go-v2 v1.38.3 // indirect
- github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.1 // indirect
- github.com/aws/aws-sdk-go-v2/config v1.31.6 // indirect
- github.com/aws/aws-sdk-go-v2/credentials v1.18.10 // indirect
- github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.6 // indirect
- github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.6 // indirect
- github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.6 // indirect
- github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect
- github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.6 // indirect
- github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 // indirect
- github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.8.6 // indirect
- github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.6 // indirect
- github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.6 // indirect
- github.com/aws/aws-sdk-go-v2/service/s3 v1.87.3 // indirect
- github.com/aws/aws-sdk-go-v2/service/sso v1.29.1 // indirect
- github.com/aws/aws-sdk-go-v2/service/ssooidc v1.34.2 // indirect
- github.com/aws/aws-sdk-go-v2/service/sts v1.38.2 // indirect
- github.com/aws/smithy-go v1.23.0 // indirect
+ github.com/aws/aws-sdk-go-v2 v1.45.1 // indirect
+ github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20 // indirect
+ github.com/aws/aws-sdk-go-v2/config v1.33.1 // indirect
+ github.com/aws/aws-sdk-go-v2/credentials v1.20.1 // indirect
+ github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.19.1 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.1 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.1 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.1 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.1 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.1 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.1 // indirect
+ github.com/aws/aws-sdk-go-v2/service/s3 v1.109.1 // indirect
+ github.com/aws/aws-sdk-go-v2/service/signin v1.7.1 // indirect
+ github.com/aws/aws-sdk-go-v2/service/sso v1.35.1 // indirect
+ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.40.1 // indirect
+ github.com/aws/aws-sdk-go-v2/service/sts v1.47.1 // indirect
+ github.com/aws/smithy-go v1.28.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
- github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/distribution/reference v0.6.0 // indirect
- github.com/docker/go-connections v0.5.0 // indirect
+ github.com/djherbis/times v1.6.0 // indirect
+ github.com/docker/go-connections v0.7.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
- github.com/ebitengine/purego v0.8.4 // indirect
+ github.com/ebitengine/purego v0.10.2 // indirect
github.com/facebookgo/ensure v0.0.0-20200202191622-63f1cf65ac4c // indirect
github.com/facebookgo/subset v0.0.0-20200203212716-c811ad88dec4 // indirect
- github.com/felixge/httpsnoop v1.0.4 // indirect
- github.com/gaissmai/bart v0.23.0 // indirect
+ github.com/fahedouch/go-logrotate v0.3.0 // indirect
+ github.com/felixge/httpsnoop v1.1.0 // indirect
+ github.com/fvbommel/sortorder v1.1.0 // indirect
+ github.com/gaissmai/bart v0.29.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
- github.com/gogo/protobuf v1.3.2 // indirect
- github.com/golang-jwt/jwt/v5 v5.2.3 // indirect
- github.com/google/cel-go v0.26.0 // indirect
+ github.com/gobwas/glob v0.2.3 // indirect
+ github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
+ github.com/google/cel-go v0.28.1 // indirect
+ github.com/goreleaser/fileglob v1.4.0 // indirect
github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 // indirect
- github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2 // indirect
+ github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.4 // indirect
github.com/joho/godotenv v1.5.1 // indirect
github.com/jsha/minica v1.1.0 // indirect
- github.com/lum8rjack/go-ja4h v0.0.0-20250606032308-3a989c6635be // indirect
+ github.com/lum8rjack/go-ja4h v0.0.0-20250828030157-fa5266d50650 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/sys/atomicwriter v0.1.0 // indirect
+ github.com/morikuni/aec v1.1.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
- github.com/nicksnyder/go-i18n/v2 v2.6.0 // indirect
+ github.com/nicksnyder/go-i18n/v2 v2.6.1 // indirect
+ github.com/nikandfor/spintax v0.0.0-20181023094358-fc346b245bb3 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
- github.com/prometheus/client_golang v1.22.0 // indirect
+ github.com/prometheus/client_golang v1.24.1 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
- github.com/prometheus/common v0.65.0 // indirect
- github.com/prometheus/procfs v0.17.0 // indirect
- github.com/redis/go-redis/v9 v9.11.0 // indirect
+ github.com/prometheus/common v0.70.1 // indirect
+ github.com/prometheus/procfs v0.21.1 // indirect
+ github.com/redis/go-redis/v9 v9.22.0 // indirect
github.com/sebest/xff v0.0.0-20210106013422-671bd2870b3a // indirect
- github.com/shirou/gopsutil/v4 v4.25.6 // indirect
- github.com/stoewer/go-strcase v1.3.1 // indirect
+ github.com/shirou/gopsutil/v4 v4.26.8 // indirect
+ github.com/tetratelabs/wazero v1.12.1-0.20260714071631-236c2458ed22 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
- go.etcd.io/bbolt v1.4.2 // indirect
- go.opentelemetry.io/auto/sdk v1.1.0 // indirect
- go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 // indirect
- go.opentelemetry.io/otel v1.37.0 // indirect
+ go.etcd.io/bbolt v1.5.0 // indirect
+ go.opentelemetry.io/auto/sdk v1.2.1 // indirect
+ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect
+ go.opentelemetry.io/otel v1.44.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.37.0 // indirect
- go.opentelemetry.io/otel/metric v1.37.0 // indirect
- go.opentelemetry.io/otel/trace v1.37.0 // indirect
- go.yaml.in/yaml/v2 v2.4.2 // indirect
- golang.org/x/exp v0.0.0-20250718183923-645b1fa84792 // indirect
- golang.org/x/net v0.42.0 // indirect
- golang.org/x/sys v0.34.0 // indirect
- golang.org/x/text v0.27.0 // indirect
- google.golang.org/genproto/googleapis/api v0.0.0-20250721164621-a45f3dfb1074 // indirect
- google.golang.org/genproto/googleapis/rpc v0.0.0-20250721164621-a45f3dfb1074 // indirect
- google.golang.org/grpc v1.74.2 // indirect
- google.golang.org/protobuf v1.36.6 // indirect
+ go.opentelemetry.io/otel/metric v1.44.0 // indirect
+ go.opentelemetry.io/otel/trace v1.44.0 // indirect
+ go.uber.org/atomic v1.11.0 // indirect
+ go.yaml.in/yaml/v2 v2.4.4 // indirect
+ go.yaml.in/yaml/v3 v3.0.4 // indirect
+ golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 // indirect
+ golang.org/x/net v0.58.0 // indirect
+ golang.org/x/sync v0.22.0 // indirect
+ golang.org/x/sys v0.47.0 // indirect
+ golang.org/x/text v0.41.0 // indirect
+ google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect
+ google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
+ google.golang.org/grpc v1.83.2 // indirect
+ google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect
gotest.tools/v3 v3.5.2 // indirect
- k8s.io/apimachinery v0.33.3 // indirect
- sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect
+ k8s.io/apimachinery v0.37.0 // indirect
+ sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
sigs.k8s.io/yaml v1.6.0 // indirect
)
diff --git a/test/go.sum b/test/go.sum
index 2a197d9b8..2d11295e6 100644
--- a/test/go.sum
+++ b/test/go.sum
@@ -1,63 +1,65 @@
-buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.6-20250717185734-6c6e0d3c608e.1 h1:Lg6klmCi3v7VvpqeeLEER9/m5S8y9e9DjhqQnSCNy4k=
-buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.6-20250717185734-6c6e0d3c608e.1/go.mod h1:avRlCjnFzl98VPaeCtJ24RrV/wwHFzB8sWXhj26+n/U=
-cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY=
-cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw=
+buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20251209175733-2a1774d88802.1 h1:j9yeqTWEFrtimt8Nng2MIeRrpoCvQzM9/g25XTvqUGg=
+buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20251209175733-2a1774d88802.1/go.mod h1:tvtbpgaVXZX4g6Pn+AnzFycuRK3MOz5HJfEGeEllXYM=
+cel.dev/expr v0.25.2 h1:K6j46C81hXtZQfuX60cVWQFBJahKSE2gfRbNuvr5bFs=
+cel.dev/expr v0.25.2/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
-github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
-github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
+github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
+github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
-github.com/TecharoHQ/thoth-proto v0.4.0 h1:UbkvfgCku0Dm1R6O4ug3HOsJNnE6F3wB8x+Dpw2lzFI=
-github.com/TecharoHQ/thoth-proto v0.4.0/go.mod h1:IcGnZt3iYUZQVEa0Lwk5l4ix0hCeXlWUV1TJMZvbWx0=
-github.com/a-h/templ v0.3.924 h1:t5gZqTneXqvehpNZsgtnlOscnBboNh9aASBH2MgV/0k=
-github.com/a-h/templ v0.3.924/go.mod h1:FFAu4dI//ESmEN7PQkJ7E7QfnSEMdcnu7QrAY8Dn334=
+github.com/TecharoHQ/thoth-proto v0.5.0 h1:Fa663s4soYiURSU8MfW9tZ2wF+LsCRSaYmjUSyagfBM=
+github.com/TecharoHQ/thoth-proto v0.5.0/go.mod h1:C/U7FqTxpVn4V/qebC/GcW32I0h9xzsmWehF27KFOJs=
+github.com/a-h/templ v0.3.1020 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw=
+github.com/a-h/templ v0.3.1020/go.mod h1:A2DlK61v+K+NRoGnhmYbNYVmtYHcFO5/AisMvBdDxTM=
github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ=
github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw=
-github.com/aws/aws-sdk-go-v2 v1.38.3 h1:B6cV4oxnMs45fql4yRH+/Po/YU+597zgWqvDpYMturk=
-github.com/aws/aws-sdk-go-v2 v1.38.3/go.mod h1:sDioUELIUO9Znk23YVmIk86/9DOpkbyyVb1i/gUNFXY=
-github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.1 h1:i8p8P4diljCr60PpJp6qZXNlgX4m2yQFpYk+9ZT+J4E=
-github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.1/go.mod h1:ddqbooRZYNoJ2dsTwOty16rM+/Aqmk/GOXrK8cg7V00=
-github.com/aws/aws-sdk-go-v2/config v1.31.6 h1:a1t8fXY4GT4xjyJExz4knbuoxSCacB5hT/WgtfPyLjo=
-github.com/aws/aws-sdk-go-v2/config v1.31.6/go.mod h1:5ByscNi7R+ztvOGzeUaIu49vkMk2soq5NaH5PYe33MQ=
-github.com/aws/aws-sdk-go-v2/credentials v1.18.10 h1:xdJnXCouCx8Y0NncgoptztUocIYLKeQxrCgN6x9sdhg=
-github.com/aws/aws-sdk-go-v2/credentials v1.18.10/go.mod h1:7tQk08ntj914F/5i9jC4+2HQTAuJirq7m1vZVIhEkWs=
-github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.6 h1:wbjnrrMnKew78/juW7I2BtKQwa1qlf6EjQgS69uYY14=
-github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.6/go.mod h1:AtiqqNrDioJXuUgz3+3T0mBWN7Hro2n9wll2zRUc0ww=
-github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.6 h1:uF68eJA6+S9iVr9WgX1NaRGyQ/6MdIyc4JNUo6TN1FA=
-github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.6/go.mod h1:qlPeVZCGPiobx8wb1ft0GHT5l+dc6ldnwInDFaMvC7Y=
-github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.6 h1:pa1DEC6JoI0zduhZePp3zmhWvk/xxm4NB8Hy/Tlsgos=
-github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.6/go.mod h1:gxEjPebnhWGJoaDdtDkA0JX46VRg1wcTHYe63OfX5pE=
-github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo=
-github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo=
-github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.6 h1:R0tNFJqfjHL3900cqhXuwQ+1K4G0xc9Yf8EDbFXCKEw=
-github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.6/go.mod h1:y/7sDdu+aJvPtGXr4xYosdpq9a6T9Z0jkXfugmti0rI=
-github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 h1:oegbebPEMA/1Jny7kvwejowCaHz1FWZAQ94WXFNCyTM=
-github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1/go.mod h1:kemo5Myr9ac0U9JfSjMo9yHLtw+pECEHsFtJ9tqCEI8=
-github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.8.6 h1:hncKj/4gR+TPauZgTAsxOxNcvBayhUlYZ6LO/BYiQ30=
-github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.8.6/go.mod h1:OiIh45tp6HdJDDJGnja0mw8ihQGz3VGrUflLqSL0SmM=
-github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.6 h1:LHS1YAIJXJ4K9zS+1d/xa9JAA9sL2QyXIQCQFQW/X08=
-github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.6/go.mod h1:c9PCiTEuh0wQID5/KqA32J+HAgZxN9tOGXKCiYJjTZI=
-github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.6 h1:nEXUSAwyUfLTgnc9cxlDWy637qsq4UWwp3sNAfl0Z3Y=
-github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.6/go.mod h1:HGzIULx4Ge3Do2V0FaiYKcyKzOqwrhUZgCI77NisswQ=
-github.com/aws/aws-sdk-go-v2/service/s3 v1.87.3 h1:ETkfWcXP2KNPLecaDa++5bsQhCRa5M5sLUJa5DWYIIg=
-github.com/aws/aws-sdk-go-v2/service/s3 v1.87.3/go.mod h1:+/3ZTqoYb3Ur7DObD00tarKMLMuKg8iqz5CHEanqTnw=
-github.com/aws/aws-sdk-go-v2/service/sso v1.29.1 h1:8OLZnVJPvjnrxEwHFg9hVUof/P4sibH+Ea4KKuqAGSg=
-github.com/aws/aws-sdk-go-v2/service/sso v1.29.1/go.mod h1:27M3BpVi0C02UiQh1w9nsBEit6pLhlaH3NHna6WUbDE=
-github.com/aws/aws-sdk-go-v2/service/ssooidc v1.34.2 h1:gKWSTnqudpo8dAxqBqZnDoDWCiEh/40FziUjr/mo6uA=
-github.com/aws/aws-sdk-go-v2/service/ssooidc v1.34.2/go.mod h1:x7+rkNmRoEN1U13A6JE2fXne9EWyJy54o3n6d4mGaXQ=
-github.com/aws/aws-sdk-go-v2/service/sts v1.38.2 h1:YZPjhyaGzhDQEvsffDEcpycq49nl7fiGcfJTIo8BszI=
-github.com/aws/aws-sdk-go-v2/service/sts v1.38.2/go.mod h1:2dIN8qhQfv37BdUYGgEC8Q3tteM3zFxTI1MLO2O3J3c=
-github.com/aws/smithy-go v1.23.0 h1:8n6I3gXzWJB2DxBDnfxgBaSX6oe0d/t10qGz7OKqMCE=
-github.com/aws/smithy-go v1.23.0/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI=
+github.com/aws/aws-sdk-go-v2 v1.45.1 h1:iIoG3NaLhV6UZpPXyPXlDj2I9oS8tV/nMcMnITCC6Ks=
+github.com/aws/aws-sdk-go-v2 v1.45.1/go.mod h1:bttEH6JqnUL8LepvDVfdrds/fZ5bCIxzpe3abyUrhDU=
+github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20 h1:GPRlPwz40I2B2VrBEASOA3Bi77NyeqejNLkifosX0rs=
+github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20/go.mod h1:g7PNzKcsOKWb4fkSRBA7BZVAS6Y8IcxzN+nRohhQ1Q8=
+github.com/aws/aws-sdk-go-v2/config v1.33.1 h1:bq9jze1hQ5YTCLoVxNnbp0T7rglrlOE7N9YsHqjGkEw=
+github.com/aws/aws-sdk-go-v2/config v1.33.1/go.mod h1:2A3HQwG4zaL5Tm80rc6RZj8LmWWv4WYT5v8raSz/L7A=
+github.com/aws/aws-sdk-go-v2/credentials v1.20.1 h1:Z8GRNEx0u9sDkZOq4PUnN8mjGwbUQGRzMSXpvt3d8xQ=
+github.com/aws/aws-sdk-go-v2/credentials v1.20.1/go.mod h1:uBIK00kFo95dnemqfFMTWx0X8YRqsh6ecIoCjjOkZqM=
+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.19.1 h1:YIEBqcqRnpi4Pfv0YHImtgi6czGCwKHANC7SwmUAVD0=
+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.19.1/go.mod h1:imEf0oufgAo8KAkCHhrOdqGEC0YWx1PPBQH82shSxGw=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.1 h1:pc138gM1CW+XPc60rEwUlwwuwWFQK16CI1T7v1F9Oec=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.1/go.mod h1:1+koxpPIbfBdfzP6vojm5/zTpTQ/micYwlxIiNB3TxI=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.1 h1:K0JsbZQj+1h208Ro1zHeA4l7bMp0NvRffHQ91q8Ol1s=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.1/go.mod h1:W3/vL6EtCIatICGy9ab29QhMuae+cOKPWcMxv02CO+Q=
+github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.1 h1:yhw5KD1phVyP9vijxOUzDfEtJx+bt+L63k+VfuiYFAA=
+github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.1/go.mod h1:ZW2e0d7DYlRxlS9hEiMXE47gTdX5KRN4byUiNbUpG+Q=
+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 h1:bAdDl/HkGCcGPoe25ToSHEw23VIxt6CT5fLcg111BKg=
+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19/go.mod h1:KaUzbLxv4CeSxh6ZCl9B4m7CuFenS8kUEaDs+f/DQr4=
+github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.1 h1:s67hBfG5t9rn1NCvDuB4E3QIep3UFhHPtaIqFDjV3N8=
+github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.1/go.mod h1:FpvjBMXtSNMLPmDJsWwcY5cRnqJlpS2y1R6n4pvzs4k=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.1 h1:RmmWQPREQdk9U+PfqeHW3MqZaBaNK7TpV9W3RY+b+7g=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.1/go.mod h1:0A3W4F+68ZnNk5XcNL/e9HFMwnP8RlEicFfy6eOEDyw=
+github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.1 h1:ZMbtPZZQRca+3+XYQne9PBvRiYpHZlNJJOZfE9WNfT0=
+github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.1/go.mod h1:YAGWQdCYlVCoqrzvfv3RLxO6zKwti7gsAULOGWPLYv4=
+github.com/aws/aws-sdk-go-v2/service/s3 v1.109.1 h1:kVpzaDBzOdRtOftmiSpTdQbWVqRg0kONLXijktiwXnk=
+github.com/aws/aws-sdk-go-v2/service/s3 v1.109.1/go.mod h1:CUr46sCpGAg/rHaclRyhJX0LJAmH73uWSJPPSaMUrSk=
+github.com/aws/aws-sdk-go-v2/service/signin v1.7.1 h1:mdMtSVKdQ3+mzBh+l0ogrFYZVQUCg6pJZOirA2ARsYE=
+github.com/aws/aws-sdk-go-v2/service/signin v1.7.1/go.mod h1:9IqUlsJDbUPcg6cgx3WEzXdjrbWzLDQrak0aaSqlTcI=
+github.com/aws/aws-sdk-go-v2/service/sso v1.35.1 h1:B6WFn91tobD6gG4724ONHaqrpKsoETGnv98LHe/yIGM=
+github.com/aws/aws-sdk-go-v2/service/sso v1.35.1/go.mod h1:tWuiVBUtPBr8/rgRiYS8Uf85sHcAN+G7XS3D3CEoUh8=
+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.40.1 h1:6yeYCWFvgbI2TI3K6jr9LtBNhXgJ7g4xqD+DEiaDDmM=
+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.40.1/go.mod h1:naFe83jSMuYkH+QjQPX8n1MLhBkeCFM5Lsnh5m5wz3c=
+github.com/aws/aws-sdk-go-v2/service/sts v1.47.1 h1:Sv2xPnRHlThSUtVujYuUBPI/Il8si6UPHXL8DMiB/F0=
+github.com/aws/aws-sdk-go-v2/service/sts v1.47.1/go.mod h1:mKo/CzaCz8qytGW70NG4vIIGAx1HXTlb5lHNkC5k3lk=
+github.com/aws/smithy-go v1.28.1 h1:R/nXH00c8qcfCzQVELtRw+eLQWtzv+VAIEFJ1/xxXlQ=
+github.com/aws/smithy-go v1.28.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
+github.com/caarlos0/testfs v0.4.4 h1:3PHvzHi5Lt+g332CiShwS8ogTgS3HjrmzZxCm6JCDr8=
+github.com/caarlos0/testfs v0.4.4/go.mod h1:bRN55zgG4XCUVVHZCeU+/Tz1Q6AxEJOEJTliBy+1DMk=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cenkalti/backoff/v5 v5.0.2 h1:rIfFVxEf1QsI7E1ZHfp/B4DF/6QBAUhmgkxc0H7Zss8=
@@ -74,22 +76,20 @@ github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpS
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
-github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
-github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
-github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI=
-github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
-github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
-github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
+github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c=
+github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0=
+github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM=
+github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
+github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c=
+github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
-github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw=
-github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
+github.com/ebitengine/purego v0.10.2 h1:W809HbnvzAxgdm+aOvlSekrM16wGCdT/e76+9tS7gzE=
+github.com/ebitengine/purego v0.10.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/facebookgo/ensure v0.0.0-20200202191622-63f1cf65ac4c h1:8ISkoahWXwZR41ois5lSJBSVw4D0OV19Ht/JSTzvSv0=
github.com/facebookgo/ensure v0.0.0-20200202191622-63f1cf65ac4c/go.mod h1:Yg+htXGokKKdzcwhuNDwVvN+uBxDGXJ7G/VN1d8fa64=
github.com/facebookgo/flagenv v0.0.0-20160425205200-fcd59fca7456 h1:CkmB2l68uhvRlwOTPrwnuitSxi/S3Cg4L5QYOcL9MBc=
@@ -98,10 +98,14 @@ github.com/facebookgo/stack v0.0.0-20160209184415-751773369052 h1:JWuenKqqX8nojt
github.com/facebookgo/stack v0.0.0-20160209184415-751773369052/go.mod h1:UbMTZqLaRiH3MsBH8va0n7s1pQYcu3uTb8G4tygF4Zg=
github.com/facebookgo/subset v0.0.0-20200203212716-c811ad88dec4 h1:7HZCaLC5+BZpmbhCOZJ293Lz68O7PYrF2EzeiFMwCLk=
github.com/facebookgo/subset v0.0.0-20200203212716-c811ad88dec4/go.mod h1:5tD+neXqOorC30/tWg0LCSkrqj/AR6gu8yY8/fpw1q0=
-github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
-github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
-github.com/gaissmai/bart v0.23.0 h1:ct+78nySK5MaO+citQAUeef7QZ0ApXM3b+AYuCZYGIk=
-github.com/gaissmai/bart v0.23.0/go.mod h1:RpLtt3lWq1BoRz3AAyDAJ7jhLWBkYhVCfi+ximB2t68=
+github.com/fahedouch/go-logrotate v0.3.0 h1:XP+dHIDgWZ1ckz43mG6gl5ASer3PZDVr755SVMyzaUQ=
+github.com/fahedouch/go-logrotate v0.3.0/go.mod h1:X49m0bvPLkk71MHNCQ1yEfVEw8W/u+qvHa/hOnhCYf4=
+github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc=
+github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE=
+github.com/fvbommel/sortorder v1.1.0 h1:fUmoe+HLsBTctBDoaBwpQo5N+nrCp8g/BjKb/6ZQmYw=
+github.com/fvbommel/sortorder v1.1.0/go.mod h1:uk88iVf1ovNn1iLfgUVU2F9o5eO30ui720w+kxuqRs0=
+github.com/gaissmai/bart v0.29.0 h1:wO6HGE8g9YE0Wm0bCpYxwRzfQ4+fbJKOhL64e5ACGCI=
+github.com/gaissmai/bart v0.29.0/go.mod h1:GREWQfTLRWz/c5FTOsIw+KkscuFkIV5t8Rp7Nd1Td5c=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
@@ -110,202 +114,190 @@ github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
-github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
-github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
-github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0=
-github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
+github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
+github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
+github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
+github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
-github.com/google/cel-go v0.26.0 h1:DPGjXackMpJWH680oGY4lZhYjIameYmR+/6RBdDGmaI=
-github.com/google/cel-go v0.26.0/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM=
+github.com/google/cel-go v0.28.1 h1:YWIwi77J4xIsYUwAF/iIuS6haffzIHS8yWI8glSbLWM=
+github.com/google/cel-go v0.28.1/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/goreleaser/fileglob v1.4.0 h1:Y7zcUnzQjT1gbntacGAkIIfLv+OwojxTXBFxjSFoBBs=
+github.com/goreleaser/fileglob v1.4.0/go.mod h1:1pbHx7hhmJIxNZvm6fi6WVrnP0tndq6p3ayWdLn1Yf8=
github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 h1:QGLs/O40yoNK9vmy4rhUGBVyMf1lISBGtXRpsu/Qu/o=
github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0/go.mod h1:hM2alZsMUni80N33RBe6J0e423LB+odMj7d3EMP9l20=
-github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2 h1:sGm2vDRFUrQJO/Veii4h4zG2vvqG6uWNkBHSTqXOZk0=
-github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2/go.mod h1:wd1YpapPLivG6nQgbf7ZkG1hhSOXDhhn4MLTknx2aAc=
+github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.4 h1:9ZJYjPEJpcleIKcqysXOo14NLQYwu23fzmKRFpIjPjc=
+github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.4/go.mod h1:0xydIeg2omQ9DH6zYMP24Kk57793rnLUvP8fwdbFvz8=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 h1:X5VWvz21y3gzm9Nw/kaUeku/1+uBhcekkmy4IkffJww=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1/go.mod h1:Zanoh4+gvIgluNqcfMVTJueD4wSS5hT7zTt4Mrutd90=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/jsha/minica v1.1.0 h1:O2ZbzAN75w4RTB+5+HfjIEvY5nxRqDlwj3ZlLVG5JD8=
github.com/jsha/minica v1.1.0/go.mod h1:dxC3wNmD+gU1ewXo/R8jB2ihB6wNpyXrG8aUk5Iuf/k=
-github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
-github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
-github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
-github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
+github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
+github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
+github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
+github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
-github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 h1:PpXWgLPs+Fqr325bN2FD2ISlRRztXibcX6e8f5FR5Dc=
-github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
-github.com/lum8rjack/go-ja4h v0.0.0-20250606032308-3a989c6635be h1:dVIND0nXGXPQnFZYrMXT6CxHhBYhTPMm0GFqcmfaIC4=
-github.com/lum8rjack/go-ja4h v0.0.0-20250606032308-3a989c6635be/go.mod h1:q68TUR45WDa2r3yU4aO6WgxfCc0Vj1qtRaKaRE3yMLM=
+github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak=
+github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
+github.com/lum8rjack/go-ja4h v0.0.0-20250828030157-fa5266d50650 h1:hhx/Mo6+Hk0mAQS5MW311ON1VlSzp0D1cYhY27IcmnI=
+github.com/lum8rjack/go-ja4h v0.0.0-20250828030157-fa5266d50650/go.mod h1:bMqyXOakqQIdx82d4vcnk5TIZLptZ2gLqju9xmPrWYA=
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
+github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
+github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
-github.com/moby/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ=
-github.com/moby/go-archive v0.1.0/go.mod h1:G9B+YoujNohJmrIYFBpSd54GTUB4lt9S+xVQvsJyFuo=
-github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
-github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
+github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8=
+github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU=
+github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc=
+github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
+github.com/moby/moby/client v0.5.0 h1:5XhyPk2fuOWf6RlSFa3MkIIgDZkF25xToXW8Q/BH7cc=
+github.com/moby/moby/client v0.5.0/go.mod h1:rcVpF8ncl9vo5gaIBdol6CnbEtSj1uxMvEV/UrykF/s=
+github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U=
+github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs=
-github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
-github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
+github.com/moby/sys/sequential v0.7.0 h1:ASQNGNROJSuOO6LL6bPHbKvuZu6NU8P4ldPWk31zj/8=
+github.com/moby/sys/sequential v0.7.0/go.mod h1:NfSTAp6V3fw4tmkD62PEcOKeZKquXT8VKCkf7aVR79o=
github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
-github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
-github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
+github.com/morikuni/aec v1.1.0 h1:vBBl0pUnvi/Je71dsRrhMBtreIqNMYErSAbEeb8jrXQ=
+github.com/morikuni/aec v1.1.0/go.mod h1:xDRgiq/iw5l+zkao76YTKzKttOp2cwPEne25HDkJnBw=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
-github.com/nicksnyder/go-i18n/v2 v2.6.0 h1:C/m2NNWNiTB6SK4Ao8df5EWm3JETSTIGNXBpMJTxzxQ=
-github.com/nicksnyder/go-i18n/v2 v2.6.0/go.mod h1:88sRqr0C6OPyJn0/KRNaEz1uWorjxIKP7rUUcvycecE=
+github.com/neilotoole/slogt/v2 v2.0.0 h1:Kz8VWdkjXZnkVvN1hsGYI1ervWhlcDcIcVizUJbLbH8=
+github.com/neilotoole/slogt/v2 v2.0.0/go.mod h1:M/RN37tAuM3cpb/wD6lCpOSvphLMjuAzA/IV2hqJh3E=
+github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ=
+github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA=
+github.com/nikandfor/spintax v0.0.0-20181023094358-fc346b245bb3 h1:foZ9X1bz2KmW7b8Yx5V0LAQKhTazdllv5rnGUe6iGTY=
+github.com/nikandfor/spintax v0.0.0-20181023094358-fc346b245bb3/go.mod h1:wwDYKfVF3WHdY0rugsAZoIpyQjDA3bn9wEzo/QXPx1Y=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
-github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
-github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
-github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
+github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU=
+github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
-github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE=
-github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
-github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0=
-github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw=
-github.com/redis/go-redis/v9 v9.11.0 h1:E3S08Gl/nJNn5vkxd2i78wZxWAPNZgUNTp8WIJUAiIs=
-github.com/redis/go-redis/v9 v9.11.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw=
-github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
-github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
+github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY=
+github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc=
+github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
+github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
+github.com/redis/go-redis/v9 v9.22.0 h1:laDvpYXTJtZLloinw1fA5Kqd6HAEH2XKxOkG/PDq2F0=
+github.com/redis/go-redis/v9 v9.22.0/go.mod h1:y2g0Wj8rQvuK0ELM+oxSudcLtC09JScs98I/X9gRWY4=
+github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
+github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/sebest/xff v0.0.0-20210106013422-671bd2870b3a h1:iLcLb5Fwwz7g/DLK89F+uQBDeAhHhwdzB5fSlVdhGcM=
github.com/sebest/xff v0.0.0-20210106013422-671bd2870b3a/go.mod h1:wozgYq9WEBQBaIJe4YZ0qTSFAMxmcwBhQH0fO0R34Z0=
-github.com/shirou/gopsutil/v4 v4.25.6 h1:kLysI2JsKorfaFPcYmcJqbzROzsBWEOAtw6A7dIfqXs=
-github.com/shirou/gopsutil/v4 v4.25.6/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c=
-github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
-github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
-github.com/stoewer/go-strcase v1.3.1 h1:iS0MdW+kVTxgMoE1LAZyMiYJFKlOzLooE4MxjirtkAs=
-github.com/stoewer/go-strcase v1.3.1/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo=
-github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
-github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
-github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
-github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
-github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
-github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
-github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
-github.com/testcontainers/testcontainers-go v0.38.0 h1:d7uEapLcv2P8AvH8ahLqDMMxda2W9gQN1nRbHS28HBw=
-github.com/testcontainers/testcontainers-go v0.38.0/go.mod h1:C52c9MoHpWO+C4aqmgSU+hxlR5jlEayWtgYrb8Pzz1w=
-github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=
-github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4=
-github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso=
-github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=
-github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
-github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+github.com/shirou/gopsutil/v4 v4.26.8 h1:YQMTF/1J50B5+Y0vlo1eDRf5DoR7Gk69hY+8wjYkQeo=
+github.com/shirou/gopsutil/v4 v4.26.8/go.mod h1:5O9FjBiXoTDFatIWjZZosqj4pV0DRtLx598xGbBehzM=
+github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
+github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/testcontainers/testcontainers-go v0.44.0 h1:/Fwh6HY1mIikhnm9e7HwoxGycx0lzRAE0f5VQpjFxzI=
+github.com/testcontainers/testcontainers-go v0.44.0/go.mod h1:IcnwQrYTO86xHXu5bvMaBH7ATlbS3Qn1M1QWW3c66rE=
+github.com/tetratelabs/wazero v1.12.1-0.20260714071631-236c2458ed22 h1:TnM7no4lj5GodEnEBnB3iR1MHf+BpGH88LPhs2Hhs80=
+github.com/tetratelabs/wazero v1.12.1-0.20260714071631-236c2458ed22/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0=
+github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU=
+github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI=
+github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4=
+github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
-go.etcd.io/bbolt v1.4.2 h1:IrUHp260R8c+zYx/Tm8QZr04CX+qWS5PGfPdevhdm1I=
-go.etcd.io/bbolt v1.4.2/go.mod h1:Is8rSHO/b4f3XigBC0lL0+4FwAQv3HXEEIgFMuKHceM=
-go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
-go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
-go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 h1:Hf9xI/XLML9ElpiHVDNwvqI0hIFlzV8dgIr35kV1kRU=
-go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0/go.mod h1:NfchwuyNoMcZ5MLHwPrODwUF1HWCXWrL31s8gSAdIKY=
-go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
-go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
+github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
+github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
+go.etcd.io/bbolt v1.5.0 h1:S7GAl7Fxv12yohbwFfIbQCGDWbQbtDGPET4P/bD4lxU=
+go.etcd.io/bbolt v1.5.0/go.mod h1:mkltfYE5aUHQxUct9N9V+Kp7aSjFqjgrhcXIS70Lrdk=
+go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
+go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI=
+go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
+go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 h1:Ahq7pZmv87yiyn3jeFz/LekZmPLLdKejuO3NcK9MssM=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0/go.mod h1:MJTqhM0im3mRLw1i8uGHnCvUEeS7VwRyxlLC78PA18M=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.37.0 h1:bDMKF3RUSxshZ5OjOTi8rsHGaPKsAt76FaqgvIUySLc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.37.0/go.mod h1:dDT67G/IkA46Mr2l9Uj7HsQVwsjASyV9SjGofsiUZDA=
-go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
-go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
-go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
-go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
-go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc=
-go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
-go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
-go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
+go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
+go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
+go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
+go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
+go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
+go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
+go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
+go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
go.opentelemetry.io/proto/otlp v1.7.0 h1:jX1VolD6nHuFzOYso2E73H85i92Mv8JQYk0K9vz09os=
go.opentelemetry.io/proto/otlp v1.7.0/go.mod h1:fSKjH6YJ7HDlwzltzyMj036AJ3ejJLCgCSHGj4efDDo=
-go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
-go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
+go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
+go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
+go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
+go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
+go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
+go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
-golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
-golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
-golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
-golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
-golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
-golang.org/x/exp v0.0.0-20250718183923-645b1fa84792 h1:R9PFI6EUdfVKgwKjZef7QIwGcBKu86OEFpJ9nUEP2l4=
-golang.org/x/exp v0.0.0-20250718183923-645b1fa84792/go.mod h1:A+z0yzpGtvnG90cToK5n2tu8UJVP2XUATh+r+sfOOOc=
-golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
-golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
-golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
-golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
-golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
-golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
-golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
-golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
-golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
+golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
+golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 h1:MDfG8Cvcqlt9XXrmEiD4epKn7VJHZO84hejP9Jmp0MM=
+golang.org/x/exp v0.0.0-20251209150349-8475f28825e9/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=
+golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
+golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
+golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
+golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
-golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
-golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
-golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
-golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
-golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
-golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
-golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
-golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
-golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-google.golang.org/genproto/googleapis/api v0.0.0-20250721164621-a45f3dfb1074 h1:mVXdvnmR3S3BQOqHECm9NGMjYiRtEvDYcqAqedTXY6s=
-google.golang.org/genproto/googleapis/api v0.0.0-20250721164621-a45f3dfb1074/go.mod h1:vYFwMYFbmA8vl6Z/krj/h7+U/AqpHknwJX4Uqgfyc7I=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20250721164621-a45f3dfb1074 h1:qJW29YvkiJmXOYMu5Tf8lyrTp3dOS+K4z6IixtLaCf8=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20250721164621-a45f3dfb1074/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
-google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4=
-google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM=
-google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
-google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
+golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
+golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
+golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
+golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
+golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
+gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
+gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
+google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8=
+google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
+google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU=
+google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8=
+google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI=
+google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
-gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
+gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
-k8s.io/apimachinery v0.33.3 h1:4ZSrmNa0c/ZpZJhAgRdcsFcZOw1PQU1bALVQ0B3I5LA=
-k8s.io/apimachinery v0.33.3/go.mod h1:BHW0YOu7n22fFv/JkYOEfkUYNRN0fj0BlvMFWA7b+SM=
-sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE=
-sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
+k8s.io/apimachinery v0.37.0 h1:Np2AbDtf8x6RDHiD8T9LbKJ9gaegeVNa8yNm5FuGKm0=
+k8s.io/apimachinery v0.37.0/go.mod h1:RN3nhprFSCxOi5Selxd7oMTXOe/c+ZbcE7Im+TS2zkE=
+sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg=
+sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
diff --git a/test/gubal/README.md b/test/gubal/README.md
new file mode 100644
index 000000000..06260858c
--- /dev/null
+++ b/test/gubal/README.md
@@ -0,0 +1,18 @@
+# Anubis sweep policies
+
+Each `*.yaml` here is a full `botPolicies` ruleset. Every `.yaml` file results
+in a new chromesweep test by [Gubal](https://github.com/TecharoHQ/gubal).
+
+To add a pass: drop a new `*.yaml` file in this directory and rebuild. No code
+change is needed.
+
+Constraints:
+
+- The filename (without `.yaml`) becomes a Kubernetes ConfigMap name
+ (`anubis-policy-`), so use DNS-safe names: lowercase letters, digits, `-`.
+- Rulesets must **CHALLENGE** browser user-agents, not `ALLOW` them: the smoke Job
+ asserts the Anubis challenge page (which contains "Anubis") is served. An
+ `ALLOW`ed request is proxied to the backend, whose body lacks "Anubis", and the
+ pre-check would fail for reasons unrelated to the browser.
+- A ruleset that Anubis rejects makes its pod crashloop; that policy's rollout
+ times out and every version under it is reported as `error`.
diff --git a/test/gubal/argon2id.yaml b/test/gubal/argon2id.yaml
new file mode 100644
index 000000000..8e4b6015a
--- /dev/null
+++ b/test/gubal/argon2id.yaml
@@ -0,0 +1,12 @@
+# Baseline sweep policy: challenge every visitor with Anubis's proof-of-work.
+# A real, non-headless browser (the whole point of this sweep) solves the
+# challenge and reaches the backend; a bare HTTP client does not. The challenge
+# interstitial itself contains "Anubis", so the smoke-job's `grep -qi anubis`
+# pre-check passes under this policy.
+bots:
+ - name: everything-else
+ user_agent_regex: .
+ action: CHALLENGE
+ challenge:
+ difficulty: 10
+ algorithm: argon2id
diff --git a/test/gubal/default-config.yaml b/test/gubal/default-config.yaml
new file mode 100644
index 000000000..33c91df61
--- /dev/null
+++ b/test/gubal/default-config.yaml
@@ -0,0 +1,269 @@
+## Anubis has the ability to let you import snippets of configuration into the main
+## configuration file. This allows you to break up your config into smaller parts
+## that get logically assembled into one big file.
+##
+## Of note, a bot rule can either have inline bot configuration or import a
+## bot config snippet. You cannot do both in a single bot rule.
+##
+## Import paths can either be prefixed with (data) to import from the common/shared
+## rules in the data folder in the Anubis source tree or will point to absolute/relative
+## paths in your filesystem. If you don't have access to the Anubis source tree, check
+## /usr/share/docs/anubis/data or in the tarball you extracted Anubis from.
+
+bots:
+ # You can import the entire default config with this macro:
+ # - import: (data)/meta/default-config.yaml
+
+ # Pathological bots to deny
+ - # This correlates to data/bots/_deny-pathological.yaml in the source tree
+ # https://github.com/TecharoHQ/anubis/blob/main/data/bots/_deny-pathological.yaml
+ import: (data)/bots/_deny-pathological.yaml
+ - import: (data)/bots/aggressive-brazilian-scrapers.yaml
+
+ # Aggressively block AI/LLM related bots/agents by default
+ - import: (data)/meta/ai-block-aggressive.yaml
+
+ # Consider replacing the aggressive AI policy with more selective policies:
+ # - import: (data)/meta/ai-block-moderate.yaml
+ # - import: (data)/meta/ai-block-permissive.yaml
+
+ # Search engine crawlers to allow, defaults to:
+ # - Google (so they don't try to bypass Anubis)
+ # - Apple
+ # - Bing
+ # - DuckDuckGo
+ # - Qwant
+ # - The Internet Archive
+ # - Kagi
+ # - Marginalia
+ # - Mojeek
+ - import: (data)/crawlers/_allow-good.yaml
+ # Challenge Firefox AI previews
+ - import: (data)/clients/x-firefox-ai.yaml
+
+ # x.ai has a scraper that is killing gitlab instances
+ - import: (data)/crawlers/xai.yaml
+
+ # Allow common "keeping the internet working" routes (well-known, favicon, robots.txt)
+ - import: (data)/common/keep-internet-working.yaml
+
+ # # Punish any bot with "bot" in the user-agent string
+ # # This is known to have a high false-positive rate, use at your own risk
+ # - name: generic-bot-catchall
+ # user_agent_regex: (?i:bot|crawler)
+ # action: CHALLENGE
+ # challenge:
+ # difficulty: 16 # impossible
+ # algorithm: slow # intentionally waste CPU cycles and time
+
+ # Requires a subscription to Thoth to use, see
+ # https://anubis.techaro.lol/docs/admin/thoth#geoip-based-filtering
+ - name: countries-with-aggressive-scrapers
+ action: WEIGH
+ geoip:
+ countries:
+ - BR
+ - CN
+ weight:
+ adjust: 10
+
+ # Requires a subscription to Thoth to use, see
+ # https://anubis.techaro.lol/docs/admin/thoth#asn-based-filtering
+ - name: aggressive-asns-without-functional-abuse-contact
+ action: WEIGH
+ asns:
+ match:
+ - 13335 # Cloudflare
+ - 136907 # Huawei Cloud
+ - 45102 # Alibaba Cloud
+ weight:
+ adjust: 10
+
+ # ## System load based checks.
+ # # If the system is under high load, add weight.
+ # - name: high-load-average
+ # action: WEIGH
+ # expression: load_1m >= 10.0 # make sure to end the load comparison in a .0
+ # weight:
+ # adjust: 20
+
+ ## If your backend service is running on the same operating system as Anubis,
+ ## you can uncomment this rule to make the challenge easier when the system is
+ ## under low load.
+ ##
+ ## If it is not, remove weight.
+ # - name: low-load-average
+ # action: WEIGH
+ # expression: load_15m <= 4.0 # make sure to end the load comparison in a .0
+ # weight:
+ # adjust: -10
+
+ # Generic catchall rule
+ - name: generic-browser
+ user_agent_regex: >-
+ Mozilla|Opera
+ action: WEIGH
+ weight:
+ adjust: 10
+
+dnsbl: false
+
+# #
+# impressum:
+# # Displayed at the bottom of every page rendered by Anubis.
+# footer: >-
+# This website is hosted by Zombocom. If you have any complaints or notes
+# about the service, please contact
+# contact@domainhere.example
+# and we will assist you as soon as possible.
+
+# # The imprint page that will be linked to at the footer of every Anubis page.
+# page:
+# # The HTML of the page
+# title: Imprint and Privacy Policy
+# # The HTML contents of the page. The exact contents of this page can
+# # and will vary by locale. Please consult with a lawyer if you are not
+# # sure what to put here
+# body: >-
+# Last updated: June 2025
+
+# Information that is gathered from visitors
+
+# 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.
+
+# Cookies may be used to remember visitor preferences when interacting with the website.
+
+# Where registration is required, the visitor's email and a username will be stored on the server.
+
+#
+
+# Open Graph passthrough configuration, see here for more information:
+# https://anubis.techaro.lol/docs/admin/configuration/open-graph/
+openGraph:
+ # Enables Open Graph passthrough
+ enabled: false
+ # Enables the use of the HTTP host in the cache key, this enables
+ # caching metadata for multiple http hosts at once.
+ considerHost: false
+ # How long cached OpenGraph metadata should last in memory
+ ttl: 24h
+ # # If set, return these opengraph values instead of looking them up with
+ # # the target service.
+ # #
+ # # Correlates to properties in https://ogp.me/
+ # override:
+ # # og:title is required, it is the title of the website
+ # "og:title": "Techaro Anubis"
+ # "og:description": >-
+ # Anubis is a Web AI Firewall Utility that helps you fight the bots
+ # away so that you can maintain uptime at work!
+ # "description": >-
+ # Anubis is a Web AI Firewall Utility that helps you fight the bots
+ # away so that you can maintain uptime at work!
+
+# By default, send HTTP 200 back to clients that either get issued a challenge
+# or a denial. This seems weird, but this is load-bearing due to the fact that
+# the most aggressive scraper bots seem to really, really, want an HTTP 200 and
+# will stop sending requests once they get it.
+status_codes:
+ CHALLENGE: 200
+ DENY: 200
+
+# # Configuration for the metrics server. See the docs for more information:
+# #
+# # https://anubis.techaro.lol/docs/admin/policies#metrics-server
+# #
+# # This is commented out by default so that command line flags take precedence.
+# metrics:
+# bind: ":9090"
+# network: "tcp"
+#
+# # To protect your metrics server with basic auth, set credentials below:
+# #
+# # https://anubis.techaro.lol/docs/admin/policies#http-basic-authentication
+# basicAuth:
+# username: ""
+# password: ""
+#
+# # To serve metrics over TLS, set the path to the right TLS certificate and key
+# # here. When the files change on disk, they will automatically be reloaded.
+# #
+# # https://anubis.techaro.lol/docs/admin/policies#tls
+# tls:
+# certificate: /path/to/tls.crt
+# key: /path/to/tls.key
+#
+# # If you want to secure your metrics endpoint using mutual TLS (mTLS), set
+# # the path to a certificate authority public certificate here.
+# #
+# # https://anubis.techaro.lol/docs/admin/policies#mtls
+# ca: /path/to/ca.crt
+
+# Anubis can store temporary data in one of a few backends. See the storage
+# backends section of the docs for more information:
+#
+# https://anubis.techaro.lol/docs/admin/policies#storage-backends
+store:
+ backend: memory
+ parameters: {}
+
+# The weight thresholds for when to trigger individual challenges. Any
+# CHALLENGE will take precedence over this.
+#
+# A threshold has four configuration options:
+#
+# - name: the name that is reported down the stack and used for metrics
+# - expression: A CEL expression with the request weight in the variable
+# weight
+# - action: the Anubis action to apply, similar to in a bot policy
+# - challenge: which challenge to send to the user, similar to in a bot policy
+#
+# See https://anubis.techaro.lol/docs/admin/configuration/thresholds for more
+# information.
+thresholds:
+ # By default Anubis ships with the following thresholds:
+ - name: minimal-suspicion # This client is likely fine, its soul is lighter than a feather
+ expression: weight <= 0 # a feather weighs zero units
+ action: ALLOW # Allow the traffic through
+ # For clients that had some weight reduced through custom rules, give them a
+ # lightweight challenge.
+ - name: mild-suspicion
+ expression:
+ all:
+ - weight > 0
+ - weight < 10
+ action: CHALLENGE
+ challenge:
+ # https://anubis.techaro.lol/docs/admin/configuration/challenges/metarefresh
+ algorithm: metarefresh
+ difficulty: 1
+ # For clients that are browser-like but have either gained points from custom rules or
+ # report as a standard browser.
+ - name: moderate-suspicion
+ expression:
+ all:
+ - weight >= 10
+ - weight < 20
+ action: CHALLENGE
+ challenge:
+ # https://anubis.techaro.lol/docs/admin/configuration/challenges/proof-of-work
+ algorithm: fast
+ difficulty: 2 # two leading zeros, very fast for most clients
+ - name: mild-proof-of-work
+ expression:
+ all:
+ - weight >= 20
+ - weight < 30
+ action: CHALLENGE
+ challenge:
+ # https://anubis.techaro.lol/docs/admin/configuration/challenges/proof-of-work
+ algorithm: fast
+ difficulty: 4
+ # For clients that are browser like and have gained many points from custom rules
+ - name: extreme-suspicion
+ expression: weight >= 30
+ action: CHALLENGE
+ challenge:
+ # https://anubis.techaro.lol/docs/admin/configuration/challenges/proof-of-work
+ algorithm: fast
+ difficulty: 6
diff --git a/test/gubal/fast.yaml b/test/gubal/fast.yaml
new file mode 100644
index 000000000..45935bf9d
--- /dev/null
+++ b/test/gubal/fast.yaml
@@ -0,0 +1,12 @@
+# Baseline sweep policy: challenge every visitor with Anubis's proof-of-work.
+# A real, non-headless browser (the whole point of this sweep) solves the
+# challenge and reaches the backend; a bare HTTP client does not. The challenge
+# interstitial itself contains "Anubis", so the smoke-job's `grep -qi anubis`
+# pre-check passes under this policy.
+bots:
+ - name: everything-else
+ user_agent_regex: .
+ action: CHALLENGE
+ challenge:
+ difficulty: 4
+ algorithm: fast
diff --git a/test/gubal/hashx.yaml b/test/gubal/hashx.yaml
new file mode 100644
index 000000000..9dd5240ce
--- /dev/null
+++ b/test/gubal/hashx.yaml
@@ -0,0 +1,12 @@
+# Baseline sweep policy: challenge every visitor with Anubis's proof-of-work.
+# A real, non-headless browser (the whole point of this sweep) solves the
+# challenge and reaches the backend; a bare HTTP client does not. The challenge
+# interstitial itself contains "Anubis", so the smoke-job's `grep -qi anubis`
+# pre-check passes under this policy.
+bots:
+ - name: everything-else
+ user_agent_regex: .
+ action: CHALLENGE
+ challenge:
+ difficulty: 10
+ algorithm: hashx
diff --git a/test/gubal/metarefresh.yaml b/test/gubal/metarefresh.yaml
new file mode 100644
index 000000000..aec4b62dc
--- /dev/null
+++ b/test/gubal/metarefresh.yaml
@@ -0,0 +1,12 @@
+# Baseline sweep policy: challenge every visitor with Anubis's proof-of-work.
+# A real, non-headless browser (the whole point of this sweep) solves the
+# challenge and reaches the backend; a bare HTTP client does not. The challenge
+# interstitial itself contains "Anubis", so the smoke-job's `grep -qi anubis`
+# pre-check passes under this policy.
+bots:
+ - name: everything-else
+ user_agent_regex: .
+ action: CHALLENGE
+ challenge:
+ difficulty: 1
+ algorithm: metarefresh
diff --git a/test/gubal/preact.yaml b/test/gubal/preact.yaml
new file mode 100644
index 000000000..c1ff92180
--- /dev/null
+++ b/test/gubal/preact.yaml
@@ -0,0 +1,12 @@
+# Baseline sweep policy: challenge every visitor with Anubis's proof-of-work.
+# A real, non-headless browser (the whole point of this sweep) solves the
+# challenge and reaches the backend; a bare HTTP client does not. The challenge
+# interstitial itself contains "Anubis", so the smoke-job's `grep -qi anubis`
+# pre-check passes under this policy.
+bots:
+ - name: everything-else
+ user_agent_regex: .
+ action: CHALLENGE
+ challenge:
+ difficulty: 1
+ algorithm: preact
diff --git a/test/gubal/sha256.yaml b/test/gubal/sha256.yaml
new file mode 100644
index 000000000..9272b6b89
--- /dev/null
+++ b/test/gubal/sha256.yaml
@@ -0,0 +1,12 @@
+# Baseline sweep policy: challenge every visitor with Anubis's proof-of-work.
+# A real, non-headless browser (the whole point of this sweep) solves the
+# challenge and reaches the backend; a bare HTTP client does not. The challenge
+# interstitial itself contains "Anubis", so the smoke-job's `grep -qi anubis`
+# pre-check passes under this policy.
+bots:
+ - name: everything-else
+ user_agent_regex: .
+ action: CHALLENGE
+ challenge:
+ difficulty: 16
+ algorithm: sha256
diff --git a/test/i18n/test.mjs b/test/i18n/test.mjs
index 64b32703c..ba48ea282 100644
--- a/test/i18n/test.mjs
+++ b/test/i18n/test.mjs
@@ -1,12 +1,14 @@
async function fetchLanguages() {
- return fetch("http://localhost:8923/.within.website/x/cmd/anubis/static/locales/manifest.json")
- .then(resp => {
+ return fetch(
+ "http://localhost:8923/.within.website/x/cmd/anubis/static/locales/manifest.json",
+ )
+ .then((resp) => {
if (resp.status !== 200) {
throw new Error(`wanted status 200, got status: ${resp.status}`);
}
return resp;
})
- .then(resp => resp.json());
+ .then((resp) => resp.json());
}
async function getChallengePage(lang) {
@@ -14,15 +16,15 @@ async function getChallengePage(lang) {
headers: {
"Accept-Language": lang,
"User-Agent": "CHALLENGE",
- }
+ },
})
- .then(resp => {
+ .then((resp) => {
if (resp.status !== 200) {
throw new Error(`wanted status 200, got status: ${resp.status}`);
}
return resp;
})
- .then(resp => resp.text());
+ .then((resp) => resp.text());
}
(async () => {
@@ -42,7 +44,7 @@ async function getChallengePage(lang) {
console.log(`getting for ${lang}`);
const page = await getChallengePage(lang);
- resultSheet[lang] = page.includes(``)
+ resultSheet[lang] = page.includes(``);
}
for (const [lang, result] of Object.entries(resultSheet)) {
@@ -59,4 +61,4 @@ async function getChallengePage(lang) {
}
process.exit(0);
-})();
\ No newline at end of file
+})();
diff --git a/test/k8s/cert-manager/selfsigned-issuer.yaml b/test/k8s/cert-manager/selfsigned-issuer.yaml
index 07d2b7bc4..8120e82a0 100644
--- a/test/k8s/cert-manager/selfsigned-issuer.yaml
+++ b/test/k8s/cert-manager/selfsigned-issuer.yaml
@@ -3,4 +3,4 @@ kind: ClusterIssuer
metadata:
name: selfsigned
spec:
- selfSigned: {}
\ No newline at end of file
+ selfSigned: {}
diff --git a/test/k8s/deps/cert-manager.yaml b/test/k8s/deps/cert-manager.yaml
index f3e17faee..1d3fda131 100644
--- a/test/k8s/deps/cert-manager.yaml
+++ b/test/k8s/deps/cert-manager.yaml
@@ -10,4 +10,4 @@ spec:
createNamespace: true
set:
installCRDs: "true"
- "prometheus.enabled": "false"
\ No newline at end of file
+ "prometheus.enabled": "false"
diff --git a/test/lib/lib.sh b/test/lib/lib.sh
index 132848acc..0317598f3 100644
--- a/test/lib/lib.sh
+++ b/test/lib/lib.sh
@@ -1,53 +1,56 @@
REPO_ROOT=$(git rev-parse --show-toplevel)
(cd $REPO_ROOT && go install ./utils/cmd/...)
+mkdir -p pki
+echo '*' >>./pki/.gitignore
+
function cleanup() {
- set +e
+ set +e
- pkill -P $$
+ pkill -P $$
- if [ -f "docker-compose.yaml" ]; then
- docker compose down -t 1 || :
- docker compose rm -f || :
- fi
+ if [ -f "docker-compose.yaml" ]; then
+ docker compose down -t 1 || :
+ docker compose rm -f || :
+ fi
}
trap cleanup EXIT SIGINT
function build_anubis_ko() {
- (
- cd $REPO_ROOT && npm ci && npm run assets
- )
- (
- cd $REPO_ROOT &&
- VERSION=devel ko build \
- --platform=all \
- --base-import-paths \
- --tags="latest" \
- --image-user=1000 \
- --image-annotation="" \
- --image-label="" \
- ./cmd/anubis \
- --local
- )
+ (
+ cd $REPO_ROOT && npm ci && npm run assets
+ )
+ (
+ cd $REPO_ROOT &&
+ VERSION=devel ko build \
+ --platform=all \
+ --base-import-paths \
+ --tags="latest" \
+ --image-user=1000 \
+ --image-annotation="" \
+ --image-label="" \
+ ./cmd/anubis \
+ --local
+ )
}
function mint_cert() {
- if [ "$#" -ne 1 ]; then
- echo "Usage: mint_cert "
- fi
-
- domainName="$1"
-
- # If the transient local TLS certificate doesn't exist, mint a new one
- if [ ! -f "${REPO_ROOT}/test/pki/${domainName}/cert.pem" ]; then
- # Subshell to contain the directory change
- (
- cd ${REPO_ROOT}/test/pki &&
- mkdir -p "${domainName}" &&
- go tool minica -domains "${domainName}" &&
- cd "${domainName}" &&
- chmod 666 *
- )
- fi
+ if [ "$#" -ne 1 ]; then
+ echo "Usage: mint_cert "
+ fi
+
+ domainName="$1"
+
+ # If the transient local TLS certificate doesn't exist, mint a new one
+ if [ ! -f "./pki/${domainName}/cert.pem" ]; then
+ # Subshell to contain the directory change
+ (
+ cd ./pki &&
+ mkdir -p "${domainName}" &&
+ go tool minica -domains "${domainName}" &&
+ cd "${domainName}" &&
+ chmod 666 *
+ )
+ fi
}
diff --git a/test/lightpanda/README.md b/test/lightpanda/README.md
new file mode 100644
index 000000000..830b1d417
--- /dev/null
+++ b/test/lightpanda/README.md
@@ -0,0 +1,11 @@
+# Lightpanda
+
+This test makes sure that [Lightpanda](https://github.com/lightpanda-io/browser) clients are blocked by Anubis. It does so by downloading the latest nightly release of Lightpanda and testing it against an actual Anubis install.
+
+## Tale of woe
+
+On August 6th, 2026, administrators of the Haskell gitlab server [noticed a massive flood of requests](https://mailman.haskell.org/archives/list/ghc-devs@haskell.org/thread/AKWY3G76BMMOS6CNV5PZ64PHNWGDK3MM/) with the User-Agent `Lightpanda` in them. This confused me, as they use Anubis and I knew that Anubis had a rule to block Lightpanda [since some time in 2025 before configuration snippet parsing was added](https://github.com/TecharoHQ/anubis/commit/74e11505c6133ee1107811e81a0fd53e1d7876dd). At some point between when that rule was written and this test was made, Lightpanda changed their User-Agent string and this rule no longer worked.
+
+It was at least working on [March 31, 2025](https://github.com/lightpanda-io/browser/issues/500), when a user asked if Lightpanda could do User-Agent spoofing.
+
+This smoke test will let us know when Lightpanda changes their User-Agent string again.
diff --git a/test/lightpanda/anubis.yaml b/test/lightpanda/anubis.yaml
new file mode 100644
index 000000000..017cb27fc
--- /dev/null
+++ b/test/lightpanda/anubis.yaml
@@ -0,0 +1,6 @@
+bots:
+ - import: (data)/bots/headless-browsers.yaml
+
+status_codes:
+ CHALLENGE: 200
+ DENY: 403
diff --git a/test/lightpanda/test.mjs b/test/lightpanda/test.mjs
new file mode 100644
index 000000000..097484e53
--- /dev/null
+++ b/test/lightpanda/test.mjs
@@ -0,0 +1,71 @@
+import { execFile } from "node:child_process";
+import { promisify } from "node:util";
+
+const execFileAsync = promisify(execFile);
+
+const LIGHTPANDA = "./var/lightpanda";
+const TARGET = "http://localhost:8923/reqmeta";
+
+// Anubis renders "Access Denied: error code " on its DENY page. The hash
+// changes whenever the rule changes, so only match the stable prefix.
+const DENIED = "Access Denied: error code";
+
+async function lightpandaFetch(...extraArgs) {
+ const { stdout } = await execFileAsync(
+ LIGHTPANDA,
+ ["fetch", "--dump", "html", ...extraArgs, TARGET],
+ { maxBuffer: 16 * 1024 * 1024 },
+ );
+ return stdout;
+}
+
+// Sanity check: a request that isn't a headless browser must sail through. If
+// this fails then Anubis isn't up yet or it's denying everything, and the
+// Lightpanda assertions below would pass for the wrong reason.
+async function checkControl() {
+ const resp = await fetch(TARGET, {
+ headers: { "User-Agent": "AnubisCI" },
+ });
+
+ if (resp.status !== 200) {
+ throw new Error(`control request: wanted status 200, got ${resp.status}`);
+ }
+}
+
+const cases = [
+ {
+ name: "default user agent",
+ args: [],
+ },
+ {
+ // Lightpanda still sends Sec-Ch-Ua when --user-agent is overridden, so
+ // Anubis must catch it even though the User-Agent says otherwise.
+ name: "spoofed user agent",
+ args: ["--user-agent", "AnubisCI"],
+ },
+];
+
+(async () => {
+ await checkControl();
+
+ let failed = false;
+
+ for (const { name, args } of cases) {
+ const page = await lightpandaFetch(...args);
+
+ if (!page.includes(DENIED)) {
+ console.log(page);
+ console.log(`${name}: Lightpanda was not denied`);
+ failed = true;
+ continue;
+ }
+
+ console.log(`${name}: Lightpanda was denied`);
+ }
+
+ if (failed) {
+ throw new Error("lightpanda smoke test failed");
+ }
+
+ process.exit(0);
+})();
diff --git a/test/lightpanda/test.sh b/test/lightpanda/test.sh
new file mode 100755
index 000000000..ad11eb3e2
--- /dev/null
+++ b/test/lightpanda/test.sh
@@ -0,0 +1,32 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+function cleanup() {
+ pkill -P $$
+}
+
+trap cleanup EXIT SIGINT
+
+# Build static assets
+(cd ../.. && npm ci && npm run assets)
+
+mkdir -p ./var
+
+# Lightpanda only ships nightly builds, so grab one if we don't have it yet.
+if [ ! -x ./var/lightpanda ]; then
+ curl -fsSL -o ./var/lightpanda \
+ "https://github.com/lightpanda-io/browser/releases/download/nightly/lightpanda-$(uname -m)-linux"
+ chmod a+x ./var/lightpanda
+fi
+
+go tool anubis --help 2>/dev/null || :
+
+go run ../cmd/unixhttpd &
+
+go tool anubis \
+ --policy-fname ./anubis.yaml \
+ --use-remote-address \
+ --target=unix://$(pwd)/unixhttpd.sock &
+
+backoff-retry node ./test.mjs
diff --git a/test/lightpanda/var/.gitignore b/test/lightpanda/var/.gitignore
new file mode 100644
index 000000000..c96a04f00
--- /dev/null
+++ b/test/lightpanda/var/.gitignore
@@ -0,0 +1,2 @@
+*
+!.gitignore
\ No newline at end of file
diff --git a/test/log-file/anubis.yaml b/test/log-file/anubis.yaml
new file mode 100644
index 000000000..e8b6ca772
--- /dev/null
+++ b/test/log-file/anubis.yaml
@@ -0,0 +1,18 @@
+bots:
+ - name: challenge
+ user_agent_regex: CHALLENGE
+ action: CHALLENGE
+
+status_codes:
+ CHALLENGE: 200
+ DENY: 403
+
+logging:
+ sink: file
+ parameters:
+ file: "./var/anubis.log"
+ maxBackups: 3 # keep at least 3 old copies
+ maxBytes: 67108864 # each file can have up to 64 Mi of logs
+ maxAge: 7 # rotate files out every n days
+ compress: true
+ useLocalTime: false # timezone for rotated files is UTC
diff --git a/test/log-file/input.txt b/test/log-file/input.txt
new file mode 100644
index 000000000..639f5842b
--- /dev/null
+++ b/test/log-file/input.txt
@@ -0,0 +1,178 @@
+/wiki//bin
+/wiki//boot
+/wiki//dev
+/wiki//dev/de
+/wiki//dev/en
+/wiki//dev/en-ca
+/wiki//dev/es
+/wiki//dev/fr
+/wiki//dev/hr
+/wiki//dev/hu
+/wiki//dev/it
+/wiki//dev/ja
+/wiki//dev/ko
+/wiki//dev/pl
+/wiki//dev/pt-br
+/wiki//dev/ro
+/wiki//dev/ru
+/wiki//dev/sv
+/wiki//dev/uk
+/wiki//dev/zh-cn
+/wiki//etc
+/wiki//etc/conf.d
+/wiki//etc/env.d
+/wiki//etc/fstab
+/wiki//etc/fstab/de
+/wiki//etc/fstab/en
+/wiki//etc/fstab/es
+/wiki//etc/fstab/fr
+/wiki//etc/fstab/hu
+/wiki//etc/fstab/it
+/wiki//etc/fstab/ja
+/wiki//etc/fstab/ko
+/wiki//etc/fstab/ru
+/wiki//etc/fstab/sv
+/wiki//etc/fstab/uk
+/wiki//etc/fstab/zh-cn
+/wiki//etc/hosts
+/wiki//etc/local.d
+/wiki//etc/make.conf
+/wiki//etc/portage
+/wiki//etc/portage/bashrc
+/wiki//etc/portage/Bashrc
+/wiki//etc/portage/binrepos.conf
+/wiki//etc/portage/binrepos.conf/en
+/wiki//etc/portage/binrepos.conf/hu
+/wiki//etc/portage/binrepos.conf/ja
+/wiki//etc/portage/binrepos.conf/ru
+/wiki//etc/portage/categories
+/wiki//etc/portage/color.map
+/wiki//etc/portage/env
+/wiki//etc/portage/img/ico.png
+/wiki//etc/portage/license_groups
+/wiki//etc/portage/make.conf
+/wiki//etc/portage/make.conf/de
+/wiki//etc/portage/make.conf/de/etc/portage/make.conf
+/wiki//etc/portage/make.conf/en
+/wiki//etc/portage/make.conf/es
+/wiki//etc/portage/make.conf/fr
+/wiki//etc/portage/make.conf/hu
+/wiki//etc/portage/make.conf/it
+/wiki//etc/portage/make.conf/it/var/db/repos/gentoo/licenses
+/wiki//etc/portage/make.conf/ja
+/wiki//etc/portage/make.conf/pl
+/wiki//etc/portage/make.conf/ru
+/wiki//etc/portage/make.conf/uk
+/wiki//etc/portage/make.conf/zh-cn
+/wiki//etc/portage/make.profile
+/wiki//etc/portage/mirrors
+/wiki//etc/portage/modules
+/wiki//etc/portage/package.accept_keywords
+/wiki//etc/portage/package.env
+/wiki//etc/portage/package.license
+/wiki//etc/portage/package.license/en
+/wiki//etc/portage/package.license/es
+/wiki//etc/portage/package.license/hu
+/wiki//etc/portage/package.license/ja
+/wiki//etc/portage/package.mask
+/wiki//etc/portage/package.mask/en
+/wiki//etc/portage/package.mask/hu
+/wiki//etc/portage/package.mask/ja
+/wiki//etc/portage/package.properties
+/wiki//etc/portage/package.unmask
+/wiki//etc/portage/package.use
+/wiki//etc/portage/package.use/de
+/wiki//etc/portage/package.use/en
+/wiki//etc/portage/package.use/es
+/wiki//etc/portage/package.use/fr
+/wiki//etc/portage/package.use/hu
+/wiki//etc/portage/package.use/it
+/wiki//etc/portage/package.use/ja
+/wiki//etc/portage/package.use/ru
+/wiki//etc/portage/package.use/uk
+/wiki//etc/portage/package.use/zh-cn
+/wiki//etc/portage/patches
+/wiki//etc/portage/profile/make.defaults
+/wiki//etc/portage/profile/package.provided
+/wiki//etc/portage/profile/package.provided/etc/portage/profile/package.provided
+/wiki//etc/portage/profile/package.provided/etc/portage/profiles/package.provided
+/wiki//etc/portage/profile/package.use.mask
+/wiki//etc/portage/profiles/package.provided
+/wiki//etc/portage/profiles/package.use.mask
+/wiki//etc/portage/profiles/package.use.mask/etc/portage/profile/package.use.mask
+/wiki//etc/portage/profiles/package.use.mask/etc/portage/profiles/package.use.mask
+/wiki//etc/portage/profiles/use.mask
+/wiki//etc/portage/profile/use.mask
+/wiki//etc/portage/repos.conf
+/wiki//etc/portage/repos.conf/brother-overlay.conf
+/wiki//etc/portage/repos.conf/de
+/wiki//etc/portage/repos.conf/en
+/wiki//etc/portage/repos.conf/es
+/wiki//etc/portage/repos.conf/etc/portage/repos.conf/gentoo.conf
+/wiki//etc/portage/repos.conf/fr
+/wiki//etc/portage/repos.conf/fr/etc/portage/repos.conf/gentoo.conf
+/wiki//etc/portage/repos.conf/gentoo.conf
+/wiki//etc/portage/repos.conf/gentoo.conf/etc/portage/repos.conf/gentoo.conf
+/wiki//etc/portage/repos.conf/hr
+/wiki//etc/portage/repos.conf/hu
+/wiki//etc/portage/repos.conf/it
+/wiki//etc/portage/repos.conf/ja
+/wiki//etc/portage/repos.conf/ko
+/wiki//etc/portage/repos.conf/pl
+/wiki//etc/portage/repos.conf/pt-br
+/wiki//etc/portage/repos.conf/ru
+/wiki//etc/portage/repos.conf/uk
+/wiki//etc/portage/repos.conf/zh-cn
+/wiki//etc/portage/savedconfig
+/wiki//etc/portage/sets
+/wiki//etc/profile
+/wiki//etc/profile.env
+/wiki//etc/sandbox.conf
+/wiki//home
+/wiki//lib
+/wiki//lib64
+/wiki//media
+/wiki//mnt
+/wiki//opt
+/wiki//proc
+/wiki//proc/config.gz
+/wiki//run
+/wiki//sbin
+/wiki//srv
+/wiki//sys
+/wiki//tmp
+/wiki//usr
+/wiki//usr/bin
+/wiki//usr_move
+/wiki//usr/portage
+/wiki//usr/portage/distfiles
+/wiki//usr/portage/licenses
+/wiki//usr/portage/metadata
+/wiki//usr/portage/metadata/md5-cache
+/wiki//usr/portage/metadata/md5-cache/usr/portage/metadata/md5-cache
+/wiki//usr/portage/metadata/md5-cache/var/db/repos/gentoo//metadata/md5-cache
+/wiki//usr/portage/packages
+/wiki//usr/portage/profiles
+/wiki//usr/portage/profiles/license_groups
+/wiki//usr/portage/profiles/license_groups/usr/portage/profiles/license_groups
+/wiki//usr/portage/profiles/license_groups/var/db/repos/gentoo//profiles/license_groups
+/wiki//usr/share/doc/
+/wiki//var/cache/binpkgs
+/wiki//var/cache/distfiles
+/wiki//var/db/pkg
+/wiki//var/db/pkg%22
+/wiki//var/db/repos/gentoo
+/wiki//var/db/repos/gentoo/licenses
+/wiki//var/db/repos/gentoo/licenses/var/db/repos/gentoo//licenses
+/wiki//var/db/repos/gentoo/licenses/var/db/repos/gentoo/licenses
+/wiki//var/db/repos/gentoo/metadata
+/wiki//var/db/repos/gentoo/metadata/md5-cache
+/wiki//var/db/repos/gentoo/metadata/var/db/repos/gentoo//metadata
+/wiki//var/db/repos/gentoo/metadata/var/db/repos/gentoo/metadata
+/wiki//var/db/repos/gentoo/profiles
+/wiki//var/db/repos/gentoo/profiles/license_groups
+/wiki//var/db/repos/gentoo/profiles/package.mask
+/wiki//var/lib/portage
+/wiki//var/lib/portage/world
+/wiki//var/run
+/gcc-bugs/bug-122002-4@http.gcc.gnu.org%2Fbugzilla%2F/T/
\ No newline at end of file
diff --git a/test/log-file/test.mjs b/test/log-file/test.mjs
new file mode 100644
index 000000000..f9db64547
--- /dev/null
+++ b/test/log-file/test.mjs
@@ -0,0 +1,94 @@
+import { statSync } from "fs";
+
+async function getPage(path) {
+ return fetch(`http://localhost:8923${path}`, {
+ headers: {
+ "User-Agent": "CHALLENGE",
+ },
+ })
+ .then((resp) => {
+ if (resp.status !== 200) {
+ throw new Error(`wanted status 200, got status: ${resp.status}`);
+ }
+ return resp;
+ })
+ .then((resp) => resp.text());
+}
+
+async function getFileSize(filePath) {
+ try {
+ return statSync(filePath).size;
+ } catch (error) {
+ return 0;
+ }
+}
+
+(async () => {
+ const logFilePath = "./var/anubis.log";
+
+ // Get initial log file size
+ const initialSize = await getFileSize(logFilePath);
+ console.log(`Initial log file size: ${initialSize} bytes`);
+
+ // Make 35 requests with different paths
+ const requests = [];
+ for (let i = 0; i < 35; i++) {
+ requests.push(`/test${i}`);
+ }
+
+ const resultSheet = {};
+ let failed = false;
+
+ for (const path of requests) {
+ try {
+ const resp = await getPage(path);
+ resultSheet[path] = {
+ success: true,
+ line: resp.split("\n")[0],
+ };
+ } catch (error) {
+ resultSheet[path] = {
+ success: false,
+ error: error.message,
+ };
+ console.log(`✗ Request to ${path} failed: ${error.message}`);
+ failed = true;
+ }
+ }
+
+ // Check final log file size
+ const finalSize = await getFileSize(logFilePath);
+ console.log(`Final log file size: ${finalSize} bytes`);
+ console.log(`Size increase: ${finalSize - initialSize} bytes`);
+
+ // Verify that log file size increased
+ if (finalSize <= initialSize) {
+ console.error(
+ "ERROR: Log file size did not increase after making requests!",
+ );
+ failed = true;
+ }
+
+ let successCount = 0;
+ for (let [k, v] of Object.entries(resultSheet)) {
+ if (!v.success) {
+ console.error({ path: k, error: v.error });
+ } else {
+ successCount++;
+ }
+ }
+
+ console.log(`Successful requests: ${successCount}/${requests.length}`);
+
+ if (failed) {
+ console.error(
+ "Test failed: Some requests failed or log file size did not increase",
+ );
+ process.exit(1);
+ } else {
+ console.log(
+ "Test passed: All requests succeeded and log file size increased",
+ );
+ process.exit(0);
+ }
+})();
diff --git a/test/log-file/test.sh b/test/log-file/test.sh
new file mode 100755
index 000000000..c830a0bca
--- /dev/null
+++ b/test/log-file/test.sh
@@ -0,0 +1,25 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+function cleanup() {
+ pkill -P $$
+}
+
+trap cleanup EXIT SIGINT
+
+# Build static assets
+(cd ../.. && npm ci && npm run assets)
+
+go tool anubis --help 2>/dev/null || :
+
+go run ../cmd/httpdebug &
+
+go tool anubis \
+ --policy-fname ./anubis.yaml \
+ --use-remote-address \
+ --target=http://localhost:3923 &
+
+sleep 2
+
+backoff-retry node ./test.mjs
diff --git a/test/log-file/var/.gitignore b/test/log-file/var/.gitignore
new file mode 100644
index 000000000..c96a04f00
--- /dev/null
+++ b/test/log-file/var/.gitignore
@@ -0,0 +1,2 @@
+*
+!.gitignore
\ No newline at end of file
diff --git a/test/nginx-external-auth/deployment.yaml b/test/nginx-external-auth/deployment.yaml
index f4b408b94..e70265791 100644
--- a/test/nginx-external-auth/deployment.yaml
+++ b/test/nginx-external-auth/deployment.yaml
@@ -12,39 +12,37 @@ spec:
app: nginx-external-auth
spec:
volumes:
- - name: config
- configMap:
- name: nginx-cfg
- containers:
- - name: www
- image: nginx:alpine
- resources:
- limits:
- memory: "128Mi"
- cpu: "500m"
- requests:
- memory: "128Mi"
- cpu: "500m"
- ports:
- - containerPort: 80
- volumeMounts:
- name: config
- mountPath: /etc/nginx/conf.d
- readOnly: true
- - name: anubis
- image: ttl.sh/techaro/anubis-external-auth:latest
- imagePullPolicy: Always
- resources:
- limits:
- cpu: 500m
- memory: 128Mi
- requests:
- cpu: 250m
- memory: 128Mi
- env:
- - name: TARGET
- value: " "
- - name: REDIRECT_DOMAINS
- value: nginx.local.cetacean.club
-
-
+ configMap:
+ name: nginx-cfg
+ containers:
+ - name: www
+ image: nginx:alpine
+ resources:
+ limits:
+ memory: "128Mi"
+ cpu: "500m"
+ requests:
+ memory: "128Mi"
+ cpu: "500m"
+ ports:
+ - containerPort: 80
+ volumeMounts:
+ - name: config
+ mountPath: /etc/nginx/conf.d
+ readOnly: true
+ - name: anubis
+ image: ttl.sh/techaro/anubis:latest
+ imagePullPolicy: Always
+ resources:
+ limits:
+ cpu: 500m
+ memory: 128Mi
+ requests:
+ cpu: 250m
+ memory: 128Mi
+ env:
+ - name: TARGET
+ value: " "
+ - name: REDIRECT_DOMAINS
+ value: nginx.local.cetacean.club
diff --git a/test/nginx-external-auth/ingress.yaml b/test/nginx-external-auth/ingress.yaml
index 6fc87375e..2cf2c0ac3 100644
--- a/test/nginx-external-auth/ingress.yaml
+++ b/test/nginx-external-auth/ingress.yaml
@@ -9,17 +9,17 @@ metadata:
spec:
ingressClassName: traefik
tls:
- - hosts:
- - nginx.local.cetacean.club
- secretName: nginx-local-cetacean-club-public-tls
+ - hosts:
+ - nginx.local.cetacean.club
+ secretName: nginx-local-cetacean-club-public-tls
rules:
- - host: nginx.local.cetacean.club
- http:
- paths:
- - pathType: Prefix
- path: "/"
- backend:
- service:
- name: nginx-external-auth
- port:
- name: http
+ - host: nginx.local.cetacean.club
+ http:
+ paths:
+ - pathType: Prefix
+ path: "/"
+ backend:
+ service:
+ name: nginx-external-auth
+ port:
+ name: http
diff --git a/test/nginx-external-auth/kustomization.yaml b/test/nginx-external-auth/kustomization.yaml
index 7410f9724..b11a6e77a 100644
--- a/test/nginx-external-auth/kustomization.yaml
+++ b/test/nginx-external-auth/kustomization.yaml
@@ -7,4 +7,4 @@ configMapGenerator:
- name: nginx-cfg
behavior: create
files:
- - ./conf.d/default.conf
+ - ./conf.d/default.conf
diff --git a/test/nginx-external-auth/service.yaml b/test/nginx-external-auth/service.yaml
index d2e018cff..5ce5c2455 100644
--- a/test/nginx-external-auth/service.yaml
+++ b/test/nginx-external-auth/service.yaml
@@ -6,8 +6,8 @@ spec:
selector:
app: nginx-external-auth
ports:
- - name: http
- protocol: TCP
- port: 80
- targetPort: 80
+ - name: http
+ protocol: TCP
+ port: 80
+ targetPort: 80
type: ClusterIP
diff --git a/test/nginx-external-auth/start.sh b/test/nginx-external-auth/start.sh
index 044238ab6..ec3faf0e9 100755
--- a/test/nginx-external-auth/start.sh
+++ b/test/nginx-external-auth/start.sh
@@ -4,20 +4,20 @@ set -euo pipefail
# Build container image
(
- cd ../.. \
- && npm ci \
- && npm run container -- \
- --docker-repo ttl.sh/techaro/anubis-external-auth \
- --docker-tags ttl.sh/techaro/anubis-external-auth:latest
+ cd ../.. &&
+ npm ci &&
+ npm run container -- \
+ --docker-repo ttl.sh/techaro/anubis \
+ --docker-tags ttl.sh/techaro/anubis:latest
)
kubectl apply -k .
echo "open https://nginx.local.cetacean.club, press control c when done"
control_c() {
- kubectl delete -k .
- exit
+ kubectl delete -k .
+ exit
}
trap control_c SIGINT
-sleep infinity
\ No newline at end of file
+sleep infinity
diff --git a/test/nginx/conf/nginx/conf-anubis.inc b/test/nginx/conf/nginx/conf-anubis.inc
new file mode 100644
index 000000000..1535ec91a
--- /dev/null
+++ b/test/nginx/conf/nginx/conf-anubis.inc
@@ -0,0 +1,7 @@
+# /etc/nginx/conf-anubis.inc
+# Forward to anubis
+location / {
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_pass http://anubis;
+}
diff --git a/test/nginx/conf/nginx/conf.d/server-mimi-techaro-lol.conf b/test/nginx/conf/nginx/conf.d/server-mimi-techaro-lol.conf
new file mode 100644
index 000000000..e0e69e6c8
--- /dev/null
+++ b/test/nginx/conf/nginx/conf.d/server-mimi-techaro-lol.conf
@@ -0,0 +1,29 @@
+# /etc/nginx/conf.d/server-mimi-techaro-lol.conf
+
+server {
+ # Listen on 443 with SSL
+ listen 443 ssl;
+ listen [::]:443 ssl;
+ http2 on;
+
+ # Slipstream via Anubis
+ include "conf-anubis.inc";
+
+ server_name mimi.techaro.lol;
+
+ ssl_certificate /techaro/pki/mimi.techaro.lol/cert.pem;
+ ssl_certificate_key /techaro/pki/mimi.techaro.lol/key.pem;
+}
+
+server {
+ listen unix:/tmp/nginx.sock;
+
+ server_name mimi.techaro.lol;
+
+ port_in_redirect off;
+ root "/srv/http/mimi.techaro.lol";
+ index index.html;
+
+ # Your normal configuration can go here
+ # location .php { fastcgi...} etc.
+}
\ No newline at end of file
diff --git a/test/nginx/conf/nginx/conf.d/upstream-anubis.conf b/test/nginx/conf/nginx/conf.d/upstream-anubis.conf
new file mode 100644
index 000000000..7aca929ff
--- /dev/null
+++ b/test/nginx/conf/nginx/conf.d/upstream-anubis.conf
@@ -0,0 +1,17 @@
+# /etc/nginx/conf.d/upstream-anubis.conf
+
+upstream anubis {
+ zone anubis_zone 64k;
+ # Make sure this matches the values you set for `BIND` and `BIND_NETWORK`.
+ # If this does not match, your services will not be protected by Anubis.
+
+ # Try anubis first over a UNIX socket
+ #server unix:/run/anubis/nginx.sock;
+ server anubis:3000 resolve;
+
+ # Optional: fall back to serving the websites directly. This allows your
+ # websites to be resilient against Anubis failing, at the risk of exposing
+ # them to the raw internet without protection. This is a tradeoff and can
+ # be worth it in some edge cases.
+ #server unix:/run/nginx.sock backup;
+}
\ No newline at end of file
diff --git a/test/nginx/conf/nginx/mime.types b/test/nginx/conf/nginx/mime.types
new file mode 100644
index 000000000..1c00d701a
--- /dev/null
+++ b/test/nginx/conf/nginx/mime.types
@@ -0,0 +1,99 @@
+
+types {
+ text/html html htm shtml;
+ text/css css;
+ text/xml xml;
+ image/gif gif;
+ image/jpeg jpeg jpg;
+ application/javascript js;
+ application/atom+xml atom;
+ application/rss+xml rss;
+
+ text/mathml mml;
+ text/plain txt;
+ text/vnd.sun.j2me.app-descriptor jad;
+ text/vnd.wap.wml wml;
+ text/x-component htc;
+
+ image/avif avif;
+ image/png png;
+ image/svg+xml svg svgz;
+ image/tiff tif tiff;
+ image/vnd.wap.wbmp wbmp;
+ image/webp webp;
+ image/x-icon ico;
+ image/x-jng jng;
+ image/x-ms-bmp bmp;
+
+ font/woff woff;
+ font/woff2 woff2;
+
+ application/java-archive jar war ear;
+ application/json json;
+ application/mac-binhex40 hqx;
+ application/msword doc;
+ application/pdf pdf;
+ application/postscript ps eps ai;
+ application/rtf rtf;
+ application/vnd.apple.mpegurl m3u8;
+ application/vnd.google-earth.kml+xml kml;
+ application/vnd.google-earth.kmz kmz;
+ application/vnd.ms-excel xls;
+ application/vnd.ms-fontobject eot;
+ application/vnd.ms-powerpoint ppt;
+ application/vnd.oasis.opendocument.graphics odg;
+ application/vnd.oasis.opendocument.presentation odp;
+ application/vnd.oasis.opendocument.spreadsheet ods;
+ application/vnd.oasis.opendocument.text odt;
+ application/vnd.openxmlformats-officedocument.presentationml.presentation
+ pptx;
+ application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
+ xlsx;
+ application/vnd.openxmlformats-officedocument.wordprocessingml.document
+ docx;
+ application/vnd.wap.wmlc wmlc;
+ application/wasm wasm;
+ application/x-7z-compressed 7z;
+ application/x-cocoa cco;
+ application/x-java-archive-diff jardiff;
+ application/x-java-jnlp-file jnlp;
+ application/x-makeself run;
+ application/x-perl pl pm;
+ application/x-pilot prc pdb;
+ application/x-rar-compressed rar;
+ application/x-redhat-package-manager rpm;
+ application/x-sea sea;
+ application/x-shockwave-flash swf;
+ application/x-stuffit sit;
+ application/x-tcl tcl tk;
+ application/x-x509-ca-cert der pem crt;
+ application/x-xpinstall xpi;
+ application/xhtml+xml xhtml;
+ application/xspf+xml xspf;
+ application/zip zip;
+
+ application/octet-stream bin exe dll;
+ application/octet-stream deb;
+ application/octet-stream dmg;
+ application/octet-stream iso img;
+ application/octet-stream msi msp msm;
+
+ audio/midi mid midi kar;
+ audio/mpeg mp3;
+ audio/ogg ogg;
+ audio/x-m4a m4a;
+ audio/x-realaudio ra;
+
+ video/3gpp 3gpp 3gp;
+ video/mp2t ts;
+ video/mp4 mp4;
+ video/mpeg mpeg mpg;
+ video/quicktime mov;
+ video/webm webm;
+ video/x-flv flv;
+ video/x-m4v m4v;
+ video/x-mng mng;
+ video/x-ms-asf asx asf;
+ video/x-ms-wmv wmv;
+ video/x-msvideo avi;
+}
diff --git a/test/nginx/conf/nginx/nginx.conf b/test/nginx/conf/nginx/nginx.conf
new file mode 100644
index 000000000..90be80e73
--- /dev/null
+++ b/test/nginx/conf/nginx/nginx.conf
@@ -0,0 +1,32 @@
+worker_processes auto;
+
+error_log /var/log/nginx/error.log notice;
+pid /run/nginx.pid;
+
+
+events {
+ worker_connections 1024;
+}
+
+
+http {
+ resolver 169.254.42.1 valid=300s ipv6=on;
+ resolver_timeout 10s;
+ include /etc/nginx/mime.types;
+ default_type application/octet-stream;
+
+ log_format main '$remote_addr - $remote_user [$time_local] "$request" '
+ '$status $body_bytes_sent "$http_referer" '
+ '"$http_user_agent" "$http_x_forwarded_for"';
+
+ access_log /var/log/nginx/access.log main;
+
+ sendfile on;
+ #tcp_nopush on;
+
+ keepalive_timeout 65;
+
+ #gzip on;
+
+ include /etc/nginx/conf.d/*.conf;
+}
diff --git a/test/nginx/test.sh b/test/nginx/test.sh
new file mode 100755
index 000000000..c9d9475b6
--- /dev/null
+++ b/test/nginx/test.sh
@@ -0,0 +1,17 @@
+#!/usr/bin/env bash
+
+source ../lib/lib.sh
+
+export KO_DOCKER_REPO=ko.local
+
+set -euo pipefail
+
+mint_cert mimi.techaro.lol
+
+docker run --rm \
+ -v $PWD/conf/nginx:/etc/nginx:ro \
+ -v $PWD/pki:/techaro/pki:ro \
+ nginx \
+ nginx -t
+
+exit 0
diff --git a/test/palemoon/amd64/docker-compose.yml b/test/palemoon/amd64/docker-compose.yml
index 9403df3b8..59566e34d 100644
--- a/test/palemoon/amd64/docker-compose.yml
+++ b/test/palemoon/amd64/docker-compose.yml
@@ -26,7 +26,7 @@ services:
KEY_FNAME: key.pem
PROXY_TO: http://anubis:3000
volumes:
- - ../../pki/relayd:/techaro/pki:ro
+ - ./pki/relayd:/techaro/pki:ro
# novnc:
# image: geek1011/easy-novnc
@@ -42,7 +42,7 @@ services:
environment:
DISPLAY: display:0
volumes:
- - ../../pki:/usr/local/share/ca-certificates/minica:ro
+ - ./pki:/usr/local/share/ca-certificates/minica:ro
- ../scripts:/hack/scripts:ro
depends_on:
- anubis
diff --git a/test/palemoon/anubis/anubis.yaml b/test/palemoon/anubis/anubis.yaml
index c85e79704..e665f824a 100644
--- a/test/palemoon/anubis/anubis.yaml
+++ b/test/palemoon/anubis/anubis.yaml
@@ -4,7 +4,6 @@ bots:
action: CHALLENGE
challenge:
difficulty: 2
- report_as: 2
algorithm: fast
status_codes:
diff --git a/test/palemoon/i386/docker-compose.yml b/test/palemoon/i386/docker-compose.yml
index cda915dae..98ec72aff 100644
--- a/test/palemoon/i386/docker-compose.yml
+++ b/test/palemoon/i386/docker-compose.yml
@@ -24,7 +24,7 @@ services:
KEY_FNAME: key.pem
PROXY_TO: http://anubis:3000
volumes:
- - ../../pki/relayd:/techaro/pki:ro
+ - ./pki/relayd:/techaro/pki:ro
# novnc:
# image: geek1011/easy-novnc
@@ -40,5 +40,5 @@ services:
environment:
DISPLAY: display:0
volumes:
- - ../../pki:/usr/local/share/ca-certificates/minica:ro
+ - ./pki:/usr/local/share/ca-certificates/minica:ro
- ../scripts:/hack/scripts:ro
diff --git a/test/robots_txt/anubis.yaml b/test/robots_txt/anubis.yaml
new file mode 100644
index 000000000..9880a86c5
--- /dev/null
+++ b/test/robots_txt/anubis.yaml
@@ -0,0 +1,8 @@
+bots:
+ - name: challenge
+ user_agent_regex: CHALLENGE
+ action: CHALLENGE
+
+status_codes:
+ CHALLENGE: 200
+ DENY: 403
diff --git a/test/robots_txt/test.mjs b/test/robots_txt/test.mjs
new file mode 100644
index 000000000..faa6d89d9
--- /dev/null
+++ b/test/robots_txt/test.mjs
@@ -0,0 +1,27 @@
+async function getRobotsTxt() {
+ return fetch("http://localhost:8923/robots.txt", {
+ headers: {
+ "Accept-Language": "en",
+ "User-Agent": "Mozilla/5.0",
+ },
+ })
+ .then((resp) => {
+ if (resp.status !== 200) {
+ throw new Error(`wanted status 200, got status: ${resp.status}`);
+ }
+ return resp;
+ })
+ .then((resp) => resp.text());
+}
+
+(async () => {
+ const page = await getRobotsTxt();
+
+ if (page.includes(``)) {
+ console.log(page);
+ throw new Error("serve robots.txt smoke test failed");
+ }
+
+ console.log("serve-robots-txt serves robots.txt");
+ process.exit(0);
+})();
diff --git a/test/robots_txt/test.sh b/test/robots_txt/test.sh
new file mode 100755
index 000000000..327a15ade
--- /dev/null
+++ b/test/robots_txt/test.sh
@@ -0,0 +1,24 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+function cleanup() {
+ pkill -P $$
+}
+
+trap cleanup EXIT SIGINT
+
+# Build static assets
+(cd ../.. && npm ci && npm run assets)
+
+go tool anubis --help 2>/dev/null || :
+
+go run ../cmd/unixhttpd &
+
+go tool anubis \
+ --policy-fname ./anubis.yaml \
+ --use-remote-address \
+ --serve-robots-txt \
+ --target=unix://$(pwd)/unixhttpd.sock &
+
+backoff-retry node ./test.mjs
diff --git a/test/robots_txt/var/.gitignore b/test/robots_txt/var/.gitignore
new file mode 100644
index 000000000..c96a04f00
--- /dev/null
+++ b/test/robots_txt/var/.gitignore
@@ -0,0 +1,2 @@
+*
+!.gitignore
\ No newline at end of file
diff --git a/test/shared/www/index.html b/test/shared/www/index.html
index 8c55c8cb0..f259424f1 100644
--- a/test/shared/www/index.html
+++ b/test/shared/www/index.html
@@ -1,9 +1,9 @@
-
+
Anubis works!
-
-
+
+
@@ -11,7 +11,10 @@ Anubis works!
If you see this, everything has gone according to keikaku.
-
+
-
\ No newline at end of file
+
diff --git a/test/ssh-ci/Dockerfile b/test/ssh-ci/Dockerfile
index 652749d6b..a712e3eaa 100644
--- a/test/ssh-ci/Dockerfile
+++ b/test/ssh-ci/Dockerfile
@@ -1,5 +1,5 @@
ARG ALPINE_VERSION=3.22
FROM alpine:${ALPINE_VERSION}
-RUN apk add -U go nodejs git build-base git npm bash zstd brotli gzip
+RUN apk add -U go nodejs git build-base git npm bash zstd brotli gzip rust cargo rust-wasm
LABEL org.opencontainers.image.source="https://github.com/TecharoHQ/anubis"
\ No newline at end of file
diff --git a/test/ssh-ci/docker-bake.hcl b/test/ssh-ci/docker-bake.hcl
index eb72fac3b..83dc8b2c8 100644
--- a/test/ssh-ci/docker-bake.hcl
+++ b/test/ssh-ci/docker-bake.hcl
@@ -1,4 +1,4 @@
-variable "ALPINE_VERSION" { default = "3.22" }
+variable "ALPINE_VERSION" { default = "3.24" }
group "default" {
targets = [
@@ -8,14 +8,13 @@ group "default" {
target "ci-runner" {
args = {
- ALPINE_VERSION = "3.22"
+ ALPINE_VERSION = "3.24"
}
context = "."
dockerfile = "./Dockerfile"
platforms = [
"linux/amd64",
"linux/arm64",
- "linux/arm/v7",
"linux/ppc64le",
"linux/riscv64",
]
@@ -23,4 +22,4 @@ target "ci-runner" {
tags = [
"ghcr.io/techarohq/anubis/ci-runner:latest"
]
-}
\ No newline at end of file
+}
diff --git a/test/ssh-ci/rigging.sh b/test/ssh-ci/rigging.sh
index 2e35af7e3..4dbe48808 100644
--- a/test/ssh-ci/rigging.sh
+++ b/test/ssh-ci/rigging.sh
@@ -16,10 +16,11 @@ Hosts["aarch64-16k"]="ci@192.168.2.28" # GOARCH=arm64 GOOS=linux 16k page
CIRunnerImage="ghcr.io/techarohq/anubis/ci-runner:latest"
RunID=${GITHUB_RUN_ID:-$(uuidgen)}
-RunFolder="anubis/runs/${RunID}"
+RunFolder="anubis/runs/${RunID:?}"
Target="${Hosts["$1"]}"
ssh "${Target}" uname -av >/dev/null
+ssh "${Target}" rm -rf "${RunFolder}"
ssh "${Target}" mkdir -p "${RunFolder}"
git archive HEAD | ssh "${Target}" tar xC "${RunFolder}"
@@ -36,5 +37,5 @@ ssh "${Target}" < resp.status);
+ },
+ },
+ ).then((resp) => resp.status);
return statusCode;
}
const codes = {
allow: await testWithUserAgent("ALLOW"),
challenge: await testWithUserAgent("CHALLENGE"),
- deny: await testWithUserAgent("DENY")
-}
+ deny: await testWithUserAgent("DENY"),
+};
const expected = {
allow: 200,
@@ -26,5 +27,7 @@ console.log("CHALLENGE:", codes.challenge);
console.log("DENY: ", codes.deny);
if (JSON.stringify(codes) !== JSON.stringify(expected)) {
- throw new Error(`wanted ${JSON.stringify(expected)}, got: ${JSON.stringify(codes)}`);
-}
\ No newline at end of file
+ throw new Error(
+ `wanted ${JSON.stringify(expected)}, got: ${JSON.stringify(codes)}`,
+ );
+}
diff --git a/test/wildcard-match/anubis.yaml b/test/wildcard-match/anubis.yaml
new file mode 100644
index 000000000..86068200e
--- /dev/null
+++ b/test/wildcard-match/anubis.yaml
@@ -0,0 +1,6 @@
+bots:
+ - import: "./cfg/[0-9][0-9]-*.yaml"
+
+status_codes:
+ CHALLENGE: 200
+ DENY: 403
diff --git a/test/wildcard-match/cfg/00-challenge.yaml b/test/wildcard-match/cfg/00-challenge.yaml
new file mode 100644
index 000000000..5bf111261
--- /dev/null
+++ b/test/wildcard-match/cfg/00-challenge.yaml
@@ -0,0 +1,3 @@
+- name: challenge
+ user_agent_regex: CHALLENGE
+ action: CHALLENGE
diff --git a/test/wildcard-match/cfg/01-import-crawlers.yaml b/test/wildcard-match/cfg/01-import-crawlers.yaml
new file mode 100644
index 000000000..a7ccd86c0
--- /dev/null
+++ b/test/wildcard-match/cfg/01-import-crawlers.yaml
@@ -0,0 +1 @@
+- import: (data)/crawlers/*.yaml
diff --git a/test/wildcard-match/test.mjs b/test/wildcard-match/test.mjs
new file mode 100644
index 000000000..43421f1e8
--- /dev/null
+++ b/test/wildcard-match/test.mjs
@@ -0,0 +1,21 @@
+async function fetchLanguages() {
+ return fetch(
+ "http://localhost:8923/.within.website/x/cmd/anubis/static/locales/manifest.json",
+ )
+ .then((resp) => {
+ if (resp.status !== 200) {
+ throw new Error(`wanted status 200, got status: ${resp.status}`);
+ }
+ return resp;
+ })
+ .then((resp) => resp.json());
+}
+
+(async () => {
+ const languages = await fetchLanguages();
+ console.log(
+ "Anubis is running, which means that importing configuration worked",
+ );
+
+ process.exit(0);
+})();
diff --git a/test/wildcard-match/test.sh b/test/wildcard-match/test.sh
new file mode 100755
index 000000000..5f74b9d33
--- /dev/null
+++ b/test/wildcard-match/test.sh
@@ -0,0 +1,23 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+function cleanup() {
+ pkill -P $$
+}
+
+trap cleanup EXIT SIGINT
+
+# Build static assets
+(cd ../.. && npm ci && npm run assets)
+
+go tool anubis --help 2>/dev/null ||:
+
+go run ../cmd/unixhttpd &
+
+go tool anubis \
+ --policy-fname ./anubis.yaml \
+ --use-remote-address \
+ --target=unix://$(pwd)/unixhttpd.sock &
+
+backoff-retry node ./test.mjs
diff --git a/test/wildcard-match/var/.gitignore b/test/wildcard-match/var/.gitignore
new file mode 100644
index 000000000..c96a04f00
--- /dev/null
+++ b/test/wildcard-match/var/.gitignore
@@ -0,0 +1,2 @@
+*
+!.gitignore
\ No newline at end of file
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 000000000..1e12abce7
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "compilerOptions": {
+ "paths": {
+ "@lib/*": ["./web/js/lib/*"]
+ }
+ }
+}
diff --git a/utils/cmd/iplist2rule/blocklist.go b/utils/cmd/iplist2rule/blocklist.go
new file mode 100644
index 000000000..dab8cff76
--- /dev/null
+++ b/utils/cmd/iplist2rule/blocklist.go
@@ -0,0 +1,110 @@
+package main
+
+import (
+ "bufio"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/netip"
+ "strings"
+)
+
+// ParseBlocklist reads a plain-text blocklist and returns every non-commented
+// line parsed as an IP address in CIDR notation. IPv4 addresses are returned as
+// /32, IPv6 addresses as /128.
+//
+// This function was generated with GLM 4.7.
+func ParseBlocklist(list io.Reader) ([]string, error) {
+ var addrs []string
+
+ scanner := bufio.NewScanner(list)
+ for scanner.Scan() {
+ line := scanner.Text()
+ // Skip empty lines and comments (lines starting with #)
+ if line == "" || strings.HasPrefix(line, "#") {
+ continue
+ }
+
+ addr, err := netip.ParseAddr(line)
+ if err != nil {
+ // Skip lines that aren't valid IP addresses
+ continue
+ }
+
+ var cidr string
+ if addr.Is4() {
+ cidr = fmt.Sprintf("%s/32", addr.String())
+ } else {
+ cidr = fmt.Sprintf("%s/128", addr.String())
+ }
+ addrs = append(addrs, cidr)
+ }
+
+ if err := scanner.Err(); err != nil {
+ return nil, err
+ }
+
+ return addrs, nil
+}
+
+// FetchBlocklist fetches url over HTTP and parses the response body as a
+// blocklist. JSON responses (detected via the Content-Type header or a
+// ".json" URL suffix) are parsed with ParsePrefixList; everything else is
+// treated as a plain-text list and parsed with ParseBlocklist.
+func FetchBlocklist(url string) ([]string, error) {
+ resp, err := http.Get(url)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close() //nolint:errcheck
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("HTTP request failed with status: %s", resp.Status)
+ }
+
+ if isJSONBlocklist(url, resp.Header.Get("Content-Type")) {
+ return ParsePrefixList(resp.Body)
+ }
+
+ return ParseBlocklist(resp.Body)
+}
+
+func isJSONBlocklist(url, contentType string) bool {
+ if contentType == "json" {
+ return true
+ }
+
+ return strings.HasSuffix(strings.ToLower(url), ".json")
+}
+
+type Prefix struct {
+ IPv4Prefix string `json:"ipv4Prefix"`
+ IPv6Prefix string `json:"ipv6Prefix"`
+}
+
+type PrefixList struct {
+ CreationTime string `json:"creationTime"`
+ Prefixes []Prefix `json:"prefixes"`
+}
+
+// ParsePrefixList decodes a JSON document read from list in the Google/OpenAI
+// bot IP range format and returns every IPv4 and IPv6 prefix it contains.
+func ParsePrefixList(list io.Reader) ([]string, error) {
+ var pl PrefixList
+ if err := json.NewDecoder(list).Decode(&pl); err != nil {
+ return nil, fmt.Errorf("can't decode prefix list: %w", err)
+ }
+
+ var prefixes []string
+ for _, p := range pl.Prefixes {
+ switch {
+ case p.IPv4Prefix != "":
+ prefixes = append(prefixes, p.IPv4Prefix)
+ case p.IPv6Prefix != "":
+ prefixes = append(prefixes, p.IPv6Prefix)
+ }
+ }
+
+ return prefixes, nil
+}
diff --git a/utils/cmd/iplist2rule/main.go b/utils/cmd/iplist2rule/main.go
new file mode 100644
index 000000000..df3dbd17c
--- /dev/null
+++ b/utils/cmd/iplist2rule/main.go
@@ -0,0 +1,110 @@
+package main
+
+import (
+ "flag"
+ "fmt"
+ "log"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/TecharoHQ/anubis/lib/config"
+ "github.com/facebookgo/flagenv"
+ "sigs.k8s.io/yaml"
+)
+
+type Rule struct {
+ Name string `yaml:"name" json:"name"`
+ Action config.Rule `yaml:"action" json:"action"`
+ RemoteAddr []string `json:"remote_addresses,omitempty" yaml:"remote_addresses,omitempty"`
+ Weight *config.Weight `json:"weight,omitempty" yaml:"weight,omitempty"`
+}
+
+func init() {
+ flag.Usage = func() {
+ fmt.Printf(`Usage of %[1]s:
+
+ %[1]s [flags]
+
+Grabs the contents of the blocklist, converts it to an Anubis ruleset, and writes it to filename.
+
+Flags:
+`, filepath.Base(os.Args[0]))
+
+ flag.PrintDefaults()
+ }
+}
+
+var (
+ action = flag.String("action", "DENY", "Anubis action to take (ALLOW / DENY / WEIGH)")
+ manualRuleName = flag.String("rule-name", "", "If set, prefer this name over inferring from filename")
+ weight = flag.Int("weight", 0, "If set to any number, add/subtract this many weight points when --action=WEIGH")
+)
+
+func main() {
+ flagenv.Parse()
+ flag.Parse()
+
+ if flag.NArg() != 2 {
+ flag.Usage()
+ os.Exit(2)
+ }
+
+ blocklistURL := flag.Arg(0)
+ foutName := flag.Arg(1)
+ ruleName := strings.TrimSuffix(foutName, filepath.Ext(foutName))
+
+ if *manualRuleName != "" {
+ ruleName = *manualRuleName
+ }
+
+ ruleAction := config.Rule(*action)
+ if err := ruleAction.Valid(); err != nil {
+ log.Fatalf("--action=%q is invalid: %v", *action, err)
+ }
+
+ result := &Rule{
+ Name: ruleName,
+ Action: ruleAction,
+ }
+
+ if *weight != 0 {
+ if ruleAction != config.RuleWeigh {
+ log.Fatalf("used --weight=%d but --action=%s", *weight, *action)
+ }
+
+ result.Weight = &config.Weight{
+ Adjust: *weight,
+ }
+ }
+
+ ips, err := FetchBlocklist(blocklistURL)
+ if err != nil {
+ log.Fatalf("can't fetch blocklist %s: %v", blocklistURL, err)
+ }
+
+ result.RemoteAddr = ips
+
+ fout, err := os.Create(foutName)
+ if err != nil {
+ log.Fatalf("can't create output file %q: %v", foutName, err)
+ }
+
+ if _, err := fmt.Fprintf(fout, "# Generated by %s on %s from %s\n\n", filepath.Base(os.Args[0]), time.Now().Format(time.RFC3339), blocklistURL); err != nil {
+ log.Fatalf("can't write signature line: %v", err)
+ }
+
+ data, err := yaml.Marshal([]*Rule{result})
+ if err != nil {
+ log.Fatalf("can't marshal yaml: %v", err)
+ }
+
+ if _, err := fout.Write(data); err != nil {
+ log.Fatalf("can't write data: %v", err)
+ }
+
+ if err := fout.Close(); err != nil {
+ log.Fatalf("can't close file: %v", err)
+ }
+}
diff --git a/utils/cmd/wazero-exec/main.go b/utils/cmd/wazero-exec/main.go
new file mode 100644
index 000000000..da87f1b70
--- /dev/null
+++ b/utils/cmd/wazero-exec/main.go
@@ -0,0 +1,108 @@
+package main
+
+import (
+ "context"
+ "errors"
+ "flag"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/tetratelabs/wazero"
+ "github.com/tetratelabs/wazero/api"
+ "github.com/tetratelabs/wazero/experimental"
+ "github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1"
+ "github.com/tetratelabs/wazero/sys"
+)
+
+func defaultCacheDir() string {
+ dir, err := os.UserCacheDir()
+ if err != nil {
+ path := filepath.Join(os.Getenv("HOME"), ".cache", "techaro.lol", "anubis", "wazero-exec")
+ _ = os.MkdirAll(path, 0755)
+ return path
+ }
+
+ path := filepath.Join(dir, "techaro.lol", "anubis", "wazero-exec")
+ _ = os.MkdirAll(path, 0755)
+ return path
+}
+
+var (
+ cacheDir = flag.String("cache-dir", defaultCacheDir(), "default compilation cache folder")
+)
+
+func main() {
+ flag.Parse()
+
+ if flag.NArg() == 0 {
+ fmt.Fprintf(os.Stderr, "usage: %s [args]\n\n", filepath.Base(os.Args[0]))
+ flag.Usage()
+ os.Exit(2)
+ }
+
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ if err := run(ctx, flag.Arg(0), flag.Args()); err != nil {
+ fmt.Fprintf(os.Stderr, "can't run program %s: %v\n", flag.Arg(0), err)
+
+ var exitErr *sys.ExitError
+ if errors.As(err, &exitErr) && exitErr.ExitCode() != 0 {
+ os.Exit(int(exitErr.ExitCode()))
+ }
+
+ os.Exit(1)
+ }
+}
+
+func run(ctx context.Context, fname string, args []string) error {
+ cache, err := wazero.NewCompilationCacheWithDir(*cacheDir)
+ if err != nil {
+ return fmt.Errorf("can't open cache folder %q: %w", *cacheDir, err)
+ }
+
+ cfg := wazero.NewRuntimeConfig().
+ WithCompilationCache(cache).
+ WithCoreFeatures(api.CoreFeaturesV2 | experimental.CoreFeaturesExceptionHandling | experimental.CoreFeaturesExtendedConst | experimental.CoreFeaturesTailCall)
+
+ runtime := wazero.NewRuntimeWithConfig(ctx, cfg)
+ wasi_snapshot_preview1.MustInstantiate(ctx, runtime)
+
+ bin, err := os.ReadFile(fname)
+ if err != nil {
+ return fmt.Errorf("can't read file %s: %w", fname, err)
+ }
+
+ compiled, err := runtime.CompileModule(ctx, bin)
+ if err != nil {
+ return fmt.Errorf("can't compile binary %s: %w", fname, err)
+ }
+
+ cwd, err := os.Getwd()
+ if err != nil {
+ return fmt.Errorf("[unexpected] can't get cwd: %w", err)
+ }
+
+ fsConfig := wazero.NewFSConfig().WithDirMount(cwd, "/")
+
+ config := wazero.NewModuleConfig().
+ WithStdin(os.Stdin).WithStdout(os.Stdout).WithStderr(os.Stderr).
+ WithArgs(args...).WithName(filepath.Base(fname)).WithFSConfig(fsConfig).
+ WithSysNanosleep().WithSysNanotime().WithSysWalltime()
+
+ for _, kp := range os.Environ() {
+ kv := strings.SplitN(kp, "=", 2)
+ config = config.WithEnv(kv[0], kv[1])
+ }
+
+ mod, err := runtime.InstantiateModule(ctx, compiled, config)
+ if err != nil {
+ return fmt.Errorf("can't instantiate module: %w", err)
+ }
+ if err := mod.Close(ctx); err != nil {
+ return err
+ }
+
+ return nil
+}
diff --git a/utils/wasm/wasm2js/README.md b/utils/wasm/wasm2js/README.md
new file mode 100644
index 000000000..182adad95
--- /dev/null
+++ b/utils/wasm/wasm2js/README.md
@@ -0,0 +1,15 @@
+# Pre-built wasm2js binaries
+
+The existience of this folder is a bit of a crime. Let me elaborate.
+
+Anubis uses WebAssembly based checks so that it can have the proof of work run as _efficiently as possible_. This includes using the simd128 extension in WebAssembly so that clients take maximum possible advantage of their hardware. The goal of this is to make Anubis _go away as fast as it can_ so people don't see it for too long and complain.
+
+However, many "privacy browsers", iOS Lockdown Mode, and other "enhanced web privacy" configuration guides tell people to disable WebAssembly. This would mean that clients that can't/won't execute WebAssembly are de-facto blocked from the service Anubis protects.
+
+As a middle ground, the WebAssembly modules are compiled down to JavaScript a-la [Metal in The Birth and Death of JavaScript](https://www.destroyallsoftware.com/talks/the-birth-and-death-of-javascript). This will at least let the check logic run on client machines. It won't run efficiently as most of the configurations that disable WebAssembly also disable browser JIT engines, but it will _eventually_ finish.
+
+The catch that makes this folder need to exist is that the tool that's being used for this, [wasm2js from binaryen](https://github.com/WebAssembly/binaryen), is packaged in distro repositories. The downside of the distro packaging is that it is _woefully_ out of date compared to what is required to actually compile the WebAssembly to JavaScript. This leaves us in a bit of a pickle.
+
+The middle path here is to use WebAssembly to ship _the known working version_ of wasm2js in the git repo as well as the [build.sh](./build.sh) script used to build it. The build scripts will use this version of wasm2js as much as possible. These builds are known to be byte-for-byte reproducible as of [wasi-sdk v34.0-rc.2](https://github.com/WebAssembly/wasi-sdk/releases/tag/wasi-sdk-34-rc.2).
+
+Hopefully this will be robust enough to survive reality, but this is the kind of thing you only learn the efficacy of the hard way.
diff --git a/utils/wasm/wasm2js/build.sh b/utils/wasm/wasm2js/build.sh
new file mode 100755
index 000000000..1000faa9a
--- /dev/null
+++ b/utils/wasm/wasm2js/build.sh
@@ -0,0 +1,248 @@
+#!/usr/bin/env bash
+#
+# CW(Xe): Script was generated by Claude after I tried to do this manually and
+# gave up.
+#
+# Build binaryen tools (wasm2js, wasm-opt) as WASI preview 1 WebAssembly
+# modules. See the TOOLS array below to add or remove targets.
+#
+# Output: ./_.wasm
+# (default wasm2js_130.wasm, wasm-opt_130.wasm)
+#
+# Dependencies are fetched automatically if missing: wasi-sdk is downloaded
+# from its GitHub release and binaryen is git-cloned at the requested tag.
+# Pin versions with the WASI_SDK_VERSION and BINARYEN_VERSION env vars.
+#
+# How it works / notable decisions:
+#
+# * Toolchain: we use the wasi-sdk CMake toolchain file `wasi-sdk-p1.cmake`,
+# which targets `wasm32-wasip1` and points CMake at the bundled clang/lld.
+#
+# * Static binary: WASI has no dynamic linker, so libbinaryen is built as a
+# static archive (-DBUILD_SHARED_LIBS=OFF) and statically linked into the
+# final module. This is the only thing that links on WASI.
+#
+# * C++ exceptions: binaryen's parser and tools use `throw`, so we cannot
+# build with -fno-exceptions. wasi-sdk ships two libc++ flavors: the default
+# `noeh` (no exceptions) and an `eh` variant. Passing `-fwasm-exceptions`
+# makes the clang driver select the `eh` include/lib dirs. We also link
+# `-lunwind` to satisfy the `_Unwind_*` / exception-tag symbols.
+#
+# * EH encoding: by default LLVM emits the *legacy* wasm exception-handling
+# opcodes (`try`/`catch`), which modern runtimes (e.g. wasmtime >= ~45) no
+# longer accept. `-mllvm -wasm-use-legacy-eh=false` makes it emit the
+# standardized exnref-based EH (`try_table`) instead, so the module runs on
+# current runtimes with the exceptions proposal enabled.
+#
+# * Threads: binaryen calls find_package(Threads) and links pthread (wasi-libc
+# provides stubs). At runtime ThreadPool::getNumCores() returns 1 on wasip1,
+# so no threads are ever spawned and it runs single-threaded.
+#
+# * Reproducibility: the goal is a byte-for-byte identical module on any
+# machine. Ninja invokes clang with absolute source paths, so __FILE__
+# (used all over binaryen via WASM_UNREACHABLE) would otherwise bake the
+# build machine's checkout path into the module's data segments. We rewrite
+# that prefix to a stable token with -ffile-prefix-map so the output does
+# not depend on where the tree lives. Everything else feeding the module is
+# already pinned (wasi-sdk version, binaryen tag, compile/link flags), and
+# the wasm format carries no build timestamp, so the raw clang/lld output is
+# deterministic. We deliberately do NOT post-process with wasm-opt: its
+# availability and version vary per machine, which would defeat the
+# guarantee. Note this takes an explicit flag: clang's wasm driver runs
+# `wasm-opt -O3` on its own whenever a wasm-opt is on PATH and optimization
+# is on, so we pass --no-wasm-opt at link time to suppress it (see the
+# REPRO_LINK_FLAGS note below).
+#
+# * Clean build: cmake/ninja are incremental, so re-running over an existing
+# build tree only recompiles what changed and leaves stale object files
+# behind. If those objects were produced by an older revision of this script
+# (different flags), they leak into the final module and the output is no
+# longer a function of the current inputs alone. We therefore wipe the build
+# directory before configuring so every run is a full, flag-consistent build
+# from scratch.
+#
+# * Stripping: the linker emits ~2.5 MB of DWARF (.debug_*), a multi-megabyte
+# `name` section, and a `producers` section that records the exact clang
+# revision. The producers string in particular would tie the bytes to one
+# wasi-sdk build, and the debug/name sections only bloat a module we run
+# headless. We strip all of them with the wasi-sdk's OWN `llvm-strip`
+# (--strip-all), which is pinned by WASI_SDK_VERSION just like the compiler,
+# so the strip step is as deterministic as the rest of the toolchain. This
+# is NOT wasm-opt: llvm-strip ships in the sdk we already pin, so it adds no
+# new floating dependency. `target_features` is intentionally preserved --
+# it advertises the proposals (exceptions, etc.) the module needs and
+# --strip-all keeps it.
+#
+# * ASLR: the wasi-sdk clang that compiles binaryen has address-sensitive
+# ordering in its WebAssembly exception-handling codegen -- pointer values
+# leak into the iteration order of a few passes, which shuffles a handful of
+# try_table basic blocks and their branch labels. With address-space layout
+# randomization on, the same source compiles to ~29 differing bytes from one
+# run to the next. We pin pointer layout by re-exec'ing the whole build under
+# `setarch -R` (ADDR_NO_RANDOMIZE), which is inherited by cmake/ninja/clang.
+# This is Linux-only; see the re-exec guard below.
+#
+# CAVEAT -- same architecture only. setarch fixes ASLR within one host, but
+# the underlying ordering is keyed on raw pointer VALUES (clang's
+# WebAssemblyCFGStackify iterates DenseMap in
+# pointer-hash order). The x86_64 and arm64 wasi-sdk clang binaries hand out
+# different pointers, so the two arches still produce ~29 differing bytes (a
+# few reordered try_table blocks) even though every section is otherwise
+# identical. This is an upstream LLVM determinism bug, not something we can
+# pin from here; fixing it needs LLVM to sort those EH pads by block number.
+# The committed modules are the x86_64 build; CI records the expected
+# checksums for both architectures (shasums.x86_64 / shasums.arm64) and
+# accepts a match against either, so each runner verifies its own arch.
+#
+# To run the resulting module, the host runtime must enable the exceptions
+# proposal and grant filesystem access, e.g.:
+#
+# wasmtime run -W exceptions=y --dir . ./wasm2js_130.wasm input.wasm -o out.js
+#
+set -euo pipefail
+
+# Pin any timestamp a tool in the chain might embed. Nothing in the current
+# toolchain consumes this (the wasm format has no build-time field and we emit
+# no debug info), but exporting it keeps the build deterministic if a future
+# wasi-sdk or binaryen starts honoring it. Fixed constant, not "now".
+export SOURCE_DATE_EPOCH="${SOURCE_DATE_EPOCH:-1700000000}"
+
+# Resolve the directory this script lives in so it works from anywhere.
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+cd "$SCRIPT_DIR"
+
+# --- Versions (override via the environment) -------------------------------
+# wasi-sdk: full release version, e.g. 33.0. The GitHub release tag uses only
+# the major component (wasi-sdk-33) while the tarball uses the full version.
+WASI_SDK_VERSION="${WASI_SDK_VERSION:-34.0}"
+# binaryen: the numeric release, used both for the git tag (version_130) and
+# the output filename (wasm2js_130.wasm).
+BINARYEN_VERSION="${BINARYEN_VERSION:-130}"
+
+# Map the host's uname onto wasi-sdk's - asset naming.
+case "$(uname -s)" in
+Darwin) WASI_SDK_OS="macos" ;;
+Linux) WASI_SDK_OS="linux" ;;
+*)
+ echo "error: unsupported OS $(uname -s); set WASI_SDK to a prebuilt sdk" >&2
+ exit 1
+ ;;
+esac
+case "$(uname -m)" in
+x86_64 | amd64) WASI_SDK_ARCH="x86_64" ;;
+arm64 | aarch64) WASI_SDK_ARCH="arm64" ;;
+*)
+ echo "error: unsupported arch $(uname -m); set WASI_SDK to a prebuilt sdk" >&2
+ exit 1
+ ;;
+esac
+
+WASI_SDK_MAJOR="${WASI_SDK_VERSION%%.*}"
+WASI_SDK_DIRNAME="wasi-sdk-${WASI_SDK_VERSION}-${WASI_SDK_ARCH}-${WASI_SDK_OS}"
+WASI_SDK_URL="https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-${WASI_SDK_MAJOR}/${WASI_SDK_DIRNAME}.tar.gz"
+BINARYEN_TAG="version_${BINARYEN_VERSION}"
+BINARYEN_URL="https://github.com/WebAssembly/binaryen.git"
+
+# All build inputs live under ./var; outputs land in the script directory.
+VAR_DIR="$SCRIPT_DIR/var"
+
+WASI_SDK="${WASI_SDK:-$VAR_DIR/$WASI_SDK_DIRNAME}"
+BINARYEN_SRC="$VAR_DIR/binaryen"
+BUILD_DIR="$BINARYEN_SRC/build-wasi"
+
+TOOLCHAIN="$WASI_SDK/share/cmake/wasi-sdk-p1.cmake"
+# llvm-strip from the same pinned wasi-sdk; used to drop debug/name/producers.
+STRIP="$WASI_SDK/bin/llvm-strip"
+
+mkdir -p "$VAR_DIR"
+
+# --- Fetch wasi-sdk ---------------------------------------------------------
+if [[ ! -f "$TOOLCHAIN" ]]; then
+ echo ">> Downloading wasi-sdk $WASI_SDK_VERSION"
+ echo " $WASI_SDK_URL"
+ tmp_tarball="$(mktemp "$VAR_DIR/.wasi-sdk.XXXXXX.tar.gz")"
+ trap 'rm -f "$tmp_tarball"' EXIT
+ curl -fL --retry 3 -o "$tmp_tarball" "$WASI_SDK_URL"
+ # The tarball unpacks to $WASI_SDK_DIRNAME/ in the destination directory.
+ tar -xzf "$tmp_tarball" -C "$VAR_DIR"
+ rm -f "$tmp_tarball"
+ trap - EXIT
+fi
+
+if [[ ! -f "$TOOLCHAIN" ]]; then
+ echo "error: wasi-sdk toolchain not found at $TOOLCHAIN" >&2
+ echo " set WASI_SDK to the wasi-sdk install prefix" >&2
+ exit 1
+fi
+
+# --- Fetch binaryen ---------------------------------------------------------
+if [[ ! -d "$BINARYEN_SRC/.git" ]]; then
+ echo ">> Cloning binaryen at $BINARYEN_TAG"
+ git clone --depth 1 --branch "$BINARYEN_TAG" --recurse-submodules \
+ "$BINARYEN_URL" "$BINARYEN_SRC"
+fi
+
+# Flags that enable standardized wasm exception handling against wasi-sdk's
+# `eh` libc++ variant. Needed at both compile and link time.
+EH_COMPILE_FLAGS="-fwasm-exceptions -mllvm -wasm-use-legacy-eh=false"
+EH_LINK_FLAGS="-fwasm-exceptions -lunwind"
+
+# Reproducibility: strip the absolute checkout path out of every __FILE__ so the
+# module is identical regardless of where binaryen was cloned. BUILD_DIR lives
+# under BINARYEN_SRC, so generated-source paths are normalized by the same map.
+REPRO_COMPILE_FLAGS="-ffile-prefix-map=$BINARYEN_SRC=."
+
+# Reproducibility: clang's wasm driver silently runs `wasm-opt -O3` on the
+# linked module whenever an optimization level is set (Release => -O3) AND a
+# wasm-opt happens to be on PATH. That makes the bytes depend on whichever
+# wasm-opt is installed -- and an old one (e.g. the binaryen 108 that ships in
+# some distros) cannot even parse the exnref exception handling we emit, so the
+# link dies with "[parse exception: Bad type form -1] / error parsing wasm".
+# Disable the auto-pass so the module is purely the linker's deterministic
+# output (this is what the wasm-opt note in the header is really about).
+REPRO_LINK_FLAGS="--no-wasm-opt"
+
+# Reproducibility: start from a pristine build tree. cmake/ninja only rebuild
+# changed objects, so a stale tree from an earlier (differently-flagged) run
+# would silently contaminate the output. Wiping it guarantees the module is a
+# function of the current inputs alone.
+echo ">> Wiping build directory for a clean build"
+rm -rf "$BUILD_DIR"
+
+echo ">> Configuring (wasm32-wasip1, static, exceptions=exnref)"
+cmake -S "$BINARYEN_SRC" -B "$BUILD_DIR" -G Ninja \
+ -DCMAKE_TOOLCHAIN_FILE="$TOOLCHAIN" \
+ -DWASI_SDK_PREFIX="$WASI_SDK" \
+ -DCMAKE_BUILD_TYPE=Release \
+ -DBUILD_TESTS=OFF \
+ -DBUILD_SHARED_LIBS=OFF \
+ -DENABLE_WERROR=OFF \
+ -DINSTALL_LIBS=OFF \
+ -DCMAKE_C_FLAGS="$EH_COMPILE_FLAGS $REPRO_COMPILE_FLAGS" \
+ -DCMAKE_CXX_FLAGS="$EH_COMPILE_FLAGS $REPRO_COMPILE_FLAGS" \
+ -DCMAKE_EXE_LINKER_FLAGS="$EH_LINK_FLAGS $REPRO_LINK_FLAGS"
+
+# Tools to build, as binaryen CMake target names. Each is emitted to
+# ./_.wasm.
+TOOLS=(wasm2js wasm-opt)
+
+echo ">> Building: ${TOOLS[*]}"
+cmake --build "$BUILD_DIR" --target "${TOOLS[@]}"
+
+# Install the stripped linker output. We intentionally skip any wasm-opt pass:
+# optimizing here would make the result depend on whichever wasm-opt happens to
+# be installed, which is exactly the non-determinism we are eliminating. The
+# only post-processing is llvm-strip from the pinned wasi-sdk (see header note);
+# combined with the pinned toolchain and the -ffile-prefix-map applied above,
+# the module is reproducible.
+BUILD_BIN="$BUILD_DIR/bin"
+
+for tool in "${TOOLS[@]}"; do
+ out="./${tool}_${BINARYEN_VERSION}.wasm"
+ echo ">> Installing $tool -> $out (stripped)"
+ "$STRIP" --strip-all "$BUILD_BIN/$tool" -o "$out"
+ ls -l "$out" | awk '{print " size:", $5, "bytes"}'
+done
+
+echo ">> Done. Outputs in $SCRIPT_DIR:"
+ls -1 ./*_"${BINARYEN_VERSION}".wasm
diff --git a/utils/wasm/wasm2js/var/.gitignore b/utils/wasm/wasm2js/var/.gitignore
new file mode 100644
index 000000000..c96a04f00
--- /dev/null
+++ b/utils/wasm/wasm2js/var/.gitignore
@@ -0,0 +1,2 @@
+*
+!.gitignore
\ No newline at end of file
diff --git a/utils/wasm/wasm2js/wasm-opt_130.wasm b/utils/wasm/wasm2js/wasm-opt_130.wasm
new file mode 100755
index 000000000..33b5ad9a6
Binary files /dev/null and b/utils/wasm/wasm2js/wasm-opt_130.wasm differ
diff --git a/utils/wasm/wasm2js/wasm2js_130.wasm b/utils/wasm/wasm2js/wasm2js_130.wasm
new file mode 100755
index 000000000..1eb7772da
Binary files /dev/null and b/utils/wasm/wasm2js/wasm2js_130.wasm differ
diff --git a/wasm/anubis/Cargo.toml b/wasm/anubis/Cargo.toml
new file mode 100644
index 000000000..e55fb21dd
--- /dev/null
+++ b/wasm/anubis/Cargo.toml
@@ -0,0 +1,7 @@
+[package]
+name = "anubis"
+version = "0.1.0"
+edition = "2024"
+
+[dependencies]
+wee_alloc = "0.4"
diff --git a/wasm/anubis/src/lib.rs b/wasm/anubis/src/lib.rs
new file mode 100644
index 000000000..710492fb0
--- /dev/null
+++ b/wasm/anubis/src/lib.rs
@@ -0,0 +1,60 @@
+use std::sync::{LazyLock, Mutex};
+
+extern crate wee_alloc;
+
+#[global_allocator]
+static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT;
+
+#[cfg(target_arch = "wasm32")]
+mod hostimport {
+ use crate::{DATA_BUFFER, DATA_LENGTH};
+
+ #[link(wasm_import_module = "anubis")]
+ unsafe extern "C" {
+ /// The runtime expects this function to be defined. It is called whenever the Anubis check
+ /// worker processes about 1024 hashes. This can be a no-op if you want.
+ fn anubis_update_nonce(nonce: u32);
+ }
+
+ /// Safe wrapper to `anubis_update_nonce`.
+ pub fn update_nonce(nonce: u32) {
+ unsafe {
+ anubis_update_nonce(nonce);
+ }
+ }
+
+ #[unsafe(no_mangle)]
+ pub extern "C" fn data_ptr() -> *const u8 {
+ let challenge = &DATA_BUFFER;
+ challenge.as_ptr()
+ }
+
+ #[unsafe(no_mangle)]
+ pub extern "C" fn set_data_length(len: u32) {
+ let mut data_length = DATA_LENGTH.lock().unwrap();
+ *data_length = len as usize;
+ }
+}
+
+#[cfg(not(target_arch = "wasm32"))]
+mod hostimport {
+ pub fn update_nonce(_nonce: u32) {
+ // This is intentionally blank
+ }
+}
+
+/// The data buffer is a bit weird in that it doesn't have an explicit length as it can
+/// and will change depending on the challenge input that was sent by the server.
+/// However, it can only fit 4096 bytes of data (one amd64 machine page). This is
+/// slightly overkill for the purposes of an Anubis check, but it's fine to assume
+/// that the browser can afford this much ram usage.
+///
+/// Callers should fetch the base data pointer, write up to 4096 bytes, and then
+/// `set_data_length` the number of bytes they have written
+///
+/// This is also functionally a write-only buffer, so it doesn't really matter that
+/// the length of this buffer isn't exposed.
+pub static DATA_BUFFER: LazyLock<[u8; 4096]> = LazyLock::new(|| [0; 4096]);
+pub static DATA_LENGTH: LazyLock> = LazyLock::new(|| Mutex::new(0));
+
+pub use hostimport::update_nonce;
diff --git a/wasm/pow/argon2id/Cargo.toml b/wasm/pow/argon2id/Cargo.toml
new file mode 100644
index 000000000..b96b29335
--- /dev/null
+++ b/wasm/pow/argon2id/Cargo.toml
@@ -0,0 +1,21 @@
+[package]
+name = "argon2id"
+version = "0.1.0"
+edition = "2024"
+
+[lib]
+crate-type = ["cdylib"]
+
+[dependencies]
+argon2 = "0.5"
+
+anubis = { path = "../../anubis" }
+
+[lints.clippy]
+nursery = { level = "warn", priority = -1 }
+pedantic = { level = "warn", priority = -1 }
+unwrap_used = "warn"
+uninlined_format_args = "allow"
+missing_panics_doc = "allow"
+missing_errors_doc = "allow"
+cognitive_complexity = "allow"
diff --git a/wasm/pow/argon2id/src/lib.rs b/wasm/pow/argon2id/src/lib.rs
new file mode 100644
index 000000000..4eb6e9c22
--- /dev/null
+++ b/wasm/pow/argon2id/src/lib.rs
@@ -0,0 +1,171 @@
+use anubis::{DATA_BUFFER, DATA_LENGTH, update_nonce};
+use argon2::Argon2;
+use std::sync::{LazyLock, Mutex};
+
+/// Argon2id result and verification hashes are 32 bytes (256 bits). These are stored in
+/// static buffers due to the fact that you cannot easily pass data from host space to
+/// WebAssembly space.
+pub static RESULT_HASH: LazyLock> = LazyLock::new(|| Mutex::new([0; 32]));
+
+pub static VERIFICATION_HASH: LazyLock> = LazyLock::new(|| Mutex::new([0; 32]));
+
+/// Core validation function. Compare each bit in the hash by progressively masking bits until
+/// some are found to not be matching.
+///
+/// There are probably more clever ways to do this, likely involving lookup tables or something
+/// really fun like that. However in my testing this lets us get up to 200 kilohashes per second
+/// on my Ryzen 7950x3D, up from about 50 kilohashes per second in JavaScript.
+fn validate(hash: &[u8], difficulty: u32) -> bool {
+ let mut remaining = difficulty;
+ for &byte in hash {
+ // If we're out of bits to check, exit. This is all good.
+ if remaining == 0 {
+ break;
+ }
+
+ // If there are more than 8 bits remaining, the entire byte should be a
+ // zero. This fast-path compares the byte to 0 and if it matches, subtract
+ // 8 bits.
+ if remaining >= 8 {
+ if byte != 0 {
+ return false;
+ }
+ remaining -= 8;
+ } else {
+ // Otherwise mask off individual bits and check against them.
+ let mask = 0xFF << (8 - remaining);
+ if (byte & mask) != 0 {
+ return false;
+ }
+ remaining = 0;
+ }
+ }
+ true
+}
+
+/// Computes the Argon2id hash for the given nonce.
+///
+/// The challenge bytes are used directly as the Argon2id password and the nonce is used as
+/// the salt, rather than hashing a hex-formatted `challenge || nonce` string as the
+/// JavaScript implementation does. Operating on the raw challenge bytes avoids the
+/// formatting round-trip.
+///
+/// The nonce is also randomly encoded in either big or little endian depending on the last
+/// byte of the data buffer in an effort to make it more annoying to automate with GPUs.
+fn compute_hash(nonce: u32) -> [u8; 32] {
+ let data = &DATA_BUFFER;
+ let data_len = *DATA_LENGTH.lock().unwrap();
+ let data_slice: &[u8] = &data[..data_len];
+ let use_le = data_slice.last().copied().unwrap_or(0) >= 128;
+ let mut result = [0u8; 32];
+
+ let nonce = nonce as u64;
+
+ let nonce = if use_le {
+ nonce.to_le_bytes()
+ } else {
+ nonce.to_be_bytes()
+ };
+
+ let argon2 = Argon2::default();
+ argon2
+ .hash_password_into(&data_slice, &nonce, &mut result)
+ .unwrap();
+ result
+}
+
+/// This function is the main entrypoint for the Anubis proof of work implementation.
+///
+/// This expects `DATA_BUFFER` to be prepopulated with the challenge value as "raw bytes".
+/// The definition of what goes in the data buffer is an exercise for the implementor, but
+/// for Argon2id we store the challenge as "raw bytes". The data buffer is intentionally
+/// oversized so that the challenge value can be expanded in the future.
+///
+/// `difficulty` is the number of leading bits that must match `0` in order for the
+/// challenge to be successfully passed. This will be validated by the server.
+///
+/// `initial_nonce` is the initial value of the nonce (number used once). This nonce will be
+/// appended to the challenge value in order to find a hash matching the specified
+/// difficulty.
+///
+/// `iterand` (noun form of iterate) is the amount that the nonce should be increased by
+/// every iteration of the proof of work loop. This will vary by how many threads are
+/// running the proof-of-work check, and also functions as a thread ID. This prevents
+/// wasting CPU time retrying a hash+nonce pair that likely won't work.
+#[unsafe(no_mangle)]
+pub extern "C" fn anubis_work(difficulty: u32, initial_nonce: u32, iterand: u32) -> u32 {
+ let mut nonce = initial_nonce;
+
+ loop {
+ let hash = compute_hash(nonce);
+
+ if validate(&hash, difficulty) {
+ // If the challenge worked, copy the bytes into `RESULT_HASH` so the runtime
+ // can pick it up.
+ let mut result = RESULT_HASH.lock().unwrap();
+ result.copy_from_slice(&hash);
+ return nonce;
+ }
+
+ let old_nonce = nonce;
+ nonce = nonce.wrapping_add(iterand);
+
+ // Report progress each time the search crosses into a new 1024-value window of the
+ // nonce space, i.e. roughly every 1024 nonce values. The reported nonce approximates
+ // the total work done across all worker threads, which the client turns into a
+ // progress bar and hashrate. Comparing the shifted window (rather than the original
+ // `nonce > old_nonce + 1023`) reports correctly for any `iterand` and stays correct
+ // across the u32 wraparound. (Only the nonce-0 worker's callback is live; the caller
+ // silences the rest.)
+ if (nonce >> 10) != (old_nonce >> 10) {
+ update_nonce(nonce);
+ }
+ }
+}
+
+/// This function is called by the server in order to validate a proof-of-work challenge.
+/// This expects `DATA_BUFFER` to be set to the challenge value and `VERIFICATION_HASH` to
+/// be set to the "raw bytes" of the SHA-256 hash that the client calculated.
+///
+/// If everything is good, it returns true. Otherwise, it returns false.
+///
+/// XXX(Xe): this could probably return an error code for what step fails, but this is fine
+/// for now.
+#[unsafe(no_mangle)]
+pub extern "C" fn anubis_validate(nonce: u32, difficulty: u32) -> bool {
+ let computed = compute_hash(nonce);
+ let valid = validate(&computed, difficulty);
+ if !valid {
+ return false;
+ }
+
+ let verification = VERIFICATION_HASH.lock().unwrap();
+ computed == *verification
+}
+
+// These functions exist to give pointers and lengths to the runtime around the Anubis
+// checks, this allows JavaScript and Go to safely manipulate the memory layout that Rust
+// has statically allocated at compile time without having to assume how the Rust compiler
+// is going to lay it out.
+
+#[unsafe(no_mangle)]
+pub extern "C" fn result_hash_ptr() -> *const u8 {
+ let challenge = RESULT_HASH.lock().unwrap();
+ challenge.as_ptr()
+}
+
+#[unsafe(no_mangle)]
+pub extern "C" fn result_hash_size() -> usize {
+ RESULT_HASH.lock().unwrap().len()
+}
+
+#[unsafe(no_mangle)]
+pub extern "C" fn verification_hash_ptr() -> *const u8 {
+ let verification = VERIFICATION_HASH.lock().unwrap();
+ verification.as_ptr()
+}
+
+#[unsafe(no_mangle)]
+pub extern "C" fn verification_hash_size() -> usize {
+ VERIFICATION_HASH.lock().unwrap().len()
+}
diff --git a/wasm/pow/hashx/Cargo.toml b/wasm/pow/hashx/Cargo.toml
new file mode 100644
index 000000000..4459c38a9
--- /dev/null
+++ b/wasm/pow/hashx/Cargo.toml
@@ -0,0 +1,22 @@
+[package]
+name = "hashx"
+version = "0.1.0"
+edition = "2024"
+
+[lib]
+crate-type = ["cdylib"]
+
+[dependencies]
+equix = "0.6"
+hashx = "0.8"
+
+anubis = { path = "../../anubis" }
+
+[lints.clippy]
+nursery = { level = "warn", priority = -1 }
+pedantic = { level = "warn", priority = -1 }
+unwrap_used = "warn"
+uninlined_format_args = "allow"
+missing_panics_doc = "allow"
+missing_errors_doc = "allow"
+cognitive_complexity = "allow"
diff --git a/wasm/pow/hashx/src/lib.rs b/wasm/pow/hashx/src/lib.rs
new file mode 100644
index 000000000..a256e4f9a
--- /dev/null
+++ b/wasm/pow/hashx/src/lib.rs
@@ -0,0 +1,172 @@
+use anubis::{DATA_BUFFER, DATA_LENGTH, update_nonce};
+use hashx::HashX;
+use std::sync::{LazyLock, Mutex};
+
+/// HashX result and verification hashes are 32 bytes (256 bits). These are stored in static
+/// buffers due to the fact that you cannot easily pass data from host space to WebAssembly
+/// space.
+pub static RESULT_HASH: LazyLock> = LazyLock::new(|| Mutex::new([0; 32]));
+
+pub static VERIFICATION_HASH: LazyLock> = LazyLock::new(|| Mutex::new([0; 32]));
+
+/// Core validation function. Compare each bit in the hash by progressively masking bits until
+/// some are found to not be matching.
+///
+/// There are probably more clever ways to do this, likely involving lookup tables or something
+/// really fun like that. However in my testing this lets us get up to 200 kilohashes per second
+/// on my Ryzen 7950x3D, up from about 50 kilohashes per second in JavaScript.
+fn validate(hash: &[u8], difficulty: u32) -> bool {
+ let mut remaining = difficulty;
+ for &byte in hash {
+ // If we're out of bits to check, exit. This is all good.
+ if remaining == 0 {
+ break;
+ }
+
+ // If there are more than 8 bits remaining, the entire byte should be a
+ // zero. This fast-path compares the byte to 0 and if it matches, subtract
+ // 8 bits.
+ if remaining >= 8 {
+ if byte != 0 {
+ return false;
+ }
+ remaining -= 8;
+ } else {
+ // Otherwise mask off individual bits and check against them.
+ let mask = 0xFF << (8 - remaining);
+ if (byte & mask) != 0 {
+ return false;
+ }
+ remaining = 0;
+ }
+ }
+ true
+}
+
+/// Build a `HashX` instance for the current challenge.
+///
+/// HashX rejects a small fraction of seeds (`Error::ProgramConstraints`). Rather than trap
+/// on those, derive a usable instance deterministically: the overwhelmingly common case is
+/// that the challenge bytes are accepted as-is, but if they are rejected we append an
+/// incrementing little-endian counter to the challenge until `HashX::new` accepts the seed.
+/// The client and server run this identical code, so they always agree on which seed — and
+/// therefore which program — is in use. The probability of needing even one retry is well
+/// under a percent, so the common path is unchanged on the wire.
+fn make_hashx() -> HashX {
+ let data = &DATA_BUFFER;
+ let data_len = *DATA_LENGTH.lock().unwrap();
+ let data_slice: &[u8] = &data[..data_len];
+
+ if let Ok(h) = HashX::new(data_slice) {
+ return h;
+ }
+
+ let mut seed = Vec::with_capacity(data_slice.len() + 4);
+ let mut counter: u32 = 0;
+ loop {
+ seed.clear();
+ seed.extend_from_slice(data_slice);
+ seed.extend_from_slice(&counter.to_le_bytes());
+ if let Ok(h) = HashX::new(&seed) {
+ return h;
+ }
+ counter = counter.wrapping_add(1);
+ }
+}
+
+/// This function is the main entrypoint for the Anubis proof of work implementation.
+///
+/// This expects `DATA_BUFFER` to be prepopulated with the challenge value as "raw bytes".
+/// The definition of what goes in the data buffer is an exercise for the implementor, but
+/// for SHA-256 we store the hash as "raw bytes". The data buffer is intentionally oversized
+/// so that the challenge value can be expanded in the future.
+///
+/// `difficulty` is the number of leading bits that must match `0` in order for the
+/// challenge to be successfully passed. This will be validated by the server.
+///
+/// `initial_nonce` is the initial value of the nonce (number used once). This nonce will be
+/// appended to the challenge value in order to find a hash matching the specified
+/// difficulty.
+///
+/// `iterand` (noun form of iterate) is the amount that the nonce should be increased by
+/// every iteration of the proof of work loop. This will vary by how many threads are
+/// running the proof-of-work check, and also functions as a thread ID. This prevents
+/// wasting CPU time retrying a hash+nonce pair that likely won't work.
+#[unsafe(no_mangle)]
+pub extern "C" fn anubis_work(difficulty: u32, initial_nonce: u32, iterand: u32) -> u32 {
+ let h = make_hashx();
+ let mut nonce: u32 = initial_nonce;
+
+ loop {
+ let hash = h.hash_to_bytes(nonce as u64);
+
+ if validate(&hash, difficulty) {
+ // If the challenge worked, copy the bytes into `RESULT_HASH` so the runtime
+ // can pick it up.
+ let mut result = RESULT_HASH.lock().unwrap();
+ result.copy_from_slice(&hash);
+ return nonce;
+ }
+
+ let old_nonce = nonce;
+ nonce = nonce.wrapping_add(iterand);
+
+ // Report progress each time the search crosses into a new 1024-value window of the
+ // nonce space, i.e. roughly every 1024 nonce values. The reported nonce approximates
+ // the total work done across all worker threads, which the client turns into a
+ // progress bar and hashrate. Comparing the shifted window reports correctly for any
+ // `iterand` and stays correct across the u32 wraparound. (Only the nonce-0 worker's
+ // callback is live; the caller silences the rest.)
+ if (nonce >> 10) != (old_nonce >> 10) {
+ update_nonce(nonce);
+ }
+ }
+}
+
+/// This function is called by the server in order to validate a proof-of-work challenge.
+/// This expects `DATA_BUFFER` to be set to the challenge value and `VERIFICATION_HASH` to
+/// be set to the "raw bytes" of the SHA-256 hash that the client calculated.
+///
+/// If everything is good, it returns true. Otherwise, it returns false.
+///
+/// XXX(Xe): this could probably return an error code for what step fails, but this is fine
+/// for now.
+#[unsafe(no_mangle)]
+pub extern "C" fn anubis_validate(nonce: u32, difficulty: u32) -> bool {
+ let h = make_hashx();
+ let computed = h.hash_to_bytes(nonce as u64);
+ let valid = validate(&computed, difficulty);
+ if !valid {
+ return false;
+ }
+
+ let verification = VERIFICATION_HASH.lock().unwrap();
+ computed == *verification
+}
+
+// These functions exist to give pointers and lengths to the runtime around the Anubis
+// checks, this allows JavaScript and Go to safely manipulate the memory layout that Rust
+// has statically allocated at compile time without having to assume how the Rust compiler
+// is going to lay it out.
+
+#[unsafe(no_mangle)]
+pub extern "C" fn result_hash_ptr() -> *const u8 {
+ let challenge = RESULT_HASH.lock().unwrap();
+ challenge.as_ptr()
+}
+
+#[unsafe(no_mangle)]
+pub extern "C" fn result_hash_size() -> usize {
+ RESULT_HASH.lock().unwrap().len()
+}
+
+#[unsafe(no_mangle)]
+pub extern "C" fn verification_hash_ptr() -> *const u8 {
+ let verification = VERIFICATION_HASH.lock().unwrap();
+ verification.as_ptr()
+}
+
+#[unsafe(no_mangle)]
+pub extern "C" fn verification_hash_size() -> usize {
+ VERIFICATION_HASH.lock().unwrap().len()
+}
diff --git a/wasm/pow/sha256/Cargo.toml b/wasm/pow/sha256/Cargo.toml
new file mode 100644
index 000000000..fcc8d18a3
--- /dev/null
+++ b/wasm/pow/sha256/Cargo.toml
@@ -0,0 +1,21 @@
+[package]
+name = "sha256"
+version = "0.1.0"
+edition = "2024"
+
+[lib]
+crate-type = ["cdylib"]
+
+[dependencies]
+sha2 = "0.11"
+
+anubis = { path = "../../anubis" }
+
+[lints.clippy]
+nursery = { level = "warn", priority = -1 }
+pedantic = { level = "warn", priority = -1 }
+unwrap_used = "warn"
+uninlined_format_args = "allow"
+missing_panics_doc = "allow"
+missing_errors_doc = "allow"
+cognitive_complexity = "allow"
diff --git a/wasm/pow/sha256/src/lib.rs b/wasm/pow/sha256/src/lib.rs
new file mode 100644
index 000000000..e123140d7
--- /dev/null
+++ b/wasm/pow/sha256/src/lib.rs
@@ -0,0 +1,168 @@
+use anubis::{DATA_BUFFER, DATA_LENGTH, update_nonce};
+use sha2::{Digest, Sha256};
+use std::sync::{LazyLock, Mutex};
+
+/// SHA-256 hashes are 32 bytes (256 bits). These are stored in static buffers due to the
+/// fact that you cannot easily pass data from host space to WebAssembly space.
+pub static RESULT_HASH: LazyLock> = LazyLock::new(|| Mutex::new([0; 32]));
+
+pub static VERIFICATION_HASH: LazyLock> = LazyLock::new(|| Mutex::new([0; 32]));
+
+/// Core validation function. Compare each bit in the hash by progressively masking bits until
+/// some are found to not be matching.
+///
+/// There are probably more clever ways to do this, likely involving lookup tables or something
+/// really fun like that. However in my testing this lets us get up to 200 kilohashes per second
+/// on my Ryzen 7950x3D, up from about 50 kilohashes per second in JavaScript.
+fn validate(hash: &[u8], difficulty: u32) -> bool {
+ let mut remaining = difficulty;
+ for &byte in hash {
+ // If we're out of bits to check, exit. This is all good.
+ if remaining == 0 {
+ break;
+ }
+
+ // If there are more than 8 bits remaining, the entire byte should be a
+ // zero. This fast-path compares the byte to 0 and if it matches, subtract
+ // 8 bits.
+ if remaining >= 8 {
+ if byte != 0 {
+ return false;
+ }
+ remaining -= 8;
+ } else {
+ // Otherwise mask off individual bits and check against them.
+ let mask = 0xFF << (8 - remaining);
+ if (byte & mask) != 0 {
+ return false;
+ }
+ remaining = 0;
+ }
+ }
+ true
+}
+
+/// Computes hash for given nonce.
+///
+/// This differs from the JavaScript implementations by constructing the hash differently. In
+/// JavaScript implementations, the SHA-256 input is the result of appending the nonce as an
+/// integer to the hex-formatted challenge, eg:
+///
+/// sha256(`${challenge}${nonce}`);
+///
+/// This **does work**, however I think that this can be done a bit better by operating on the
+/// challenge bytes _directly_ and treating the nonce as a salt.
+///
+/// The nonce is also randomly encoded in either big or little endian depending on the last
+/// byte of the data buffer in an effort to make it more annoying to automate with GPUs.
+fn compute_hash(nonce: u32) -> [u8; 32] {
+ let data = &DATA_BUFFER;
+ let data_len = *DATA_LENGTH.lock().unwrap();
+ let data_slice: &[u8] = &data[..data_len];
+ let use_le = data_slice.last().copied().unwrap_or(0) >= 128;
+
+ let mut hasher = Sha256::new();
+ hasher.update(data_slice);
+ hasher.update(if use_le {
+ nonce.to_le_bytes()
+ } else {
+ nonce.to_be_bytes()
+ });
+ hasher.finalize().into()
+}
+
+/// This function is the main entrypoint for the Anubis proof of work implementation.
+///
+/// This expects `DATA_BUFFER` to be prepopulated with the challenge value as "raw bytes".
+/// The definition of what goes in the data buffer is an exercise for the implementor, but
+/// for SHA-256 we store the hash as "raw bytes". The data buffer is intentionally oversized
+/// so that the challenge value can be expanded in the future.
+///
+/// `difficulty` is the number of leading bits that must match `0` in order for the
+/// challenge to be successfully passed. This will be validated by the server.
+///
+/// `initial_nonce` is the initial value of the nonce (number used once). This nonce will be
+/// appended to the challenge value in order to find a hash matching the specified
+/// difficulty.
+///
+/// `iterand` (noun form of iterate) is the amount that the nonce should be increased by
+/// every iteration of the proof of work loop. This will vary by how many threads are
+/// running the proof-of-work check, and also functions as a thread ID. This prevents
+/// wasting CPU time retrying a hash+nonce pair that likely won't work.
+#[unsafe(no_mangle)]
+pub extern "C" fn anubis_work(difficulty: u32, initial_nonce: u32, iterand: u32) -> u32 {
+ let mut nonce = initial_nonce;
+
+ loop {
+ let hash = compute_hash(nonce);
+
+ if validate(&hash, difficulty) {
+ // If the challenge worked, copy the bytes into `RESULT_HASH` so the runtime
+ // can pick it up.
+ let mut result = RESULT_HASH.lock().unwrap();
+ result.copy_from_slice(&hash);
+ return nonce;
+ }
+
+ let old_nonce = nonce;
+ nonce = nonce.wrapping_add(iterand);
+
+ // Report progress each time the search crosses into a new 1024-value window of the
+ // nonce space, i.e. roughly every 1024 nonce values. The reported nonce approximates
+ // the total work done across all worker threads, which the client turns into a
+ // progress bar and hashrate. Comparing the shifted window (rather than the original
+ // `nonce > old_nonce + 1023`) reports correctly for any `iterand` and stays correct
+ // across the u32 wraparound. (Only the nonce-0 worker's callback is live; the caller
+ // silences the rest.)
+ if (nonce >> 10) != (old_nonce >> 10) {
+ update_nonce(nonce);
+ }
+ }
+}
+
+/// This function is called by the server in order to validate a proof-of-work challenge.
+/// This expects `DATA_BUFFER` to be set to the challenge value and `VERIFICATION_HASH` to
+/// be set to the "raw bytes" of the SHA-256 hash that the client calculated.
+///
+/// If everything is good, it returns true. Otherwise, it returns false.
+///
+/// XXX(Xe): this could probably return an error code for what step fails, but this is fine
+/// for now.
+#[unsafe(no_mangle)]
+pub extern "C" fn anubis_validate(nonce: u32, difficulty: u32) -> bool {
+ let computed = compute_hash(nonce);
+ let valid = validate(&computed, difficulty);
+ if !valid {
+ return false;
+ }
+
+ let verification = VERIFICATION_HASH.lock().unwrap();
+ computed == *verification
+}
+
+// These functions exist to give pointers and lengths to the runtime around the Anubis
+// checks, this allows JavaScript and Go to safely manipulate the memory layout that Rust
+// has statically allocated at compile time without having to assume how the Rust compiler
+// is going to lay it out.
+
+#[unsafe(no_mangle)]
+pub extern "C" fn result_hash_ptr() -> *const u8 {
+ let challenge = RESULT_HASH.lock().unwrap();
+ challenge.as_ptr()
+}
+
+#[unsafe(no_mangle)]
+pub extern "C" fn result_hash_size() -> usize {
+ RESULT_HASH.lock().unwrap().len()
+}
+
+#[unsafe(no_mangle)]
+pub extern "C" fn verification_hash_ptr() -> *const u8 {
+ let verification = VERIFICATION_HASH.lock().unwrap();
+ verification.as_ptr()
+}
+
+#[unsafe(no_mangle)]
+pub extern "C" fn verification_hash_size() -> usize {
+ VERIFICATION_HASH.lock().unwrap().len()
+}
diff --git a/wasm/scripts/build_wasm.sh b/wasm/scripts/build_wasm.sh
new file mode 100755
index 000000000..fac6037b7
--- /dev/null
+++ b/wasm/scripts/build_wasm.sh
@@ -0,0 +1,114 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+shopt -s nullglob
+
+source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
+
+src_dirs=(./wasm/pow ./wasm/anubis)
+dst_dirs=(./web/static/wasm/simd128 ./web/static/wasm/baseline)
+
+# rustc enables reference-types, multivalue and friends by default for
+# wasm32-unknown-unknown. With reference-types on, LLVM encodes the call_indirect
+# table index as an overlong LEB128 (80 80 80 80 00) rather than the MVP's single
+# reserved 0x00 byte. Engines predating the proposal read one byte, see 0x80, and
+# refuse to compile the module:
+#
+# CompileError: expected table index 0, found 128
+#
+# Clearing the features with -Ctarget-feature is not enough, because the shipped
+# std rlibs are already encoded that way; fixing it at the source would mean
+# rebuilding std on nightly. Instead each module is round-tripped through wasm-opt
+# against the feature set that its oldest supported browser actually implements.
+# That re-emits the MVP call_indirect encoding, and makes wasm-opt reject the
+# module outright if it ever picks up a feature not on the list, so a toolchain
+# upgrade becomes a loud build failure rather than a browser that cannot start.
+#
+# Keep this a pure decode/re-encode with no optimization passes: the wasm build of
+# wasm-opt exhausts its stack on -O2, and we would rather not have an optimizer
+# rewriting proof-of-work code.
+#
+# Chrome shipped sign-ext and mutable-globals in 74, bulk-memory and
+# nontrapping-fptoint in 75, multivalue in 85 and simd in 91.
+baseline_features="-mvp --enable-sign-ext --enable-mutable-globals --enable-bulk-memory --enable-nontrapping-float-to-int"
+simd128_features="${baseline_features} --enable-multivalue --enable-simd"
+
+run_wasm_opt() {
+ if command -v wasm-opt 2>&1 >/dev/null; then
+ wasm-opt "$@"
+ elif command -v wasmtime 2>&1 >/dev/null; then
+ wasmtime run -W exceptions=y --dir . ./utils/wasm/wasm2js/wasm-opt_130.wasm "$@"
+ else
+ go run ./utils/cmd/wazero-exec ./utils/wasm/wasm2js/wasm-opt_130.wasm "$@"
+ fi
+}
+
+copy_modules() {
+ local src="${1}" dst="${2}"
+
+ local files=("${src}"/*.wasm)
+ if [ "${#files[@]}" -eq 0 ]; then
+ echo "cargo built no modules in ${src}" >&2
+ exit 1
+ fi
+
+ cp -vf "${files[@]}" "${dst}"
+}
+
+# reencode DIR FEATURE_FLAGS... rewrites every module in DIR against FEATURE_FLAGS.
+reencode() {
+ local dir="${1}"
+ shift
+
+ local files=("${dir}"/*.wasm)
+ if [ "${#files[@]}" -eq 0 ]; then
+ echo "no modules to re-encode in ${dir}" >&2
+ exit 1
+ fi
+
+ local out err
+ for fname in "${files[@]}"; do
+ out="$(mktemp "${fname}.XXXXXX")"
+ err="$(mktemp)"
+ # stderr is held back because wasm-opt warns on every module that no passes
+ # were specified.
+ if ! run_wasm_opt "$@" "${fname}" -o "${out}" 2>"${err}"; then
+ cat "${err}" >&2
+ rm -f "${out}" "${err}"
+ echo "wasm-opt rejected ${fname}" >&2
+ exit 1
+ fi
+ rm -f "${err}"
+ mv -f "${out}" "${fname}"
+ done
+}
+
+# Newest source file timestamp (unix seconds).
+newest_src="$(mtimes "${src_dirs[@]}" -type f | sort -n | tail -1)"
+
+# Oldest destination file timestamp (unix seconds). Empty if no outputs exist yet
+# (e.g. the output dirs haven't been created, in which case find would error out).
+oldest_dst="$(mtimes "${dst_dirs[@]}" -type f -name '*.wasm' 2>/dev/null | sort -n | head -1 || true)"
+
+if all_populated '*.wasm' "${dst_dirs[@]}" && [ -n "$oldest_dst" ] && awk "BEGIN { exit !($newest_src <= $oldest_dst) }"; then
+ echo "wasm artifacts are up to date, skipping build"
+ exit 0
+fi
+
+mkdir -p ./web/static/wasm/{simd128,baseline}
+
+cargo clean --quiet
+
+# With simd128
+RUSTFLAGS='-C target-feature=+simd128' cargo build --quiet --release --target wasm32-unknown-unknown
+copy_modules ./target/wasm32-unknown-unknown/release ./web/static/wasm/simd128
+reencode ./web/static/wasm/simd128 ${simd128_features}
+
+cargo clean --quiet
+
+# Without simd128
+cargo build --quiet --release --target wasm32-unknown-unknown
+copy_modules ./target/wasm32-unknown-unknown/release ./web/static/wasm/baseline
+reencode ./web/static/wasm/baseline ${baseline_features}
+
+cargo clean --quiet
diff --git a/wasm/scripts/build_wasm2js.sh b/wasm/scripts/build_wasm2js.sh
new file mode 100755
index 000000000..3236b0ea4
--- /dev/null
+++ b/wasm/scripts/build_wasm2js.sh
@@ -0,0 +1,55 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+shopt -s nullglob
+
+source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
+
+WASM2JS_VERSION="130"
+WASM2JS_FLAGS="-all --strip-debug --rse --rereloop --optimize-for-js --flatten --dce --dfo --fpcast-emu --denan --dealign --remove-imports --remove-unused-names --remove-unused-brs --reorder-functions --reorder-locals --strip-target-features --untee --vacuum -s 4 -ffm -lmu -tnh -iit -n"
+src_dirs=(./web/static/wasm/baseline)
+dst_dirs=(./web/js/gen/wasm2js)
+
+# Newest source file timestamp (unix seconds).
+newest_src="$(mtimes "${src_dirs[@]}" -type f | sort -n | tail -1)"
+
+# Oldest destination file timestamp (unix seconds). Empty if no outputs exist yet
+# (e.g. the output dirs haven't been created, in which case find would error out).
+oldest_dst="$(mtimes "${dst_dirs[@]}" -type f -name '*.wasm.js' 2>/dev/null | sort -n | head -1 || true)"
+
+if all_populated '*.wasm.js' "${dst_dirs[@]}" && [ -n "$oldest_dst" ] && awk "BEGIN { exit !($newest_src <= $oldest_dst) }"; then
+ echo "wasm2js artifacts are up to date, skipping build"
+ exit 0
+fi
+
+run_wasm2js() {
+ if command -v wasm2js 2>&1 >/dev/null; then
+ echo ">> wasm2js (sys) ${*}"
+ wasm2js $WASM2JS_FLAGS $*
+ elif command -v wasmtime 2>&1 >/dev/null; then
+ echo ">> wasm2js (wasmtime) ${*}"
+ wasmtime run -W exceptions=y --dir . ./utils/wasm/wasm2js/wasm2js_130.wasm $WASM2JS_FLAGS $*
+ else
+ echo ">> wasm2js (wazero-exec, slow) ${*}"
+ go run ./utils/cmd/wazero-exec ./utils/wasm/wasm2js/wasm2js_130.wasm $WASM2JS_FLAGS $*
+ fi
+}
+
+mkdir -p ./web/js/gen/wasm2js
+
+srcs=(./web/static/wasm/baseline/*.wasm)
+if [ "${#srcs[@]}" -eq 0 ]; then
+ echo "no modules in ./web/static/wasm/baseline, run wasm/scripts/build_wasm.sh first" >&2
+ exit 1
+fi
+
+for fname in "${srcs[@]}"; do
+ output="./web/js/gen/wasm2js/$(basename "${fname}").js"
+ run_wasm2js "${fname}" -o "${output}"
+ # wasm2js emits an import of the anubis host module on line 1; replace it with
+ # a stub. Rewriting through a temp file rather than sed -i because BSD sed
+ # reads the next argument as a mandatory backup suffix and GNU sed does not.
+ tmp="$(mktemp)"
+ sed '1s$.*$const anubis = { anubis_update_nonce: (_ignored) => { } };$' "${output}" >"${tmp}"
+ mv -f "${tmp}" "${output}"
+done
diff --git a/wasm/scripts/lib.sh b/wasm/scripts/lib.sh
new file mode 100644
index 000000000..a14d46e2f
--- /dev/null
+++ b/wasm/scripts/lib.sh
@@ -0,0 +1,31 @@
+# shellcheck shell=bash
+
+# BSD `find` has no `-printf` flag, so you have to get file timestamps from
+# `stat`. Beautifully, both `stat` implementations differ in how these
+# timestamps are extracted. Figure out which flag you need with a small test
+# because people can run BSD coreutils (or busybox?).
+if stat -f '%m' . >/dev/null 2>&1; then
+ stat_mtime_args=(-f '%m')
+else
+ stat_mtime_args=(-c '%Y')
+fi
+
+# Implements the subset of `find -printf` that's actually needed here.
+mtimes() {
+ find "$@" -exec stat "${stat_mtime_args[@]}" {} +
+}
+
+# all_populated PATTERN DIR... succeeds when every DIR holds at least one file
+# matching PATTERN. Timestamps alone can't answer "are the artifacts there?":
+# a run that dies partway through leaves one output directory filled and the
+# next one empty, and every source file is older than whatever that run managed
+# to write. Check that the files exist before believing the clock.
+all_populated() {
+ local pattern="${1}"
+ shift
+
+ local dir
+ for dir in "$@"; do
+ compgen -G "${dir}/${pattern}" >/dev/null || return 1
+ done
+}
diff --git a/wasm/wasm.go b/wasm/wasm.go
new file mode 100644
index 000000000..8526c1936
--- /dev/null
+++ b/wasm/wasm.go
@@ -0,0 +1,317 @@
+package wasm
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "math"
+ "strconv"
+ "time"
+
+ "github.com/prometheus/client_golang/prometheus"
+ "github.com/prometheus/client_golang/prometheus/promauto"
+ "github.com/tetratelabs/wazero"
+ "github.com/tetratelabs/wazero/api"
+)
+
+var (
+ validationTime = promauto.NewHistogramVec(prometheus.HistogramOpts{
+ Name: "anubis_wasm_validation_time",
+ Help: "The time taken for the validation function to run per checker (nanoseconds)",
+ Buckets: prometheus.ExponentialBucketsRange(1, math.Pow(2, 31), 32),
+ }, []string{"fname"})
+
+ validationCount = promauto.NewCounterVec(prometheus.CounterOpts{
+ Name: "anubis_wasm_validation",
+ Help: "The number of times the validation logic has been run and its success rate",
+ }, []string{"fname", "success"})
+
+ ErrNotEnoughData = errors.New("wasm: not enough data being written")
+ ErrTooMuchData = errors.New("wasm: too much data being written")
+)
+
+const pageSize = 65536
+
+type Runner struct {
+ r wazero.Runtime
+ code wazero.CompiledModule
+ fname string
+}
+
+func NewRunner(ctx context.Context, fname string, fin io.ReadCloser) (*Runner, error) {
+ data, err := io.ReadAll(fin)
+ if err != nil {
+ return nil, fmt.Errorf("wasm: can't read from fin: %w", err)
+ }
+
+ cfg := wazero.NewRuntimeConfig().WithMemoryLimitPages(512)
+
+ r := wazero.NewRuntimeWithConfig(ctx, cfg)
+
+ _, err = r.NewHostModuleBuilder("anubis").
+ NewFunctionBuilder().
+ WithFunc(func(context.Context, uint32) {}).
+ Export("anubis_update_nonce").
+ Instantiate(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("wasm: can't export anubis_update_nonce: %w", err)
+ }
+
+ code, err := r.CompileModule(ctx, data)
+ if err != nil {
+ return nil, fmt.Errorf("wasm: can't compile module: %w", err)
+ }
+
+ result := &Runner{
+ r: r,
+ code: code,
+ fname: fname,
+ }
+
+ return result, nil
+}
+
+func (r *Runner) checkExports(module api.Module) error {
+ funcs := []string{
+ "anubis_work",
+ "anubis_validate",
+ "data_ptr",
+ "set_data_length",
+ "result_hash_ptr",
+ "result_hash_size",
+ "verification_hash_ptr",
+ "verification_hash_size",
+ }
+
+ var errs []error
+
+ for _, fun := range funcs {
+ if module.ExportedFunction(fun) == nil {
+ errs = append(errs, fmt.Errorf("function %s is not defined", fun))
+ }
+ }
+
+ if len(errs) != 0 {
+ return errors.Join(errs...)
+ }
+
+ return nil
+}
+
+func (r *Runner) anubisWork(ctx context.Context, module api.Module, difficulty, initialNonce, iterand uint32) (uint32, error) {
+ results, err := module.ExportedFunction("anubis_work").Call(ctx, uint64(difficulty), uint64(initialNonce), uint64(iterand))
+ if err != nil {
+ return 0, err
+ }
+
+ return uint32(results[0]), nil
+}
+
+func (r *Runner) anubisValidate(ctx context.Context, module api.Module, nonce, difficulty uint32) (bool, error) {
+ results, err := module.ExportedFunction("anubis_validate").Call(ctx, uint64(nonce), uint64(difficulty))
+ if err != nil {
+ return false, err
+ }
+
+ // Rust booleans are 1 if true
+ return results[0] == 1, nil
+}
+
+func (r *Runner) dataPtr(ctx context.Context, module api.Module) (uint32, error) {
+ results, err := module.ExportedFunction("data_ptr").Call(ctx)
+ if err != nil {
+ return 0, err
+ }
+
+ return uint32(results[0]), nil
+}
+
+func (r *Runner) setDataLength(ctx context.Context, module api.Module, length uint32) error {
+ _, err := module.ExportedFunction("set_data_length").Call(ctx, uint64(length))
+ return err
+}
+
+func (r *Runner) resultHashPtr(ctx context.Context, module api.Module) (uint32, error) {
+ results, err := module.ExportedFunction("result_hash_ptr").Call(ctx)
+ if err != nil {
+ return 0, err
+ }
+
+ return uint32(results[0]), nil
+}
+
+func (r *Runner) resultHashSize(ctx context.Context, module api.Module) (uint32, error) {
+ results, err := module.ExportedFunction("result_hash_size").Call(ctx)
+ if err != nil {
+ return 0, err
+ }
+
+ return uint32(results[0]), nil
+}
+
+func (r *Runner) verificationHashPtr(ctx context.Context, module api.Module) (uint32, error) {
+ results, err := module.ExportedFunction("verification_hash_ptr").Call(ctx)
+ if err != nil {
+ return 0, err
+ }
+
+ return uint32(results[0]), nil
+}
+
+func (r *Runner) verificationHashSize(ctx context.Context, module api.Module) (uint32, error) {
+ results, err := module.ExportedFunction("verification_hash_size").Call(ctx)
+ if err != nil {
+ return 0, err
+ }
+
+ return uint32(results[0]), nil
+}
+
+func (r *Runner) writeData(ctx context.Context, module api.Module, data []byte) error {
+ if len(data) == 0 {
+ return ErrNotEnoughData
+ }
+
+ if len(data) > 4096 {
+ return ErrTooMuchData
+ }
+
+ length := uint32(len(data))
+
+ dataPtr, err := r.dataPtr(ctx, module)
+ if err != nil {
+ return fmt.Errorf("can't read data pointer: %w", err)
+ }
+
+ if !module.Memory().Write(dataPtr, data) {
+ return fmt.Errorf("[unexpected] can't write memory, is data out of range??")
+ }
+
+ if err := r.setDataLength(ctx, module, length); err != nil {
+ return fmt.Errorf("can't set data length: %w", err)
+ }
+
+ return nil
+}
+
+func (r *Runner) readResult(ctx context.Context, module api.Module) ([]byte, error) {
+ length, err := r.resultHashSize(ctx, module)
+ if err != nil {
+ return nil, fmt.Errorf("can't get result hash size: %w", err)
+ }
+
+ ptr, err := r.resultHashPtr(ctx, module)
+ if err != nil {
+ return nil, fmt.Errorf("can't get result hash pointer: %w", err)
+ }
+
+ buf, ok := module.Memory().Read(ptr, length)
+ if !ok {
+ return nil, fmt.Errorf("[unexpected] can't read from memory, is something out of range??")
+ }
+
+ return buf, nil
+}
+
+func (r *Runner) run(ctx context.Context, data []byte, difficulty, initialNonce, iterand uint32) (uint32, []byte, api.Module, error) {
+ mod, err := r.r.InstantiateModule(ctx, r.code, wazero.NewModuleConfig().WithName(r.fname))
+ if err != nil {
+ return 0, nil, nil, fmt.Errorf("can't instantiate module: %w", err)
+ }
+
+ if err := r.checkExports(mod); err != nil {
+ return 0, nil, mod, err
+ }
+
+ if err := r.writeData(ctx, mod, data); err != nil {
+ return 0, nil, mod, err
+ }
+
+ nonce, err := r.anubisWork(ctx, mod, difficulty, initialNonce, iterand)
+ if err != nil {
+ return 0, nil, mod, fmt.Errorf("can't run work function: %w", err)
+ }
+
+ hash, err := r.readResult(ctx, mod)
+ if err != nil {
+ return 0, nil, mod, fmt.Errorf("can't read result: %w", err)
+ }
+
+ return nonce, hash, mod, nil
+}
+
+func (r *Runner) Run(ctx context.Context, data []byte, difficulty, initialNonce, iterand uint32) (uint32, []byte, error) {
+ nonce, hash, mod, err := r.run(ctx, data, difficulty, initialNonce, iterand)
+ if mod != nil {
+ if err := mod.Close(ctx); err != nil {
+ return 0, nil, fmt.Errorf("can't close wasm module: %w", err)
+ }
+ }
+ if err != nil {
+ return 0, nil, fmt.Errorf("can't run %s: %w", r.fname, err)
+ }
+
+ return nonce, hash, nil
+}
+
+func (r *Runner) verify(ctx context.Context, data, verify []byte, nonce, difficulty uint32) (bool, api.Module, error) {
+ mod, err := r.r.InstantiateModule(ctx, r.code, wazero.NewModuleConfig().WithName(r.fname))
+ if err != nil {
+ return false, nil, fmt.Errorf("can't instantiate module: %w", err)
+ }
+
+ if err := r.checkExports(mod); err != nil {
+ return false, mod, err
+ }
+
+ if err := r.writeData(ctx, mod, data); err != nil {
+ return false, mod, err
+ }
+
+ if err := r.writeVerification(ctx, mod, verify); err != nil {
+ return false, mod, err
+ }
+
+ ok, err := r.anubisValidate(ctx, mod, nonce, difficulty)
+ if err != nil {
+ return false, mod, fmt.Errorf("can't validate hash %x from challenge %x, nonce %d and difficulty %d: %w", verify, data, nonce, difficulty, err)
+ }
+
+ return ok, mod, nil
+}
+
+func (r *Runner) Verify(ctx context.Context, data, verify []byte, nonce, difficulty uint32) (bool, error) {
+ t0 := time.Now()
+ ok, mod, err := r.verify(ctx, data, verify, nonce, difficulty)
+ if mod != nil {
+ if err := mod.Close(ctx); err != nil {
+ return false, fmt.Errorf("can't close wasm module: %w", err)
+ }
+ }
+ validationTime.WithLabelValues(r.fname).Observe(float64(time.Since(t0)))
+ validationCount.WithLabelValues(r.fname, strconv.FormatBool(ok)).Inc()
+ return ok, err
+}
+
+func (r *Runner) writeVerification(ctx context.Context, module api.Module, data []byte) error {
+ length, err := r.verificationHashSize(ctx, module)
+ if err != nil {
+ return fmt.Errorf("can't get verification hash size: %w", err)
+ }
+
+ if length != uint32(len(data)) {
+ return fmt.Errorf("data is too big, want %d bytes, got: %d", length, len(data))
+ }
+
+ ptr, err := r.verificationHashPtr(ctx, module)
+ if err != nil {
+ return fmt.Errorf("can't get verification hash pointer: %w", err)
+ }
+
+ if !module.Memory().Write(ptr, data) {
+ return fmt.Errorf("[unexpected] can't write memory, is data out of range??")
+ }
+
+ return nil
+}
diff --git a/wasm/wasm_test.go b/wasm/wasm_test.go
new file mode 100644
index 000000000..37342d3e2
--- /dev/null
+++ b/wasm/wasm_test.go
@@ -0,0 +1,243 @@
+package wasm
+
+import (
+ "context"
+ "crypto/sha256"
+ "errors"
+ "fmt"
+ "io/fs"
+ "testing"
+ "time"
+
+ "github.com/TecharoHQ/anubis/web"
+ "github.com/tetratelabs/wazero"
+)
+
+func abiTest(t testing.TB, kind, fname string, difficulty uint32) {
+ fin, err := web.Static.Open("static/wasm/" + kind + "/" + fname)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer fin.Close() //nolint:errcheck
+ ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
+ t.Cleanup(cancel)
+
+ runner, err := NewRunner(ctx, fname, fin)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ h := sha256.New()
+ fmt.Fprint(h, t.Name()) //nolint:errcheck
+ data := h.Sum(nil)
+
+ nonce, hash, mod, err := runner.run(ctx, data, difficulty, 0, 1)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if err := runner.writeVerification(ctx, mod, hash); err != nil {
+ t.Fatalf("can't write verification: %v", err)
+ }
+
+ ok, err := runner.anubisValidate(ctx, mod, nonce, difficulty)
+ if err != nil {
+ t.Fatalf("can't run validation: %v", err)
+ }
+
+ if !ok {
+ t.Error("validation failed")
+ }
+
+ t.Logf("used %d pages of wasm memory (%d bytes)", mod.Memory().Size()/pageSize, mod.Memory().Size())
+}
+
+func TestAlgos(t *testing.T) {
+ fnames, err := fs.ReadDir(web.Static, "static/wasm/baseline")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ for _, kind := range []string{"baseline", "simd128"} {
+ for _, fname := range fnames {
+ t.Run(kind+"/"+fname.Name(), func(t *testing.T) {
+ abiTest(t, kind, fname.Name(), 4)
+ })
+ }
+ }
+}
+
+func bench(b *testing.B, kind, fname string, difficulties []uint32) {
+ b.Helper()
+
+ fin, err := web.Static.Open("static/wasm/" + kind + "/" + fname)
+ if err != nil {
+ b.Fatal(err)
+ }
+ defer fin.Close() //nolint:errcheck
+ ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
+ b.Cleanup(cancel)
+
+ runner, err := NewRunner(ctx, fname, fin)
+ if err != nil {
+ b.Fatal(err)
+ }
+
+ h := sha256.New()
+ fmt.Fprint(h, "This is an example value that exists only to test the system.") //nolint:errcheck
+ data := h.Sum(nil)
+
+ _, _, mod, err := runner.run(ctx, data, 0, 0, 1)
+ if err != nil {
+ b.Fatal(err)
+ }
+
+ for _, difficulty := range difficulties {
+ b.Run(fmt.Sprintf("difficulty/%d", difficulty), func(b *testing.B) {
+ for b.Loop() {
+ difficulty := difficulty
+ _, err := runner.anubisWork(ctx, mod, difficulty, 0, 1)
+ if err != nil {
+ b.Fatalf("can't do test work run: %v", err)
+ }
+ }
+ })
+ }
+}
+
+func BenchmarkArgon2ID(b *testing.B) {
+ for _, kind := range []string{"baseline", "simd128"} {
+ b.Run(kind, func(b *testing.B) {
+ bench(b, kind, "argon2id.wasm", []uint32{4, 6, 8})
+ })
+ }
+}
+
+func BenchmarkHashX(b *testing.B) {
+ for _, kind := range []string{"baseline", "simd128"} {
+ b.Run(kind, func(b *testing.B) {
+ bench(b, kind, "hashx.wasm", []uint32{4, 6, 8})
+ })
+ }
+}
+
+func BenchmarkSHA256(b *testing.B) {
+ for _, kind := range []string{"baseline", "simd128"} {
+ b.Run(kind, func(b *testing.B) {
+ bench(b, kind, "sha256.wasm", []uint32{4, 6, 8, 10, 12, 14, 16})
+ })
+ }
+}
+
+func BenchmarkValidate(b *testing.B) {
+ fnames, err := fs.ReadDir(web.Static, "static/wasm/simd128")
+ if err != nil {
+ b.Fatal(err)
+ }
+
+ h := sha256.New()
+ fmt.Fprint(h, "This is an example value that exists only to test the system.") //nolint:errcheck
+ data := h.Sum(nil)
+
+ for _, fname := range fnames {
+ fname := fname.Name()
+
+ difficulty := uint32(1)
+
+ switch fname {
+ case "sha256.wasm":
+ difficulty = 16
+ }
+
+ b.Run(fname, func(b *testing.B) {
+ fin, err := web.Static.Open("static/wasm/simd128/" + fname)
+ if err != nil {
+ b.Fatal(err)
+ }
+ defer fin.Close() //nolint:errcheck
+ ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
+ b.Cleanup(cancel)
+
+ runner, err := NewRunner(ctx, fname, fin)
+ if err != nil {
+ b.Fatal(err)
+ }
+
+ nonce, hash, mod, err := runner.run(ctx, data, difficulty, 0, 1)
+ if err != nil {
+ b.Fatal(err)
+ }
+
+ if err := runner.writeVerification(ctx, mod, hash); err != nil {
+ b.Fatalf("can't write verification: %v", err)
+ }
+
+ for b.Loop() {
+ _, err := runner.anubisValidate(ctx, mod, nonce, difficulty)
+ if err != nil {
+ b.Fatalf("can't run validation: %v", err)
+ }
+ }
+ })
+ }
+}
+
+func TestRunnerDataHardening(t *testing.T) {
+ fnames, err := fs.ReadDir(web.Static, "static/wasm/simd128")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ for _, fname := range fnames {
+ fname := fname.Name()
+ t.Run(fname, func(t *testing.T) {
+ for _, tt := range []struct {
+ name string
+ data []byte
+ err error
+ }{
+ {
+ name: "simple okay",
+ data: make([]byte, 4096),
+ err: nil,
+ },
+ {
+ name: "not enough",
+ data: make([]byte, 0),
+ err: ErrNotEnoughData,
+ },
+ {
+ name: "too much",
+ data: make([]byte, 8192),
+ err: ErrTooMuchData,
+ },
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ fin, err := web.Static.Open("static/wasm/simd128/" + fname)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer fin.Close() //nolint:errcheck
+ ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
+ t.Cleanup(cancel)
+
+ runner, err := NewRunner(ctx, fname, fin)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ mod, err := runner.r.InstantiateModule(ctx, runner.code, wazero.NewModuleConfig().WithName(runner.fname))
+ if err != nil {
+ t.Fatalf("can't instantiate module: %v", err)
+ }
+
+ if err := runner.writeData(t.Context(), mod, tt.data); !errors.Is(err, tt.err) {
+ t.Logf("wanted error: %v", tt.err)
+ t.Logf("got error: %v", err)
+ t.Error()
+ }
+ })
+ }
+ })
+ }
+}
diff --git a/web/build.sh b/web/build.sh
index d0818e0c2..9e650f081 100755
--- a/web/build.sh
+++ b/web/build.sh
@@ -41,16 +41,16 @@ cp ../lib/localization/locales/*.json static/locales/
shopt -s nullglob globstar
-for file in js/**/*.ts js/**/*.mjs; do
- out="static/${file}"
- if [[ "$file" == *.ts ]]; then
- out="static/${file%.ts}.mjs"
- fi
-
- mkdir -p "$(dirname "$out")"
-
- esbuild "$file" --sourcemap --bundle --minify --outfile="$out" --banner:js="$LICENSE"
- gzip -f -k -n "$out"
- zstd -f -k --ultra -22 "$out"
- brotli -fZk "$out"
+for file in js/*.ts js/worker/*.ts; do
+ out="static/${file}"
+ if [[ "$file" == *.ts ]]; then
+ out="static/${file%.ts}.mjs"
+ fi
+
+ mkdir -p "$(dirname "$out")"
+
+ esbuild "$file" --sourcemap --bundle --minify --target=chrome66 --outfile="$out" --banner:js="$LICENSE"
+ gzip -f -k -n "$out"
+ zstd -f -k --ultra -22 "$out"
+ brotli -fZk "$out"
done
diff --git a/web/index.go b/web/index.go
index c3ba03d0a..ddc5dc81f 100644
--- a/web/index.go
+++ b/web/index.go
@@ -1,19 +1,23 @@
package web
import (
+ "context"
+ "fmt"
+ "io"
+
"github.com/a-h/templ"
"github.com/TecharoHQ/anubis/lib/challenge"
+ "github.com/TecharoHQ/anubis/lib/config"
"github.com/TecharoHQ/anubis/lib/localization"
- "github.com/TecharoHQ/anubis/lib/policy/config"
)
-func Base(title string, body templ.Component, impressum *config.Impressum, localizer *localization.SimpleLocalizer) templ.Component {
- return base(title, body, impressum, nil, nil, localizer)
+func Base(title string, body templ.Component, impressum *config.Impressum, honeypot *config.Honeypot, localizer *localization.SimpleLocalizer) templ.Component {
+ return base(title, body, impressum, honeypot, nil, nil, localizer)
}
-func BaseWithChallengeAndOGTags(title string, body templ.Component, impressum *config.Impressum, challenge *challenge.Challenge, rules *config.ChallengeRules, ogTags map[string]string, localizer *localization.SimpleLocalizer) templ.Component {
- return base(title, body, impressum, struct {
+func BaseWithChallengeAndOGTags(title string, body templ.Component, impressum *config.Impressum, honeypot *config.Honeypot, challenge *challenge.Challenge, rules *config.ChallengeRules, ogTags map[string]string, localizer *localization.SimpleLocalizer) templ.Component {
+ return base(title, body, impressum, honeypot, struct {
Rules *config.ChallengeRules `json:"rules"`
Challenge any `json:"challenge"`
}{
@@ -22,10 +26,19 @@ func BaseWithChallengeAndOGTags(title string, body templ.Component, impressum *c
}, ogTags, localizer)
}
-func ErrorPage(msg, mail string, localizer *localization.SimpleLocalizer) templ.Component {
- return errorPage(msg, mail, localizer)
+func ErrorPage(msg, mail, code string, localizer *localization.SimpleLocalizer) templ.Component {
+ return errorPage(msg, mail, code, localizer)
}
func Bench(localizer *localization.SimpleLocalizer) templ.Component {
return bench(localizer)
}
+
+func honeypotLink(href string) templ.Component {
+ return templ.ComponentFunc(func(ctx context.Context, w io.Writer) error {
+ if _, err := fmt.Fprintf(w, ``, href); err != nil {
+ return err
+ }
+ return nil
+ })
+}
diff --git a/web/index.templ b/web/index.templ
index 54b368ccd..f3c61e740 100644
--- a/web/index.templ
+++ b/web/index.templ
@@ -3,12 +3,13 @@ package web
import (
"fmt"
"github.com/TecharoHQ/anubis"
+ "github.com/TecharoHQ/anubis/lib/config"
"github.com/TecharoHQ/anubis/lib/localization"
- "github.com/TecharoHQ/anubis/lib/policy/config"
"github.com/TecharoHQ/anubis/xess"
+ "github.com/google/uuid"
)
-templ base(title string, body templ.Component, impressum *config.Impressum, challenge any, ogTags map[string]string, localizer *localization.SimpleLocalizer) {
+templ base(title string, body templ.Component, impressum *config.Impressum, honeypot *config.Honeypot, challenge any, ogTags map[string]string, localizer *localization.SimpleLocalizer) {
@@ -39,19 +40,19 @@ templ base(title string, body templ.Component, impressum *config.Impressum, chal
}
#progress {
- display: none;
- width: 90%;
- width: min(20rem, 90%);
- height: 2rem;
- border-radius: 1rem;
- overflow: hidden;
- margin: 1rem 0 2rem;
- outline-offset: 2px;
- outline: #b16286 solid 4px;
- }
+ display: none;
+ width: 90%;
+ width: min(20rem, 90%);
+ height: 2rem;
+ border-radius: 1rem;
+ overflow: hidden;
+ margin: 1rem 0 2rem;
+ outline-offset: 2px;
+ outline: #00407D solid 4px;
+ }
.bar-inner {
- background-color: #b16286;
+ background-color: #00407D;
height: 100%;
width: 0;
transition: width 0.25s ease-in;
@@ -63,6 +64,9 @@ templ base(title string, body templ.Component, impressum *config.Impressum, chal
@templ.JSONScript("anubis_public_url", anubis.PublicUrl)
+ if honeypot != nil && honeypot.Enabled {
+ @honeypotLink(anubis.BasePrefix + fmt.Sprintf("%shoneypot/%s/init", anubis.APIPrefix, uuid.NewString()))
+ }
{ title }
@body
@@ -77,7 +81,7 @@ templ base(title string, body templ.Component, impressum *config.Impressum, chal
if impressum != nil {
@templ.Raw(impressum.Footer)
- -- Imprint
+ -- Imprint
}
{ localizer.T("version_info") } { anubis.Version }.
@@ -88,10 +92,13 @@ templ base(title string, body templ.Component, impressum *config.Impressum, chal
}
-templ errorPage(message, mail string, localizer *localization.SimpleLocalizer) {
+templ errorPage(message, mail, code string, localizer *localization.SimpleLocalizer) {
{ message }.
+ if code != "" {
+
{ code }
+ }
if mail != "" {
{ localizer.T("go_home") } { localizer.T("contact_webmaster") }
diff --git a/web/index_templ.go b/web/index_templ.go
index 13f17e421..748912775 100644
--- a/web/index_templ.go
+++ b/web/index_templ.go
@@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT.
-// templ: version: v0.3.924
+// templ: version: v0.3.1020
package web
//lint:file-ignore SA4006 This context is only used if a nested component is present.
@@ -11,12 +11,13 @@ import templruntime "github.com/a-h/templ/runtime"
import (
"fmt"
"github.com/TecharoHQ/anubis"
+ "github.com/TecharoHQ/anubis/lib/config"
"github.com/TecharoHQ/anubis/lib/localization"
- "github.com/TecharoHQ/anubis/lib/policy/config"
"github.com/TecharoHQ/anubis/xess"
+ "github.com/google/uuid"
)
-func base(title string, body templ.Component, impressum *config.Impressum, challenge any, ogTags map[string]string, localizer *localization.SimpleLocalizer) templ.Component {
+func base(title string, body templ.Component, impressum *config.Impressum, honeypot *config.Honeypot, challenge any, ogTags map[string]string, localizer *localization.SimpleLocalizer) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
@@ -42,11 +43,11 @@ func base(title string, body templ.Component, impressum *config.Impressum, chall
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
- templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.GetLang())
+ templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(localizer.GetLang())
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 13, Col: 33}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 14, Col: 33}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -57,7 +58,7 @@ func base(title string, body templ.Component, impressum *config.Impressum, chall
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(title)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 15, Col: 17}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 16, Col: 17}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
@@ -70,7 +71,7 @@ func base(title string, body templ.Component, impressum *config.Impressum, chall
var templ_7745c5c3_Var4 templ.SafeURL
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinURLErrs(anubis.BasePrefix + xess.URL)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 16, Col: 61}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 17, Col: 61}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
@@ -86,11 +87,11 @@ func base(title string, body templ.Component, impressum *config.Impressum, chall
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
- templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(key)
+ templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(key)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 20, Col: 24}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 21, Col: 24}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -99,11 +100,11 @@ func base(title string, body templ.Component, impressum *config.Impressum, chall
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
- templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(value)
+ templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(value)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 20, Col: 42}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 21, Col: 42}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -112,7 +113,7 @@ func base(title string, body templ.Component, impressum *config.Impressum, chall
return templ_7745c5c3_Err
}
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -132,20 +133,30 @@ func base(title string, body templ.Component, impressum *config.Impressum, chall
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if honeypot != nil && honeypot.Enabled {
+ templ_7745c5c3_Err = honeypotLink(anubis.BasePrefix+fmt.Sprintf("%shoneypot/%s/init", anubis.APIPrefix, uuid.NewString())).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(title)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 67, Col: 47}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 71, Col: 47}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -153,77 +164,77 @@ func base(title string, body templ.Component, impressum *config.Impressum, chall
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -283,7 +294,7 @@ func base(title string, body templ.Component, impressum *config.Impressum, chall
})
}
-func errorPage(message, mail string, localizer *localization.SimpleLocalizer) templ.Component {
+func errorPage(message, mail, code string, localizer *localization.SimpleLocalizer) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
@@ -304,113 +315,132 @@ func errorPage(message, mail string, localizer *localization.SimpleLocalizer) te
templ_7745c5c3_Var16 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "
)
)
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\">
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(message)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 94, Col: 14}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 98, Col: 14}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, ".
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, ".")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- if mail != "" {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "
")
+ if code != "" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var19 string
- templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T("go_home"))
+ templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(code)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 97, Col: 40}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 100, Col: 20}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, " ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ if mail != "" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var20 string
- templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T("contact_webmaster"))
+ templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T("go_home"))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 97, Col: 81}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 104, Col: 40}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, " ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var21 templ.SafeURL
- templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinURLErrs("mailto:" + templ.SafeURL(mail))
+ var templ_7745c5c3_Var21 string
+ templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T("contact_webmaster"))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 98, Col: 45}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 104, Col: 81}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\">")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var23 string
+ templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(mail)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 106, Col: 11}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var23 string
- templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T("go_home"))
+ var templ_7745c5c3_Var24 string
+ templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T("go_home"))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 103, Col: 42}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 110, Col: 42}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -434,39 +464,39 @@ func StaticHappy(localizer *localization.SimpleLocalizer) templ.Component {
}()
}
ctx = templ.InitializeContext(ctx)
- templ_7745c5c3_Var24 := templ.GetChildren(ctx)
- if templ_7745c5c3_Var24 == nil {
- templ_7745c5c3_Var24 = templ.NopComponent
+ templ_7745c5c3_Var25 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var25 == nil {
+ templ_7745c5c3_Var25 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, ")
)
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "\">
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var26 string
- templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T("static_check_endpoint"))
+ var templ_7745c5c3_Var27 string
+ templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T("static_check_endpoint"))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 116, Col: 43}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 123, Col: 43}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, " ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -490,181 +520,181 @@ func bench(localizer *localization.SimpleLocalizer) templ.Component {
}()
}
ctx = templ.InitializeContext(ctx)
- templ_7745c5c3_Var27 := templ.GetChildren(ctx)
- if templ_7745c5c3_Var27 == nil {
- templ_7745c5c3_Var27 = templ.NopComponent
+ templ_7745c5c3_Var28 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var28 == nil {
+ templ_7745c5c3_Var28 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "