56 Commits

Author SHA1 Message Date
WeebDataHoarder
6a6c3fef07 testdata: Initial action/challenges testing 2025-04-29 05:20:33 +02:00
WeebDataHoarder
467ad9c5a9 state: fix errors when loading network lists 2025-04-29 05:19:18 +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
56 changed files with 2940 additions and 1086 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,
@@ -12,11 +12,13 @@ local Build(go, alpine, os, arch) = {
CGO_ENABLED: "0",
GOOS: os,
GOARCH: arch,
GORACE: "halt_on_error=1"
},
steps: [
{
name: "build",
image: "golang:" + go +"-alpine" + alpine,
mirror: mirror,
commands: [
"apk update",
"apk add --no-cache git",
@@ -25,9 +27,20 @@ local Build(go, alpine, os, arch) = {
"go build -v -o ./.bin/test-wasm-runtime ./cmd/test-wasm-runtime",
],
},
{
name: "test",
image: "golang:" + go +"-alpine" + alpine,
mirror: mirror,
commands: [
"apk update",
"apk add --no-cache git",
"go test -p 1 -timeout 20m -v ./tests/"
],
},
{
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 +49,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 +80,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 +93,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 +103,25 @@ local Publish(registry, repo, secret, go, alpine, os, arch, trigger, platforms,
},
trigger: trigger,
steps: [
{
name: "test",
image: "golang:" + go +"-alpine" + alpine,
mirror: mirror,
commands: [
"apk update",
"apk add --no-cache git",
"go test -p 1 -timeout 20m -v ./tests/"
],
},
{
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 +131,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 +162,19 @@ 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"),
Build(mirror, goVersion, alpineVersion, "linux", "arm64"),
# 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

@@ -3,6 +3,7 @@ environment:
CGO_ENABLED: "0"
GOARCH: amd64
GOOS: linux
GORACE: halt_on_error=1
GOTOOLCHAIN: local
kind: pipeline
name: build-1.24-alpine3.21-amd64
@@ -17,13 +18,22 @@ 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:
- apk update
- apk add --no-cache git
- go test -p 1 -timeout 20m -v ./tests/
image: golang:1.24-alpine3.21
mirror: https://mirror.gcr.io
name: test
- 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
@@ -31,7 +41,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 +60,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,6 +71,7 @@ steps:
depends_on:
- build
image: alpine:3.21
mirror: https://mirror.gcr.io
name: test-wasm-fail
type: docker
---
@@ -58,6 +79,7 @@ environment:
CGO_ENABLED: "0"
GOARCH: arm64
GOOS: linux
GORACE: halt_on_error=1
GOTOOLCHAIN: local
kind: pipeline
name: build-1.24-alpine3.21-arm64
@@ -72,13 +94,22 @@ 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:
- apk update
- apk add --no-cache git
- go test -p 1 -timeout 20m -v ./tests/
image: golang:1.24-alpine3.21
mirror: https://mirror.gcr.io
name: test
- 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
@@ -86,7 +117,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 +136,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,6 +147,7 @@ steps:
depends_on:
- build
image: alpine:3.21
mirror: https://mirror.gcr.io
name: test-wasm-fail
type: docker
---
@@ -115,9 +157,24 @@ platform:
arch: amd64
os: linux
steps:
- commands:
- apk update
- apk add --no-cache git
- go test -p 1 -timeout 20m -v ./tests/
image: golang:1.24-alpine3.21
mirror: https://mirror.gcr.io
name: test
- 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 +185,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 +212,24 @@ platform:
arch: amd64
os: linux
steps:
- commands:
- apk update
- apk add --no-cache git
- go test -p 1 -timeout 20m -v ./tests/
image: golang:1.24-alpine3.21
mirror: https://mirror.gcr.io
name: test
- 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 +240,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 +249,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 +267,24 @@ platform:
arch: amd64
os: linux
steps:
- commands:
- apk update
- apk add --no-cache git
- go test -p 1 -timeout 20m -v ./tests/
image: golang:1.24-alpine3.21
mirror: https://mirror.gcr.io
name: test
- 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 +295,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 +322,24 @@ platform:
arch: amd64
os: linux
steps:
- commands:
- apk update
- apk add --no-cache git
- go test -p 1 -timeout 20m -v ./tests/
image: golang:1.24-alpine3.21
mirror: https://mirror.gcr.io
name: test
- 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 +351,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 +377,24 @@ platform:
arch: amd64
os: linux
steps:
- commands:
- apk update
- apk add --no-cache git
- go test -p 1 -timeout 20m -v ./tests/
image: golang:1.24-alpine3.21
mirror: https://mirror.gcr.io
name: test
- 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 +406,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 +415,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 +432,24 @@ platform:
arch: amd64
os: linux
steps:
- commands:
- apk update
- apk add --no-cache git
- go test -p 1 -timeout 20m -v ./tests/
image: golang:1.24-alpine3.21
mirror: https://mirror.gcr.io
name: test
- 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 +461,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 +482,6 @@ trigger:
type: docker
---
kind: signature
hmac: 8aed9810938e4aa4b34c4afb35e1101f27f98a61ffe5349be9a30f22ce7480ed
hmac: 07ac33f9298a9910aacb29ef18931cb999841f76be8a95ca210f9f3704c347f9
...

View File

@@ -32,14 +32,20 @@ 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_SLOG_LEVEL="WARN"
@@ -47,21 +53,15 @@ 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"]

View File

@@ -1,7 +1,7 @@
### <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.
[![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)
@@ -32,7 +32,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)
@@ -80,12 +80,15 @@ 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.
You can alter the language and strings in the templates directly from the [config.yml](#config) file if specified.
### Extended rule actions
In addition to the common PASS / CHALLENGE / DENY rules, go-away offers more actions that can be extended via code.
| Action | Behavior | Terminating |
|:---------:|:------------------------------------------------------------------------|:-----------:|
| NONE | Do nothing, continue. Useful for specifying on checks or challenges. | No |
| 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 |
@@ -93,6 +96,7 @@ In addition to the common PASS / CHALLENGE / DENY rules, go-away offers more act
| 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 |
| CONTEXT | Modify the request context and apply different options | No |
CHECK allows the client to be challenged but continue matching rules after these, for example, chaining a list of challenges that must be passed.
@@ -248,31 +252,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,17 +299,18 @@ 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.
* [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
## Setup
@@ -312,6 +318,10 @@ go-away can take plaintext HTTP/1 and _HTTP/2_ / _h2c_ connections if desired ov
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.
### Config
While most basic configuration can be passed via the command line, we support passing a [config.yml](examples/config.yml) with more advanced setup, including string replacement or custom backends configuration.
### Binary / Go
Requires Go 1.24+. Builds statically without CGo usage.
@@ -341,7 +351,7 @@ Available under [Dockerfile](Dockerfile). See the _docker compose_ below for the
Example follows a hypothetical Forgejo server running on `http://forgejo:3000` serving `git.example.com`
Container images are published under `git.gammaspectra.live/git/go-away`, `codeberg.org/weebdatahoarder/go-away` and `ghcr.io/weebdatahoarder/go-away`
Container images are published under `git.gammaspectra.live/git/go-away`, `codeberg.org/gone/go-away` and `ghcr.io/weebdatahoarder/go-away`
```yaml
networks:
@@ -353,7 +363,7 @@ volumes:
services:
go-away:
# image: codeberg.org/weebdatahoarder/go-away:latest
# image: codeberg.org/gone/go-away:latest
# image: ghcr.io/weebdatahoarder/go-away:latest
image: git.gammaspectra.live/git/go-away:latest
restart: always
@@ -366,12 +376,17 @@ services:
volumes:
- "goaway_cache:/cache"
- "./examples/forgejo.yml:/policy.yml:ro"
- "./examples/snippets/:/policy/snippets/:ro"
#- "./your/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"
# Enable Prometheus metrics under /metrics on this bind
#GOAWAY_METRICS_BIND: ":9090"
# Enable Go debug profiles under this bind
#GOAWAY_DEBUG_BIND: ":6060"
# set to letsencrypt or other directory URL to enable HTTPS. Above ports will be TLS only.
# enables request JA3N / JA4 client TLS fingerprinting
@@ -400,18 +415,21 @@ services:
# If left empty, the header on GOAWAY_CLIENT_IP_HEADER will be left as-is
#GOAWAY_BACKEND_IP_HEADER: ""
# Alternate way of specifying parameters or more advanced settings
# Pass path to YAML file
#GOAWAY_CONFIG: ""
GOAWAY_POLICY: "/policy.yml"
GOAWAY_POLICY_SNIPPETS: "/policy/snippets"
# Include extra snippets to load from this path.
# Note that the default snippets from example/snippets/ are included by default
#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"
@@ -426,9 +444,14 @@ services:
## Other Similar Projects
* [Anubis](https://anubis.techaro.lol/): Proxy that uses JavaScript proof of work to weight request based on rules [[source]](https://github.com/TecharoHQ/anubis)
* [powxy](https://sr.ht/~runxiyu/powxy/): Powxy is a reverse proxy that protects your upstream service by challenging clients with SHA-256 proof-of-work. [[source](https://git.sr.ht/~runxiyu/powxy)]
* [anticrawl](https://flak.tedunangst.com/post/anticrawl): Go http handler / proxy for regex based rules [[source]](https://humungus.tedunangst.com/r/anticrawl)
| 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 |
## Development

View File

@@ -4,78 +4,27 @@ 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/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 +50,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 +80,28 @@ 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"], "name of the challenge template theme to use (forgejo => [forgejo-auto, forgejo-dark, forgejo-light, gitea...])")
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,14 +115,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)
// preload missing settings
opt.ChallengeTemplateOverrides["Theme"] = *templateTheme
// 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
var kValue string
@@ -189,7 +161,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,30 +169,42 @@ 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)
for k, v := range opt.Backends {
if v.IpHeader == "" {
//set default value
v.IpHeader = *backendIpHeader
}
backend, err := v.Create()
if err != nil {
log.Fatal(fmt.Errorf("backend %s: failed to make reverse proxy: %w", k, err))
fatal(fmt.Errorf("backend %s: failed to make reverse proxy: %w", k, err))
}
backend.ErrorLog = slog.NewLogLogger(slog.With("backend", k).Handler(), slog.LevelError)
@@ -228,49 +212,29 @@ func main() {
}
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))
}
}
var tlsConfig *tls.Config
if *acmeAutocert != "" {
switch *acmeAutocert {
case "letsencrypt":
*acmeAutocert = acme.LetsEncryptURL
fatal(fmt.Errorf("failed to open cache directory: %w", err))
}
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()
acmeCache = path.Join(*cachePath, "acme")
}
loadPolicyState := func() (http.Handler, error) {
@@ -279,27 +243,24 @@ func main() {
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,48 +271,32 @@ 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.LevelError)
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",
)
@@ -369,18 +314,61 @@ func main() {
continue
}
serverHandler.Store(&handler)
swap(handler)
slog.Warn("handler configuration reloaded")
}
}()
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.LevelError),
}
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.LevelError),
}
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)
}
}

24
docker-entrypoint.sh Executable file
View File

@@ -0,0 +1,24 @@
#!/bin/sh
set -e
if [ "${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-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

@@ -4,142 +4,13 @@
<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 .Meta }}
<meta {{ 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>
@@ -154,43 +25,29 @@
src="{{ .Path }}/assets/static/logo.png?cacheBust={{ .Random }}"
/>
{{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 +55,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,16 @@
<!DOCTYPE html>
{{$theme := "forgejo-auto"}}
{{ if .Theme }}
{{$theme = .Theme}}
{{ end }}
{{$theme := "forgejo-auto"}}{{ if .Theme }}{{$theme = .Theme}}{{ 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 .Meta }}
<meta {{ range $key, $value := . }}{{ $key | attr }}="{{ $value }}" {{end}}/>
{{ end }}
{{ range .HeaderTags }}
{{ . }}
{{ . }}
{{ end }}
@@ -61,36 +53,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 +94,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>

101
examples/config.yml Normal file
View File

@@ -0,0 +1,101 @@
# 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
# forgejo: Looks like native Forgejo. Includes logos and resources from your instance. Supports Theme.
#
#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"
# 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 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
# 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

@@ -81,6 +81,7 @@ conditions:
- '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 +93,16 @@ 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
- name: undesired-networks
conditions:
- 'remoteAddress.network("huawei-cloud") || remoteAddress.network("alibaba-cloud") || remoteAddress.network("zenlayer-inc")'
@@ -106,7 +117,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
@@ -196,6 +207,7 @@ 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
@@ -220,16 +232,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)']
@@ -285,6 +287,21 @@ 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:
@@ -292,6 +309,7 @@ rules:
conditions:
- 'userAgent.startsWith("Lynx/")'
# Comment this rule out to not challenge tool-like user agents
- name: standard-tools
action: challenge
settings:
@@ -306,3 +324,5 @@ rules:
challenges: [http-cookie-check, preload-link, meta-refresh, resource-load, js-pow-sha256]
conditions:
- '($is-generic-browser)'
# If end of rules is reached, default is PASS

View File

@@ -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,16 @@ 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
- name: undesired-crawlers
conditions:
- '($is-headless-chromium)'
@@ -59,7 +69,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
@@ -98,16 +108,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 == "/"'
@@ -137,6 +137,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,14 +157,15 @@ 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
@@ -159,3 +173,5 @@ rules:
challenges: [preload-link, meta-refresh, resource-load, js-pow-sha256]
conditions:
- '($is-generic-browser)'
# If end of rules is reached, default is PASS

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")'

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

@@ -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)
}

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

@@ -0,0 +1,55 @@
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"
)
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"`
}
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 {
w.Header().Set(k, v)
}
return true, nil
}

View File

@@ -23,7 +23,7 @@ func FillRegistration(state challenge.StateInterface, reg *challenge.Registratio
return challenge.VerifyResultFail
}
utils.SetCookie(utils.CookiePrefix+reg.Name, token, expiry, w, r)
utils.SetCookie(challenge.RequestDataFromContext(r.Context()).CookiePrefix+reg.Name, token, expiry, w, r)
uri, err := challenge.RedirectUrl(r, reg)
if err != nil {

View File

@@ -1,19 +1,21 @@
package challenge
import (
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/google/cel-go/cel"
"github.com/google/cel-go/common/types"
"github.com/google/cel-go/common/types/traits"
"net"
"net/http"
"net/netip"
"net/textproto"
"strings"
"time"
)
@@ -35,14 +37,17 @@ type RequestData struct {
Time time.Time
ChallengeVerify map[Id]VerifyResult
ChallengeState map[Id]VerifyState
RemoteAddress net.IP
RemoteAddress netip.AddrPort
State StateInterface
CookiePrefix string
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 +60,6 @@ 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.fp = make(map[string]string, 2)
@@ -72,10 +76,30 @@ func CreateRequestData(r *http.Request, state StateInterface) (*http.Request, *R
}
}
data.query = condition.NewValuesMap(r.URL.Query())
data.header = condition.NewMIMEMap(textproto.MIMEHeader(r.Header))
q := r.URL.Query()
// 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)
sum := sha256.New()
sum.Write([]byte(r.Host))
sum.Write([]byte{0})
sum.Write(data.NetworkPrefix().AsSlice())
sum.Write([]byte{0})
sum.Write(state.PublicKey())
sum.Write([]byte{0})
data.CookiePrefix = utils.CookiePrefix + hex.EncodeToString(sum.Sum(nil)[:6]) + "-"
r = r.WithContext(context.WithValue(r.Context(), requestDataContextKey{}, &data))
r = utils.SetRemoteAddress(r, data.RemoteAddress)
data.r = r
return r, &data
}
@@ -87,7 +111,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 +131,73 @@ func (d *RequestData) Parent() cel.Activation {
return nil
}
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"
RequestOptCacheMetaTags = "proxy-meta-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) EvaluateChallenges(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
var issuedChallenge string
if q.Has(QueryArgChallenge) {
issuedChallenge = q.Get(QueryArgChallenge)
}
for _, reg := range d.State.GetChallenges() {
key := GetChallengeKeyForRequest(d.State, reg, d.Expiration(reg.Duration), r)
verifyResult, verifyState, err := reg.VerifyChallengeToken(d.State.PublicKey(), key, r)
if err != nil && !errors.Is(err, http.ErrNoCookie) {
// clear invalid cookie
utils.ClearCookie(utils.CookiePrefix+reg.Name, w, r)
utils.ClearCookie(d.CookiePrefix+reg.Name, w, r)
}
// prevent evaluating the challenge if not solved
@@ -130,6 +214,11 @@ func (d *RequestData) EvaluateChallenges(w http.ResponseWriter, r *http.Request)
}
}
}
if !verifyResult.Ok() && issuedChallenge == reg.Name {
// we issued the challenge, must skip to prevent loops
verifyResult = VerifyResultSkip
}
d.ChallengeVerify[reg.Id()] = verifyResult
d.ChallengeState[reg.Id()] = verifyState
}
@@ -154,7 +243,7 @@ func (d *RequestData) HasValidChallenge(id Id) bool {
return d.ChallengeVerify[id].Ok()
}
func (d *RequestData) Headers(headers http.Header) {
func (d *RequestData) RequestHeaders(headers http.Header) {
headers.Set("X-Away-Id", d.Id.String())
for id, result := range d.ChallengeVerify {

View File

@@ -119,13 +119,9 @@ 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() {
@@ -133,14 +129,14 @@ func FillRegistration(state challenge.StateInterface, reg *challenge.Registratio
if err != nil {
return challenge.VerifyResultFail
}
utils.SetCookie(utils.CookiePrefix+reg.Name, token, expiry, w, r)
utils.SetCookie(data.CookiePrefix+reg.Name, token, expiry, w, r)
return challenge.VerifyResultNotOK
} else {
token, err := reg.IssueChallengeToken(state.PrivateKey(), key, nil, expiry, true)
if err != nil {
return challenge.VerifyResultFail
}
utils.SetCookie(utils.CookiePrefix+reg.Name, token, expiry, w, r)
utils.SetCookie(data.CookiePrefix+reg.Name, token, expiry, w, r)
return challenge.VerifyResultOK
}
}

View File

@@ -8,18 +8,33 @@ import (
"git.gammaspectra.live/git/go-away/utils"
"net/http"
"net/url"
"strings"
)
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[:])
}
@@ -39,11 +54,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 +72,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
}
@@ -93,7 +110,9 @@ 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())
if ref := r.Referer(); ref != "" {
values.Set(QueryArgReferer, r.Referer())
}
values.Set(QueryArgChallenge, reg.Name)
uri.RawQuery = values.Encode()
@@ -102,6 +121,26 @@ func RedirectUrl(r *http.Request, reg *Registration) (*url.URL, error) {
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()
http.Redirect(w, r, reqUri.String(), http.StatusTemporaryRedirect)
return
}
state.ErrorPage(w, r, http.StatusBadRequest, err, redirect)
return
} else if !verifyResult.Ok() {
@@ -136,7 +175,7 @@ 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)
utils.ClearCookie(data.CookiePrefix+reg.Name, w, r)
state.ChallengeFailed(r, reg, nil, redirect, nil)
responseFunc(state, data, w, r, verifyResult, nil, redirect)
return nil
@@ -144,9 +183,9 @@ func VerifyHandlerFunc(state StateInterface, reg *Registration, verify VerifyFun
challengeToken, err := reg.IssueChallengeToken(state.PrivateKey(), key, []byte(token), expiration, true)
if err != nil {
utils.ClearCookie(utils.CookiePrefix+reg.Name, w, r)
utils.ClearCookie(data.CookiePrefix+reg.Name, w, r)
} else {
utils.SetCookie(utils.CookiePrefix+reg.Name, challengeToken, expiration, w, r)
utils.SetCookie(data.CookiePrefix+reg.Name, challengeToken, expiration, w, r)
}
data.ChallengeVerify[reg.id] = verifyResult
state.ChallengePassed(r, reg, redirect, nil)
@@ -155,7 +194,7 @@ func VerifyHandlerFunc(state StateInterface, reg *Registration, verify VerifyFun
return nil
}()
if err != nil {
utils.ClearCookie(utils.CookiePrefix+reg.Name, w, r)
utils.ClearCookie(data.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

@@ -137,19 +137,21 @@ func FillRegistration(state challenge.StateInterface, reg *challenge.Registratio
defer response.Body.Close()
defer io.Copy(io.Discard, response.Body)
data := challenge.RequestDataFromContext(r.Context())
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)
utils.SetCookie(data.CookiePrefix+reg.Name, token, expiry, w, r)
return challenge.VerifyResultNotOK
} else {
token, err := reg.IssueChallengeToken(state.PrivateKey(), key, sum, expiry, true)
if err != nil {
return challenge.VerifyResultFail
}
utils.SetCookie(utils.CookiePrefix+reg.Name, token, expiry, w, r)
utils.SetCookie(data.CookiePrefix+reg.Name, token, expiry, w, r)
return challenge.VerifyResultOK
}
}

View File

@@ -42,12 +42,13 @@ 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
@@ -72,7 +73,7 @@ func GetChallengeKeyForRequest(state StateInterface, reg *Registration, until ti
sum[0] = 0
if address.To4() != nil {
if data.RemoteAddress.Addr().Unmap().Is4() {
// Is IPv4, mark
sum.Set(KeyFlagIsIPv4)
}

View File

@@ -13,7 +13,7 @@ func init() {
}
type Parameters struct {
Mode string `yaml:"refresh-mode"`
Mode string `yaml:"refresh-via"`
}
var DefaultParameters = Parameters{
@@ -47,8 +47,11 @@ func FillRegistration(state challenge.StateInterface, reg *challenge.Registratio
if params.Mode == "meta" {
state.ChallengePage(w, r, state.Settings().ChallengeResponseCode, reg, map[string]any{
"Meta": map[string]string{
"refresh": "0; url=" + uri.String(),
"Meta": []map[string]string{
{
"http-equiv": "refresh",
"content": "0; url=" + uri.String(),
},
},
})
} else {

View File

@@ -2,12 +2,11 @@ package challenge
import (
"bytes"
http_cel "codeberg.org/gone/http-cel"
"crypto/ed25519"
"errors"
"fmt"
"git.gammaspectra.live/git/go-away/lib/condition"
"git.gammaspectra.live/git/go-away/lib/policy"
"git.gammaspectra.live/git/go-away/utils"
"github.com/go-jose/go-jose/v4"
"github.com/go-jose/go-jose/v4/jwt"
"github.com/goccy/go-yaml/ast"
@@ -68,11 +67,11 @@ func (r Register) Create(state StateInterface, name string, pol policy.Challenge
}
if len(conditions) > 0 {
ast, err := condition.FromStrings(state.ProgramEnv(), condition.OperatorOr, conditions...)
ast, err := http_cel.NewAst(state.ProgramEnv(), 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)
reg.Condition, err = http_cel.ProgramAst(state.ProgramEnv(), ast)
if err != nil {
return nil, 0, fmt.Errorf("error compiling program: %v", err)
}
@@ -193,7 +192,7 @@ 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)
cookie, err := r.Cookie(RequestDataFromContext(r.Context()).CookiePrefix + reg.Name)
if err != nil {
return VerifyResultNone, VerifyStateNone, err
}

View File

@@ -33,6 +33,7 @@ func ServeChallengeScript(w http.ResponseWriter, r *http.Request, reg *Registrat
"Random": utils.CacheBust(),
"Challenge": reg.Name,
"ChallengeScript": script,
"Strings": data.State.Options().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/lib/settings"
"github.com/google/cel-go/cel"
"log/slog"
"net/http"
@@ -96,6 +97,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 +112,9 @@ type StateInterface interface {
GetChallengeByName(name string) (*Registration, bool)
GetChallenges() Register
Settings() policy.Settings
Settings() policy.StateSettings
Options() settings.Settings
GetBackend(host string) http.Handler
}

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,11 +1,111 @@
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/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
}

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,6 +38,98 @@ func GetLoggerForRequest(r *http.Request) *slog.Logger {
return slog.With(args...)
}
func (state *State) fetchMetaTags(host string, backend http.Handler, r *http.Request) []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, "meta")
if result == nil {
return nil
}
entries := make([]html.Node, 0, len(result))
safeAttributes := []string{"name", "property", "content"}
for _, n := range result {
if n.Namespace != "" {
continue
}
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
}
}
// 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 {
switch name {
// standard content tags
case "application-name", "author", "description", "keywords", "robots", "thumbnail":
keep = true
case "go-import", "go-source":
// golang tags
keep = true
case "apple-itunes-app":
}
}
// 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
@@ -90,17 +141,31 @@ func (state *State) handleRequest(w http.ResponseWriter, r *http.Request) {
return
}
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
}
lg := state.Logger(r)
cleanupRequest := func(r *http.Request, fromChallenge bool) {
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()
// delete query parameters that were set by go-away
for k := range q {
if strings.HasPrefix(k, challenge.QueryArgPrefix) {
@@ -109,7 +174,7 @@ func (state *State) handleRequest(w http.ResponseWriter, r *http.Request) {
}
r.URL.RawQuery = q.Encode()
data.Headers(r.Header)
data.RequestHeaders(r.Header)
// delete cookies set by go-away to prevent user tracking that way
cookies := r.Cookies()
@@ -124,7 +189,7 @@ func (state *State) handleRequest(w http.ResponseWriter, r *http.Request) {
for _, rule := range state.rules {
next, err := rule.Evaluate(lg, w, r, func() http.Handler {
cleanupRequest(r, true)
return backend
return getBackend()
})
if err != nil {
state.ErrorPage(w, r, http.StatusInternalServerError, err, "")
@@ -137,13 +202,16 @@ 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
return getBackend()
})
}
@@ -151,14 +219,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 {

View File

@@ -1,14 +1,13 @@
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/lib/settings"
"git.gammaspectra.live/git/go-away/utils"
"github.com/google/cel-go/cel"
"log/slog"
"maps"
"net/http"
)
@@ -42,7 +41,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 +50,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 +59,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 +95,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) Options() settings.Settings {
return state.opt
}
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"
@@ -66,12 +66,12 @@ func NewRuleState(state challenge.StateInterface, r policy.Rule, replacer *strin
conditions = append(conditions, cond)
}
ast, err := condition.FromStrings(state.ProgramEnv(), condition.OperatorOr, conditions...)
ast, err := http_cel.NewAst(state.ProgramEnv(), http_cel.OperatorOr, conditions...)
if err != nil {
return RuleState{}, fmt.Errorf("error compiling conditions: %w", err)
}
program, err := condition.Program(state.ProgramEnv(), ast)
program, err := http_cel.ProgramAst(state.ProgramEnv(), ast)
if err != nil {
return RuleState{}, fmt.Errorf("error compiling program: %w", err)
}
@@ -107,6 +107,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 +137,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

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

@@ -0,0 +1,106 @@
package settings
import (
"git.gammaspectra.live/git/go-away/utils"
"net/http"
"net/http/httputil"
)
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"`
}
func (b Backend) Create() (*httputil.ReverseProxy, error) {
if b.IpHeader == "-" {
b.IpHeader = ""
}
proxy, err := utils.MakeReverseProxy(b.URL, b.GoDNS)
if err != nil {
return nil, err
}
transport := proxy.Transport.(*http.Transport)
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 != "" {
director := proxy.Director
proxy.Director = func(req *http.Request) {
if b.IpHeader != "" {
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
}

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

@@ -0,0 +1,168 @@
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"
"sync/atomic"
)
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"`
// TLSCertificate Alternate to TLSAcmeAutoCert
TLSCertificate string `yaml:"tls-certificate"`
// TLSPrivateKey Alternate to TLSAcmeAutoCert
TLSPrivateKey string `yaml:"tls-key"`
}
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,
)
}
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)
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 {
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
}

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

@@ -0,0 +1,49 @@
package settings
import "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 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),
}

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

@@ -0,0 +1,55 @@
package settings
import (
"html/template"
"maps"
)
type Strings map[string]string
var DefaultStrings = make(Strings).set(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:",
})
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)
}

View File

@@ -1,22 +1,28 @@
package lib
import (
http_cel "codeberg.org/gone/http-cel"
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"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/yl2chen/cidranger"
"golang.org/x/net/html"
"log/slog"
"net"
"net/http"
"net/http/httputil"
"os"
"path"
"strconv"
"strings"
"time"
)
@@ -31,7 +37,8 @@ type State struct {
publicKey ed25519.PublicKey
privateKey ed25519.PrivateKey
settings policy.Settings
opt settings.Settings
settings policy.StateSettings
networks map[string]cidranger.Ranger
@@ -41,13 +48,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 +69,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,22 +100,18 @@ func NewState(p policy.Policy, settings policy.Settings) (handler http.Handler,
}
}
if state.Settings().ChallengeTemplate == "" {
state.settings.ChallengeTemplate = "anubis"
}
if templates["challenge-"+state.Options().ChallengeTemplate+".gohtml"] == nil {
if templates["challenge-"+state.Settings().ChallengeTemplate+".gohtml"] == nil {
if data, err := os.ReadFile(state.Settings().ChallengeTemplate); err == nil && len(data) > 0 {
name := path.Base(state.Settings().ChallengeTemplate)
if data, err := os.ReadFile(state.Options().ChallengeTemplate); err == nil && len(data) > 0 {
name := path.Base(state.Options().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.Options().ChallengeTemplate, err)
}
state.settings.ChallengeTemplate = name
state.opt.ChallengeTemplate = name
}
return nil, fmt.Errorf("no template defined for %s", settings.ChallengeTemplate)
return nil, fmt.Errorf("no template defined for %s", state.Options().ChallengeTemplate)
}
state.networks = make(map[string]cidranger.Ranger)
@@ -117,15 +124,19 @@ func NewState(p policy.Policy, settings policy.Settings) (handler http.Handler,
for i, e := range network {
prefixes, err := func() ([]net.IPNet, error) {
var useCache bool
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
@@ -165,16 +176,22 @@ func NewState(p policy.Policy, settings policy.Settings) (handler http.Handler,
}
return prefixes, nil
}()
if err != nil {
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)
}
continue
}
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())
@@ -189,7 +206,7 @@ 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)
}
@@ -215,7 +232,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 +248,30 @@ 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:
return errors.New("already closed")
default:
close(state.close)
}
return nil
}

153
lib/template.go Normal file
View File

@@ -0,0 +1,153 @@
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) 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()
input["Path"] = state.UrlPath()
input["Links"] = state.Options().Links
input["Strings"] = state.Options().Strings
for k, v := range state.Options().ChallengeTemplateOverrides {
input[k] = v
}
if reg != nil {
input["Challenge"] = reg.Name
}
maps.Copy(input, params)
if _, ok := input["Title"]; !ok {
input["Title"] = state.Options().Strings.Get("title_challenge")
}
if data.GetOptBool(challenge.RequestOptCacheMetaTags, false) {
backend, host := data.BackendHost()
if tags := state.fetchMetaTags(host, backend, r); len(tags) > 0 {
tagMap, _ := input["Meta"].([]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
}
tagMap = append(tagMap, tagAttrs)
}
input["Meta"] = tagMap
}
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
buf := bytes.NewBuffer(make([]byte, 0, 8192))
err := templates["challenge-"+state.Options().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))
input := map[string]any{
"Id": data.Id.String(),
"Random": utils.CacheBust(),
"Error": err.Error(),
"Path": state.UrlPath(),
"Theme": "",
"Title": template.HTML(string(state.Options().Strings.Get("title_error")) + " " + http.StatusText(status)),
"Challenge": "",
"Redirect": redirect,
"Links": state.Options().Links,
"Strings": state.Options().Strings,
}
for k, v := range state.Options().ChallengeTemplateOverrides {
input[k] = v
}
if data.GetOptBool(challenge.RequestOptCacheMetaTags, false) {
backend, host := data.BackendHost()
if tags := state.fetchMetaTags(host, backend, r); len(tags) > 0 {
tagMap, _ := input["Meta"].([]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
}
tagMap = append(tagMap, tagAttrs)
}
input["Meta"] = tagMap
}
}
err2 := templates["challenge-"+state.Options().ChallengeTemplate+".gohtml"].Execute(buf, input)
if err2 != nil {
// nested errors!
panic(err2)
} else {
w.WriteHeader(status)
_, _ = w.Write(buf.Bytes())
}
}

280
tests/action_test.go Normal file
View File

@@ -0,0 +1,280 @@
package tests
import (
"encoding/base64"
"errors"
"fmt"
"git.gammaspectra.live/git/go-away/lib/policy"
"git.gammaspectra.live/git/go-away/utils"
"io"
"net/http"
"net/url"
"strings"
"testing"
)
func testAction(t *testing.T, pol policy.Policy, expected int, url string) (*http.Response, error) {
settings := setupDefaultSettings(t)
var r *http.Response
err := MakeGoAwayState(pol, settings, func(do func(r *http.Request, errs ...error) (*http.Response, error)) error {
request, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return err
}
request.Header.Set(settings.ClientIpHeader, "127.0.0.1")
response, err := do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode != expected {
return fmt.Errorf("expected status code %d, got %d", expected, response.StatusCode)
}
r = response
return nil
})
return r, err
}
func TestActionPass(t *testing.T) {
pol, err := policy.NewPolicy(strings.NewReader(
`
rules:
- name: test
conditions: ["true"]
action: pass
settings:
`,
))
if err != nil {
t.Fatal(fmt.Errorf("failed to create policy: %w", err))
}
_, err = testAction(t, *pol, http.StatusOK, "/test")
if err != nil {
t.Fatal(err)
}
}
func TestActionNone(t *testing.T) {
pol, err := policy.NewPolicy(strings.NewReader(
`
rules:
- name: test
conditions: ["true"]
action: none
settings:
`,
))
if err != nil {
t.Fatal(fmt.Errorf("failed to create policy: %w", err))
}
_, err = testAction(t, *pol, http.StatusOK, "/test")
if err != nil {
t.Fatal(err)
}
}
func TestActionDrop(t *testing.T) {
pol, err := policy.NewPolicy(strings.NewReader(
`
rules:
- name: test
conditions: ["true"]
action: drop
settings:
`,
))
if err != nil {
t.Fatal(fmt.Errorf("failed to create policy: %w", err))
}
response, err := testAction(t, *pol, http.StatusForbidden, "/test")
if err != nil {
t.Fatal(err)
}
data, err := io.ReadAll(response.Body)
if err != nil {
t.Fatal(err)
}
if len(data) != 0 {
t.Fatal(fmt.Errorf("expected empty response, got %s", string(data)))
}
}
func TestActionDeny(t *testing.T) {
pol, err := policy.NewPolicy(strings.NewReader(
`
rules:
- name: test
conditions: ["true"]
action: deny
settings:
`,
))
if err != nil {
t.Fatal(fmt.Errorf("failed to create policy: %w", err))
}
response, err := testAction(t, *pol, http.StatusForbidden, "/test")
if err != nil {
t.Fatal(err)
}
data, err := io.ReadAll(response.Body)
if err != nil {
t.Fatal(err)
}
if len(data) == 0 {
t.Fatal(errors.New("expected non-empty response, got none"))
}
}
func TestActionBlock(t *testing.T) {
pol, err := policy.NewPolicy(strings.NewReader(
`
rules:
- name: test
conditions: ["true"]
action: block
settings:
`,
))
if err != nil {
t.Fatal(fmt.Errorf("failed to create policy: %w", err))
}
response, err := testAction(t, *pol, http.StatusForbidden, "/test")
if err != nil {
t.Fatal(err)
}
data, err := io.ReadAll(response.Body)
if err != nil {
t.Fatal(err)
}
if len(data) == 0 {
t.Fatal(errors.New("expected non-empty response, got none"))
}
}
func TestActionCode(t *testing.T) {
pol, err := policy.NewPolicy(strings.NewReader(
`
rules:
- name: test
conditions: ["true"]
action: code
settings:
http-code: 418
`,
))
if err != nil {
t.Fatal(fmt.Errorf("failed to create policy: %w", err))
}
_, err = testAction(t, *pol, http.StatusTeapot, "/test")
if err != nil {
t.Fatal(err)
}
}
func TestActionContextResponseHeaders(t *testing.T) {
pol, err := policy.NewPolicy(strings.NewReader(
`
rules:
- name: test
conditions: ["true"]
action: context
settings:
response-headers:
X-World-Domination: yes
`,
))
if err != nil {
t.Fatal(fmt.Errorf("failed to create policy: %w", err))
}
response, err := testAction(t, *pol, http.StatusOK, "/test")
if err != nil {
t.Fatal(err)
}
if response.Header.Get("X-World-Domination") != "yes" {
t.Fatal(fmt.Errorf("expected header set, got %s", response.Header.Get("X-World-Domination")))
}
}
func TestActionContextSetMetaTags(t *testing.T) {
pol, err := policy.NewPolicy(strings.NewReader(
`
rules:
- name: test-context
conditions: ["true"]
action: context
settings:
context-set:
proxy-meta-tags: yes
- name: test
conditions: ["true"]
action: deny
`,
))
if err != nil {
t.Fatal(fmt.Errorf("failed to create policy: %w", err))
}
uri, err := url.Parse("/test")
if err != nil {
t.Fatal(fmt.Errorf("failed to create policy: %w", err))
}
q := uri.Query()
q.Set("mime-type", "text/html")
q.Set("content", base64.RawURLEncoding.EncodeToString([]byte(`
<!DOCTYPE html>
<html>
<head>
<meta name="description" content="test">
</head>
</html>
`)))
uri.RawQuery = q.Encode()
response, err := testAction(t, *pol, http.StatusForbidden, uri.String())
if err != nil {
t.Fatal(err)
}
tags := utils.FetchTagsFromReader(response.Body, "meta")
if str := func() string {
for _, t := range tags {
var is bool
var val string
for _, a := range t.Attr {
if a.Key == "name" && a.Val == "description" {
is = true
}
if a.Key == "content" {
val = a.Val
}
}
if is {
return val
}
}
return "NONE"
}(); str != "test" {
t.Fatal(fmt.Errorf("expected meta tag with 'test', got %s", str))
}
}

34
tests/away.go Normal file
View File

@@ -0,0 +1,34 @@
package tests
import (
"git.gammaspectra.live/git/go-away/lib"
"git.gammaspectra.live/git/go-away/lib/policy"
"git.gammaspectra.live/git/go-away/lib/settings"
"net/http"
"net/http/httptest"
)
var DefaultSettings = policy.StateSettings{
Cache: nil,
Backends: map[string]http.Handler{
"*": MakeTestBackend(),
},
MainName: "go-away/tests",
MainVersion: "testing",
BasePath: "/.go-away",
ChallengeResponseCode: http.StatusTeapot,
ClientIpHeader: "X-Forwarded-For",
}
func MakeGoAwayState(pol policy.Policy, stateSettings policy.StateSettings, f func(do func(r *http.Request, errs ...error) (*http.Response, error)) error) error {
state, err := lib.NewState(pol, settings.DefaultSettings, stateSettings)
if err != nil {
return err
}
return f(func(r *http.Request, errs ...error) (*http.Response, error) {
recorder := httptest.NewRecorder()
state.ServeHTTP(recorder, r)
return recorder.Result(), nil
})
}

57
tests/backend.go Normal file
View File

@@ -0,0 +1,57 @@
package tests
import (
"encoding/base64"
"encoding/json"
"net/http"
"strconv"
)
func MakeTestBackend() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
responseCode := http.StatusOK
var err error
if opt := q.Get("http-code"); opt != "" {
rc, err := strconv.ParseInt(opt, 10, 64)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
responseCode = int(rc)
}
type ResponseJson struct {
Method string `json:"method"`
Path string `json:"path"`
Query string `json:"query"`
}
if opt := q.Get("mime-type"); opt != "" {
w.Header().Set("Content-Type", opt)
} else {
w.Header().Set("Content-Type", "application/json")
}
var data []byte
if opt := q.Get("content"); opt != "" {
data, err = base64.RawURLEncoding.DecodeString(opt)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
} else {
data, err = json.Marshal(ResponseJson{
Method: r.Method,
Path: r.URL.Path,
Query: r.URL.RawQuery,
})
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
}
w.WriteHeader(responseCode)
_, _ = w.Write(data)
})
}

362
tests/challenge_test.go Normal file
View File

@@ -0,0 +1,362 @@
package tests
import (
"encoding/hex"
"fmt"
challenge2 "git.gammaspectra.live/git/go-away/lib/challenge"
"git.gammaspectra.live/git/go-away/lib/policy"
"golang.org/x/net/html"
"log/slog"
"net/http"
"net/url"
"strings"
"testing"
)
func setupDefaultSettings(t *testing.T) policy.StateSettings {
settings := DefaultSettings
slog.SetDefault(slog.New(initLogger(t)))
return settings
}
func TestChallengeCookie(t *testing.T) {
settings := setupDefaultSettings(t)
pol, err := policy.NewPolicy(strings.NewReader(
`
challenges:
"challenge-cookie":
runtime: "cookie"
rules:
- name: catch-all
conditions: ["true"]
action: challenge
settings:
challenges: ["challenge-cookie"]
`,
))
if err != nil {
t.Fatal(fmt.Errorf("failed to create policy: %w", err))
}
var expectedCode = http.StatusTemporaryRedirect
err = MakeGoAwayState(*pol, settings, func(do func(r *http.Request, errs ...error) (*http.Response, error)) error {
challenge, err := http.NewRequest(http.MethodGet, "/test", nil)
challenge.Header.Set(settings.ClientIpHeader, "127.0.0.1")
challengeResponse, err := do(challenge)
if err != nil {
return err
}
defer challengeResponse.Body.Close()
if challengeResponse.StatusCode != expectedCode {
return fmt.Errorf("expected challenge status code %d, got %d", expectedCode, challengeResponse.StatusCode)
} else if cookies := challengeResponse.Cookies(); len(cookies) == 0 {
return fmt.Errorf("expected set cookies to be non-empty, got none")
} else if challengeResponse.Header.Get("Location") == "" {
return fmt.Errorf("expected header 'Location' to be non-empty, got none")
}
solveLocation := challengeResponse.Header.Get("Location")
if !strings.HasPrefix(solveLocation, "/test") {
return fmt.Errorf("expected next location to start with '/test', got %s", solveLocation)
}
// test pass
pass, err := http.NewRequest(http.MethodGet, solveLocation, nil)
pass.Header.Set(settings.ClientIpHeader, "127.0.0.1")
if err != nil {
return err
}
for _, c := range challengeResponse.Cookies() {
pass.AddCookie(c)
}
response, err := do(pass)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return fmt.Errorf("expected pass status code %d, got %d", http.StatusOK, response.StatusCode)
}
// test failure
fail, err := http.NewRequest(http.MethodGet, solveLocation, nil)
fail.Header.Set(settings.ClientIpHeader, "127.0.0.1")
if err != nil {
return err
}
response, err = do(fail)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode != http.StatusForbidden {
return fmt.Errorf("expected fail status code %d, got %d", http.StatusForbidden, response.StatusCode)
}
return nil
})
if err != nil {
t.Fatal(err)
}
}
func TestChallengeHeaderRefresh(t *testing.T) {
settings := setupDefaultSettings(t)
pol, err := policy.NewPolicy(strings.NewReader(
`
challenges:
"challenge-header-refresh":
runtime: "refresh"
parameters:
refresh-via: "header"
rules:
- name: catch-all
conditions: ["true"]
action: challenge
settings:
challenges: ["challenge-header-refresh"]
`,
))
if err != nil {
t.Fatal(fmt.Errorf("failed to create policy: %w", err))
}
var expectedCode = settings.ChallengeResponseCode
err = MakeGoAwayState(*pol, settings, func(do func(r *http.Request, errs ...error) (*http.Response, error)) error {
challenge, err := http.NewRequest(http.MethodGet, "/test", nil)
challenge.Header.Set(settings.ClientIpHeader, "127.0.0.1")
challengeResponse, err := do(challenge)
if err != nil {
return err
}
defer challengeResponse.Body.Close()
if challengeResponse.StatusCode != expectedCode {
return fmt.Errorf("expected challenge status code %d, got %d", expectedCode, challengeResponse.StatusCode)
} else if challengeResponse.Header.Get("Refresh") == "" {
return fmt.Errorf("expected header 'Refresh' to be non-empty, got none")
}
solveLocation, err := url.QueryUnescape(strings.Split(challengeResponse.Header.Get("Refresh"), "; url=")[1])
if err != nil {
return err
}
// test solve
solve, err := http.NewRequest(http.MethodGet, solveLocation, nil)
solve.Header.Set(settings.ClientIpHeader, "127.0.0.1")
if err != nil {
return err
}
response, err := do(solve)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode != http.StatusTemporaryRedirect {
return fmt.Errorf("expected solve status code %d, got %d", http.StatusTemporaryRedirect, response.StatusCode)
} else if cookies := response.Cookies(); len(cookies) == 0 {
return fmt.Errorf("expected set cookies to be non-empty, got none")
} else if response.Header.Get("Location") == "" {
return fmt.Errorf("expected header 'Location' to be non-empty, got none")
} else if !strings.HasPrefix(response.Header.Get("Location"), "/test") {
return fmt.Errorf("expected next location to start with '/test', got %s", response.Header.Get("Location"))
}
// test pass
pass, err := http.NewRequest(http.MethodGet, response.Header.Get("Location"), nil)
pass.Header.Set(settings.ClientIpHeader, "127.0.0.1")
if err != nil {
return err
}
for _, c := range response.Cookies() {
pass.AddCookie(c)
}
response, err = do(pass)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return fmt.Errorf("expected pass status code %d, got %d", http.StatusOK, response.StatusCode)
}
// test failure
uri, err := url.Parse(solveLocation)
q := uri.Query()
q.Set(challenge2.QueryArgToken, hex.EncodeToString(make([]byte, challenge2.KeySize)))
uri.RawQuery = q.Encode()
fail, err := http.NewRequest(http.MethodGet, uri.String(), nil)
fail.Header.Set(settings.ClientIpHeader, "127.0.0.1")
if err != nil {
return err
}
response, err = do(fail)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode != http.StatusBadRequest {
return fmt.Errorf("expected fail status code %d, got %d", http.StatusBadRequest, response.StatusCode)
}
return nil
})
if err != nil {
t.Fatal(err)
}
}
func TestChallengeMetaRefresh(t *testing.T) {
settings := setupDefaultSettings(t)
pol, err := policy.NewPolicy(strings.NewReader(
`
challenges:
"challenge-meta-refresh":
runtime: "refresh"
parameters:
refresh-via: "meta"
rules:
- name: catch-all
conditions: ["true"]
action: challenge
settings:
challenges: ["challenge-meta-refresh"]
`,
))
if err != nil {
t.Fatal(fmt.Errorf("failed to create policy: %w", err))
}
var expectedCode = settings.ChallengeResponseCode
err = MakeGoAwayState(*pol, settings, func(do func(r *http.Request, errs ...error) (*http.Response, error)) error {
challenge, err := http.NewRequest(http.MethodGet, "/test", nil)
challenge.Header.Set(settings.ClientIpHeader, "127.0.0.1")
challengeResponse, err := do(challenge)
if err != nil {
return err
}
defer challengeResponse.Body.Close()
if challengeResponse.StatusCode != expectedCode {
return fmt.Errorf("expected challenge status code %d, got %d", expectedCode, challengeResponse.StatusCode)
} else if challengeResponse.Header.Get("Refresh") != "" {
return fmt.Errorf("expected header 'Refresh' to be empty, got \"%s\"", challengeResponse.Header.Get("Refresh"))
}
node, err := html.ParseWithOptions(challengeResponse.Body, html.ParseOptionEnableScripting(false))
if err != nil {
return nil
}
var refresh string
for n := range node.Descendants() {
if n.Type == html.ElementNode && n.Data == "meta" {
var is bool
var val string
for _, a := range n.Attr {
if a.Key == "http-equiv" && a.Val == "refresh" {
is = true
}
if a.Key == "content" {
val = a.Val
}
}
if is {
refresh = val
break
}
}
}
solveLocation, err := url.QueryUnescape(strings.Split(refresh, "; url=")[1])
if err != nil {
return err
}
// test solve
solve, err := http.NewRequest(http.MethodGet, solveLocation, nil)
solve.Header.Set(settings.ClientIpHeader, "127.0.0.1")
if err != nil {
return err
}
response, err := do(solve)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode != http.StatusTemporaryRedirect {
return fmt.Errorf("expected solve status code %d, got %d", http.StatusTemporaryRedirect, response.StatusCode)
} else if cookies := response.Cookies(); len(cookies) == 0 {
return fmt.Errorf("expected set cookies to be non-empty, got none")
} else if response.Header.Get("Location") == "" {
return fmt.Errorf("expected header 'Location' to be non-empty, got none")
} else if !strings.HasPrefix(response.Header.Get("Location"), "/test") {
return fmt.Errorf("expected next location to start with '/test', got %s", response.Header.Get("Location"))
}
// test pass
pass, err := http.NewRequest(http.MethodGet, response.Header.Get("Location"), nil)
pass.Header.Set(settings.ClientIpHeader, "127.0.0.1")
if err != nil {
return err
}
for _, c := range response.Cookies() {
pass.AddCookie(c)
}
response, err = do(pass)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return fmt.Errorf("expected pass status code %d, got %d", http.StatusOK, response.StatusCode)
}
// test failure
uri, err := url.Parse(solveLocation)
q := uri.Query()
q.Set(challenge2.QueryArgToken, hex.EncodeToString(make([]byte, challenge2.KeySize)))
uri.RawQuery = q.Encode()
fail, err := http.NewRequest(http.MethodGet, uri.String(), nil)
fail.Header.Set(settings.ClientIpHeader, "127.0.0.1")
if err != nil {
return err
}
response, err = do(fail)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode != http.StatusBadRequest {
return fmt.Errorf("expected fail status code %d, got %d", http.StatusBadRequest, response.StatusCode)
}
return nil
})
if err != nil {
t.Fatal(err)
}
}

57
tests/logger_test.go Normal file
View File

@@ -0,0 +1,57 @@
package tests
import (
"context"
"fmt"
"log/slog"
"testing"
)
type logger struct {
t *testing.T
attrs []slog.Attr
}
func (l logger) Enabled(ctx context.Context, level slog.Level) bool {
return true
}
func (l logger) Handle(ctx context.Context, record slog.Record) error {
str := fmt.Sprintf("[%s] %s", record.Level, record.Message)
if record.NumAttrs() > 0 || len(l.attrs) > 0 {
str += ": "
}
for _, attr := range l.attrs {
str += fmt.Sprintf("%s=%s ", attr.Key, attr.Value.String())
}
record.Attrs(func(attr slog.Attr) bool {
str += fmt.Sprintf("%s=%s ", attr.Key, attr.Value.String())
return true
})
if record.Level == slog.LevelError {
l.t.Error(str)
} else {
l.t.Log(str)
}
return nil
}
func (l logger) WithAttrs(attrs []slog.Attr) slog.Handler {
newAttrs := make([]slog.Attr, 0, len(attrs)+len(l.attrs))
newAttrs = append(newAttrs, l.attrs...)
newAttrs = append(newAttrs, attrs...)
return logger{
t: l.t,
attrs: newAttrs,
}
}
func (l logger) WithGroup(name string) slog.Handler {
return l
}
func initLogger(t *testing.T) slog.Handler {
return logger{t: t}
}

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

@@ -10,6 +10,7 @@ import (
"net"
"net/http"
"net/http/httputil"
"net/netip"
"net/url"
"strings"
)
@@ -68,13 +69,14 @@ func EnsureNoOpenRedirect(redirect string) (string, error) {
return uri.String(), nil
}
func MakeReverseProxy(target string) (*httputil.ReverseProxy, error) {
func MakeReverseProxy(target string, goDns bool) (*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" {
@@ -88,9 +90,17 @@ func MakeReverseProxy(target string) (*httputil.ReverseProxy, error) {
}
// 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,
},
}
transport.DialContext = dialer.DialContext
}
rp := httputil.NewSingleHostReverseProxy(u)
rp.Transport = transport
return rp, nil
@@ -108,22 +118,44 @@ 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
}
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
}
func CacheBust() 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 {

59
utils/tagfetcher.go Normal file
View File

@@ -0,0 +1,59 @@
package utils
import (
"golang.org/x/net/html"
"io"
"mime"
"net/http"
"net/http/httptest"
"net/url"
)
func FetchTags(backend http.Handler, uri *url.URL, kind 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
}
return FetchTagsFromReader(response.Body, kind)
}
func FetchTagsFromReader(r io.Reader, kind string) (result []html.Node) {
//TODO: handle non UTF-8 documents
node, err := html.ParseWithOptions(r, html.ParseOptionEnableScripting(false))
if err != nil {
return nil
}
for n := range node.Descendants() {
if n.Type == html.ElementNode && n.Data == kind {
result = append(result, html.Node{
Type: n.Type,
DataAtom: n.DataAtom,
Data: n.Data,
Namespace: n.Namespace,
Attr: n.Attr,
})
}
}
return result
}