109 Commits

Author SHA1 Message Date
WeebDataHoarder
6e53bc224a bind/tls: allow configuring min/max TLS version, ciphers, and curves 2025-06-18 16:08:35 +02:00
WeebDataHoarder
c1cb81e758 bind/tls: allow specifying multiple tls entries 2025-06-18 15:44:12 +02:00
WeebDataHoarder
9a6f25df59 http/query: preserve raw query state when modifying url query 2025-06-09 13:49:37 +02:00
Alan Orth
c16f0863ae examples/generic.yml: use path.matches in condition
The string here uses a character set with path.contains, which will
not work in CEL. We need to use path.matches to use regex syntax.
2025-05-17 23:50:36 +03:00
Alan Orth
85a8f0d9ec examples: remove erroneous whitespace 2025-05-17 23:45:39 +03:00
WeebDataHoarder
a5e2e6625b cmd: move http/backend error logs to debug level 2025-05-17 18:55:48 +02:00
WeebDataHoarder
d24e4b521a examples/snippets: add CGNAT range to networks-private 2025-05-14 21:12:48 +02:00
WeebDataHoarder
3ac6b9d366 cmd/go-away: log private key fingerprint on load 2025-05-14 01:30:48 +02:00
WeebDataHoarder
484a5e3535 challenge/context: clear cookies by issuing a new cookie instead of clearing it 2025-05-14 01:30:31 +02:00
WeebDataHoarder
6032ac0b78 http: add cache-control headers to prevent caching by other proxies elsewhere 2025-05-13 23:48:21 +02:00
WeebDataHoarder
163fce6cfc challenge/resource-load: use proper redirect URL to current issued challenge, add static/dynamic cache bust 2025-05-13 23:43:31 +02:00
WeebDataHoarder
3abdc2ee5b examples: add private / localhost networks to snippets and forgejo/generic examples 2025-05-13 03:06:23 +02:00
WeebDataHoarder
3b045e9608 state/template: fix not allowing external templates to be defined 2025-05-08 12:14:01 +02:00
WeebDataHoarder
1d2f4e8a5b challenge/context: use additional HTTP headers in challenge key generation if the challenge allows for it 2025-05-04 20:22:34 +02:00
Alan Orth
c6a1d50f39 examples/config.yml: fix YAML syntax 2025-05-04 12:25:44 +03:00
WeebDataHoarder
b1f1e9a54f challenge/http: fix setting request headers properly, add method header 2025-05-04 04:03:07 +02:00
WeebDataHoarder
e0c0f8745d readme: add latest release badge 2025-05-04 04:02:38 +02:00
WeebDataHoarder
fb6c5c3eb4 examples/forgejo: remove standard-bots rule, it's redundant 2025-05-03 22:43:09 +02:00
WeebDataHoarder
aebbfa4eaa context: set client network address without original port on backend-ip-header option 2025-05-03 22:32:25 +02:00
WeebDataHoarder
816d0fef90 ci: trigger on tags 2025-05-03 22:14:15 +02:00
WeebDataHoarder
06aca367a1 ci: change push trigger 2025-05-03 22:12:13 +02:00
WeebDataHoarder
44c9114ae5 challenges: add refresh via JavaScript window.location 2025-05-03 21:35:12 +02:00
WeebDataHoarder
4b1878f1ac examples/forgejo: exclude fetchers from suspicious crawler 2025-05-03 21:21:13 +02:00
WeebDataHoarder
925a1d59a2 challenges: return ErrNoCookie when no cookies of given name are present 2025-05-03 17:41:50 +02:00
WeebDataHoarder
76417b4308 challenges: parse all existing cookies with given name and extract valid one always 2025-05-03 17:37:52 +02:00
WeebDataHoarder
0e62f80f9b challenges: prevent unbounded growth of stored cookies by bundling all state onto a single JWT token 2025-05-03 17:30:39 +02:00
WeebDataHoarder
2cb5972371 challenges/context: allow setting request headers towards the backend 2025-05-03 15:55:13 +02:00
WeebDataHoarder
3d73ee76c4 state: add more meta tags onto cached tags, add missing txt and xml resources to well-known snippet 2025-05-03 05:59:32 +02:00
WeebDataHoarder
5bc1ab428b docker: add GOAWAY_CHALLENGE_TEMPLATE_LOGO parameter to Dockerfile 2025-05-03 04:17:02 +02:00
WeebDataHoarder
606f8ec3a0 templates: explicitly allow overriding logo via cmdline/override in config, have bundled templates support it 2025-05-03 04:14:11 +02:00
WeebDataHoarder
1ea19c5a6c state context: Added proxy-safe-link-tags to proxy <link> tags, use specific LinkTags ranger on templates instead of raw elements 2025-05-03 04:12:58 +02:00
WeebDataHoarder
736c2708e9 examples/forgejo: exclude fetchers from TLS Fingerprint rule 2025-05-02 22:21:40 +02:00
WeebDataHoarder
74cc614564 readme: cleanup, redirect to wiki as necessary 2025-05-02 20:55:44 +02:00
WeebDataHoarder
e8e072286e challenge: lower preload-early-hint-deadline to 2 seconds by default 2025-05-02 20:42:25 +02:00
WeebDataHoarder
0d28d1680c readme: add ngx_http_js_challenge_module and haproxy-protection 2025-05-02 13:39:25 +02:00
pwgen2155
2ab45983e9 feat: all betterstack ip ranges and useragent (#16)
ref: https://betterstack.com/docs/uptime/frequently-asked-questions/#what-ips-does-uptime-use

I believe this is how you do it. Will test later on. Unfortunately their playwrite contains a generic user agent...

Co-authored-by: WeebDataHoarder <weebdatahoarder@noreply.gammaspectra.live>
Reviewed-on: https://git.gammaspectra.live/git/go-away/pulls/16
Co-authored-by: pwgen2155 <pwgen2155@noreply.gammaspectra.live>
Co-committed-by: pwgen2155 <pwgen2155@noreply.gammaspectra.live>
2025-05-02 11:00:39 +00:00
WeebDataHoarder
a2225fe749 context: allow nil request context in fetch cases 2025-05-02 02:23:48 +02:00
nakoo
61d0964eb0 docker: fix docker entrypoint to optionally accept the command option 2025-05-01 21:08:38 +00:00
WeebDataHoarder
b9ca196c63 settings/bind: allow specifying bind/client timeouts 2025-05-01 22:26:51 +02:00
WeebDataHoarder
f6a8f50a53 settings/backend: allow configuring dial and transport timeouts 2025-05-01 22:23:23 +02:00
WeebDataHoarder
3047dcfd4b examples/forgejo: Restrict meta tag fetching for likely bots 2025-05-01 16:15:28 +02:00
WeebDataHoarder
868c76eeb9 examples/forgejo: add commit graph endpoint to heavy resources 2025-05-01 14:20:03 +02:00
WeebDataHoarder
d412672ed4 state: explicitly free resources on Close() 2025-05-01 14:16:19 +02:00
WeebDataHoarder
d80e282781 readme: note existence of the wiki 2025-05-01 03:23:14 +02:00
WeebDataHoarder
2ecbd1db21 condition: ast: deprecated inNetwork is not a member function, fix logic 2025-05-01 02:44:12 +02:00
WeebDataHoarder
d6c29846df condition: generalize AST compilation, hot load network prefix blocks as needed, walk the AST and detect and preload networks 2025-05-01 02:40:43 +02:00
WeebDataHoarder
6e47cec540 examples/forgejo: allow releases summary-card fetch 2025-05-01 02:34:14 +02:00
WeebDataHoarder
fccaa64fad conditions: verify that AST condition result is bool 2025-05-01 01:58:08 +02:00
WeebDataHoarder
a9f03267b6 settings: allow transparent backends that don't set all values 2025-04-30 20:54:50 +02:00
WeebDataHoarder
4ce6d9efa3 cmd: add go runtime version and arch logs 2025-04-30 10:45:14 +02:00
WeebDataHoarder
cb46d4c7b6 ci: trigger builds on PRs 2025-04-30 10:44:47 +02:00
WeebDataHoarder
e46a5c75f8 debug: output mismatched backend host 2025-04-30 03:11:29 +02:00
WeebDataHoarder
b3cd741bee readme: note that port is necessary in case of non-standard port usage 2025-04-30 03:08:18 +02:00
WeebDataHoarder
3606590b48 Revert "docker: fix docker entrypoint to allow the command option"
This reverts commit 3c73c2de1c.

Fixes #14
2025-04-30 02:41:25 +02:00
WeebDataHoarder
a87023861a state: fix errors when loading network lists 2025-04-29 13:45:30 +02:00
WeebDataHoarder
e7833a7106 cmd: attach slog to all http servers 2025-04-29 02:14:02 +02:00
nakoo
3c73c2de1c docker: fix docker entrypoint to allow the command option 2025-04-28 15:54:59 +00:00
WeebDataHoarder
62277aac64 examples: modify spa to allow cookie fallback on other endpoints 2025-04-28 17:30:23 +02:00
WeebDataHoarder
6db839e23f examples: add spa.yml for single page application examples 2025-04-28 17:25:49 +02:00
WeebDataHoarder
e49c4ae72f action/context: add capability to set response headers 2025-04-28 12:40:03 +02:00
WeebDataHoarder
61655b6a02 utils: remove debug print of all received networks on RADb 2025-04-28 12:25:53 +02:00
WeebDataHoarder
b8bf35d4de utils: fix radb fetching lines too long for scanner buffer size, allow caching empty results 2025-04-27 22:04:21 +02:00
WeebDataHoarder
b285c13e4c state: do not cache network prefixes if they have zero entries 2025-04-27 21:49:44 +02:00
WeebDataHoarder
e7ef9af42a utils: remove debug initialization code from RADb helper 2025-04-27 21:42:58 +02:00
WeebDataHoarder
2bb8ec833d challenges/refresh: change refresh-mode to refresh-via as examples show 2025-04-27 21:42:29 +02:00
WeebDataHoarder
a5d973dbaa actions: fix context action stopping processing 2025-04-27 21:41:55 +02:00
WeebDataHoarder
1a9224e453 challenge: fix skipped challenged being logged as issued due to inner condition 2025-04-27 21:41:30 +02:00
WeebDataHoarder
3234c4e801 feature: Implement <meta> tag fetcher from backends with allow-listed entries to prevent unwanted keys to pass 2025-04-27 21:40:59 +02:00
WeebDataHoarder
957303bbca examples: Do not block generic tools on generic.yml by default 2025-04-27 21:19:17 +02:00
WeebDataHoarder
d36d8354a2 examples: clarify rules order, default action and standard-tools rule 2025-04-27 20:53:30 +02:00
WeebDataHoarder
666ffa574a challenge: implement IPv6 Happy Eyeballs again, use errors to detect this within challenge, cleanup referrer tags 2025-04-27 18:49:58 +02:00
WeebDataHoarder
06c363e55a context: add ip prefix on keyed cookie 2025-04-27 17:37:34 +02:00
WeebDataHoarder
62ece572d9 challenge: Use top /24 for IPv4 or top /64 for IPv6 2025-04-27 17:30:34 +02:00
WeebDataHoarder
c5ad9cdf03 context: add CONTEXT action to apply options on current request 2025-04-27 17:20:57 +02:00
WeebDataHoarder
d353286a08 readme: update "why do this?" section with Wikimedia blog 2025-04-27 16:50:59 +02:00
WeebDataHoarder
0473109e60 http: allow specifying Go DNS resolver on config backends 2025-04-27 13:16:42 +02:00
WeebDataHoarder
eb96acb559 cmd: have -check use same logger as fatal errors 2025-04-27 12:18:49 +02:00
WeebDataHoarder
c33531d7eb cmd: log errors with ERROR severity via slog, additionally print newline string, fixes #12 2025-04-27 12:17:18 +02:00
WeebDataHoarder
b3eb0ab4b7 docker: remove GOAWAY_POLICY_SNIPPETS by default 2025-04-27 11:51:17 +02:00
WeebDataHoarder
45692ec6c0 readme: use proper forge for powxy 2025-04-26 00:03:43 +02:00
WeebDataHoarder
32b7c578f6 readme: add CSSWAF, rewrite table 2025-04-25 23:56:29 +02:00
WeebDataHoarder
01ef63abea challenge: quote expected challenge name on error 2025-04-25 23:20:53 +02:00
WeebDataHoarder
0b9f077b6c context: delete query parameters set by go-away 2025-04-25 22:48:34 +02:00
WeebDataHoarder
a85aa95dbd cmd: support changing path from well-known prefix, allow configuring full path 2025-04-25 22:16:09 +02:00
WeebDataHoarder
a1f97adde8 metrics: fix global state reset on policy reload 2025-04-25 22:11:08 +02:00
WeebDataHoarder
bca5b25f28 docker: include default snippets onto Dockerfile, allow multiple snippets folders, closes #8 2025-04-25 18:09:25 +02:00
WeebDataHoarder
d665036d98 examples: move desired-crawlers before undesired-networks 2025-04-25 17:59:16 +02:00
WeebDataHoarder
9300132a4b readme: mark string support and https listeners off todo list 2025-04-25 17:52:32 +02:00
WeebDataHoarder
9ebb78f09f readme: note support for string editing under templates 2025-04-25 17:35:22 +02:00
WeebDataHoarder
398675aa3c config: Add string replacement for templates, add example config.yml (close #10) 2025-04-25 17:32:45 +02:00
WeebDataHoarder
01df790e30 docker: added config/metrics/debug options 2025-04-25 13:07:34 +02:00
WeebDataHoarder
13c0c5473e ci/readme: update Codeberg mirror path 2025-04-25 12:18:29 +02:00
WeebDataHoarder
4d7436c51b cel: use generic env from https://codeberg.org/gone/http-cel 2025-04-25 12:08:55 +02:00
WeebDataHoarder
bc0eaeca21 metrics: Add rule action metrics 2025-04-25 11:40:39 +02:00
WeebDataHoarder
d6d69d0192 metrics: track DEFAULT rule hit 2025-04-25 11:40:38 +02:00
WeebDataHoarder
47f9f6fee6 metrics: Added prometheus metrics for rules and challenges 2025-04-25 11:27:42 +02:00
Alan Orth
6f3d81618c examples: add TikTokSpider
Requests using this user agent are coming from the same Amazon net-
works as Bytespider.
2025-04-25 11:02:48 +03:00
WeebDataHoarder
1f84f5e981 examples: forgejo: Add branches/tags listing on repo to API endpoints 2025-04-24 20:51:15 +02:00
WeebDataHoarder
1e569571a0 readme: cleanup other project forge icons 2025-04-24 18:34:25 +02:00
WeebDataHoarder
ef89de8914 readme: Added https://git.sequentialread.com/forest/pow-bot-deterrent to other projects 2025-04-24 18:26:06 +02:00
WeebDataHoarder
9541c58eeb settings: introduce settings YAML file to complement cmd arguments 2025-04-24 18:26:06 +02:00
Alan Orth
fc7d67ad70 Add examples/snippets/bot-uptimerobot.yml
Add network prefixes and user agent for UptimeRobot.

Source: https://uptimerobot.com/help/locations/
2025-04-24 13:39:33 +03:00
WeebDataHoarder
96870cc192 dnsbl: normal error handling on resolution error 2025-04-24 00:02:06 +02:00
WeebDataHoarder
74a067ae10 ci: use mirror for image fetches 2025-04-23 23:45:43 +02:00
WeebDataHoarder
3bbd50764a challenge: add cookie prefix to cookies tied to host/pubkey to prevent reuse 2025-04-23 22:38:14 +02:00
WeebDataHoarder
49e46e7e9f condition: fix http query values context 2025-04-23 22:29:17 +02:00
WeebDataHoarder
cd372e1512 challenge: Skip already issued challenges 2025-04-23 22:06:11 +02:00
WeebDataHoarder
cef915b353 http: use Query.Get instead of FormValue, allows POST through 2025-04-23 21:30:39 +02:00
WeebDataHoarder
10ceca02c9 docker: Remove outdated DNSBL argument 2025-04-23 21:15:56 +02:00
66 changed files with 3300 additions and 1751 deletions

View File

@@ -1,5 +1,5 @@
// yaml_stream.jsonnet
local Build(go, alpine, os, arch) = {
local Build(mirror, go, alpine, os, arch) = {
kind: "pipeline",
type: "docker",
name: "build-" + go + "-alpine" + alpine + "-" + arch,
@@ -17,6 +17,7 @@ local Build(go, alpine, os, arch) = {
{
name: "build",
image: "golang:" + go +"-alpine" + alpine,
mirror: mirror,
commands: [
"apk update",
"apk add --no-cache git",
@@ -28,6 +29,7 @@ local Build(go, alpine, os, arch) = {
{
name: "check-policy-forgejo",
image: "alpine:" + alpine,
mirror: mirror,
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/"
@@ -36,14 +38,25 @@ local Build(go, alpine, os, arch) = {
{
name: "check-policy-generic",
image: "alpine:" + alpine,
mirror: mirror,
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: "check-policy-spa",
image: "alpine:" + alpine,
mirror: mirror,
depends_on: ["build"],
commands: [
"./.bin/go-away --check --slog-level DEBUG --backend example.com=http://127.0.0.1:80 --policy examples/spa.yml --policy-snippets examples/snippets/"
],
},
{
name: "test-wasm-success",
image: "alpine:" + alpine,
mirror: mirror,
depends_on: ["build"],
commands: [
"./.bin/test-wasm-runtime -wasm ./embed/challenge/js-pow-sha256/runtime/runtime.wasm " +
@@ -56,6 +69,7 @@ local Build(go, alpine, os, arch) = {
{
name: "test-wasm-fail",
image: "alpine:" + alpine,
mirror: mirror,
depends_on: ["build"],
commands: [
"./.bin/test-wasm-runtime -wasm ./embed/challenge/js-pow-sha256/runtime/runtime.wasm " +
@@ -68,7 +82,7 @@ local Build(go, alpine, os, arch) = {
]
};
local Publish(registry, repo, secret, go, alpine, os, arch, trigger, platforms, extra) = {
local Publish(mirror, registry, repo, secret, go, alpine, os, arch, trigger, platforms, extra) = {
kind: "pipeline",
type: "docker",
name: "publish-" + go + "-alpine" + alpine + "-" + secret,
@@ -78,6 +92,15 @@ local Publish(registry, repo, secret, go, alpine, os, arch, trigger, platforms,
},
trigger: trigger,
steps: [
{
name: "setup-buildkitd",
image: "alpine:" + alpine,
mirror: mirror,
commands: [
"echo '[registry.\"docker.io\"]' > buildkitd.toml",
"echo ' mirrors = [\"mirror.gcr.io\"]' >> buildkitd.toml"
],
},
{
name: "docker",
image: "plugins/buildx",
@@ -87,13 +110,15 @@ local Publish(registry, repo, secret, go, alpine, os, arch, trigger, platforms,
SOURCE_DATE_EPOCH: 0,
TZ: "UTC",
LC_ALL: "C",
PLUGIN_BUILDER_CONFIG: "buildkitd.toml",
PLUGIN_BUILDER_DRIVER: "docker-container",
},
settings: {
registry: registry,
repo: repo,
mirror: mirror,
compress: true,
platform: platforms,
builder_driver: "docker-container",
build_args: {
from_builder: "golang:" + go +"-alpine" + alpine,
from: "alpine:" + alpine,
@@ -116,17 +141,22 @@ local containerArchitectures = ["linux/amd64", "linux/arm64", "linux/riscv64"];
local alpineVersion = "3.21";
local goVersion = "1.24";
local mirror = "https://mirror.gcr.io";
[
Build(goVersion, alpineVersion, "linux", "amd64"),
Build(goVersion, alpineVersion, "linux", "arm64"),
Build(mirror, goVersion, alpineVersion, "linux", "amd64") + {"trigger": {event: ["push", "tag"], }},
Build(mirror, goVersion, alpineVersion, "linux", "arm64") + {"trigger": {event: ["push", "tag"], }},
# Test PRs
Build(mirror, goVersion, alpineVersion, "linux", "amd64") + {"name": "test-pr", "trigger": {event: ["pull_request"], }},
# latest
Publish("git.gammaspectra.live", "git.gammaspectra.live/git/go-away", "git", goVersion, alpineVersion, "linux", "amd64", {event: ["push"], branch: ["master"], }, containerArchitectures, {tags: ["latest"],}) + {name: "publish-latest-git"},
Publish("codeberg.org", "codeberg.org/weebdatahoarder/go-away", "codeberg", goVersion, alpineVersion, "linux", "amd64", {event: ["push"], branch: ["master"], }, containerArchitectures, {tags: ["latest"],}) + {name: "publish-latest-codeberg"},
Publish("ghcr.io", "ghcr.io/weebdatahoarder/go-away", "github", goVersion, alpineVersion, "linux", "amd64", {event: ["push"], branch: ["master"], }, containerArchitectures, {tags: ["latest"],}) + {name: "publish-latest-github"},
Publish(mirror, "git.gammaspectra.live", "git.gammaspectra.live/git/go-away", "git", goVersion, alpineVersion, "linux", "amd64", {event: ["push"], branch: ["master"], }, containerArchitectures, {tags: ["latest"],}) + {name: "publish-latest-git"},
Publish(mirror, "codeberg.org", "codeberg.org/gone/go-away", "codeberg", goVersion, alpineVersion, "linux", "amd64", {event: ["push"], branch: ["master"], }, containerArchitectures, {tags: ["latest"],}) + {name: "publish-latest-codeberg"},
Publish(mirror, "ghcr.io", "ghcr.io/weebdatahoarder/go-away", "github", goVersion, alpineVersion, "linux", "amd64", {event: ["push"], branch: ["master"], }, containerArchitectures, {tags: ["latest"],}) + {name: "publish-latest-github"},
# modern
Publish("git.gammaspectra.live", "git.gammaspectra.live/git/go-away", "git", goVersion, alpineVersion, "linux", "amd64", {event: ["promote", "tag"], target: ["production"], }, containerArchitectures, {auto_tag: true,}),
Publish("codeberg.org", "codeberg.org/weebdatahoarder/go-away", "codeberg", goVersion, alpineVersion, "linux", "amd64", {event: ["promote", "tag"], target: ["production"], }, containerArchitectures, {auto_tag: true,}),
Publish("ghcr.io", "ghcr.io/weebdatahoarder/go-away", "github", goVersion, alpineVersion, "linux", "amd64", {event: ["promote", "tag"], target: ["production"], }, containerArchitectures, {auto_tag: true,}),
Publish(mirror, "git.gammaspectra.live", "git.gammaspectra.live/git/go-away", "git", goVersion, alpineVersion, "linux", "amd64", {event: ["promote", "tag"], target: ["production"], }, containerArchitectures, {auto_tag: true,}),
Publish(mirror, "codeberg.org", "codeberg.org/gone/go-away", "codeberg", goVersion, alpineVersion, "linux", "amd64", {event: ["promote", "tag"], target: ["production"], }, containerArchitectures, {auto_tag: true,}),
Publish(mirror, "ghcr.io", "ghcr.io/weebdatahoarder/go-away", "github", goVersion, alpineVersion, "linux", "amd64", {event: ["promote", "tag"], target: ["production"], }, containerArchitectures, {auto_tag: true,}),
]

View File

@@ -17,6 +17,7 @@ steps:
- 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
mirror: https://mirror.gcr.io
name: build
- commands:
- ./.bin/go-away --check --slog-level DEBUG --backend example.com=http://127.0.0.1:80
@@ -24,6 +25,7 @@ steps:
depends_on:
- build
image: alpine:3.21
mirror: https://mirror.gcr.io
name: check-policy-forgejo
- commands:
- ./.bin/go-away --check --slog-level DEBUG --backend example.com=http://127.0.0.1:80
@@ -31,7 +33,16 @@ steps:
depends_on:
- build
image: alpine:3.21
mirror: https://mirror.gcr.io
name: check-policy-generic
- commands:
- ./.bin/go-away --check --slog-level DEBUG --backend example.com=http://127.0.0.1:80
--policy examples/spa.yml --policy-snippets examples/snippets/
depends_on:
- build
image: alpine:3.21
mirror: https://mirror.gcr.io
name: check-policy-spa
- 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
@@ -41,6 +52,7 @@ steps:
depends_on:
- build
image: alpine:3.21
mirror: https://mirror.gcr.io
name: test-wasm-success
- commands:
- ./.bin/test-wasm-runtime -wasm ./embed/challenge/js-pow-sha256/runtime/runtime.wasm
@@ -51,7 +63,12 @@ steps:
depends_on:
- build
image: alpine:3.21
mirror: https://mirror.gcr.io
name: test-wasm-fail
trigger:
event:
- push
- tag
type: docker
---
environment:
@@ -72,6 +89,7 @@ steps:
- 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
mirror: https://mirror.gcr.io
name: build
- commands:
- ./.bin/go-away --check --slog-level DEBUG --backend example.com=http://127.0.0.1:80
@@ -79,6 +97,7 @@ steps:
depends_on:
- build
image: alpine:3.21
mirror: https://mirror.gcr.io
name: check-policy-forgejo
- commands:
- ./.bin/go-away --check --slog-level DEBUG --backend example.com=http://127.0.0.1:80
@@ -86,7 +105,16 @@ steps:
depends_on:
- build
image: alpine:3.21
mirror: https://mirror.gcr.io
name: check-policy-generic
- commands:
- ./.bin/go-away --check --slog-level DEBUG --backend example.com=http://127.0.0.1:80
--policy examples/spa.yml --policy-snippets examples/snippets/
depends_on:
- build
image: alpine:3.21
mirror: https://mirror.gcr.io
name: check-policy-spa
- 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
@@ -96,6 +124,7 @@ steps:
depends_on:
- build
image: alpine:3.21
mirror: https://mirror.gcr.io
name: test-wasm-success
- commands:
- ./.bin/test-wasm-runtime -wasm ./embed/challenge/js-pow-sha256/runtime/runtime.wasm
@@ -106,7 +135,83 @@ steps:
depends_on:
- build
image: alpine:3.21
mirror: https://mirror.gcr.io
name: test-wasm-fail
trigger:
event:
- push
- tag
type: docker
---
environment:
CGO_ENABLED: "0"
GOARCH: amd64
GOOS: linux
GOTOOLCHAIN: local
kind: pipeline
name: test-pr
platform:
arch: amd64
os: linux
steps:
- commands:
- apk update
- apk add --no-cache git
- mkdir .bin
- 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
mirror: https://mirror.gcr.io
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
mirror: https://mirror.gcr.io
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
mirror: https://mirror.gcr.io
name: check-policy-generic
- commands:
- ./.bin/go-away --check --slog-level DEBUG --backend example.com=http://127.0.0.1:80
--policy examples/spa.yml --policy-snippets examples/snippets/
depends_on:
- build
image: alpine:3.21
mirror: https://mirror.gcr.io
name: check-policy-spa
- 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
./embed/challenge/js-pow-sha256/test/make-challenge-out.json -verify-challenge
./embed/challenge/js-pow-sha256/test/verify-challenge.json -verify-challenge-out
0
depends_on:
- build
image: alpine:3.21
mirror: https://mirror.gcr.io
name: test-wasm-success
- 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
./embed/challenge/js-pow-sha256/test/make-challenge-out.json -verify-challenge
./embed/challenge/js-pow-sha256/test/verify-challenge-fail.json -verify-challenge-out
1
depends_on:
- build
image: alpine:3.21
mirror: https://mirror.gcr.io
name: test-wasm-fail
trigger:
event:
- pull_request
type: docker
---
kind: pipeline
@@ -115,9 +220,17 @@ platform:
arch: amd64
os: linux
steps:
- commands:
- echo '[registry."docker.io"]' > buildkitd.toml
- echo ' mirrors = ["mirror.gcr.io"]' >> buildkitd.toml
image: alpine:3.21
mirror: https://mirror.gcr.io
name: setup-buildkitd
- environment:
DOCKER_BUILDKIT: "1"
LC_ALL: C
PLUGIN_BUILDER_CONFIG: buildkitd.toml
PLUGIN_BUILDER_DRIVER: docker-container
SOURCE_DATE_EPOCH: 0
TZ: UTC
image: plugins/buildx
@@ -128,8 +241,8 @@ steps:
build_args:
from: alpine:3.21
from_builder: golang:1.24-alpine3.21
builder_driver: docker-container
compress: true
mirror: https://mirror.gcr.io
password:
from_secret: git_password
platform:
@@ -155,9 +268,17 @@ platform:
arch: amd64
os: linux
steps:
- commands:
- echo '[registry."docker.io"]' > buildkitd.toml
- echo ' mirrors = ["mirror.gcr.io"]' >> buildkitd.toml
image: alpine:3.21
mirror: https://mirror.gcr.io
name: setup-buildkitd
- environment:
DOCKER_BUILDKIT: "1"
LC_ALL: C
PLUGIN_BUILDER_CONFIG: buildkitd.toml
PLUGIN_BUILDER_DRIVER: docker-container
SOURCE_DATE_EPOCH: 0
TZ: UTC
image: plugins/buildx
@@ -168,8 +289,8 @@ steps:
build_args:
from: alpine:3.21
from_builder: golang:1.24-alpine3.21
builder_driver: docker-container
compress: true
mirror: https://mirror.gcr.io
password:
from_secret: codeberg_password
platform:
@@ -177,7 +298,7 @@ steps:
- linux/arm64
- linux/riscv64
registry: codeberg.org
repo: codeberg.org/weebdatahoarder/go-away
repo: codeberg.org/gone/go-away
tags:
- latest
username:
@@ -195,9 +316,17 @@ platform:
arch: amd64
os: linux
steps:
- commands:
- echo '[registry."docker.io"]' > buildkitd.toml
- echo ' mirrors = ["mirror.gcr.io"]' >> buildkitd.toml
image: alpine:3.21
mirror: https://mirror.gcr.io
name: setup-buildkitd
- environment:
DOCKER_BUILDKIT: "1"
LC_ALL: C
PLUGIN_BUILDER_CONFIG: buildkitd.toml
PLUGIN_BUILDER_DRIVER: docker-container
SOURCE_DATE_EPOCH: 0
TZ: UTC
image: plugins/buildx
@@ -208,8 +337,8 @@ steps:
build_args:
from: alpine:3.21
from_builder: golang:1.24-alpine3.21
builder_driver: docker-container
compress: true
mirror: https://mirror.gcr.io
password:
from_secret: github_password
platform:
@@ -235,9 +364,17 @@ platform:
arch: amd64
os: linux
steps:
- commands:
- echo '[registry."docker.io"]' > buildkitd.toml
- echo ' mirrors = ["mirror.gcr.io"]' >> buildkitd.toml
image: alpine:3.21
mirror: https://mirror.gcr.io
name: setup-buildkitd
- environment:
DOCKER_BUILDKIT: "1"
LC_ALL: C
PLUGIN_BUILDER_CONFIG: buildkitd.toml
PLUGIN_BUILDER_DRIVER: docker-container
SOURCE_DATE_EPOCH: 0
TZ: UTC
image: plugins/buildx
@@ -249,8 +386,8 @@ steps:
build_args:
from: alpine:3.21
from_builder: golang:1.24-alpine3.21
builder_driver: docker-container
compress: true
mirror: https://mirror.gcr.io
password:
from_secret: git_password
platform:
@@ -275,9 +412,17 @@ platform:
arch: amd64
os: linux
steps:
- commands:
- echo '[registry."docker.io"]' > buildkitd.toml
- echo ' mirrors = ["mirror.gcr.io"]' >> buildkitd.toml
image: alpine:3.21
mirror: https://mirror.gcr.io
name: setup-buildkitd
- environment:
DOCKER_BUILDKIT: "1"
LC_ALL: C
PLUGIN_BUILDER_CONFIG: buildkitd.toml
PLUGIN_BUILDER_DRIVER: docker-container
SOURCE_DATE_EPOCH: 0
TZ: UTC
image: plugins/buildx
@@ -289,8 +434,8 @@ steps:
build_args:
from: alpine:3.21
from_builder: golang:1.24-alpine3.21
builder_driver: docker-container
compress: true
mirror: https://mirror.gcr.io
password:
from_secret: codeberg_password
platform:
@@ -298,7 +443,7 @@ steps:
- linux/arm64
- linux/riscv64
registry: codeberg.org
repo: codeberg.org/weebdatahoarder/go-away
repo: codeberg.org/gone/go-away
username:
from_secret: codeberg_username
trigger:
@@ -315,9 +460,17 @@ platform:
arch: amd64
os: linux
steps:
- commands:
- echo '[registry."docker.io"]' > buildkitd.toml
- echo ' mirrors = ["mirror.gcr.io"]' >> buildkitd.toml
image: alpine:3.21
mirror: https://mirror.gcr.io
name: setup-buildkitd
- environment:
DOCKER_BUILDKIT: "1"
LC_ALL: C
PLUGIN_BUILDER_CONFIG: buildkitd.toml
PLUGIN_BUILDER_DRIVER: docker-container
SOURCE_DATE_EPOCH: 0
TZ: UTC
image: plugins/buildx
@@ -329,8 +482,8 @@ steps:
build_args:
from: alpine:3.21
from_builder: golang:1.24-alpine3.21
builder_driver: docker-container
compress: true
mirror: https://mirror.gcr.io
password:
from_secret: github_password
platform:
@@ -350,6 +503,6 @@ trigger:
type: docker
---
kind: signature
hmac: 8aed9810938e4aa4b34c4afb35e1101f27f98a61ffe5349be9a30f22ce7480ed
hmac: df53e4ea6f1c47df4d2a3f89b931b8513e83daa9c6c15baba2662d8112a721c8
...

View File

@@ -1,132 +0,0 @@
# Challenges
Challenges can be [transparent](#transparent) (not shown to user, depends on backend or other logic), [non-JavaScript](#non-javascript) (challenges common browser properties), or [custom JavaScript](README.md#custom-javascript) (from Proof of Work to fingerprinting or Captcha is supported)
## Transparent
### http
Verify incoming requests against a specified backend to allow the user through. Cookies and some other headers are passed.
For example, this allows verifying the user cookies against the backend to have the user skip all other challenges.
Example on Forgejo, checks that current user is authenticated:
```yaml
http-cookie-check:
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
Requires HTTP/2+ response parsing and logic, silent challenge (does not display a challenge page).
Browsers that support [103 Early Hints](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status/103) are indicated to fetch a CSS resource via [Link](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Link) preload that solves the challenge.
The server waits until solved or defined timeout, then continues on other challenges if failed.
Example:
```yaml
preload-link:
condition: '"Sec-Fetch-Mode" in headers && headers["Sec-Fetch-Mode"] == "navigate"'
runtime: "preload-link"
parameters:
preload-early-hint-deadline: 3s
```
### 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
### cookie
Requires HTTP parsing and a Cookie Jar, silent challenge (does not display a challenge page unless failed).
Serves the client with a Set-Cookie that solves the challenge, and redirects it back to the same page. Browser must present the cookie to load.
Several tools implement this, but usually not mass scrapers.
### header-refresh
Requires HTTP response parsing and logic, displays challenge site instantly.
Have the browser solve the challenge by following the URL listed on HTTP [Refresh](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Refresh) instantly.
### meta-refresh
Requires HTTP and HTML response parsing and logic, displays challenge site instantly.
Have the browser solve the challenge by following the URL listed on HTML `<meta http-equiv=refresh>` tag instantly. Equivalent to above.
### resource-load
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).
## Custom JavaScript
### js-pow-sha256
Requires JavaScript and workers, displays challenge site.
Has the user solve a Proof of Work using SHA256 hashes, with configurable difficulty.
Example:
```yaml
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

@@ -32,36 +32,37 @@ RUN test -e "${GOBIN}/go-away"
FROM --platform=$TARGETPLATFORM ${from}
COPY --from=build /go/bin/go-away /bin/go-away
COPY examples/snippets/ /snippets/
COPY docker-entrypoint.sh /
ENV TZ UTC
ENV GOAWAY_METRICS_BIND=""
ENV GOAWAY_DEBUG_BIND=""
ENV GOAWAY_BIND=":8080"
ENV GOAWAY_BIND_NETWORK="tcp"
ENV GOAWAY_SOCKET_MODE="0770"
ENV GOAWAY_CONFIG=""
ENV GOAWAY_POLICY="/policy.yml"
ENV GOAWAY_POLICY_SNIPPETS="/policy/snippets"
ENV GOAWAY_POLICY_SNIPPETS=""
ENV GOAWAY_CHALLENGE_TEMPLATE="anubis"
ENV GOAWAY_CHALLENGE_TEMPLATE_THEME=""
ENV GOAWAY_CHALLENGE_TEMPLATE_LOGO=""
ENV GOAWAY_SLOG_LEVEL="WARN"
ENV GOAWAY_CLIENT_IP_HEADER=""
ENV GOAWAY_BACKEND_IP_HEADER=""
ENV GOAWAY_JWT_PRIVATE_KEY_SEED=""
ENV GOAWAY_BACKEND=""
ENV GOAWAY_DNSBL="dnsbl.dronebl.org"
ENV GOAWAY_ACME_AUTOCERT=""
ENV GOAWAY_CACHE="/cache"
EXPOSE 8080/tcp
EXPOSE 8080/udp
EXPOSE 9090/tcp
EXPOSE 6060/tcp
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}" --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}" \
--slog-level "${GOAWAY_SLOG_LEVEL}" \
--acme-autocert "${GOAWAY_ACME_AUTOCERT}" \
--backend "${GOAWAY_BACKEND}"
ENTRYPOINT ["/docker-entrypoint.sh"]

273
README.md
View File

@@ -1,22 +1,25 @@
### <a id=why></a>
# go-away
Self-hosted abuse detection and rule enforcement against low-effort mass AI scraping and bots.
Self-hosted abuse detection and rule enforcement against low-effort mass AI scraping and bots. Uses conventional non-nuclear options.
[![Latest Release](https://img.shields.io/gitea/v/release/git/go-away?gitea_url=https%3A%2F%2Fgit.gammaspectra.live)](https://git.gammaspectra.live/git/go-away/releases)
[![Build Status](https://ci.gammaspectra.live/api/badges/git/go-away/status.svg)](https://ci.gammaspectra.live/git/go-away)
[![Go Reference](https://pkg.go.dev/badge/git.gammaspectra.live/git/go-away.svg)](https://pkg.go.dev/git.gammaspectra.live/git/go-away)
go-away sits in between your site and the Internet / upstream proxy.
Incoming requests can be selected by [rules](#rich-rule-matching) to be [actioned](#extended-rule-actions) or [challenged](CHALLENGES.md#challenges) to filter suspicious requests.
Incoming requests can be selected by [rules](#rich-rule-matching) to be [actioned](https://git.gammaspectra.live/git/go-away/wiki/Rule-Actions) or [challenged](https://git.gammaspectra.live/git/go-away/wiki/Challenges) to filter suspicious requests.
The tool is designed highly flexible so the operator can minimize impact to legit users, while surgically targeting heavy endpoints or scrapers.
[Challenges](CHALLENGES.md#challenges) can be transparent (not shown to user, depends on backend or other logic), [non-JavaScript](#non-javascript-challenges) (challenges common browser properties), or [custom JavaScript](#custom-javascript-wasm-challenges) (from Proof of Work to fingerprinting or Captcha is supported)
[Challenges](https://git.gammaspectra.live/git/go-away/wiki/Challenges) can be transparent (not shown to user, depends on backend or other logic), [non-JavaScript](#non-javascript-challenges) (challenges common browser properties), or [custom JavaScript](#custom-javascript-wasm-challenges) (from Proof of Work to fingerprinting or Captcha is supported)
See _[Why do this?](#why-do-this)_ section for the challenges and reasoning behind this tool.
This documentation and go-away are in active development. See [What's left?](#what-s-left) section for a breakdown.
**This documentation and go-away are in active development.** See [What's left?](#what-s-left) section for a breakdown.
Check this README for a general introduction. An [in-depth Wiki](https://git.gammaspectra.live/git/go-away/wiki/) is available and being improved.
## Support
@@ -32,7 +35,7 @@ Source code is automatically pushed to the following mirrors. Packages are also
[![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)
[![Codeberg](https://img.shields.io/badge/Codeberg-mirror+packages-2185D0?style=flat&logo=codeberg&labelColor=fff)](https://codeberg.org/gone/go-away) ![](https://codeberg.org/gone/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)
@@ -40,6 +43,13 @@ Source code is automatically pushed to the following mirrors. Packages are also
Note that issues or pull requests should be issued on the [main Forge](https://git.gammaspectra.live/git/go-away).
## Installation and Setup
See the [Installation page](https://git.gammaspectra.live/git/go-away/wiki/Installation) on the Wiki for all the details.
go-away can be directly run from command line, via pre-built containers, or your own built containers.
## Features
### Rich rule matching
@@ -69,7 +79,17 @@ Only available when TLS is enabled
fp.ja4 (string) JA4 TLS Fingerprint
```
### Template support
### Package path
You can modify the path where challenges are served and package name, if you don't want its presence to be easily discoverable.
No source code editing or forking necessary!
Simply pass a new absolute path via the cmdline _path_ argument, like so: `--path "/.goaway_example"`
### Page template and customization support
Internal or external templates can be loaded to customize the look of the challenge or error page. Additionally, themes can be configured to change the look of these quickly.
@@ -80,25 +100,19 @@ These templates are included by default:
External templates for your site can be loaded specifying a full path to the `.gohtml` file. See [embed/templates/](embed/templates/) for examples to follow.
### Extended rule actions
You can alter the language and strings in the templates directly from the [config.yml](examples/config.yml) file if specified, or add footer links directly.
In addition to the common PASS / CHALLENGE / DENY rules, go-away offers more actions that can be extended via code.
Some templates support themes. Specify that either via the [config.yml](examples/config.yml) file, or via `challenge-template-theme` cmdline argument.
| 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 |
Most templates support overriding the logo. Specify that either via the [config.yml](examples/config.yml) file, or via `challenge-template-logo` cmdline argument.
**Feel free to make any changes to existing templates or bring your own, alter any logos or styling, it's yours to adapt!**
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.
### Advanced actions
PROXY allows the operator to send matching requests to a different backend, for example, a poison generator or a scraping maze.
In addition to the common PASS / CHALLENGE / DENY rules, go-away offers more actions, plus any more extensible via code.
See the [Rule Actions page](https://git.gammaspectra.live/git/go-away/wiki/Rule-Actions) on the Wiki.
### Multiple challenge matching
@@ -126,15 +140,15 @@ Several challenges that do not require JavaScript are offered, some targeting th
These can be used for light checking of requests that eliminate most of the low effort scraping.
See [Challenges](CHALLENGES.md#challenges) for a list of them.
See [Transparent challenges](https://git.gammaspectra.live/git/go-away/wiki/Challenges#transparent) and [Non-JavaScript challenges](https://git.gammaspectra.live/git/go-away/wiki/Challenges#non-javascript) on the Wiki for more information.
### Custom JavaScript / WASM challenges
A WASM interface for server-side proof generation and checking is offered. We provide `js-pow-sha256` as an example of one.
An internal test has shown you can implement Captchas or other browser fingerprinting tests within this interface.
You can implement Captchas or other browser fingerprinting tests within this interface.
If you are interested in creating your own, see the [Development](#development) section below.
See [Custom JavaScript challenges](https://git.gammaspectra.live/git/go-away/wiki/Challenges#custom-javascript) on the Wiki for more information.
### Upstream PROXY support
@@ -150,7 +164,6 @@ You can enable automatic certificate generation and TLS for the site via any ACM
Without TLS, HTTP/2 cleartext is supported, but you will need to configure the upstream proxy to send this protocol (`h2c://` on Caddy for example).
### TLS Fingerprinting
When running with TLS via autocert, TLS Fingerprinting of the incoming client is done.
@@ -184,14 +197,6 @@ Example for _regex_:
```
### Sharing of signing seed across instances
You can share the signing secret across multiple of your instances if you'd like to deploy multiple across the world.
That way signed secrets will be verifiable across all the instances.
By default, a random temporary key is generated every run.
### Multiple backend support
Multiple backends are supported, and rules specific on backend can be defined, and conditions and rules can match this as well.
@@ -200,12 +205,6 @@ Subdomain wildcards like `*.example.com`, or full fallback wildcard `*` are supp
This allows one instance to run multiple domains or subdomains.
### Package path
You can modify the path where challenges are served and package name, if you don't want its presence to be easily discoverable.
No source code editing or forking necessary!
### IPv6 Happy Eyeballs challenge retry
In case a client connects over IPv4 first then IPv6 due to [Fast Fallback / Happy Eyeballs](https://en.wikipedia.org/wiki/Happy_Eyeballs), the challenge will automatically be retried.
@@ -248,31 +247,32 @@ See [examples/snippets/](examples/snippets/) for some defaults including indexer
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.
Recently these networks go from using residential IP blocks to sending requests at several hundred rps.
Recently these networks go from using residential IP blocks to sending requests at several hundred requests per second.
If the server gets sluggish, more requests pile up. Even when denied they scrape for weeks later. Effectively spray and pray scraping, process later.
At some point about 300Mbit/s of incoming requests (not including the responses) was hitting the server. And all of them nonsense URLs, or hitting archive/bundle downloads per commit.
If AI is so smart, why not just git clone the repositories?
**If AI is so smart, why not just git clone the repositories?**
* Wikimedia has posted about [How crawlers impact the operations of the Wikimedia projects](https://diff.wikimedia.org/2025/04/01/how-crawlers-impact-the-operations-of-the-wikimedia-projects/) [01/04/2025]
Xe (anubis creator) has written about similar frustrations in several blogposts:
* Xe (Anubis creator) has written about similar frustrations in several blogposts:
* [Amazon's AI crawler is making my git server unstable](https://xeiaso.net/notes/2025/amazon-crawler/) [01/17/2025]
* [Anubis works](https://xeiaso.net/notes/2025/anubis-works/) [04/12/2025]
* [Amazon's AI crawler is making my git server unstable](https://xeiaso.net/notes/2025/amazon-crawler/) [01/17/2025]
* [Anubis works](https://xeiaso.net/notes/2025/anubis-works/) [04/12/2025]
* Drew DeVault (sourcehut) has posted several articles and outages regarding the same issues:
* [Drew Blog: Please stop externalizing your costs directly into my face](https://drewdevault.com/2025/03/17/2025-03-17-Stop-externalizing-your-costs-on-me.html) [17/03/2025]
* (fun tidbit: I'm the one quoted as having the feedback discussion interrupted to deal with bots!)
* [sourcehut status: LLM crawlers continue to DDoS SourceHut](https://status.sr.ht/issues/2025-03-17-git.sr.ht-llms/) [17/03/2025]
* [sourcehut Blog: You cannot have our user's data](https://sourcehut.org/blog/2025-04-15-you-cannot-have-our-users-data/) [15/04/2025]
Drew DeVault (sourcehut) has posted several articles regarding the same issues:
* [Please stop externalizing your costs directly into my face](https://drewdevault.com/2025/03/17/2025-03-17-Stop-externalizing-your-costs-on-me.html) [17/03/2025]
* (fun tidbit: I'm the one quoted as having the feedback discussion interrupted to deal with bots!)
* [sourcehut Blog: You cannot have our user's data](https://sourcehut.org/blog/2025-04-15-you-cannot-have-our-users-data/)
Others were also suffering at the same time [[1]](https://donotsta.re/notice/AreSNZlRlJv73AW7tI) [[2]](https://community.ipfire.org/t/suricata-ruleset-to-prevent-ai-scraping/11974) [[3]](https://gabrielsimmer.com/blog/stop-scraping-git-forge) [[4]](https://gabrielsimmer.com/blog/stop-scraping-git-forge) [[5]](https://blog.nytsoi.net/2025/03/01/obliterated-by-ai).
* Others were also suffering at the same time [[1]](https://donotsta.re/notice/AreSNZlRlJv73AW7tI) [[2]](https://community.ipfire.org/t/suricata-ruleset-to-prevent-ai-scraping/11974) [[3]](https://gabrielsimmer.com/blog/stop-scraping-git-forge) [[4]](https://gabrielsimmer.com/blog/stop-scraping-git-forge) [[5]](https://blog.nytsoi.net/2025/03/01/obliterated-by-ai).
---
Initially I deployed Anubis, and yeah, it does work!
This tool started as a way to replace [Anubis](https://anubis.techaro.lol/) as it was not found as featureful as desired.
This tool started as a way to replace [Anubis](https://anubis.techaro.lol/) as it was not found as featureful as desired, and the impact was too high.
go-away may not be as straight to configure as Anubis but this was chosen to reduce impact on legitimate users, and offers many more options to dynamically target new waves.
@@ -294,182 +294,37 @@ However, a few points are left before go-away can be called v1.0.0:
* [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.
* [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.
* [ ] Allow end 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.
* [x] Define strings and multi-language support for quick modification by operators without custom templates.
* [ ] Have highly tested paths that match examples.
* [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.
* [ ] Expose metrics for gathering common network ranges, challenge solve rates and acting on them.
## Setup
go-away can take plaintext HTTP/1 and _HTTP/2_ / _h2c_ connections if desired over the same port. When doing this, it is recommended to have another reverse proxy above (for example [Caddy](https://caddyserver.com/), nginx, HAProxy) to handle HTTPs or similar.
We also support the `autocert` parameter to configure HTTP(s). This will also allow TLS Fingerprinting to be done on incoming clients. This doesn't require any upstream proxies, and we recommend it's exposed directly or via SNI / Layer 4 proxying.
### Binary / Go
Requires Go 1.24+. Builds statically without CGo usage.
We have Go 1.22+ support on the [go1.22 branch](https://git.gammaspectra.live/git/go-away/src/branch/go1.22).
It will be regularly rebased to keep current with recent releases, at least until v1.0.0.
Some features, such as TLS Fingerprinting, are not available on Go 1.22.
```shell
git clone https://git.gammaspectra.live/git/go-away.git && cd go-away
CGO_ENABLED=0 go build -pgo=auto -v -trimpath -o ./go-away ./cmd/go-away
# Run on port 8080, forwarding matching requests on git.example.com to http://forgejo:3000
./go-away --bind :8080 \
--backend git.example.com=http://forgejo:3000 \
--policy examples/forgejo.yml \
--challenge-template forgejo --challenge-template-theme forgejo-dark
```
### Dockerfile
Available under [Dockerfile](Dockerfile). See the _docker compose_ below for the environment variables.
### docker compose
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:
external: false
volumes:
goaway_cache:
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:
- "3000:8080"
networks:
- forgejo
depends_on:
- forgejo
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)
#GOAWAY_BIND_NETWORK: "tcp"
#GOAWAY_SOCKET_MODE: "0770"
# set to letsencrypt or other directory URL to enable HTTPS. Above ports will be TLS only.
# enables request JA3N / JA4 client TLS fingerprinting
# TLS fingerprints are served on X-TLS-Fingerprint-JA3N and X-TLS-Fingerprint-JA4 headers
# TLS fingerprints can be matched against on CEL conditions
#GOAWAY_ACME_AUTOCERT: ""
# Cache path for several services like certificates and caching network ranges
# Can be semi-ephemeral, recommended to be mapped to a permanent volume
#GOAWAY_CACHE="/cache"
# default is WARN, set to INFO to also see challenge successes and others
#GOAWAY_SLOG_LEVEL: "INFO"
# this value is used to sign cookies and challenges. by default a new one is generated each time
# set to generate to create one, then set the same value across all your instances
#GOAWAY_JWT_PRIVATE_KEY_SEED: ""
# HTTP header that the client ip will be fetched from
# Defaults to the connection ip itself, if set here make sure your upstream proxy sets this properly
# Usually X-Forwarded-For is a good pick
# Not necessary with GOAWAY_BIND_NETWORK: proxy
GOAWAY_CLIENT_IP_HEADER: "X-Real-Ip"
# HTTP header that go-away will set the obtained ip will be set to
# If left empty, the header on GOAWAY_CLIENT_IP_HEADER will be left as-is
#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
GOAWAY_CHALLENGE_TEMPLATE: forgejo
GOAWAY_CHALLENGE_TEMPLATE_THEME: forgejo-dark
# 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
# command: ["--backend", "ci.example.com=http://ci:3000"]
forgejo:
# etc.
```
* [x] More defined way of picking HTTP/HTTP(s) listeners and certificates.
* [x] Expose metrics for challenge solve rates and acting on them.
* [ ] Metrics for common network ranges / AS / useragent
## 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)
| Project | Source Code | Description | Method |
|:----------------------------------------------------------------------------------------:|:---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------:|:---------------------------------------------------------------------------------------------------------------------------|:---------------------------------------------|
| [Anubis](https://anubis.techaro.lol/) | [![GitHub](https://img.shields.io/badge/GitHub-TecharoHQ/anubis-blue?style=flat&logo=github&labelColor=fff&logoColor=24292f)](https://github.com/TecharoHQ/anubis)<br/>Go / [MIT](https://github.com/TecharoHQ/anubis/blob/main/LICENSE) | Proxy that uses JavaScript proof of work to weight request based on simple match rules | JavaScript PoW (SHA-256) |
| [powxy](https://forge.lindenii.runxiyu.org/powxy/-/repos/powxy/) | [![lindenii.runxiyu.org](https://img.shields.io/badge/lindenii-powxy-blue?style=flat&logo=git&labelColor=fff&logoColor=000)](https://forge.lindenii.runxiyu.org/powxy/-/repos/powxy/)<br/> Go / [BSD 2-Clause](https://forge.lindenii.runxiyu.org/powxy/-/repos/powxy/tree/LICENSE) | Powxy is a reverse proxy that protects your upstream service by challenging clients with proof-of-work. | JavaScript PoW (SHA-256) with manual program |
| [PoW! Bot Deterrent](https://git.sequentialread.com/forest/pow-bot-deterrent) | [![SequentialRead](https://img.shields.io/badge/SequentialRead-forest/pow--bot--deterrent-blue?style=flat&logo=gitea&labelColor=fff&logoColor=000)](https://git.sequentialread.com/forest/pow-bot-deterrent)<br/> Go / [GPL v3.0](https://git.sequentialread.com/forest/pow-bot-deterrent/src/branch/main/LICENSE.md) | A proof-of-work based bot deterrent. Lightweight, self-hosted and copyleft licensed. | JavaScript PoW (WASM scrypt) |
| [CSSWAF](https://github.com/yzqzss/csswaf) | [![GitHub](https://img.shields.io/badge/GitHub-yzqzss/csswaf-blue?style=flat&logo=github&labelColor=fff&logoColor=24292f)](https://github.com/yzqzss/csswaf)<br/>Go / [MIT](https://github.com/yzqzss/csswaf/blob/main/LICENSE) | A CSS-based NoJS Anti-BOT WAF (Proof of Concept) | Non-JS CSS Subresource loading order |
| [anticrawl](https://flak.tedunangst.com/post/anticrawl) | [![humungus.tedunangst.com](https://img.shields.io/badge/tedunangst-anticrawl-blue?style=flat&logo=mercurial&labelColor=fff&logoColor=000)](https://humungus.tedunangst.com/r/anticrawl)<br/>Go / None | Go http handler / proxy for regex based rules | Non-JS manual Challenge/Response |
| [ngx_http_js_challenge_module](https://github.com/simon987/ngx_http_js_challenge_module) | [![GitHub](https://img.shields.io/badge/GitHub-simon987/ngx_http_js_challenge_module-blue?style=flat&logo=github&labelColor=fff&logoColor=24292f)](https://github.com/simon987/ngx_http_js_challenge_module)<br/>C / [GPL v3.0](https://github.com/simon987/ngx_http_js_challenge_module/blob/master/LICENSE) | Simple javascript proof-of-work based access for Nginx with virtually no overhead. | JavaScript Challenge |
| [haproxy-protection](https://gitgud.io/fatchan/haproxy-protection/) | [![GitGud](https://img.shields.io/badge/GitGud-fatchan/haproxy--protection-blue?style=flat&logo=gitlab&labelColor=fff&logoColor=000)](https://gitgud.io/fatchan/haproxy-protection/)<br/> Lua / [GPL v3.0](https://gitgud.io/fatchan/haproxy-protection/-/blob/master/LICENSE.txt) | HAProxy configuration and lua scripts allowing a challenge-response page where users solve a captcha and/or proof-of-work. | JavaScript Challenge / Captcha |
## Development
This Go package can be used as a command on `git.gammaspectra.live/git/go-away/cmd/go-away` or a library under `git.gammaspectra.live/git/go-away/lib`
### Compiling WASM runtime challenge modules
Custom WASM runtime modules follow the WASI `wasip1` preview syscall API.
It is recommended using TinyGo to compile / refresh modules, and some function helpers are provided.
If you want to use a different language or compiler, enable `wasip1` and the following interface must be exported:
```
// Allocation is a combination of pointer location in WASM memory and size of it
type Allocation uint64
func (p Allocation) Pointer() uint32 {
return uint32(p >> 32)
}
func (p Allocation) Size() uint32 {
return uint32(p)
}
// MakeChallenge MakeChallengeInput / MakeChallengeOutput are valid JSON.
// See lib/challenge/wasm/interface/interface.go for a definition
func MakeChallenge(in Allocation[MakeChallengeInput]) Allocation[MakeChallengeOutput]
// VerifyChallenge VerifyChallengeInput is valid JSON.
// See lib/challenge/wasm/interface/interface.go for a definition
func VerifyChallenge(in Allocation[VerifyChallengeInput]) VerifyChallengeOutput
func malloc(size uint32) uintptr
func free(size uintptr)
```
Modules will be recreated for each call, so there is no state leftover.

View File

@@ -4,78 +4,28 @@ import (
"bytes"
"crypto/ed25519"
"crypto/rand"
"crypto/tls"
"encoding/hex"
"errors"
"flag"
"fmt"
"git.gammaspectra.live/git/go-away/lib"
"git.gammaspectra.live/git/go-away/lib/policy"
"git.gammaspectra.live/git/go-away/lib/settings"
"git.gammaspectra.live/git/go-away/utils"
"github.com/pires/go-proxyproto"
"golang.org/x/crypto/acme"
"golang.org/x/crypto/acme/autocert"
"log"
"github.com/goccy/go-yaml"
"github.com/prometheus/client_golang/prometheus/promhttp"
"log/slog"
"net"
"net/http"
"net/http/pprof"
"os"
"os/signal"
"path"
"runtime"
"runtime/debug"
"strconv"
"strings"
"sync/atomic"
"syscall"
)
func setupListener(network, address, socketMode string, proxy bool) (net.Listener, string) {
if network == "proxy" {
network = "tcp"
proxy = true
}
formattedAddress := ""
switch network {
case "unix":
formattedAddress = "unix:" + address
case "tcp":
formattedAddress = "http://localhost" + address
default:
formattedAddress = fmt.Sprintf(`(%s) %s`, network, address)
}
listener, err := net.Listen(network, address)
if err != nil {
log.Fatal(fmt.Errorf("failed to bind to %s: %w", formattedAddress, err))
}
// additional permission handling for unix sockets
if network == "unix" {
mode, err := strconv.ParseUint(socketMode, 8, 0)
if err != nil {
listener.Close()
log.Fatal(fmt.Errorf("could not parse socket mode %s: %w", socketMode, err))
}
err = os.Chmod(address, os.FileMode(mode))
if err != nil {
listener.Close()
log.Fatal(fmt.Errorf("could not change socket mode: %w", err))
}
}
if proxy {
slog.Warn("listener PROXY enabled")
formattedAddress += " +PROXY"
listener = &proxyproto.Listener{
Listener: listener,
}
}
return listener, formattedAddress
}
var internalCmdName = "go-away"
var internalMainName = "go-away"
var internalMainVersion = "dev"
@@ -101,40 +51,29 @@ func (v *MultiVar) Set(value string) error {
return nil
}
func newACMEManager(clientDirectory string, backends map[string]http.Handler) *autocert.Manager {
var domains []string
for d := range backends {
parts := strings.Split(d, ":")
d = parts[0]
if net.ParseIP(d) != nil {
continue
}
domains = append(domains, d)
}
manager := &autocert.Manager{
Prompt: autocert.AcceptTOS,
HostPolicy: autocert.HostWhitelist(domains...),
Client: &acme.Client{
HTTPClient: http.DefaultClient,
DirectoryURL: clientDirectory,
},
}
return manager
func fatal(err error) {
slog.Error(err.Error())
_, _ = fmt.Fprintln(os.Stderr, "================================================")
_, _ = fmt.Fprintln(os.Stderr, "Fatal error:")
_, _ = fmt.Fprintln(os.Stderr, err.Error())
os.Exit(1)
}
func main() {
bind := flag.String("bind", ":8080", "network address to bind HTTP/HTTP(s) to")
bindNetwork := flag.String("bind-network", "tcp", "network family to bind HTTP to, e.g. unix, tcp")
bindProxy := flag.Bool("bind-proxy", false, "use PROXY protocol in front of the listener")
socketMode := flag.String("socket-mode", "0770", "socket mode (permissions) for unix domain sockets.")
opt := settings.DefaultSettings
flag.StringVar(&opt.Bind.Address, "bind", opt.Bind.Address, "network address to bind HTTP/HTTP(s) to")
flag.StringVar(&opt.Bind.Network, "bind-network", opt.Bind.Network, "network family to bind HTTP to, e.g. unix, tcp")
flag.BoolVar(&opt.Bind.Proxy, "bind-proxy", opt.Bind.Proxy, "use PROXY protocol in front of the listener")
flag.StringVar(&opt.Bind.SocketMode, "socket-mode", opt.Bind.SocketMode, "socket mode (permissions) for unix domain sockets.")
flag.StringVar(&opt.BindMetrics, "metrics-bind", opt.BindMetrics, "network address to bind metrics on")
flag.StringVar(&opt.BindDebug, "debug-bind", opt.BindDebug, "network address to bind debug on")
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")
flag.BoolVar(&opt.Bind.Passthrough, "passthrough", opt.Bind.Passthrough, "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)")
flag.StringVar(&opt.Bind.TLSAcmeAutoCert, "acme-autocert", opt.Bind.TLSAcmeAutoCert, "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.)")
@@ -142,19 +81,29 @@ func main() {
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...])")
var policySnippets MultiVar
flag.Var(&policySnippets, "policy-snippets", "path to YAML snippets folder (can be specified multiple times)")
packageName := flag.String("package-path", internalCmdName, "package name to expose in .well-known url path")
flag.StringVar(&opt.ChallengeTemplate, "challenge-template", opt.ChallengeTemplate, "name or path of the challenge template to use (anubis, forgejo)")
templateTheme := flag.String("challenge-template-theme", opt.ChallengeTemplateOverrides["Theme"], "override template theme to use (forgejo => [forgejo-auto, forgejo-dark, forgejo-light, gitea...])")
templateLogo := flag.String("challenge-template-logo", opt.ChallengeTemplateOverrides["Logo"], "override template logo to use")
basePath := flag.String("path", "/.well-known/."+internalCmdName, "base path where to expose go-away package onto, challenges will be served from here")
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")
var backends MultiVar
flag.Var(&backends, "backend", "backend definition in the form of an.example.com=http://backend:1234 (can be specified multiple times)")
settingsFile := flag.String("config", "", "path to config override YAML file")
flag.Parse()
if *backendIpHeader == "" {
*backendIpHeader = *clientIpHeader
}
var err error
{
@@ -168,13 +117,39 @@ func main() {
leveler.Set(programLevel)
h := slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{
AddSource: *debugMode,
AddSource: programLevel <= slog.LevelDebug,
Level: leveler,
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
if a.Key == "source" {
if src, ok := a.Value.Any().(*slog.Source); ok {
return slog.String(a.Key, fmt.Sprintf("%s:%d", src.File, src.Line))
}
}
return a
},
})
slog.SetDefault(slog.New(h))
// set default log logger to slog logger level
slog.SetLogLoggerLevel(programLevel)
}
slog.Info("go-away", "package", internalMainName, "version", internalMainVersion, "cmd", internalCmdName)
slog.Info("go-away", "package", internalMainName, "version", internalMainVersion, "cmd", internalCmdName, "go", runtime.Version(), "os", runtime.GOOS, "arch", runtime.GOARCH)
// preload missing settings
opt.ChallengeTemplateOverrides["Theme"] = *templateTheme
opt.ChallengeTemplateOverrides["Logo"] = *templateLogo
// load overrides
if *settingsFile != "" {
settingsData, err := os.ReadFile(*settingsFile)
if err != nil {
fatal(fmt.Errorf("could not read settings file: %w", err))
}
err = yaml.Unmarshal(settingsData, &opt)
if err != nil {
fatal(fmt.Errorf("could not parse settings file: %w", err))
}
}
var seed []byte
@@ -189,7 +164,7 @@ func main() {
if strings.ToLower(kValue) == "generate" {
_, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
log.Fatal(fmt.Errorf("failed to generate private key: %w", err))
fatal(fmt.Errorf("failed to generate private key: %w", err))
}
fmt.Printf("%x\n", priv.Seed())
os.Exit(0)
@@ -197,109 +172,98 @@ func main() {
seed, err = hex.DecodeString(kValue)
if err != nil {
log.Fatal(fmt.Errorf("failed to decode seed: %w", err))
fatal(fmt.Errorf("failed to decode seed: %w", err))
}
if len(seed) != ed25519.SeedSize {
log.Fatal(fmt.Errorf("invalid seed length: %d, expected %d", len(seed), ed25519.SeedSize))
fatal(fmt.Errorf("invalid seed length: %d, expected %d", len(seed), ed25519.SeedSize))
}
}
createdBackends := make(map[string]http.Handler)
parsedBackends := make(map[string]string)
for _, backend := range backends {
if backend == "" {
// skip empty to allow no values
continue
}
parts := strings.Split(backend, "=")
if len(parts) != 2 {
log.Fatal(fmt.Errorf("invalid backend definition: %s, expected 2 parts, got %v", backend, parts))
fatal(fmt.Errorf("invalid backend definition: %s, expected 2 parts, got %v", backend, parts))
}
// make no-settings, default backend
opt.Backends[parts[0]] = settings.Backend{
URL: parts[1],
IpHeader: *backendIpHeader,
}
parsedBackends[parts[0]] = parts[1]
}
for k, v := range parsedBackends {
backend, err := utils.MakeReverseProxy(v)
if err != nil {
log.Fatal(fmt.Errorf("backend %s: failed to make reverse proxy: %w", k, err))
for k, v := range opt.Backends {
if v.IpHeader == "" {
//set default value
v.IpHeader = *backendIpHeader
}
backend.ErrorLog = slog.NewLogLogger(slog.With("backend", k).Handler(), slog.LevelError)
backend, err := v.Create()
if err != nil {
fatal(fmt.Errorf("backend %s: failed to make reverse proxy: %w", k, err))
}
backend.ErrorLog = slog.NewLogLogger(slog.With("backend", k).Handler(), slog.LevelDebug)
createdBackends[k] = backend
}
if len(createdBackends) == 0 {
log.Fatal(fmt.Errorf("no backends defined in policy file"))
fatal(fmt.Errorf("no backends defined in cmdline or settings file"))
}
var cache utils.Cache
var acmeCache string
if *cachePath != "" {
err = os.MkdirAll(*cachePath, 0755)
if err != nil {
log.Fatal(fmt.Errorf("failed to create cache directory: %w", err))
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))
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))
fatal(fmt.Errorf("failed to open cache directory: %w", err))
}
acmeCache = path.Join(*cachePath, "acme")
}
var tlsConfig *tls.Config
if *acmeAutocert != "" {
switch *acmeAutocert {
case "letsencrypt":
*acmeAutocert = acme.LetsEncryptURL
}
acmeManager := newACMEManager(*acmeAutocert, createdBackends)
if *cachePath != "" {
err = os.MkdirAll(path.Join(*cachePath, "acme"), 0755)
if err != nil {
log.Fatal(fmt.Errorf("failed to create acme cache directory: %w", err))
}
acmeManager.Cache = autocert.DirCache(path.Join(*cachePath, "acme"))
}
slog.Warn(
"acme-autocert enabled",
"directory", *acmeAutocert,
)
tlsConfig = acmeManager.TLSConfig()
}
loadPolicyState := func() (http.Handler, error) {
loadPolicyState := func() (*lib.State, error) {
policyData, err := os.ReadFile(*policyFile)
if err != nil {
return nil, fmt.Errorf("failed to read policy file: %w", err)
}
p, err := policy.NewPolicy(bytes.NewReader(policyData), *policySnippets)
p, err := policy.NewPolicy(bytes.NewReader(policyData), policySnippets...)
if err != nil {
return nil, fmt.Errorf("failed to parse policy file: %w", err)
}
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,
stateSettings := policy.StateSettings{
Cache: cache,
Backends: createdBackends,
MainName: internalMainName,
MainVersion: internalMainVersion,
BasePath: *basePath,
PrivateKeySeed: seed,
ClientIpHeader: *clientIpHeader,
BackendIpHeader: *backendIpHeader,
ChallengeResponseCode: http.StatusTeapot,
}
state, err := lib.NewState(*p, settings)
state, err := lib.NewState(*p, opt, stateSettings)
if err != nil {
return nil, fmt.Errorf("failed to create state: %w", err)
@@ -310,50 +274,35 @@ func main() {
if *check {
_, err := loadPolicyState()
if err != nil {
slog.Error(err.Error())
os.Exit(1)
fatal(err)
}
slog.Info("load ok")
os.Exit(0)
}
listener, listenUrl := setupListener(*bindNetwork, *bind, *socketMode, *bindProxy)
listener, listenUrl := opt.Bind.Listener()
slog.Warn(
"listening",
"url", listenUrl,
)
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)
server, swap, err := opt.Bind.Server(createdBackends, acmeCache)
if err != nil {
fatal(fmt.Errorf("failed to create server: %w", err))
}
server.ErrorLog = slog.NewLogLogger(slog.With("server", "http").Handler(), slog.LevelDebug)
go func() {
handler, err := loadPolicyState()
if err != nil {
log.Fatal(fmt.Errorf("failed to load policy state: %w", err))
fatal(fmt.Errorf("failed to load policy state: %w", err))
}
serverHandler.Store(&handler)
swap(handler)
slog.Warn(
"handler configuration loaded",
"key_fingerprint", hex.EncodeToString(handler.PrivateKeyFingerprint()),
)
// allow reloading from now on
@@ -363,24 +312,71 @@ func main() {
if sig != syscall.SIGHUP {
continue
}
oldHandler := handler
handler, err = loadPolicyState()
if err != nil {
slog.Error("handler configuration reload error", "err", err)
continue
}
serverHandler.Store(&handler)
swap(handler)
slog.Warn("handler configuration reloaded")
if oldHandler != nil {
_ = oldHandler.Close()
}
}
}()
if tlsConfig != nil {
if opt.BindDebug != "" {
go func() {
mux := http.NewServeMux()
mux.HandleFunc("/debug/pprof/", pprof.Index)
mux.HandleFunc("/debug/pprof/profile", pprof.Profile)
mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
mux.HandleFunc("/debug/pprof/trace", pprof.Trace)
debugServer := http.Server{
Addr: opt.BindDebug,
Handler: mux,
ErrorLog: slog.NewLogLogger(slog.With("server", "debug").Handler(), slog.LevelDebug),
}
slog.Warn(
"listening debug",
"bind", opt.BindDebug,
)
if err = debugServer.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
fatal(err)
}
}()
}
if opt.BindMetrics != "" {
go func() {
mux := http.NewServeMux()
mux.Handle("/metrics", promhttp.Handler())
metricsServer := http.Server{
Addr: opt.BindMetrics,
Handler: mux,
ErrorLog: slog.NewLogLogger(slog.With("server", "metrics").Handler(), slog.LevelDebug),
}
slog.Warn(
"listening metrics",
"bind", opt.BindMetrics,
)
if err = metricsServer.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
fatal(err)
}
}()
}
if server.TLSConfig != nil {
if err := server.ServeTLS(listener, "", ""); !errors.Is(err, http.ErrServerClosed) {
log.Fatal(err)
fatal(err)
}
} else {
if err := server.Serve(listener); !errors.Is(err, http.ErrServerClosed) {
log.Fatal(err)
fatal(err)
}
}

26
docker-entrypoint.sh Executable file
View File

@@ -0,0 +1,26 @@
#!/bin/sh
set -e
if [ $# -eq 0 ] || [ "${1#-}" != "$1" ]; then
set -- /bin/go-away \
--bind "${GOAWAY_BIND}" --bind-network "${GOAWAY_BIND_NETWORK}" --socket-mode "${GOAWAY_SOCKET_MODE}" \
--metrics-bind "${GOAWAY_METRICS_BIND}" --debug-bind "${GOAWAY_DEBUG_BIND}" \
--config "${GOAWAY_CONFIG}" \
--policy "${GOAWAY_POLICY}" --policy-snippets "/snippets" --policy-snippets "${GOAWAY_POLICY_SNIPPETS}" \
--client-ip-header "${GOAWAY_CLIENT_IP_HEADER}" --backend-ip-header "${GOAWAY_BACKEND_IP_HEADER}" \
--cache "${GOAWAY_CACHE}" \
--challenge-template "${GOAWAY_CHALLENGE_TEMPLATE}" \
--challenge-template-logo "${GOAWAY_CHALLENGE_TEMPLATE_LOGO}" \
--challenge-template-theme "${GOAWAY_CHALLENGE_TEMPLATE_THEME}" \
--slog-level "${GOAWAY_SLOG_LEVEL}" \
--acme-autocert "${GOAWAY_ACME_AUTOCERT}" \
--backend "${GOAWAY_BACKEND}" \
"$@"
fi
if [ "$1" = "go-away" ]; then
shift
set -- /bin/go-away "$@"
fi
exec "$@"

View File

@@ -103,3 +103,17 @@ footer {
padding: 0.5em 10px;
}
}
body,
html {
height: 100%;
display: flex;
justify-content: center;
align-items: center;
margin-left: auto;
margin-right: auto;
}
.centered-div {
text-align: center;
}

View File

@@ -1,145 +1,20 @@
<!DOCTYPE html>
{{$logo := print .Path "/assets/static/logo.png?cacheBust=" .Random }}{{ if .Logo }}{{$logo = .Logo}}{{ end }}
<html>
<head>
<title>{{ .Title }}</title>
<link rel="stylesheet" href="{{ .Path }}/assets/static/anubis/style.css?cacheBust={{ .Random }}"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
{{ range $key, $value := .Meta }}
{{ if eq $key "refresh"}}
<meta http-equiv="{{ $key }}" content="{{ $value }}"/>
{{else}}
<meta name="{{ $key }}" content="{{ $value }}"/>
{{end}}
<meta name="referrer" content="origin"/>
{{ range .MetaTags }}
<meta {{ range $key, $value := . }}{{ $key | attr }}="{{ $value }}" {{end}}/>
{{ end }}
{{ range .LinkTags }}
<link {{ range $key, $value := . }}{{ $key | attr }}="{{ $value }}" {{end}}/>
{{ end }}
{{ range .HeaderTags }}
{{ . }}
{{ . }}
{{ end }}
<style>
body,
html {
height: 100%;
display: flex;
justify-content: center;
align-items: center;
margin-left: auto;
margin-right: auto;
}
.centered-div {
text-align: center;
}
.lds-roller,
.lds-roller div,
.lds-roller div:after {
box-sizing: border-box;
}
.lds-roller {
display: inline-block;
position: relative;
width: 80px;
height: 80px;
}
.lds-roller div {
animation: lds-roller 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite;
transform-origin: 40px 40px;
}
.lds-roller div:after {
content: " ";
display: block;
position: absolute;
width: 7.2px;
height: 7.2px;
border-radius: 50%;
background: currentColor;
margin: -3.6px 0 0 -3.6px;
}
.lds-roller div:nth-child(1) {
animation-delay: -0.036s;
}
.lds-roller div:nth-child(1):after {
top: 62.62742px;
left: 62.62742px;
}
.lds-roller div:nth-child(2) {
animation-delay: -0.072s;
}
.lds-roller div:nth-child(2):after {
top: 67.71281px;
left: 56px;
}
.lds-roller div:nth-child(3) {
animation-delay: -0.108s;
}
.lds-roller div:nth-child(3):after {
top: 70.90963px;
left: 48.28221px;
}
.lds-roller div:nth-child(4) {
animation-delay: -0.144s;
}
.lds-roller div:nth-child(4):after {
top: 72px;
left: 40px;
}
.lds-roller div:nth-child(5) {
animation-delay: -0.18s;
}
.lds-roller div:nth-child(5):after {
top: 70.90963px;
left: 31.71779px;
}
.lds-roller div:nth-child(6) {
animation-delay: -0.216s;
}
.lds-roller div:nth-child(6):after {
top: 67.71281px;
left: 24px;
}
.lds-roller div:nth-child(7) {
animation-delay: -0.252s;
}
.lds-roller div:nth-child(7):after {
top: 62.62742px;
left: 17.37258px;
}
.lds-roller div:nth-child(8) {
animation-delay: -0.288s;
}
.lds-roller div:nth-child(8):after {
top: 56px;
left: 12.28719px;
}
@keyframes lds-roller {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>
</head>
<body id="top">
<main>
@@ -151,46 +26,32 @@
<img
id="image"
style="width:100%;max-width:256px;"
src="{{ .Path }}/assets/static/logo.png?cacheBust={{ .Random }}"
src="{{ $logo }}"
/>
{{if .Challenge }}
<p id="status">Loading challenge <em>{{ .Challenge }}</em>...</p>
<p id="status">{{ .Strings.Get "status_loading_challenge" }} <em>{{ .Challenge }}</em>...</p>
{{else if .Error}}
<p id="status">Error: {{ .Error }}</p>
<p id="status">{{ .Strings.Get "status_error" }} {{ .Error }}</p>
{{else}}
<p id="status">Loading...</p>
<p id="status">{{ .Strings.Get "status_loading" }}</p>
{{end}}
{{if not .HideSpinner }}
<div id="spinner" class="lds-roller">
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
</div>
{{end}}
<details style="padding-bottom: 2em;">
<summary>Why am I seeing this?</summary>
<p>You are seeing this because the administrator of this website has set up <a href="https://git.gammaspectra.live/git/go-away">go-away</a> to protect the server against the scourge of <a href="https://thelibre.news/foss-infrastructure-is-under-attack-by-ai-companies/">AI companies aggressively scraping websites</a>. This can and does cause downtime for the websites, which makes their resources inaccessible for everyone.</p>
<p>Please note that some challenges requires the use of modern JavaScript features and some plugins may will disable. Please disable such plugins for this domain (for example, JShelter).</p>
<p>If you have any issues contact the administrator and provide this Request Id: <em>{{ .Id }}</em></p>
<details>
<summary>{{ .Strings.Get "details_title" }}</summary>
{{.Strings.Get "details_text"}}
</details>
<noscript>
<p>
Sadly, you may need to enable JavaScript to get past this challenge. This is required because AI companies have changed
the social contract around how website hosting works.
</p>
</noscript>
{{if .Redirect }}
<a role="button" href="{{ .Redirect }}">Refresh page</a>
<a style="margin-top: 2em; margin-bottom: 2em;" role="button" href="{{ .Redirect }}">{{ .Strings.Get "button_refresh_page" }}</a>
{{end}}
<div id="testarea"></div>
{{if .EndTags }}
<noscript>
{{ .Strings.Get "noscript_warning" }}
</noscript>
{{end}}
<p><small>{{ .Strings.Get "details_contact_admin_with_request_id" }}: <em>{{ .Id }}</em></small></p>
</div>
@@ -198,6 +59,10 @@
<center>
<p>
Protected by <a href="https://git.gammaspectra.live/git/go-away">go-away</a> :: Request Id <em>{{ .Id }}</em>
{{ range .Links }}
:: <a href="{{ .URL }}">{{ .Name }}</a>
{{ end }}
</p>
</center>
</footer>

View File

@@ -1,24 +1,20 @@
<!DOCTYPE html>
{{$theme := "forgejo-auto"}}
{{ if .Theme }}
{{$theme = .Theme}}
{{ end }}
{{$theme := "forgejo-auto"}}{{ if .Theme }}{{$theme = .Theme}}{{ end }}
{{$logo := "/assets/img/logo.png"}}{{ if .Logo }}{{$logo = .Logo}}{{ end }}
<html lang="en-US" data-theme="{{ $theme }}">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ .Title }}</title>
<meta name="referrer" content="no-referrer">
{{ range $key, $value := .Meta }}
{{ if eq $key "refresh"}}
<meta http-equiv="{{ $key }}" content="{{ $value }}"/>
{{else}}
<meta name="{{ $key }}" content="{{ $value }}"/>
{{end}}
<meta name="referrer" content="origin">
{{ range .MetaTags }}
<meta {{ range $key, $value := . }}{{ $key | attr }}="{{ $value }}" {{end}}/>
{{ end }}
{{ range .LinkTags }}
<link {{ range $key, $value := . }}{{ $key | attr }}="{{ $value }}" {{end}}/>
{{ end }}
{{ range .HeaderTags }}
{{ . }}
{{ . }}
{{ end }}
@@ -53,7 +49,7 @@
<div class="ui stackable middle very relaxed page grid">
<div class="sixteen wide center aligned centered column">
<div>
<img class="logo" id="image" src="/assets/img/logo.png" />
<img class="logo" id="image" src="{{ $logo }}" />
</div>
<div class="hero">
<h2 class="ui icon header title" id="title">
@@ -61,36 +57,32 @@
</h2>
{{if .Challenge }}
<h3 id="status">Loading challenge <em>{{ .Challenge }}</em>...</h3>
<h3 id="status">{{ .Strings.Get "status_loading_challenge" }} <em>{{ .Challenge }}</em>...</h3>
{{else if .Error}}
<h3 id="status">Error: {{ .Error }}</h3>
<h3 id="status">{{ .Strings.Get "status_error" }} {{ .Error }}</h3>
{{else}}
<h3 id="status">Loading...</h3>
<h3 id="status">{{ .Strings.Get "status_loading" }}</h3>
{{end}}
<div id="spinner"></div>
<details style="padding-bottom: 2em;">
<summary>Why am I seeing this?</summary>
<p>You are seeing this because the administrator of this website has set up <a href="https://git.gammaspectra.live/git/go-away">go-away</a> to protect the server against the scourge of <a href="https://thelibre.news/foss-infrastructure-is-under-attack-by-ai-companies/">AI companies aggressively scraping websites</a>. This can and does cause downtime for the websites, which makes their resources inaccessible for everyone.</p>
<p>Please note that some challenges requires the use of modern JavaScript features and some plugins may will disable. Please disable such plugins for this domain (for example, JShelter).</p>
<p>If you have any issues contact the administrator and provide the Request Id: <em>{{ .Id }}</em></p>
<details>
<summary>{{ .Strings.Get "details_title" }}</summary>
{{.Strings.Get "details_text"}}
</details>
<noscript>
<p>
Sadly, you may need to enable JavaScript to get past this challenge. This is required because AI companies have changed
the social contract around how website hosting works.
</p>
</noscript>
{{if .Redirect }}
<div class="button-row">
<a role="button" class="ui small primary button" href="{{ .Redirect }}">Refresh page</a>
</div>
<div class="button-row" style="margin-top: 2em; margin-bottom: 2em;" >
<a role="button" class="ui small primary button" href="{{ .Redirect }}">{{ .Strings.Get "button_refresh_page" }}</a>
</div>
{{end}}
{{if .EndTags }}
<noscript>
{{ .Strings.Get "noscript_warning" }}
</noscript>
{{end}}
<div id="testarea"></div>
<p><small>{{ .Strings.Get "details_contact_admin_with_request_id" }}: <em>{{ .Id }}</em></small></p>
</div>
</div>
</div>
@@ -106,6 +98,9 @@
<footer class="page-footer" role="group" aria-label="">
<div class="left-links" role="contentinfo" aria-label="">
Protected by <a href="https://git.gammaspectra.live/git/go-away">go-away</a> :: Request Id <em>{{ .Id }}</em>
{{ range .Links }}
:: <a href="{{ .URL }}">{{ .Name }}</a>
{{ end }}
</div>
</footer>

117
examples/config.yml Normal file
View File

@@ -0,0 +1,117 @@
# Configuration file
# Parameters that exist both on config and cmdline will have cmdline as preference
bind:
#address: ":8080"
#network: "tcp"
#socket-mode": "0770"
# Enable PROXY mode on this listener, to allow passing origin info. Default false
#proxy: true
# Enable passthrough mode, which will allow traffic onto the backends while rules load. Default false
#passthrough: true
# Enable TLS on this listener and obtain certificates via an ACME directory URL, or letsencrypt
#tls-acme-autocert: "letsencrypt"
# Enable TLS on this listener and obtain certificates via a certificate and key file on disk
# Only set one of tls-acme-autocert or tls-certificate+tls-key
#tls-certificate: ""
#tls-key: ""
# Bind the Go debug port
#bind-debug: ":6060"
# Bind the Prometheus metrics onto /metrics path on this port
#bind-metrics: ":9090"
# These links will be shown on the presented challenge or error pages
links:
#- name: Privacy
# url: "/privacy.html"
#- name: Contact
# url: "mailto:admin@example.com"
#- name: Donations
# url: "https://donations.example.com/abcd"
# HTML Template to use for challenge or error pages
# External templates can be included by providing a disk path
# Bundled templates:
# anubis: An Anubis-like template with no configuration parameters. Supports Logo.
# forgejo: Looks like native Forgejo. Includes logos and resources from your instance. Supports Theme, Logo.
#
#challenge-template: "anubis"
# Allows overriding specific settings set on templates. Key-Values will be passed to templates as-is
challenge-template-overrides:
# Set template theme if supported
#Theme: "forgejo-auto"
# Set logo on template if supported
#Logo: "/my/custom/logo/path.png"
# Advanced backend configuration
# Backends setup via cmdline will be added here
backends:
# Example HTTP backend and setting client ip header
#"git.example.com":
# url: "http://forgejo:3000"
# ip-header: "X-Client-Ip"
# Example HTTP backend matching a non-standard port in Host
# Standard ports are 80 and 443. Others will be sent in Host by browsers
#"git.example.com:8080":
# url: "http://forgejo:3000"
# ip-header: "X-Client-Ip"
# Example HTTPS backend with host/SNI override, HTTP/2 and no certificate verification
#"ssl.example.com":
# url: "https://127.0.0.1:8443"
# host: ssl.example.com
# http2-enabled: true
# tls-skip-verify: true
# Example HTTPS transparent backend with host/SNI override, HTTP/2, and subdirectory
#"ssl.example.com":
# url: "https://ssl.example.com/subdirectory/"
# host: ssl.example.com
# http2-enabled: true
# ip-header: "-"
# transparent: true
# List of strings you can replace to alter the presentation on challenge/error templates
# Can use other languages.
# Note raw HTML is allowed, be careful with it.
# Default strings exist in code, uncomment any to set it
strings:
#title_challenge: "Checking you are not a bot"
#title_error: "Oh no!"
#noscript_warning: "<p>Sadly, you may need to enable JavaScript to get past this challenge. This is required because AI companies have changed the social contract around how website hosting works.</p>"
#details_title: "Why am I seeing this?"
#details_text: >
# <p>
# You are seeing this because the administrator of this website has set up <a href="https://git.gammaspectra.live/git/go-away">go-away</a>
# to protect the server against the scourge of <a href="https://thelibre.news/foss-infrastructure-is-under-attack-by-ai-companies/">AI companies aggressively scraping websites</a>.
# </p>
# <p>
# Mass scraping can and does cause downtime for the websites, which makes their resources inaccessible for everyone.
# </p>
# <p>
# Please note that some challenges requires the use of modern JavaScript features and some plugins may disable these.
# Disable such plugins for this domain (for example, JShelter) if you encounter any issues.
# </p>
#details_contact_admin_with_request_id: "If you have any issues contact the site administrator and provide the following Request Id"
#button_refresh_page: "Refresh page"
#status_loading_challenge: "Loading challenge"
#status_starting_challenge: "Starting challenge"
#status_loading: "Loading..."
#status_calculating: "Calculating..."
#status_challenge_success: "Challenge success!"
#status_challenge_done_took: "Done! Took"
#status_error: "Error:"

View File

@@ -50,7 +50,7 @@ conditions:
is-suspicious-crawler:
# TLS Fingerprint for specific agent without ALPN
- '(userAgent.startsWith("Mozilla/") || userAgent.startsWith("Opera/")) && ("ja4" in fp && fp.ja4.matches("^t[0-9a-z]+00_"))'
- '(userAgent.startsWith("Mozilla/") || userAgent.startsWith("Opera/")) && ("ja4" in fp && fp.ja4.matches("^t[0-9a-z]+00_")) && !(userAgent.contains("compatible;") || userAgent.contains("+http") || userAgent.contains("facebookexternalhit/") || userAgent.contains("Twitterbot/"))'
# Old engines
- 'userAgent.contains("Presto/") || userAgent.contains("Trident/")'
# Old IE browsers
@@ -75,12 +75,14 @@ conditions:
- 'path.matches("^/[^/]+/[^/]+/search/")'
- 'path.matches("^/[^/]+/[^/]+/find/")'
- 'path.matches("^/[^/]+/[^/]+/activity")'
- 'path.matches("^/[^/]+/[^/]+/graph$")'
# any search with a custom query
- '"q" in query && query.q != ""'
# user activity tab
- 'path.matches("^/[^/]+$") && "tab" in query && query.tab == "activity"'
# Rules are checked sequentially in order, from top to bottom
rules:
- name: allow-well-known-resources
conditions:
@@ -92,6 +94,25 @@ rules:
- '($is-static-asset)'
action: pass
- name: desired-crawlers
conditions:
- *is-bot-googlebot
- *is-bot-bingbot
- *is-bot-duckduckbot
- *is-bot-kagibot
- *is-bot-qwantbot
- *is-bot-yandexbot
action: pass
# Matches private networks and localhost.
# Uncomment this if you want to let your own tools this way
# - name: allow-private-networks
# conditions:
# # Allows localhost and private networks CIDR
# - *is-network-localhost
# - *is-network-private
# action: pass
- name: undesired-networks
conditions:
- 'remoteAddress.network("huawei-cloud") || remoteAddress.network("alibaba-cloud") || remoteAddress.network("zenlayer-inc")'
@@ -106,7 +127,7 @@ rules:
- 'userAgent.matches("^Opera/[0-9.]+\\.\\(")'
# AI bullshit stuff, they do not respect robots.txt even while they read it
# TikTok Bytedance AI training
- 'userAgent.contains("Bytedance") || userAgent.contains("Bytespider")'
- 'userAgent.contains("Bytedance") || userAgent.contains("Bytespider") || userAgent.contains("TikTokSpider")'
# Meta AI training; The Meta-ExternalAgent crawler crawls the web for use cases such as training AI models or improving products by indexing content directly.
- 'userAgent.contains("meta-externalagent/") || userAgent.contains("meta-externalfetcher/") || userAgent.contains("FacebookBot")'
# Anthropic AI training and usage
@@ -135,7 +156,7 @@ rules:
- name: 0
action: check
settings:
challenges: [js-pow-sha256, http-cookie-check]
challenges: [js-refresh, http-cookie-check]
- name: 1
action: check
settings:
@@ -161,11 +182,12 @@ rules:
- 'path.matches("^/[^/]+/[^/]+/archive/.*\\.(bundle|zip|tar\\.gz)") && ($is-generic-browser)'
action: challenge
settings:
challenges: [ js-pow-sha256 ]
challenges: [ js-refresh ]
- name: allow-git-operations
conditions:
- '($is-git-path)'
# Includes repository and wiki git endpoints
- 'path.matches("^/[^/]+/[^/]+\\.git")'
- 'path.matches("^/[^/]+/[^/]+/") && ($is-git-ua)'
action: pass
@@ -196,12 +218,13 @@ rules:
# OCI packages API and package managers
- 'path.startsWith("/api/packages/") || path == "/api/packages"'
- 'path.startsWith("/v2/") || path == "/v2"'
- 'path.endsWith("/branches/list") || path.endsWith("/tags/list")'
action: pass
- name: preview-fetchers
conditions:
# These summary cards are included in most previews at the end of the url
- 'path.endsWith("/-/summary-card")'
- 'path.endsWith("/-/summary-card") || path.matches("^/[^/]+/[^/]+/releases/summary-card/[^/]+$")'
#- 'userAgent.contains("facebookexternalhit/")'
#- 'userAgent.contains("Twitterbot/")'
action: pass
@@ -220,16 +243,6 @@ rules:
- '(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:
- *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
conditions: ['($is-heavy-resource)']
@@ -238,18 +251,11 @@ rules:
- name: 0
action: check
settings:
challenges: [preload-link, header-refresh, js-pow-sha256, http-cookie-check]
challenges: [preload-link, header-refresh, js-refresh, http-cookie-check]
- name: 1
action: check
settings:
challenges: [ resource-load, js-pow-sha256, http-cookie-check ]
- name: standard-bots
action: check
settings:
challenges: [meta-refresh, resource-load]
conditions:
- '($is-generic-robot-ua)'
challenges: [ resource-load, js-refresh, http-cookie-check ]
# Allow all source downloads not caught in browser above
# todo: limit this as needed?
@@ -270,14 +276,7 @@ rules:
# if DNSBL fails, check additional challenges
fail: check
fail-settings:
challenges: [js-pow-sha256, http-cookie-check]
- name: suspicious-fetchers
action: check
settings:
challenges: [js-pow-sha256]
conditions:
- 'userAgent.contains("facebookexternalhit/") || userAgent.contains("facebookcatalog/")'
challenges: [js-refresh, http-cookie-check]
# Allow PUT/DELETE/PATCH/POST requests in general
- name: non-get-request
@@ -285,6 +284,26 @@ rules:
conditions:
- '!(method == "HEAD" || method == "GET")'
# Enable fetching OpenGraph and other tags from backend on these paths
- name: enable-meta-tags
action: context
conditions:
- 'userAgent.contains("facebookexternalhit/") || userAgent.contains("Facebot/") || userAgent.contains("Twitterbot/")'
- '($is-generic-robot-ua)'
- '!($is-generic-browser)'
settings:
context-set:
# Map OpenGraph or similar <meta> tags back to the reply, even if denied/challenged
proxy-meta-tags: "true"
# proxy-safe-link-tags: "true"
# Set additional response headers
#response-headers:
# X-Clacks-Overhead:
# - GNU Terry Pratchett
- name: plaintext-browser
action: challenge
settings:
@@ -292,6 +311,7 @@ rules:
conditions:
- 'userAgent.startsWith("Lynx/")'
# Comment this rule out to not challenge tool-like user agents
- name: standard-tools
action: challenge
settings:
@@ -303,6 +323,8 @@ rules:
- name: standard-browser
action: challenge
settings:
challenges: [http-cookie-check, preload-link, meta-refresh, resource-load, js-pow-sha256]
challenges: [http-cookie-check, preload-link, meta-refresh, resource-load, js-refresh, js-pow-sha256]
conditions:
- '($is-generic-browser)'
# If end of rules is reached, default is PASS

View File

@@ -10,7 +10,7 @@ networks:
challenges:
# Challenges will get included from snippets
conditions:
# Conditions will get replaced on rules AST when found as ($condition-name)
@@ -27,7 +27,7 @@ conditions:
# Old IE browsers
- 'userAgent.matches("MSIE ([2-9]|10|11)\\.")'
# Old Linux browsers
- 'userAgent.contains("Linux i[63]86") || userAgent.contains("FreeBSD i[63]86")'
- 'userAgent.matches("Linux i[63]86") || userAgent.matches("FreeBSD i[63]86")'
# Old Windows browsers
- 'userAgent.matches("Windows (3|95|98|CE)") || userAgent.matches("Windows NT [1-5]\\.")'
# Old mobile browsers
@@ -38,7 +38,7 @@ conditions:
- 'userAgent.matches("^Mozilla/[1-4]")'
# Rules are checked sequentially in order, from top to bottom
rules:
- name: allow-well-known-resources
conditions:
@@ -50,6 +50,25 @@ rules:
- '($is-static-asset)'
action: pass
- name: desired-crawlers
conditions:
- *is-bot-googlebot
- *is-bot-bingbot
- *is-bot-duckduckbot
- *is-bot-kagibot
- *is-bot-qwantbot
- *is-bot-yandexbot
action: pass
# Matches private networks and localhost.
# Uncomment this if you want to let your own tools this way
# - name: allow-private-networks
# conditions:
# # Allows localhost and private networks CIDR
# - *is-network-localhost
# - *is-network-private
# action: pass
- name: undesired-crawlers
conditions:
- '($is-headless-chromium)'
@@ -59,7 +78,7 @@ rules:
- 'userAgent.matches("^Opera/[0-9.]+\\.\\(")'
# AI bullshit stuff, they do not respect robots.txt even while they read it
# TikTok Bytedance AI training
- 'userAgent.contains("Bytedance") || userAgent.contains("Bytespider")'
- 'userAgent.contains("Bytedance") || userAgent.contains("Bytespider") || userAgent.contains("TikTokSpider")'
# Meta AI training; The Meta-ExternalAgent crawler crawls the web for use cases such as training AI models or improving products by indexing content directly.
- 'userAgent.contains("meta-externalagent/") || userAgent.contains("meta-externalfetcher/") || userAgent.contains("FacebookBot")'
# Anthropic AI training and usage
@@ -88,7 +107,7 @@ rules:
- name: 0
action: check
settings:
challenges: [js-pow-sha256]
challenges: [js-refresh]
- name: 1
action: check
settings:
@@ -98,16 +117,6 @@ rules:
settings:
challenges: [header-refresh]
- name: desired-crawlers
conditions:
- *is-bot-googlebot
- *is-bot-bingbot
- *is-bot-duckduckbot
- *is-bot-kagibot
- *is-bot-qwantbot
- *is-bot-yandexbot
action: pass
- name: homesite
conditions:
- 'path == "/"'
@@ -122,12 +131,12 @@ rules:
# if DNSBL fails, check additional challenges
fail: check
fail-settings:
challenges: [js-pow-sha256]
challenges: [js-refresh]
- name: suspicious-fetchers
action: check
settings:
challenges: [js-pow-sha256]
challenges: [js-refresh]
conditions:
- 'userAgent.contains("facebookexternalhit/") || userAgent.contains("facebookcatalog/")'
@@ -137,6 +146,19 @@ rules:
conditions:
- '!(method == "HEAD" || method == "GET")'
# Enable fetching OpenGraph and other tags from backend on these paths
- name: enable-meta-tags
action: context
settings:
context-set:
# Map OpenGraph or similar <meta> tags back to the reply, even if denied/challenged
proxy-meta-tags: "true"
# Set additional response headers
#response-headers:
# X-Clacks-Overhead:
# - GNU Terry Pratchett
- name: plaintext-browser
action: challenge
settings:
@@ -144,18 +166,21 @@ rules:
conditions:
- 'userAgent.startsWith("Lynx/")'
- name: standard-tools
action: challenge
settings:
challenges: [cookie]
conditions:
- '($is-generic-robot-ua)'
- '($is-tool-ua)'
- '!($is-generic-browser)'
# Uncomment this rule out to challenge tool-like user agents
#- name: standard-tools
# action: challenge
# settings:
# challenges: [cookie]
# conditions:
# - '($is-generic-robot-ua)'
# - '($is-tool-ua)'
# - '!($is-generic-browser)'
- name: standard-browser
action: challenge
settings:
challenges: [preload-link, meta-refresh, resource-load, js-pow-sha256]
challenges: [preload-link, meta-refresh, resource-load, js-refresh]
conditions:
- '($is-generic-browser)'
# If end of rules is reached, default is PASS

View File

@@ -0,0 +1,8 @@
networks:
betterstack:
- url: https://uptime.betterstack.com/ips-by-cluster.json
jq-path: '.[] | .[]'
conditions:
is-bot-betterstack:
- &is-bot-betterstack '((userAgent.startsWith("Better Stack Better Uptime Bot") || userAgent.startsWith("Better Uptime Bot") || userAgent == "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.17 Safari/537.36")) && remoteAddress.network("betterstack")'

View File

@@ -0,0 +1,8 @@
networks:
uptimerobot:
- url: https://uptimerobot.com/inc/files/ips/IPv4andIPv6.txt
regex: "(?P<prefix>[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+(/[0-9]+)?|[0-9a-f:]+:.+)"
conditions:
is-bot-uptimerobot:
- &is-bot-uptimerobot 'userAgent.contains("http://www.uptimerobot.com/") && remoteAddress.network("uptimerobot")'

View File

@@ -0,0 +1,6 @@
challenges:
js-refresh:
# Challenges with a redirect via window.location (requires HTML parsing and JavaScript logic)
runtime: "refresh"
parameters:
refresh-via: "javascript"

View File

@@ -9,7 +9,7 @@ challenges:
condition: '"Sec-Fetch-Mode" in headers && headers["Sec-Fetch-Mode"] == "navigate"'
runtime: "preload-link"
parameters:
preload-early-hint-deadline: 3s
preload-early-hint-deadline: 2s
# Challenges with a redirect via Refresh header (non-JS, requires HTTP parsing and logic)
header-refresh:

View File

@@ -1,8 +1,19 @@
conditions:
is-well-known-asset:
- 'path == "/robots.txt"'
# general txt files or scraper
- 'path == "/robots.txt" || path == "/security.txt"'
# ads txt files
- 'path == "/app-ads.txt" || path == "/ads.txt"'
# generally requested by browsers
- 'path == "/favicon.ico"'
- 'path.startsWith("/.well-known")'
# used by some applications
- 'path == "/crossdomain.xml"'
# well-known paths
- 'path.startsWith("/.well-known/")'
is-git-ua:
- 'userAgent.startsWith("git/") || userAgent.contains("libgit")'

View File

@@ -1,37 +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]+),"
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]+),"

View File

@@ -0,0 +1,22 @@
networks:
localhost:
# localhost and loopback addresses
- prefixes:
- "127.0.0.0/8"
- "::1/128"
private:
# Private network CIDR blocks
- prefixes:
# private networks
- "10.0.0.0/8"
- "172.16.0.0/12"
- "192.168.0.0/16"
- "fc00::/7"
# CGNAT
- "100.64.0.0/10"
conditions:
is-network-localhost:
- &is-network-localhost 'remoteAddress.network("localhost")'
is-network-private:
- &is-network-private 'remoteAddress.network("private")'

87
examples/spa.yml Normal file
View File

@@ -0,0 +1,87 @@
# Example cmdline (forward requests from upstream to port :8080)
# $ go-away --bind :8080 --backend site.example.com=http://site:3000 --policy examples/spa.yml --policy-snippets example/snippets/ --challenge-template anubis
# Define networks to be used later below
networks:
# Networks will get included from snippets
challenges:
# Challenges will get included from snippets
conditions:
# Conditions will get replaced on rules AST when found as ($condition-name)
is-static-asset:
- '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)$")'
# Add other paths where you have static assets
# - 'path.startsWith("/static/") || path.startsWith("/assets/")'
# Rules are checked sequentially in order, from top to bottom
rules:
- name: allow-well-known-resources
conditions:
- '($is-well-known-asset)'
action: pass
- name: allow-static-resources
conditions:
- '($is-static-asset)'
action: pass
- name: unknown-crawlers
conditions:
# No user agent set
- 'userAgent == ""'
action: deny
# Enable fetching OpenGraph and other tags from backend on index
- name: enable-meta-tags
action: context
conditions:
- 'path == "/" || path == "/index.html"'
settings:
context-set:
# Map OpenGraph or similar <meta> tags back to the reply, even if denied/challenged
proxy-meta-tags: "true"
# Challenge incoming visitors so challenge is remembered on api endpoints
# API requests will have this challenge stored
- name: index
conditions:
- 'path == "/" || path == "/index.html"'
settings:
challenges: [ preload-link, header-refresh ]
action: challenge
# Allow PUT/DELETE/PATCH/POST requests in general
- name: non-get-request
action: pass
conditions:
- '!(method == "HEAD" || method == "GET")'
# Challenge rest of endpoints (SPA API etc.)
# Above rule on index ensures clients have passed a challenge beforehand
- name: standard-browser
action: challenge
settings:
challenges: [ preload-link, header-refresh ]
# Fallback on cookie challenge
fail: challenge
fail-settings:
challenges: [ cookie ]
conditions:
- '($is-generic-browser)'
- name: other-fetchers
action: challenge
settings:
challenges: [ cookie ]
conditions:
- '!($is-generic-browser)'

9
go.mod
View File

@@ -5,6 +5,7 @@ go 1.24.0
toolchain go1.24.2
require (
codeberg.org/gone/http-cel v1.0.0
codeberg.org/meta/gzipped/v2 v2.0.0-20231111234332-aa70c3194756
github.com/alphadose/haxmap v1.4.1
github.com/go-jose/go-jose/v4 v4.1.0
@@ -12,6 +13,7 @@ require (
github.com/google/cel-go v0.25.0
github.com/itchyny/gojq v0.12.17
github.com/pires/go-proxyproto v0.8.0
github.com/prometheus/client_golang v1.22.0
github.com/tetratelabs/wazero v1.9.0
github.com/yl2chen/cidranger v1.0.2
golang.org/x/crypto v0.37.0
@@ -20,11 +22,18 @@ require (
require (
cel.dev/expr v0.23.1 // indirect
github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/itchyny/timefmt-go v0.1.6 // indirect
github.com/kevinpollet/nego v0.0.0-20211010160919-a65cd48cee43 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.63.0 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
github.com/stoewer/go-strcase v1.3.0 // indirect
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
golang.org/x/net v0.39.0 // indirect
golang.org/x/sys v0.32.0 // indirect
golang.org/x/text v0.24.0 // 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

28
go.sum
View File

@@ -1,11 +1,17 @@
cel.dev/expr v0.23.1 h1:K4KOtPCJQjVggkARsjG9RWXP6O4R73aHeJMa/dmCQQg=
cel.dev/expr v0.23.1/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw=
codeberg.org/gone/http-cel v1.0.0 h1:flEv/KzEye4W7vjwkdAkwo7VCbuj9xZLjyTn/rjWFDQ=
codeberg.org/gone/http-cel v1.0.0/go.mod h1:uRkxygsQp5EFE3e9dRkJ4HK453G5YZDHCq9DEG5CoDw=
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/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/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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=
@@ -13,8 +19,6 @@ github.com/go-jose/go-jose/v4 v4.1.0 h1:cYSYxd3pw5zd2FSXk2vGdn9igQU2PS8MuxrCOCl0
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=
@@ -25,10 +29,24 @@ 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/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/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=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.63.0 h1:YR/EIY1o3mEFP/kZCD7iDMnLPlGyuU2Gb3HIcXnA98k=
github.com/prometheus/common v0.63.0/go.mod h1:VVFF/fBIoToEnWRVkYoXEkq3R3paCoxG9PXP74SnV18=
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
github.com/stoewer/go-strcase v1.3.0 h1:g0eASXYtp+yvN9fK8sH94oCIk0fau9uV1/ZdJ0AVEzs=
github.com/stoewer/go-strcase v1.3.0/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ -50,14 +68,12 @@ golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5Z
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
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=

View File

@@ -28,7 +28,10 @@ func (a Block) Handle(logger *slog.Logger, w http.ResponseWriter, r *http.Reques
data := challenge.RequestDataFromContext(r.Context())
w.Header().Set("Content-Type", "text/plain")
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
w.Header().Set("Connection", "close")
data.ResponseHeaders(w)
w.WriteHeader(a.Code)
_, _ = w.Write([]byte(fmt.Errorf("access blocked: blocked by administrative rule %s/%s", data.Id.String(), a.RuleHash).Error()))

View File

@@ -43,7 +43,8 @@ func init() {
return nil, fmt.Errorf("no registered challenges found in rule %s", ruleName)
}
passHandler, ok := Register[policy.RuleAction(strings.ToUpper(params.PassAction))]
passAction := policy.RuleAction(strings.ToUpper(params.PassAction))
passHandler, ok := Register[passAction]
if !ok {
return nil, fmt.Errorf("unknown pass action %s", params.PassAction)
}
@@ -53,7 +54,8 @@ func init() {
return nil, err
}
failHandler, ok := Register[policy.RuleAction(strings.ToUpper(params.FailAction))]
failAction := policy.RuleAction(strings.ToUpper(params.FailAction))
failHandler, ok := Register[failAction]
if !ok {
return nil, fmt.Errorf("unknown pass action %s", params.FailAction)
}
@@ -69,8 +71,10 @@ func init() {
Continue: cont,
Challenges: regs,
PassAction: passActionHandler,
FailAction: failActionHandler,
PassAction: passAction,
PassActionHandler: passActionHandler,
FailAction: failAction,
FailActionHandler: failActionHandler,
}, nil
}
Register[policy.RuleActionCHALLENGE] = func(state challenge.StateInterface, ruleName, ruleHash string, settings ast.Node) (Handler, error) {
@@ -104,20 +108,26 @@ type Challenge struct {
Continue bool
Challenges []*challenge.Registration
PassAction Handler
FailAction Handler
PassAction policy.RuleAction
PassActionHandler Handler
FailAction policy.RuleAction
FailActionHandler 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()) {
data.State.ChallengeChecked(r, reg, r.URL.String(), logger)
if a.Continue {
return true, nil
}
// we passed!
return a.PassAction.Handle(logger.With("challenge", reg.Name), w, r, done)
data.State.ActionHit(r, a.PassAction, logger)
return a.PassActionHandler.Handle(logger.With("challenge", reg.Name), w, r, done)
}
}
// none matched, issue challenges in sequential priority
@@ -132,8 +142,10 @@ func (a Challenge) Handle(logger *slog.Logger, w http.ResponseWriter, r *http.Re
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)
if result != challenge.VerifyResultSkip {
data.State.ChallengeIssued(r, reg, r.URL.String(), logger)
}
data.ChallengeVerify[reg.Id()] = result
data.ChallengeState[reg.Id()] = challenge.VerifyStatePass
switch result {
@@ -143,7 +155,8 @@ func (a Challenge) Handle(logger *slog.Logger, w http.ResponseWriter, r *http.Re
return true, nil
}
return a.PassAction.Handle(logger.With("challenge", reg.Name), w, r, done)
data.State.ActionHit(r, a.PassAction, logger)
return a.PassActionHandler.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
@@ -157,7 +170,8 @@ func (a Challenge) Handle(logger *slog.Logger, w http.ResponseWriter, r *http.Re
continue
}
return a.FailAction.Handle(logger, w, r, done)
data.State.ActionHit(r, a.FailAction, logger)
return a.FailActionHandler.Handle(logger, w, r, done)
case challenge.VerifyResultNone:
// challenge was issued
if reg.Class == challenge.ClassTransparent {
@@ -174,5 +188,6 @@ func (a Challenge) Handle(logger *slog.Logger, w http.ResponseWriter, r *http.Re
}
// nothing matched, execute default action
return a.FailAction.Handle(logger, w, r, done)
data.State.ActionHit(r, a.FailAction, logger)
return a.FailActionHandler.Handle(logger, w, r, done)
}

View File

@@ -42,6 +42,12 @@ type CodeSettings struct {
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) {
data := challenge.RequestDataFromContext(r.Context())
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
data.ResponseHeaders(w)
w.WriteHeader(int(a))
return false, nil
}

69
lib/action/context.go Normal file
View File

@@ -0,0 +1,69 @@
package action
import (
"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"
"net/textproto"
)
func init() {
Register[policy.RuleActionCONTEXT] = func(state challenge.StateInterface, ruleName, ruleHash string, settings ast.Node) (Handler, error) {
params := ContextDefaultSettings
if settings != nil {
ymlData, err := settings.MarshalYAML()
if err != nil {
return nil, err
}
err = yaml.Unmarshal(ymlData, &params)
if err != nil {
return nil, err
}
}
return Context{
opts: params,
}, nil
}
}
var ContextDefaultSettings = ContextSettings{}
type ContextSettings struct {
ContextSet map[string]string `yaml:"context-set"`
ResponseHeaders map[string][]string `yaml:"response-headers"`
RequestHeaders map[string][]string `yaml:"request-headers"`
}
type Context struct {
opts ContextSettings
}
func (a Context) 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 k, v := range a.opts.ContextSet {
data.SetOpt(k, v)
}
for k, v := range a.opts.ResponseHeaders {
// do this to allow unsetting values that are sent automatically
w.Header()[textproto.CanonicalMIMEHeaderKey(k)] = nil
for _, val := range v {
w.Header().Add(k, val)
}
}
for k, v := range a.opts.RequestHeaders {
// do this to allow unsetting values that are sent automatically
r.Header[textproto.CanonicalMIMEHeaderKey(k)] = nil
for _, val := range v {
r.Header.Add(k, val)
}
}
return true, nil
}

View File

@@ -33,6 +33,8 @@ func (a Drop) Handle(logger *slog.Logger, w http.ResponseWriter, r *http.Request
w.Header().Set("Content-Type", "text/plain")
w.Header().Set("Content-Length", "0")
w.Header().Set("Connection", "close")
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
w.WriteHeader(http.StatusForbidden)
return false, nil

View File

@@ -2,7 +2,6 @@ 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"
@@ -18,18 +17,15 @@ func FillRegistration(state challenge.StateInterface, reg *challenge.Registratio
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)
data := challenge.RequestDataFromContext(r.Context())
data.IssueChallengeToken(reg, key, nil, expiry, true)
uri, err := challenge.RedirectUrl(r, reg)
if err != nil {
return challenge.VerifyResultFail
}
data.ResponseHeaders(w)
http.Redirect(w, r, uri.String(), http.StatusTemporaryRedirect)
return challenge.VerifyResultNone
}

View File

@@ -1,19 +1,26 @@
package challenge
import (
"bytes"
http_cel "codeberg.org/gone/http-cel"
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"git.gammaspectra.live/git/go-away/lib/condition"
"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"
"github.com/google/cel-go/common/types"
"github.com/google/cel-go/common/types/traits"
"net"
"maps"
unsaferand "math/rand/v2"
"net/http"
"net/netip"
"net/textproto"
"strings"
"time"
)
@@ -21,7 +28,11 @@ type requestDataContextKey struct {
}
func RequestDataFromContext(ctx context.Context) *RequestData {
return ctx.Value(requestDataContextKey{}).(*RequestData)
val := ctx.Value(requestDataContextKey{})
if val == nil {
return nil
}
return val.(*RequestData)
}
type RequestId [16]byte
@@ -31,18 +42,27 @@ func (id RequestId) String() string {
}
type RequestData struct {
Id RequestId
Time time.Time
ChallengeVerify map[Id]VerifyResult
ChallengeState map[Id]VerifyState
RemoteAddress net.IP
Id RequestId
Time time.Time
ChallengeVerify map[Id]VerifyResult
ChallengeState map[Id]VerifyState
ChallengeMap TokenChallengeMap
challengeMapModified bool
RemoteAddress netip.AddrPort
State StateInterface
cookieName string
issuedChallenge string
ExtraHeaders http.Header
r *http.Request
fp map[string]string
header traits.Mapper
query traits.Mapper
opts map[string]string
}
func CreateRequestData(r *http.Request, state StateInterface) (*http.Request, *RequestData) {
@@ -55,7 +75,8 @@ func CreateRequestData(r *http.Request, state StateInterface) (*http.Request, *R
data.ChallengeState = make(map[Id]VerifyState, len(state.GetChallenges()))
data.Time = time.Now().UTC()
data.State = state
data.r = r
data.ExtraHeaders = make(http.Header)
data.fp = make(map[string]string, 2)
@@ -63,19 +84,35 @@ func CreateRequestData(r *http.Request, state StateInterface) (*http.Request, *R
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))
q := r.URL.Query()
if q.Has(QueryArgChallenge) {
data.issuedChallenge = q.Get(QueryArgChallenge)
}
// delete query parameters that were set by go-away
for k := range q {
if strings.HasPrefix(k, QueryArgPrefix) {
q.Del(k)
}
}
data.query = http_cel.NewValuesMap(q)
data.header = http_cel.NewMIMEMap(textproto.MIMEHeader(r.Header))
data.opts = make(map[string]string)
r = r.WithContext(context.WithValue(r.Context(), requestDataContextKey{}, &data))
r = utils.SetRemoteAddress(r, data.RemoteAddress)
data.r = r
data.cookieName = utils.DefaultCookiePrefix + hex.EncodeToString(data.cookieHostKey()) + "-state"
return r, &data
}
@@ -87,7 +124,7 @@ func (d *RequestData) ResolveName(name string) (any, bool) {
case "method":
return d.r.Method, true
case "remoteAddress":
return d.RemoteAddress, true
return d.RemoteAddress.Addr().AsSlice(), true
case "userAgent":
return d.r.UserAgent(), true
case "path":
@@ -107,13 +144,126 @@ 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)
func (d *RequestData) NetworkPrefix() netip.Addr {
address := d.RemoteAddress.Addr().Unmap()
if address.Is4() {
// Take a /24 for IPv4
prefix, _ := address.Prefix(24)
return prefix.Addr()
} else {
// Take a /64 for IPv6
prefix, _ := address.Prefix(64)
return prefix.Addr()
}
}
const (
RequestOptBackendHost = "backend-host"
RequestOptProxyMetaTags = "proxy-meta-tags"
RequestOptProxySafeLinkTags = "proxy-safe-link-tags"
)
func (d *RequestData) SetOpt(n, v string) {
d.opts[n] = v
}
func (d *RequestData) GetOpt(n, def string) string {
v, ok := d.opts[n]
if !ok {
return def
}
return v
}
func (d *RequestData) GetOptBool(n string, def bool) bool {
v, ok := d.opts[n]
if !ok {
return def
}
switch v {
case "true", "t", "1", "yes", "yep", "y", "ok":
return true
case "false", "f", "0", "no", "nope", "n", "err":
return false
default:
return def
}
}
func (d *RequestData) BackendHost() (http.Handler, string) {
host := d.r.Host
if opt := d.GetOpt(RequestOptBackendHost, ""); opt != "" && opt != host {
host = d.r.Host
}
return d.State.GetBackend(host), host
}
func (d *RequestData) ClearChallengeToken(reg *Registration) {
delete(d.ChallengeMap, reg.Name)
d.challengeMapModified = true
}
func (d *RequestData) IssueChallengeToken(reg *Registration, key Key, result []byte, until time.Time, ok bool) {
d.ChallengeMap[reg.Name] = TokenChallenge{
Key: key[:],
Result: result,
Ok: ok,
Expiry: jwt.NumericDate(until.Unix()),
IssuedAt: jwt.NumericDate(time.Now().UTC().Unix()),
}
d.challengeMapModified = true
}
var ErrVerifyKeyMismatch = errors.New("verify: key mismatch")
var ErrVerifyVerifyMismatch = errors.New("verify: verification mismatch")
var ErrTokenExpired = errors.New("token: expired")
func (d *RequestData) VerifyChallengeToken(reg *Registration, token TokenChallenge, expectedKey Key) (VerifyResult, VerifyState, error) {
if token.Expiry.Time().Compare(time.Now()) < 0 {
return VerifyResultFail, VerifyStateNone, ErrTokenExpired
}
if token.NotBefore.Time().Compare(time.Now()) > 0 {
return VerifyResultFail, VerifyStateNone, errors.New("token not valid yet")
}
if bytes.Compare(expectedKey[:], token.Key) != 0 {
return VerifyResultFail, VerifyStateNone, ErrVerifyKeyMismatch
}
if reg.Verify != nil {
if unsaferand.Float64() < reg.VerifyProbability {
// random spot check
if ok, err := reg.Verify(expectedKey, token.Result, d.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 !token.Ok {
return VerifyResultNotOK, VerifyStateBrief, nil
}
return VerifyResultOK, VerifyStateBrief, nil
}
func (d *RequestData) verifyChallenge(reg *Registration, key Key) (verifyResult VerifyResult, verifyState VerifyState, err error) {
token, ok := d.ChallengeMap[reg.Name]
if !ok {
verifyResult = VerifyResultFail
verifyState = VerifyStateNone
} else {
verifyResult, verifyState, err = d.VerifyChallengeToken(reg, token, key)
if err != nil && !errors.Is(err, http.ErrNoCookie) {
// clear invalid cookie
utils.ClearCookie(utils.CookiePrefix+reg.Name, w, r)
// clear invalid state
d.ClearChallengeToken(reg)
}
// prevent evaluating the challenge if not solved
@@ -121,29 +271,49 @@ func (d *RequestData) EvaluateChallenges(w http.ResponseWriter, r *http.Request)
out, _, err := reg.Condition.Eval(d)
// verify eligibility
if err != nil {
d.State.Logger(r).Error(err.Error(), "challenge", reg.Name)
d.State.Logger(d.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
return verifyResult, verifyState, err
}
}
}
}
if !verifyResult.Ok() && d.issuedChallenge == reg.Name {
// we issued the challenge, must skip to prevent loops
verifyResult = VerifyResultSkip
}
return verifyResult, verifyState, err
}
func (d *RequestData) EvaluateChallenges(w http.ResponseWriter, r *http.Request) {
challengeMap, err := d.verifyChallengeState()
if err != nil {
if !errors.Is(err, http.ErrNoCookie) {
//queue resend invalid cookie and continue
d.challengeMapModified = true
}
challengeMap = make(TokenChallengeMap)
}
d.ChallengeMap = challengeMap
for _, reg := range d.State.GetChallenges() {
key := GetChallengeKeyForRequest(d.State, reg, d.Expiration(reg.Duration), r)
verifyResult, verifyState, err := d.verifyChallenge(reg, key)
if err != nil {
// clear invalid state
d.ClearChallengeToken(reg)
}
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 {
@@ -154,9 +324,38 @@ func (d *RequestData) HasValidChallenge(id Id) bool {
return d.ChallengeVerify[id].Ok()
}
func (d *RequestData) Headers(headers http.Header) {
func (d *RequestData) ResponseHeaders(w http.ResponseWriter) {
// 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")
// send Vary header to mark that response may vary based on Cookie values and other client headers
w.Header().Set("Vary", "Cookie, Accept, Accept-Encoding, Accept-Language, User-Agent")
if d.State.Settings().MainName != "" {
w.Header().Add("Via", fmt.Sprintf("%s %s@%s", d.r.Proto, d.State.Settings().MainName, d.State.Settings().MainVersion))
}
if d.challengeMapModified {
expiration := d.Expiration(DefaultDuration)
if token, err := d.issueChallengeState(expiration); err == nil {
utils.SetCookie(d.cookieName, token, expiration, w, d.r)
} else {
d.State.Logger(d.r).Error("error while issuing cookie", "error", err)
}
}
}
func (d *RequestData) RequestHeaders(headers http.Header) {
headers.Set("X-Away-Id", d.Id.String())
if d.State.Settings().BackendIpHeader != "" {
if d.State.Settings().ClientIpHeader != "" {
headers.Del(d.State.Settings().ClientIpHeader)
}
headers.Set(d.State.Settings().BackendIpHeader, d.RemoteAddress.Addr().Unmap().String())
}
for id, result := range d.ChallengeVerify {
if result.Ok() {
c, ok := d.State.GetChallenge(id)
@@ -168,4 +367,136 @@ func (d *RequestData) Headers(headers http.Header) {
headers.Set(fmt.Sprintf("X-Away-Challenge-%s-State", c.Name), d.ChallengeState[id].String())
}
}
if ja4, ok := d.fp["fp4"]; ok {
headers.Set("X-TLS-Fingerprint-JA4", ja4)
}
if ja3n, ok := d.fp["ja3n"]; ok {
headers.Set("X-TLS-Fingerprint-JA3N", ja3n)
}
maps.Copy(headers, d.ExtraHeaders)
}
type Token struct {
State TokenChallengeMap `json:"state"`
Expiry jwt.NumericDate `json:"exp,omitempty"`
NotBefore jwt.NumericDate `json:"nbf,omitempty"`
IssuedAt jwt.NumericDate `json:"iat,omitempty"`
}
type TokenChallengeMap map[string]TokenChallenge
type TokenChallenge struct {
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 (d *RequestData) verifyChallengeStateCookie(cookie *http.Cookie) (TokenChallengeMap, error) {
cookie, err := d.r.Cookie(d.cookieName)
if err != nil {
return nil, err
}
if cookie == nil {
return nil, http.ErrNoCookie
}
encryptedToken, err := jwt.ParseSignedAndEncrypted(cookie.Value,
[]jose.KeyAlgorithm{jose.DIRECT},
[]jose.ContentEncryption{jose.A256GCM},
[]jose.SignatureAlgorithm{jose.EdDSA},
)
if err != nil {
return nil, err
}
signedToken, err := encryptedToken.Decrypt(d.cookieKey())
if err != nil {
return nil, err
}
var i Token
err = signedToken.Claims(d.State.PublicKey(), &i)
if err != nil {
return nil, err
}
if i.Expiry.Time().Compare(time.Now()) < 0 {
return nil, ErrTokenExpired
}
if i.NotBefore.Time().Compare(time.Now()) > 0 {
return nil, errors.New("token not valid yet")
}
return i.State, nil
}
func (d *RequestData) verifyChallengeState() (state TokenChallengeMap, err error) {
cookies := d.r.CookiesNamed(d.cookieName)
if len(cookies) == 0 {
return nil, http.ErrNoCookie
}
for _, cookie := range cookies {
state, err = d.verifyChallengeStateCookie(cookie)
if err == nil {
return state, nil
}
}
return state, err
}
func (d *RequestData) issueChallengeState(until time.Time) (string, error) {
signer, err := jose.NewSigner(jose.SigningKey{
Algorithm: jose.EdDSA,
Key: d.State.PrivateKey(),
}, nil)
if err != nil {
return "", err
}
encrypter, err := jose.NewEncrypter(jose.A256GCM, jose.Recipient{
Algorithm: jose.DIRECT,
Key: d.cookieKey(),
}, (&jose.EncrypterOptions{
Compression: jose.DEFLATE,
}).WithContentType("JWT"))
if err != nil {
return "", err
}
return jwt.SignedAndEncrypted(signer, encrypter).Claims(Token{
State: d.ChallengeMap,
Expiry: jwt.NumericDate(until.Unix()),
NotBefore: jwt.NumericDate(time.Now().UTC().AddDate(0, 0, -1).Unix()),
IssuedAt: jwt.NumericDate(time.Now().UTC().Unix()),
}).Serialize()
}
func (d *RequestData) cookieKey() []byte {
sum := sha256.New()
sum.Write([]byte(d.r.Host))
sum.Write([]byte{0})
sum.Write(d.NetworkPrefix().AsSlice())
sum.Write([]byte{0})
sum.Write(d.State.PrivateKey())
sum.Write([]byte{0})
// version/compressor
sum.Write([]byte("1.0/DEFLATE"))
sum.Write([]byte{0})
return sum.Sum(nil)
}
func (d *RequestData) cookieHostKey() []byte {
sum := sha256.New()
sum.Write([]byte(d.r.Host))
sum.Write([]byte{0})
sum.Write(d.NetworkPrefix().AsSlice())
sum.Write([]byte{0})
return sum.Sum(nil)[:6]
}

View File

@@ -119,28 +119,16 @@ func FillRegistration(state challenge.StateInterface, reg *challenge.Registratio
data := challenge.RequestDataFromContext(r.Context())
result, err := lookup(r.Context(), params.Decay, params.Timeout, dnsbl, decayMap, data.RemoteAddress)
result, err := lookup(r.Context(), params.Decay, params.Timeout, dnsbl, decayMap, data.RemoteAddress.Addr().Unmap().AsSlice())
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
data.State.Logger(r).Debug("dnsbl lookup failed", "address", data.RemoteAddress.Addr().String(), "result", result, "err", err)
}
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)
data.IssueChallengeToken(reg, key, nil, expiry, false)
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)
data.IssueChallengeToken(reg, key, nil, expiry, true)
return challenge.VerifyResultOK
}
}

View File

@@ -8,18 +8,35 @@ import (
"git.gammaspectra.live/git/go-away/utils"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
var ErrInvalidToken = errors.New("invalid token")
var ErrMismatchedToken = errors.New("mismatched token")
var ErrMismatchedTokenHappyEyeballs = errors.New("mismatched token: IPv4 to IPv6 upgrade detected, retrying")
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 len(expectedKey) != KeySize {
return VerifyResultFail, ErrInvalidToken
}
if subtle.ConstantTimeCompare(key[:], expectedKey) == 1 {
return VerifyResultOK, nil
}
return VerifyResultFail, errors.New("invalid token")
kk := Key(expectedKey)
// IPv4 -> IPv6 Happy Eyeballs
if key.Get(KeyFlagIsIPv4) == 0 && kk.Get(KeyFlagIsIPv4) > 0 {
return VerifyResultOK, ErrMismatchedTokenHappyEyeballs
}
return VerifyResultFail, ErrMismatchedToken
}, func(key Key) string {
return hex.EncodeToString(key[:])
}
@@ -32,6 +49,7 @@ const (
QueryArgRequestId = QueryArgPrefix + "_id"
QueryArgChallenge = QueryArgPrefix + "_challenge"
QueryArgToken = QueryArgPrefix + "_token"
QueryArgBust = QueryArgPrefix + "_bust"
)
const MakeChallengeUrlSuffix = "/make-challenge"
@@ -39,11 +57,13 @@ 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))
q := r.URL.Query()
if q.Get(QueryArgChallenge) != reg.Name {
return RequestId{}, "", "", fmt.Errorf("unexpected challenge: got \"%s\"", q.Get(QueryArgChallenge))
}
requestIdHex := r.FormValue(QueryArgRequestId)
requestIdHex := q.Get(QueryArgRequestId)
if len(requestId) != hex.DecodedLen(len(requestIdHex)) {
return RequestId{}, "", "", errors.New("invalid request id")
@@ -55,8 +75,8 @@ func GetVerifyInformation(r *http.Request, reg *Registration) (requestId Request
return RequestId{}, "", "", errors.New("invalid request id")
}
token = r.FormValue(QueryArgToken)
redirect, err = utils.EnsureNoOpenRedirect(r.FormValue(QueryArgRedirect))
token = q.Get(QueryArgToken)
redirect, err = utils.EnsureNoOpenRedirect(q.Get(QueryArgRedirect))
if err != nil {
return RequestId{}, "", "", err
}
@@ -74,12 +94,13 @@ func VerifyUrl(r *http.Request, reg *Registration, token string) (*url.URL, erro
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()
values, _ := utils.ParseRawQuery(r.URL.RawQuery)
values.Set(QueryArgRequestId, url.QueryEscape(data.Id.String()))
values.Set(QueryArgRedirect, url.QueryEscape(redirectUrl.String()))
values.Set(QueryArgToken, url.QueryEscape(token))
values.Set(QueryArgChallenge, url.QueryEscape(reg.Name))
values.Set(QueryArgBust, url.QueryEscape(strconv.FormatInt(time.Now().UTC().UnixMilli(), 10)))
uri.RawQuery = utils.EncodeRawQuery(values)
return uri, nil
}
@@ -91,23 +112,48 @@ func RedirectUrl(r *http.Request, reg *Registration) (*url.URL, error) {
}
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()
values, _ := utils.ParseRawQuery(r.URL.RawQuery)
values.Set(QueryArgRequestId, url.QueryEscape(data.Id.String()))
if ref := r.Referer(); ref != "" {
values.Set(QueryArgReferer, url.QueryEscape(r.Referer()))
}
values.Set(QueryArgChallenge, url.QueryEscape(reg.Name))
uri.RawQuery = utils.EncodeRawQuery(values)
return uri, nil
}
func VerifyHandlerChallengeResponseFunc(state StateInterface, data *RequestData, w http.ResponseWriter, r *http.Request, verifyResult VerifyResult, err error, redirect string) {
if err != nil {
// Happy Eyeballs! auto retry
if errors.Is(err, ErrMismatchedTokenHappyEyeballs) {
reqUri := *r.URL
q := reqUri.Query()
ref := q.Get(QueryArgReferer)
// delete query parameters that were set by go-away
for k := range q {
if strings.HasPrefix(k, QueryArgPrefix) {
q.Del(k)
}
}
if ref != "" {
q.Set(QueryArgReferer, ref)
}
reqUri.RawQuery = q.Encode()
data.ResponseHeaders(w)
http.Redirect(w, r, reqUri.String(), http.StatusTemporaryRedirect)
return
}
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
}
data.ResponseHeaders(w)
http.Redirect(w, r, redirect, http.StatusTemporaryRedirect)
}
@@ -136,18 +182,12 @@ func VerifyHandlerFunc(state StateInterface, reg *Registration, verify VerifyFun
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.IssueChallengeToken(reg, key, []byte(token), expiration, true)
data.ChallengeVerify[reg.id] = verifyResult
state.ChallengePassed(r, reg, redirect, nil)
@@ -155,7 +195,6 @@ func VerifyHandlerFunc(state StateInterface, reg *Registration, verify VerifyFun
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

View File

@@ -5,7 +5,6 @@ import (
"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"
@@ -109,12 +108,14 @@ func FillRegistration(state challenge.StateInterface, reg *challenge.Registratio
}
}
data := challenge.RequestDataFromContext(r.Context())
request, err := http.NewRequest(params.HttpMethod, params.Url, nil)
if err != nil {
return challenge.VerifyResultFail
}
var excludeHeaders = []string{"Host", "Content-Length"}
var excludeHeaders = []string{"Host", "Content-Length", "Upgrade", "Accept-Encoding", "Range"}
for k, v := range r.Header {
if slices.Contains(excludeHeaders, k) {
// skip these parameters
@@ -122,10 +123,12 @@ func FillRegistration(state challenge.StateInterface, reg *challenge.Registratio
}
request.Header[k] = v
}
// set id
request.Header.Set("X-Away-Id", challenge.RequestDataFromContext(r.Context()).Id.String())
// set id, ip, and other headers
data.RequestHeaders(request.Header)
// set request info in X headers
request.Header.Set("X-Away-Method", r.Method)
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)
@@ -138,18 +141,10 @@ func FillRegistration(state challenge.StateInterface, reg *challenge.Registratio
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)
data.IssueChallengeToken(reg, key, sum, expiry, false)
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)
data.IssueChallengeToken(reg, key, sum, expiry, true)
return challenge.VerifyResultOK
}
}

View File

@@ -42,37 +42,36 @@ func KeyFromString(s string) (Key, error) {
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())
keyAddr := data.NetworkPrefix().As16()
hasher.Write(keyAddr[:])
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)))
for _, k := range reg.KeyHeaders {
hasher.Write([]byte(k))
hasher.Write([]byte{0})
for _, v := range r.Header.Values(k) {
hasher.Write([]byte(v))
hasher.Write([]byte{1})
}
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(state.PrivateKeyFingerprint())
hasher.Write([]byte{0})
sum := Key(hasher.Sum(nil))
sum[0] = 0
if address.To4() != nil {
if data.RemoteAddress.Addr().Unmap().Is4() {
// Is IPv4, mark
sum.Set(KeyFlagIsIPv4)
}

View File

@@ -22,7 +22,7 @@ type Parameters struct {
}
var DefaultParameters = Parameters{
Deadline: time.Second * 3,
Deadline: time.Second * 2,
}
func FillRegistration(state challenge.StateInterface, reg *challenge.Registration, parameters ast.Node) error {
@@ -44,6 +44,9 @@ func FillRegistration(state challenge.StateInterface, reg *challenge.Registratio
reg.Class = challenge.ClassTransparent
// some of regular headers are not sent in default headers
reg.KeyHeaders = challenge.MinimalKeyHeaders
ob := challenge.NewAwaiter[string]()
reg.Object = ob
@@ -66,9 +69,9 @@ func FillRegistration(state challenge.StateInterface, reg *challenge.Registratio
}
// remove redirect args
values := uri.Query()
values, _ := utils.ParseRawQuery(uri.RawQuery)
values.Del(challenge.QueryArgRedirect)
uri.RawQuery = values.Encode()
uri.RawQuery = utils.EncodeRawQuery(values)
// Redirect URI must be absolute to work
uri.Scheme = utils.GetRequestScheme(r)
@@ -98,6 +101,7 @@ func FillRegistration(state challenge.StateInterface, reg *challenge.Registratio
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("Cache-Control", "no-cache, no-store, must-revalidate")
w.Header().Set("Content-Length", "0")
data := challenge.RequestDataFromContext(r.Context())
@@ -110,6 +114,9 @@ func FillRegistration(state challenge.StateInterface, reg *challenge.Registratio
}
verifyResult, _ := verifier(key, []byte(token), r)
data.ResponseHeaders(w)
if !verifyResult.Ok() {
w.WriteHeader(http.StatusUnauthorized)
} else {

View File

@@ -1,9 +1,12 @@
package refresh
import (
"encoding/json"
"fmt"
"git.gammaspectra.live/git/go-away/lib/challenge"
"github.com/goccy/go-yaml"
"github.com/goccy/go-yaml/ast"
"html/template"
"net/http"
"time"
)
@@ -13,7 +16,7 @@ func init() {
}
type Parameters struct {
Mode string `yaml:"refresh-mode"`
Mode string `yaml:"refresh-via"`
}
var DefaultParameters = Parameters{
@@ -45,10 +48,23 @@ func FillRegistration(state challenge.StateInterface, reg *challenge.Registratio
return challenge.VerifyResultFail
}
if params.Mode == "meta" {
if params.Mode == "javascript" {
data, err := json.Marshal(uri.String())
if err != nil {
return challenge.VerifyResultFail
}
state.ChallengePage(w, r, state.Settings().ChallengeResponseCode, reg, map[string]any{
"Meta": map[string]string{
"refresh": "0; url=" + uri.String(),
"EndTags": []template.HTML{
template.HTML(fmt.Sprintf("<script type=\"text/javascript\">window.location = %s;</script>", string(data))),
},
})
} else if params.Mode == "meta" {
state.ChallengePage(w, r, state.Settings().ChallengeResponseCode, reg, map[string]any{
"MetaTags": []map[string]string{
{
"http-equiv": "refresh",
"content": "0; url=" + uri.String(),
},
},
})
} else {

View File

@@ -1,19 +1,12 @@
package challenge
import (
"bytes"
"crypto/ed25519"
"errors"
http_cel "codeberg.org/gone/http-cel"
"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"
@@ -42,6 +35,24 @@ var idCounter Id
// DefaultDuration TODO: adjust
const DefaultDuration = time.Hour * 24 * 7
var DefaultKeyHeaders = []string{
// General browser information
"User-Agent",
// Accept headers
"Accept-Language",
"Accept-Encoding",
// NOTE: not sent in preload
"Sec-Ch-Ua",
"Sec-Ch-Ua-Platform",
}
var MinimalKeyHeaders = []string{
"Accept-Language",
// General browser information
"User-Agent",
}
func (r Register) Create(state StateInterface, name string, pol policy.Challenge, replacer *strings.Replacer) (*Registration, Id, error) {
runtime, ok := Runtimes[pol.Runtime]
if !ok {
@@ -49,9 +60,10 @@ func (r Register) Create(state StateInterface, name string, pol policy.Challenge
}
reg := &Registration{
Name: name,
Path: path.Join(state.UrlPath(), "challenge", name),
Duration: pol.Duration,
Name: name,
Path: path.Join(state.UrlPath(), "challenge", name),
Duration: pol.Duration,
KeyHeaders: DefaultKeyHeaders,
}
if reg.Duration == 0 {
@@ -68,13 +80,10 @@ func (r Register) Create(state StateInterface, name string, pol policy.Challenge
}
if len(conditions) > 0 {
ast, err := condition.FromStrings(state.ProgramEnv(), condition.OperatorOr, conditions...)
var err error
reg.Condition, err = state.RegisterCondition(http_cel.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)
return nil, 0, fmt.Errorf("error compiling condition: %w", err)
}
}
@@ -136,6 +145,9 @@ type Registration struct {
Verify VerifyFunc
VerifyProbability float64
// KeyHeaders The client headers used in key generation, in this order
KeyHeaders []string
// 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
@@ -149,104 +161,10 @@ type Registration struct {
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

@@ -1,10 +1,8 @@
package resource_load
import (
"fmt"
"git.gammaspectra.live/git/go-away/lib/challenge"
"github.com/goccy/go-yaml/ast"
"html/template"
"net/http"
"time"
)
@@ -25,13 +23,21 @@ func FillRegistrationHeader(state challenge.StateInterface, reg *challenge.Regis
return challenge.VerifyResultFail
}
redirectUri, err := challenge.RedirectUrl(r, reg)
if err != nil {
return challenge.VerifyResultFail
}
// self redirect!
//TODO: adjust deadline
w.Header().Set("Refresh", "2; url="+r.URL.String())
w.Header().Set("Refresh", "2; url="+redirectUri.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())),
"LinkTags": []map[string]string{
{
"href": uri.String(),
"rel": "stylesheet",
"crossorigin": "use-credentials",
},
},
})
return challenge.VerifyResultNone
@@ -42,7 +48,11 @@ func FillRegistrationHeader(state challenge.StateInterface, reg *challenge.Regis
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("Cache-Control", "no-cache, no-store, must-revalidate")
w.Header().Set("Content-Length", "0")
data.ResponseHeaders(w)
if !verifyResult.Ok() {
w.WriteHeader(http.StatusForbidden)
} else {

View File

@@ -24,15 +24,17 @@ func ServeChallengeScript(w http.ResponseWriter, r *http.Request, reg *Registrat
panic(err)
}
data.ResponseHeaders(w)
w.WriteHeader(http.StatusOK)
err = scriptTemplate.Execute(w, map[string]any{
"Id": data.Id.String(),
"Path": reg.Path,
"Parameters": paramData,
"Random": utils.CacheBust(),
"Random": utils.StaticCacheBust(),
"Challenge": reg.Name,
"ChallengeScript": script,
"Strings": data.State.Strings(),
})
if err != nil {
//TODO: log

View File

@@ -14,9 +14,8 @@ const u = (url = "", params = {}) => {
(async () => {
const status = document.getElementById('status');
const title = document.getElementById('title');
const spinner = document.getElementById('spinner');
status.innerText = 'Starting challenge {{ .Challenge }}...';
status.innerText = '{{ .Strings.Get "status_starting_challenge" }} {{ .Challenge }}...';
try {
const info = await setup({
@@ -25,15 +24,13 @@ const u = (url = "", params = {}) => {
});
if (info != "") {
status.innerText = 'Calculating... ' + info
status.innerText = '{{ .Strings.Get "status_calculating" }} ' + info
} else {
status.innerText = 'Calculating...';
status.innerText = '{{ .Strings.Get "status_calculating" }}';
}
} catch (err) {
title.innerHTML = "Oh no!";
status.innerHTML = `Failed to initialize: ${err.message}`;
spinner.innerHTML = "";
spinner.style.display = "none";
title.innerHTML = '{{ .Strings.Get "title_error" }}';
status.innerHTML = `{{ .Strings.Get "status_error" }} ${err.message}`;
return
}
@@ -44,11 +41,11 @@ const u = (url = "", params = {}) => {
const t1 = Date.now();
console.log({ result, info });
title.innerHTML = "Challenge success!";
title.innerHTML = '{{ .Strings.Get "status_challenge_success" }}';
if (info != "") {
status.innerHTML = `Done! Took ${t1 - t0}ms, ${info}`;
status.innerHTML = `{{ .Strings.Get "status_challenge_done_took" }} ${t1 - t0}ms, ${info}`;
} else {
status.innerHTML = `Done! Took ${t1 - t0}ms`;
status.innerHTML = `{{ .Strings.Get "status_challenge_done_took" }} ${t1 - t0}ms`;
}
setTimeout(() => {
@@ -62,9 +59,7 @@ const u = (url = "", params = {}) => {
});
}, 500);
} catch (err) {
title.innerHTML = "Oh no!";
status.innerHTML = `Failed to challenge: ${err.message}`;
spinner.innerHTML = "";
spinner.style.display = "none";
title.innerHTML = '{{ .Strings.Get "title_error" }}';
status.innerHTML = `{{ .Strings.Get "status_error" }} ${err.message}`;
}
})();

View File

@@ -3,6 +3,7 @@ package challenge
import (
"crypto/ed25519"
"git.gammaspectra.live/git/go-away/lib/policy"
"git.gammaspectra.live/git/go-away/utils"
"github.com/google/cel-go/cel"
"log/slog"
"net/http"
@@ -85,9 +86,10 @@ func (r VerifyResult) String() string {
}
type StateInterface interface {
ProgramEnv() *cel.Env
RegisterCondition(operator string, conditions ...string) (cel.Program, error)
Client() *http.Client
PrivateKeyFingerprint() []byte
PrivateKey() ed25519.PrivateKey
PublicKey() ed25519.PublicKey
@@ -96,6 +98,11 @@ type StateInterface interface {
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)
ChallengeChecked(r *http.Request, reg *Registration, redirect string, logger *slog.Logger)
RuleHit(r *http.Request, name string, logger *slog.Logger)
RuleMiss(r *http.Request, name string, logger *slog.Logger)
ActionHit(r *http.Request, name policy.RuleAction, logger *slog.Logger)
Logger(r *http.Request) *slog.Logger
@@ -106,7 +113,9 @@ type StateInterface interface {
GetChallengeByName(name string) (*Registration, bool)
GetChallenges() Register
Settings() policy.Settings
Settings() policy.StateSettings
Strings() utils.Strings
GetBackend(host string) http.Handler
}

View File

@@ -97,7 +97,7 @@ func FillJavaScriptRegistration(state challenge.StateInterface, reg *challenge.R
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())),
template.HTML(fmt.Sprintf("<script async type=\"module\" src=\"%s?cacheBust=%s\"></script>", reg.Path+"/script.mjs", utils.StaticCacheBust())),
},
})
return challenge.VerifyResultNone
@@ -164,6 +164,9 @@ func FillJavaScriptRegistration(state challenge.StateInterface, reg *challenge.R
w.Header()[k] = v
}
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(out.Data)))
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
data.ResponseHeaders(w)
w.WriteHeader(out.Code)
_, _ = w.Write(out.Data)
return nil

View File

@@ -1,177 +0,0 @@
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"
)
type Condition struct {
Expression *cel.Ast
}
const (
OperatorOr = "||"
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 {
ast, issues := env.Compile(c)
if issues != nil && issues.Err() != nil {
return nil, fmt.Errorf("condition %s: %s", issues.Err(), c)
}
asts = append(asts, ast)
}
return Merge(env, operator, asts...)
}
func Merge(env *cel.Env, operator string, conditions ...*cel.Ast) (*cel.Ast, error) {
if len(conditions) == 0 {
return nil, nil
} else if len(conditions) == 1 {
return conditions[0], nil
}
var asts []string
for _, c := range conditions {
ast, err := cel.AstToString(c)
if err != nil {
return nil, err
}
asts = append(asts, "("+ast+")")
}
condition := strings.Join(asts, " "+operator+" ")
ast, issues := env.Compile(condition)
if issues != nil && issues.Err() != nil {
return nil, issues.Err()
}
return ast, nil
}

View File

@@ -1,158 +0,0 @@
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,13 +1,224 @@
package lib
import (
"git.gammaspectra.live/git/go-away/lib/condition"
http_cel "codeberg.org/gone/http-cel"
"fmt"
"github.com/google/cel-go/cel"
"github.com/google/cel-go/common/ast"
"github.com/google/cel-go/common/types"
"github.com/google/cel-go/common/types/ref"
"log/slog"
"net"
)
func (state *State) initConditions() (err error) {
state.programEnv, err = condition.NewRulesEnvironment(state.networks)
state.programEnv, err = http_cel.NewEnvironment(
cel.Variable("fp", cel.MapType(cel.StringType, cel.StringType)),
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 := 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)
}
}),
),
),
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 := 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)
}
}),
),
),
)
if err != nil {
return err
}
return nil
}
func (state *State) RegisterCondition(operator string, conditions ...string) (cel.Program, error) {
compiledAst, err := http_cel.NewAst(state.ProgramEnv(), operator, conditions...)
if err != nil {
return nil, err
}
if out := compiledAst.OutputType(); out == nil {
return nil, fmt.Errorf("no output")
} else if out != types.BoolType {
return nil, fmt.Errorf("output type is not bool")
}
walkExpr(compiledAst.NativeRep().Expr(), func(e ast.Expr) {
if e.Kind() == ast.CallKind {
call := e.AsCall()
switch call.FunctionName() {
// deprecated
case "inNetwork":
args := call.Args()
if !call.IsMemberFunction() && len(args) == 2 {
// we have a network select function
switch args[1].Kind() {
case ast.LiteralKind:
lit := args[1].AsLiteral()
if lit.Type() == types.StringType {
if fn, ok := state.networks[lit.Value().(string)]; ok {
// preload
fn()
}
}
}
}
case "network":
args := call.Args()
if call.IsMemberFunction() && len(args) == 1 {
// we have a network select function
switch args[0].Kind() {
case ast.LiteralKind:
lit := args[0].AsLiteral()
if lit.Type() == types.StringType {
if fn, ok := state.networks[lit.Value().(string)]; ok {
// preload
fn()
}
}
}
}
}
}
})
return http_cel.ProgramAst(state.ProgramEnv(), compiledAst)
}
func walkExpr(e ast.Expr, fn func(ast.Expr)) {
fn(e)
switch e.Kind() {
case ast.CallKind:
ee := e.AsCall()
walkExpr(ee.Target(), fn)
for _, arg := range ee.Args() {
walkExpr(arg, fn)
}
case ast.ComprehensionKind:
ee := e.AsComprehension()
walkExpr(ee.Result(), fn)
walkExpr(ee.IterRange(), fn)
walkExpr(ee.AccuInit(), fn)
walkExpr(ee.LoopCondition(), fn)
walkExpr(ee.LoopStep(), fn)
case ast.ListKind:
ee := e.AsList()
for _, element := range ee.Elements() {
walkExpr(element, fn)
}
case ast.MapKind:
ee := e.AsMap()
for _, entry := range ee.Entries() {
switch entry.Kind() {
case ast.MapEntryKind:
eee := entry.AsMapEntry()
walkExpr(eee.Key(), fn)
walkExpr(eee.Value(), fn)
case ast.StructFieldKind:
eee := entry.AsStructField()
walkExpr(eee.Value(), fn)
}
}
case ast.SelectKind:
ee := e.AsSelect()
walkExpr(ee.Operand(), fn)
case ast.StructKind:
ee := e.AsStruct()
for _, field := range ee.Fields() {
switch field.Kind() {
case ast.MapEntryKind:
eee := field.AsMapEntry()
walkExpr(eee.Key(), fn)
walkExpr(eee.Value(), fn)
case ast.StructFieldKind:
eee := field.AsStructField()
walkExpr(eee.Value(), fn)
}
}
}
}

View File

@@ -6,62 +6,21 @@ import (
"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"
"html/template"
"golang.org/x/net/html"
"log/slog"
"net/http"
"net/http/pprof"
"strconv"
"slices"
"strings"
"time"
)
var templates map[string]*template.Template
func init() {
templates = make(map[string]*template.Template)
dir, err := embed.TemplatesFs.ReadDir(".")
if err != nil {
panic(err)
}
for _, e := range dir {
if e.IsDir() {
continue
}
data, err := embed.TemplatesFs.ReadFile(e.Name())
if err != nil {
panic(err)
}
err = initTemplate(e.Name(), string(data))
if err != nil {
panic(err)
}
}
}
func initTemplate(name, data string) error {
tpl := template.New(name)
_, err := tpl.Parse(data)
if err != nil {
return err
}
templates[name] = tpl
return nil
}
func (state *State) addTiming(w http.ResponseWriter, name, desc string, duration time.Duration) {
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 := challenge.RequestDataFromContext(r.Context())
args := []any{
"request_id", data.Id.String(),
"remote_address", data.RemoteAddress.String(),
"remote_address", data.RemoteAddress.Addr().String(),
"user_agent", r.UserAgent(),
"host", r.Host,
"path", r.URL.Path,
@@ -79,52 +38,249 @@ func GetLoggerForRequest(r *http.Request) *slog.Logger {
return slog.With(args...)
}
func (state *State) fetchTags(host string, backend http.Handler, r *http.Request, meta, link bool) []html.Node {
uri := *r.URL
q := uri.Query()
for k := range q {
if strings.HasPrefix(k, challenge.QueryArgPrefix) {
q.Del(k)
}
}
uri.RawQuery = q.Encode()
key := fmt.Sprintf("%s:%s", host, uri.String())
if v, ok := state.tagCache.Get(key); ok {
return v
}
result := utils.FetchTags(backend, &uri, func() (r []string) {
if meta {
r = append(r, "meta")
} else if link {
r = append(r, "link")
}
return r
}()...)
if result == nil {
return nil
}
entries := make([]html.Node, 0, len(result))
for _, n := range result {
if n.Namespace != "" {
continue
}
switch n.Data {
case "link":
safeAttributes := []string{"rel", "href", "hreflang", "media", "title", "type"}
var name string
for _, attr := range n.Attr {
if attr.Namespace != "" {
continue
}
if attr.Key == "rel" {
name = attr.Val
break
}
}
if name == "" {
continue
}
var keep bool
if name == "icon" || name == "alternate icon" {
keep = true
} else if name == "alternate" || name == "canonical" || name == "search" {
// urls to versions of document
keep = true
} else if name == "author" || name == "privacy-policy" || name == "license" || name == "copyright" || name == "terms-of-service" {
keep = true
} else if name == "manifest" {
// web app manifest
keep = true
}
// prevent other arbitrary arguments
if keep {
newNode := html.Node{
Type: html.ElementNode,
Data: n.Data,
}
for _, attr := range n.Attr {
if attr.Namespace != "" {
continue
}
if slices.Contains(safeAttributes, attr.Key) {
newNode.Attr = append(newNode.Attr, attr)
}
}
if len(newNode.Attr) == 0 {
continue
}
entries = append(entries, newNode)
}
case "meta":
safeAttributes := []string{"name", "property", "content"}
var name string
for _, attr := range n.Attr {
if attr.Namespace != "" {
continue
}
if attr.Key == "name" {
name = attr.Val
break
}
if attr.Key == "property" && name == "" {
name = attr.Val
}
}
if name == "" {
continue
}
// prevent unwanted keys like CSRF and other internal entries to pass through as much as possible
var keep bool
if strings.HasPrefix("og:", name) || strings.HasPrefix("fb:", name) || strings.HasPrefix("twitter:", name) || strings.HasPrefix("profile:", name) {
// social / OpenGraph tags
keep = true
} else if name == "vcs" || strings.HasPrefix("vcs:", name) {
// source tags
keep = true
} else if name == "forge" || strings.HasPrefix("forge:", name) {
// forge tags
keep = true
} else if strings.HasPrefix("citation_", name) {
// citations for Google Scholar
keep = true
} else {
switch name {
case "theme-color", "color-scheme", "origin-trials":
// modifies page presentation
keep = true
case "application-name", "origin", "author", "creator", "contact", "title", "description", "thumbnail", "rating":
// standard content tags
keep = true
case "license", "license:uri", "rights", "rights-standard":
// licensing standards
keep = true
case "go-import", "go-source":
// golang tags
keep = true
case "apple-itunes-app", "appstore:bundle_id", "appstore:developer_url", "appstore:store_id", "google-play-app":
// application linking
keep = true
case "verify-v1", "google-site-verification", "p:domain_verify", "yandex-verification", "alexaverifyid":
// site verification
keep = true
case "keywords", "robots", "google", "googlebot", "bingbot", "pinterest", "Slurp":
// scraper and search content directives
keep = true
}
}
// prevent other arbitrary arguments
if keep {
newNode := html.Node{
Type: html.ElementNode,
Data: n.Data,
}
for _, attr := range n.Attr {
if attr.Namespace != "" {
continue
}
if slices.Contains(safeAttributes, attr.Key) {
newNode.Attr = append(newNode.Attr, attr)
}
}
if len(newNode.Attr) == 0 {
continue
}
entries = append(entries, newNode)
}
}
}
state.tagCache.Set(key, entries, time.Hour*6)
return entries
}
func (state *State) handleRequest(w http.ResponseWriter, r *http.Request) {
host := r.Host
data := challenge.RequestDataFromContext(r.Context())
lg := state.Logger(r)
backend := state.GetBackend(host)
if backend == nil {
lg.Debug("no backend for host", "host", host)
http.Error(w, http.StatusText(http.StatusServiceUnavailable), http.StatusServiceUnavailable)
return
}
lg := state.Logger(r)
getBackend := func() http.Handler {
if opt := data.GetOpt(challenge.RequestOptBackendHost, ""); opt != "" && opt != host {
b := state.GetBackend(host)
if b == nil {
http.Error(w, http.StatusText(http.StatusServiceUnavailable), http.StatusServiceUnavailable)
// return empty backend
return http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})
}
return b
}
return backend
}
cleanupRequest := func(r *http.Request, fromChallenge bool) {
cleanupRequest := func(r *http.Request, fromChallenge bool, ruleName string, ruleAction policy.RuleAction) {
if fromChallenge {
r.Header.Del("Referer")
}
if ref := r.FormValue(challenge.QueryArgReferer); ref != "" {
q := r.URL.Query()
if ref := q.Get(challenge.QueryArgReferer); ref != "" {
r.Header.Set("Referer", ref)
}
q := r.URL.Query()
rawQ, _ := utils.ParseRawQuery(r.URL.RawQuery)
// delete query parameters that were set by go-away
for k := range q {
for k := range rawQ {
if strings.HasPrefix(k, challenge.QueryArgPrefix) {
q.Del(k)
rawQ.Del(k)
}
}
r.URL.RawQuery = q.Encode()
r.URL.RawQuery = utils.EncodeRawQuery(rawQ)
data.Headers(r.Header)
data.ExtraHeaders.Set("X-Away-Rule", ruleName)
data.ExtraHeaders.Set("X-Away-Action", string(ruleAction))
// 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) {
if !strings.HasPrefix(c.Name, utils.DefaultCookiePrefix) {
r.AddCookie(c)
}
}
// set response headers
data.ResponseHeaders(w)
}
for _, rule := range state.rules {
next, err := rule.Evaluate(lg, w, r, func() http.Handler {
cleanupRequest(r, true)
return backend
cleanupRequest(r, true, rule.Name, rule.Action)
return getBackend()
})
if err != nil {
state.ErrorPage(w, r, http.StatusInternalServerError, err, "")
@@ -137,13 +293,13 @@ func (state *State) handleRequest(w http.ResponseWriter, r *http.Request) {
}
}
state.RuleHit(r, "DEFAULT", lg)
data.State.ActionHit(r, policy.RuleActionPASS, lg)
// 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
cleanupRequest(r, false, "DEFAULT", policy.RuleActionPASS)
return getBackend()
})
}
@@ -151,14 +307,6 @@ func (state *State) setupRoutes() error {
state.Mux.HandleFunc("/", state.handleRequest)
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()+"/assets/", gzipped.FileServer(gzipped.FS(embed.AssetsFs))))
for _, reg := range state.challenges {
@@ -179,9 +327,5 @@ func (state *State) ServeHTTP(w http.ResponseWriter, r *http.Request) {
data.EvaluateChallenges(w, r)
if state.Settings().MainName != "" {
w.Header().Add("Via", fmt.Sprintf("%s %s@%s", r.Proto, state.Settings().MainName, state.Settings().MainVersion))
}
state.Mux.ServeHTTP(w, r)
}

View File

@@ -1,14 +1,12 @@
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"
)
@@ -28,6 +26,10 @@ func (state *State) PrivateKey() ed25519.PrivateKey {
return state.privateKey
}
func (state *State) PrivateKeyFingerprint() []byte {
return state.privateKeyFingerprint
}
func (state *State) PublicKey() ed25519.PublicKey {
return state.publicKey
}
@@ -42,7 +44,7 @@ func (state *State) ChallengeFailed(r *http.Request, reg *challenge.Registration
}
logger.Warn("challenge failed", "challenge", reg.Name, "err", err, "redirect", redirect)
//TODO: metrics
metrics.Challenge(reg.Name, "fail")
}
func (state *State) ChallengePassed(r *http.Request, reg *challenge.Registration, redirect string, logger *slog.Logger) {
@@ -51,7 +53,7 @@ func (state *State) ChallengePassed(r *http.Request, reg *challenge.Registration
}
logger.Warn("challenge passed", "challenge", reg.Name, "redirect", redirect)
//TODO: metrics
metrics.Challenge(reg.Name, "pass")
}
func (state *State) ChallengeIssued(r *http.Request, reg *challenge.Registration, redirect string, logger *slog.Logger) {
@@ -60,69 +62,29 @@ func (state *State) ChallengeIssued(r *http.Request, reg *challenge.Registration
}
logger.Info("challenge issued", "challenge", reg.Name, "redirect", redirect)
//TODO: metrics
metrics.Challenge(reg.Name, "issue")
}
func (state *State) ChallengeChecked(r *http.Request, reg *challenge.Registration, redirect string, logger *slog.Logger) {
metrics.Challenge(reg.Name, "check")
}
func (state *State) RuleHit(r *http.Request, name string, logger *slog.Logger) {
metrics.Rule(name, "hit")
}
func (state *State) RuleMiss(r *http.Request, name string, logger *slog.Logger) {
metrics.Rule(name, "miss")
}
func (state *State) ActionHit(r *http.Request, name policy.RuleAction, logger *slog.Logger) {
metrics.Action(name)
}
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
@@ -136,10 +98,14 @@ func (state *State) GetChallengeByName(name string) (*challenge.Registration, bo
reg, _, ok := state.challenges.GetByName(name)
return reg, ok
}
func (state *State) Settings() policy.Settings {
func (state *State) Settings() policy.StateSettings {
return state.settings
}
func (state *State) Strings() utils.Strings {
return state.opt.Strings
}
func (state *State) GetBackend(host string) http.Handler {
return utils.SelectHTTPHandler(state.Settings().Backends, host)
}

50
lib/metrics.go Normal file
View File

@@ -0,0 +1,50 @@
package lib
import (
"git.gammaspectra.live/git/go-away/lib/policy"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
type stateMetrics struct {
rules *prometheus.CounterVec
actions *prometheus.CounterVec
challenges *prometheus.CounterVec
}
func newMetrics() *stateMetrics {
return &stateMetrics{
rules: promauto.NewCounterVec(prometheus.CounterOpts{
Name: "go-away_rule_results",
Help: "The number of rule hits or misses",
}, []string{"rule", "result"}),
actions: promauto.NewCounterVec(prometheus.CounterOpts{
Name: "go-away_action_results",
Help: "The number of each action issued",
}, []string{"action"}),
challenges: promauto.NewCounterVec(prometheus.CounterOpts{
Name: "go-away_challenge_results",
Help: "The number of challenges issued, passed or explicitly failed",
}, []string{"challenge", "action"}),
}
}
func (metrics *stateMetrics) Rule(name, result string) {
metrics.rules.With(prometheus.Labels{"rule": name, "result": result}).Inc()
}
func (metrics *stateMetrics) Action(action policy.RuleAction) {
metrics.actions.With(prometheus.Labels{"action": string(action)}).Inc()
}
func (metrics *stateMetrics) Challenge(name, result string) {
metrics.challenges.With(prometheus.Labels{"challenge": name, "action": result}).Inc()
}
func (metrics *stateMetrics) Reset() {
metrics.rules.Reset()
metrics.actions.Reset()
metrics.challenges.Reset()
}
var metrics = newMetrics()

View File

@@ -1,22 +0,0 @@
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

@@ -20,66 +20,77 @@ type Policy struct {
Rules []Rule `yaml:"rules"`
}
func NewPolicy(r io.Reader, snippetsDirectory string) (*Policy, error) {
func NewPolicy(r io.Reader, snippetsDirectories ...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 == "" {
if len(snippetsDirectories) == 0 {
err := yaml.NewDecoder(r).Decode(&p)
if err != nil {
return nil, err
}
} else {
err := yaml.NewDecoder(r, yaml.ReferenceDirs(snippetsDirectory)).Decode(&p)
var entries []string
for _, dir := range snippetsDirectories {
if dir == "" {
// skip nil directories
continue
}
dirFiles, err := os.ReadDir(dir)
if err != nil {
return nil, err
}
for _, file := range dirFiles {
if file.IsDir() {
continue
}
entries = append(entries, path.Join(dir, file.Name()))
}
}
err := yaml.NewDecoder(r, yaml.ReferenceFiles(entries...)).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
}
entryData, err := os.ReadFile(entry)
if err != nil {
return nil, err
}
err = yaml.NewDecoder(bytes.NewReader(entryData), yaml.ReferenceFiles(entries...)).Decode(&entryPolicy)
if err != nil {
return nil, err
}
// add networks / conditions / challenges definitions if they don't exist already
// 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.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.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
}
for k, v := range entryPolicy.Challenges {
// add challenge if policy entry does not exist
_, ok := p.Challenges[k]
if !ok {
p.Challenges[k] = v
}
}
}
}

View File

@@ -26,6 +26,9 @@ const (
// RuleActionPROXY Proxies request to a backend, with optional path replacements
RuleActionPROXY RuleAction = "PROXY"
// RuleActionCONTEXT Changes Request Context information or properties
RuleActionCONTEXT RuleAction = "CONTEXT"
)
type Rule struct {

19
lib/policy/state.go Normal file
View File

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

View File

@@ -1,12 +1,12 @@
package lib
import (
http_cel "codeberg.org/gone/http-cel"
"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"
@@ -29,7 +29,6 @@ type RuleState struct {
}
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))
@@ -38,7 +37,7 @@ func NewRuleState(state challenge.StateInterface, r policy.Rule, replacer *strin
}
hasher.Write([]byte(r.Name))
hasher.Write([]byte{0})
hasher.Write(fp[:])
hasher.Write(state.PrivateKeyFingerprint())
sum := hasher.Sum(nil)
rule := RuleState{
@@ -66,14 +65,9 @@ func NewRuleState(state challenge.StateInterface, r policy.Rule, replacer *strin
conditions = append(conditions, cond)
}
ast, err := condition.FromStrings(state.ProgramEnv(), condition.OperatorOr, conditions...)
program, err := state.RegisterCondition(http_cel.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)
return RuleState{}, fmt.Errorf("error compiling condition: %w", err)
}
rule.Condition = program
}
@@ -107,6 +101,9 @@ func (rule RuleState) Evaluate(logger *slog.Logger, w http.ResponseWriter, r *ht
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 {
data.State.RuleHit(r, rule.Name, logger)
data.State.ActionHit(r, rule.Action, logger)
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)
@@ -134,7 +131,13 @@ func (rule RuleState) Evaluate(logger *slog.Logger, w http.ResponseWriter, r *ht
return next, nil
}
}
} else {
data.State.RuleMiss(r, rule.Name, logger)
}
} else if out != nil {
err := fmt.Errorf("return type not Bool, got %s", out.Type().TypeName())
lg.Error(err.Error())
return false, fmt.Errorf("error: evaluating administrative rule %s/%s: %w", data.Id.String(), rule.Hash, err)
}
return true, nil

162
lib/settings/backend.go Normal file
View File

@@ -0,0 +1,162 @@
package settings
import (
"git.gammaspectra.live/git/go-away/lib/challenge"
"git.gammaspectra.live/git/go-away/utils"
"net/http"
"net/http/httputil"
"time"
)
type Backend struct {
// URL Target server backend path. Supports http/https/unix protocols.
URL string `yaml:"url"`
// Host Override the Host header and TLS SNI with this value if specified
Host string `yaml:"host"`
//ProxyProtocol uint8 `yaml:"proxy-protocol"`
// HTTP2Enabled Enable HTTP2 to backend
HTTP2Enabled bool `yaml:"http2-enabled"`
// TLSSkipVerify Disable TLS certificate verification, if any
TLSSkipVerify bool `yaml:"tls-skip-verify"`
// IpHeader HTTP header to set containing the IP header. Set - to forcefully ignore global defaults.
IpHeader string `yaml:"ip-header"`
// GoDNS Resolve URL using the Go DNS server
// Only relevant when running with CGO enabled
GoDNS bool `yaml:"go-dns"`
// Transparent Do not add extra headers onto this backend
// This prevents GoAway headers from being set, or other state
Transparent bool `yaml:"transparent"`
// DialTimeout is the maximum amount of time a dial will wait for
// a connect to complete.
//
// The default is no timeout.
//
// When using TCP and dialing a host name with multiple IP
// addresses, the timeout may be divided between them.
//
// With or without a timeout, the operating system may impose
// its own earlier timeout. For instance, TCP timeouts are
// often around 3 minutes.
DialTimeout time.Duration `yaml:"dial-timeout"`
// TLSHandshakeTimeout specifies the maximum amount of time to
// wait for a TLS handshake. Zero means no timeout.
TLSHandshakeTimeout time.Duration `yaml:"tls-handshake-timeout"`
// IdleConnTimeout is the maximum amount of time an idle
// (keep-alive) connection will remain idle before closing
// itself.
// Zero means no limit.
IdleConnTimeout time.Duration `yaml:"idle-conn-timeout"`
// ResponseHeaderTimeout, if non-zero, specifies the amount of
// time to wait for a server's response headers after fully
// writing the request (including its body, if any). This
// time does not include the time to read the response body.
ResponseHeaderTimeout time.Duration `yaml:"response-header-timeout"`
// ExpectContinueTimeout, if non-zero, specifies the amount of
// time to wait for a server's first response headers after fully
// writing the request headers if the request has an
// "Expect: 100-continue" header. Zero means no timeout and
// causes the body to be sent immediately, without
// waiting for the server to approve.
// This time does not include the time to send the request header.
ExpectContinueTimeout time.Duration `yaml:"expect-continue-timeout"`
}
func (b Backend) Create() (*httputil.ReverseProxy, error) {
if b.IpHeader == "-" {
b.IpHeader = ""
}
proxy, err := utils.MakeReverseProxy(b.URL, b.GoDNS, b.DialTimeout)
if err != nil {
return nil, err
}
transport := proxy.Transport.(*http.Transport)
// set transport timeouts
transport.TLSHandshakeTimeout = b.TLSHandshakeTimeout
transport.IdleConnTimeout = b.IdleConnTimeout
transport.ResponseHeaderTimeout = b.ResponseHeaderTimeout
transport.ExpectContinueTimeout = b.ExpectContinueTimeout
if b.HTTP2Enabled {
transport.ForceAttemptHTTP2 = true
}
if b.TLSSkipVerify {
transport.TLSClientConfig.InsecureSkipVerify = true
}
if b.Host != "" {
transport.TLSClientConfig.ServerName = b.Host
}
if b.IpHeader != "" || b.Host != "" || !b.Transparent {
director := proxy.Director
proxy.Director = func(req *http.Request) {
if !b.Transparent {
if data := challenge.RequestDataFromContext(req.Context()); data != nil {
data.RequestHeaders(req.Header)
}
}
if b.IpHeader != "" && !b.Transparent {
if ip := utils.GetRemoteAddress(req.Context()); ip != nil {
req.Header.Set(b.IpHeader, ip.Addr().Unmap().String())
}
}
if b.Host != "" {
req.Host = b.Host
}
director(req)
}
}
/*if b.ProxyProtocol > 0 {
dialContext := transport.DialContext
if dialContext == nil {
dialContext = (&net.Dialer{}).DialContext
}
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
conn, err := dialContext(ctx, network, addr)
if err != nil {
return nil, err
}
addrPort := utils.GetRemoteAddress(ctx)
if addrPort == nil {
// pass as is
hdr := proxyproto.HeaderProxyFromAddrs(b.ProxyProtocol, conn.LocalAddr(), conn.RemoteAddr())
_, err = hdr.WriteTo(conn)
if err != nil {
conn.Close()
return nil, err
}
} else {
// set proper headers!
hdr := proxyproto.HeaderProxyFromAddrs(b.ProxyProtocol, net.TCPAddrFromAddrPort(*addrPort), conn.RemoteAddr())
_, err = hdr.WriteTo(conn)
if err != nil {
conn.Close()
return nil, err
}
}
return conn, nil
}
}*/
proxy.Transport = transport
return proxy, nil
}

337
lib/settings/bind.go Normal file
View File

@@ -0,0 +1,337 @@
package settings
import (
"context"
"crypto/tls"
"fmt"
"git.gammaspectra.live/git/go-away/utils"
"github.com/pires/go-proxyproto"
"golang.org/x/crypto/acme"
"golang.org/x/crypto/acme/autocert"
"log/slog"
"net"
"net/http"
"os"
"strconv"
"strings"
"sync/atomic"
"time"
)
type TLSEntry struct {
// Certificate Path to the certificate file
Certificate string `yaml:"certificate"`
// Key Path to the corresponding key file
Key string `yaml:"key"`
}
type Bind struct {
Address string `yaml:"address"`
Network string `yaml:"network"`
SocketMode string `yaml:"socket-mode"`
Proxy bool `yaml:"proxy"`
Passthrough bool `yaml:"passthrough"`
// TLSAcmeAutoCert URL to ACME directory, or letsencrypt
TLSAcmeAutoCert string `yaml:"tls-acme-autocert"`
// TLSEntries Alternate to TLSAcmeAutoCert. Allows multiple entries with matching.
// Entries on this list can be live-reloaded if application implements SIGHUP handling
TLSEntries []TLSEntry `yaml:"tls-entries"`
// TLSCertificate Alternate to TLSAcmeAutoCert. Preferred over TLSEntries if specified.
TLSCertificate string `yaml:"tls-certificate"`
// TLSPrivateKey Alternate to TLSAcmeAutoCert. Preferred over TLSEntries if specified.
TLSPrivateKey string `yaml:"tls-key"`
// General TLS config
// TLSMinVersion TLS Minimum supported version.
// Default is Golang's default, at writing time it's TLS 1.2. Lowest supported is TLS 1.0
TLSMinVersion string `yaml:"tls-min-version"`
// TLSMaxVersion TLS Maximum supported version.
// Default is Golang's default, at writing time it's TLS 1.3, and is automatically increased.
// Lowest supported is TLS 1.2
TLSMaxVersion string `yaml:"tls-max-version"`
// TLSCurves List of supported TLS curve ids from Golang internals
// See this list https://github.com/golang/go/blob/go1.24.0/src/crypto/tls/common.go#L138-L153 for supported values
// Default values are chosen by Golang. It's recommended to leave the default
TLSCurves []tls.CurveID `yaml:"tls-curves"`
// TLSCiphers List of supported TLS ciphers from Golang internals, case sensitive. TLS 1.3 suites are not configurable.
// See this list https://github.com/golang/go/blob/go1.24.0/src/crypto/tls/cipher_suites.go#L56-L73 for supported values
// Default values are chosen by Golang. It's recommended to leave the default
TLSCiphers []string `yaml:"tls-ciphers"`
// ReadTimeout is the maximum duration for reading the entire
// request, including the body. A zero or negative value means
// there will be no timeout.
//
// Because ReadTimeout does not let Handlers make per-request
// decisions on each request body's acceptable deadline or
// upload rate, most users will prefer to use
// ReadHeaderTimeout. It is valid to use them both.
ReadTimeout time.Duration `yaml:"read-timeout"`
// ReadHeaderTimeout is the amount of time allowed to read
// request headers. The connection's read deadline is reset
// after reading the headers and the Handler can decide what
// is considered too slow for the body. If zero, the value of
// ReadTimeout is used. If negative, or if zero and ReadTimeout
// is zero or negative, there is no timeout.
ReadHeaderTimeout time.Duration `yaml:"read-header-timeout"`
// WriteTimeout is the maximum duration before timing out
// writes of the response. It is reset whenever a new
// request's header is read. Like ReadTimeout, it does not
// let Handlers make decisions on a per-request basis.
// A zero or negative value means there will be no timeout.
WriteTimeout time.Duration `yaml:"write-timeout"`
// IdleTimeout is the maximum amount of time to wait for the
// next request when keep-alives are enabled. If zero, the value
// of ReadTimeout is used. If negative, or if zero and ReadTimeout
// is zero or negative, there is no timeout.
IdleTimeout time.Duration `yaml:"idle-timeout"`
}
func (b *Bind) Listener() (net.Listener, string) {
return setupListener(b.Network, b.Address, b.SocketMode, b.Proxy)
}
func (b *Bind) Server(backends map[string]http.Handler, acmeCachePath string) (*http.Server, func(http.Handler), error) {
var tlsConfig *tls.Config
if b.TLSAcmeAutoCert != "" {
switch b.TLSAcmeAutoCert {
case "letsencrypt":
b.TLSAcmeAutoCert = acme.LetsEncryptURL
}
acmeManager := newACMEManager(b.TLSAcmeAutoCert, backends)
if acmeCachePath != "" {
err := os.MkdirAll(acmeCachePath, 0755)
if err != nil {
return nil, nil, fmt.Errorf("failed to create acme cache directory: %w", err)
}
acmeManager.Cache = autocert.DirCache(acmeCachePath)
}
slog.Warn(
"acme-autocert enabled",
"directory", b.TLSAcmeAutoCert,
)
tlsConfig = acmeManager.TLSConfig()
} else if b.TLSCertificate != "" && b.TLSPrivateKey != "" {
tlsConfig = &tls.Config{}
var err error
tlsConfig.Certificates = make([]tls.Certificate, 1)
tlsConfig.Certificates[0], err = tls.LoadX509KeyPair(b.TLSCertificate, b.TLSPrivateKey)
if err != nil {
return nil, nil, err
}
slog.Warn(
"TLS enabled",
"certificate", b.TLSCertificate,
)
} else if len(b.TLSEntries) > 0 {
tlsConfig = &tls.Config{}
var err error
var certificatesPtr atomic.Pointer[[]tls.Certificate]
swapTls := func() error {
certs := make([]tls.Certificate, 0, len(b.TLSEntries))
for _, entry := range b.TLSEntries {
cert, err := tls.LoadX509KeyPair(entry.Certificate, entry.Key)
if err != nil {
return fmt.Errorf("failed to load TLS certificate %s: %w", entry.Certificate, err)
}
certs = append(certs, cert)
}
certificatesPtr.Swap(&certs)
return nil
}
tlsConfig.GetCertificate = func(clientHello *tls.ClientHelloInfo) (*tls.Certificate, error) {
certs := certificatesPtr.Load()
if certs == nil || len(*certs) == 0 {
panic("no certificates found")
}
for _, cert := range *certs {
if err := clientHello.SupportsCertificate(&cert); err == nil {
return &cert, nil
}
}
// if none match, return first
return &(*certs)[0], nil
}
err = swapTls()
if err != nil {
return nil, nil, err
}
slog.Warn(
"TLS enabled with multiple certificates",
"certificates", len(b.TLSEntries),
)
}
if tlsConfig != nil {
if b.TLSMinVersion != "" {
switch strings.NewReplacer("-", "", "_", "", " ", "", ".", "").Replace(strings.ToLower(b.TLSMinVersion)) {
case "13", "tls13":
tlsConfig.MinVersion = tls.VersionTLS13
case "12", "tls12":
tlsConfig.MinVersion = tls.VersionTLS12
case "11", "tls11":
tlsConfig.MinVersion = tls.VersionTLS11
case "10", "tls10":
tlsConfig.MinVersion = tls.VersionTLS10
default:
return nil, nil, fmt.Errorf("unsupported minimum TLS version: %s", b.TLSMinVersion)
}
}
if b.TLSMaxVersion != "" {
switch strings.NewReplacer("-", "", "_", "", " ", "", ".", "").Replace(strings.ToLower(b.TLSMaxVersion)) {
case "13", "tls13":
tlsConfig.MaxVersion = tls.VersionTLS13
case "12", "tls12":
tlsConfig.MaxVersion = tls.VersionTLS12
default:
return nil, nil, fmt.Errorf("unsupported maximum TLS version: %s", b.TLSMinVersion)
}
}
if len(b.TLSCiphers) > 0 {
for _, cipher := range b.TLSCiphers {
if c := func() *tls.CipherSuite {
for _, c := range tls.CipherSuites() {
if c.Name == cipher {
return c
}
}
for _, c := range tls.InsecureCipherSuites() {
if c.Name == cipher {
return c
}
}
return nil
}(); c != nil {
tlsConfig.CipherSuites = append(tlsConfig.CipherSuites, c.ID)
} else {
return nil, nil, fmt.Errorf("unsupported TLS cipher suite: %s", cipher)
}
}
}
if len(b.TLSCurves) > 0 {
tlsConfig.CurvePreferences = append(tlsConfig.CurvePreferences, b.TLSCurves...)
}
}
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)
server.ReadTimeout = b.ReadTimeout
server.ReadHeaderTimeout = b.ReadHeaderTimeout
server.WriteTimeout = b.WriteTimeout
server.IdleTimeout = b.IdleTimeout
swap := func(handler http.Handler) {
serverHandler.Store(&handler)
}
if b.Passthrough {
// setup a passthrough handler temporarily
swap(http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
backend := utils.SelectHTTPHandler(backends, r.Host)
if backend == nil {
slog.Debug("no backend for host", "host", r.Host)
http.Error(w, http.StatusText(http.StatusBadGateway), http.StatusBadGateway)
} else {
backend.ServeHTTP(w, r)
}
})))
}
return server, swap, nil
}
func setupListener(network, address, socketMode string, proxy bool) (net.Listener, string) {
if network == "proxy" {
network = "tcp"
proxy = true
}
formattedAddress := ""
switch network {
case "unix":
formattedAddress = "unix:" + address
case "tcp":
formattedAddress = "http://localhost" + address
default:
formattedAddress = fmt.Sprintf(`(%s) %s`, network, address)
}
listener, err := net.Listen(network, address)
if err != nil {
panic(fmt.Errorf("failed to bind to %s: %w", formattedAddress, err))
}
// additional permission handling for unix sockets
if network == "unix" {
mode, err := strconv.ParseUint(socketMode, 8, 0)
if err != nil {
listener.Close()
panic(fmt.Errorf("could not parse socket mode %s: %w", socketMode, err))
}
err = os.Chmod(address, os.FileMode(mode))
if err != nil {
listener.Close()
panic(fmt.Errorf("could not change socket mode: %w", err))
}
}
if proxy {
slog.Warn("listener PROXY enabled")
formattedAddress += " +PROXY"
listener = &proxyproto.Listener{
Listener: listener,
}
}
return listener, formattedAddress
}
func newACMEManager(clientDirectory string, backends map[string]http.Handler) *autocert.Manager {
manager := &autocert.Manager{
Prompt: autocert.AcceptTOS,
HostPolicy: autocert.HostPolicy(func(ctx context.Context, host string) error {
if utils.SelectHTTPHandler(backends, host) != nil {
return nil
}
return fmt.Errorf("acme/autocert: host %s not configured in backends", host)
}),
Client: &acme.Client{
HTTPClient: http.DefaultClient,
DirectoryURL: clientDirectory,
},
}
return manager
}

52
lib/settings/settings.go Normal file
View File

@@ -0,0 +1,52 @@
package settings
import (
"git.gammaspectra.live/git/go-away/utils"
"maps"
)
type Settings struct {
Bind Bind `yaml:"bind"`
Backends map[string]Backend `yaml:"backends"`
BindDebug string `yaml:"bind-debug"`
BindMetrics string `yaml:"bind-metrics"`
Strings utils.Strings `yaml:"strings"`
// Links to add to challenge/error pages like privacy/impressum.
Links []Link `yaml:"links"`
ChallengeTemplate string `yaml:"challenge-template"`
// ChallengeTemplateOverrides Key/Value overrides for the current chosen template
ChallengeTemplateOverrides map[string]string `yaml:"challenge-template-overrides"`
}
type Link struct {
Name string `yaml:"name"`
URL string `yaml:"url"`
}
var DefaultSettings = Settings{
Strings: DefaultStrings,
ChallengeTemplate: "anubis",
ChallengeTemplateOverrides: func() map[string]string {
m := make(map[string]string)
maps.Copy(m, map[string]string{
"Theme": "",
"Logo": "",
})
return m
}(),
Bind: Bind{
Address: ":8080",
Network: "tcp",
SocketMode: "0770",
Proxy: false,
TLSAcmeAutoCert: "",
},
Backends: make(map[string]Backend),
}

38
lib/settings/strings.go Normal file
View File

@@ -0,0 +1,38 @@
package settings
import (
"git.gammaspectra.live/git/go-away/utils"
)
var DefaultStrings = utils.NewStrings(map[string]string{
"title_challenge": "Checking you are not a bot",
"title_error": "Oh no!",
"noscript_warning": "<p>Sadly, you may need to enable JavaScript to get past this challenge. This is required because AI companies have changed the social contract around how website hosting works.</p>",
"details_title": "Why am I seeing this?",
"details_text": `
<p>
You are seeing this because the administrator of this website has set up <a href="https://git.gammaspectra.live/git/go-away">go-away</a>
to protect the server against the scourge of <a href="https://thelibre.news/foss-infrastructure-is-under-attack-by-ai-companies/">AI companies aggressively scraping websites</a>.
</p>
<p>
Mass scraping can and does cause downtime for the websites, which makes their resources inaccessible for everyone.
</p>
<p>
Please note that some challenges requires the use of modern JavaScript features and some plugins may disable these.
Disable such plugins for this domain (for example, JShelter) if you encounter any issues.
</p>
`,
"details_contact_admin_with_request_id": "If you have any issues contact the site administrator and provide the following Request Id",
"button_refresh_page": "Refresh page",
"status_loading_challenge": "Loading challenge",
"status_starting_challenge": "Starting challenge",
"status_loading": "Loading...",
"status_calculating": "Calculating...",
"status_challenge_success": "Challenge success!",
"status_challenge_done_took": "Done! Took",
"status_error": "Error:",
})

View File

@@ -1,23 +1,30 @@
package lib
import (
http_cel "codeberg.org/gone/http-cel"
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"git.gammaspectra.live/git/go-away/lib/challenge"
"git.gammaspectra.live/git/go-away/lib/condition"
"git.gammaspectra.live/git/go-away/lib/policy"
"git.gammaspectra.live/git/go-away/lib/settings"
"git.gammaspectra.live/git/go-away/utils"
"github.com/google/cel-go/cel"
"github.com/google/cel-go/common/types"
"github.com/yl2chen/cidranger"
"golang.org/x/net/html"
"log/slog"
"net"
"net/http"
"net/http/httputil"
"os"
"path"
"strconv"
"strings"
"sync"
"time"
)
@@ -28,12 +35,14 @@ type State struct {
programEnv *cel.Env
publicKey ed25519.PublicKey
privateKey ed25519.PrivateKey
publicKey ed25519.PublicKey
privateKey ed25519.PrivateKey
privateKeyFingerprint []byte
settings policy.Settings
opt settings.Settings
settings policy.StateSettings
networks map[string]cidranger.Ranger
networks map[string]func() cidranger.Ranger
challenges challenge.Register
@@ -41,13 +50,17 @@ type State struct {
close chan struct{}
tagCache *utils.DecayMap[string, []html.Node]
Mux *http.ServeMux
}
func NewState(p policy.Policy, settings policy.Settings) (handler http.Handler, err error) {
state := new(State)
func NewState(p policy.Policy, opt settings.Settings, settings policy.StateSettings) (state *State, err error) {
state = new(State)
state.close = make(chan struct{})
state.settings = settings
state.opt = opt
metrics.Reset()
state.client = &http.Client{
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
@@ -58,7 +71,7 @@ func NewState(p policy.Policy, settings policy.Settings) (handler http.Handler,
return nil, fmt.Errorf("failed to initialize RADb client: %w", err)
}
state.urlPath = "/.well-known/." + state.Settings().PackageName
state.urlPath = state.Settings().BasePath
// set a reasonable configuration for default http proxy if there is none
for _, backend := range state.Settings().Backends {
@@ -89,97 +102,106 @@ func NewState(p policy.Policy, settings policy.Settings) (handler http.Handler,
}
}
if state.Settings().ChallengeTemplate == "" {
state.settings.ChallengeTemplate = "anubis"
}
fp := sha256.Sum256(state.privateKey)
state.privateKeyFingerprint = fp[:]
if templates["challenge-"+state.Settings().ChallengeTemplate+".gohtml"] == nil {
if templates["challenge-"+state.opt.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.opt.ChallengeTemplate); err == nil && len(data) > 0 {
name := path.Base(state.opt.ChallengeTemplate)
err := initTemplate(name, string(data))
if err != nil {
return nil, fmt.Errorf("error loading template %s: %w", settings.ChallengeTemplate, err)
return nil, fmt.Errorf("error loading template %s: %w", state.opt.ChallengeTemplate, err)
}
state.settings.ChallengeTemplate = name
state.opt.ChallengeTemplate = name
} else {
return nil, fmt.Errorf("no template defined for %s", state.opt.ChallengeTemplate)
}
return nil, fmt.Errorf("no template defined for %s", settings.ChallengeTemplate)
}
state.networks = make(map[string]cidranger.Ranger)
state.networks = make(map[string]func() cidranger.Ranger)
networkCache := utils.CachePrefix(state.Settings().Cache, "networks/")
for k, network := range p.Networks {
state.networks[k] = sync.OnceValue[cidranger.Ranger](func() cidranger.Ranger {
ranger := cidranger.NewPCTrieRanger()
for i, e := range network {
prefixes, err := func() ([]net.IPNet, error) {
var useCache bool
ranger := cidranger.NewPCTrieRanger()
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)
if e.Url != nil {
slog.Debug("loading network url list", "network", k, "url", *e.Url)
useCache = true
sum := sha256.Sum256([]byte(*e.Url))
cacheKey += hex.EncodeToString(sum[:4])
} else if e.ASN != nil {
slog.Debug("loading ASN", "network", k, "asn", *e.ASN)
useCache = true
cacheKey += strconv.FormatInt(int64(*e.ASN), 10)
}
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)
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 {
cached = append(cached, *ipNet)
// use
return cached, nil
}
}
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
}
}
prefixes, err := e.FetchPrefixes(state.client, state.radb)
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
}()
if err != nil {
if len(cached) > 0 {
// use cached meanwhile
return cached, err
if e.Url != nil {
slog.Error("error loading network list", "network", k, "url", *e.Url, "error", err)
} else if e.ASN != nil {
slog.Error("error loading ASN", "network", k, "asn", *e.ASN, "error", err)
} else {
slog.Error("error loading list", "network", k, "error", err)
}
return nil, err
continue
}
if useCache && networkCache != nil {
var l []string
for _, n := range prefixes {
l = append(l, n.String())
for _, prefix := range prefixes {
err = ranger.Insert(cidranger.NewBasicRangerEntry(prefix))
if err != nil {
slog.Error("error inserting prefix", "network", k, "prefix", prefix.String(), "error", err)
}
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
slog.Warn("loaded network prefixes", "network", k, "count", ranger.Len())
return ranger
})
}
err = state.initConditions()
@@ -189,11 +211,17 @@ func NewState(p policy.Policy, settings policy.Settings) (handler http.Handler,
var replacements []string
for k, entries := range p.Conditions {
ast, err := condition.FromStrings(state.programEnv, condition.OperatorOr, entries...)
ast, err := http_cel.NewAst(state.programEnv, http_cel.OperatorOr, entries...)
if err != nil {
return nil, fmt.Errorf("conditions %s: error compiling conditions: %v", k, err)
}
if out := ast.OutputType(); out == nil {
return nil, fmt.Errorf("conditions %s: error compiling conditions: no output", k)
} else if out != types.BoolType {
return nil, fmt.Errorf("conditions %s: error compiling conditions: output type is not bool", k)
}
cond, err := cel.AstToString(ast)
if err != nil {
return nil, fmt.Errorf("conditions %s: error printing condition: %v", k, err)
@@ -215,7 +243,6 @@ func NewState(p policy.Policy, settings policy.Settings) (handler http.Handler,
}
for _, r := range p.Rules {
rule, err := NewRuleState(state, r, conditionReplacer, nil)
if err != nil {
return nil, fmt.Errorf("rule %s: %w", r.Name, err)
@@ -232,5 +259,38 @@ func NewState(p policy.Policy, settings policy.Settings) (handler http.Handler,
return nil, err
}
state.tagCache = utils.NewDecayMap[string, []html.Node]()
go func() {
ticker := time.NewTicker(time.Minute * 37)
defer ticker.Stop()
for {
select {
case <-ticker.C:
state.tagCache.Decay()
case <-state.close:
return
}
}
}()
return state, nil
}
func (state *State) Close() error {
select {
case <-state.close:
default:
close(state.close)
for _, c := range state.challenges {
if c.Object != nil {
err := c.Object.Close()
if err != nil {
return err
}
}
}
}
return nil
}

151
lib/template.go Normal file
View File

@@ -0,0 +1,151 @@
package lib
import (
"bytes"
"git.gammaspectra.live/git/go-away/embed"
"git.gammaspectra.live/git/go-away/lib/challenge"
"git.gammaspectra.live/git/go-away/utils"
"html/template"
"maps"
"net/http"
)
var templates map[string]*template.Template
func init() {
templates = make(map[string]*template.Template)
dir, err := embed.TemplatesFs.ReadDir(".")
if err != nil {
panic(err)
}
for _, e := range dir {
if e.IsDir() {
continue
}
data, err := embed.TemplatesFs.ReadFile(e.Name())
if err != nil {
panic(err)
}
err = initTemplate(e.Name(), string(data))
if err != nil {
panic(err)
}
}
}
func initTemplate(name, data string) error {
tpl := template.New(name).Funcs(template.FuncMap{
"attr": func(s string) template.HTMLAttr {
return template.HTMLAttr(s)
},
"safe": func(s string) template.HTML {
return template.HTML(s)
},
})
_, err := tpl.Parse(data)
if err != nil {
return err
}
templates[name] = tpl
return nil
}
func (state *State) addCachedTags(data *challenge.RequestData, r *http.Request, input map[string]any) {
proxyMetaTags := data.GetOptBool(challenge.RequestOptProxyMetaTags, false)
proxySafeLinkTags := data.GetOptBool(challenge.RequestOptProxySafeLinkTags, false)
if proxyMetaTags || proxySafeLinkTags {
backend, host := data.BackendHost()
if tags := state.fetchTags(host, backend, r, proxyMetaTags, proxySafeLinkTags); len(tags) > 0 {
metaTagMap, _ := input["MetaTags"].([]map[string]string)
linkTagMap, _ := input["LinkTags"].([]map[string]string)
for _, tag := range tags {
tagAttrs := make(map[string]string, len(tag.Attr))
for _, v := range tag.Attr {
tagAttrs[v.Key] = v.Val
}
metaTagMap = append(metaTagMap, tagAttrs)
}
input["MetaTags"] = metaTagMap
input["LinkTags"] = linkTagMap
}
}
}
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.StaticCacheBust()
input["Path"] = state.UrlPath()
input["Links"] = state.opt.Links
input["Strings"] = state.opt.Strings
for k, v := range state.opt.ChallengeTemplateOverrides {
input[k] = v
}
if reg != nil {
input["Challenge"] = reg.Name
}
maps.Copy(input, params)
if _, ok := input["Title"]; !ok {
input["Title"] = state.opt.Strings.Get("title_challenge")
}
state.addCachedTags(data, r, input)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
buf := bytes.NewBuffer(make([]byte, 0, 8192))
err := templates["challenge-"+state.opt.ChallengeTemplate+".gohtml"].Execute(buf, input)
if err != nil {
state.ErrorPage(w, r, http.StatusInternalServerError, err, "")
} else {
data.ResponseHeaders(w)
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")
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
buf := bytes.NewBuffer(make([]byte, 0, 8192))
input := map[string]any{
"Id": data.Id.String(),
"Random": utils.StaticCacheBust(),
"Error": err.Error(),
"Path": state.UrlPath(),
"Theme": "",
"Title": template.HTML(string(state.opt.Strings.Get("title_error")) + " " + http.StatusText(status)),
"Challenge": "",
"Redirect": redirect,
"Links": state.opt.Links,
"Strings": state.opt.Strings,
}
for k, v := range state.opt.ChallengeTemplateOverrides {
input[k] = v
}
state.addCachedTags(data, r, input)
err2 := templates["challenge-"+state.opt.ChallengeTemplate+".gohtml"].Execute(buf, input)
if err2 != nil {
// nested errors!
panic(err2)
} else {
data.ResponseHeaders(w)
w.WriteHeader(status)
_, _ = w.Write(buf.Bytes())
}
}

View File

@@ -6,7 +6,7 @@ import (
"time"
)
var CookiePrefix = ".go-away-"
var DefaultCookiePrefix = ".go-away-"
// 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

View File

@@ -10,17 +10,17 @@ func zilch[T any]() T {
return zero
}
type DecayMap[K, V comparable] struct {
type DecayMap[K comparable, V any] struct {
data map[K]DecayMapEntry[V]
lock sync.RWMutex
}
type DecayMapEntry[V comparable] struct {
type DecayMapEntry[V any] struct {
Value V
expiry time.Time
}
func NewDecayMap[K, V comparable]() *DecayMap[K, V] {
func NewDecayMap[K comparable, V any]() *DecayMap[K, V] {
return &DecayMap[K, V]{
data: make(map[K]DecayMapEntry[V]),
}

View File

@@ -7,11 +7,15 @@ import (
"encoding/base64"
"errors"
"fmt"
"maps"
"net"
"net/http"
"net/http/httputil"
"net/netip"
"net/url"
"slices"
"strings"
"time"
)
func NewServer(handler http.Handler, tlsConfig *tls.Config) *http.Server {
@@ -68,13 +72,14 @@ func EnsureNoOpenRedirect(redirect string) (string, error) {
return uri.String(), nil
}
func MakeReverseProxy(target string) (*httputil.ReverseProxy, error) {
func MakeReverseProxy(target string, goDns bool, dialTimeout time.Duration) (*httputil.ReverseProxy, error) {
u, err := url.Parse(target)
if err != nil {
return nil, fmt.Errorf("failed to parse target URL: %w", err)
}
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.TLSClientConfig = &tls.Config{}
// https://github.com/oauth2-proxy/oauth2-proxy/blob/4e2100a2879ef06aea1411790327019c1a09217c/pkg/upstream/http.go#L124
if u.Scheme == "unix" {
@@ -83,14 +88,30 @@ func MakeReverseProxy(target string) (*httputil.ReverseProxy, error) {
u.Path = ""
// tell transport how to dial unix sockets
transport.DialContext = func(ctx context.Context, _, _ string) (net.Conn, error) {
dialer := net.Dialer{}
dialer := net.Dialer{
Timeout: dialTimeout,
}
return dialer.DialContext(ctx, "unix", addr)
}
// tell transport how to handle the unix url scheme
transport.RegisterProtocol("unix", UnixRoundTripper{Transport: transport})
} else if goDns {
dialer := &net.Dialer{
Resolver: &net.Resolver{
PreferGo: true,
},
Timeout: dialTimeout,
}
transport.DialContext = dialer.DialContext
} else {
dialer := &net.Dialer{
Timeout: dialTimeout,
}
transport.DialContext = dialer.DialContext
}
rp := httputil.NewSingleHostReverseProxy(u)
rp.Transport = transport
return rp, nil
@@ -108,33 +129,91 @@ func GetRequestScheme(r *http.Request) string {
return "http"
}
func GetRequestAddress(r *http.Request, clientHeader string) net.IP {
var ipStr string
func GetRequestAddress(r *http.Request, clientHeader string) netip.AddrPort {
strVal := r.RemoteAddr
if clientHeader != "" {
ipStr = r.Header.Get(clientHeader)
strVal = r.Header.Get(clientHeader)
}
if ipStr != "" {
if strVal != "" {
// handle X-Forwarded-For
ipStr = strings.Split(ipStr, ",")[0]
strVal = strings.Split(strVal, ",")[0]
}
// fallback
if ipStr == "" {
ipStr, _, _ = net.SplitHostPort(r.RemoteAddr)
if strVal == "" {
strVal = r.RemoteAddr
}
ipStr = strings.Trim(ipStr, "[]")
return net.ParseIP(ipStr)
addrPort, err := netip.ParseAddrPort(strVal)
if err != nil {
addr, err2 := netip.ParseAddr(strVal)
if err2 != nil {
return netip.AddrPort{}
}
addrPort = netip.AddrPortFrom(addr, 0)
}
return addrPort
}
func CacheBust() string {
return cacheBust
type remoteAddress struct{}
func SetRemoteAddress(r *http.Request, addrPort netip.AddrPort) *http.Request {
return r.WithContext(context.WithValue(r.Context(), remoteAddress{}, addrPort))
}
func GetRemoteAddress(ctx context.Context) *netip.AddrPort {
ip, ok := ctx.Value(remoteAddress{}).(netip.AddrPort)
if !ok {
return nil
}
return &ip
}
var cacheBust string
func init() {
buf := make([]byte, 16)
func RandomCacheBust(n int) string {
buf := make([]byte, n)
_, _ = rand.Read(buf)
cacheBust = base64.RawURLEncoding.EncodeToString(buf)
return base64.RawURLEncoding.EncodeToString(buf)
}
var staticCacheBust = RandomCacheBust(16)
func StaticCacheBust() string {
return staticCacheBust
}
func ParseRawQuery(rawQuery string) (m url.Values, err error) {
m = make(url.Values)
for rawQuery != "" {
var key string
key, rawQuery, _ = strings.Cut(rawQuery, "&")
if strings.Contains(key, ";") {
err = fmt.Errorf("invalid semicolon separator in query")
continue
}
if key == "" {
continue
}
key, value, _ := strings.Cut(key, "=")
m[key] = append(m[key], value)
}
return m, err
}
func EncodeRawQuery(v url.Values) string {
if len(v) == 0 {
return ""
}
var buf strings.Builder
for _, k := range slices.Sorted(maps.Keys(v)) {
vs := v[k]
for _, v := range vs {
if buf.Len() > 0 {
buf.WriteByte('&')
}
buf.WriteString(k)
buf.WriteByte('=')
buf.WriteString(v)
}
}
return buf.String()
}

View File

@@ -53,6 +53,9 @@ func (db *RADb) query(fn func(n int, record []byte) error, queries ...string) er
scanner := bufio.NewScanner(conn)
scanner.Split(bufio.ScanLines)
// 16 MiB lines
const bufferSize = 1024 * 1024 * 16
scanner.Buffer(make([]byte, 0, bufferSize), bufferSize)
for _, q := range queries {
@@ -76,6 +79,10 @@ func (db *RADb) query(fn func(n int, record []byte) error, queries ...string) er
}
n++
}
if scanner.Err() != nil {
return scanner.Err()
}
}
if len(queries) > 1 {
@@ -90,11 +97,6 @@ func (db *RADb) query(fn func(n int, record []byte) error, queries ...string) er
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 {

26
utils/strings.go Normal file
View File

@@ -0,0 +1,26 @@
package utils
import (
"html/template"
"maps"
)
type Strings map[string]string
func (s Strings) set(v map[string]string) Strings {
maps.Copy(s, v)
return s
}
func (s Strings) Get(value string) template.HTML {
v, ok := (s)[value]
if !ok {
// fallback
return template.HTML("string:" + value)
}
return template.HTML(v)
}
func NewStrings[T ~map[string]string](v T) Strings {
return make(Strings).set(v)
}

55
utils/tagfetcher.go Normal file
View File

@@ -0,0 +1,55 @@
package utils
import (
"golang.org/x/net/html"
"mime"
"net/http"
"net/http/httptest"
"net/url"
"slices"
)
func FetchTags(backend http.Handler, uri *url.URL, kinds ...string) (result []html.Node) {
writer := httptest.NewRecorder()
backend.ServeHTTP(writer, &http.Request{
Method: http.MethodGet,
URL: uri,
Header: http.Header{
"User-Agent": []string{"Mozilla 5.0 (compatible; go-away/1.0 fetch-tags) TwitterBot/1.0"},
"Accept": []string{"text/html,application/xhtml+xml"},
},
Close: true,
})
response := writer.Result()
if response == nil {
return nil
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return nil
}
if contentType, _, _ := mime.ParseMediaType(response.Header.Get("Content-Type")); contentType != "text/html" && contentType != "application/xhtml+xml" {
return nil
}
//TODO: handle non UTF-8 documents
node, err := html.ParseWithOptions(response.Body, html.ParseOptionEnableScripting(false))
if err != nil {
return nil
}
for n := range node.Descendants() {
if n.Type == html.ElementNode && slices.Contains(kinds, n.Data) {
result = append(result, html.Node{
Type: n.Type,
DataAtom: n.DataAtom,
Data: n.Data,
Namespace: n.Namespace,
Attr: n.Attr,
})
}
}
return result
}