14 Commits

Author SHA1 Message Date
WeebDataHoarder
71b99f9d12 Update go.mod dependencies 2025-04-23 20:48:13 +02:00
WeebDataHoarder
cb02fb20e9 cmd: print current version name on cmd and Via header 2025-04-23 20:46:17 +02:00
WeebDataHoarder
57755112ea ci: check example policy files
cmd: add check parameter
2025-04-23 20:35:20 +02:00
WeebDataHoarder
6bb7ca979d Implement cache for networks 2025-04-23 20:35:20 +02:00
WeebDataHoarder
a0224cb21c policy: allow fetching ASN directly via RADb WHOIS service 2025-04-23 20:35:20 +02:00
WeebDataHoarder
612362dbe5 readme: note existence of snippets 2025-04-23 20:35:20 +02:00
WeebDataHoarder
d56d621f7a Allow reloading config via SIGHUP 2025-04-23 20:35:20 +02:00
WeebDataHoarder
9719c0ff39 Support atomically swapping http handler for passhtrough 2025-04-23 20:35:20 +02:00
WeebDataHoarder
3b11792594 Implement policy snippets 2025-04-23 20:35:20 +02:00
WeebDataHoarder
d83fe3653a examples: update bot matches, allow badges to be fetched 2025-04-23 20:35:20 +02:00
WeebDataHoarder
1cc95a5fa7 readme: update mirror list with badges / icons.
Update README What's left section with changes and CHALLENGES

readme: Add note on package mirrors on codeberg and github
2025-04-23 20:35:20 +02:00
WeebDataHoarder
ead41055ca Condition, rules, state and action refactor / rewrite
Add nested rules
Add backend action, allow wildcard in backends
Remove poison from tree, update README with action table

Allow defining pass/fail actions on challenge,

Remove redirect/referer parameters on backend pass

Set challenge cookie tied to host

Rewrite DNSBL condition into a challenge

Allow passing an arbitrary path for assets to js challenges

Optimize programs exhaustively on compilation

Activation instead of map for CEL context, faster map access, new network override

Return valid host on cookie setting in case Host is an IP address.
bug: does not work with IPv6, see https://github.com/golang/go/issues/65521

Apply TLS fingerprinter on GetConfigForClient instead of GetCertificate

Cleanup go-away cookies before passing to backend

Code action for specifically replying with an HTTP code
2025-04-23 20:35:20 +02:00
WeebDataHoarder
1c7fe1bed9 Added powxy to README 2025-04-23 20:35:20 +02:00
Alan Orth
27b25082b9 Dockerfile: qualify docker.io registry
Other container runtimes can use Dockerfiles, but complain if the
registry is unqualified. It's a good practice to qualify this so
it is not implied.
2025-04-23 15:35:29 +03:00
76 changed files with 4065 additions and 2416 deletions

View File

@@ -21,10 +21,26 @@ local Build(go, alpine, os, arch) = {
"apk update",
"apk add --no-cache git",
"mkdir .bin",
"go build -v -o ./.bin/go-away ./cmd/go-away",
"go build -v -pgo=auto -v -trimpath -ldflags=-buildid= -o ./.bin/go-away ./cmd/go-away",
"go build -v -o ./.bin/test-wasm-runtime ./cmd/test-wasm-runtime",
],
},
{
name: "check-policy-forgejo",
image: "alpine:" + alpine,
depends_on: ["build"],
commands: [
"./.bin/go-away --check --slog-level DEBUG --backend example.com=http://127.0.0.1:80 --policy examples/forgejo.yml --policy-snippets examples/snippets/"
],
},
{
name: "check-policy-generic",
image: "alpine:" + alpine,
depends_on: ["build"],
commands: [
"./.bin/go-away --check --slog-level DEBUG --backend example.com=http://127.0.0.1:80 --policy examples/generic.yml --policy-snippets examples/snippets/"
],
},
{
name: "test-wasm-success",
image: "alpine:" + alpine,

View File

@@ -14,10 +14,24 @@ steps:
- apk update
- apk add --no-cache git
- mkdir .bin
- go build -v -o ./.bin/go-away ./cmd/go-away
- go build -v -pgo=auto -v -trimpath -ldflags=-buildid= -o ./.bin/go-away ./cmd/go-away
- go build -v -o ./.bin/test-wasm-runtime ./cmd/test-wasm-runtime
image: golang:1.24-alpine3.21
name: build
- commands:
- ./.bin/go-away --check --slog-level DEBUG --backend example.com=http://127.0.0.1:80
--policy examples/forgejo.yml --policy-snippets examples/snippets/
depends_on:
- build
image: alpine:3.21
name: check-policy-forgejo
- commands:
- ./.bin/go-away --check --slog-level DEBUG --backend example.com=http://127.0.0.1:80
--policy examples/generic.yml --policy-snippets examples/snippets/
depends_on:
- build
image: alpine:3.21
name: check-policy-generic
- commands:
- ./.bin/test-wasm-runtime -wasm ./embed/challenge/js-pow-sha256/runtime/runtime.wasm
-make-challenge ./embed/challenge/js-pow-sha256/test/make-challenge.json -make-challenge-out
@@ -55,10 +69,24 @@ steps:
- apk update
- apk add --no-cache git
- mkdir .bin
- go build -v -o ./.bin/go-away ./cmd/go-away
- go build -v -pgo=auto -v -trimpath -ldflags=-buildid= -o ./.bin/go-away ./cmd/go-away
- go build -v -o ./.bin/test-wasm-runtime ./cmd/test-wasm-runtime
image: golang:1.24-alpine3.21
name: build
- commands:
- ./.bin/go-away --check --slog-level DEBUG --backend example.com=http://127.0.0.1:80
--policy examples/forgejo.yml --policy-snippets examples/snippets/
depends_on:
- build
image: alpine:3.21
name: check-policy-forgejo
- commands:
- ./.bin/go-away --check --slog-level DEBUG --backend example.com=http://127.0.0.1:80
--policy examples/generic.yml --policy-snippets examples/snippets/
depends_on:
- build
image: alpine:3.21
name: check-policy-generic
- commands:
- ./.bin/test-wasm-runtime -wasm ./embed/challenge/js-pow-sha256/runtime/runtime.wasm
-make-challenge ./embed/challenge/js-pow-sha256/test/make-challenge.json -make-challenge-out
@@ -322,6 +350,6 @@ trigger:
type: docker
---
kind: signature
hmac: f27dd6fbc73d3dd6e26739576a02b6bf0f9d1c43ee9d6d1439afacdf4e4dbf96
hmac: 8aed9810938e4aa4b34c4afb35e1101f27f98a61ffe5349be9a30f22ce7480ed
...

View File

@@ -13,14 +13,15 @@ For example, this allows verifying the user cookies against the backend to have
Example on Forgejo, checks that current user is authenticated:
```yaml
http-cookie-check:
mode: http
url: http://forgejo:3000/user/stopwatches
# url: http://forgejo:3000/repo/search
# url: http://forgejo:3000/notifications/new
runtime: http
parameters:
http-url: http://forgejo:3000/user/stopwatches
# http-url: http://forgejo:3000/repo/search
# http-url: http://forgejo:3000/notifications/new
http-method: GET
http-cookie: i_like_gitea
http-code: 200
verify-probability: 0.1
```
### preload-link
@@ -33,18 +34,45 @@ The server waits until solved or defined timeout, then continues on other challe
Example:
```yaml
self-preload-link:
preload-link:
condition: '"Sec-Fetch-Mode" in headers && headers["Sec-Fetch-Mode"] == "navigate"'
mode: "preload-link"
runtime:
# verifies that result = key
mode: "key"
probability: 0.1
runtime: "preload-link"
parameters:
preload-early-hint-deadline: 3s
key-code: 200
key-mime: text/css
key-content: ""
```
### dnsbl
You can configure a [DNSBL (Domain Name System blocklist)](https://en.wikipedia.org/wiki/Domain_Name_System_blocklist) to be queried.
This allows you to serve harder or different challenges to higher risk clients, or block them from specific sections.
Only rules that match a DNSBL challenge will cause a query to be sent, meaning the bulk of requests will not be sent to this service upstream.
Results will be temporarily cached.
By default, [DroneBL](https://dronebl.org/) is used.
Example challenge definition and rule:
```yaml
challenges:
dnsbl:
runtime: dnsbl
parameters:
# dnsbl-host: "dnsbl.dronebl.org"
dnsbl-decay: 1h
dnsbl-timeout: 1s
rules:
# check DNSBL and serve harder challenges
- name: undesired-dnsbl
action: check
settings:
challenges: [dnsbl]
# if DNSBL fails, check additional challenges
fail: check
fail-settings:
challenges: [js-pow-sha256]
```
## Non-JavaScript
@@ -76,20 +104,6 @@ Requires HTTP and HTML response parsing and logic, displays challenge site.
Servers a challenge page with a linked resource that is loaded by the browser, which solves the challenge. Page refreshes a few seconds later via [Refresh](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Refresh).
Example:
```yaml
self-resource-load:
mode: "resource-load"
runtime:
# verifies that result = key
mode: "key"
probability: 0.1
parameters:
key-code: 200
key-mime: text/css
key-content: ""
```
## Custom JavaScript
### js-pow-sha256
@@ -101,18 +115,18 @@ Has the user solve a Proof of Work using SHA256 hashes, with configurable diffic
Example:
```yaml
js-pow-sha256:
# Asset must be under challenges/{name}/static/{asset}
# Other files here will be available under that path
mode: js
asset: load.mjs
runtime: js
parameters:
# difficulty is number of bits that must be set to 0 from start
# Anubis challenge difficulty 5 becomes 5 * 8 = 20
difficulty: 20
runtime:
mode: wasm
# Verify must be under challenges/{name}/runtime/{asset}
asset: runtime.wasm
probability: 0.02
# specifies the folder path that assets are under
# can be either embedded or external path
# defaults to name of challenge
path: "js-pow-sha256"
# needs to be under static folder
js-loader: load.mjs
# needs to be under runtime folder
wasm-runtime: runtime.wasm
wasm-runtime-settings:
difficulty: 20
verify-probability: 0.02
```

View File

@@ -1,5 +1,5 @@
ARG from_builder=golang:1.24-alpine3.21
ARG from=alpine:3.21
ARG from_builder=docker.io/golang:1.24-alpine3.21
ARG from=docker.io/alpine:3.21
ARG BUILDPLATFORM
@@ -39,6 +39,7 @@ ENV GOAWAY_BIND=":8080"
ENV GOAWAY_BIND_NETWORK="tcp"
ENV GOAWAY_SOCKET_MODE="0770"
ENV GOAWAY_POLICY="/policy.yml"
ENV GOAWAY_POLICY_SNIPPETS="/policy/snippets"
ENV GOAWAY_CHALLENGE_TEMPLATE="anubis"
ENV GOAWAY_CHALLENGE_TEMPLATE_THEME=""
ENV GOAWAY_SLOG_LEVEL="WARN"
@@ -56,7 +57,8 @@ EXPOSE 8080/udp
ENV JWT_PRIVATE_KEY_SEED="${GOAWAY_JWT_PRIVATE_KEY_SEED}"
ENTRYPOINT /bin/go-away --bind "${GOAWAY_BIND}" --bind-network "${GOAWAY_BIND_NETWORK}" --socket-mode "${GOAWAY_SOCKET_MODE}" \
--policy ${GOAWAY_POLICY} --client-ip-header "${GOAWAY_CLIENT_IP_HEADER}" --backend-ip-header "${GOAWAY_BACKEND_IP_HEADER}" \
--policy "${GOAWAY_POLICY}" --policy-snippets "${GOAWAY_POLICY_SNIPPETS}" \
--client-ip-header "${GOAWAY_CLIENT_IP_HEADER}" --backend-ip-header "${GOAWAY_BACKEND_IP_HEADER}" \
--cache "${GOAWAY_CACHE}" \
--dnsbl "${GOAWAY_DNSBL}" \
--challenge-template "${GOAWAY_CHALLENGE_TEMPLATE}" --challenge-template-theme "${GOAWAY_CHALLENGE_TEMPLATE_THEME}" \

100
README.md
View File

@@ -26,7 +26,19 @@ If you have some suggestion or issue, feel free to open a [New Issue](https://gi
For real-time chat and other support join IRC on [#go-away](ircs://irc.libera.chat/#go-away) on Libera.Chat [[WebIRC]](https://web.libera.chat/?nick=Guest?#go-away). The channel may not be monitored at all times, feel free to ping the operators there.
A source code mirror exists on [sourcehut](https://git.sr.ht/~datahoarder/go-away), [Codeberg.org](https://codeberg.org/WeebDataHoarder/go-away), and [GitHub](https://github.com/WeebDataHoarder/go-away).
## Code Mirrors
Source code is automatically pushed to the following mirrors. Packages are also mirrored on Codeberg and GitHub.
[![GammaSpectra.live](https://img.shields.io/badge/GammaSpectra.live-main+packages-green?style=flat&logo=data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+CjxzdmcgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgd2lkdGg9IjYwMCIgaGVpZ2h0PSI2MDAiIHZpZXdCb3g9Ii0zMDAgLTMwMCA2MDAgNjAwIj4KPGNpcmNsZSByPSI1MCIvPgo8cGF0aCBkPSJNNzUsMCBBIDc1LDc1IDAgMCwwIDM3LjUsLTY0Ljk1MiBMIDEyNSwtMjE2LjUwNiBBIDI1MCwyNTAgMCAwLDEgMjUwLDAgeiIgaWQ9ImJsZCIvPgo8dXNlIHhsaW5rOmhyZWY9IiNibGQiIHRyYW5zZm9ybT0icm90YXRlKDEyMCkiLz4KPHVzZSB4bGluazpocmVmPSIjYmxkIiB0cmFuc2Zvcm09InJvdGF0ZSgyNDApIi8+Cjwvc3ZnPg==&labelColor=fff)](https://git.gammaspectra.live/git/go-away) ![](https://git.gammaspectra.live/git/go-away/badges/stars.svg?style=flat) [![](https://git.gammaspectra.live/git/go-away/badges/issues/open.svg?style=flat)](https://git.gammaspectra.live/git/go-away/issues?state=open) [![](https://git.gammaspectra.live/git/go-away/badges/pulls/open.svg?style=flat)](https://git.gammaspectra.live/git/go-away/pulls?state=open)
[![Codeberg](https://img.shields.io/badge/Codeberg-mirror+packages-2185D0?style=flat&logo=codeberg&labelColor=fff)](https://codeberg.org/WeebDataHoarder/go-away) ![](https://codeberg.org/WeebDataHoarder/go-away/badges/stars.svg?style=flat)
[![GitHub](https://img.shields.io/badge/GitHub-mirror+packages-blue?style=flat&logo=github&labelColor=fff&logoColor=24292f)](https://github.com/WeebDataHoarder/go-away) ![](https://img.shields.io/github/stars/WeebDataHoarder/go-away?style=flat)
[![sourcehut](https://img.shields.io/badge/sourcehut-mirror-blue?style=flat&logo=sourcehut&labelColor=fff&logoColor=000)](https://git.sr.ht/~datahoarder/go-away)
Note that issues or pull requests should be issued on the [main Forge](https://git.gammaspectra.live/git/go-away).
## Features
@@ -42,26 +54,19 @@ Rules and conditions are served with this environment:
```
remoteAddress (net.IP) - Connecting client remote address from headers or properties
remoteAddress.network(networkName string) bool - Check whether a given IP is listed on the underlying defined network
remoteAddress.network(networkCIDR string) bool - Check whether a given IP is listed on the CIDR
host (string) - HTTP Host
method (string) - HTTP Method/Verb
userAgent (string) - HTTP User-Agent header
path (string) - HTTP request Path
query (map[string]string) - HTTP request Query arguments
headers (map[string]string) - HTTP request headers
fp (map[string]string) - Available fingerprints
Only available when TLS is enabled
fpJA3N (string) JA3N TLS Fingerprint
fpJA4 (string) JA4 TLS Fingerprint
```
Additionally, these functions are available:
```
Check whether a given IP is listed on the underlying defined network or CIDR
inNetwork(networkName string, address net.IP) bool
inNetwork(networkCIDR string, address net.IP) bool
Check whether a given IP is listed on the provided DNSBL
inDNSBL(address net.IP) bool
fp.ja3n (string) JA3N TLS Fingerprint
fp.ja4 (string) JA4 TLS Fingerprint
```
### Template support
@@ -77,14 +82,23 @@ External templates for your site can be loaded specifying a full path to the `.g
### Extended rule actions
In addition to the common PASS / CHALLENGE / DENY rules, we offer CHECK and POISON.
In addition to the common PASS / CHALLENGE / DENY rules, go-away offers more actions that can be extended via code.
| Action | Behavior | Terminating |
|:---------:|:------------------------------------------------------------------------|:-----------:|
| PASS | Passes the request to the backend immediately | Yes |
| DENY | Denies the request with a descriptive page | Yes |
| BLOCK | Denies the request with a response code | Yes |
| DROP | Drops the connection without sending a reply | Yes |
| CHALLENGE | Issues a challenge that when passed, acts like PASS | Yes |
| CHECK | Issues a challenge that when passed, continues executing rules | No |
| PROXY | Proxies request to a different backend, with optional path replacements | Yes |
CHECK allows the client to be challenged but continue matching rules after these, for example, chaining a list of challenges that must be passed.
For example, you could use this to implement browser in checks without explicitly allowing all requests, and later deferring to a secondary check/challenge.
POISON sends defined responses to bad clients that will annoy them.
This must be configured by the operator, some networks have been seen to only stop when served back this output.
Currently, an HTML payload exists that uncompressed to about one GiB of nonsense DOM. You could use this to send garbage for would-be training data.
PROXY allows the operator to send matching requests to a different backend, for example, a poison generator or a scraping maze.
### Multiple challenge matching
@@ -94,16 +108,17 @@ For example:
```yaml
- name: standard-browser
action: challenge
challenges: [http-cookie-check, self-preload-link, self-meta-refresh, self-resource-load, js-pow-sha256]
settings:
challenges: [http-cookie-check, preload-link, meta-refresh, resource-load, js-pow-sha256]
conditions:
- '($is-generic-browser)'
```
This rule has the user be checked against a backend, then attempts pass a few browser challenges.
In this case the processing would stop at `self-meta-refresh` due to the behavior of earlier challenges (cookie check and preload link allow failing / continue due to being silent, while meta-refresh requires displaying a challenge page).
In this case the processing would stop at `meta-refresh` due to the behavior of earlier challenges (cookie check and preload link allow failing / continue due to being silent, while meta-refresh requires displaying a challenge page).
Any of these listed challenges being passed in the past will allow the client through, including non-offered `self-resource-load` and `js-pow-sha256`.
Any of these listed challenges being passed in the past will allow the client through, including non-offered `resource-load` and `js-pow-sha256`.
### Non-Javascript challenges
@@ -144,19 +159,6 @@ This can be targeted on conditions or other application logic.
Read more about [JA3](https://medium.com/salesforce-engineering/tls-fingerprinting-with-ja3-and-ja3s-247362855967) and [JA4](https://github.com/FoxIO-LLC/ja4/blob/main/technical_details/README.md).
### DNSBL
You can configure a [DNSBL (Domain Name System blocklist)](https://en.wikipedia.org/wiki/Domain_Name_System_blocklist) to be queried on rules and conditions.
This allows you to serve harder or different challenges to higher risk clients, or block them from specific sections.
Only rules that match DNSBL will cause a query to be sent, meaning the bulk of requests will not be sent to this service upstream.
Results will be temporarily cached
By default, [DroneBL](https://dronebl.org/) is used.
### Network range and automated filtering
Some specific search spiders do follow _robots.txt_ and are well behaved. However, many actors can reuse user agents, so the origin network ranges must be checked.
@@ -194,6 +196,8 @@ By default, a random temporary key is generated every run.
Multiple backends are supported, and rules specific on backend can be defined, and conditions and rules can match this as well.
Subdomain wildcards like `*.example.com`, or full fallback wildcard `*` are supported.
This allows one instance to run multiple domains or subdomains.
### Package path
@@ -215,7 +219,6 @@ This is tracked by tagging challenges with a readable flag indicating the type o
The policy file at [examples/forgejo.yml](examples/forgejo.yml) provides a ready template to be used on your own Forgejo instance.
Important notes:
* Edit the `homesite` rule, as it's targeted to common users or orgs on the instance. A better regex might be possible in the future.
* Edit the `http-cookie-check` challenge, as this will fetch the listed backend with the given session cookie to check for user login.
* Adjust the desired blocked networks or others. A template list of network ranges is provided, feel free to remove these if not needed.
* Check the conditions and base rules to change your challenges offered and other ordering.
@@ -233,6 +236,14 @@ Important notes:
* Add or modify rules to target specific pages on your site as desired.
* By default Googlebot / Bingbot / DuckDuckBot / Kagibot / Qwantbot / Yandexbot are allowed by useragent and network ranges.
### Snippets
You can define snippets to be included. YAML anchors/aliases are supported.
See [examples/snippets/](examples/snippets/) for some defaults including indexer bots, challenges and other general matches.
## Why do this?
In the past few years this small git instance has been hit by waves and waves of scraping.
This was usually fought back by random useragent blocks for bots that did not follow [robots.txt](/robots.txt), until the past half year, where low-effort mass scraping was used more prominently.
@@ -280,16 +291,16 @@ go-away offers a highly configurable set of challenges and rules that you can ad
go-away has most of the desired features from the original checklist that was made in its development.
However, a few points are left before go-away can be called v1.0.0:
* [ ] Several parts of the code are going through a refactor, which won't impact end users or operators.
* [x] Several parts of the code are going through a refactor, which won't impact end users or operators.
* [ ] Documentation is lacking and a more extensive one with inline example is in the works.
* [ ] Policy file syntax is going to stay mostly unchanged, except in the challenges definition section.
* [x] Policy file syntax is going to stay mostly unchanged, except in the challenges definition section.
* [ ] Allow users to pick fallback challenges if any fail, specially with custom ones.
* [ ] Replace Anubis-like default template with own one.
* [ ] Define strings and multi-language support for quick modification by operators without custom templates.
* [ ] Have highly tested paths that match examples.
* [ ] Caching of temporary fetches, for example, network ranges.
* [ ] Allow live and dynamic policy reloading.
* [ ] Multiple domains / subdomains -> one backend handling, CEL rules for backends
* [x] Caching of temporary fetches, for example, network ranges.
* [x] Allow live and dynamic policy reloading.
* [x] Multiple domains / subdomains -> one backend handling, CEL rules for backends
* [ ] Merge all rules and conditions into one large AST for higher performance.
* [ ] Explore exposing a module for direct Caddy usage.
* [ ] More defined way of picking HTTP/HTTP(s) listeners and certificates.
@@ -330,6 +341,8 @@ Available under [Dockerfile](Dockerfile). See the _docker compose_ below for the
Example follows a hypothetical Forgejo server running on `http://forgejo:3000` serving `git.example.com`
Container images are published under `git.gammaspectra.live/git/go-away`, `codeberg.org/weebdatahoarder/go-away` and `ghcr.io/weebdatahoarder/go-away`
```yaml
networks:
forgejo:
@@ -340,6 +353,8 @@ volumes:
services:
go-away:
# image: codeberg.org/weebdatahoarder/go-away:latest
# image: ghcr.io/weebdatahoarder/go-away:latest
image: git.gammaspectra.live/git/go-away:latest
restart: always
ports:
@@ -351,6 +366,7 @@ services:
volumes:
- "goaway_cache:/cache"
- "./examples/forgejo.yml:/policy.yml:ro"
- "./examples/snippets/:/policy/snippets/:ro"
environment:
#GOAWAY_BIND: ":8080"
# Supported tcp, unix, and proxy (for enabling PROXY module for request unwrapping)
@@ -385,6 +401,8 @@ services:
#GOAWAY_BACKEND_IP_HEADER: ""
GOAWAY_POLICY: "/policy.yml"
GOAWAY_POLICY_SNIPPETS: "/policy/snippets"
# Template, and theme for the template to pick. defaults to an anubis-like one
# An file path can be specified. See embed/templates for a few examples
@@ -394,6 +412,7 @@ services:
# specify a DNSBL for usage in conditions. Defaults to DroneBL
# GOAWAY_DNSBL: "dnsbl.dronebl.org"
# Backend to match. Can be subdomain or full wildcards, "*.example.com" or "*"
GOAWAY_BACKEND: "git.example.com=http://forgejo:3000"
# additional backends can be specified via more command arguments
@@ -408,6 +427,7 @@ services:
## Other Similar Projects
* [Anubis](https://anubis.techaro.lol/): Proxy that uses JavaScript proof of work to weight request based on rules [[source]](https://github.com/TecharoHQ/anubis)
* [powxy](https://sr.ht/~runxiyu/powxy/): Powxy is a reverse proxy that protects your upstream service by challenging clients with SHA-256 proof-of-work. [[source](https://git.sr.ht/~runxiyu/powxy)]
* [anticrawl](https://flak.tedunangst.com/post/anticrawl): Go http handler / proxy for regex based rules [[source]](https://humungus.tedunangst.com/r/anticrawl)

View File

@@ -1,9 +0,0 @@
#!/bin/bash
set -e
set -o pipefail
cd "$(cd -P -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
go run ./generate-poison -path ./poison/

View File

@@ -1,180 +0,0 @@
package main
import (
"bytes"
"compress/gzip"
"flag"
"fmt"
"github.com/andybalholm/brotli"
"github.com/klauspost/compress/zstd"
"io"
"math/rand/v2"
"os"
"path"
"slices"
"strings"
"sync"
)
type poisonCharacterGenerator struct {
Header []byte
AllowedBytes []byte
Repeat int
counter int
}
func (r *poisonCharacterGenerator) Read(p []byte) (n int, err error) {
if len(r.Header) > 0 {
copy(p, r.Header)
nn := min(len(r.Header), len(p))
r.Header = r.Header[nn:]
p = p[nn:]
}
stride := min(len(p), r.Repeat)
for i := 0; i < len(p); i += stride {
copy(p[i:], bytes.Repeat([]byte{r.AllowedBytes[r.counter]}, stride))
r.counter = (r.counter + 1) % len(r.AllowedBytes)
}
return len(p), nil
}
type poisonValuesGenerator struct {
Header []byte
AllowedValues [][]byte
counter int
}
func (r *poisonValuesGenerator) Read(p []byte) (n int, err error) {
var i int
if len(r.Header) > 0 {
copy(p, r.Header)
nn := min(len(r.Header), len(p))
r.Header = r.Header[nn:]
i += nn
for i < len(p) {
copy(p[i:], r.AllowedValues[r.counter])
i += len(r.AllowedValues[r.counter])
r.counter = (r.counter + 1) % len(r.AllowedValues)
if r.counter == 0 {
break
}
}
}
for i < len(p) {
buf := slices.Repeat(r.AllowedValues[r.counter], len(r.AllowedValues)-r.counter)
copy(p[i:], buf)
i += len(buf)
r.counter = (r.counter + 1) % len(r.AllowedValues)
}
return len(p), nil
}
func main() {
outputPath := flag.String("path", "./", "path to poison files")
flag.Parse()
const Gigabyte = 1024 * 1024 * 1024
compressPoison(*outputPath, "text/html", &poisonValuesGenerator{
Header: []byte(fmt.Sprintf("<!DOCTYPE html><html><head><title>%d</title></head><body>", rand.Uint64())),
AllowedValues: [][]byte{
[]byte("<div><div class=\"\"><h2></h2></div><br>\n"),
[]byte("<span><span><p><span>\n"),
[]byte("<p></span></script><h3><p><span>\n"),
[]byte("<div><span><p></h1>"),
[]byte("</div></div></div>\n"),
[]byte("</p></p></p>"),
[]byte("<h1>Are you a bot?</h1><img>\n"),
[]byte("</span></span></span><script>{let a = (new XMLSerializer).serializeToString(document); console.log(a); let b = URL.createObjectURL(new Blob([a])); Array.from(document.getElementsByTagName(\"img\")).forEach((img) => {img.src = b;}); document.getElementsByTagName(\"body\")[0].prepend((new DOMParser()).parseFromString(a, \"text/html\"));}</script>"),
},
}, Gigabyte)
}
var poisonEncodings = []string{"br", "zstd", "gzip"}
func compressPoison(outputPath, mime string, r io.Reader, maxSize int64) {
r = io.LimitReader(r, maxSize)
var closers []func()
var encoders []io.Writer
var writers []io.Writer
var readers []io.Reader
for _, encoding := range poisonEncodings {
f, err := os.Create(path.Join(outputPath, strings.ReplaceAll(mime, "/", "_")+"."+encoding+".poison"))
if err != nil {
panic(err)
}
switch encoding {
case "zstd":
w, err := zstd.NewWriter(f, zstd.WithEncoderLevel(zstd.SpeedBestCompression), zstd.WithEncoderCRC(false), zstd.WithWindowSize(zstd.MaxWindowSize))
if err != nil {
panic(err)
}
encoders = append(encoders, w)
closers = append(closers, func() {
w.Close()
f.Close()
})
case "br":
w := brotli.NewWriterLevel(f, brotli.BestCompression)
encoders = append(encoders, w)
closers = append(closers, func() {
w.Close()
f.Close()
})
case "gzip":
w, err := gzip.NewWriterLevel(f, gzip.BestCompression)
if err != nil {
panic(err)
}
encoders = append(encoders, w)
closers = append(closers, func() {
w.Close()
f.Close()
})
}
r, w := io.Pipe()
readers = append(readers, r)
writers = append(writers, w)
}
var wg sync.WaitGroup
for i := range poisonEncodings {
wg.Add(1)
go func() {
defer wg.Done()
_, err := io.Copy(encoders[i], readers[i])
if err != nil {
panic(err)
}
closers[i]()
// discard remaining data
_, _ = io.Copy(io.Discard, readers[i])
}()
}
_, err := io.Copy(io.MultiWriter(writers...), r)
if err != nil {
panic(err)
}
for _, w := range writers {
if pw, ok := w.(io.Closer); ok {
pw.Close()
} else {
panic("writer is not a Closer")
}
}
wg.Wait()
}

View File

@@ -1,7 +1,7 @@
package main
import (
"context"
"bytes"
"crypto/ed25519"
"crypto/rand"
"crypto/tls"
@@ -15,19 +15,18 @@ import (
"github.com/pires/go-proxyproto"
"golang.org/x/crypto/acme"
"golang.org/x/crypto/acme/autocert"
"gopkg.in/yaml.v3"
"log"
"log/slog"
"maps"
"net"
"net/http"
"os"
"os/signal"
"path"
"runtime/debug"
"strconv"
"strings"
"sync"
"time"
"sync/atomic"
"syscall"
)
func setupListener(network, address, socketMode string, proxy bool) (net.Listener, string) {
@@ -77,14 +76,19 @@ func setupListener(network, address, socketMode string, proxy bool) (net.Listene
return listener, formattedAddress
}
var internalPackageName = func() string {
var internalCmdName = "go-away"
var internalMainName = "go-away"
var internalMainVersion = "dev"
func init() {
buildInfo, ok := debug.ReadBuildInfo()
if !ok {
return "go-away"
return
}
return buildInfo.Path
}()
internalCmdName = buildInfo.Path
internalMainName = buildInfo.Main.Path
internalMainVersion = buildInfo.Main.Version
}
type MultiVar []string
@@ -129,20 +133,20 @@ func main() {
slogLevel := flag.String("slog-level", "WARN", "logging level (see https://pkg.go.dev/log/slog#hdr-Levels)")
debugMode := flag.Bool("debug", false, "debug mode with logs and server timings")
passThrough := flag.Bool("passthrough", false, "passthrough mode sends all requests to matching backends until state is loaded")
check := flag.Bool("check", false, "check configuration and policies, then exit")
acmeAutocert := flag.String("acme-autocert", "", "enables HTTP(s) mode and uses the provided ACME server URL or available service (available: letsencrypt)")
clientIpHeader := flag.String("client-ip-header", "", "Client HTTP header to fetch their IP address from (X-Real-Ip, X-Client-Ip, X-Forwarded-For, Cf-Connecting-Ip, etc.)")
backendIpHeader := flag.String("backend-ip-header", "", "Backend HTTP header to set the client IP address from, if empty defaults to leaving Client header alone (X-Real-Ip, X-Client-Ip, X-Forwarded-For, Cf-Connecting-Ip, etc.)")
dnsbl := flag.String("dnsbl", "dnsbl.dronebl.org", "blocklist for DNSBL (default DroneBL)")
cachePath := flag.String("cache", path.Join(os.TempDir(), "go_away_cache"), "path to temporary cache directory")
policyFile := flag.String("policy", "", "path to policy YAML file")
policySnippets := flag.String("policy-snippets", "", "path to YAML snippets folder")
challengeTemplate := flag.String("challenge-template", "anubis", "name or path of the challenge template to use (anubis, forgejo)")
challengeTemplateTheme := flag.String("challenge-template-theme", "", "name of the challenge template theme to use (forgejo => [forgejo-auto, forgejo-dark, forgejo-light, gitea...])")
packageName := flag.String("package-path", internalPackageName, "package name to expose in .well-known url path")
packageName := flag.String("package-path", internalCmdName, "package name to expose in .well-known url path")
jwtPrivateKeySeed := flag.String("jwt-private-key-seed", "", "Seed for the jwt private key, or on JWT_PRIVATE_KEY_SEED env. One be generated by passing \"generate\" as a value, follows RFC 8032 private key definition. Defaults to random")
@@ -170,6 +174,8 @@ func main() {
slog.SetDefault(slog.New(h))
}
slog.Info("go-away", "package", internalMainName, "version", internalMainVersion, "cmd", internalCmdName)
var seed []byte
var kValue string
@@ -200,22 +206,9 @@ func main() {
}
policyData, err := os.ReadFile(*policyFile)
if err != nil {
log.Fatal(fmt.Errorf("failed to read policy file: %w", err))
}
var p policy.Policy
if err = yaml.Unmarshal(policyData, &p); err != nil {
log.Fatal(fmt.Errorf("failed to parse policy file: %w", err))
}
createdBackends := make(map[string]http.Handler)
parsedBackends := make(map[string]string)
//TODO: deprecate
maps.Copy(parsedBackends, p.Backends)
for _, backend := range backends {
parts := strings.Split(backend, "=")
if len(parts) != 2 {
@@ -234,11 +227,27 @@ func main() {
createdBackends[k] = backend
}
if len(createdBackends) == 0 {
log.Fatal(fmt.Errorf("no backends defined in policy file"))
}
var cache utils.Cache
if *cachePath != "" {
err = os.MkdirAll(*cachePath, 0755)
if err != nil {
log.Fatal(fmt.Errorf("failed to create cache directory: %w", err))
}
for _, n := range []string{"networks", "acme"} {
err = os.MkdirAll(path.Join(*cachePath, n), 0755)
if err != nil {
log.Fatal(fmt.Errorf("failed to create cache sub directory %s: %w", n, err))
}
}
cache, err = utils.CacheDirectory(*cachePath)
if err != nil {
log.Fatal(fmt.Errorf("failed to open cache directory: %w", err))
}
}
var tlsConfig *tls.Config
@@ -264,99 +273,112 @@ func main() {
tlsConfig = acmeManager.TLSConfig()
}
var wg sync.WaitGroup
loadPolicyState := func() (http.Handler, error) {
policyData, err := os.ReadFile(*policyFile)
if err != nil {
return nil, fmt.Errorf("failed to read policy file: %w", err)
}
passThroughCtx, cancelFunc := context.WithCancel(context.Background())
defer cancelFunc()
p, err := policy.NewPolicy(bytes.NewReader(policyData), *policySnippets)
if err != nil {
return nil, fmt.Errorf("failed to parse policy file: %w", err)
}
if *passThrough {
wg.Add(1)
go func() {
defer wg.Done()
settings := policy.Settings{
Cache: cache,
Backends: createdBackends,
Debug: *debugMode,
MainName: internalMainName,
MainVersion: internalMainVersion,
PackageName: *packageName,
ChallengeTemplate: *challengeTemplate,
ChallengeTemplateTheme: *challengeTemplateTheme,
PrivateKeySeed: seed,
ClientIpHeader: *clientIpHeader,
BackendIpHeader: *backendIpHeader,
ChallengeResponseCode: http.StatusTeapot,
}
server := utils.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
backend, ok := createdBackends[r.Host]
if !ok {
http.Error(w, http.StatusText(http.StatusServiceUnavailable), http.StatusServiceUnavailable)
return
}
state, err := lib.NewState(*p, settings)
backend.ServeHTTP(w, r)
}), tlsConfig)
listener, listenUrl := setupListener(*bindNetwork, *bind, *socketMode, *bindProxy)
slog.Warn(
"listening passthrough",
"url", listenUrl,
)
defer listener.Close()
wg.Add(1)
go func() {
defer wg.Done()
if tlsConfig != nil {
if err := server.ServeTLS(listener, "", ""); !errors.Is(err, http.ErrServerClosed) {
log.Fatal(err)
}
} else {
if err := server.Serve(listener); !errors.Is(err, http.ErrServerClosed) {
log.Fatal(err)
}
}
}()
<-passThroughCtx.Done()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := server.Shutdown(ctx); err != nil {
log.Fatal(err)
}
_ = server.Close()
}()
if err != nil {
return nil, fmt.Errorf("failed to create state: %w", err)
}
return state, nil
}
settings := lib.StateSettings{
Backends: createdBackends,
Debug: *debugMode,
PackageName: *packageName,
ChallengeTemplate: *challengeTemplate,
ChallengeTemplateTheme: *challengeTemplateTheme,
PrivateKeySeed: seed,
ClientIpHeader: *clientIpHeader,
BackendIpHeader: *backendIpHeader,
if *check {
_, err := loadPolicyState()
if err != nil {
slog.Error(err.Error())
os.Exit(1)
}
slog.Info("load ok")
os.Exit(0)
}
if *dnsbl != "" {
settings.DNSBL = utils.NewDNSBL(*dnsbl, net.DefaultResolver)
}
state, err := lib.NewState(p, settings)
if err != nil {
log.Fatal(fmt.Errorf("failed to create state: %w", err))
}
// cancel the existing server listener
cancelFunc()
wg.Wait()
listener, listenUrl := setupListener(*bindNetwork, *bind, *socketMode, *bindProxy)
slog.Warn(
"listening",
"url", listenUrl,
)
server := utils.NewServer(state, tlsConfig)
var serverHandler atomic.Pointer[http.Handler]
server := utils.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if handler := serverHandler.Load(); handler == nil {
http.Error(w, http.StatusText(http.StatusBadGateway), http.StatusBadGateway)
} else {
(*handler).ServeHTTP(w, r)
}
}), tlsConfig)
if *passThrough {
// setup a passthrough handler temporarily
fn := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
backend := utils.SelectHTTPHandler(createdBackends, r.Host)
if backend == nil {
http.Error(w, http.StatusText(http.StatusBadGateway), http.StatusBadGateway)
} else {
backend.ServeHTTP(w, r)
}
}))
serverHandler.Store(&fn)
}
go func() {
handler, err := loadPolicyState()
if err != nil {
log.Fatal(fmt.Errorf("failed to load policy state: %w", err))
}
serverHandler.Store(&handler)
slog.Warn(
"handler configuration loaded",
)
// allow reloading from now on
c := make(chan os.Signal, 1)
signal.Notify(c, syscall.SIGHUP)
for sig := range c {
if sig != syscall.SIGHUP {
continue
}
handler, err = loadPolicyState()
if err != nil {
slog.Error("handler configuration reload error", "err", err)
continue
}
serverHandler.Store(&handler)
slog.Warn("handler configuration reloaded")
}
}()
if tlsConfig != nil {
if err := server.ServeTLS(listener, "", ""); !errors.Is(err, http.ErrServerClosed) {
log.Fatal(err)
}
} else {
if err := server.Serve(listener); !errors.Is(err, http.ErrServerClosed) {
log.Fatal(err)
}

View File

@@ -1,15 +1,59 @@
package embed
import "embed"
import (
"embed"
"errors"
"io/fs"
"os"
)
//go:embed assets
var AssetsFs embed.FS
var assetsFs embed.FS
//go:embed challenge
var ChallengeFs embed.FS
var challengeFs embed.FS
//go:embed templates
var TemplatesFs embed.FS
var templatesFs embed.FS
//go:embed poison/*.poison
var PoisonFs embed.FS
type FSInterface interface {
fs.FS
fs.ReadDirFS
fs.ReadFileFS
}
func trimPrefix(embedFS embed.FS, prefix string) FSInterface {
subFS, err := fs.Sub(embedFS, prefix)
if err != nil {
panic(err)
}
if properFS, ok := subFS.(FSInterface); ok {
return properFS
} else {
panic("unsupported")
}
}
var ChallengeFs = trimPrefix(challengeFs, "challenge")
var TemplatesFs = trimPrefix(templatesFs, "templates")
var AssetsFs = trimPrefix(assetsFs, "assets")
func GetFallbackFS(embedFS FSInterface, prefix string) (FSInterface, error) {
var outFs fs.FS
if stat, err := os.Stat(prefix); err == nil && stat.IsDir() {
outFs = embedFS
} else if _, err := embedFS.ReadDir(prefix); err == nil {
outFs, err = fs.Sub(embedFS, prefix)
if err != nil {
return nil, err
}
} else {
return nil, err
}
if properFS, ok := outFs.(FSInterface); ok {
return properFS, nil
} else {
return nil, errors.New("unsupported FS")
}
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -11,7 +11,7 @@
<meta name="{{ $key }}" content="{{ $value }}"/>
{{end}}
{{ end }}
{{ range .Tags }}
{{ range .HeaderTags }}
{{ . }}
{{ end }}
<style>
@@ -155,7 +155,6 @@
/>
{{if .Challenge }}
<p id="status">Loading challenge <em>{{ .Challenge }}</em>...</p>
<script async type="module" src="{{ .Path }}/challenge/{{ .Challenge }}/challenge.mjs?cacheBust={{ .Random }}"></script>
{{else if .Error}}
<p id="status">Error: {{ .Error }}</p>
{{else}}
@@ -202,6 +201,11 @@
</p>
</center>
</footer>
{{ range .EndTags }}
{{ . }}
{{ end }}
</main>
</body>
</html>

View File

@@ -17,7 +17,7 @@
<meta name="{{ $key }}" content="{{ $value }}"/>
{{end}}
{{ end }}
{{ range .Tags }}
{{ range .HeaderTags }}
{{ . }}
{{ end }}
@@ -62,7 +62,6 @@
{{if .Challenge }}
<h3 id="status">Loading challenge <em>{{ .Challenge }}</em>...</h3>
<script async type="module" src="{{ .Path }}/challenge/{{ .Challenge }}/challenge.mjs?cacheBust={{ .Random }}"></script>
{{else if .Error}}
<h3 id="status">Error: {{ .Error }}</h3>
{{else}}
@@ -110,5 +109,9 @@
</div>
</footer>
{{ range .EndTags }}
{{ . }}
{{ end }}
</body>
</html>

View File

@@ -1,189 +1,41 @@
# Example cmdline (forward requests from upstream to port :8080)
# $ go-away --bind :8080 --backend git.example.com=http://forgejo:3000 --policy examples/forgejo.yml --challenge-template forgejo --challenge-template-theme forgejo-auto
# $ go-away --bind :8080 --backend git.example.com=http://forgejo:3000 --policy examples/forgejo.yml --policy-snippets example/snippets/ --challenge-template forgejo --challenge-template-theme forgejo-auto
# Define networks to be used later below
networks:
# todo: support direct ASN lookups
# todo: cache these values
# Networks will get included from snippets
huawei-cloud:
# AS136907
- url: https://raw.githubusercontent.com/ipverse/asn-ip/refs/heads/master/as/136907/aggregated.json
jq-path: '.subnets.ipv4[], .subnets.ipv6[]'
- asn: 136907
alibaba-cloud:
# AS45102
- url: https://raw.githubusercontent.com/ipverse/asn-ip/refs/heads/master/as/45102/aggregated.json
jq-path: '.subnets.ipv4[], .subnets.ipv6[]'
- asn: 45102
zenlayer-inc:
# AS21859
- url: https://raw.githubusercontent.com/ipverse/asn-ip/refs/heads/master/as/21859/aggregated.json
jq-path: '.subnets.ipv4[], .subnets.ipv6[]'
aws-cloud:
- url: https://ip-ranges.amazonaws.com/ip-ranges.json
jq-path: '(.prefixes[] | select(has("ip_prefix")) | .ip_prefix), (.prefixes[] | select(has("ipv6_prefix")) | .ipv6_prefix)'
google-cloud:
- url: https://www.gstatic.com/ipranges/cloud.json
jq-path: '(.prefixes[] | select(has("ipv4Prefix")) | .ipv4Prefix), (.prefixes[] | select(has("ipv6Prefix")) | .ipv6Prefix)'
oracle-cloud:
- url: https://docs.oracle.com/en-us/iaas/tools/public_ip_ranges.json
jq-path: '.regions[] | .cidrs[] | .cidr'
azure-cloud:
# todo: https://www.microsoft.com/en-us/download/details.aspx?id=56519 does not provide direct JSON
- url: https://raw.githubusercontent.com/femueller/cloud-ip-ranges/refs/heads/master/microsoft-azure-ip-ranges.json
jq-path: '.values[] | .properties.addressPrefixes[]'
digitalocean:
- url: https://www.digitalocean.com/geo/google.csv
regex: "(?P<prefix>(([0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+)|([0-9a-f:]+::))/[0-9]+),"
linode:
- url: https://geoip.linode.com/
regex: "(?P<prefix>(([0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+)|([0-9a-f:]+::))/[0-9]+),"
vultr:
- url: "https://geofeed.constant.com/?json"
jq-path: '.subnets[] | .ip_prefix'
cloudflare:
- url: https://www.cloudflare.com/ips-v4
regex: "(?P<prefix>[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+/[0-9]+)"
- url: https://www.cloudflare.com/ips-v6
regex: "(?P<prefix>[0-9a-f:]+::/[0-9]+)"
icloud-private-relay:
- url: https://mask-api.icloud.com/egress-ip-ranges.csv
regex: "(?P<prefix>(([0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+)|([0-9a-f:]+::))/[0-9]+),"
tunnelbroker-relay:
# HE Tunnelbroker
- url: https://tunnelbroker.net/export/google
regex: "(?P<prefix>([0-9a-f:]+::)/[0-9]+),"
googlebot:
- url: https://developers.google.com/static/search/apis/ipranges/googlebot.json
jq-path: '(.prefixes[] | select(has("ipv4Prefix")) | .ipv4Prefix), (.prefixes[] | select(has("ipv6Prefix")) | .ipv6Prefix)'
bingbot:
- url: https://www.bing.com/toolbox/bingbot.json
jq-path: '(.prefixes[] | select(has("ipv4Prefix")) | .ipv4Prefix), (.prefixes[] | select(has("ipv6Prefix")) | .ipv6Prefix)'
qwantbot:
- url: https://help.qwant.com/wp-content/uploads/sites/2/2025/01/qwantbot.json
jq-path: '(.prefixes[] | select(has("ipv4Prefix")) | .ipv4Prefix), (.prefixes[] | select(has("ipv6Prefix")) | .ipv6Prefix)'
duckduckbot:
- url: https://duckduckgo.com/duckduckgo-help-pages/results/duckduckbot
regex: "<li><div>(?P<prefix>[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+)</div></li>"
yandexbot:
# todo: detected as bot
# - url: https://yandex.com/ips
# regex: "<span>(?P<prefix>(([0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+)|([0-9a-f:]+::))/[0-9]+)[ \\\\t]*</span><br/>"
- prefixes:
- "5.45.192.0/18"
- "5.255.192.0/18"
- "37.9.64.0/18"
- "37.140.128.0/18"
- "77.88.0.0/18"
- "84.252.160.0/19"
- "87.250.224.0/19"
- "90.156.176.0/22"
- "93.158.128.0/18"
- "95.108.128.0/17"
- "141.8.128.0/18"
- "178.154.128.0/18"
- "185.32.187.0/24"
- "2a02:6b8::/29"
kagibot:
- url: https://kagi.com/bot
regex: "\\n(?P<prefix>[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+) "
- asn: 21859
challenges:
js-pow-sha256:
# Asset must be under challenges/{name}/static/{asset}
# Other files here will be available under that path
mode: js
asset: load.mjs
parameters:
difficulty: 20
runtime:
mode: wasm
# Verify must be under challenges/{name}/runtime/{asset}
asset: runtime.wasm
probability: 0.02
# Challenges with a cookie, self redirect (non-JS, requires HTTP parsing)
self-cookie:
mode: "cookie"
# Challenges with a redirect via Link header with rel=preload and early hints (non-JS, requires HTTP parsing, fetching and logic)
# Works on HTTP/2 and above!
self-preload-link:
condition: '"Sec-Fetch-Mode" in headers && headers["Sec-Fetch-Mode"] == "navigate"'
mode: "preload-link"
runtime:
# verifies that result = key
mode: "key"
probability: 0.1
parameters:
preload-early-hint-deadline: 3s
key-code: 200
key-mime: text/css
key-content: ""
# Challenges with a redirect via Refresh header (non-JS, requires HTTP parsing and logic)
self-header-refresh:
mode: "header-refresh"
runtime:
# verifies that result = key
mode: "key"
probability: 0.1
# Challenges with a redirect via Refresh meta (non-JS, requires HTML parsing and logic)
self-meta-refresh:
mode: "meta-refresh"
runtime:
# verifies that result = key
mode: "key"
probability: 0.1
# Challenges with loading a random CSS or image document (non-JS, requires HTML parsing and logic)
self-resource-load:
mode: "resource-load"
runtime:
# verifies that result = key
mode: "key"
probability: 0.1
parameters:
key-code: 200
key-mime: text/css
key-content: ""
# Challenges will get included from snippets
# Verifies the existence of a cookie and confirms it against some backend request, passing the entire client cookie contents
http-cookie-check:
mode: http
url: http://forgejo:3000/user/stopwatches
# url: http://forgejo:3000/repo/search
# url: http://forgejo:3000/notifications/new
runtime: http
parameters:
http-url: http://forgejo:3000/user/stopwatches
# http-url: http://forgejo:3000/repo/search
# http-url: http://forgejo:3000/notifications/new
http-method: GET
http-cookie: i_like_gitea
http-code: 200
verify-probability: 0.1
conditions:
# Conditions will get replaced on rules AST when found as ($condition-name)
# Checks to detect a headless chromium via headers only
is-headless-chromium:
- 'userAgent.contains("HeadlessChrome") || userAgent.contains("HeadlessChromium")'
- '"Sec-Ch-Ua" in headers && (headers["Sec-Ch-Ua"].contains("HeadlessChrome") || headers["Sec-Ch-Ua"].contains("HeadlessChromium"))'
#- '(userAgent.contains("Chrome/") || userAgent.contains("Chromium/")) && (!("Accept-Language" in headers) || !("Accept-Encoding" in headers))'
is-generic-browser:
- 'userAgent.startsWith("Mozilla/") || userAgent.startsWith("Opera/")'
is-well-known-asset:
- 'path == "/robots.txt"'
- 'path.startsWith("/.well-known")'
# Conditions will get included from snippets
is-static-asset:
- 'path == "/favicon.ico"'
- 'path == "/apple-touch-icon.png"'
- 'path == "/apple-touch-icon-precomposed.png"'
- 'path.startsWith("/assets/")'
@@ -193,42 +45,12 @@ conditions:
- 'path.startsWith("/user/avatar/")'
- 'path.startsWith("/attachments/")'
is-git-ua:
- 'userAgent.startsWith("git/") || userAgent.contains("libgit")'
- 'userAgent.startsWith("go-git")'
- 'userAgent.startsWith("JGit/") || userAgent.startsWith("JGit-")'
# Golang proxy and initial fetch
- 'userAgent.startsWith("GoModuleMirror/")'
- 'userAgent.startsWith("Go-http-client/") && "go-get" in query && query["go-get"] == "1"'
- '"Git-Protocol" in headers && headers["Git-Protocol"] == "version=2"'
is-git-path:
- 'path.matches("^/[^/]+/[^/]+/(git-upload-pack|git-receive-pack|HEAD|info/refs|info/lfs|objects)")'
is-generic-robot-ua:
- 'userAgent.contains("compatible;") && !userAgent.contains("Trident/")'
- 'userAgent.matches("\\+https?://")'
- 'userAgent.contains("@")'
- 'userAgent.matches("[bB]ot/[0-9]")'
is-tool-ua:
- 'userAgent.startsWith("python-requests/")'
- 'userAgent.startsWith("Python-urllib/")'
- 'userAgent.startsWith("python-httpx/")'
- 'userAgent.contains("aoihttp/")'
- 'userAgent.startsWith("http.rb/")'
- 'userAgent.startsWith("curl/")'
- 'userAgent.startsWith("Wget/")'
- 'userAgent.startsWith("libcurl/")'
- 'userAgent.startsWith("okhttp/")'
- 'userAgent.startsWith("Java/")'
- 'userAgent.startsWith("Apache-HttpClient//")'
- 'userAgent.startsWith("Go-http-client/")'
- 'userAgent.startsWith("node-fetch/")'
- 'userAgent.startsWith("reqwest/")'
is-suspicious-crawler:
# TLS Fingerprint for specific agent without ALPN
- '(userAgent.startsWith("Mozilla/") || userAgent.startsWith("Opera/")) && fpJA4.matches("^t[0-9a-z]+00_")'
- '(userAgent.startsWith("Mozilla/") || userAgent.startsWith("Opera/")) && ("ja4" in fp && fp.ja4.matches("^t[0-9a-z]+00_"))'
# Old engines
- 'userAgent.contains("Presto/") || userAgent.contains("Trident/")'
# Old IE browsers
@@ -243,6 +65,7 @@ conditions:
- 'userAgent.startsWith("Opera/")'
#- 'userAgent.matches("Gecko/(201[0-9]|200[0-9])")'
- 'userAgent.matches("^Mozilla/[1-4]")'
is-heavy-resource:
- 'path.startsWith("/explore/")'
- 'path.matches("^/[^/]+/[^/]+/src/commit/")'
@@ -271,8 +94,8 @@ rules:
- name: undesired-networks
conditions:
- 'inNetwork("huawei-cloud", remoteAddress) || inNetwork("alibaba-cloud", remoteAddress) || inNetwork("zenlayer-inc", remoteAddress)'
action: poison
- 'remoteAddress.network("huawei-cloud") || remoteAddress.network("alibaba-cloud") || remoteAddress.network("zenlayer-inc")'
action: drop
- name: undesired-crawlers
conditions:
@@ -296,7 +119,7 @@ rules:
- 'userAgent.contains("Amazonbot") || userAgent.contains("Google-Extended") || userAgent.contains("PanguBot") || userAgent.contains("AI2Bot") || userAgent.contains("Diffbot") || userAgent.contains("cohere-training-data-crawler") || userAgent.contains("Applebot-Extended")'
# SEO / Ads and marketing
- 'userAgent.contains("BLEXBot")'
action: poison
action: drop
- name: unknown-crawlers
conditions:
@@ -305,22 +128,22 @@ rules:
action: deny
# check a sequence of challenges for non logged in
- name: suspicious-crawlers/0
- name: suspicious-crawlers
conditions: ['($is-suspicious-crawler)']
action: check
challenges: [js-pow-sha256, http-cookie-check]
- name: suspicious-crawlers/1
conditions: ['($is-suspicious-crawler)']
action: check
challenges: [self-preload-link]
- name: suspicious-crawlers/2
conditions: ['($is-suspicious-crawler)']
action: check
challenges: [self-header-refresh]
- name: suspicious-crawlers/3
conditions: ['($is-suspicious-crawler)']
action: check
challenges: [self-resource-load]
action: none
children:
- name: 0
action: check
settings:
challenges: [js-pow-sha256, http-cookie-check]
- name: 1
action: check
settings:
challenges: [preload-link, resource-load]
- name: 2
action: check
settings:
challenges: [header-refresh]
- name: always-pow-challenge
conditions:
@@ -337,7 +160,8 @@ rules:
# Match archive downloads from browsers and not tools
- 'path.matches("^/[^/]+/[^/]+/archive/.*\\.(bundle|zip|tar\\.gz)") && ($is-generic-browser)'
action: challenge
challenges: [ js-pow-sha256 ]
settings:
challenges: [ js-pow-sha256 ]
- name: allow-git-operations
conditions:
@@ -393,32 +217,37 @@ rules:
# generic /*/*/ match gave too many options for scrapers to trigger random endpoints
# this is a negative match of endpoints that Forgejo holds as reserved as users or orgs
# see https://codeberg.org/forgejo/forgejo/src/branch/forgejo/models/user/user.go#L582
- '(path.matches("^/[^/]+/[^/]+/?$") || path.matches("^/[^/]+/[^/]+/(issues|pulls)/[0-9]+$") || (path.matches("^/[^/]+/?$") && size(query) == 0)) && !path.matches("(?i)^/(api|metrics|v2|assets|attachments|avatar|avatars|repo-avatars|captcha|login|org|repo|user|admin|devtest|explore|issues|pulls|milestones|notifications|ghost)(/|$)")'
- '(path.matches("^/[^/]+/[^/]+/?$") || path.matches("^/[^/]+/[^/]+/badges/") || path.matches("^/[^/]+/[^/]+/(issues|pulls)/[0-9]+$") || (path.matches("^/[^/]+/?$") && size(query) == 0)) && !path.matches("(?i)^/(api|metrics|v2|assets|attachments|avatar|avatars|repo-avatars|captcha|login|org|repo|user|admin|devtest|explore|issues|pulls|milestones|notifications|ghost)(/|$)")'
action: pass
- name: desired-crawlers
conditions:
- 'userAgent.contains("+https://kagi.com/bot") && inNetwork("kagibot", remoteAddress)'
- '(userAgent.contains("+http://www.google.com/bot.html") || userAgent.contains("Google-PageRenderer") || userAgent.contains("Google-InspectionTool") || userAgent.contains("Googlebot")) && inNetwork("googlebot", remoteAddress)'
- 'userAgent.contains("+http://www.bing.com/bingbot.htm") && inNetwork("bingbot", remoteAddress)'
- 'userAgent.contains("+http://duckduckgo.com/duckduckbot.html") && inNetwork("duckduckbot", remoteAddress)'
- 'userAgent.contains("+https://help.qwant.com/bot/") && inNetwork("qwantbot", remoteAddress)'
- 'userAgent.contains("+http://yandex.com/bots") && inNetwork("yandexbot", remoteAddress)'
- *is-bot-googlebot
- *is-bot-bingbot
- *is-bot-duckduckbot
- *is-bot-kagibot
- *is-bot-qwantbot
- *is-bot-yandexbot
action: pass
# check a sequence of challenges
- name: heavy-operations/0
action: check
challenges: [self-preload-link, self-header-refresh, js-pow-sha256, http-cookie-check]
conditions: ['($is-heavy-resource)']
- name: heavy-operations/1
action: check
challenges: [self-resource-load, js-pow-sha256, http-cookie-check]
- name: heavy-operations
conditions: ['($is-heavy-resource)']
action: none
children:
- name: 0
action: check
settings:
challenges: [preload-link, header-refresh, js-pow-sha256, http-cookie-check]
- name: 1
action: check
settings:
challenges: [ resource-load, js-pow-sha256, http-cookie-check ]
- name: standard-bots
action: check
challenges: [self-meta-refresh, self-resource-load]
settings:
challenges: [meta-refresh, resource-load]
conditions:
- '($is-generic-robot-ua)'
@@ -433,15 +262,20 @@ rules:
action: pass
# check DNSBL and serve harder challenges
# todo: make this specific to score
- name: undesired-dnsbl
conditions:
- 'inDNSBL(remoteAddress)'
action: check
challenges: [js-pow-sha256, http-cookie-check]
settings:
challenges: [dnsbl]
# if DNSBL fails, check additional challenges
fail: check
fail-settings:
challenges: [js-pow-sha256, http-cookie-check]
- name: suspicious-fetchers
action: check
challenges: [js-pow-sha256]
settings:
challenges: [js-pow-sha256]
conditions:
- 'userAgent.contains("facebookexternalhit/") || userAgent.contains("facebookcatalog/")'
@@ -453,19 +287,22 @@ rules:
- name: plaintext-browser
action: challenge
challenges: [http-cookie-check, self-meta-refresh, self-cookie]
settings:
challenges: [http-cookie-check, meta-refresh, cookie]
conditions:
- 'userAgent.startsWith("Lynx/")'
- name: standard-tools
action: challenge
challenges: [self-cookie]
settings:
challenges: [cookie]
conditions:
- '($is-tool-ua)'
- '!($is-generic-browser)'
- name: standard-browser
action: challenge
challenges: [http-cookie-check, self-preload-link, self-meta-refresh, self-resource-load, js-pow-sha256]
settings:
challenges: [http-cookie-check, preload-link, meta-refresh, resource-load, js-pow-sha256]
conditions:
- '($is-generic-browser)'

View File

@@ -1,153 +1,27 @@
# Example cmdline (forward requests from upstream to port :8080)
# $ go-away --bind :8080 --backend site.example.com=http://site:3000 --policy examples/generic.yml --challenge-template anubis
# $ go-away --bind :8080 --backend site.example.com=http://site:3000 --policy examples/generic.yml --policy-snippets example/snippets/ --challenge-template anubis
# Define networks to be used later below
networks:
googlebot:
- url: https://developers.google.com/static/search/apis/ipranges/googlebot.json
jq-path: '(.prefixes[] | select(has("ipv4Prefix")) | .ipv4Prefix), (.prefixes[] | select(has("ipv6Prefix")) | .ipv6Prefix)'
bingbot:
- url: https://www.bing.com/toolbox/bingbot.json
jq-path: '(.prefixes[] | select(has("ipv4Prefix")) | .ipv4Prefix), (.prefixes[] | select(has("ipv6Prefix")) | .ipv6Prefix)'
qwantbot:
- url: https://help.qwant.com/wp-content/uploads/sites/2/2025/01/qwantbot.json
jq-path: '(.prefixes[] | select(has("ipv4Prefix")) | .ipv4Prefix), (.prefixes[] | select(has("ipv6Prefix")) | .ipv6Prefix)'
duckduckbot:
- url: https://duckduckgo.com/duckduckgo-help-pages/results/duckduckbot
regex: "<li><div>(?P<prefix>[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+)</div></li>"
yandexbot:
# todo: detected as bot
# - url: https://yandex.com/ips
# regex: "<span>(?P<prefix>(([0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+)|([0-9a-f:]+::))/[0-9]+)[ \\\\t]*</span><br/>"
- prefixes:
- "5.45.192.0/18"
- "5.255.192.0/18"
- "37.9.64.0/18"
- "37.140.128.0/18"
- "77.88.0.0/18"
- "84.252.160.0/19"
- "87.250.224.0/19"
- "90.156.176.0/22"
- "93.158.128.0/18"
- "95.108.128.0/17"
- "141.8.128.0/18"
- "178.154.128.0/18"
- "185.32.187.0/24"
- "2a02:6b8::/29"
kagibot:
- url: https://kagi.com/bot
regex: "\\n(?P<prefix>[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+) "
# Networks will get included from snippets
challenges:
js-pow-sha256:
# Asset must be under challenges/{name}/static/{asset}
# Other files here will be available under that path
mode: js
asset: load.mjs
parameters:
difficulty: 15
runtime:
mode: wasm
# Verify must be under challenges/{name}/runtime/{asset}
asset: runtime.wasm
probability: 0.02
# Challenges with a cookie, self redirect (non-JS, requires HTTP parsing)
self-cookie:
mode: "cookie"
# Challenges with a redirect via Link header with rel=preload and early hints (non-JS, requires HTTP parsing, fetching and logic)
# Works on HTTP/2 and above!
self-preload-link:
condition: '"Sec-Fetch-Mode" in headers && headers["Sec-Fetch-Mode"] == "navigate"'
mode: "preload-link"
runtime:
# verifies that result = key
mode: "key"
probability: 0.1
parameters:
preload-early-hint-deadline: 3s
key-code: 200
key-mime: text/css
key-content: ""
# Challenges with a redirect via Refresh header (non-JS, requires HTTP parsing and logic)
self-header-refresh:
mode: "header-refresh"
runtime:
# verifies that result = key
mode: "key"
probability: 0.1
# Challenges with a redirect via Refresh meta (non-JS, requires HTML parsing and logic)
self-meta-refresh:
mode: "meta-refresh"
runtime:
# verifies that result = key
mode: "key"
probability: 0.1
# Challenges with loading a random CSS or image document (non-JS, requires HTML parsing and logic)
self-resource-load:
mode: "resource-load"
runtime:
# verifies that result = key
mode: "key"
probability: 0.1
parameters:
key-code: 200
key-mime: text/css
key-content: ""
# Challenges will get included from snippets
conditions:
# Conditions will get replaced on rules AST when found as ($condition-name)
# Checks to detect a headless chromium via headers only
is-headless-chromium:
- 'userAgent.contains("HeadlessChrome") || userAgent.contains("HeadlessChromium")'
- '"Sec-Ch-Ua" in headers && (headers["Sec-Ch-Ua"].contains("HeadlessChrome") || headers["Sec-Ch-Ua"].contains("HeadlessChromium"))'
#- '(userAgent.contains("Chrome/") || userAgent.contains("Chromium/")) && (!("Accept-Language" in headers) || !("Accept-Encoding" in headers))'
is-generic-browser:
- 'userAgent.startsWith("Mozilla/") || userAgent.startsWith("Opera/")'
# Conditions will get included from snippets
is-well-known-asset:
- 'path == "/robots.txt"'
- 'path.startsWith("/.well-known")'
is-static-asset:
- 'path == "/favicon.ico"'
- 'path == "/apple-touch-icon.png"'
- 'path == "/apple-touch-icon-precomposed.png"'
- 'path.matches("\\.(manifest|ttf|woff|woff2|jpg|jpeg|gif|png|webp|avif|svg|mp4|webm|css|js|mjs|wasm)$")'
is-generic-robot-ua:
- 'userAgent.contains("compatible;") && !userAgent.contains("Trident/")'
- 'userAgent.matches("\\+https?://")'
- 'userAgent.contains("@")'
- 'userAgent.matches("[bB]ot/[0-9]")'
is-tool-ua:
- 'userAgent.startsWith("python-requests/")'
- 'userAgent.startsWith("Python-urllib/")'
- 'userAgent.startsWith("python-httpx/")'
- 'userAgent.contains("aoihttp/")'
- 'userAgent.startsWith("http.rb/")'
- 'userAgent.startsWith("curl/")'
- 'userAgent.startsWith("Wget/")'
- 'userAgent.startsWith("libcurl/")'
- 'userAgent.startsWith("okhttp/")'
- 'userAgent.startsWith("Java/")'
- 'userAgent.startsWith("Apache-HttpClient//")'
- 'userAgent.startsWith("Go-http-client/")'
- 'userAgent.startsWith("node-fetch/")'
- 'userAgent.startsWith("reqwest/")'
is-suspicious-crawler:
- 'userAgent.contains("Presto/") || userAgent.contains("Trident/")'
# Old IE browsers
@@ -198,7 +72,7 @@ rules:
- 'userAgent.contains("Amazonbot") || userAgent.contains("Google-Extended") || userAgent.contains("PanguBot") || userAgent.contains("AI2Bot") || userAgent.contains("Diffbot") || userAgent.contains("cohere-training-data-crawler") || userAgent.contains("Applebot-Extended")'
# SEO / Ads and marketing
- 'userAgent.contains("BLEXBot")'
action: deny
action: drop
- name: unknown-crawlers
conditions:
@@ -207,31 +81,31 @@ rules:
action: deny
# check a sequence of challenges
- name: suspicious-crawlers/0
- name: suspicious-crawlers
conditions: ['($is-suspicious-crawler)']
action: check
challenges: [js-pow-sha256]
- name: suspicious-crawlers/1
conditions: ['($is-suspicious-crawler)']
action: check
challenges: [self-preload-link]
- name: suspicious-crawlers/2
conditions: ['($is-suspicious-crawler)']
action: check
challenges: [self-header-refresh]
- name: suspicious-crawlers/3
conditions: ['($is-suspicious-crawler)']
action: check
challenges: [self-resource-load]
action: none
children:
- name: 0
action: check
settings:
challenges: [js-pow-sha256]
- name: 1
action: check
settings:
challenges: [preload-link, resource-load]
- name: 2
action: check
settings:
challenges: [header-refresh]
- name: desired-crawlers
conditions:
- 'userAgent.contains("+https://kagi.com/bot") && inNetwork("kagibot", remoteAddress)'
- '(userAgent.contains("+http://www.google.com/bot.html") || userAgent.contains("Google-InspectionTool") || userAgent.contains("Googlebot")) && inNetwork("googlebot", remoteAddress)'
- 'userAgent.contains("+http://www.bing.com/bingbot.htm") && inNetwork("bingbot", remoteAddress)'
- 'userAgent.contains("+http://duckduckgo.com/duckduckbot.html") && inNetwork("duckduckbot", remoteAddress)'
- 'userAgent.contains("+https://help.qwant.com/bot/") && inNetwork("qwantbot", remoteAddress)'
- 'userAgent.contains("+http://yandex.com/bots") && inNetwork("yandexbot", remoteAddress)'
- *is-bot-googlebot
- *is-bot-bingbot
- *is-bot-duckduckbot
- *is-bot-kagibot
- *is-bot-qwantbot
- *is-bot-yandexbot
action: pass
- name: homesite
@@ -240,15 +114,20 @@ rules:
action: pass
# check DNSBL and serve harder challenges
# todo: make this specific to score
- name: undesired-dnsbl
conditions:
- 'inDNSBL(remoteAddress)'
action: check
challenges: [js-pow-sha256]
settings:
challenges: [dnsbl]
# if DNSBL fails, check additional challenges
fail: check
fail-settings:
challenges: [js-pow-sha256]
- name: suspicious-fetchers
action: check
challenges: [js-pow-sha256]
settings:
challenges: [js-pow-sha256]
conditions:
- 'userAgent.contains("facebookexternalhit/") || userAgent.contains("facebookcatalog/")'
@@ -260,13 +139,15 @@ rules:
- name: plaintext-browser
action: challenge
challenges: [self-meta-refresh, self-cookie]
settings:
challenges: [meta-refresh, cookie]
conditions:
- 'userAgent.startsWith("Lynx/")'
- name: standard-tools
action: challenge
challenges: [self-cookie]
settings:
challenges: [cookie]
conditions:
- '($is-generic-robot-ua)'
- '($is-tool-ua)'
@@ -274,6 +155,7 @@ rules:
- name: standard-browser
action: challenge
challenges: [self-preload-link, self-meta-refresh, self-resource-load, js-pow-sha256]
settings:
challenges: [preload-link, meta-refresh, resource-load, js-pow-sha256]
conditions:
- '($is-generic-browser)'

View File

@@ -0,0 +1,8 @@
networks:
bingbot:
- url: https://www.bing.com/toolbox/bingbot.json
jq-path: '(.prefixes[] | select(has("ipv4Prefix")) | .ipv4Prefix), (.prefixes[] | select(has("ipv6Prefix")) | .ipv6Prefix)'
conditions:
is-bot-bingbot:
- &is-bot-bingbot 'userAgent.contains("+http://www.bing.com/bingbot.htm") && remoteAddress.network("bingbot")'

View File

@@ -0,0 +1,8 @@
networks:
duckduckbot:
- url: https://duckduckgo.com/duckduckgo-help-pages/results/duckduckbot
regex: "<li><div>(?P<prefix>[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+)</div></li>"
conditions:
is-bot-duckduckbot:
- &is-bot-duckduckbot 'userAgent.contains("+http://duckduckgo.com/duckduckbot.html") && remoteAddress.network("duckduckbot")'

View File

@@ -0,0 +1,8 @@
networks:
googlebot:
- url: https://developers.google.com/static/search/apis/ipranges/googlebot.json
jq-path: '(.prefixes[] | select(has("ipv4Prefix")) | .ipv4Prefix), (.prefixes[] | select(has("ipv6Prefix")) | .ipv6Prefix)'
conditions:
is-bot-googlebot:
- &is-bot-googlebot '(userAgent.contains("+http://www.google.com/bot.html") || userAgent.contains("Google-PageRenderer") || userAgent.contains("Google-InspectionTool") || userAgent.contains("Googlebot")) && remoteAddress.network("googlebot")'

View File

@@ -0,0 +1,8 @@
networks:
kagibot:
- url: https://kagi.com/bot
regex: "\\n(?P<prefix>[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+) "
conditions:
is-bot-kagibot:
- &is-bot-kagibot 'userAgent.contains("+https://kagi.com/bot") && remoteAddress.network("kagibot")'

View File

@@ -0,0 +1,8 @@
networks:
qwantbot:
- url: https://help.qwant.com/wp-content/uploads/sites/2/2025/01/qwantbot.json
jq-path: '(.prefixes[] | select(has("ipv4Prefix")) | .ipv4Prefix), (.prefixes[] | select(has("ipv6Prefix")) | .ipv6Prefix)'
conditions:
is-bot-qwantbot:
- &is-bot-qwantbot 'userAgent.contains("+https://help.qwant.com/bot/") && remoteAddress.network("qwantbot")'

View File

@@ -0,0 +1,24 @@
networks:
yandexbot:
# todo: detected as bot
# - url: https://yandex.com/ips
# regex: "<span>(?P<prefix>(([0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+)|([0-9a-f:]+::))/[0-9]+)[ \\\\t]*</span><br/>"
- prefixes:
- "5.45.192.0/18"
- "5.255.192.0/18"
- "37.9.64.0/18"
- "37.140.128.0/18"
- "77.88.0.0/18"
- "84.252.160.0/19"
- "87.250.224.0/19"
- "90.156.176.0/22"
- "93.158.128.0/18"
- "95.108.128.0/17"
- "141.8.128.0/18"
- "178.154.128.0/18"
- "185.32.187.0/24"
- "2a02:6b8::/29"
conditions:
is-bot-yandexbot:
- &is-bot-yandexbot 'userAgent.contains("+http://yandex.com/bots") && remoteAddress.network("yandexbot")'

View File

@@ -0,0 +1,6 @@
challenges:
dnsbl:
runtime: dnsbl
parameters:
dnsbl-decay: 1h
dnsbl-timeout: 1s

View File

@@ -0,0 +1,15 @@
challenges:
js-pow-sha256:
runtime: js
parameters:
# specifies the folder path that assets are under
# can be either embedded or external path
# defaults to name of challenge
path: "js-pow-sha256"
# needs to be under static folder
js-loader: load.mjs
# needs to be under runtime folder
wasm-runtime: runtime.wasm
wasm-runtime-settings:
difficulty: 20
verify-probability: 0.02

View File

@@ -0,0 +1,28 @@
challenges:
# Challenges with a cookie, self redirect (non-JS, requires HTTP parsing)
cookie:
runtime: "cookie"
# Challenges with a redirect via Link header with rel=preload and early hints (non-JS, requires HTTP parsing, fetching and logic)
# Works on HTTP/2 and above!
preload-link:
condition: '"Sec-Fetch-Mode" in headers && headers["Sec-Fetch-Mode"] == "navigate"'
runtime: "preload-link"
parameters:
preload-early-hint-deadline: 3s
# Challenges with a redirect via Refresh header (non-JS, requires HTTP parsing and logic)
header-refresh:
runtime: "refresh"
parameters:
refresh-via: "header"
# Challenges with a redirect via Refresh meta (non-JS, requires HTML parsing and logic)
meta-refresh:
runtime: "refresh"
parameters:
refresh-via: "meta"
# Challenges with loading a random CSS or image document (non-JS, requires HTML parsing and logic)
resource-load:
runtime: "resource-load"

View File

@@ -0,0 +1,45 @@
conditions:
is-well-known-asset:
- 'path == "/robots.txt"'
- 'path == "/favicon.ico"'
- 'path.startsWith("/.well-known")'
is-git-ua:
- 'userAgent.startsWith("git/") || userAgent.contains("libgit")'
- 'userAgent.startsWith("go-git")'
- 'userAgent.startsWith("JGit/") || userAgent.startsWith("JGit-")'
# Golang proxy and initial fetch
- 'userAgent.startsWith("GoModuleMirror/")'
- 'userAgent.startsWith("Go-http-client/") && "go-get" in query && query["go-get"] == "1"'
- '"Git-Protocol" in headers && headers["Git-Protocol"] == "version=2"'
is-generic-browser:
- 'userAgent.startsWith("Mozilla/") || userAgent.startsWith("Opera/")'
is-generic-robot-ua:
- 'userAgent.matches("compatible[;)]") && !userAgent.contains("Trident/")'
- 'userAgent.matches("\\+https?://")'
- 'userAgent.contains("@")'
- 'userAgent.matches("[bB]ot/[0-9]")'
is-tool-ua:
- 'userAgent.startsWith("python-requests/")'
- 'userAgent.startsWith("Python-urllib/")'
- 'userAgent.startsWith("python-httpx/")'
- 'userAgent.contains("aoihttp/")'
- 'userAgent.startsWith("http.rb/")'
- 'userAgent.startsWith("curl/")'
- 'userAgent.startsWith("Wget/")'
- 'userAgent.startsWith("libcurl/")'
- 'userAgent.startsWith("okhttp/")'
- 'userAgent.startsWith("Java/")'
- 'userAgent.startsWith("Apache-HttpClient//")'
- 'userAgent.startsWith("Go-http-client/")'
- 'userAgent.startsWith("node-fetch/")'
- 'userAgent.startsWith("reqwest/")'
# Checks to detect a headless chromium via headers only
is-headless-chromium:
- 'userAgent.contains("HeadlessChrome") || userAgent.contains("HeadlessChromium")'
- '"Sec-Ch-Ua" in headers && (headers["Sec-Ch-Ua"].contains("HeadlessChrome") || headers["Sec-Ch-Ua"].contains("HeadlessChromium"))'
#- '(userAgent.contains("Chrome/") || userAgent.contains("Chromium/")) && (!("Accept-Language" in headers) || !("Accept-Encoding" in headers))'

View File

@@ -0,0 +1,37 @@
networks:
# aws-cloud:
# - url: https://ip-ranges.amazonaws.com/ip-ranges.json
# jq-path: '(.prefixes[] | select(has("ip_prefix")) | .ip_prefix), (.prefixes[] | select(has("ipv6_prefix")) | .ipv6_prefix)'
# google-cloud:
# - url: https://www.gstatic.com/ipranges/cloud.json
# jq-path: '(.prefixes[] | select(has("ipv4Prefix")) | .ipv4Prefix), (.prefixes[] | select(has("ipv6Prefix")) | .ipv6Prefix)'
# oracle-cloud:
# - url: https://docs.oracle.com/en-us/iaas/tools/public_ip_ranges.json
# jq-path: '.regions[] | .cidrs[] | .cidr'
# azure-cloud:
# # todo: https://www.microsoft.com/en-us/download/details.aspx?id=56519 does not provide direct JSON
# - url: https://raw.githubusercontent.com/femueller/cloud-ip-ranges/refs/heads/master/microsoft-azure-ip-ranges.json
# jq-path: '.values[] | .properties.addressPrefixes[]'
#
# digitalocean:
# - url: https://www.digitalocean.com/geo/google.csv
# regex: "(?P<prefix>(([0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+)|([0-9a-f:]+::))/[0-9]+),"
# linode:
# - url: https://geoip.linode.com/
# regex: "(?P<prefix>(([0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+)|([0-9a-f:]+::))/[0-9]+),"
# vultr:
# - url: "https://geofeed.constant.com/?json"
# jq-path: '.subnets[] | .ip_prefix'
# cloudflare:
# - url: https://www.cloudflare.com/ips-v4
# regex: "(?P<prefix>[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+/[0-9]+)"
# - url: https://www.cloudflare.com/ips-v6
# regex: "(?P<prefix>[0-9a-f:]+::/[0-9]+)"
#
# icloud-private-relay:
# - url: https://mask-api.icloud.com/egress-ip-ranges.csv
# regex: "(?P<prefix>(([0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+)|([0-9a-f:]+::))/[0-9]+),"
# tunnelbroker-relay:
# # HE Tunnelbroker
# - url: https://tunnelbroker.net/export/google
# regex: "(?P<prefix>([0-9a-f:]+::)/[0-9]+),"

11
go.mod
View File

@@ -6,16 +6,15 @@ toolchain go1.24.2
require (
codeberg.org/meta/gzipped/v2 v2.0.0-20231111234332-aa70c3194756
github.com/andybalholm/brotli v1.1.1
github.com/alphadose/haxmap v1.4.1
github.com/go-jose/go-jose/v4 v4.1.0
github.com/google/cel-go v0.24.1
github.com/goccy/go-yaml v1.17.1
github.com/google/cel-go v0.25.0
github.com/itchyny/gojq v0.12.17
github.com/klauspost/compress v1.18.0
github.com/pires/go-proxyproto v0.8.0
github.com/tetratelabs/wazero v1.9.0
github.com/yl2chen/cidranger v1.0.2
golang.org/x/crypto v0.37.0
gopkg.in/yaml.v3 v3.0.1
)
require (
@@ -27,7 +26,7 @@ require (
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
golang.org/x/net v0.39.0 // indirect
golang.org/x/text v0.24.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250414145226-207652e42e2e // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250414145226-207652e42e2e // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250422160041-2d3770c4ea7f // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250422160041-2d3770c4ea7f // indirect
google.golang.org/protobuf v1.36.6 // indirect
)

17
go.sum
View File

@@ -2,8 +2,8 @@ cel.dev/expr v0.23.1 h1:K4KOtPCJQjVggkARsjG9RWXP6O4R73aHeJMa/dmCQQg=
cel.dev/expr v0.23.1/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw=
codeberg.org/meta/gzipped/v2 v2.0.0-20231111234332-aa70c3194756 h1:bDqEUEYt4UJy8mfLCZeJuXx+xNJvdqTbkE4Ci11NQYU=
codeberg.org/meta/gzipped/v2 v2.0.0-20231111234332-aa70c3194756/go.mod h1:aJ/ghJW7viYfwZ6OizDst+uJgbb6r/Hvoqhmi1OPTTw=
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
github.com/alphadose/haxmap v1.4.1 h1:VtD6VCxUkjNIfJk/aWdYFfOzrRddDFjmvmRmILg7x8Q=
github.com/alphadose/haxmap v1.4.1/go.mod h1:rjHw1IAqbxm0S3U5tD16GoKsiAd8FWx5BJ2IYqXwgmM=
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/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -11,8 +11,12 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-jose/go-jose/v4 v4.1.0 h1:cYSYxd3pw5zd2FSXk2vGdn9igQU2PS8MuxrCOCl0FdY=
github.com/go-jose/go-jose/v4 v4.1.0/go.mod h1:GG/vqmYm3Von2nYiB2vGTXzdoNKE5tix5tuc6iAd+sw=
github.com/goccy/go-yaml v1.17.1 h1:LI34wktB2xEE3ONG/2Ar54+/HJVBriAGJ55PHls4YuY=
github.com/goccy/go-yaml v1.17.1/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/google/cel-go v0.24.1 h1:jsBCtxG8mM5wiUJDSGUqU0K7Mtr3w7Eyv00rw4DiZxI=
github.com/google/cel-go v0.24.1/go.mod h1:Hdf9TqOaTNSFQA1ybQaRqATVoK7m/zcf7IMhGXP5zI8=
github.com/google/cel-go v0.25.0 h1:jsFw9Fhn+3y2kBbltZR4VEz5xKkcIFRPDnuEzAGv5GY=
github.com/google/cel-go v0.25.0/go.mod h1:hjEb6r5SuOSlhCHmFoLzu8HGCERvIsDAbxDAyNU/MmI=
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/itchyny/gojq v0.12.17 h1:8av8eGduDb5+rvEdaOO+zQUjA04MS0m3Ps8HiD+fceg=
@@ -21,8 +25,6 @@ github.com/itchyny/timefmt-go v0.1.6 h1:ia3s54iciXDdzWzwaVKXZPbiXzxxnv1SPGFfM/my
github.com/itchyny/timefmt-go v0.1.6/go.mod h1:RRDZYC5s9ErkjQvTvvU7keJjxUYzIISJGxm9/mAERQg=
github.com/kevinpollet/nego v0.0.0-20211010160919-a65cd48cee43 h1:Pdirg1gwhEcGjMLyuSxGn9664p+P8J9SrfMgpFwrDyg=
github.com/kevinpollet/nego v0.0.0-20211010160919-a65cd48cee43/go.mod h1:ahLMuLCUyDdXqtqGyuwGev7/PGtO7r7ocvdwDuEN/3E=
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/pires/go-proxyproto v0.8.0 h1:5unRmEAPbHXHuLjDg01CxJWf91cw3lKHc/0xzKpXEe0=
github.com/pires/go-proxyproto v0.8.0/go.mod h1:iknsfgnH8EkjrMeMyvfKByp9TiBZCKZM0jx2xmKqnVY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -40,8 +42,6 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I=
github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yl2chen/cidranger v1.0.2 h1:lbOWZVCG1tCRX4u24kuM1Tb4nHqWkDxwLdoS+SevawU=
github.com/yl2chen/cidranger v1.0.2/go.mod h1:9U1yz7WPYDwf0vpNWFaeRh0bjwz5RVgRy/9UEQfHl0g=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
@@ -54,11 +54,14 @@ golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
google.golang.org/genproto/googleapis/api v0.0.0-20250414145226-207652e42e2e h1:UdXH7Kzbj+Vzastr5nVfccbmFsmYNygVLSPk1pEfDoY=
google.golang.org/genproto/googleapis/api v0.0.0-20250414145226-207652e42e2e/go.mod h1:085qFyf2+XaZlRdCgKNCIZ3afY2p4HHZdoIRpId8F4A=
google.golang.org/genproto/googleapis/api v0.0.0-20250422160041-2d3770c4ea7f h1:tjZsroqekhC63+WMqzmWyW5Twj/ZfR5HAlpd5YQ1Vs0=
google.golang.org/genproto/googleapis/api v0.0.0-20250422160041-2d3770c4ea7f/go.mod h1:Cd8IzgPo5Akum2c9R6FsXNaZbH3Jpa2gpHlW89FqlyQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250414145226-207652e42e2e h1:ztQaXfzEXTmCBvbtWYRhJxW+0iJcz2qXfd38/e9l7bA=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250414145226-207652e42e2e/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250422160041-2d3770c4ea7f h1:N/PrbTw4kdkqNRzVfWPrBekzLuarFREcbFOiOLkXon4=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250422160041-2d3770c4ea7f/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

80
lib/action/backend.go Normal file
View File

@@ -0,0 +1,80 @@
package action
import (
"fmt"
"git.gammaspectra.live/git/go-away/lib/challenge"
"git.gammaspectra.live/git/go-away/lib/policy"
"github.com/goccy/go-yaml"
"github.com/goccy/go-yaml/ast"
"log/slog"
"net/http"
"regexp"
)
func init() {
Register[policy.RuleActionPROXY] = func(state challenge.StateInterface, ruleName, ruleHash string, settings ast.Node) (Handler, error) {
params := ProxyDefaultSettings
if settings != nil {
ymlData, err := settings.MarshalYAML()
if err != nil {
return nil, err
}
err = yaml.Unmarshal(ymlData, &params)
if err != nil {
return nil, err
}
}
if params.Match != "" {
expr, err := regexp.Compile(params.Match)
if err != nil {
return nil, err
}
return Proxy{
Match: expr,
Rewrite: params.Rewrite,
Backend: params.Backend,
}, nil
}
return Proxy{
Backend: params.Backend,
}, nil
}
}
var ProxyDefaultSettings = ProxySettings{}
type ProxySettings struct {
Match string `yaml:"proxy-match"`
Rewrite string `yaml:"proxy-rewrite"`
Backend string `yaml:"proxy-backend"`
}
type Proxy struct {
Match *regexp.Regexp
Rewrite string
Backend string
}
func (a Proxy) Handle(logger *slog.Logger, w http.ResponseWriter, r *http.Request, done func() (backend http.Handler)) (next bool, err error) {
data := challenge.RequestDataFromContext(r.Context())
backend := data.State.GetBackend(a.Backend)
if backend == nil {
return false, fmt.Errorf("backend for %s not found", a.Backend)
}
if a.Match != nil {
// rewrite query
r.URL.Path = a.Match.ReplaceAllString(r.URL.Path, a.Rewrite)
}
// set headers, ignore reply
_ = done()
backend.ServeHTTP(w, r)
return false, nil
}

36
lib/action/block.go Normal file
View File

@@ -0,0 +1,36 @@
package action
import (
"fmt"
"git.gammaspectra.live/git/go-away/lib/challenge"
"git.gammaspectra.live/git/go-away/lib/policy"
"github.com/goccy/go-yaml/ast"
"log/slog"
"net/http"
)
func init() {
Register[policy.RuleActionBLOCK] = func(state challenge.StateInterface, ruleName, ruleHash string, settings ast.Node) (Handler, error) {
return Block{
Code: http.StatusForbidden,
RuleHash: ruleHash,
}, nil
}
}
type Block struct {
Code int
RuleHash string
}
func (a Block) Handle(logger *slog.Logger, w http.ResponseWriter, r *http.Request, done func() (backend http.Handler)) (next bool, err error) {
logger.Info("request blocked")
data := challenge.RequestDataFromContext(r.Context())
w.Header().Set("Content-Type", "text/plain")
w.Header().Set("Connection", "close")
w.WriteHeader(a.Code)
_, _ = w.Write([]byte(fmt.Errorf("access blocked: blocked by administrative rule %s/%s", data.Id.String(), a.RuleHash).Error()))
return false, nil
}

178
lib/action/challenge.go Normal file
View File

@@ -0,0 +1,178 @@
package action
import (
"fmt"
"git.gammaspectra.live/git/go-away/lib/challenge"
"git.gammaspectra.live/git/go-away/lib/policy"
"github.com/goccy/go-yaml"
"github.com/goccy/go-yaml/ast"
"log/slog"
"net/http"
"strings"
)
func init() {
i := func(state challenge.StateInterface, ruleName, ruleHash string, settings ast.Node, cont bool) (Handler, error) {
params := ChallengeDefaultSettings
if settings != nil {
ymlData, err := settings.MarshalYAML()
if err != nil {
return nil, err
}
err = yaml.Unmarshal(ymlData, &params)
if err != nil {
return nil, err
}
}
if params.Code == 0 {
params.Code = state.Settings().ChallengeResponseCode
}
var regs []*challenge.Registration
for _, regName := range params.Challenges {
if reg, ok := state.GetChallengeByName(regName); ok {
regs = append(regs, reg)
} else {
return nil, fmt.Errorf("challenge %s not found", regName)
}
}
if len(regs) == 0 {
return nil, fmt.Errorf("no registered challenges found in rule %s", ruleName)
}
passHandler, ok := Register[policy.RuleAction(strings.ToUpper(params.PassAction))]
if !ok {
return nil, fmt.Errorf("unknown pass action %s", params.PassAction)
}
passActionHandler, err := passHandler(state, ruleName, ruleHash, params.PassSettings)
if err != nil {
return nil, err
}
failHandler, ok := Register[policy.RuleAction(strings.ToUpper(params.FailAction))]
if !ok {
return nil, fmt.Errorf("unknown pass action %s", params.FailAction)
}
failActionHandler, err := failHandler(state, ruleName, ruleHash, params.FailSettings)
if err != nil {
return nil, err
}
return Challenge{
RuleHash: ruleHash,
Code: params.Code,
Continue: cont,
Challenges: regs,
PassAction: passActionHandler,
FailAction: failActionHandler,
}, nil
}
Register[policy.RuleActionCHALLENGE] = func(state challenge.StateInterface, ruleName, ruleHash string, settings ast.Node) (Handler, error) {
return i(state, ruleName, ruleHash, settings, false)
}
Register[policy.RuleActionCHECK] = func(state challenge.StateInterface, ruleName, ruleHash string, settings ast.Node) (Handler, error) {
return i(state, ruleName, ruleHash, settings, true)
}
}
var ChallengeDefaultSettings = ChallengeSettings{
PassAction: string(policy.RuleActionPASS),
FailAction: string(policy.RuleActionDENY),
}
type ChallengeSettings struct {
Code int `yaml:"http-code"`
Challenges []string `yaml:"challenges"`
PassAction string `yaml:"pass"`
PassSettings ast.Node `yaml:"pass-settings"`
// FailAction Executed in case no challenges match or
FailAction string `yaml:"fail"`
FailSettings ast.Node `yaml:"fail-settings"`
}
type Challenge struct {
RuleHash string
Code int
Continue bool
Challenges []*challenge.Registration
PassAction Handler
FailAction Handler
}
func (a Challenge) Handle(logger *slog.Logger, w http.ResponseWriter, r *http.Request, done func() (backend http.Handler)) (next bool, err error) {
data := challenge.RequestDataFromContext(r.Context())
for _, reg := range a.Challenges {
if data.HasValidChallenge(reg.Id()) {
if a.Continue {
return true, nil
}
// we passed!
return a.PassAction.Handle(logger.With("challenge", reg.Name), w, r, done)
}
}
// none matched, issue challenges in sequential priority
for _, reg := range a.Challenges {
result := data.ChallengeVerify[reg.Id()]
state := data.ChallengeState[reg.Id()]
if result.Ok() || result == challenge.VerifyResultSkip || state == challenge.VerifyStatePass {
// skip already ok'd challenges for some reason (TODO: why)
// also skip skipped challenges due to preconditions
continue
}
expiry := data.Expiration(reg.Duration)
key := challenge.GetChallengeKeyForRequest(data.State, reg, expiry, r)
data.State.ChallengeIssued(r, reg, r.URL.String(), logger)
result = reg.IssueChallenge(w, r, key, expiry)
data.ChallengeVerify[reg.Id()] = result
data.ChallengeState[reg.Id()] = challenge.VerifyStatePass
switch result {
case challenge.VerifyResultOK:
data.State.ChallengePassed(r, reg, r.URL.String(), logger)
if a.Continue {
return true, nil
}
return a.PassAction.Handle(logger.With("challenge", reg.Name), w, r, done)
case challenge.VerifyResultNotOK:
// we have had the challenge checked, but it's not ok!
// safe to continue
continue
case challenge.VerifyResultFail:
err := fmt.Errorf("challenge %s failed on issuance", reg.Name)
data.State.ChallengeFailed(r, reg, err, r.URL.String(), logger)
if reg.Class == challenge.ClassTransparent {
// allow continuing transparent challenges
continue
}
return a.FailAction.Handle(logger, w, r, done)
case challenge.VerifyResultNone:
// challenge was issued
if reg.Class == challenge.ClassTransparent {
// allow continuing transparent challenges
continue
}
// we cannot continue after issuance
return false, nil
case challenge.VerifyResultSkip:
// continue onto next one due to precondition
continue
}
}
// nothing matched, execute default action
return a.FailAction.Handle(logger, w, r, done)
}

47
lib/action/code.go Normal file
View File

@@ -0,0 +1,47 @@
package action
import (
"errors"
"git.gammaspectra.live/git/go-away/lib/challenge"
"git.gammaspectra.live/git/go-away/lib/policy"
"github.com/goccy/go-yaml"
"github.com/goccy/go-yaml/ast"
"log/slog"
"net/http"
)
func init() {
Register[policy.RuleActionCODE] = func(state challenge.StateInterface, ruleName, ruleHash string, settings ast.Node) (Handler, error) {
params := CodeDefaultSettings
if settings != nil {
ymlData, err := settings.MarshalYAML()
if err != nil {
return nil, err
}
err = yaml.Unmarshal(ymlData, &params)
if err != nil {
return nil, err
}
}
if params.Code == 0 {
return nil, errors.New("http-code not set")
}
return Code(params.Code), nil
}
}
var CodeDefaultSettings = CodeSettings{}
type CodeSettings struct {
Code int `yaml:"http-code"`
}
type Code int
func (a Code) Handle(logger *slog.Logger, w http.ResponseWriter, r *http.Request, done func() (backend http.Handler)) (next bool, err error) {
w.WriteHeader(int(a))
return false, nil
}

31
lib/action/deny.go Normal file
View File

@@ -0,0 +1,31 @@
package action
import (
"fmt"
"git.gammaspectra.live/git/go-away/lib/challenge"
"git.gammaspectra.live/git/go-away/lib/policy"
"github.com/goccy/go-yaml/ast"
"log/slog"
"net/http"
)
func init() {
Register[policy.RuleActionDENY] = func(state challenge.StateInterface, ruleName, ruleHash string, settings ast.Node) (Handler, error) {
return Deny{
Code: http.StatusForbidden,
RuleHash: ruleHash,
}, nil
}
}
type Deny struct {
Code int
RuleHash string
}
func (a Deny) Handle(logger *slog.Logger, w http.ResponseWriter, r *http.Request, done func() (backend http.Handler)) (next bool, err error) {
logger.Info("request denied")
data := challenge.RequestDataFromContext(r.Context())
data.State.ErrorPage(w, r, a.Code, fmt.Errorf("access denied: denied by administrative rule %s/%s", data.Id.String(), a.RuleHash), "")
return false, nil
}

39
lib/action/drop.go Normal file
View File

@@ -0,0 +1,39 @@
package action
import (
"git.gammaspectra.live/git/go-away/lib/challenge"
"git.gammaspectra.live/git/go-away/lib/policy"
"github.com/goccy/go-yaml/ast"
"log/slog"
"net/http"
)
func init() {
Register[policy.RuleActionDROP] = func(state challenge.StateInterface, ruleName, ruleHash string, settings ast.Node) (Handler, error) {
return Drop{}, nil
}
}
type Drop struct {
}
func (a Drop) Handle(logger *slog.Logger, w http.ResponseWriter, r *http.Request, done func() (backend http.Handler)) (next bool, err error) {
logger.Info("request dropped")
if hj, ok := w.(http.Hijacker); ok {
if conn, _, err := hj.Hijack(); err == nil {
// drop without sending data
_ = conn.Close()
return false, nil
}
}
// fallback
w.Header().Set("Content-Type", "text/plain")
w.Header().Set("Content-Length", "0")
w.Header().Set("Connection", "close")
w.WriteHeader(http.StatusForbidden)
return false, nil
}

21
lib/action/none.go Normal file
View File

@@ -0,0 +1,21 @@
package action
import (
"git.gammaspectra.live/git/go-away/lib/challenge"
"git.gammaspectra.live/git/go-away/lib/policy"
"github.com/goccy/go-yaml/ast"
"log/slog"
"net/http"
)
func init() {
Register[policy.RuleActionNONE] = func(state challenge.StateInterface, ruleName, ruleHash string, settings ast.Node) (Handler, error) {
return None{}, nil
}
}
type None struct{}
func (a None) Handle(logger *slog.Logger, w http.ResponseWriter, r *http.Request, done func() (backend http.Handler)) (next bool, err error) {
return true, nil
}

23
lib/action/pass.go Normal file
View File

@@ -0,0 +1,23 @@
package action
import (
"git.gammaspectra.live/git/go-away/lib/challenge"
"git.gammaspectra.live/git/go-away/lib/policy"
"github.com/goccy/go-yaml/ast"
"log/slog"
"net/http"
)
func init() {
Register[policy.RuleActionPASS] = func(state challenge.StateInterface, ruleName, ruleHash string, settings ast.Node) (Handler, error) {
return Pass{}, nil
}
}
type Pass struct{}
func (a Pass) Handle(logger *slog.Logger, w http.ResponseWriter, r *http.Request, done func() (backend http.Handler)) (next bool, err error) {
logger.Debug("request passed")
done().ServeHTTP(w, r)
return false, nil
}

20
lib/action/register.go Normal file
View File

@@ -0,0 +1,20 @@
package action
import (
"git.gammaspectra.live/git/go-away/lib/challenge"
"git.gammaspectra.live/git/go-away/lib/policy"
"github.com/goccy/go-yaml/ast"
"log/slog"
"net/http"
)
type Handler interface {
// Handle An incoming request.
// If next is true, continue processing
// If next is false, stop processing. If passing to a backend, done() must be called beforehand to set headers.
Handle(logger *slog.Logger, w http.ResponseWriter, r *http.Request, done func() (backend http.Handler)) (next bool, err error)
}
type NewFunc func(state challenge.StateInterface, ruleName, ruleHash string, settings ast.Node) (Handler, error)
var Register = make(map[policy.RuleAction]NewFunc)

View File

@@ -1,125 +1,13 @@
package lib
import (
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"errors"
"github.com/go-jose/go-jose/v4/jwt"
"net"
"net/http"
"strings"
"time"
_ "git.gammaspectra.live/git/go-away/lib/challenge/cookie"
_ "git.gammaspectra.live/git/go-away/lib/challenge/dnsbl"
_ "git.gammaspectra.live/git/go-away/lib/challenge/http"
_ "git.gammaspectra.live/git/go-away/lib/challenge/preload-link"
_ "git.gammaspectra.live/git/go-away/lib/challenge/refresh"
_ "git.gammaspectra.live/git/go-away/lib/challenge/resource-load"
_ "git.gammaspectra.live/git/go-away/lib/challenge/wasm"
)
type ChallengeInformation struct {
Name string `json:"name"`
Key []byte `json:"key"`
Result []byte `json:"result"`
Expiry *jwt.NumericDate `json:"exp,omitempty"`
NotBefore *jwt.NumericDate `json:"nbf,omitempty"`
IssuedAt *jwt.NumericDate `json:"iat,omitempty"`
}
func getRequestScheme(r *http.Request) string {
if proto := r.Header.Get("X-Forwarded-Proto"); proto == "http" || proto == "https" {
return proto
}
if r.TLS != nil {
return "https"
}
return "http"
}
func getRequestAddress(r *http.Request, clientHeader string) net.IP {
var ipStr string
if clientHeader != "" {
ipStr = r.Header.Get(clientHeader)
}
if ipStr != "" {
// handle X-Forwarded-For
ipStr = strings.Split(ipStr, ",")[0]
}
// fallback
if ipStr == "" {
parts := strings.Split(r.RemoteAddr, ":")
// drop port
ipStr = strings.Join(parts[:len(parts)-1], ":")
}
ipStr = strings.Trim(ipStr, "[]")
return net.ParseIP(ipStr)
}
type ChallengeKey []byte
const ChallengeKeySize = sha256.Size
func (k *ChallengeKey) Set(flags ChallengeKeyFlags) {
(*k)[0] |= uint8(flags)
}
func (k *ChallengeKey) Get(flags ChallengeKeyFlags) ChallengeKeyFlags {
return ChallengeKeyFlags((*k)[0] & uint8(flags))
}
func (k *ChallengeKey) Unset(flags ChallengeKeyFlags) {
(*k)[0] = (*k)[0] & ^(uint8(flags))
}
type ChallengeKeyFlags uint8
const (
ChallengeKeyFlagIsIPv4 = ChallengeKeyFlags(1 << iota)
)
func ChallengeKeyFromString(s string) (ChallengeKey, error) {
b, err := hex.DecodeString(s)
if err != nil {
return nil, err
}
if len(b) != ChallengeKeySize {
return nil, errors.New("invalid challenge key")
}
return ChallengeKey(b), nil
}
func (state *State) GetChallengeKeyForRequest(challengeName string, until time.Time, r *http.Request) ChallengeKey {
data := RequestDataFromContext(r.Context())
address := data.RemoteAddress
hasher := sha256.New()
hasher.Write([]byte("challenge\x00"))
hasher.Write([]byte(challengeName))
hasher.Write([]byte{0})
hasher.Write(address.To16())
hasher.Write([]byte{0})
// specific headers
for _, k := range []string{
"Accept-Language",
// General browser information
"User-Agent",
// TODO: not sent in preload
//"Sec-Ch-Ua",
//"Sec-Ch-Ua-Platform",
} {
hasher.Write([]byte(r.Header.Get(k)))
hasher.Write([]byte{0})
}
hasher.Write([]byte{0})
_ = binary.Write(hasher, binary.LittleEndian, until.UTC().Unix())
hasher.Write([]byte{0})
hasher.Write(state.publicKey)
hasher.Write([]byte{0})
sum := ChallengeKey(hasher.Sum(nil))
sum[0] = 0
if address.To4() != nil {
// Is IPv4, mark
sum.Set(ChallengeKeyFlagIsIPv4)
}
return ChallengeKey(sum)
}
// This file loads embedded challenge runtimes so their init() is called

47
lib/challenge/awaiter.go Normal file
View File

@@ -0,0 +1,47 @@
package challenge
import (
"context"
"github.com/alphadose/haxmap"
"sync/atomic"
)
type awaiterCallback func(result VerifyResult)
type Awaiter[K ~string | ~int64 | ~uint64] haxmap.Map[K, awaiterCallback]
func NewAwaiter[T ~string | ~int64 | ~uint64]() *Awaiter[T] {
return (*Awaiter[T])(haxmap.New[T, awaiterCallback]())
}
func (a *Awaiter[T]) Await(key T, ctx context.Context) VerifyResult {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
var result atomic.Int64
a.m().Set(key, func(receivedResult VerifyResult) {
result.Store(int64(receivedResult))
cancel()
})
// cleanup
defer a.m().Del(key)
<-ctx.Done()
return VerifyResult(result.Load())
}
func (a *Awaiter[T]) Solve(key T, result VerifyResult) {
if f, ok := a.m().GetAndDel(key); ok && f != nil {
f(result)
}
}
func (a *Awaiter[T]) m() *haxmap.Map[T, awaiterCallback] {
return (*haxmap.Map[T, awaiterCallback])(a)
}
func (a *Awaiter[T]) Close() error {
return nil
}

View File

@@ -0,0 +1,38 @@
package cookie
import (
"git.gammaspectra.live/git/go-away/lib/challenge"
"git.gammaspectra.live/git/go-away/utils"
"github.com/goccy/go-yaml/ast"
"net/http"
"time"
)
func init() {
challenge.Runtimes[Key] = FillRegistration
}
const Key = "cookie"
func FillRegistration(state challenge.StateInterface, reg *challenge.Registration, parameters ast.Node) error {
reg.Class = challenge.ClassBlocking
reg.IssueChallenge = func(w http.ResponseWriter, r *http.Request, key challenge.Key, expiry time.Time) challenge.VerifyResult {
token, err := reg.IssueChallengeToken(state.PrivateKey(), key, nil, expiry, true)
if err != nil {
return challenge.VerifyResultFail
}
utils.SetCookie(utils.CookiePrefix+reg.Name, token, expiry, w, r)
uri, err := challenge.RedirectUrl(r, reg)
if err != nil {
return challenge.VerifyResultFail
}
http.Redirect(w, r, uri.String(), http.StatusTemporaryRedirect)
return challenge.VerifyResultNone
}
return nil
}

171
lib/challenge/data.go Normal file
View File

@@ -0,0 +1,171 @@
package challenge
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"git.gammaspectra.live/git/go-away/lib/condition"
"git.gammaspectra.live/git/go-away/utils"
"github.com/google/cel-go/cel"
"github.com/google/cel-go/common/types"
"github.com/google/cel-go/common/types/traits"
"net"
"net/http"
"net/textproto"
"time"
)
type requestDataContextKey struct {
}
func RequestDataFromContext(ctx context.Context) *RequestData {
return ctx.Value(requestDataContextKey{}).(*RequestData)
}
type RequestId [16]byte
func (id RequestId) String() string {
return hex.EncodeToString(id[:])
}
type RequestData struct {
Id RequestId
Time time.Time
ChallengeVerify map[Id]VerifyResult
ChallengeState map[Id]VerifyState
RemoteAddress net.IP
State StateInterface
r *http.Request
fp map[string]string
header traits.Mapper
query traits.Mapper
}
func CreateRequestData(r *http.Request, state StateInterface) (*http.Request, *RequestData) {
var data RequestData
// generate random id, todo: is this fast?
_, _ = rand.Read(data.Id[:])
data.RemoteAddress = utils.GetRequestAddress(r, state.Settings().ClientIpHeader)
data.ChallengeVerify = make(map[Id]VerifyResult, len(state.GetChallenges()))
data.ChallengeState = make(map[Id]VerifyState, len(state.GetChallenges()))
data.Time = time.Now().UTC()
data.State = state
data.r = r
data.fp = make(map[string]string, 2)
if fp := utils.GetTLSFingerprint(r); fp != nil {
if ja3nPtr := fp.JA3N(); ja3nPtr != nil {
ja3n := ja3nPtr.String()
data.fp["ja3n"] = ja3n
r.Header.Set("X-TLS-Fingerprint-JA3N", ja3n)
}
if ja4Ptr := fp.JA4(); ja4Ptr != nil {
ja4 := ja4Ptr.String()
data.fp["ja4"] = ja4
r.Header.Set("X-TLS-Fingerprint-JA4", ja4)
}
}
data.query = condition.NewValuesMap(r.URL.Query())
data.header = condition.NewMIMEMap(textproto.MIMEHeader(r.Header))
r = r.WithContext(context.WithValue(r.Context(), requestDataContextKey{}, &data))
return r, &data
}
func (d *RequestData) ResolveName(name string) (any, bool) {
switch name {
case "host":
return d.r.Host, true
case "method":
return d.r.Method, true
case "remoteAddress":
return d.RemoteAddress, true
case "userAgent":
return d.r.UserAgent(), true
case "path":
return d.r.URL.Path, true
case "query":
return d.query, true
case "headers":
return d.header, true
case "fp":
return d.fp, true
default:
return nil, false
}
}
func (d *RequestData) Parent() cel.Activation {
return nil
}
func (d *RequestData) EvaluateChallenges(w http.ResponseWriter, r *http.Request) {
for _, reg := range d.State.GetChallenges() {
key := GetChallengeKeyForRequest(d.State, reg, d.Expiration(reg.Duration), r)
verifyResult, verifyState, err := reg.VerifyChallengeToken(d.State.PublicKey(), key, r)
if err != nil && !errors.Is(err, http.ErrNoCookie) {
// clear invalid cookie
utils.ClearCookie(utils.CookiePrefix+reg.Name, w, r)
}
// prevent evaluating the challenge if not solved
if !verifyResult.Ok() && reg.Condition != nil {
out, _, err := reg.Condition.Eval(d)
// verify eligibility
if err != nil {
d.State.Logger(r).Error(err.Error(), "challenge", reg.Name)
} else if out != nil && out.Type() == types.BoolType {
if out.Equal(types.True) != types.True {
// skip challenge match due to precondition!
verifyResult = VerifyResultSkip
continue
}
}
}
d.ChallengeVerify[reg.Id()] = verifyResult
d.ChallengeState[reg.Id()] = verifyState
}
if d.State.Settings().BackendIpHeader != "" {
if d.State.Settings().ClientIpHeader != "" {
r.Header.Del(d.State.Settings().ClientIpHeader)
}
r.Header.Set(d.State.Settings().BackendIpHeader, d.RemoteAddress.String())
}
// send these to client so we consistently get the headers
//w.Header().Set("Accept-CH", "Sec-CH-UA, Sec-CH-UA-Platform")
//w.Header().Set("Critical-CH", "Sec-CH-UA, Sec-CH-UA-Platform")
}
func (d *RequestData) Expiration(duration time.Duration) time.Time {
return d.Time.Add(duration).Round(duration)
}
func (d *RequestData) HasValidChallenge(id Id) bool {
return d.ChallengeVerify[id].Ok()
}
func (d *RequestData) Headers(headers http.Header) {
headers.Set("X-Away-Id", d.Id.String())
for id, result := range d.ChallengeVerify {
if result.Ok() {
c, ok := d.State.GetChallenge(id)
if !ok {
panic("challenge not found")
}
headers.Set(fmt.Sprintf("X-Away-Challenge-%s-Result", c.Name), result.String())
headers.Set(fmt.Sprintf("X-Away-Challenge-%s-State", c.Name), d.ChallengeState[id].String())
}
}
}

View File

@@ -0,0 +1,149 @@
package http
import (
"context"
"errors"
"git.gammaspectra.live/git/go-away/lib/challenge"
"git.gammaspectra.live/git/go-away/utils"
"github.com/goccy/go-yaml"
"github.com/goccy/go-yaml/ast"
"net"
"net/http"
"time"
)
func init() {
challenge.Runtimes[Key] = FillRegistration
}
const Key = "dnsbl"
type Parameters struct {
VerifyProbability float64 `yaml:"verify-probability"`
Host string `yaml:"dnsbl-host"`
Timeout time.Duration `yaml:"dnsbl-timeout"`
Decay time.Duration `yaml:"dnsbl-decay"`
}
var DefaultParameters = Parameters{
VerifyProbability: 0.10,
Timeout: time.Second * 1,
Decay: time.Hour * 1,
Host: "dnsbl.dronebl.org",
}
func lookup(ctx context.Context, decay, timeout time.Duration, dnsbl *utils.DNSBL, decayMap *utils.DecayMap[[net.IPv6len]byte, utils.DNSBLResponse], ip net.IP) (utils.DNSBLResponse, error) {
var key [net.IPv6len]byte
copy(key[:], ip.To16())
result, ok := decayMap.Get(key)
if ok {
return result, nil
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
result, err := dnsbl.Lookup(ctx, ip)
if err != nil {
}
decayMap.Set(key, result, decay)
return result, err
}
type closer chan struct{}
func (c closer) Close() error {
select {
case <-c:
default:
close(c)
}
return nil
}
func FillRegistration(state challenge.StateInterface, reg *challenge.Registration, parameters ast.Node) error {
params := DefaultParameters
if parameters != nil {
ymlData, err := parameters.MarshalYAML()
if err != nil {
return err
}
err = yaml.Unmarshal(ymlData, &params)
if err != nil {
return err
}
}
if params.Host == "" {
return errors.New("empty host")
}
reg.Class = challenge.ClassTransparent
if params.VerifyProbability <= 0 {
//20% default
params.VerifyProbability = 0.20
} else if params.VerifyProbability > 1.0 {
params.VerifyProbability = 1.0
}
reg.VerifyProbability = params.VerifyProbability
decayMap := utils.NewDecayMap[[net.IPv6len]byte, utils.DNSBLResponse]()
dnsbl := utils.NewDNSBL(params.Host, &net.Resolver{
PreferGo: true,
})
ob := make(closer)
go func() {
ticker := time.NewTicker(params.Timeout / 3)
defer ticker.Stop()
for {
select {
case <-ticker.C:
decayMap.Decay()
case <-ob:
return
}
}
}()
// allow freeing the ticker/decay map
reg.Object = ob
reg.IssueChallenge = func(w http.ResponseWriter, r *http.Request, key challenge.Key, expiry time.Time) challenge.VerifyResult {
data := challenge.RequestDataFromContext(r.Context())
result, err := lookup(r.Context(), params.Decay, params.Timeout, dnsbl, decayMap, data.RemoteAddress)
if err != nil {
data.State.Logger(r).Debug("dnsbl lookup failed", "address", data.RemoteAddress.String(), "result", result, "err", err)
}
if err != nil {
return challenge.VerifyResultFail
}
if result.Bad() {
token, err := reg.IssueChallengeToken(state.PrivateKey(), key, nil, expiry, false)
if err != nil {
return challenge.VerifyResultFail
}
utils.SetCookie(utils.CookiePrefix+reg.Name, token, expiry, w, r)
return challenge.VerifyResultNotOK
} else {
token, err := reg.IssueChallengeToken(state.PrivateKey(), key, nil, expiry, true)
if err != nil {
return challenge.VerifyResultFail
}
utils.SetCookie(utils.CookiePrefix+reg.Name, token, expiry, w, r)
return challenge.VerifyResultOK
}
}
return nil
}

164
lib/challenge/helper.go Normal file
View File

@@ -0,0 +1,164 @@
package challenge
import (
"crypto/subtle"
"encoding/hex"
"errors"
"fmt"
"git.gammaspectra.live/git/go-away/utils"
"net/http"
"net/url"
)
func NewKeyVerifier() (verify VerifyFunc, issue func(key Key) string) {
return func(key Key, token []byte, r *http.Request) (VerifyResult, error) {
expectedKey, err := hex.DecodeString(string(token))
if err != nil {
return VerifyResultFail, err
}
if subtle.ConstantTimeCompare(key[:], expectedKey) == 1 {
return VerifyResultOK, nil
}
return VerifyResultFail, errors.New("invalid token")
}, func(key Key) string {
return hex.EncodeToString(key[:])
}
}
const (
QueryArgPrefix = "__goaway"
QueryArgReferer = QueryArgPrefix + "_referer"
QueryArgRedirect = QueryArgPrefix + "_redirect"
QueryArgRequestId = QueryArgPrefix + "_id"
QueryArgChallenge = QueryArgPrefix + "_challenge"
QueryArgToken = QueryArgPrefix + "_token"
)
const MakeChallengeUrlSuffix = "/make-challenge"
const VerifyChallengeUrlSuffix = "/verify-challenge"
func GetVerifyInformation(r *http.Request, reg *Registration) (requestId RequestId, redirect, token string, err error) {
if r.FormValue(QueryArgChallenge) != reg.Name {
return RequestId{}, "", "", fmt.Errorf("unexpected challenge: got %s", r.FormValue(QueryArgChallenge))
}
requestIdHex := r.FormValue(QueryArgRequestId)
if len(requestId) != hex.DecodedLen(len(requestIdHex)) {
return RequestId{}, "", "", errors.New("invalid request id")
}
n, err := hex.Decode(requestId[:], []byte(requestIdHex))
if err != nil {
return RequestId{}, "", "", err
} else if n != len(requestId) {
return RequestId{}, "", "", errors.New("invalid request id")
}
token = r.FormValue(QueryArgToken)
redirect, err = utils.EnsureNoOpenRedirect(r.FormValue(QueryArgRedirect))
if err != nil {
return RequestId{}, "", "", err
}
return
}
func VerifyUrl(r *http.Request, reg *Registration, token string) (*url.URL, error) {
redirectUrl, err := RedirectUrl(r, reg)
if err != nil {
return nil, err
}
uri := new(url.URL)
uri.Path = reg.Path + VerifyChallengeUrlSuffix
data := RequestDataFromContext(r.Context())
values := uri.Query()
values.Set(QueryArgRequestId, data.Id.String())
values.Set(QueryArgRedirect, redirectUrl.String())
values.Set(QueryArgToken, token)
values.Set(QueryArgChallenge, reg.Name)
uri.RawQuery = values.Encode()
return uri, nil
}
func RedirectUrl(r *http.Request, reg *Registration) (*url.URL, error) {
uri, err := url.ParseRequestURI(r.URL.String())
if err != nil {
return nil, err
}
data := RequestDataFromContext(r.Context())
values := uri.Query()
values.Set(QueryArgRequestId, data.Id.String())
values.Set(QueryArgReferer, r.Referer())
values.Set(QueryArgChallenge, reg.Name)
uri.RawQuery = values.Encode()
return uri, nil
}
func VerifyHandlerChallengeResponseFunc(state StateInterface, data *RequestData, w http.ResponseWriter, r *http.Request, verifyResult VerifyResult, err error, redirect string) {
if err != nil {
state.ErrorPage(w, r, http.StatusBadRequest, err, redirect)
return
} else if !verifyResult.Ok() {
state.ErrorPage(w, r, http.StatusForbidden, fmt.Errorf("access denied: failed challenge"), redirect)
return
}
http.Redirect(w, r, redirect, http.StatusTemporaryRedirect)
}
func VerifyHandlerFunc(state StateInterface, reg *Registration, verify VerifyFunc, responseFunc func(state StateInterface, data *RequestData, w http.ResponseWriter, r *http.Request, verifyResult VerifyResult, err error, redirect string)) http.HandlerFunc {
if verify == nil {
verify = reg.Verify
}
if responseFunc == nil {
responseFunc = VerifyHandlerChallengeResponseFunc
}
return func(w http.ResponseWriter, r *http.Request) {
data := RequestDataFromContext(r.Context())
requestId, redirect, token, err := GetVerifyInformation(r, reg)
if err != nil {
state.ChallengeFailed(r, reg, err, "", nil)
responseFunc(state, data, w, r, VerifyResultFail, fmt.Errorf("internal error: %w", err), "")
return
}
data.Id = requestId
err = func() (err error) {
expiration := data.Expiration(reg.Duration)
key := GetChallengeKeyForRequest(state, reg, expiration, r)
verifyResult, err := verify(key, []byte(token), r)
if err != nil {
return err
} else if !verifyResult.Ok() {
utils.ClearCookie(utils.CookiePrefix+reg.Name, w, r)
state.ChallengeFailed(r, reg, nil, redirect, nil)
responseFunc(state, data, w, r, verifyResult, nil, redirect)
return nil
}
challengeToken, err := reg.IssueChallengeToken(state.PrivateKey(), key, []byte(token), expiration, true)
if err != nil {
utils.ClearCookie(utils.CookiePrefix+reg.Name, w, r)
} else {
utils.SetCookie(utils.CookiePrefix+reg.Name, challengeToken, expiration, w, r)
}
data.ChallengeVerify[reg.id] = verifyResult
state.ChallengePassed(r, reg, redirect, nil)
responseFunc(state, data, w, r, verifyResult, nil, redirect)
return nil
}()
if err != nil {
utils.ClearCookie(utils.CookiePrefix+reg.Name, w, r)
state.ChallengeFailed(r, reg, err, redirect, nil)
responseFunc(state, data, w, r, VerifyResultFail, fmt.Errorf("access denied: error in challenge %s: %w", reg.Name, err), redirect)
return
}
}
}

158
lib/challenge/http/http.go Normal file
View File

@@ -0,0 +1,158 @@
package http
import (
"crypto/sha256"
"crypto/subtle"
"errors"
"git.gammaspectra.live/git/go-away/lib/challenge"
"git.gammaspectra.live/git/go-away/utils"
"github.com/goccy/go-yaml"
"github.com/goccy/go-yaml/ast"
"io"
"net/http"
"slices"
"time"
)
func init() {
challenge.Runtimes[Key] = FillRegistration
}
const Key = "http"
type Parameters struct {
VerifyProbability float64 `yaml:"verify-probability"`
HttpMethod string `yaml:"http-method"`
HttpCode int `yaml:"http-code"`
HttpCookie string `yaml:"http-cookie"`
Url string `yaml:"http-url"`
}
var DefaultParameters = Parameters{
VerifyProbability: 0.20,
HttpMethod: http.MethodGet,
HttpCode: http.StatusOK,
}
func FillRegistration(state challenge.StateInterface, reg *challenge.Registration, parameters ast.Node) error {
params := DefaultParameters
if parameters != nil {
ymlData, err := parameters.MarshalYAML()
if err != nil {
return err
}
err = yaml.Unmarshal(ymlData, &params)
if err != nil {
return err
}
}
if params.Url == "" {
return errors.New("empty url")
}
reg.Class = challenge.ClassTransparent
bindAuthValue := func(key challenge.Key, r *http.Request) ([]byte, error) {
var cookieValue string
if cookie, err := r.Cookie(params.HttpCookie); err != nil || cookie == nil {
// skip check if we don't have cookie or it's expired
return nil, http.ErrNoCookie
} else {
cookieValue = cookie.Value
}
// bind hash of cookie contents
sum := sha256.New()
sum.Write([]byte(cookieValue))
sum.Write([]byte{0})
sum.Write(key[:])
return sum.Sum(nil), nil
}
if params.VerifyProbability <= 0 {
//20% default
params.VerifyProbability = 0.20
} else if params.VerifyProbability > 1.0 {
params.VerifyProbability = 1.0
}
reg.VerifyProbability = params.VerifyProbability
if params.HttpCookie != "" {
// re-verify the cookie value
// TODO: configure to verify with backend
reg.Verify = func(key challenge.Key, token []byte, r *http.Request) (challenge.VerifyResult, error) {
sum, err := bindAuthValue(key, r)
if err != nil {
return challenge.VerifyResultFail, err
}
if subtle.ConstantTimeCompare(sum, token) == 1 {
return challenge.VerifyResultOK, nil
}
return challenge.VerifyResultFail, errors.New("invalid cookie value")
}
}
reg.IssueChallenge = func(w http.ResponseWriter, r *http.Request, key challenge.Key, expiry time.Time) challenge.VerifyResult {
var sum []byte
if params.HttpCookie != "" {
if c, err := r.Cookie(params.HttpCookie); err != nil || c == nil {
// skip check if we don't have cookie or it's expired
return challenge.VerifyResultSkip
} else {
sum, err = bindAuthValue(key, r)
if err != nil {
return challenge.VerifyResultFail
}
}
}
request, err := http.NewRequest(params.HttpMethod, params.Url, nil)
if err != nil {
return challenge.VerifyResultFail
}
var excludeHeaders = []string{"Host", "Content-Length"}
for k, v := range r.Header {
if slices.Contains(excludeHeaders, k) {
// skip these parameters
continue
}
request.Header[k] = v
}
// set id
request.Header.Set("X-Away-Id", challenge.RequestDataFromContext(r.Context()).Id.String())
// set request info in X headers
request.Header.Set("X-Away-Host", r.Host)
request.Header.Set("X-Away-Path", r.URL.Path)
request.Header.Set("X-Away-Query", r.URL.RawQuery)
response, err := state.Client().Do(request)
if err != nil {
return challenge.VerifyResultFail
}
defer response.Body.Close()
defer io.Copy(io.Discard, response.Body)
if response.StatusCode != params.HttpCode {
token, err := reg.IssueChallengeToken(state.PrivateKey(), key, sum, expiry, false)
if err != nil {
return challenge.VerifyResultFail
}
utils.SetCookie(utils.CookiePrefix+reg.Name, token, expiry, w, r)
return challenge.VerifyResultNotOK
} else {
token, err := reg.IssueChallengeToken(state.PrivateKey(), key, sum, expiry, true)
if err != nil {
return challenge.VerifyResultFail
}
utils.SetCookie(utils.CookiePrefix+reg.Name, token, expiry, w, r)
return challenge.VerifyResultOK
}
}
return nil
}

80
lib/challenge/key.go Normal file
View File

@@ -0,0 +1,80 @@
package challenge
import (
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"errors"
"net/http"
"time"
)
type Key [KeySize]byte
const KeySize = sha256.Size
func (k *Key) Set(flags KeyFlags) {
(*k)[0] |= uint8(flags)
}
func (k *Key) Get(flags KeyFlags) KeyFlags {
return KeyFlags((*k)[0] & uint8(flags))
}
func (k *Key) Unset(flags KeyFlags) {
(*k)[0] = (*k)[0] & ^(uint8(flags))
}
type KeyFlags uint8
const (
KeyFlagIsIPv4 = KeyFlags(1 << iota)
)
func KeyFromString(s string) (Key, error) {
b, err := hex.DecodeString(s)
if err != nil {
return Key{}, err
}
if len(b) != KeySize {
return Key{}, errors.New("invalid challenge key")
}
return Key(b), nil
}
func GetChallengeKeyForRequest(state StateInterface, reg *Registration, until time.Time, r *http.Request) Key {
data := RequestDataFromContext(r.Context())
address := data.RemoteAddress
hasher := sha256.New()
hasher.Write([]byte("challenge\x00"))
hasher.Write([]byte(reg.Name))
hasher.Write([]byte{0})
hasher.Write(address.To16())
hasher.Write([]byte{0})
// specific headers
for _, k := range []string{
"Accept-Language",
// General browser information
"User-Agent",
// TODO: not sent in preload
//"Sec-Ch-Ua",
//"Sec-Ch-Ua-Platform",
} {
hasher.Write([]byte(r.Header.Get(k)))
hasher.Write([]byte{0})
}
hasher.Write([]byte{0})
_ = binary.Write(hasher, binary.LittleEndian, until.UTC().Unix())
hasher.Write([]byte{0})
hasher.Write(state.PublicKey())
hasher.Write([]byte{0})
sum := Key(hasher.Sum(nil))
sum[0] = 0
if address.To4() != nil {
// Is IPv4, mark
sum.Set(KeyFlagIsIPv4)
}
return Key(sum)
}

View File

@@ -0,0 +1,128 @@
package preload_link
import (
"context"
"fmt"
"git.gammaspectra.live/git/go-away/lib/challenge"
"git.gammaspectra.live/git/go-away/utils"
"github.com/goccy/go-yaml"
"github.com/goccy/go-yaml/ast"
"net/http"
"time"
)
func init() {
challenge.Runtimes[Key] = FillRegistration
}
const Key = "preload-link"
type Parameters struct {
Deadline time.Duration `yaml:"preload-early-hint-deadline"`
}
var DefaultParameters = Parameters{
Deadline: time.Second * 3,
}
func FillRegistration(state challenge.StateInterface, reg *challenge.Registration, parameters ast.Node) error {
params := DefaultParameters
if parameters != nil {
ymlData, err := parameters.MarshalYAML()
if err != nil {
return err
}
err = yaml.Unmarshal(ymlData, &params)
if err != nil {
return err
}
}
verifier, issuer := challenge.NewKeyVerifier()
reg.Verify = verifier
reg.Class = challenge.ClassTransparent
ob := challenge.NewAwaiter[string]()
reg.Object = ob
reg.IssueChallenge = func(w http.ResponseWriter, r *http.Request, key challenge.Key, expiry time.Time) challenge.VerifyResult {
// this only works on HTTP/2 and HTTP/3
if r.ProtoMajor < 2 {
// this can happen if we are an upgraded request from HTTP/1.1 to HTTP/2 in H2C
if _, ok := w.(http.Pusher); !ok {
return challenge.VerifyResultSkip
}
}
issuerKey := issuer(key)
uri, err := challenge.VerifyUrl(r, reg, issuerKey)
if err != nil {
return challenge.VerifyResultFail
}
// remove redirect args
values := uri.Query()
values.Del(challenge.QueryArgRedirect)
uri.RawQuery = values.Encode()
// Redirect URI must be absolute to work
uri.Scheme = utils.GetRequestScheme(r)
uri.Host = r.Host
w.Header().Set("Link", fmt.Sprintf("<%s>; rel=\"preload\"; as=\"style\"; fetchpriority=high", uri.String()))
defer func() {
// remove old header so it won't show on response!
w.Header().Del("Link")
}()
w.WriteHeader(http.StatusEarlyHints)
ctx, cancel := context.WithTimeout(r.Context(), params.Deadline)
defer cancel()
if result := ob.Await(issuerKey, ctx); result.Ok() {
// this should serve!
return challenge.VerifyResultOK
} else if result == challenge.VerifyResultNone {
// we hit timeout
return challenge.VerifyResultFail
} else {
return result
}
}
mux := http.NewServeMux()
mux.HandleFunc("GET "+reg.Path+challenge.VerifyChallengeUrlSuffix, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/css; charset=utf-8")
w.Header().Set("Content-Length", "0")
data := challenge.RequestDataFromContext(r.Context())
key := challenge.GetChallengeKeyForRequest(state, reg, data.Expiration(reg.Duration), r)
issuerKey := issuer(key)
_, _, token, err := challenge.GetVerifyInformation(r, reg)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
}
verifyResult, _ := verifier(key, []byte(token), r)
if !verifyResult.Ok() {
w.WriteHeader(http.StatusUnauthorized)
} else {
w.WriteHeader(http.StatusOK)
}
ob.Solve(issuerKey, verifyResult)
if !verifyResult.Ok() {
// also give data on other failure when mismatched
ob.Solve(token, verifyResult)
}
})
reg.Handler = mux
return nil
}

View File

@@ -0,0 +1,64 @@
package refresh
import (
"git.gammaspectra.live/git/go-away/lib/challenge"
"github.com/goccy/go-yaml"
"github.com/goccy/go-yaml/ast"
"net/http"
"time"
)
func init() {
challenge.Runtimes["refresh"] = FillRegistration
}
type Parameters struct {
Mode string `yaml:"refresh-mode"`
}
var DefaultParameters = Parameters{
Mode: "header",
}
func FillRegistration(state challenge.StateInterface, reg *challenge.Registration, parameters ast.Node) error {
params := DefaultParameters
if parameters != nil {
ymlData, err := parameters.MarshalYAML()
if err != nil {
return err
}
err = yaml.Unmarshal(ymlData, &params)
if err != nil {
return err
}
}
reg.Class = challenge.ClassBlocking
verifier, issuer := challenge.NewKeyVerifier()
reg.Verify = verifier
reg.IssueChallenge = func(w http.ResponseWriter, r *http.Request, key challenge.Key, expiry time.Time) challenge.VerifyResult {
uri, err := challenge.VerifyUrl(r, reg, issuer(key))
if err != nil {
return challenge.VerifyResultFail
}
if params.Mode == "meta" {
state.ChallengePage(w, r, state.Settings().ChallengeResponseCode, reg, map[string]any{
"Meta": map[string]string{
"refresh": "0; url=" + uri.String(),
},
})
} else {
// self redirect!
w.Header().Set("Refresh", "0; url="+uri.String())
state.ChallengePage(w, r, state.Settings().ChallengeResponseCode, reg, nil)
}
return challenge.VerifyResultNone
}
return nil
}

252
lib/challenge/register.go Normal file
View File

@@ -0,0 +1,252 @@
package challenge
import (
"bytes"
"crypto/ed25519"
"errors"
"fmt"
"git.gammaspectra.live/git/go-away/lib/condition"
"git.gammaspectra.live/git/go-away/lib/policy"
"git.gammaspectra.live/git/go-away/utils"
"github.com/go-jose/go-jose/v4"
"github.com/go-jose/go-jose/v4/jwt"
"github.com/goccy/go-yaml/ast"
"github.com/google/cel-go/cel"
"io"
"math/rand/v2"
"net/http"
"path"
"strings"
"time"
)
type Register map[Id]*Registration
func (r Register) Get(id Id) (*Registration, bool) {
c, ok := r[id]
return c, ok
}
func (r Register) GetByName(name string) (*Registration, Id, bool) {
for id, c := range r {
if c.Name == name {
return c, id, true
}
}
return nil, 0, false
}
var idCounter Id
// DefaultDuration TODO: adjust
const DefaultDuration = time.Hour * 24 * 7
func (r Register) Create(state StateInterface, name string, pol policy.Challenge, replacer *strings.Replacer) (*Registration, Id, error) {
runtime, ok := Runtimes[pol.Runtime]
if !ok {
return nil, 0, fmt.Errorf("unknown challenge runtime %s", pol.Runtime)
}
reg := &Registration{
Name: name,
Path: path.Join(state.UrlPath(), "challenge", name),
Duration: pol.Duration,
}
if reg.Duration == 0 {
reg.Duration = DefaultDuration
}
// allow nesting
var conditions []string
for _, cond := range pol.Conditions {
if replacer != nil {
cond = replacer.Replace(cond)
}
conditions = append(conditions, cond)
}
if len(conditions) > 0 {
ast, err := condition.FromStrings(state.ProgramEnv(), condition.OperatorOr, conditions...)
if err != nil {
return nil, 0, fmt.Errorf("error compiling conditions: %v", err)
}
reg.Condition, err = condition.Program(state.ProgramEnv(), ast)
if err != nil {
return nil, 0, fmt.Errorf("error compiling program: %v", err)
}
}
if _, oldId, ok := r.GetByName(reg.Name); ok {
reg.id = oldId
} else {
idCounter++
reg.id = idCounter
}
err := runtime(state, reg, pol.Parameters)
if err != nil {
return nil, 0, fmt.Errorf("error filling registration: %v", err)
}
r[reg.id] = reg
return reg, reg.id, nil
}
func (r Register) Add(c *Registration) Id {
if _, oldId, ok := r.GetByName(c.Name); ok {
c.id = oldId
r[oldId] = c
return oldId
} else {
idCounter++
c.id = idCounter
r[idCounter] = c
return idCounter
}
}
type Registration struct {
// id The assigned internal identifier
id Id
// Name The unique name for this challenge
Name string
// Class whether this challenge is transparent or otherwise
Class Class
// Condition A CEL condition which is passed the same environment as general rules.
// If nil, always true
// If non-nil, must return true for this challenge to be allowed to be executed
Condition cel.Program
// Path The url path that this challenge is hosted under for the Handler to be called.
Path string
// Duration How long this challenge will be valid when passed
Duration time.Duration
// Handler An HTTP handler for all requests coming on the Path
// This handler will need to handle MakeChallengeUrlSuffix and VerifyChallengeUrlSuffix as well if needed
// Recommended to use http.ServeMux
Handler http.Handler
// Verify Verify an issued token
Verify VerifyFunc
VerifyProbability float64
// IssueChallenge Issues a challenge to a request.
// If Class is ClassTransparent and VerifyResult is !VerifyResult.Ok(), continue with other challenges
// TODO: have this return error as well
IssueChallenge func(w http.ResponseWriter, r *http.Request, key Key, expiry time.Time) VerifyResult
// Object used to handle state or similar
// Can be nil if no state is needed
// If non-nil must implement io.Closer even if there's nothing to do
Object io.Closer
}
type VerifyFunc func(key Key, token []byte, r *http.Request) (VerifyResult, error)
type Token struct {
Name string `json:"name"`
Key []byte `json:"key"`
Result []byte `json:"result,omitempty"`
Ok bool `json:"ok"`
Expiry jwt.NumericDate `json:"exp,omitempty"`
NotBefore jwt.NumericDate `json:"nbf,omitempty"`
IssuedAt jwt.NumericDate `json:"iat,omitempty"`
}
func (reg Registration) Id() Id {
return reg.id
}
func (reg Registration) IssueChallengeToken(privateKey ed25519.PrivateKey, key Key, result []byte, until time.Time, ok bool) (token string, err error) {
signer, err := jose.NewSigner(jose.SigningKey{
Algorithm: jose.EdDSA,
Key: privateKey,
}, nil)
if err != nil {
return "", err
}
token, err = jwt.Signed(signer).Claims(Token{
Name: reg.Name,
Key: key[:],
Result: result,
Ok: ok,
Expiry: jwt.NumericDate(until.Unix()),
NotBefore: jwt.NumericDate(time.Now().UTC().AddDate(0, 0, -1).Unix()),
IssuedAt: jwt.NumericDate(time.Now().UTC().Unix()),
}).Serialize()
if err != nil {
return "", err
}
return token, nil
}
var ErrVerifyKeyMismatch = errors.New("verify: key mismatch")
var ErrVerifyVerifyMismatch = errors.New("verify: verification mismatch")
var ErrTokenExpired = errors.New("token: expired")
func (reg Registration) VerifyChallengeToken(publicKey ed25519.PublicKey, expectedKey Key, r *http.Request) (VerifyResult, VerifyState, error) {
cookie, err := r.Cookie(utils.CookiePrefix + reg.Name)
if err != nil {
return VerifyResultNone, VerifyStateNone, err
}
if cookie == nil {
return VerifyResultNone, VerifyStateNone, http.ErrNoCookie
}
token, err := jwt.ParseSigned(cookie.Value, []jose.SignatureAlgorithm{jose.EdDSA})
if err != nil {
return VerifyResultFail, VerifyStateNone, err
}
var i Token
err = token.Claims(publicKey, &i)
if err != nil {
return VerifyResultFail, VerifyStateNone, err
}
if i.Name != reg.Name {
return VerifyResultFail, VerifyStateNone, errors.New("token invalid name")
}
if i.Expiry.Time().Compare(time.Now()) < 0 {
return VerifyResultFail, VerifyStateNone, ErrTokenExpired
}
if i.NotBefore.Time().Compare(time.Now()) > 0 {
return VerifyResultFail, VerifyStateNone, errors.New("token not valid yet")
}
if bytes.Compare(expectedKey[:], i.Key) != 0 {
return VerifyResultFail, VerifyStateNone, ErrVerifyKeyMismatch
}
if reg.Verify != nil {
if rand.Float64() < reg.VerifyProbability {
// random spot check
if ok, err := reg.Verify(expectedKey, i.Result, r); err != nil {
return VerifyResultFail, VerifyStateFull, err
} else if ok == VerifyResultNotOK {
return VerifyResultNotOK, VerifyStateFull, nil
} else if !ok.Ok() {
return ok, VerifyStateFull, ErrVerifyVerifyMismatch
} else {
return ok, VerifyStateFull, nil
}
}
}
if !i.Ok {
return VerifyResultNotOK, VerifyStateBrief, nil
}
return VerifyResultOK, VerifyStateBrief, nil
}
type FillRegistration func(state StateInterface, reg *Registration, parameters ast.Node) error
var Runtimes = make(map[string]FillRegistration)

View File

@@ -0,0 +1,55 @@
package resource_load
import (
"fmt"
"git.gammaspectra.live/git/go-away/lib/challenge"
"github.com/goccy/go-yaml/ast"
"html/template"
"net/http"
"time"
)
func init() {
challenge.Runtimes["resource-load"] = FillRegistrationHeader
}
func FillRegistrationHeader(state challenge.StateInterface, reg *challenge.Registration, parameters ast.Node) error {
reg.Class = challenge.ClassBlocking
verifier, issuer := challenge.NewKeyVerifier()
reg.Verify = verifier
reg.IssueChallenge = func(w http.ResponseWriter, r *http.Request, key challenge.Key, expiry time.Time) challenge.VerifyResult {
uri, err := challenge.VerifyUrl(r, reg, issuer(key))
if err != nil {
return challenge.VerifyResultFail
}
// self redirect!
//TODO: adjust deadline
w.Header().Set("Refresh", "2; url="+r.URL.String())
state.ChallengePage(w, r, state.Settings().ChallengeResponseCode, reg, map[string]any{
"HeaderTags": []template.HTML{
template.HTML(fmt.Sprintf("<link href=\"%s\" rel=\"stylesheet\" crossorigin=\"use-credentials\">", uri.String())),
},
})
return challenge.VerifyResultNone
}
mux := http.NewServeMux()
mux.HandleFunc("GET "+reg.Path+challenge.VerifyChallengeUrlSuffix, challenge.VerifyHandlerFunc(state, reg, nil, func(state challenge.StateInterface, data *challenge.RequestData, w http.ResponseWriter, r *http.Request, verifyResult challenge.VerifyResult, err error, redirect string) {
//TODO: add other types inside css that need to be loaded!
w.Header().Set("Content-Type", "text/css; charset=utf-8")
w.Header().Set("Content-Length", "0")
if !verifyResult.Ok() {
w.WriteHeader(http.StatusForbidden)
} else {
w.WriteHeader(http.StatusOK)
}
}))
reg.Handler = mux
return nil
}

41
lib/challenge/script.go Normal file
View File

@@ -0,0 +1,41 @@
package challenge
import (
_ "embed"
"encoding/json"
"git.gammaspectra.live/git/go-away/utils"
"net/http"
"text/template"
)
//go:embed script.mjs
var scriptData []byte
var scriptTemplate = template.Must(template.New("script.mjs").Parse(string(scriptData)))
func ServeChallengeScript(w http.ResponseWriter, r *http.Request, reg *Registration, params any, script string) {
data := RequestDataFromContext(r.Context())
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
w.Header().Set("Content-Type", "text/javascript; charset=utf-8")
paramData, err := json.Marshal(params)
if err != nil {
//TODO: log
panic(err)
}
w.WriteHeader(http.StatusOK)
err = scriptTemplate.Execute(w, map[string]any{
"Id": data.Id.String(),
"Path": reg.Path,
"Parameters": paramData,
"Random": utils.CacheBust(),
"Challenge": reg.Name,
"ChallengeScript": script,
})
if err != nil {
//TODO: log
panic(err)
}
}

View File

@@ -54,10 +54,11 @@ const u = (url = "", params = {}) => {
setTimeout(() => {
const redir = window.location.href;
window.location.href = u("{{ .Path }}/verify-challenge", {
result: result,
redirect: redir,
requestId: "{{ .Id }}",
elapsedTime: t1 - t0,
__goaway_token: result,
__goaway_challenge: "{{ .Challenge }}",
__goaway_redirect: redir,
__goaway_id: "{{ .Id }}",
__goaway_elapsedTime: t1 - t0,
});
}, 500);
} catch (err) {

View File

@@ -1,176 +0,0 @@
package challenge
import (
"bytes"
"crypto/ed25519"
"errors"
"git.gammaspectra.live/git/go-away/utils"
"github.com/go-jose/go-jose/v4"
"github.com/go-jose/go-jose/v4/jwt"
"github.com/google/cel-go/cel"
"math/rand/v2"
"net/http"
"time"
)
type Result int
const (
// ResultStop Stop testing other challenges and return
ResultStop = Result(iota)
// ResultContinue Test next
ResultContinue
// ResultPass passed, return and proxy
ResultPass
)
type Id int
type Challenge struct {
Id Id
Program cel.Program
Name string
Path string
Verify func(key []byte, result string, r *http.Request) (bool, error)
VerifyProbability float64
ServeStatic http.Handler
ServeChallenge func(w http.ResponseWriter, r *http.Request, key []byte, expiry time.Time) Result
ServeScript http.Handler
ServeScriptPath string
ServeMakeChallenge http.Handler
ServeVerifyChallenge http.Handler
}
type Token struct {
Name string `json:"name"`
Key []byte `json:"key"`
Result []byte `json:"result,omitempty"`
Expiry *jwt.NumericDate `json:"exp,omitempty"`
NotBefore *jwt.NumericDate `json:"nbf,omitempty"`
IssuedAt *jwt.NumericDate `json:"iat,omitempty"`
}
func (c Challenge) IssueChallengeToken(privateKey ed25519.PrivateKey, key, result []byte, until time.Time) (token string, err error) {
signer, err := jose.NewSigner(jose.SigningKey{
Algorithm: jose.EdDSA,
Key: privateKey,
}, nil)
if err != nil {
return "", err
}
expiry := jwt.NumericDate(until.Unix())
notBefore := jwt.NumericDate(time.Now().UTC().AddDate(0, 0, -1).Unix())
issuedAt := jwt.NumericDate(time.Now().UTC().Unix())
token, err = jwt.Signed(signer).Claims(Token{
Name: c.Name,
Key: key,
Result: result,
Expiry: &expiry,
NotBefore: &notBefore,
IssuedAt: &issuedAt,
}).Serialize()
if err != nil {
return "", err
}
return token, nil
}
type VerifyResult int
const (
VerifyResultNONE = VerifyResult(iota)
VerifyResultFAIL
VerifyResultSKIP
// VerifyResultPASS Client just passed this challenge
VerifyResultPASS
VerifyResultOK
VerifyResultBRIEF
VerifyResultFULL
)
func (r VerifyResult) Ok() bool {
return r >= VerifyResultPASS
}
func (r VerifyResult) String() string {
switch r {
case VerifyResultNONE:
return "NONE"
case VerifyResultFAIL:
return "FAIL"
case VerifyResultSKIP:
return "SKIP"
case VerifyResultPASS:
return "PASS"
case VerifyResultOK:
return "OK"
case VerifyResultBRIEF:
return "BRIEF"
case VerifyResultFULL:
return "FULL"
default:
panic("unsupported")
}
}
var ErrVerifyKeyMismatch = errors.New("verify: key mismatch")
var ErrVerifyVerifyMismatch = errors.New("verify: verification mismatch")
func (c Challenge) VerifyChallengeToken(publicKey ed25519.PublicKey, expectedKey []byte, r *http.Request) (VerifyResult, error) {
cookie, err := r.Cookie(utils.CookiePrefix + c.Name)
if err != nil {
return VerifyResultNONE, err
}
if cookie == nil {
return VerifyResultNONE, http.ErrNoCookie
}
token, err := jwt.ParseSigned(cookie.Value, []jose.SignatureAlgorithm{jose.EdDSA})
if err != nil {
return VerifyResultFAIL, err
}
var i Token
err = token.Claims(publicKey, &i)
if err != nil {
return VerifyResultFAIL, err
}
if i.Name != c.Name {
return VerifyResultFAIL, errors.New("token invalid name")
}
if i.Expiry == nil && i.Expiry.Time().Compare(time.Now()) < 0 {
return VerifyResultFAIL, errors.New("token expired")
}
if i.NotBefore == nil && i.NotBefore.Time().Compare(time.Now()) > 0 {
return VerifyResultFAIL, errors.New("token not valid yet")
}
if bytes.Compare(expectedKey, i.Key) != 0 {
return VerifyResultFAIL, ErrVerifyKeyMismatch
}
if c.Verify != nil {
if rand.Float64() < c.VerifyProbability {
// random spot check
if ok, err := c.Verify(expectedKey, string(i.Result), r); err != nil {
return VerifyResultFAIL, err
} else if !ok {
return VerifyResultFAIL, ErrVerifyVerifyMismatch
}
return VerifyResultFULL, nil
} else {
return VerifyResultBRIEF, nil
}
}
return VerifyResultOK, nil
}

112
lib/challenge/types.go Normal file
View File

@@ -0,0 +1,112 @@
package challenge
import (
"crypto/ed25519"
"git.gammaspectra.live/git/go-away/lib/policy"
"github.com/google/cel-go/cel"
"log/slog"
"net/http"
)
type Id int64
type Class uint8
const (
// ClassTransparent Transparent challenges work inline in the execution process.
// These can pass or continue, so more challenges or requests can ve served afterward.
ClassTransparent = Class(iota)
// ClassBlocking Blocking challenges must serve a different response to challenge the requester.
// These can pass or stop, for example, due to serving a challenge
ClassBlocking
)
type VerifyState uint8
const (
VerifyStateNone = VerifyState(iota)
// VerifyStatePass Challenge was just passed on this request
VerifyStatePass
// VerifyStateBrief Challenge token was verified but didn't check the challenge
VerifyStateBrief
// VerifyStateFull Challenge token was verified and challenge verification was done
VerifyStateFull
)
func (r VerifyState) String() string {
switch r {
case VerifyStatePass:
return "PASS"
case VerifyStateBrief:
return "BRIEF"
case VerifyStateFull:
return "FULL"
default:
panic("unsupported")
}
}
type VerifyResult uint8
const (
// VerifyResultNone A negative pass result, without a token
VerifyResultNone = VerifyResult(iota)
// VerifyResultFail A negative pass result, with an invalid token
VerifyResultFail
// VerifyResultSkip Challenge was skipped due to precondition
VerifyResultSkip
// VerifyResultNotOK A negative pass result, with a valid token
VerifyResultNotOK
// VerifyResultOK A positive pass result, with a valid token
VerifyResultOK
)
func (r VerifyResult) Ok() bool {
return r >= VerifyResultOK
}
func (r VerifyResult) String() string {
switch r {
case VerifyResultNone:
return "None"
case VerifyResultFail:
return "Fail"
case VerifyResultSkip:
return "Skip"
case VerifyResultNotOK:
return "NotOK"
case VerifyResultOK:
return "OK"
default:
panic("unsupported")
}
}
type StateInterface interface {
ProgramEnv() *cel.Env
Client() *http.Client
PrivateKey() ed25519.PrivateKey
PublicKey() ed25519.PublicKey
UrlPath() string
ChallengeFailed(r *http.Request, reg *Registration, err error, redirect string, logger *slog.Logger)
ChallengePassed(r *http.Request, reg *Registration, redirect string, logger *slog.Logger)
ChallengeIssued(r *http.Request, reg *Registration, redirect string, logger *slog.Logger)
Logger(r *http.Request) *slog.Logger
ChallengePage(w http.ResponseWriter, r *http.Request, status int, reg *Registration, params map[string]any)
ErrorPage(w http.ResponseWriter, r *http.Request, status int, err error, redirect string)
GetChallenge(id Id) (*Registration, bool)
GetChallengeByName(name string) (*Registration, bool)
GetChallenges() Register
Settings() policy.Settings
GetBackend(host string) http.Handler
}

View File

@@ -111,6 +111,7 @@ type VerifyChallengeInput struct {
type VerifyChallengeOutput uint64
// TODO: expand allowed values
const (
VerifyChallengeOutputOK = VerifyChallengeOutput(iota)
VerifyChallengeOutputFailed

View File

@@ -0,0 +1,186 @@
package wasm
import (
"codeberg.org/meta/gzipped/v2"
"context"
"errors"
"fmt"
"git.gammaspectra.live/git/go-away/embed"
"git.gammaspectra.live/git/go-away/lib/challenge"
_interface "git.gammaspectra.live/git/go-away/lib/challenge/wasm/interface"
"git.gammaspectra.live/git/go-away/utils"
"git.gammaspectra.live/git/go-away/utils/inline"
"github.com/goccy/go-yaml"
"github.com/goccy/go-yaml/ast"
"github.com/tetratelabs/wazero/api"
"html/template"
"io"
"io/fs"
"net/http"
"path"
"time"
)
func init() {
challenge.Runtimes["js"] = FillJavaScriptRegistration
}
type Parameters struct {
Path string `yaml:"path"`
// Loader path to js/mjs file to use as challenge issuer
Loader string `yaml:"js-loader"`
// Runtime path to WASM wasip1 runtime
Runtime string `yaml:"wasm-runtime"`
Settings map[string]string `yaml:"wasm-runtime-settings"`
NativeCompiler bool `yaml:"wasm-native-compiler"`
VerifyProbability float64 `yaml:"verify-probability"`
}
var DefaultParameters = Parameters{
VerifyProbability: 0.1,
NativeCompiler: true,
}
func FillJavaScriptRegistration(state challenge.StateInterface, reg *challenge.Registration, parameters ast.Node) error {
params := DefaultParameters
if parameters != nil {
ymlData, err := parameters.MarshalYAML()
if err != nil {
return err
}
err = yaml.Unmarshal(ymlData, &params)
if err != nil {
return err
}
}
reg.Class = challenge.ClassBlocking
mux := http.NewServeMux()
if params.Path == "" {
params.Path = reg.Name
}
assetsFs, err := embed.GetFallbackFS(embed.ChallengeFs, params.Path)
if err != nil {
return err
}
if params.VerifyProbability <= 0 {
//10% default
params.VerifyProbability = 0.1
} else if params.VerifyProbability > 1.0 {
params.VerifyProbability = 1.0
}
reg.VerifyProbability = params.VerifyProbability
ob := NewRunner(params.NativeCompiler)
reg.Object = ob
wasmData, err := assetsFs.ReadFile(path.Join("runtime", params.Runtime))
if err != nil {
return fmt.Errorf("could not load runtime: %w", err)
}
err = ob.Compile("runtime", wasmData)
if err != nil {
return fmt.Errorf("compiling runtime: %w", err)
}
reg.IssueChallenge = func(w http.ResponseWriter, r *http.Request, key challenge.Key, expiry time.Time) challenge.VerifyResult {
state.ChallengePage(w, r, state.Settings().ChallengeResponseCode, reg, map[string]any{
"EndTags": []template.HTML{
template.HTML(fmt.Sprintf("<script async type=\"module\" src=\"%s?cacheBust=%s\"></script>", reg.Path+"/script.mjs", utils.CacheBust())),
},
})
return challenge.VerifyResultNone
}
reg.Verify = func(key challenge.Key, token []byte, r *http.Request) (challenge.VerifyResult, error) {
var ok bool
err = ob.Instantiate("runtime", func(ctx context.Context, mod api.Module) (err error) {
in := _interface.VerifyChallengeInput{
Key: key[:],
Parameters: params.Settings,
Result: token,
}
out, err := VerifyChallengeCall(ctx, mod, in)
if err != nil {
return err
}
if out == _interface.VerifyChallengeOutputError {
return errors.New("error checking challenge")
}
ok = out == _interface.VerifyChallengeOutputOK
return nil
})
if err != nil {
return challenge.VerifyResultFail, err
}
if ok {
return challenge.VerifyResultOK, nil
}
return challenge.VerifyResultFail, nil
}
// serve assets if existent
if staticFs, err := fs.Sub(assetsFs, "static"); err != nil {
return fmt.Errorf("no static assets: %w", err)
} else {
mux.Handle("GET "+reg.Path+"/static/", http.StripPrefix(reg.Path+"/static/", gzipped.FileServer(gzipped.FS(staticFs))))
}
mux.HandleFunc(reg.Path+challenge.MakeChallengeUrlSuffix, func(w http.ResponseWriter, r *http.Request) {
data := challenge.RequestDataFromContext(r.Context())
err := ob.Instantiate("runtime", func(ctx context.Context, mod api.Module) (err error) {
key := challenge.GetChallengeKeyForRequest(state, reg, data.Expiration(reg.Duration), r)
in := _interface.MakeChallengeInput{
Key: key[:],
Parameters: params.Settings,
Headers: inline.MIMEHeader(r.Header),
}
in.Data, err = io.ReadAll(r.Body)
if err != nil {
return err
}
out, err := MakeChallengeCall(ctx, mod, in)
if err != nil {
return err
}
// set output headers
for k, v := range out.Headers {
w.Header()[k] = v
}
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(out.Data)))
w.WriteHeader(out.Code)
_, _ = w.Write(out.Data)
return nil
})
if err != nil {
state.ErrorPage(w, r, http.StatusInternalServerError, err, "")
return
}
})
mux.HandleFunc(reg.Path+challenge.VerifyChallengeUrlSuffix, challenge.VerifyHandlerFunc(state, reg, nil, nil))
mux.HandleFunc("GET "+reg.Path+"/script.mjs", func(w http.ResponseWriter, r *http.Request) {
challenge.ServeChallengeScript(w, r, reg, params.Settings, path.Join(reg.Path, "static", params.Loader))
})
reg.Handler = mux
return nil
}

View File

@@ -94,11 +94,13 @@ func (r *Runner) Compile(key string, binary []byte) error {
return nil
}
func (r *Runner) Close() {
func (r *Runner) Close() error {
for _, module := range r.modules {
module.Close(r.context)
if err := module.Close(r.context); err != nil {
return err
}
}
r.runtime.Close(r.context)
return r.runtime.Close(r.context)
}
var ErrModuleNotFound = errors.New("module not found")

View File

@@ -3,6 +3,12 @@ package condition
import (
"fmt"
"github.com/google/cel-go/cel"
"github.com/google/cel-go/common/types"
"github.com/google/cel-go/common/types/ref"
"github.com/google/cel-go/ext"
"github.com/yl2chen/cidranger"
"log/slog"
"net"
"strings"
)
@@ -15,6 +21,124 @@ const (
OperatorAnd = "&&"
)
func NewRulesEnvironment(networks map[string]cidranger.Ranger) (*cel.Env, error) {
return cel.NewEnv(
ext.Strings(
ext.StringsLocale("en_US"),
ext.StringsValidateFormatCalls(true),
),
cel.DefaultUTCTimeZone(true),
//TODO: custom type for remoteAddress
cel.Variable("remoteAddress", cel.BytesType),
cel.Variable("host", cel.StringType),
cel.Variable("method", cel.StringType),
cel.Variable("userAgent", cel.StringType),
cel.Variable("path", cel.StringType),
cel.Variable("query", cel.MapType(cel.StringType, cel.StringType)),
cel.Variable("fp", cel.MapType(cel.StringType, cel.StringType)),
// http.Header
cel.Variable("headers", cel.MapType(cel.StringType, cel.StringType)),
//TODO: dynamic type?
cel.Function("inDNSBL",
cel.Overload("inDNSBL_ip",
[]*cel.Type{cel.AnyType},
cel.BoolType,
cel.UnaryBinding(func(val ref.Val) ref.Val {
slog.Error("inDNSBL function has been deprecated, replace with dnsbl challenge")
return types.Bool(false)
}),
),
),
cel.Function("network",
cel.MemberOverload("netIP_network_string",
[]*cel.Type{cel.BytesType, cel.StringType},
cel.BoolType,
cel.BinaryBinding(func(lhs ref.Val, rhs ref.Val) ref.Val {
var ip net.IP
switch v := lhs.Value().(type) {
case []byte:
ip = v
case net.IP:
ip = v
}
if ip == nil {
panic(fmt.Errorf("invalid ip %v", lhs.Value()))
}
val, ok := rhs.Value().(string)
if !ok {
panic(fmt.Errorf("invalid network value %v", rhs.Value()))
}
network, ok := networks[val]
if !ok {
_, ipNet, err := net.ParseCIDR(val)
if err != nil {
panic("network not found")
}
return types.Bool(ipNet.Contains(ip))
} else {
ok, err := network.Contains(ip)
if err != nil {
panic(err)
}
return types.Bool(ok)
}
}),
),
),
cel.Function("inNetwork",
cel.Overload("inNetwork_string_ip",
[]*cel.Type{cel.StringType, cel.BytesType},
cel.BoolType,
cel.BinaryBinding(func(lhs ref.Val, rhs ref.Val) ref.Val {
var ip net.IP
switch v := rhs.Value().(type) {
case []byte:
ip = v
case net.IP:
ip = v
}
if ip == nil {
panic(fmt.Errorf("invalid ip %v", rhs.Value()))
}
val, ok := lhs.Value().(string)
if !ok {
panic(fmt.Errorf("invalid value %v", lhs.Value()))
}
slog.Debug(fmt.Sprintf("inNetwork function has been deprecated and will be removed in a future release, use remoteAddress.network(\"%s\") instead", val))
network, ok := networks[val]
if !ok {
_, ipNet, err := net.ParseCIDR(val)
if err != nil {
panic("network not found")
}
return types.Bool(ipNet.Contains(ip))
} else {
ok, err := network.Contains(ip)
if err != nil {
panic(err)
}
return types.Bool(ok)
}
}),
),
),
)
}
func Program(env *cel.Env, ast *cel.Ast) (cel.Program, error) {
return env.Program(ast,
cel.EvalOptions(cel.OptOptimize),
)
}
func FromStrings(env *cel.Env, operator string, conditions ...string) (*cel.Ast, error) {
var asts []*cel.Ast
for _, c := range conditions {

158
lib/condition/map.go Normal file
View File

@@ -0,0 +1,158 @@
package condition
import (
"fmt"
"github.com/google/cel-go/common/types"
"github.com/google/cel-go/common/types/ref"
"github.com/google/cel-go/common/types/traits"
"net/textproto"
"reflect"
"strings"
)
type mimeLike struct {
m textproto.MIMEHeader
}
func (a mimeLike) ConvertToNative(typeDesc reflect.Type) (any, error) {
return nil, fmt.Errorf("type conversion error from map to '%v'", typeDesc)
}
func (a mimeLike) ConvertToType(typeVal ref.Type) ref.Val {
switch typeVal {
case types.MapType:
return a
case types.TypeType:
return types.MapType
}
return types.NewErr("type conversion error from '%s' to '%s'", types.MapType, typeVal)
}
func (a mimeLike) Equal(other ref.Val) ref.Val {
return types.Bool(false)
}
func (a mimeLike) Type() ref.Type {
return types.MapType
}
func (a mimeLike) Value() any {
return a.m
}
func (a mimeLike) Contains(key ref.Val) ref.Val {
_, found := a.Find(key)
return types.Bool(found)
}
func (a mimeLike) Get(key ref.Val) ref.Val {
v, found := a.Find(key)
if !found {
return types.ValOrErr(v, "no such key: %v", key)
}
return v
}
func (a mimeLike) Iterator() traits.Iterator {
panic("implement me")
}
func (a mimeLike) IsZeroValue() bool {
return len(a.m) == 0
}
func (a mimeLike) Size() ref.Val {
return types.Int(len(a.m))
}
func (a mimeLike) Find(key ref.Val) (ref.Val, bool) {
k, ok := key.(types.String)
if !ok {
return nil, false
}
return singleVal(a.m.Values(string(k)), true)
}
type valuesLike struct {
m map[string][]string
}
func (a valuesLike) ConvertToNative(typeDesc reflect.Type) (any, error) {
return nil, fmt.Errorf("type conversion error from map to '%v'", typeDesc)
}
func (a valuesLike) ConvertToType(typeVal ref.Type) ref.Val {
switch typeVal {
case types.MapType:
return a
case types.TypeType:
return types.MapType
}
return types.NewErr("type conversion error from '%s' to '%s'", types.MapType, typeVal)
}
func (a valuesLike) Equal(other ref.Val) ref.Val {
return types.Bool(false)
}
func (a valuesLike) Type() ref.Type {
return types.MapType
}
func (a valuesLike) Value() any {
return a.m
}
func (a valuesLike) Contains(key ref.Val) ref.Val {
_, found := a.Find(key)
return types.Bool(found)
}
func (a valuesLike) Get(key ref.Val) ref.Val {
v, found := a.Find(key)
if !found {
return types.ValOrErr(v, "no such key: %v", key)
}
return v
}
func (a valuesLike) Iterator() traits.Iterator {
panic("implement me")
}
func (a valuesLike) IsZeroValue() bool {
return len(a.m) == 0
}
func (a valuesLike) Size() ref.Val {
return types.Int(len(a.m))
}
func (a valuesLike) Find(key ref.Val) (ref.Val, bool) {
k, ok := key.(types.String)
if !ok {
return nil, false
}
val, ok := a.m[string(k)]
return singleVal(val, ok)
}
func singleVal(values []string, ok bool) (ref.Val, bool) {
if len(values) == 0 || !ok {
return nil, false
}
if len(values) > 1 {
return types.String(strings.Join(values, ",")), true
}
return types.String(values[0]), true
}
func NewMIMEMap(m textproto.MIMEHeader) traits.Mapper {
return mimeLike{m: m}
}
func NewValuesMap(m map[string][]string) traits.Mapper {
return mimeLike{m: m}
}

View File

@@ -1,118 +1,11 @@
package lib
import (
"context"
"fmt"
"github.com/google/cel-go/cel"
"github.com/google/cel-go/common/types"
"github.com/google/cel-go/common/types/ref"
"log/slog"
"net"
"time"
"git.gammaspectra.live/git/go-away/lib/condition"
)
func (state *State) initConditions() (err error) {
state.RulesEnv, err = cel.NewEnv(
cel.DefaultUTCTimeZone(true),
cel.Variable("remoteAddress", cel.BytesType),
cel.Variable("host", cel.StringType),
cel.Variable("method", cel.StringType),
cel.Variable("userAgent", cel.StringType),
cel.Variable("path", cel.StringType),
cel.Variable("query", cel.MapType(cel.StringType, cel.StringType)),
cel.Variable("fpJA3N", cel.StringType),
cel.Variable("fpJA4", cel.StringType),
// http.Header
cel.Variable("headers", cel.MapType(cel.StringType, cel.StringType)),
//TODO: dynamic type?
cel.Function("inDNSBL",
cel.Overload("inDNSBL_ip",
[]*cel.Type{cel.AnyType},
cel.BoolType,
cel.UnaryBinding(func(val ref.Val) ref.Val {
if state.Settings.DNSBL == nil {
return types.Bool(false)
}
var ip net.IP
switch v := val.Value().(type) {
case []byte:
ip = v
case net.IP:
ip = v
case string:
ip = net.ParseIP(v)
}
if ip == nil {
panic(fmt.Errorf("invalid ip %v", val.Value()))
}
var key [net.IPv6len]byte
copy(key[:], ip.To16())
result, ok := state.DecayMap.Get(key)
if ok {
return types.Bool(result.Bad())
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
result, err := state.Settings.DNSBL.Lookup(ctx, ip)
if err != nil {
slog.Debug("dnsbl lookup failed", "address", ip.String(), "result", result, "err", err)
} else {
slog.Debug("dnsbl lookup", "address", ip.String(), "result", result)
}
//TODO: configure decay
state.DecayMap.Set(key, result, time.Hour)
return types.Bool(result.Bad())
}),
),
),
cel.Function("inNetwork",
cel.Overload("inNetwork_string_ip",
[]*cel.Type{cel.StringType, cel.AnyType},
cel.BoolType,
cel.BinaryBinding(func(lhs ref.Val, rhs ref.Val) ref.Val {
var ip net.IP
switch v := rhs.Value().(type) {
case []byte:
ip = v
case net.IP:
ip = v
case string:
ip = net.ParseIP(v)
}
if ip == nil {
panic(fmt.Errorf("invalid ip %v", rhs.Value()))
}
val, ok := lhs.Value().(string)
if !ok {
panic(fmt.Errorf("invalid value %v", lhs.Value()))
}
network, ok := state.Networks[val]
if !ok {
_, ipNet, err := net.ParseCIDR(val)
if err != nil {
panic("network not found")
}
return types.Bool(ipNet.Contains(ip))
} else {
ok, err := network.Contains(ip)
if err != nil {
panic(err)
}
return types.Bool(ok)
}
}),
),
),
)
state.programEnv, err = condition.NewRulesEnvironment(state.networks)
if err != nil {
return err
}

View File

@@ -1,28 +1,16 @@
package lib
import (
"bytes"
"codeberg.org/meta/gzipped/v2"
"context"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"git.gammaspectra.live/git/go-away/embed"
"git.gammaspectra.live/git/go-away/lib/action"
"git.gammaspectra.live/git/go-away/lib/challenge"
"git.gammaspectra.live/git/go-away/lib/policy"
"git.gammaspectra.live/git/go-away/utils"
"github.com/google/cel-go/common/types"
"html/template"
"io"
"log/slog"
"maps"
"net"
"net/http"
"net/http/pprof"
"path"
"path/filepath"
"strconv"
"strings"
"time"
@@ -30,20 +18,11 @@ import (
var templates map[string]*template.Template
var cacheBust string
// DefaultValidity TODO: adjust
const DefaultValidity = time.Hour * 24 * 7
func init() {
buf := make([]byte, 16)
_, _ = rand.Read(buf)
cacheBust = base64.RawURLEncoding.EncodeToString(buf)
templates = make(map[string]*template.Template)
dir, err := embed.TemplatesFs.ReadDir("templates")
dir, err := embed.TemplatesFs.ReadDir(".")
if err != nil {
panic(err)
}
@@ -51,7 +30,7 @@ func init() {
if e.IsDir() {
continue
}
data, err := embed.TemplatesFs.ReadFile(filepath.Join("templates", e.Name()))
data, err := embed.TemplatesFs.ReadFile(e.Name())
if err != nil {
panic(err)
}
@@ -72,69 +51,16 @@ func initTemplate(name, data string) error {
return nil
}
func (state *State) challengePage(w http.ResponseWriter, id string, status int, challenge string, params map[string]any) error {
input := make(map[string]any)
input["Id"] = id
input["Random"] = cacheBust
input["Challenge"] = challenge
input["Path"] = state.UrlPath
input["Theme"] = state.Settings.ChallengeTemplateTheme
maps.Copy(input, params)
if _, ok := input["Title"]; !ok {
input["Title"] = "Checking you are not a bot"
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
buf := bytes.NewBuffer(make([]byte, 0, 8192))
err := templates["challenge-"+state.Settings.ChallengeTemplate+".gohtml"].Execute(buf, input)
if err != nil {
_ = state.errorPage(w, id, http.StatusInternalServerError, err, "")
} else {
w.WriteHeader(status)
_, _ = w.Write(buf.Bytes())
}
return nil
}
func (state *State) errorPage(w http.ResponseWriter, id string, status int, err error, redirect string) error {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
buf := bytes.NewBuffer(make([]byte, 0, 8192))
err2 := templates["challenge-"+state.Settings.ChallengeTemplate+".gohtml"].Execute(buf, map[string]any{
"Id": id,
"Random": cacheBust,
"Error": err.Error(),
"Path": state.UrlPath,
"Theme": state.Settings.ChallengeTemplateTheme,
"Title": "Oh no! " + http.StatusText(status),
"HideSpinner": true,
"Challenge": "",
"Redirect": redirect,
})
if err2 != nil {
panic(err2)
} else {
w.WriteHeader(status)
_, _ = w.Write(buf.Bytes())
}
return nil
}
func (state *State) addTiming(w http.ResponseWriter, name, desc string, duration time.Duration) {
if state.Settings.Debug {
if state.Settings().Debug {
w.Header().Add("Server-Timing", fmt.Sprintf("%s;desc=%s;dur=%d", name, strconv.Quote(desc), duration.Milliseconds()))
}
}
func GetLoggerForRequest(r *http.Request) *slog.Logger {
data := RequestDataFromContext(r.Context())
data := challenge.RequestDataFromContext(r.Context())
args := []any{
"request_id", hex.EncodeToString(data.Id[:]),
"request_id", data.Id.String(),
"remote_address", data.RemoteAddress.String(),
"user_agent", r.UserAgent(),
"host", r.Host,
@@ -153,272 +79,95 @@ func GetLoggerForRequest(r *http.Request) *slog.Logger {
return slog.With(args...)
}
func (state *State) logger(r *http.Request) *slog.Logger {
return GetLoggerForRequest(r)
}
func (state *State) handleRequest(w http.ResponseWriter, r *http.Request) {
host := r.Host
data := RequestDataFromContext(r.Context())
data := challenge.RequestDataFromContext(r.Context())
backend, ok := state.Settings.Backends[host]
if !ok {
backend := state.GetBackend(host)
if backend == nil {
http.Error(w, http.StatusText(http.StatusServiceUnavailable), http.StatusServiceUnavailable)
return
}
lg := state.logger(r)
lg := state.Logger(r)
start := time.Now()
state.addTiming(w, "rule-env", "Setup the rule environment", time.Since(start))
var (
ruleEvalDuration time.Duration
)
serve := func() {
state.addTiming(w, "rule-eval", "Evaluate access rules", ruleEvalDuration)
backend.ServeHTTP(w, r)
}
fail := func(code int, err error) {
state.addTiming(w, "rule-eval", "Evaluate access rules", ruleEvalDuration)
_ = state.errorPage(w, r.Header.Get("X-Away-Id"), code, err, "")
}
setAwayState := func(rule RuleState) {
r.Header.Set("X-Away-Rule", rule.Name)
r.Header.Set("X-Away-Hash", rule.Hash)
r.Header.Set("X-Away-Action", string(rule.Action))
data.Headers(state, r.Header)
}
for _, rule := range state.Rules {
// skip rules that have host match
if rule.Host != nil && *rule.Host != host {
continue
cleanupRequest := func(r *http.Request, fromChallenge bool) {
if fromChallenge {
r.Header.Del("Referer")
}
if ref := r.FormValue(challenge.QueryArgReferer); ref != "" {
r.Header.Set("Referer", ref)
}
start = time.Now()
out, _, err := rule.Program.Eval(data.ProgramEnv)
ruleEvalDuration += time.Since(start)
if err != nil {
fail(http.StatusInternalServerError, err)
lg.Error(err.Error(), "rule", rule.Name, "rule_hash", rule.Hash)
panic(err)
return
} else if out != nil && out.Type() == types.BoolType {
if out.Equal(types.True) == types.True {
switch rule.Action {
default:
panic(fmt.Errorf("unknown action %s", rule.Action))
case policy.RuleActionPASS:
lg.Debug("request passed", "rule", rule.Name, "rule_hash", rule.Hash)
setAwayState(rule)
serve()
return
case policy.RuleActionCHALLENGE, policy.RuleActionCHECK:
for _, challengeId := range rule.Challenges {
if result := data.Challenges[challengeId]; !result.Ok() {
continue
} else {
if rule.Action == policy.RuleActionCHECK {
goto nextRule
}
// we passed the challenge!
lg.Debug("request passed", "rule", rule.Name, "rule_hash", rule.Hash, "challenge", state.Challenges[challengeId].Name)
setAwayState(rule)
serve()
return
}
}
// none matched, issue first challenge in priority
for _, challengeId := range rule.Challenges {
result := data.Challenges[challengeId]
if result.Ok() || result == challenge.VerifyResultSKIP {
// skip already ok'd challenges for some reason, and also skip skipped challenges
continue
}
c := state.Challenges[challengeId]
if c.ServeChallenge != nil {
result := c.ServeChallenge(w, r, state.GetChallengeKeyForRequest(c.Name, data.Expires, r), data.Expires)
switch result {
case challenge.ResultStop:
lg.Info("request challenged", "rule", rule.Name, "rule_hash", rule.Hash, "challenge", c.Name)
return
case challenge.ResultContinue:
continue
case challenge.ResultPass:
if rule.Action == policy.RuleActionCHECK {
goto nextRule
}
state.logger(r).Warn("challenge passed", "rule", rule.Name, "rule_hash", rule.Hash, "challenge", c.Name)
// set pass if caller didn't set one
if !data.Challenges[c.Id].Ok() {
data.Challenges[c.Id] = challenge.VerifyResultPASS
}
// we pass the challenge early!
lg.Debug("request passed", "rule", rule.Name, "rule_hash", rule.Hash, "challenge", c.Name)
setAwayState(rule)
serve()
return
}
} else {
panic("challenge not found")
}
}
case policy.RuleActionDENY:
lg.Info("request denied", "rule", rule.Name, "rule_hash", rule.Hash)
//TODO: config error code
fail(http.StatusForbidden, fmt.Errorf("access denied: denied by administrative rule %s/%s", r.Header.Get("X-Away-Id"), rule.Hash))
return
case policy.RuleActionBLOCK:
lg.Info("request blocked", "rule", rule.Name, "rule_hash", rule.Hash)
//TODO: config error code
//TODO: configure block
fail(http.StatusForbidden, fmt.Errorf("access denied: blocked by administrative rule %s/%s", r.Header.Get("X-Away-Id"), rule.Hash))
return
case policy.RuleActionPOISON:
lg.Info("request poisoned", "rule", rule.Name, "rule_hash", rule.Hash)
mime := "text/html"
switch path.Ext(r.URL.Path) {
case ".css":
case ".json", ".js", ".mjs":
}
encodings := strings.Split(r.Header.Get("Accept-Encoding"), ",")
for i, encoding := range encodings {
encodings[i] = strings.TrimSpace(strings.ToLower(encoding))
}
reader, encoding := state.getPoison(mime, encodings)
if reader == nil {
mime = "application/octet-stream"
reader, encoding = state.getPoison(mime, encodings)
}
if reader != nil {
defer reader.Close()
w.Header().Set("Cache-Control", "max-age=0, private, must-revalidate, no-transform")
w.Header().Set("Vary", "Accept-Encoding")
w.Header().Set("Content-Type", mime)
w.Header().Set("X-Content-Type-Options", "nosniff")
if encoding != "" {
w.Header().Set("Content-Encoding", encoding)
}
w.WriteHeader(http.StatusOK)
if flusher, ok := w.(http.Flusher); ok {
// trigger chunked encoding
flusher.Flush()
}
if r != nil {
_, _ = io.Copy(w, reader)
}
} else {
http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
}
return
}
q := r.URL.Query()
// delete query parameters that were set by go-away
for k := range q {
if strings.HasPrefix(k, challenge.QueryArgPrefix) {
q.Del(k)
}
}
r.URL.RawQuery = q.Encode()
nextRule:
data.Headers(r.Header)
// delete cookies set by go-away to prevent user tracking that way
cookies := r.Cookies()
r.Header.Del("Cookie")
for _, c := range cookies {
if !strings.HasPrefix(c.Name, utils.CookiePrefix) {
r.AddCookie(c)
}
}
}
serve()
return
for _, rule := range state.rules {
next, err := rule.Evaluate(lg, w, r, func() http.Handler {
cleanupRequest(r, true)
return backend
})
if err != nil {
state.ErrorPage(w, r, http.StatusInternalServerError, err, "")
panic(err)
return
}
if !next {
return
}
}
// default pass
_, _ = action.Pass{}.Handle(lg, w, r, func() http.Handler {
r.Header.Set("X-Away-Rule", "DEFAULT")
r.Header.Set("X-Away-Action", "PASS")
cleanupRequest(r, false)
return backend
})
}
func (state *State) setupRoutes() error {
state.Mux.HandleFunc("/", state.handleRequest)
if state.Settings.Debug {
http.HandleFunc(state.UrlPath+"/debug/pprof/", pprof.Index)
http.HandleFunc(state.UrlPath+"/debug/pprof/profile", pprof.Profile)
http.HandleFunc(state.UrlPath+"/debug/pprof/symbol", pprof.Symbol)
http.HandleFunc(state.UrlPath+"/debug/pprof/trace", pprof.Trace)
if state.Settings().Debug {
//TODO: split this to a different listener, metrics listener
http.HandleFunc(state.urlPath+"/debug/pprof/", pprof.Index)
http.HandleFunc(state.urlPath+"/debug/pprof/profile", pprof.Profile)
http.HandleFunc(state.urlPath+"/debug/pprof/symbol", pprof.Symbol)
http.HandleFunc(state.urlPath+"/debug/pprof/trace", pprof.Trace)
}
state.Mux.Handle("GET "+state.UrlPath+"/assets/", http.StripPrefix(state.UrlPath, gzipped.FileServer(gzipped.FS(embed.AssetsFs))))
state.Mux.Handle("GET "+state.urlPath+"/assets/", http.StripPrefix(state.UrlPath()+"/assets/", gzipped.FileServer(gzipped.FS(embed.AssetsFs))))
for _, c := range state.Challenges {
if c.ServeStatic != nil {
state.Mux.Handle("GET "+c.Path+"/static/", c.ServeStatic)
}
for _, reg := range state.challenges {
if c.ServeScript != nil {
state.Mux.Handle("GET "+c.ServeScriptPath, c.ServeScript)
}
if c.ServeMakeChallenge != nil {
state.Mux.Handle(fmt.Sprintf("POST %s/make-challenge", c.Path), c.ServeMakeChallenge)
}
if c.ServeVerifyChallenge != nil {
state.Mux.Handle(fmt.Sprintf("GET %s/verify-challenge", c.Path), c.ServeVerifyChallenge)
} else if c.Verify != nil {
state.Mux.HandleFunc(fmt.Sprintf("GET %s/verify-challenge", c.Path), func(w http.ResponseWriter, r *http.Request) {
redirect, err := utils.EnsureNoOpenRedirect(r.FormValue("redirect"))
if redirect == "" {
_ = state.errorPage(w, r.Header.Get("X-Away-Id"), http.StatusInternalServerError, err, "")
return
}
err = func() (err error) {
data := RequestDataFromContext(r.Context())
key := state.GetChallengeKeyForRequest(c.Name, data.Expires, r)
result := r.FormValue("result")
requestId, err := hex.DecodeString(r.FormValue("requestId"))
if err == nil {
// override
r.Header.Set("X-Away-Id", hex.EncodeToString(requestId))
}
start := time.Now()
ok, err := c.Verify(key, result, r)
state.addTiming(w, "challenge-verify", "Verify client challenge", time.Since(start))
if err != nil {
state.logger(r).Error(fmt.Errorf("challenge error: %w", err).Error(), "challenge", c.Name, "redirect", redirect)
return err
} else if !ok {
state.logger(r).Warn("challenge failed", "challenge", c.Name, "redirect", redirect)
utils.ClearCookie(utils.CookiePrefix+c.Name, w)
_ = state.errorPage(w, r.Header.Get("X-Away-Id"), http.StatusForbidden, fmt.Errorf("access denied: failed challenge %s", c.Name), redirect)
return nil
}
state.logger(r).Info("challenge passed", "challenge", c.Name, "redirect", redirect)
token, err := c.IssueChallengeToken(state.privateKey, key, []byte(result), data.Expires)
if err != nil {
utils.ClearCookie(utils.CookiePrefix+c.Name, w)
} else {
utils.SetCookie(utils.CookiePrefix+c.Name, token, data.Expires, w)
}
data.Challenges[c.Id] = challenge.VerifyResultPASS
http.Redirect(w, r, redirect, http.StatusTemporaryRedirect)
return nil
}()
if err != nil {
utils.ClearCookie(utils.CookiePrefix+c.Name, w)
_ = state.errorPage(w, r.Header.Get("X-Away-Id"), http.StatusInternalServerError, err, redirect)
return
}
})
if reg.Handler != nil {
state.Mux.Handle(reg.Path+"/", reg.Handler)
} else if reg.Verify != nil {
// default verify
state.Mux.HandleFunc(reg.Path+challenge.VerifyChallengeUrlSuffix, challenge.VerifyHandlerFunc(state, reg, nil, nil))
}
}
@@ -426,116 +175,13 @@ func (state *State) setupRoutes() error {
}
func (state *State) ServeHTTP(w http.ResponseWriter, r *http.Request) {
r, data := challenge.CreateRequestData(r, state)
var data RequestData
// generate random id, todo: is this fast?
_, _ = rand.Read(data.Id[:])
data.RemoteAddress = getRequestAddress(r, state.Settings.ClientIpHeader)
data.Challenges = make(map[challenge.Id]challenge.VerifyResult, len(state.Challenges))
data.Expires = time.Now().UTC().Add(DefaultValidity).Round(DefaultValidity)
data.EvaluateChallenges(w, r)
var ja3n, ja4 string
if fp := utils.GetTLSFingerprint(r); fp != nil {
if ja3nPtr := fp.JA3N(); ja3nPtr != nil {
ja3n = ja3nPtr.String()
r.Header.Set("X-TLS-Fingerprint-JA3N", ja3n)
}
if ja4Ptr := fp.JA4(); ja4Ptr != nil {
ja4 = ja4Ptr.String()
r.Header.Set("X-TLS-Fingerprint-JA4", ja4)
}
if state.Settings().MainName != "" {
w.Header().Add("Via", fmt.Sprintf("%s %s@%s", r.Proto, state.Settings().MainName, state.Settings().MainVersion))
}
data.ProgramEnv = map[string]any{
"host": r.Host,
"method": r.Method,
"remoteAddress": data.RemoteAddress,
"userAgent": r.UserAgent(),
"path": r.URL.Path,
"fpJA3N": ja3n,
"fpJA4": ja4,
"query": func() map[string]string {
result := make(map[string]string)
for k, v := range r.URL.Query() {
result[k] = strings.Join(v, ",")
}
return result
}(),
"headers": func() map[string]string {
result := make(map[string]string)
for k, v := range r.Header {
result[k] = strings.Join(v, ",")
}
return result
}(),
}
r = r.WithContext(context.WithValue(r.Context(), "_goaway_data", &data))
for _, c := range state.Challenges {
key := state.GetChallengeKeyForRequest(c.Name, data.Expires, r)
result, err := c.VerifyChallengeToken(state.publicKey, key, r)
if err != nil && !errors.Is(err, http.ErrNoCookie) {
// clear invalid cookie
utils.ClearCookie(utils.CookiePrefix+c.Name, w)
}
// prevent the challenge if not solved
if !result.Ok() && c.Program != nil {
out, _, err := c.Program.Eval(data.ProgramEnv)
// verify eligibility
if err != nil {
state.logger(r).Error(err.Error(), "challenge", c.Name)
} else if out != nil && out.Type() == types.BoolType {
if out.Equal(types.True) != types.True {
// skip challenge match!
result = challenge.VerifyResultSKIP
continue
}
}
}
data.Challenges[c.Id] = result
}
r.Header.Set("X-Away-Id", hex.EncodeToString(data.Id[:]))
if state.Settings.BackendIpHeader != "" {
r.Header.Del(state.Settings.ClientIpHeader)
r.Header.Set(state.Settings.BackendIpHeader, data.RemoteAddress.String())
}
w.Header().Add("Via", fmt.Sprintf("%s %s", r.Proto, "go-away"))
// send these to client so we consistently get the headers
//w.Header().Set("Accept-CH", "Sec-CH-UA, Sec-CH-UA-Platform")
//w.Header().Set("Critical-CH", "Sec-CH-UA, Sec-CH-UA-Platform")
state.Mux.ServeHTTP(w, r)
}
func RequestDataFromContext(ctx context.Context) *RequestData {
return ctx.Value("_goaway_data").(*RequestData)
}
type RequestData struct {
Id [16]byte
ProgramEnv map[string]any
Expires time.Time
Challenges map[challenge.Id]challenge.VerifyResult
RemoteAddress net.IP
}
func (d *RequestData) HasValidChallenge(id challenge.Id) bool {
return d.Challenges[id].Ok()
}
func (d *RequestData) Headers(state *State, headers http.Header) {
for id, result := range d.Challenges {
if result.Ok() {
c, ok := state.Challenges[id]
if !ok {
panic("challenge not found")
}
headers.Set(fmt.Sprintf("X-Away-Challenge-%s-Result", c.Name), result.String())
}
}
}

145
lib/interface.go Normal file
View File

@@ -0,0 +1,145 @@
package lib
import (
"bytes"
"crypto/ed25519"
"git.gammaspectra.live/git/go-away/lib/challenge"
"git.gammaspectra.live/git/go-away/lib/policy"
"git.gammaspectra.live/git/go-away/utils"
"github.com/google/cel-go/cel"
"log/slog"
"maps"
"net/http"
)
// Defines challenge.StateInterface
var _ challenge.StateInterface
func (state *State) ProgramEnv() *cel.Env {
return state.programEnv
}
func (state *State) Client() *http.Client {
return state.client
}
func (state *State) PrivateKey() ed25519.PrivateKey {
return state.privateKey
}
func (state *State) PublicKey() ed25519.PublicKey {
return state.publicKey
}
func (state *State) UrlPath() string {
return state.urlPath
}
func (state *State) ChallengeFailed(r *http.Request, reg *challenge.Registration, err error, redirect string, logger *slog.Logger) {
if logger == nil {
logger = state.Logger(r)
}
logger.Warn("challenge failed", "challenge", reg.Name, "err", err, "redirect", redirect)
//TODO: metrics
}
func (state *State) ChallengePassed(r *http.Request, reg *challenge.Registration, redirect string, logger *slog.Logger) {
if logger == nil {
logger = state.Logger(r)
}
logger.Warn("challenge passed", "challenge", reg.Name, "redirect", redirect)
//TODO: metrics
}
func (state *State) ChallengeIssued(r *http.Request, reg *challenge.Registration, redirect string, logger *slog.Logger) {
if logger == nil {
logger = state.Logger(r)
}
logger.Info("challenge issued", "challenge", reg.Name, "redirect", redirect)
//TODO: metrics
}
func (state *State) Logger(r *http.Request) *slog.Logger {
return GetLoggerForRequest(r)
}
func (state *State) ChallengePage(w http.ResponseWriter, r *http.Request, status int, reg *challenge.Registration, params map[string]any) {
data := challenge.RequestDataFromContext(r.Context())
input := make(map[string]any)
input["Id"] = data.Id.String()
input["Random"] = utils.CacheBust()
if reg != nil {
input["Challenge"] = reg.Name
input["Path"] = state.UrlPath()
}
input["Theme"] = state.Settings().ChallengeTemplateTheme
maps.Copy(input, params)
if _, ok := input["Title"]; !ok {
input["Title"] = "Checking you are not a bot"
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
buf := bytes.NewBuffer(make([]byte, 0, 8192))
err := templates["challenge-"+state.Settings().ChallengeTemplate+".gohtml"].Execute(buf, input)
if err != nil {
state.ErrorPage(w, r, http.StatusInternalServerError, err, "")
} else {
w.WriteHeader(status)
_, _ = w.Write(buf.Bytes())
}
}
func (state *State) ErrorPage(w http.ResponseWriter, r *http.Request, status int, err error, redirect string) {
data := challenge.RequestDataFromContext(r.Context())
w.Header().Set("Content-Type", "text/html; charset=utf-8")
buf := bytes.NewBuffer(make([]byte, 0, 8192))
err2 := templates["challenge-"+state.Settings().ChallengeTemplate+".gohtml"].Execute(buf, map[string]any{
"Id": data.Id.String(),
"Random": utils.CacheBust(),
"Error": err.Error(),
"Path": state.UrlPath(),
"Theme": state.Settings().ChallengeTemplateTheme,
"Title": "Oh no! " + http.StatusText(status),
"HideSpinner": true,
"Challenge": "",
"Redirect": redirect,
})
if err2 != nil {
// nested errors!
panic(err2)
} else {
w.WriteHeader(status)
_, _ = w.Write(buf.Bytes())
}
}
func (state *State) GetChallenge(id challenge.Id) (*challenge.Registration, bool) {
reg, ok := state.challenges.Get(id)
return reg, ok
}
func (state *State) GetChallenges() challenge.Register {
return state.challenges
}
func (state *State) GetChallengeByName(name string) (*challenge.Registration, bool) {
reg, _, ok := state.challenges.GetByName(name)
return reg, ok
}
func (state *State) Settings() policy.Settings {
return state.settings
}
func (state *State) GetBackend(host string) http.Handler {
return utils.SelectHTTPHandler(state.Settings().Backends, host)
}

View File

@@ -1,26 +0,0 @@
package lib
import (
"git.gammaspectra.live/git/go-away/embed"
"io"
"path"
"slices"
"strings"
)
var poisonEncodings = []string{"br", "zstd", "gzip"}
func (state *State) getPoison(mime string, encodings []string) (r io.ReadCloser, encoding string) {
for _, encoding = range poisonEncodings {
if !slices.Contains(encodings, encoding) {
continue
}
p := path.Join("poison", strings.ReplaceAll(mime, "/", "_")+"."+encoding+".poison")
f, err := embed.PoisonFs.Open(p)
if err == nil {
return f, encoding
}
}
return nil, ""
}

View File

@@ -1,15 +1,15 @@
package policy
import (
"github.com/goccy/go-yaml/ast"
"time"
)
type Challenge struct {
Conditions []string `yaml:"conditions"`
Mode string `yaml:"mode"`
Asset *string `yaml:"asset,omitempty"`
Url *string `yaml:"url,omitempty"`
Runtime string `yaml:"runtime"`
Parameters map[string]string `json:"parameters,omitempty"`
Runtime struct {
Mode string `yaml:"mode,omitempty"`
Asset string `yaml:"asset,omitempty"`
Probability float64 `yaml:"probability,omitempty"`
} `yaml:"runtime"`
Duration time.Duration `yaml:"duration"`
Parameters ast.Node `yaml:"parameters,omitempty"`
}

View File

@@ -4,6 +4,7 @@ import (
"encoding/json"
"errors"
"fmt"
"git.gammaspectra.live/git/go-away/utils"
"github.com/itchyny/gojq"
"io"
"net"
@@ -13,16 +14,20 @@ import (
)
type Network struct {
// Fetches
Url *string `yaml:"url,omitempty"`
File *string `yaml:"file,omitempty"`
ASN *int `yaml:"asn,omitempty"`
// Filtering
JqPath *string `yaml:"jq-path,omitempty"`
Regex *string `yaml:"regex,omitempty"`
Prefixes []string `yaml:"prefixes,omitempty"`
}
func (n Network) FetchPrefixes(c *http.Client) (output []net.IPNet, err error) {
func (n Network) FetchPrefixes(c *http.Client, whois *utils.RADb) (output []net.IPNet, err error) {
if len(n.Prefixes) > 0 {
for _, prefix := range n.Prefixes {
ipNet, err := parseCIDROrIP(prefix)
@@ -51,6 +56,12 @@ func (n Network) FetchPrefixes(c *http.Client) (output []net.IPNet, err error) {
}
defer file.Close()
reader = file
} else if n.ASN != nil {
result, err := whois.FetchASNets(*n.ASN)
if err != nil {
return nil, fmt.Errorf("failed to fetch ASN %d: %v", *n.ASN, err)
}
return result, nil
} else {
if len(output) > 0 {
return output, nil
@@ -115,3 +126,30 @@ func (n Network) FetchPrefixes(c *http.Client) (output []net.IPNet, err error) {
}
return output, nil
}
func parseCIDROrIP(value string) (net.IPNet, error) {
_, ipNet, err := net.ParseCIDR(value)
if err != nil {
ip := net.ParseIP(value)
if ip == nil {
return net.IPNet{}, fmt.Errorf("failed to parse CIDR: %s", err)
}
if ip4 := ip.To4(); ip4 != nil {
return net.IPNet{
IP: ip4,
// single ip
Mask: net.CIDRMask(len(ip4)*8, len(ip4)*8),
}, nil
}
return net.IPNet{
IP: ip,
// single ip
Mask: net.CIDRMask(len(ip)*8, len(ip)*8),
}, nil
} else if ipNet != nil {
return *ipNet, nil
} else {
return net.IPNet{}, errors.New("invalid CIDR")
}
}

22
lib/policy/options.go Normal file
View File

@@ -0,0 +1,22 @@
package policy
import (
"git.gammaspectra.live/git/go-away/utils"
"net/http"
)
type Settings struct {
Cache utils.Cache
Backends map[string]http.Handler
PrivateKeySeed []byte
Debug bool
MainName string
MainVersion string
PackageName string
ChallengeTemplate string
ChallengeTemplateTheme string
ClientIpHeader string
BackendIpHeader string
ChallengeResponseCode int
}

View File

@@ -1,38 +1,13 @@
package policy
import (
"errors"
"fmt"
"net"
"bytes"
"github.com/goccy/go-yaml"
"io"
"os"
"path"
)
func parseCIDROrIP(value string) (net.IPNet, error) {
_, ipNet, err := net.ParseCIDR(value)
if err != nil {
ip := net.ParseIP(value)
if ip == nil {
return net.IPNet{}, fmt.Errorf("failed to parse CIDR: %s", err)
}
if ip4 := ip.To4(); ip4 != nil {
return net.IPNet{
IP: ip4,
// single ip
Mask: net.CIDRMask(len(ip4)*8, len(ip4)*8),
}, nil
}
return net.IPNet{
IP: ip,
// single ip
Mask: net.CIDRMask(len(ip)*8, len(ip)*8),
}, nil
} else if ipNet != nil {
return *ipNet, nil
} else {
return net.IPNet{}, errors.New("invalid CIDR")
}
}
type Policy struct {
// Networks map of networks and prefixes to be loaded
@@ -43,8 +18,70 @@ type Policy struct {
Challenges map[string]Challenge `yaml:"challenges"`
Rules []Rule `yaml:"rules"`
// Backends
// Deprecated
Backends map[string]string `json:"backends"`
}
func NewPolicy(r io.Reader, snippetsDirectory string) (*Policy, error) {
var p Policy
p.Networks = make(map[string][]Network)
p.Conditions = make(map[string][]string)
p.Challenges = make(map[string]Challenge)
if snippetsDirectory == "" {
err := yaml.NewDecoder(r).Decode(&p)
if err != nil {
return nil, err
}
} else {
err := yaml.NewDecoder(r, yaml.ReferenceDirs(snippetsDirectory)).Decode(&p)
if err != nil {
return nil, err
}
// add specific entries from snippets
entries, err := os.ReadDir(snippetsDirectory)
if err != nil {
return nil, err
}
for _, entry := range entries {
var entryPolicy Policy
if !entry.IsDir() {
entryData, err := os.ReadFile(path.Join(snippetsDirectory, entry.Name()))
if err != nil {
return nil, err
}
err = yaml.NewDecoder(bytes.NewReader(entryData), yaml.ReferenceDirs(snippetsDirectory)).Decode(&entryPolicy)
if err != nil {
return nil, err
}
// add networks / conditions / challenges definitions if they don't exist already
for k, v := range entryPolicy.Networks {
// add network if policy entry does not exist
_, ok := p.Networks[k]
if !ok {
p.Networks[k] = v
}
}
for k, v := range entryPolicy.Conditions {
// add condition if policy entry does not exist
_, ok := p.Conditions[k]
if !ok {
p.Conditions[k] = v
}
}
for k, v := range entryPolicy.Challenges {
// add challenge if policy entry does not exist
_, ok := p.Challenges[k]
if !ok {
p.Challenges[k] = v
}
}
}
}
}
return &p, nil
}

View File

@@ -1,22 +1,40 @@
package policy
import "github.com/goccy/go-yaml/ast"
type RuleAction string
const (
RuleActionPASS RuleAction = "PASS"
RuleActionDENY RuleAction = "DENY"
RuleActionBLOCK RuleAction = "BLOCK"
// RuleActionNONE Does nothing. Useful for parent rules when children want to be specified
RuleActionNONE RuleAction = "NONE"
// RuleActionPASS Passes the connection immediately
RuleActionPASS RuleAction = "PASS"
// RuleActionDENY Denies the connection with a fancy page
RuleActionDENY RuleAction = "DENY"
// RuleActionBLOCK Denies the connection with a response code
RuleActionBLOCK RuleAction = "BLOCK"
// RuleActionCODE Returns a specified HTTP code
RuleActionCODE RuleAction = "CODE"
// RuleActionDROP Drops the connection without sending a reply
RuleActionDROP RuleAction = "DROP"
// RuleActionCHALLENGE Issues a challenge that when passed, passes the connection
RuleActionCHALLENGE RuleAction = "CHALLENGE"
RuleActionCHECK RuleAction = "CHECK"
RuleActionPOISON RuleAction = "POISON"
// RuleActionCHECK Issues a challenge that when passed, continues checking rules
RuleActionCHECK RuleAction = "CHECK"
// RuleActionPROXY Proxies request to a backend, with optional path replacements
RuleActionPROXY RuleAction = "PROXY"
)
type Rule struct {
Name string `yaml:"name"`
Host *string `yaml:"host"`
Conditions []string `yaml:"conditions"`
Action string `yaml:"action"`
Challenges []string `yaml:"challenges"`
Settings ast.Node `yaml:"settings"`
Children []Rule `yaml:"children"`
}

141
lib/rule.go Normal file
View File

@@ -0,0 +1,141 @@
package lib
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"git.gammaspectra.live/git/go-away/lib/action"
"git.gammaspectra.live/git/go-away/lib/challenge"
"git.gammaspectra.live/git/go-away/lib/condition"
"git.gammaspectra.live/git/go-away/lib/policy"
"github.com/google/cel-go/cel"
"github.com/google/cel-go/common/types"
"github.com/google/cel-go/common/types/ref"
"log/slog"
"net/http"
"strings"
)
type RuleState struct {
Name string
Hash string
Condition cel.Program
Action policy.RuleAction
Handler action.Handler
Children []RuleState
}
func NewRuleState(state challenge.StateInterface, r policy.Rule, replacer *strings.Replacer, parent *RuleState) (RuleState, error) {
fp := sha256.Sum256(state.PrivateKey())
hasher := sha256.New()
if parent != nil {
hasher.Write([]byte(parent.Name))
hasher.Write([]byte{0})
r.Name = fmt.Sprintf("%s/%s", parent.Name, r.Name)
}
hasher.Write([]byte(r.Name))
hasher.Write([]byte{0})
hasher.Write(fp[:])
sum := hasher.Sum(nil)
rule := RuleState{
Name: r.Name,
Hash: hex.EncodeToString(sum[:10]),
Action: policy.RuleAction(strings.ToUpper(r.Action)),
}
newHandler, ok := action.Register[rule.Action]
if !ok {
return RuleState{}, fmt.Errorf("unknown action %s", r.Action)
}
actionHandler, err := newHandler(state, rule.Name, rule.Hash, r.Settings)
if err != nil {
return RuleState{}, err
}
rule.Handler = actionHandler
if len(r.Conditions) > 0 {
// allow nesting
var conditions []string
for _, cond := range r.Conditions {
cond = replacer.Replace(cond)
conditions = append(conditions, cond)
}
ast, err := condition.FromStrings(state.ProgramEnv(), condition.OperatorOr, conditions...)
if err != nil {
return RuleState{}, fmt.Errorf("error compiling conditions: %w", err)
}
program, err := condition.Program(state.ProgramEnv(), ast)
if err != nil {
return RuleState{}, fmt.Errorf("error compiling program: %w", err)
}
rule.Condition = program
}
if len(r.Children) > 0 {
for _, child := range r.Children {
childRule, err := NewRuleState(state, child, replacer, &rule)
if err != nil {
return RuleState{}, fmt.Errorf("child %s: %w", child.Name, err)
}
rule.Children = append(rule.Children, childRule)
}
}
return rule, nil
}
func (rule RuleState) Evaluate(logger *slog.Logger, w http.ResponseWriter, r *http.Request, done func() http.Handler) (next bool, err error) {
data := challenge.RequestDataFromContext(r.Context())
var out ref.Val
lg := logger.With("rule", rule.Name, "rule_hash", rule.Hash, "action", string(rule.Action))
if rule.Condition != nil {
out, _, err = rule.Condition.Eval(data)
} else {
// default true
out = types.Bool(true)
}
if err != nil {
lg.Error(err.Error())
return false, fmt.Errorf("error: evaluating administrative rule %s/%s: %w", data.Id.String(), rule.Hash, err)
} else if out != nil && out.Type() == types.BoolType {
if out.Equal(types.True) == types.True {
next, err = rule.Handler.Handle(lg, w, r, func() http.Handler {
r.Header.Set("X-Away-Rule", rule.Name)
r.Header.Set("X-Away-Hash", rule.Hash)
r.Header.Set("X-Away-Action", string(rule.Action))
return done()
})
if err != nil {
lg.Error(err.Error())
return false, fmt.Errorf("error: executing administrative rule %s/%s: %w", data.Id.String(), rule.Hash, err)
}
if !next {
return next, nil
}
for _, child := range rule.Children {
next, err = child.Evaluate(logger, w, r, done)
if err != nil {
lg.Error(err.Error())
return false, fmt.Errorf("error: executing administrative rule %s/%s: %w", data.Id.String(), rule.Hash, err)
}
if !next {
return next, nil
}
}
}
}
return true, nil
}

View File

@@ -1,157 +1,86 @@
package lib
import (
"codeberg.org/meta/gzipped/v2"
"context"
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"git.gammaspectra.live/git/go-away/embed"
"git.gammaspectra.live/git/go-away/lib/challenge"
"git.gammaspectra.live/git/go-away/lib/challenge/wasm"
"git.gammaspectra.live/git/go-away/lib/challenge/wasm/interface"
"git.gammaspectra.live/git/go-away/lib/condition"
"git.gammaspectra.live/git/go-away/lib/policy"
"git.gammaspectra.live/git/go-away/utils"
"git.gammaspectra.live/git/go-away/utils/inline"
"github.com/google/cel-go/cel"
"github.com/tetratelabs/wazero/api"
"github.com/yl2chen/cidranger"
"html/template"
"io"
"io/fs"
"log/slog"
"net"
"net/http"
"net/http/httputil"
"net/url"
"os"
"path"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
)
type State struct {
Client *http.Client
Settings StateSettings
UrlPath string
Mux *http.ServeMux
client *http.Client
radb *utils.RADb
urlPath string
Networks map[string]cidranger.Ranger
Wasm *wasm.Runner
Challenges map[challenge.Id]challenge.Challenge
RulesEnv *cel.Env
Rules []RuleState
programEnv *cel.Env
publicKey ed25519.PublicKey
privateKey ed25519.PrivateKey
Poison map[string][]byte
settings policy.Settings
ChallengeSolve sync.Map
networks map[string]cidranger.Ranger
DecayMap *utils.DecayMap[[net.IPv6len]byte, utils.DNSBLResponse]
challenges challenge.Register
rules []RuleState
close chan struct{}
Mux *http.ServeMux
}
func (state *State) AwaitChallenge(key []byte, ctx context.Context) challenge.VerifyResult {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
var result atomic.Int64
state.ChallengeSolve.Store(string(key), ChallengeCallback(func(receivedResult challenge.VerifyResult) {
result.Store(int64(receivedResult))
cancel()
}))
<-ctx.Done()
return challenge.VerifyResult(result.Load())
}
func (state *State) SolveChallenge(key []byte, result challenge.VerifyResult) {
if f, ok := state.ChallengeSolve.LoadAndDelete(string(key)); ok && f != nil {
if cb, ok := f.(ChallengeCallback); ok {
cb(result)
}
}
}
type ChallengeCallback func(result challenge.VerifyResult)
type RuleState struct {
Name string
Hash string
Host *string
Program cel.Program
Action policy.RuleAction
Challenges []challenge.Id
}
type StateSettings struct {
Backends map[string]http.Handler
PrivateKeySeed []byte
Debug bool
PackageName string
ChallengeTemplate string
ChallengeTemplateTheme string
ClientIpHeader string
BackendIpHeader string
DNSBL *utils.DNSBL
}
func NewState(p policy.Policy, settings StateSettings) (handler http.Handler, err error) {
func NewState(p policy.Policy, settings policy.Settings) (handler http.Handler, err error) {
state := new(State)
state.close = make(chan struct{})
state.Settings = settings
state.Client = &http.Client{
state.settings = settings
state.client = &http.Client{
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
state.UrlPath = "/.well-known/." + state.Settings.PackageName
if state.Settings.DNSBL != nil {
state.DecayMap = utils.NewDecayMap[[net.IPv6len]byte, utils.DNSBLResponse]()
state.radb, err = utils.NewRADb()
if err != nil {
return nil, fmt.Errorf("failed to initialize RADb client: %w", err)
}
state.urlPath = "/.well-known/." + state.Settings().PackageName
// set a reasonable configuration for default http proxy if there is none
for _, backend := range state.Settings.Backends {
for _, backend := range state.Settings().Backends {
if proxy, ok := backend.(*httputil.ReverseProxy); ok {
if proxy.ErrorHandler == nil {
proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
state.logger(r).Error(err.Error())
_ = state.errorPage(w, r.Header.Get("X-Away-Id"), http.StatusBadGateway, err, "")
state.Logger(r).Error(err.Error())
state.ErrorPage(w, r, http.StatusBadGateway, err, "")
}
}
}
}
if len(state.Settings.PrivateKeySeed) > 0 {
if len(state.Settings.PrivateKeySeed) != ed25519.SeedSize {
return nil, fmt.Errorf("invalid private key seed length: %d", len(state.Settings.PrivateKeySeed))
if len(state.Settings().PrivateKeySeed) > 0 {
if len(state.Settings().PrivateKeySeed) != ed25519.SeedSize {
return nil, fmt.Errorf("invalid private key seed length: %d", len(state.Settings().PrivateKeySeed))
}
state.privateKey = ed25519.NewKeyFromSeed(state.Settings.PrivateKeySeed)
state.privateKey = ed25519.NewKeyFromSeed(state.Settings().PrivateKeySeed)
state.publicKey = state.privateKey.Public().(ed25519.PublicKey)
clear(state.Settings.PrivateKeySeed)
clear(state.settings.PrivateKeySeed)
} else {
state.publicKey, state.privateKey, err = ed25519.GenerateKey(rand.Reader)
@@ -160,53 +89,99 @@ func NewState(p policy.Policy, settings StateSettings) (handler http.Handler, er
}
}
privateKeyFingerprint := sha256.Sum256(state.privateKey)
if state.Settings.ChallengeTemplate == "" {
state.Settings.ChallengeTemplate = "anubis"
if state.Settings().ChallengeTemplate == "" {
state.settings.ChallengeTemplate = "anubis"
}
if templates["challenge-"+state.Settings.ChallengeTemplate+".gohtml"] == nil {
if templates["challenge-"+state.Settings().ChallengeTemplate+".gohtml"] == nil {
if data, err := os.ReadFile(state.Settings.ChallengeTemplate); err == nil && len(data) > 0 {
name := path.Base(state.Settings.ChallengeTemplate)
if data, err := os.ReadFile(state.Settings().ChallengeTemplate); err == nil && len(data) > 0 {
name := path.Base(state.Settings().ChallengeTemplate)
err := initTemplate(name, string(data))
if err != nil {
return nil, fmt.Errorf("error loading template %s: %w", settings.ChallengeTemplate, err)
}
state.Settings.ChallengeTemplate = name
state.settings.ChallengeTemplate = name
}
return nil, fmt.Errorf("no template defined for %s", settings.ChallengeTemplate)
}
state.Networks = make(map[string]cidranger.Ranger)
state.networks = make(map[string]cidranger.Ranger)
networkCache := utils.CachePrefix(state.Settings().Cache, "networks/")
for k, network := range p.Networks {
ranger := cidranger.NewPCTrieRanger()
for _, e := range network {
if e.Url != nil {
slog.Debug("loading network url list", "network", k, "url", *e.Url)
}
prefixes, err := e.FetchPrefixes(state.Client)
if err != nil {
slog.Error("error fetching network url list", "network", k, "url", *e.Url)
continue
}
for i, e := range network {
prefixes, err := func() ([]net.IPNet, error) {
var useCache bool
if e.Url != nil {
slog.Debug("loading network url list", "network", k, "url", *e.Url)
useCache = true
} else if e.ASN != nil {
slog.Debug("loading ASN", "network", k, "asn", *e.ASN)
useCache = true
}
cacheKey := fmt.Sprintf("%s-%d", k, i)
var cached []net.IPNet
if useCache && networkCache != nil {
//TODO: add randomness
cachedData, err := networkCache.Get(cacheKey, time.Hour*24)
var l []string
_ = json.Unmarshal(cachedData, &l)
for _, n := range l {
_, ipNet, err := net.ParseCIDR(n)
if err == nil {
cached = append(cached, *ipNet)
}
}
if err == nil {
// use
return cached, nil
}
}
prefixes, err := e.FetchPrefixes(state.client, state.radb)
if err != nil {
if len(cached) > 0 {
// use cached meanwhile
return cached, err
}
return nil, err
}
if useCache && networkCache != nil {
var l []string
for _, n := range prefixes {
l = append(l, n.String())
}
cachedData, err := json.Marshal(l)
if err == nil {
_ = networkCache.Set(cacheKey, cachedData)
}
}
return prefixes, nil
}()
for _, prefix := range prefixes {
err = ranger.Insert(cidranger.NewBasicRangerEntry(prefix))
if err != nil {
return nil, fmt.Errorf("networks %s: error inserting prefix %s: %v", k, prefix.String(), err)
}
}
if err != nil {
slog.Error("error loading network list", "network", k, "url", *e.Url, "error", err)
continue
}
}
slog.Warn("loaded network prefixes", "network", k, "count", ranger.Len())
state.Networks[k] = ranger
state.networks[k] = ranger
}
state.Wasm = wasm.NewRunner(true)
err = state.initConditions()
if err != nil {
return nil, err
@@ -214,7 +189,7 @@ func NewState(p policy.Policy, settings StateSettings) (handler http.Handler, er
var replacements []string
for k, entries := range p.Conditions {
ast, err := condition.FromStrings(state.RulesEnv, condition.OperatorOr, entries...)
ast, err := condition.FromStrings(state.programEnv, condition.OperatorOr, entries...)
if err != nil {
return nil, fmt.Errorf("conditions %s: error compiling conditions: %v", k, err)
}
@@ -229,563 +204,26 @@ func NewState(p policy.Policy, settings StateSettings) (handler http.Handler, er
}
conditionReplacer := strings.NewReplacer(replacements...)
state.Challenges = make(map[challenge.Id]challenge.Challenge)
idCounter := challenge.Id(1)
state.challenges = make(challenge.Register)
//TODO: move this to self-contained challenge files
for challengeName, p := range p.Challenges {
// allow nesting
var conditions []string
for _, cond := range p.Conditions {
cond = conditionReplacer.Replace(cond)
conditions = append(conditions, cond)
for challengeName, pol := range p.Challenges {
_, _, err := state.challenges.Create(state, challengeName, pol, conditionReplacer)
if err != nil {
return nil, fmt.Errorf("challenge %s: %w", challengeName, err)
}
var program cel.Program
if len(conditions) > 0 {
ast, err := condition.FromStrings(state.RulesEnv, condition.OperatorOr, conditions...)
if err != nil {
return nil, fmt.Errorf("challenge %s: error compiling conditions: %v", challengeName, err)
}
program, err = state.RulesEnv.Program(ast)
if err != nil {
return nil, fmt.Errorf("challenge %s: error compiling program: %v", challengeName, err)
}
}
c := challenge.Challenge{
Id: idCounter,
Program: program,
Name: challengeName,
Path: fmt.Sprintf("%s/challenge/%s", state.UrlPath, challengeName),
VerifyProbability: p.Runtime.Probability,
}
idCounter++
if c.VerifyProbability <= 0 {
//10% default
c.VerifyProbability = 0.1
} else if c.VerifyProbability > 1.0 {
c.VerifyProbability = 1.0
}
assetPath := c.Path + "/static/"
subFs, err := fs.Sub(embed.ChallengeFs, fmt.Sprintf("challenge/%s/static", challengeName))
if err == nil {
c.ServeStatic = http.StripPrefix(
assetPath,
gzipped.FileServer(gzipped.FS(subFs)),
)
}
switch p.Mode {
default:
return nil, fmt.Errorf("unknown challenge mode: %s", p.Mode)
case "http":
if p.Url == nil {
return nil, fmt.Errorf("challenge %s: missing url", challengeName)
}
method := p.Parameters["http-method"]
if method == "" {
method = "GET"
}
httpCode, _ := strconv.Atoi(p.Parameters["http-code"])
if httpCode == 0 {
httpCode = http.StatusOK
}
expectedCookie := p.Parameters["http-cookie"]
c.Verify = func(key []byte, result string, r *http.Request) (bool, error) {
var cookieValue string
if expectedCookie != "" {
if cookie, err := r.Cookie(expectedCookie); err != nil || cookie == nil {
// skip check if we don't have cookie or it's expired
return false, nil
} else {
cookieValue = cookie.Value
}
}
// bind hash of cookie contents
sum := sha256.New()
sum.Write([]byte(cookieValue))
sum.Write([]byte{0})
sum.Write(key)
sum.Write([]byte{0})
sum.Write(state.publicKey)
if subtle.ConstantTimeCompare(sum.Sum(nil), []byte(result)) == 1 {
return true, nil
}
return false, nil
}
c.ServeChallenge = func(w http.ResponseWriter, r *http.Request, key []byte, expiry time.Time) challenge.Result {
data := RequestDataFromContext(r.Context())
if result := data.Challenges[c.Id]; result.Ok() {
return challenge.ResultPass
}
var cookieValue string
if expectedCookie != "" {
if cookie, err := r.Cookie(expectedCookie); err != nil || cookie == nil {
// skip check if we don't have cookie or it's expired
return challenge.ResultContinue
} else {
cookieValue = cookie.Value
}
}
request, err := http.NewRequest(method, *p.Url, nil)
if err != nil {
return challenge.ResultContinue
}
request.Header = r.Header
response, err := state.Client.Do(request)
if err != nil {
return challenge.ResultContinue
}
defer response.Body.Close()
defer io.Copy(io.Discard, response.Body)
if response.StatusCode != httpCode {
utils.ClearCookie(utils.CookiePrefix+c.Name, w)
// continue other challenges!
//TODO: negatively cache failure
return challenge.ResultContinue
} else {
// bind hash of cookie contents
sum := sha256.New()
sum.Write([]byte(cookieValue))
sum.Write([]byte{0})
sum.Write(key)
sum.Write([]byte{0})
sum.Write(state.publicKey)
token, err := c.IssueChallengeToken(state.privateKey, key, sum.Sum(nil), expiry)
if err != nil {
utils.ClearCookie(utils.CookiePrefix+c.Name, w)
} else {
utils.SetCookie(utils.CookiePrefix+challengeName, token, expiry, w)
}
data.Challenges[c.Id] = challenge.VerifyResultPASS
// we passed it!
return challenge.ResultPass
}
}
case "cookie":
c.ServeChallenge = func(w http.ResponseWriter, r *http.Request, key []byte, expiry time.Time) challenge.Result {
if chall := r.URL.Query().Get("__goaway_challenge"); chall == challengeName {
state.logger(r).Warn("challenge failed", "challenge", c.Name)
utils.ClearCookie(utils.CookiePrefix+c.Name, w)
_ = state.errorPage(w, r.Header.Get("X-Away-Id"), http.StatusForbidden, fmt.Errorf("access denied: failed challenge %s", c.Name), "")
return challenge.ResultStop
}
token, err := c.IssueChallengeToken(state.privateKey, key, nil, expiry)
if err != nil {
utils.ClearCookie(utils.CookiePrefix+challengeName, w)
} else {
utils.SetCookie(utils.CookiePrefix+challengeName, token, expiry, w)
}
// self redirect!
uri, err := url.ParseRequestURI(r.URL.String())
values := uri.Query()
values.Set("__goaway_challenge", challengeName)
uri.RawQuery = values.Encode()
http.Redirect(w, r, uri.String(), http.StatusTemporaryRedirect)
return challenge.ResultStop
}
case "meta-refresh":
c.ServeChallenge = func(w http.ResponseWriter, r *http.Request, key []byte, expiry time.Time) challenge.Result {
redirectUri := new(url.URL)
redirectUri.Path = c.Path + "/verify-challenge"
values := make(url.Values)
values.Set("result", hex.EncodeToString(key))
values.Set("redirect", r.URL.String())
values.Set("requestId", r.Header.Get("X-Away-Id"))
redirectUri.RawQuery = values.Encode()
_ = state.challengePage(w, r.Header.Get("X-Away-Id"), http.StatusTeapot, "", map[string]any{
"Meta": map[string]string{
"refresh": "0; url=" + redirectUri.String(),
},
})
return challenge.ResultStop
}
case "header-refresh":
c.ServeChallenge = func(w http.ResponseWriter, r *http.Request, key []byte, expiry time.Time) challenge.Result {
redirectUri := new(url.URL)
redirectUri.Path = c.Path + "/verify-challenge"
values := make(url.Values)
values.Set("result", hex.EncodeToString(key))
values.Set("redirect", r.URL.String())
values.Set("requestId", r.Header.Get("X-Away-Id"))
redirectUri.RawQuery = values.Encode()
// self redirect!
w.Header().Set("Refresh", "0; url="+redirectUri.String())
_ = state.challengePage(w, r.Header.Get("X-Away-Id"), http.StatusTeapot, "", nil)
return challenge.ResultStop
}
case "preload-link":
deadline, _ := time.ParseDuration(p.Parameters["preload-early-hint-deadline"])
if deadline == 0 {
deadline = time.Second * 3
}
c.ServeChallenge = func(w http.ResponseWriter, r *http.Request, key []byte, expiry time.Time) challenge.Result {
// this only works on HTTP/2 and HTTP/3
if r.ProtoMajor < 2 {
// this can happen if we are an upgraded request from HTTP/1.1 to HTTP/2 in H2C
if _, ok := w.(http.Pusher); !ok {
return challenge.ResultContinue
}
}
data := RequestDataFromContext(r.Context())
redirectUri := new(url.URL)
redirectUri.Scheme = getRequestScheme(r)
redirectUri.Host = r.Host
redirectUri.Path = c.Path + "/verify-challenge"
values := make(url.Values)
values.Set("result", hex.EncodeToString(key))
values.Set("requestId", r.Header.Get("X-Away-Id"))
redirectUri.RawQuery = values.Encode()
w.Header().Set("Link", fmt.Sprintf("<%s>; rel=\"preload\"; as=\"style\"; fetchpriority=high", redirectUri.String()))
defer func() {
// remove old header so it won't show on response!
w.Header().Del("Link")
}()
w.WriteHeader(http.StatusEarlyHints)
ctx, cancel := context.WithTimeout(r.Context(), deadline)
defer cancel()
if result := state.AwaitChallenge(key, ctx); result.Ok() {
data.Challenges[c.Id] = challenge.VerifyResultPASS
// this should serve!
return challenge.ResultPass
}
data.Challenges[c.Id] = challenge.VerifyResultFAIL
// we failed, continue
return challenge.ResultContinue
}
case "resource-load":
c.ServeChallenge = func(w http.ResponseWriter, r *http.Request, key []byte, expiry time.Time) challenge.Result {
redirectUri := new(url.URL)
redirectUri.Path = c.Path + "/verify-challenge"
values := make(url.Values)
values.Set("result", hex.EncodeToString(key))
values.Set("requestId", r.Header.Get("X-Away-Id"))
redirectUri.RawQuery = values.Encode()
// self redirect!
w.Header().Set("Refresh", "2; url="+r.URL.String())
_ = state.challengePage(w, r.Header.Get("X-Away-Id"), http.StatusTeapot, "", map[string]any{
"Tags": []template.HTML{
template.HTML(fmt.Sprintf("<link href=\"%s\" rel=\"stylesheet\" crossorigin=\"use-credentials\">", redirectUri.String())),
},
})
return challenge.ResultStop
}
case "js":
c.ServeChallenge = func(w http.ResponseWriter, r *http.Request, key []byte, expiry time.Time) challenge.Result {
_ = state.challengePage(w, r.Header.Get("X-Away-Id"), http.StatusTeapot, challengeName, nil)
return challenge.ResultStop
}
c.ServeScriptPath = c.Path + "/challenge.mjs"
c.ServeScript = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
params, _ := json.Marshal(p.Parameters)
//TODO: move this to http.go as a template
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
w.Header().Set("Content-Type", "text/javascript; charset=utf-8")
w.WriteHeader(http.StatusOK)
err := templates["challenge.mjs"].Execute(w, map[string]any{
"Path": c.Path,
"Parameters": string(params),
"Random": cacheBust,
"Challenge": challengeName,
"ChallengeScript": func() string {
if p.Asset != nil {
return assetPath + *p.Asset
} else if p.Url != nil {
return *p.Url
} else {
panic("not implemented")
}
}(),
})
if err != nil {
//TODO: log
}
})
}
// how to runtime
switch p.Runtime.Mode {
default:
return nil, fmt.Errorf("unknown challenge runtime mode: %s", p.Runtime.Mode)
case "":
case "http":
case "key":
mimeType := p.Parameters["key-mime"]
if mimeType == "" {
mimeType = "text/html; charset=utf-8"
}
httpCode, _ := strconv.Atoi(p.Parameters["key-code"])
if httpCode == 0 {
httpCode = http.StatusTemporaryRedirect
}
var content []byte
if data, ok := p.Parameters["key-content"]; ok {
content = []byte(data)
}
c.Verify = func(key []byte, result string, r *http.Request) (bool, error) {
resultBytes, err := hex.DecodeString(result)
if err != nil {
return false, err
}
if subtle.ConstantTimeCompare(resultBytes, key) != 1 {
return false, nil
}
return true, nil
}
c.ServeVerifyChallenge = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
redirect, err := utils.EnsureNoOpenRedirect(r.FormValue("redirect"))
if err != nil {
_ = state.errorPage(w, r.Header.Get("X-Away-Id"), http.StatusBadRequest, err, "")
return
}
err = func() (err error) {
data := RequestDataFromContext(r.Context())
key := state.GetChallengeKeyForRequest(challengeName, data.Expires, r)
result := r.FormValue("result")
requestId, err := hex.DecodeString(r.FormValue("requestId"))
if err == nil {
r.Header.Set("X-Away-Id", hex.EncodeToString(requestId))
}
if ok, err := c.Verify(key, result, r); err != nil {
return err
} else if !ok {
utils.ClearCookie(utils.CookiePrefix+challengeName, w)
data.Challenges[c.Id] = challenge.VerifyResultFAIL
state.SolveChallenge(key, challenge.VerifyResultFAIL)
state.logger(r).Warn("challenge failed", "challenge", challengeName, "redirect", redirect)
// catch happy eyeballs IPv4 -> IPv6 migration, re-direct to try again
if resultKey, err := ChallengeKeyFromString(result); err == nil && resultKey.Get(ChallengeKeyFlagIsIPv4) > 0 && key.Get(ChallengeKeyFlagIsIPv4) == 0 {
} else {
_ = state.errorPage(w, r.Header.Get("X-Away-Id"), http.StatusForbidden, fmt.Errorf("access denied: failed challenge %s", challengeName), redirect)
return nil
}
} else {
state.logger(r).Warn("challenge passed", "challenge", challengeName, "redirect", redirect)
token, err := c.IssueChallengeToken(state.privateKey, key, []byte(result), data.Expires)
if err != nil {
utils.ClearCookie(utils.CookiePrefix+challengeName, w)
} else {
utils.SetCookie(utils.CookiePrefix+challengeName, token, data.Expires, w)
}
data.Challenges[c.Id] = challenge.VerifyResultPASS
state.SolveChallenge(key, challenge.VerifyResultPASS)
}
switch httpCode {
case http.StatusMovedPermanently, http.StatusFound, http.StatusSeeOther, http.StatusTemporaryRedirect, http.StatusPermanentRedirect:
if redirect == "" {
_ = state.errorPage(w, r.Header.Get("X-Away-Id"), http.StatusBadRequest, errors.New("no redirect found"), "")
return nil
}
http.Redirect(w, r, redirect, httpCode)
default:
w.Header().Set("Content-Type", mimeType)
w.WriteHeader(httpCode)
if content != nil {
_, _ = w.Write(content)
}
}
return nil
}()
if err != nil {
utils.ClearCookie(utils.CookiePrefix+challengeName, w)
_ = state.errorPage(w, r.Header.Get("X-Away-Id"), http.StatusInternalServerError, err, redirect)
return
}
})
case "wasm":
wasmData, err := embed.ChallengeFs.ReadFile(fmt.Sprintf("challenge/%s/runtime/%s", challengeName, p.Runtime.Asset))
if err != nil {
return nil, fmt.Errorf("c %s: could not load runtime: %w", challengeName, err)
}
err = state.Wasm.Compile(challengeName, wasmData)
if err != nil {
return nil, fmt.Errorf("c %s: compiling runtime: %w", challengeName, err)
}
c.ServeMakeChallenge = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
err := state.Wasm.Instantiate(challengeName, func(ctx context.Context, mod api.Module) (err error) {
data := RequestDataFromContext(r.Context())
in := _interface.MakeChallengeInput{
Key: state.GetChallengeKeyForRequest(challengeName, data.Expires, r),
Parameters: p.Parameters,
Headers: inline.MIMEHeader(r.Header),
}
in.Data, err = io.ReadAll(r.Body)
if err != nil {
return err
}
out, err := wasm.MakeChallengeCall(ctx, mod, in)
if err != nil {
return err
}
// set output headers
for k, v := range out.Headers {
w.Header()[k] = v
}
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(out.Data)))
w.WriteHeader(out.Code)
_, _ = w.Write(out.Data)
return nil
})
if err != nil {
_ = state.errorPage(w, r.Header.Get("X-Away-Id"), http.StatusInternalServerError, err, "")
return
}
})
c.Verify = func(key []byte, result string, r *http.Request) (ok bool, err error) {
err = state.Wasm.Instantiate(challengeName, func(ctx context.Context, mod api.Module) (err error) {
in := _interface.VerifyChallengeInput{
Key: key,
Parameters: p.Parameters,
Result: []byte(result),
}
out, err := wasm.VerifyChallengeCall(ctx, mod, in)
if err != nil {
return err
}
if out == _interface.VerifyChallengeOutputError {
return errors.New("error checking challenge")
}
ok = out == _interface.VerifyChallengeOutputOK
return nil
})
if err != nil {
return false, err
}
return ok, nil
}
}
state.Challenges[c.Id] = c
}
for _, rule := range p.Rules {
hasher := sha256.New()
hasher.Write([]byte(rule.Name))
hasher.Write([]byte{0})
if rule.Host != nil {
hasher.Write([]byte(*rule.Host))
}
hasher.Write([]byte{0})
hasher.Write(privateKeyFingerprint[:])
sum := hasher.Sum(nil)
for _, r := range p.Rules {
challenges := make([]challenge.Id, 0, len(rule.Challenges))
for _, challengeName := range rule.Challenges {
c, ok := state.GetChallengeByName(challengeName)
if !ok {
return nil, fmt.Errorf("challenge %s not found", challengeName)
}
challenges = append(challenges, c.Id)
}
r := RuleState{
Name: rule.Name,
Hash: hex.EncodeToString(sum[:8]),
Host: rule.Host,
Action: policy.RuleAction(strings.ToUpper(rule.Action)),
Challenges: challenges,
}
if (r.Action == policy.RuleActionCHALLENGE || r.Action == policy.RuleActionCHECK) && len(r.Challenges) == 0 {
return nil, fmt.Errorf("no challenges found in rule %s", rule.Name)
}
// allow nesting
var conditions []string
for _, cond := range rule.Conditions {
cond = conditionReplacer.Replace(cond)
conditions = append(conditions, cond)
}
ast, err := condition.FromStrings(state.RulesEnv, condition.OperatorOr, conditions...)
rule, err := NewRuleState(state, r, conditionReplacer, nil)
if err != nil {
return nil, fmt.Errorf("rules %s: error compiling conditions: %v", rule.Name, err)
return nil, fmt.Errorf("rule %s: %w", r.Name, err)
}
program, err := state.RulesEnv.Program(ast)
if err != nil {
return nil, fmt.Errorf("rules %s: error compiling program: %v", rule.Name, err)
}
r.Program = program
slog.Warn("loaded rule", "rule", r.Name, "hash", r.Hash, "action", rule.Action)
slog.Warn("loaded rule", "rule", rule.Name, "hash", rule.Hash, "action", rule.Action, "children", len(rule.Children))
state.Rules = append(state.Rules, r)
state.rules = append(state.rules, rule)
}
state.Mux = http.NewServeMux()
@@ -794,28 +232,5 @@ func NewState(p policy.Policy, settings StateSettings) (handler http.Handler, er
return nil, err
}
if state.DecayMap != nil {
go func() {
ticker := time.NewTicker(17 * time.Minute)
for {
select {
case <-ticker.C:
state.DecayMap.Decay()
case <-state.close:
return
}
}
}()
}
return state, nil
}
func (state *State) GetChallengeByName(name string) (challenge.Challenge, bool) {
for _, c := range state.Challenges {
if c.Name == name {
return c, true
}
}
return challenge.Challenge{}, false
}

86
utils/cache.go Normal file
View File

@@ -0,0 +1,86 @@
package utils
import (
"errors"
"os"
"path"
"time"
)
type Cache interface {
Get(key string, maxAge time.Duration) ([]byte, error)
Set(key string, value []byte) error
}
func CachePrefix(c Cache, prefix string) Cache {
if c == nil {
return nil
}
return prefixCache{
c: c,
prefix: prefix,
}
}
func CacheDirectory(directory string) (Cache, error) {
if stat, err := os.Stat(directory); err != nil {
return nil, err
} else if !stat.IsDir() {
return nil, errors.New("not a directory")
}
return dirCache(directory), nil
}
type prefixCache struct {
c Cache
prefix string
}
func (c prefixCache) Get(key string, maxAge time.Duration) ([]byte, error) {
return c.c.Get(c.prefix+key, maxAge)
}
func (c prefixCache) Set(key string, value []byte) error {
return c.c.Set(c.prefix+key, value)
}
type dirCache string
var ErrExpired = errors.New("key expired")
func (d dirCache) Get(key string, maxAge time.Duration) ([]byte, error) {
fname := path.Join(string(d), key)
stat, err := os.Stat(fname)
if err != nil {
return nil, err
}
if stat.IsDir() {
return nil, errors.New("key is directory")
}
data, err := os.ReadFile(fname)
if err != nil {
return nil, err
}
if stat.ModTime().Before(time.Now().Add(-maxAge)) {
return data, ErrExpired
} else {
return data, nil
}
}
func (d dirCache) Set(key string, value []byte) error {
fname := path.Join(string(d), key)
fs, err := os.Create(fname)
if err != nil {
return err
}
defer fs.Close()
_, err = fs.Write(value)
fs.Sync()
fs.Close()
_ = os.Chtimes(fname, time.Time{}, time.Now())
return err
}

View File

@@ -1,27 +1,41 @@
package utils
import (
"net"
"net/http"
"time"
)
var CookiePrefix = ".go-away-"
func SetCookie(name, value string, expiry time.Time, w http.ResponseWriter) {
// getValidHost Gets a valid host for an http.Cookie Domain field
// TODO: bug: does not work with IPv6, see https://github.com/golang/go/issues/65521
func getValidHost(host string) string {
ipStr, _, err := net.SplitHostPort(host)
if err != nil {
return host
}
return ipStr
}
func SetCookie(name, value string, expiry time.Time, w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{
Name: name,
Value: value,
Expires: expiry,
SameSite: http.SameSiteLaxMode,
Path: "/",
Domain: getValidHost(r.Host),
})
}
func ClearCookie(name string, w http.ResponseWriter) {
func ClearCookie(name string, w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{
Name: name,
Value: "",
Expires: time.Now().Add(-1 * time.Hour),
MaxAge: -1,
SameSite: http.SameSiteLaxMode,
Domain: getValidHost(r.Host),
})
}

View File

@@ -16,31 +16,27 @@ import (
)
func applyTLSFingerprinter(server *http.Server) {
if server.TLSConfig == nil {
return
}
server.TLSConfig = server.TLSConfig.Clone()
getCertificate := server.TLSConfig.GetCertificate
if getCertificate == nil {
server.TLSConfig.GetCertificate = func(clientHello *tls.ClientHelloInfo) (*tls.Certificate, error) {
ja3n, ja4 := buildTLSFingerprint(clientHello)
ptr := clientHello.Context().Value(tlsFingerprintKey{})
if fpPtr, ok := ptr.(*TLSFingerprint); ok && ptr != nil && fpPtr != nil {
fpPtr.ja3n.Store(&ja3n)
fpPtr.ja4.Store(&ja4)
}
getConfigForClient := server.TLSConfig.GetConfigForClient
if getConfigForClient == nil {
getConfigForClient = func(info *tls.ClientHelloInfo) (*tls.Config, error) {
return nil, nil
}
} else {
server.TLSConfig.GetCertificate = func(clientHello *tls.ClientHelloInfo) (*tls.Certificate, error) {
ja3n, ja4 := buildTLSFingerprint(clientHello)
ptr := clientHello.Context().Value(tlsFingerprintKey{})
if fpPtr, ok := ptr.(*TLSFingerprint); ok && ptr != nil && fpPtr != nil {
fpPtr.ja3n.Store(&ja3n)
fpPtr.ja4.Store(&ja4)
}
}
return getCertificate(clientHello)
server.TLSConfig.GetConfigForClient = func(clientHello *tls.ClientHelloInfo) (*tls.Config, error) {
ja3n, ja4 := buildTLSFingerprint(clientHello)
ptr := clientHello.Context().Value(tlsFingerprintKey{})
if fpPtr, ok := ptr.(*TLSFingerprint); ok && ptr != nil && fpPtr != nil {
fpPtr.ja3n.Store(&ja3n)
fpPtr.ja4.Store(&ja4)
}
return getConfigForClient(clientHello)
}
server.ConnContext = func(ctx context.Context, c net.Conn) context.Context {
return context.WithValue(ctx, tlsFingerprintKey{}, &TLSFingerprint{})

View File

@@ -2,7 +2,9 @@ package utils
import (
"context"
"crypto/rand"
"crypto/tls"
"encoding/base64"
"errors"
"fmt"
"net"
@@ -13,7 +15,6 @@ import (
)
func NewServer(handler http.Handler, tlsConfig *tls.Config) *http.Server {
if tlsConfig == nil {
proto := new(http.Protocols)
proto.SetHTTP1(true)
@@ -34,6 +35,21 @@ func NewServer(handler http.Handler, tlsConfig *tls.Config) *http.Server {
}
}
func SelectHTTPHandler(backends map[string]http.Handler, host string) http.Handler {
backend, ok := backends[host]
if !ok {
// do wildcard match
wildcard := "*." + strings.Join(strings.Split(host, ".")[1:], ".")
backend, ok = backends[wildcard]
if !ok {
// return fallback
backend = backends["*"]
}
}
return backend
}
func EnsureNoOpenRedirect(redirect string) (string, error) {
uri, err := url.Parse(redirect)
if err != nil {
@@ -79,3 +95,46 @@ func MakeReverseProxy(target string) (*httputil.ReverseProxy, error) {
return rp, nil
}
func GetRequestScheme(r *http.Request) string {
if proto := r.Header.Get("X-Forwarded-Proto"); proto == "http" || proto == "https" {
return proto
}
if r.TLS != nil {
return "https"
}
return "http"
}
func GetRequestAddress(r *http.Request, clientHeader string) net.IP {
var ipStr string
if clientHeader != "" {
ipStr = r.Header.Get(clientHeader)
}
if ipStr != "" {
// handle X-Forwarded-For
ipStr = strings.Split(ipStr, ",")[0]
}
// fallback
if ipStr == "" {
ipStr, _, _ = net.SplitHostPort(r.RemoteAddr)
}
ipStr = strings.Trim(ipStr, "[]")
return net.ParseIP(ipStr)
}
func CacheBust() string {
return cacheBust
}
var cacheBust string
func init() {
buf := make([]byte, 16)
_, _ = rand.Read(buf)
cacheBust = base64.RawURLEncoding.EncodeToString(buf)
}

167
utils/radb.go Normal file
View File

@@ -0,0 +1,167 @@
package utils
import (
"bufio"
"bytes"
"fmt"
"net"
"regexp"
"strings"
"time"
)
type RADb struct {
target string
dialer net.Dialer
}
const RADBServer = "whois.radb.net:43"
func NewRADb() (*RADb, error) {
host, port, err := net.SplitHostPort(RADBServer)
if err != nil {
return nil, err
}
return &RADb{
target: fmt.Sprintf("%s:%s", host, port),
dialer: net.Dialer{
Timeout: 5 * time.Second,
},
}, nil
}
var whoisRouteRegex = regexp.MustCompile("(?P<prefix>(([0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+)|([0-9a-f:]+::))/[0-9]+)")
func (db *RADb) query(fn func(n int, record []byte) error, queries ...string) error {
conn, err := db.dialer.Dial("tcp", db.target)
if err != nil {
return err
}
defer conn.Close()
if len(queries) > 1 {
// enable persistent conn
_ = conn.SetDeadline(time.Now().Add(time.Second * 5))
_, err = conn.Write([]byte("!!\n"))
if err != nil {
return err
}
}
scanner := bufio.NewScanner(conn)
scanner.Split(bufio.ScanLines)
for _, q := range queries {
_ = conn.SetDeadline(time.Now().Add(time.Second * 5))
_, err = conn.Write([]byte(strings.TrimSpace(q) + "\n"))
if err != nil {
return err
}
n := 0
for scanner.Scan() {
buf := bytes.Trim(scanner.Bytes(), "\r\n")
if bytes.HasPrefix(buf, []byte("%")) || bytes.Equal(buf, []byte("C")) {
// end of record
break
}
err = fn(n, buf)
if err != nil {
return err
}
n++
}
}
if len(queries) > 1 {
// exit
_ = conn.SetDeadline(time.Now().Add(time.Second * 5))
_, err = conn.Write([]byte("q\n"))
if err != nil {
return err
}
}
return nil
}
func init() {
db, _ := NewRADb()
db.FetchIPInfo(net.ParseIP("162.158.62.1"))
}
func (db *RADb) FetchIPInfo(ip net.IP) (result []string, err error) {
var ipNet net.IPNet
if ip4 := ip.To4(); ip4 != nil {
ipNet = net.IPNet{
IP: ip4,
// single ip
Mask: net.CIDRMask(len(ip4)*8, len(ip4)*8),
}
} else {
ipNet = net.IPNet{
IP: ip,
// single ip
Mask: net.CIDRMask(len(ip)*8, len(ip)*8),
}
}
err = db.query(func(n int, record []byte) error {
result = append(result, string(record))
return nil
}, fmt.Sprintf("!r%s,l", ipNet.String()))
if err != nil {
return nil, err
}
return result, nil
}
func (db *RADb) FetchASNets(asn int) (result []net.IPNet, err error) {
ix := whoisRouteRegex.SubexpIndex("prefix")
if ix == -1 {
panic("invalid regex prefix")
}
var data []byte
err = db.query(func(n int, record []byte) error {
if n == 0 {
// do not append ASN number reply
return nil
}
// pad data
if n == 1 {
data = append(data, ' ')
}
data = append(data, record...)
return nil
},
// See https://www.radb.net/query/help
// fetch IPv4 routes
fmt.Sprintf("!gas%d", asn),
// fetch IPv6 routes
fmt.Sprintf("!6as%d", asn),
)
if err != nil {
return nil, err
}
matches := whoisRouteRegex.FindAllSubmatch(data, -1)
for _, match := range matches {
_, ipNet, err := net.ParseCIDR(string(match[ix]))
if err != nil {
return nil, fmt.Errorf("invalid CIDR %s: %w", string(match[ix]), err)
}
result = append(result, *ipNet)
}
return result, nil
}