Compare commits
148 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
816d0fef90 | ||
|
|
06aca367a1 | ||
|
|
44c9114ae5 | ||
|
|
4b1878f1ac | ||
|
|
925a1d59a2 | ||
|
|
76417b4308 | ||
|
|
0e62f80f9b | ||
|
|
2cb5972371 | ||
|
|
3d73ee76c4 | ||
|
|
5bc1ab428b | ||
|
|
606f8ec3a0 | ||
|
|
1ea19c5a6c | ||
|
|
736c2708e9 | ||
|
|
74cc614564 | ||
|
|
e8e072286e | ||
|
|
0d28d1680c | ||
|
|
2ab45983e9 | ||
|
|
a2225fe749 | ||
|
|
61d0964eb0 | ||
|
|
b9ca196c63 | ||
|
|
f6a8f50a53 | ||
|
|
3047dcfd4b | ||
|
|
868c76eeb9 | ||
|
|
d412672ed4 | ||
|
|
d80e282781 | ||
|
|
2ecbd1db21 | ||
|
|
d6c29846df | ||
|
|
6e47cec540 | ||
|
|
fccaa64fad | ||
|
|
a9f03267b6 | ||
|
|
4ce6d9efa3 | ||
|
|
cb46d4c7b6 | ||
|
|
e46a5c75f8 | ||
|
|
b3cd741bee | ||
|
|
3606590b48 | ||
|
|
a87023861a | ||
|
|
e7833a7106 | ||
|
|
3c73c2de1c | ||
|
|
62277aac64 | ||
|
|
6db839e23f | ||
|
|
e49c4ae72f | ||
|
|
61655b6a02 | ||
|
|
b8bf35d4de | ||
|
|
b285c13e4c | ||
|
|
e7ef9af42a | ||
|
|
2bb8ec833d | ||
|
|
a5d973dbaa | ||
|
|
1a9224e453 | ||
|
|
3234c4e801 | ||
|
|
957303bbca | ||
|
|
d36d8354a2 | ||
|
|
666ffa574a | ||
|
|
06c363e55a | ||
|
|
62ece572d9 | ||
|
|
c5ad9cdf03 | ||
|
|
d353286a08 | ||
|
|
0473109e60 | ||
|
|
eb96acb559 | ||
|
|
c33531d7eb | ||
|
|
b3eb0ab4b7 | ||
|
|
45692ec6c0 | ||
|
|
32b7c578f6 | ||
|
|
01ef63abea | ||
|
|
0b9f077b6c | ||
|
|
a85aa95dbd | ||
|
|
a1f97adde8 | ||
|
|
bca5b25f28 | ||
|
|
d665036d98 | ||
|
|
9300132a4b | ||
|
|
9ebb78f09f | ||
|
|
398675aa3c | ||
|
|
01df790e30 | ||
|
|
13c0c5473e | ||
|
|
4d7436c51b | ||
|
|
bc0eaeca21 | ||
|
|
d6d69d0192 | ||
|
|
47f9f6fee6 | ||
|
|
6f3d81618c | ||
|
|
1f84f5e981 | ||
|
|
1e569571a0 | ||
|
|
ef89de8914 | ||
|
|
9541c58eeb | ||
|
|
fc7d67ad70 | ||
|
|
96870cc192 | ||
|
|
74a067ae10 | ||
|
|
3bbd50764a | ||
|
|
49e46e7e9f | ||
|
|
cd372e1512 | ||
|
|
cef915b353 | ||
|
|
10ceca02c9 | ||
|
|
71b99f9d12 | ||
|
|
cb02fb20e9 | ||
|
|
57755112ea | ||
|
|
6bb7ca979d | ||
|
|
a0224cb21c | ||
|
|
612362dbe5 | ||
|
|
d56d621f7a | ||
|
|
9719c0ff39 | ||
|
|
3b11792594 | ||
|
|
d83fe3653a | ||
|
|
1cc95a5fa7 | ||
|
|
ead41055ca | ||
|
|
1c7fe1bed9 | ||
|
|
27b25082b9 | ||
|
|
41920b491a | ||
|
|
1f6e705cbe | ||
|
|
a4bbe474db | ||
|
|
5189dd41f9 | ||
|
|
14c6a42e88 | ||
|
|
629e6b40b4 | ||
|
|
6202f5a642 | ||
|
|
3f674206e8 | ||
|
|
82eed95ff6 | ||
|
|
f04d801a43 | ||
|
|
4e19b38e9d | ||
|
|
5b7621b446 | ||
|
|
2866cf8e80 | ||
|
|
6458e6d019 | ||
|
|
f690cfaac3 | ||
|
|
058bfcef63 | ||
|
|
773dfee845 | ||
|
|
82c3843faa | ||
|
|
dc685ab2ce | ||
|
|
e3037cf34d | ||
|
|
87d71e783c | ||
|
|
ce8bc52d94 | ||
|
|
5efe6a8abc | ||
|
|
c84c67439e | ||
|
|
b0aa9ff450 | ||
|
|
bf3a46e153 | ||
|
|
ede95964cc | ||
|
|
69730e2e44 | ||
|
|
6dc6f1030e | ||
|
|
cdb0f23641 | ||
|
|
e4f9d09dd6 | ||
|
|
9bdb8bf72e | ||
|
|
2ff9c01eb3 | ||
|
|
39fbcf92d2 | ||
|
|
3d4a0af16f | ||
|
|
a5be4faa8a | ||
|
|
b1620e4d92 | ||
|
|
910ce2cde4 | ||
|
|
530413d87f | ||
|
|
d72010bb64 | ||
|
|
2cd6d0cebf | ||
|
|
1c0e015c69 | ||
|
|
e0baaa91b7 | ||
|
|
2485257153 |
@@ -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,
|
||||
@@ -8,6 +8,7 @@ local Build(go, alpine, os, arch) = {
|
||||
arch: arch
|
||||
},
|
||||
environment: {
|
||||
GOTOOLCHAIN: "local",
|
||||
CGO_ENABLED: "0",
|
||||
GOOS: os,
|
||||
GOARCH: arch,
|
||||
@@ -16,17 +17,46 @@ local Build(go, alpine, os, arch) = {
|
||||
{
|
||||
name: "build",
|
||||
image: "golang:" + go +"-alpine" + alpine,
|
||||
mirror: mirror,
|
||||
commands: [
|
||||
"apk update",
|
||||
"apk add --no-cache git",
|
||||
"mkdir .bin",
|
||||
"go build -v -o ./.bin/go-away ./cmd/go-away",
|
||||
"go build -v -pgo=auto -v -trimpath -ldflags=-buildid= -o ./.bin/go-away ./cmd/go-away",
|
||||
"go build -v -o ./.bin/test-wasm-runtime ./cmd/test-wasm-runtime",
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "check-policy-forgejo",
|
||||
image: "alpine:" + alpine,
|
||||
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/"
|
||||
],
|
||||
},
|
||||
{
|
||||
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 " +
|
||||
@@ -39,6 +69,7 @@ local Build(go, alpine, os, arch) = {
|
||||
{
|
||||
name: "test-wasm-fail",
|
||||
image: "alpine:" + alpine,
|
||||
mirror: mirror,
|
||||
depends_on: ["build"],
|
||||
commands: [
|
||||
"./.bin/test-wasm-runtime -wasm ./embed/challenge/js-pow-sha256/runtime/runtime.wasm " +
|
||||
@@ -51,39 +82,53 @@ local Build(go, alpine, os, arch) = {
|
||||
]
|
||||
};
|
||||
|
||||
local Publish(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,
|
||||
name: "publish-" + go + "-alpine" + alpine + "-" + secret,
|
||||
platform: {
|
||||
os: os,
|
||||
arch: arch,
|
||||
},
|
||||
trigger: trigger,
|
||||
steps: [
|
||||
{
|
||||
name: "setup-buildkitd",
|
||||
image: "alpine:" + alpine,
|
||||
mirror: mirror,
|
||||
commands: [
|
||||
"echo '[registry.\"docker.io\"]' > buildkitd.toml",
|
||||
"echo ' mirrors = [\"mirror.gcr.io\"]' >> buildkitd.toml"
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "docker",
|
||||
image: "plugins/buildx",
|
||||
privileged: true,
|
||||
environment: {
|
||||
DOCKER_BUILDKIT: "1"
|
||||
DOCKER_BUILDKIT: "1",
|
||||
SOURCE_DATE_EPOCH: 0,
|
||||
TZ: "UTC",
|
||||
LC_ALL: "C",
|
||||
PLUGIN_BUILDER_CONFIG: "buildkitd.toml",
|
||||
PLUGIN_BUILDER_DRIVER: "docker-container",
|
||||
},
|
||||
settings: {
|
||||
registry: "git.gammaspectra.live",
|
||||
repo: "git.gammaspectra.live/git/go-away",
|
||||
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,
|
||||
},
|
||||
auto_tag_suffix: "alpine" + alpine,
|
||||
username: {
|
||||
from_secret: "git_username",
|
||||
from_secret: secret + "_username",
|
||||
},
|
||||
password: {
|
||||
from_secret: "git_password",
|
||||
from_secret: secret + "_password",
|
||||
},
|
||||
} + extra,
|
||||
},
|
||||
@@ -91,18 +136,27 @@ local Publish(go, alpine, os, arch, trigger, platforms, extra) = {
|
||||
};
|
||||
|
||||
#
|
||||
local containerArchitectures = ["linux/amd64", "linux/arm64", "linux/riscv64"];
|
||||
|
||||
local alpineVersion = "3.21";
|
||||
local goVersion = "1.24";
|
||||
|
||||
local mirror = "https://mirror.gcr.io";
|
||||
|
||||
[
|
||||
Build("1.24", "3.20", "linux", "amd64"),
|
||||
Build("1.24", "3.20", "linux", "arm64"),
|
||||
Build("1.24", "3.21", "linux", "amd64"),
|
||||
Build("1.24", "3.21", "linux", "arm64"),
|
||||
Build(mirror, goVersion, alpineVersion, "linux", "amd64") + {"trigger": {event: ["push", "tag"], }},
|
||||
Build(mirror, goVersion, alpineVersion, "linux", "arm64") + {"trigger": {event: ["push", "tag"], }},
|
||||
|
||||
# Test PRs
|
||||
Build(mirror, goVersion, alpineVersion, "linux", "amd64") + {"name": "test-pr", "trigger": {event: ["pull_request"], }},
|
||||
|
||||
# latest
|
||||
Publish("1.24", "3.21", "linux", "amd64", {event: ["push"], branch: ["master"], }, ["linux/amd64", "linux/arm64"], {tags: ["latest"],}) + {name: "publish-latest"},
|
||||
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("1.24", "3.21", "linux", "amd64", {event: ["promote", "tag"], target: ["production"], }, ["linux/amd64", "linux/arm64"], {auto_tag: true,}),
|
||||
# legacy
|
||||
Publish("1.24", "3.20", "linux", "amd64", {event: ["promote", "tag"], target: ["production"], }, ["linux/amd64", "linux/arm64"], {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,}),
|
||||
]
|
||||
427
.drone.yml
427
.drone.yml
@@ -3,86 +3,7 @@ environment:
|
||||
CGO_ENABLED: "0"
|
||||
GOARCH: amd64
|
||||
GOOS: linux
|
||||
kind: pipeline
|
||||
name: build-1.24-alpine3.20-amd64
|
||||
platform:
|
||||
arch: amd64
|
||||
os: linux
|
||||
steps:
|
||||
- commands:
|
||||
- apk update
|
||||
- apk add --no-cache git
|
||||
- mkdir .bin
|
||||
- go build -v -o ./.bin/go-away ./cmd/go-away
|
||||
- go build -v -o ./.bin/test-wasm-runtime ./cmd/test-wasm-runtime
|
||||
image: golang:1.24-alpine3.20
|
||||
name: build
|
||||
- commands:
|
||||
- ./.bin/test-wasm-runtime -wasm ./embed/challenge/js-pow-sha256/runtime/runtime.wasm
|
||||
-make-challenge ./embed/challenge/js-pow-sha256/test/make-challenge.json -make-challenge-out
|
||||
./embed/challenge/js-pow-sha256/test/make-challenge-out.json -verify-challenge
|
||||
./embed/challenge/js-pow-sha256/test/verify-challenge.json -verify-challenge-out
|
||||
0
|
||||
depends_on:
|
||||
- build
|
||||
image: alpine:3.20
|
||||
name: test-wasm-success
|
||||
- commands:
|
||||
- ./.bin/test-wasm-runtime -wasm ./embed/challenge/js-pow-sha256/runtime/runtime.wasm
|
||||
-make-challenge ./embed/challenge/js-pow-sha256/test/make-challenge.json -make-challenge-out
|
||||
./embed/challenge/js-pow-sha256/test/make-challenge-out.json -verify-challenge
|
||||
./embed/challenge/js-pow-sha256/test/verify-challenge-fail.json -verify-challenge-out
|
||||
1
|
||||
depends_on:
|
||||
- build
|
||||
image: alpine:3.20
|
||||
name: test-wasm-fail
|
||||
type: docker
|
||||
---
|
||||
environment:
|
||||
CGO_ENABLED: "0"
|
||||
GOARCH: arm64
|
||||
GOOS: linux
|
||||
kind: pipeline
|
||||
name: build-1.24-alpine3.20-arm64
|
||||
platform:
|
||||
arch: arm64
|
||||
os: linux
|
||||
steps:
|
||||
- commands:
|
||||
- apk update
|
||||
- apk add --no-cache git
|
||||
- mkdir .bin
|
||||
- go build -v -o ./.bin/go-away ./cmd/go-away
|
||||
- go build -v -o ./.bin/test-wasm-runtime ./cmd/test-wasm-runtime
|
||||
image: golang:1.24-alpine3.20
|
||||
name: build
|
||||
- commands:
|
||||
- ./.bin/test-wasm-runtime -wasm ./embed/challenge/js-pow-sha256/runtime/runtime.wasm
|
||||
-make-challenge ./embed/challenge/js-pow-sha256/test/make-challenge.json -make-challenge-out
|
||||
./embed/challenge/js-pow-sha256/test/make-challenge-out.json -verify-challenge
|
||||
./embed/challenge/js-pow-sha256/test/verify-challenge.json -verify-challenge-out
|
||||
0
|
||||
depends_on:
|
||||
- build
|
||||
image: alpine:3.20
|
||||
name: test-wasm-success
|
||||
- commands:
|
||||
- ./.bin/test-wasm-runtime -wasm ./embed/challenge/js-pow-sha256/runtime/runtime.wasm
|
||||
-make-challenge ./embed/challenge/js-pow-sha256/test/make-challenge.json -make-challenge-out
|
||||
./embed/challenge/js-pow-sha256/test/make-challenge-out.json -verify-challenge
|
||||
./embed/challenge/js-pow-sha256/test/verify-challenge-fail.json -verify-challenge-out
|
||||
1
|
||||
depends_on:
|
||||
- build
|
||||
image: alpine:3.20
|
||||
name: test-wasm-fail
|
||||
type: docker
|
||||
---
|
||||
environment:
|
||||
CGO_ENABLED: "0"
|
||||
GOARCH: amd64
|
||||
GOOS: linux
|
||||
GOTOOLCHAIN: local
|
||||
kind: pipeline
|
||||
name: build-1.24-alpine3.21-amd64
|
||||
platform:
|
||||
@@ -93,10 +14,35 @@ steps:
|
||||
- apk update
|
||||
- apk add --no-cache git
|
||||
- mkdir .bin
|
||||
- go build -v -o ./.bin/go-away ./cmd/go-away
|
||||
- go build -v -pgo=auto -v -trimpath -ldflags=-buildid= -o ./.bin/go-away ./cmd/go-away
|
||||
- go build -v -o ./.bin/test-wasm-runtime ./cmd/test-wasm-runtime
|
||||
image: golang:1.24-alpine3.21
|
||||
mirror: https://mirror.gcr.io
|
||||
name: build
|
||||
- commands:
|
||||
- ./.bin/go-away --check --slog-level DEBUG --backend example.com=http://127.0.0.1:80
|
||||
--policy examples/forgejo.yml --policy-snippets examples/snippets/
|
||||
depends_on:
|
||||
- build
|
||||
image: alpine:3.21
|
||||
mirror: https://mirror.gcr.io
|
||||
name: check-policy-forgejo
|
||||
- commands:
|
||||
- ./.bin/go-away --check --slog-level DEBUG --backend example.com=http://127.0.0.1:80
|
||||
--policy examples/generic.yml --policy-snippets examples/snippets/
|
||||
depends_on:
|
||||
- build
|
||||
image: alpine:3.21
|
||||
mirror: https://mirror.gcr.io
|
||||
name: check-policy-generic
|
||||
- commands:
|
||||
- ./.bin/go-away --check --slog-level DEBUG --backend example.com=http://127.0.0.1:80
|
||||
--policy examples/spa.yml --policy-snippets examples/snippets/
|
||||
depends_on:
|
||||
- build
|
||||
image: alpine:3.21
|
||||
mirror: https://mirror.gcr.io
|
||||
name: check-policy-spa
|
||||
- commands:
|
||||
- ./.bin/test-wasm-runtime -wasm ./embed/challenge/js-pow-sha256/runtime/runtime.wasm
|
||||
-make-challenge ./embed/challenge/js-pow-sha256/test/make-challenge.json -make-challenge-out
|
||||
@@ -106,6 +52,7 @@ steps:
|
||||
depends_on:
|
||||
- build
|
||||
image: alpine:3.21
|
||||
mirror: https://mirror.gcr.io
|
||||
name: test-wasm-success
|
||||
- commands:
|
||||
- ./.bin/test-wasm-runtime -wasm ./embed/challenge/js-pow-sha256/runtime/runtime.wasm
|
||||
@@ -116,13 +63,19 @@ steps:
|
||||
depends_on:
|
||||
- build
|
||||
image: alpine:3.21
|
||||
mirror: https://mirror.gcr.io
|
||||
name: test-wasm-fail
|
||||
trigger:
|
||||
event:
|
||||
- push
|
||||
- tag
|
||||
type: docker
|
||||
---
|
||||
environment:
|
||||
CGO_ENABLED: "0"
|
||||
GOARCH: arm64
|
||||
GOOS: linux
|
||||
GOTOOLCHAIN: local
|
||||
kind: pipeline
|
||||
name: build-1.24-alpine3.21-arm64
|
||||
platform:
|
||||
@@ -133,10 +86,35 @@ steps:
|
||||
- apk update
|
||||
- apk add --no-cache git
|
||||
- mkdir .bin
|
||||
- go build -v -o ./.bin/go-away ./cmd/go-away
|
||||
- go build -v -pgo=auto -v -trimpath -ldflags=-buildid= -o ./.bin/go-away ./cmd/go-away
|
||||
- go build -v -o ./.bin/test-wasm-runtime ./cmd/test-wasm-runtime
|
||||
image: golang:1.24-alpine3.21
|
||||
mirror: https://mirror.gcr.io
|
||||
name: build
|
||||
- commands:
|
||||
- ./.bin/go-away --check --slog-level DEBUG --backend example.com=http://127.0.0.1:80
|
||||
--policy examples/forgejo.yml --policy-snippets examples/snippets/
|
||||
depends_on:
|
||||
- build
|
||||
image: alpine:3.21
|
||||
mirror: https://mirror.gcr.io
|
||||
name: check-policy-forgejo
|
||||
- commands:
|
||||
- ./.bin/go-away --check --slog-level DEBUG --backend example.com=http://127.0.0.1:80
|
||||
--policy examples/generic.yml --policy-snippets examples/snippets/
|
||||
depends_on:
|
||||
- build
|
||||
image: alpine:3.21
|
||||
mirror: https://mirror.gcr.io
|
||||
name: check-policy-generic
|
||||
- commands:
|
||||
- ./.bin/go-away --check --slog-level DEBUG --backend example.com=http://127.0.0.1:80
|
||||
--policy examples/spa.yml --policy-snippets examples/snippets/
|
||||
depends_on:
|
||||
- build
|
||||
image: alpine:3.21
|
||||
mirror: https://mirror.gcr.io
|
||||
name: check-policy-spa
|
||||
- commands:
|
||||
- ./.bin/test-wasm-runtime -wasm ./embed/challenge/js-pow-sha256/runtime/runtime.wasm
|
||||
-make-challenge ./embed/challenge/js-pow-sha256/test/make-challenge.json -make-challenge-out
|
||||
@@ -146,6 +124,7 @@ steps:
|
||||
depends_on:
|
||||
- build
|
||||
image: alpine:3.21
|
||||
mirror: https://mirror.gcr.io
|
||||
name: test-wasm-success
|
||||
- commands:
|
||||
- ./.bin/test-wasm-runtime -wasm ./embed/challenge/js-pow-sha256/runtime/runtime.wasm
|
||||
@@ -156,17 +135,104 @@ steps:
|
||||
depends_on:
|
||||
- build
|
||||
image: alpine:3.21
|
||||
mirror: https://mirror.gcr.io
|
||||
name: test-wasm-fail
|
||||
trigger:
|
||||
event:
|
||||
- push
|
||||
- tag
|
||||
type: docker
|
||||
---
|
||||
environment:
|
||||
CGO_ENABLED: "0"
|
||||
GOARCH: amd64
|
||||
GOOS: linux
|
||||
GOTOOLCHAIN: local
|
||||
kind: pipeline
|
||||
name: publish-latest
|
||||
name: test-pr
|
||||
platform:
|
||||
arch: amd64
|
||||
os: linux
|
||||
steps:
|
||||
- commands:
|
||||
- apk update
|
||||
- apk add --no-cache git
|
||||
- mkdir .bin
|
||||
- go build -v -pgo=auto -v -trimpath -ldflags=-buildid= -o ./.bin/go-away ./cmd/go-away
|
||||
- go build -v -o ./.bin/test-wasm-runtime ./cmd/test-wasm-runtime
|
||||
image: golang:1.24-alpine3.21
|
||||
mirror: https://mirror.gcr.io
|
||||
name: build
|
||||
- commands:
|
||||
- ./.bin/go-away --check --slog-level DEBUG --backend example.com=http://127.0.0.1:80
|
||||
--policy examples/forgejo.yml --policy-snippets examples/snippets/
|
||||
depends_on:
|
||||
- build
|
||||
image: alpine:3.21
|
||||
mirror: https://mirror.gcr.io
|
||||
name: check-policy-forgejo
|
||||
- commands:
|
||||
- ./.bin/go-away --check --slog-level DEBUG --backend example.com=http://127.0.0.1:80
|
||||
--policy examples/generic.yml --policy-snippets examples/snippets/
|
||||
depends_on:
|
||||
- build
|
||||
image: alpine:3.21
|
||||
mirror: https://mirror.gcr.io
|
||||
name: check-policy-generic
|
||||
- commands:
|
||||
- ./.bin/go-away --check --slog-level DEBUG --backend example.com=http://127.0.0.1:80
|
||||
--policy examples/spa.yml --policy-snippets examples/snippets/
|
||||
depends_on:
|
||||
- build
|
||||
image: alpine:3.21
|
||||
mirror: https://mirror.gcr.io
|
||||
name: check-policy-spa
|
||||
- commands:
|
||||
- ./.bin/test-wasm-runtime -wasm ./embed/challenge/js-pow-sha256/runtime/runtime.wasm
|
||||
-make-challenge ./embed/challenge/js-pow-sha256/test/make-challenge.json -make-challenge-out
|
||||
./embed/challenge/js-pow-sha256/test/make-challenge-out.json -verify-challenge
|
||||
./embed/challenge/js-pow-sha256/test/verify-challenge.json -verify-challenge-out
|
||||
0
|
||||
depends_on:
|
||||
- build
|
||||
image: alpine:3.21
|
||||
mirror: https://mirror.gcr.io
|
||||
name: test-wasm-success
|
||||
- commands:
|
||||
- ./.bin/test-wasm-runtime -wasm ./embed/challenge/js-pow-sha256/runtime/runtime.wasm
|
||||
-make-challenge ./embed/challenge/js-pow-sha256/test/make-challenge.json -make-challenge-out
|
||||
./embed/challenge/js-pow-sha256/test/make-challenge-out.json -verify-challenge
|
||||
./embed/challenge/js-pow-sha256/test/verify-challenge-fail.json -verify-challenge-out
|
||||
1
|
||||
depends_on:
|
||||
- build
|
||||
image: alpine:3.21
|
||||
mirror: https://mirror.gcr.io
|
||||
name: test-wasm-fail
|
||||
trigger:
|
||||
event:
|
||||
- pull_request
|
||||
type: docker
|
||||
---
|
||||
kind: pipeline
|
||||
name: publish-latest-git
|
||||
platform:
|
||||
arch: amd64
|
||||
os: linux
|
||||
steps:
|
||||
- commands:
|
||||
- echo '[registry."docker.io"]' > buildkitd.toml
|
||||
- echo ' mirrors = ["mirror.gcr.io"]' >> buildkitd.toml
|
||||
image: alpine:3.21
|
||||
mirror: https://mirror.gcr.io
|
||||
name: setup-buildkitd
|
||||
- environment:
|
||||
DOCKER_BUILDKIT: "1"
|
||||
LC_ALL: C
|
||||
PLUGIN_BUILDER_CONFIG: buildkitd.toml
|
||||
PLUGIN_BUILDER_DRIVER: docker-container
|
||||
SOURCE_DATE_EPOCH: 0
|
||||
TZ: UTC
|
||||
image: plugins/buildx
|
||||
name: docker
|
||||
privileged: true
|
||||
@@ -175,13 +241,14 @@ 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:
|
||||
- linux/amd64
|
||||
- linux/arm64
|
||||
- linux/riscv64
|
||||
registry: git.gammaspectra.live
|
||||
repo: git.gammaspectra.live/git/go-away
|
||||
tags:
|
||||
@@ -196,13 +263,120 @@ trigger:
|
||||
type: docker
|
||||
---
|
||||
kind: pipeline
|
||||
name: publish-1.24-alpine3.21
|
||||
name: publish-latest-codeberg
|
||||
platform:
|
||||
arch: amd64
|
||||
os: linux
|
||||
steps:
|
||||
- commands:
|
||||
- echo '[registry."docker.io"]' > buildkitd.toml
|
||||
- echo ' mirrors = ["mirror.gcr.io"]' >> buildkitd.toml
|
||||
image: alpine:3.21
|
||||
mirror: https://mirror.gcr.io
|
||||
name: setup-buildkitd
|
||||
- environment:
|
||||
DOCKER_BUILDKIT: "1"
|
||||
LC_ALL: C
|
||||
PLUGIN_BUILDER_CONFIG: buildkitd.toml
|
||||
PLUGIN_BUILDER_DRIVER: docker-container
|
||||
SOURCE_DATE_EPOCH: 0
|
||||
TZ: UTC
|
||||
image: plugins/buildx
|
||||
name: docker
|
||||
privileged: true
|
||||
settings:
|
||||
auto_tag_suffix: alpine3.21
|
||||
build_args:
|
||||
from: alpine:3.21
|
||||
from_builder: golang:1.24-alpine3.21
|
||||
compress: true
|
||||
mirror: https://mirror.gcr.io
|
||||
password:
|
||||
from_secret: codeberg_password
|
||||
platform:
|
||||
- linux/amd64
|
||||
- linux/arm64
|
||||
- linux/riscv64
|
||||
registry: codeberg.org
|
||||
repo: codeberg.org/gone/go-away
|
||||
tags:
|
||||
- latest
|
||||
username:
|
||||
from_secret: codeberg_username
|
||||
trigger:
|
||||
branch:
|
||||
- master
|
||||
event:
|
||||
- push
|
||||
type: docker
|
||||
---
|
||||
kind: pipeline
|
||||
name: publish-latest-github
|
||||
platform:
|
||||
arch: amd64
|
||||
os: linux
|
||||
steps:
|
||||
- commands:
|
||||
- echo '[registry."docker.io"]' > buildkitd.toml
|
||||
- echo ' mirrors = ["mirror.gcr.io"]' >> buildkitd.toml
|
||||
image: alpine:3.21
|
||||
mirror: https://mirror.gcr.io
|
||||
name: setup-buildkitd
|
||||
- environment:
|
||||
DOCKER_BUILDKIT: "1"
|
||||
LC_ALL: C
|
||||
PLUGIN_BUILDER_CONFIG: buildkitd.toml
|
||||
PLUGIN_BUILDER_DRIVER: docker-container
|
||||
SOURCE_DATE_EPOCH: 0
|
||||
TZ: UTC
|
||||
image: plugins/buildx
|
||||
name: docker
|
||||
privileged: true
|
||||
settings:
|
||||
auto_tag_suffix: alpine3.21
|
||||
build_args:
|
||||
from: alpine:3.21
|
||||
from_builder: golang:1.24-alpine3.21
|
||||
compress: true
|
||||
mirror: https://mirror.gcr.io
|
||||
password:
|
||||
from_secret: github_password
|
||||
platform:
|
||||
- linux/amd64
|
||||
- linux/arm64
|
||||
- linux/riscv64
|
||||
registry: ghcr.io
|
||||
repo: ghcr.io/weebdatahoarder/go-away
|
||||
tags:
|
||||
- latest
|
||||
username:
|
||||
from_secret: github_username
|
||||
trigger:
|
||||
branch:
|
||||
- master
|
||||
event:
|
||||
- push
|
||||
type: docker
|
||||
---
|
||||
kind: pipeline
|
||||
name: publish-1.24-alpine3.21-git
|
||||
platform:
|
||||
arch: amd64
|
||||
os: linux
|
||||
steps:
|
||||
- commands:
|
||||
- echo '[registry."docker.io"]' > buildkitd.toml
|
||||
- echo ' mirrors = ["mirror.gcr.io"]' >> buildkitd.toml
|
||||
image: alpine:3.21
|
||||
mirror: https://mirror.gcr.io
|
||||
name: setup-buildkitd
|
||||
- environment:
|
||||
DOCKER_BUILDKIT: "1"
|
||||
LC_ALL: C
|
||||
PLUGIN_BUILDER_CONFIG: buildkitd.toml
|
||||
PLUGIN_BUILDER_DRIVER: docker-container
|
||||
SOURCE_DATE_EPOCH: 0
|
||||
TZ: UTC
|
||||
image: plugins/buildx
|
||||
name: docker
|
||||
privileged: true
|
||||
@@ -212,13 +386,14 @@ 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:
|
||||
- linux/amd64
|
||||
- linux/arm64
|
||||
- linux/riscv64
|
||||
registry: git.gammaspectra.live
|
||||
repo: git.gammaspectra.live/git/go-away
|
||||
username:
|
||||
@@ -232,33 +407,93 @@ trigger:
|
||||
type: docker
|
||||
---
|
||||
kind: pipeline
|
||||
name: publish-1.24-alpine3.20
|
||||
name: publish-1.24-alpine3.21-codeberg
|
||||
platform:
|
||||
arch: amd64
|
||||
os: linux
|
||||
steps:
|
||||
- commands:
|
||||
- echo '[registry."docker.io"]' > buildkitd.toml
|
||||
- echo ' mirrors = ["mirror.gcr.io"]' >> buildkitd.toml
|
||||
image: alpine:3.21
|
||||
mirror: https://mirror.gcr.io
|
||||
name: setup-buildkitd
|
||||
- environment:
|
||||
DOCKER_BUILDKIT: "1"
|
||||
LC_ALL: C
|
||||
PLUGIN_BUILDER_CONFIG: buildkitd.toml
|
||||
PLUGIN_BUILDER_DRIVER: docker-container
|
||||
SOURCE_DATE_EPOCH: 0
|
||||
TZ: UTC
|
||||
image: plugins/buildx
|
||||
name: docker
|
||||
privileged: true
|
||||
settings:
|
||||
auto_tag: true
|
||||
auto_tag_suffix: alpine3.20
|
||||
auto_tag_suffix: alpine3.21
|
||||
build_args:
|
||||
from: alpine:3.20
|
||||
from_builder: golang:1.24-alpine3.20
|
||||
builder_driver: docker-container
|
||||
from: alpine:3.21
|
||||
from_builder: golang:1.24-alpine3.21
|
||||
compress: true
|
||||
mirror: https://mirror.gcr.io
|
||||
password:
|
||||
from_secret: git_password
|
||||
from_secret: codeberg_password
|
||||
platform:
|
||||
- linux/amd64
|
||||
- linux/arm64
|
||||
registry: git.gammaspectra.live
|
||||
repo: git.gammaspectra.live/git/go-away
|
||||
- linux/riscv64
|
||||
registry: codeberg.org
|
||||
repo: codeberg.org/gone/go-away
|
||||
username:
|
||||
from_secret: git_username
|
||||
from_secret: codeberg_username
|
||||
trigger:
|
||||
event:
|
||||
- promote
|
||||
- tag
|
||||
target:
|
||||
- production
|
||||
type: docker
|
||||
---
|
||||
kind: pipeline
|
||||
name: publish-1.24-alpine3.21-github
|
||||
platform:
|
||||
arch: amd64
|
||||
os: linux
|
||||
steps:
|
||||
- commands:
|
||||
- echo '[registry."docker.io"]' > buildkitd.toml
|
||||
- echo ' mirrors = ["mirror.gcr.io"]' >> buildkitd.toml
|
||||
image: alpine:3.21
|
||||
mirror: https://mirror.gcr.io
|
||||
name: setup-buildkitd
|
||||
- environment:
|
||||
DOCKER_BUILDKIT: "1"
|
||||
LC_ALL: C
|
||||
PLUGIN_BUILDER_CONFIG: buildkitd.toml
|
||||
PLUGIN_BUILDER_DRIVER: docker-container
|
||||
SOURCE_DATE_EPOCH: 0
|
||||
TZ: UTC
|
||||
image: plugins/buildx
|
||||
name: docker
|
||||
privileged: true
|
||||
settings:
|
||||
auto_tag: true
|
||||
auto_tag_suffix: alpine3.21
|
||||
build_args:
|
||||
from: alpine:3.21
|
||||
from_builder: golang:1.24-alpine3.21
|
||||
compress: true
|
||||
mirror: https://mirror.gcr.io
|
||||
password:
|
||||
from_secret: github_password
|
||||
platform:
|
||||
- linux/amd64
|
||||
- linux/arm64
|
||||
- linux/riscv64
|
||||
registry: ghcr.io
|
||||
repo: ghcr.io/weebdatahoarder/go-away
|
||||
username:
|
||||
from_secret: github_username
|
||||
trigger:
|
||||
event:
|
||||
- promote
|
||||
@@ -268,6 +503,6 @@ trigger:
|
||||
type: docker
|
||||
---
|
||||
kind: signature
|
||||
hmac: 8583621811fa483a6594352a8f9eeca7d66f6509f8e36bd2299b9e0723ed1451
|
||||
hmac: df53e4ea6f1c47df4d2a3f89b931b8513e83daa9c6c15baba2662d8112a721c8
|
||||
|
||||
...
|
||||
|
||||
29
Dockerfile
29
Dockerfile
@@ -1,5 +1,5 @@
|
||||
ARG from_builder=golang:1.24-alpine3.21
|
||||
ARG from=alpine:3.21
|
||||
ARG from_builder=docker.io/golang:1.24-alpine3.21
|
||||
ARG from=docker.io/alpine:3.21
|
||||
|
||||
ARG BUILDPLATFORM
|
||||
|
||||
@@ -7,6 +7,7 @@ FROM --platform=$BUILDPLATFORM ${from_builder} AS build
|
||||
|
||||
ARG TARGETARCH
|
||||
ARG TARGETOS
|
||||
ARG GOTOOLCHAIN=local
|
||||
|
||||
RUN apk update && apk add --no-cache \
|
||||
bash \
|
||||
@@ -22,42 +23,46 @@ RUN ./build-compress.sh
|
||||
ENV CGO_ENABLED=0
|
||||
ENV GOOS=${TARGETOS}
|
||||
ENV GOARCH=${TARGETARCH}
|
||||
ENV GOTOOLCHAIN=${GOTOOLCHAIN}
|
||||
|
||||
RUN go build -pgo=auto -v -trimpath -o "${GOBIN}/go-away" ./cmd/go-away
|
||||
RUN go build -pgo=auto -v -trimpath -ldflags=-buildid= -o "${GOBIN}/go-away" ./cmd/go-away
|
||||
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=""
|
||||
ENV GOAWAY_CHALLENGE_TEMPLATE="anubis"
|
||||
ENV GOAWAY_CHALLENGE_TEMPLATE_THEME=""
|
||||
ENV GOAWAY_CHALLENGE_TEMPLATE_LOGO=""
|
||||
ENV GOAWAY_SLOG_LEVEL="WARN"
|
||||
ENV GOAWAY_CLIENT_IP_HEADER=""
|
||||
ENV GOAWAY_BACKEND_IP_HEADER=""
|
||||
ENV GOAWAY_JWT_PRIVATE_KEY_SEED=""
|
||||
ENV GOAWAY_BACKEND=""
|
||||
ENV GOAWAY_DNSBL="dnsbl.dronebl.org"
|
||||
ENV GOAWAY_ACME_AUTOCERT=""
|
||||
ENV GOAWAY_CACHE="/cache"
|
||||
|
||||
|
||||
EXPOSE 8080/tcp
|
||||
EXPOSE 8080/udp
|
||||
EXPOSE 9090/tcp
|
||||
EXPOSE 6060/tcp
|
||||
|
||||
ENV JWT_PRIVATE_KEY_SEED="${GOAWAY_JWT_PRIVATE_KEY_SEED}"
|
||||
|
||||
ENTRYPOINT /bin/go-away --bind "${GOAWAY_BIND}" --bind-network "${GOAWAY_BIND_NETWORK}" --socket-mode "${GOAWAY_SOCKET_MODE}" \
|
||||
--policy ${GOAWAY_POLICY} --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"]
|
||||
|
||||
494
README.md
494
README.md
@@ -1,14 +1,24 @@
|
||||
### <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.
|
||||
|
||||
[](https://ci.gammaspectra.live/git/go-away)
|
||||
[](https://pkg.go.dev/git.gammaspectra.live/git/go-away)
|
||||
|
||||
This documentation is a work in progress. For now, see policy examples under [examples/](examples/).
|
||||
go-away sits in between your site and the Internet / upstream proxy.
|
||||
|
||||
This Go package can be used as a command on `git.gammaspectra.live/git/go-away/cmd/go-away` or a library under `git.gammaspectra.live/git/go-away/lib`
|
||||
Incoming requests can be selected by [rules](#rich-rule-matching) to be [actioned](https://git.gammaspectra.live/git/go-away/wiki/Rule-Actions) or [challenged](https://git.gammaspectra.live/git/go-away/wiki/Challenges) to filter suspicious requests.
|
||||
|
||||
The tool is designed highly flexible so the operator can minimize impact to legit users, while surgically targeting heavy endpoints or scrapers.
|
||||
|
||||
[Challenges](https://git.gammaspectra.live/git/go-away/wiki/Challenges) can be transparent (not shown to user, depends on backend or other logic), [non-JavaScript](#non-javascript-challenges) (challenges common browser properties), or [custom JavaScript](#custom-javascript-wasm-challenges) (from Proof of Work to fingerprinting or Captcha is supported)
|
||||
|
||||
See _[Why do this?](#why-do-this)_ section for the challenges and reasoning behind this tool.
|
||||
|
||||
**This documentation and go-away are in active development.** See [What's left?](#what-s-left) section for a breakdown.
|
||||
|
||||
Check this README for a general introduction. An [in-depth Wiki](https://git.gammaspectra.live/git/go-away/wiki/) is available and being improved.
|
||||
|
||||
## Support
|
||||
|
||||
@@ -16,7 +26,27 @@ If you have some suggestion or issue, feel free to open a [New Issue](https://gi
|
||||
|
||||
[Pull Requests](https://git.gammaspectra.live/git/go-away/pulls) are encouraged and desired.
|
||||
|
||||
For real-time chat and other support join IRC on [##go-away](ircs://irc.libera.chat/##go-away) on Libera.Chat. The channel may not be monitored at all times, feel free to ping the operators there.
|
||||
For real-time chat and other support join IRC on [#go-away](ircs://irc.libera.chat/#go-away) on Libera.Chat [[WebIRC]](https://web.libera.chat/?nick=Guest?#go-away). The channel may not be monitored at all times, feel free to ping the operators there.
|
||||
|
||||
## Code Mirrors
|
||||
|
||||
Source code is automatically pushed to the following mirrors. Packages are also mirrored on Codeberg and GitHub.
|
||||
|
||||
[](https://git.gammaspectra.live/git/go-away)  [](https://git.gammaspectra.live/git/go-away/issues?state=open) [](https://git.gammaspectra.live/git/go-away/pulls?state=open)
|
||||
|
||||
[](https://codeberg.org/gone/go-away) 
|
||||
|
||||
[](https://github.com/WeebDataHoarder/go-away) 
|
||||
|
||||
[](https://git.sr.ht/~datahoarder/go-away)
|
||||
|
||||
Note that issues or pull requests should be issued on the [main Forge](https://git.gammaspectra.live/git/go-away).
|
||||
|
||||
## Installation and Setup
|
||||
|
||||
See the [Installation page](https://git.gammaspectra.live/git/go-away/wiki/Installation) on the Wiki for all the details.
|
||||
|
||||
go-away can be directly run from command line, via pre-built containers, or your own built containers.
|
||||
|
||||
|
||||
## Features
|
||||
@@ -33,46 +63,55 @@ Rules and conditions are served with this environment:
|
||||
|
||||
```
|
||||
remoteAddress (net.IP) - Connecting client remote address from headers or properties
|
||||
remoteAddress.network(networkName string) bool - Check whether a given IP is listed on the underlying defined network
|
||||
remoteAddress.network(networkCIDR string) bool - Check whether a given IP is listed on the CIDR
|
||||
host (string) - HTTP Host
|
||||
method (string) - HTTP Method/Verb
|
||||
userAgent (string) - HTTP User-Agent header
|
||||
path (string) - HTTP request Path
|
||||
query (map[string]string) - HTTP request Query arguments
|
||||
headers (map[string]string) - HTTP request headers
|
||||
|
||||
fp (map[string]string) - Available fingerprints
|
||||
|
||||
Only available when TLS is enabled
|
||||
fpJA3N (string) JA3N TLS Fingerprint
|
||||
fpJA4 (string) JA4 TLS Fingerprint
|
||||
fp.ja3n (string) JA3N TLS Fingerprint
|
||||
fp.ja4 (string) JA4 TLS Fingerprint
|
||||
```
|
||||
|
||||
Additionally, these functions are available:
|
||||
```
|
||||
Check whether a given IP is listed on the underlying defined network or CIDR
|
||||
inNetwork(networkName string, address net.IP) bool
|
||||
inNetwork(networkCIDR string, address net.IP) bool
|
||||
|
||||
Check whether a given IP is listed on the provided DNSBL
|
||||
inDNSBL(address net.IP) bool
|
||||
```
|
||||
|
||||
### Template support
|
||||
### Package path
|
||||
|
||||
You can modify the path where challenges are served and package name, if you don't want its presence to be easily discoverable.
|
||||
|
||||
No source code editing or forking necessary!
|
||||
|
||||
Simply pass a new absolute path via the cmdline _path_ argument, like so: `--path "/.goaway_example"`
|
||||
|
||||
### Page template and customization support
|
||||
|
||||
Internal or external templates can be loaded to customize the look of the challenge or error page. Additionally, themes can be configured to change the look of these quickly.
|
||||
|
||||
These templates are included by default:
|
||||
|
||||
* `anubis`: An anubis-like themed challenge.
|
||||
* `forgejo`: Uses the Forgejo template and assets from your own instance. Supports specifying themes like `forgejo-light` and `forgejo-dark`.
|
||||
* `forgejo`: Uses the Forgejo template and assets from your own instance. Supports specifying themes like `forgejo-auto`, `forgejo-light` and `forgejo-dark`.
|
||||
|
||||
External templates for your site can be loaded specifying a full path to the `.gohtml` file. See [embed/templates/](embed/templates/) for examples to follow.
|
||||
|
||||
### Extended rule actions
|
||||
You can alter the language and strings in the templates directly from the [config.yml](examples/config.yml) file if specified, or add footer links directly.
|
||||
|
||||
In addition to the common PASS / CHALLENGE / DENY rules, we offer CHECK and POISON.
|
||||
Some templates support themes. Specify that either via the [config.yml](examples/config.yml) file, or via `challenge-template-theme` cmdline argument.
|
||||
|
||||
CHECK allows the client to be challenged but continue matching rules after these.
|
||||
Most templates support overriding the logo. Specify that either via the [config.yml](examples/config.yml) file, or via `challenge-template-logo` cmdline argument.
|
||||
|
||||
POISON sends defined responses to bad clients that will annoy them.
|
||||
**Feel free to make any changes to existing templates or bring your own, alter any logos or styling, it's yours to adapt!**
|
||||
|
||||
### Advanced actions
|
||||
|
||||
In addition to the common PASS / CHALLENGE / DENY rules, go-away offers more actions, plus any more extensible via code.
|
||||
|
||||
See the [Rule Actions page](https://git.gammaspectra.live/git/go-away/wiki/Rule-Actions) on the Wiki.
|
||||
|
||||
### Multiple challenge matching
|
||||
|
||||
@@ -82,16 +121,17 @@ For example:
|
||||
```yaml
|
||||
- name: standard-browser
|
||||
action: challenge
|
||||
challenges: [http-cookie-check, self-preload-link, self-meta-refresh, self-resource-load, js-pow-sha256]
|
||||
settings:
|
||||
challenges: [http-cookie-check, preload-link, meta-refresh, resource-load, js-pow-sha256]
|
||||
conditions:
|
||||
- '($is-generic-browser)'
|
||||
```
|
||||
|
||||
This rule has the user be checked against a backend, then attempts pass a few browser challenges.
|
||||
|
||||
In this case the processing would stop at `self-meta-refresh` due to the behavior of earlier challenges.
|
||||
In this case the processing would stop at `meta-refresh` due to the behavior of earlier challenges (cookie check and preload link allow failing / continue due to being silent, while meta-refresh requires displaying a challenge page).
|
||||
|
||||
Any of these listed challenges being passed in the past will allow the client through, including non-offered `self-resource-load` and `js-pow-sha256`.
|
||||
Any of these listed challenges being passed in the past will allow the client through, including non-offered `resource-load` and `js-pow-sha256`.
|
||||
|
||||
### Non-Javascript challenges
|
||||
|
||||
@@ -99,15 +139,15 @@ Several challenges that do not require JavaScript are offered, some targeting th
|
||||
|
||||
These can be used for light checking of requests that eliminate most of the low effort scraping.
|
||||
|
||||
See [Challenges](#challenges) below for a list of them.
|
||||
See [Transparent challenges](https://git.gammaspectra.live/git/go-away/wiki/Challenges#transparent) and [Non-JavaScript challenges](https://git.gammaspectra.live/git/go-away/wiki/Challenges#non-javascript) on the Wiki for more information.
|
||||
|
||||
### Custom proof-of-work JS / WASM challenges
|
||||
### Custom JavaScript / WASM challenges
|
||||
|
||||
A WASM interface for server-side proof generation and checking is offered. We provide `js-pow-sha256` as an example of one.
|
||||
|
||||
An internal test has shown you can implement Captchas or other browser fingerprinting tests within this interface.
|
||||
You can implement Captchas or other browser fingerprinting tests within this interface.
|
||||
|
||||
If you are interested in creating your own, see the [Development](#development) section below.
|
||||
See [Custom JavaScript challenges](https://git.gammaspectra.live/git/go-away/wiki/Challenges#custom-javascript) on the Wiki for more information.
|
||||
|
||||
### Upstream PROXY support
|
||||
|
||||
@@ -123,7 +163,6 @@ You can enable automatic certificate generation and TLS for the site via any ACM
|
||||
|
||||
Without TLS, HTTP/2 cleartext is supported, but you will need to configure the upstream proxy to send this protocol (`h2c://` on Caddy for example).
|
||||
|
||||
|
||||
### TLS Fingerprinting
|
||||
|
||||
When running with TLS via autocert, TLS Fingerprinting of the incoming client is done.
|
||||
@@ -132,20 +171,11 @@ This can be targeted on conditions or other application logic.
|
||||
|
||||
Read more about [JA3](https://medium.com/salesforce-engineering/tls-fingerprinting-with-ja3-and-ja3s-247362855967) and [JA4](https://github.com/FoxIO-LLC/ja4/blob/main/technical_details/README.md).
|
||||
|
||||
### Network range and automated filtering
|
||||
|
||||
### DNSBL
|
||||
Some specific search spiders do follow _robots.txt_ and are well behaved. However, many actors can reuse user agents, so the origin network ranges must be checked.
|
||||
|
||||
You can configure a [DNSBL (Domain Name System blocklist)](https://en.wikipedia.org/wiki/Domain_Name_System_blocklist) to be queried on rules and conditions.
|
||||
|
||||
This allows you to serve harder or different challenges to higher risk clients, or block them from specific sections.
|
||||
|
||||
Only rules that match DNSBL will cause a query to be sent, meaning the bulk of requests will not be sent to this service upstream.
|
||||
|
||||
Results will be temporarily cached
|
||||
|
||||
By default, [DroneBL](https://dronebl.org/) is used.
|
||||
|
||||
### Network range loading
|
||||
The samples provide example network range fetching and rules for Googlebot / Bingbot / DuckDuckBot / Kagibot / Qwantbot / Yandexbot.
|
||||
|
||||
Network ranges can be loaded via fetched JSON / TXT / HTML pages, or via lists. You can filter these using _jq_ or a regex.
|
||||
|
||||
@@ -166,66 +196,19 @@ Example for _regex_:
|
||||
```
|
||||
|
||||
|
||||
### Sharing of signing seed across instances
|
||||
|
||||
You can share the signing secret across multiple of your instances if you'd like to deploy multiple across the world.
|
||||
|
||||
That way signed secrets will be verifiable across all the instances.
|
||||
|
||||
By default, a random temporary key is generated every run.
|
||||
|
||||
### Multiple backend support
|
||||
|
||||
Multiple backends are supported, and rules specific on backend can be defined, and conditions and rules can match this as well.
|
||||
|
||||
Subdomain wildcards like `*.example.com`, or full fallback wildcard `*` are supported.
|
||||
|
||||
This allows one instance to run multiple domains or subdomains.
|
||||
|
||||
### Package path
|
||||
### IPv6 Happy Eyeballs challenge retry
|
||||
|
||||
You can modify the path where challenges are served and package name, if you don't want its presence to be easily discoverable.
|
||||
In case a client connects over IPv4 first then IPv6 due to [Fast Fallback / Happy Eyeballs](https://en.wikipedia.org/wiki/Happy_Eyeballs), the challenge will automatically be retried.
|
||||
|
||||
No source code editing or forking necessary!
|
||||
|
||||
## Why?
|
||||
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.
|
||||
|
||||
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 at nonsense URLs
|
||||
|
||||
If AI is so smart, why not just git clone the repositories?
|
||||
|
||||
|
||||
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]
|
||||
|
||||
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!)
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
### Can't scrapers adapt?
|
||||
|
||||
Yes, they can. At the moment their spray-and-pray approach is cheap for them.
|
||||
|
||||
If they have to start adding an active browser in their scraping, that makes their collection expensive and slow.
|
||||
|
||||
This would more or less eliminate the high rate low effort passive scraping and replace it with an active model.
|
||||
|
||||
go-anubis offers a highly configurable set of challenges and rules that you can adapt to new ways.
|
||||
This is tracked by tagging challenges with a readable flag indicating the type of address.
|
||||
|
||||
## Example policies
|
||||
|
||||
@@ -234,7 +217,6 @@ go-anubis offers a highly configurable set of challenges and rules that you can
|
||||
The policy file at [examples/forgejo.yml](examples/forgejo.yml) provides a ready template to be used on your own Forgejo instance.
|
||||
|
||||
Important notes:
|
||||
* Edit the `homesite` rule, as it's targeted to common users or orgs on the instance. A better regex might be possible in the future.
|
||||
* Edit the `http-cookie-check` challenge, as this will fetch the listed backend with the given session cookie to check for user login.
|
||||
* Adjust the desired blocked networks or others. A template list of network ranges is provided, feel free to remove these if not needed.
|
||||
* Check the conditions and base rules to change your challenges offered and other ordering.
|
||||
@@ -252,258 +234,96 @@ Important notes:
|
||||
* Add or modify rules to target specific pages on your site as desired.
|
||||
* By default Googlebot / Bingbot / DuckDuckBot / Kagibot / Qwantbot / Yandexbot are allowed by useragent and network ranges.
|
||||
|
||||
### Snippets
|
||||
|
||||
## Setup
|
||||
You can define snippets to be included. YAML anchors/aliases are supported.
|
||||
|
||||
It is recommended to have another reverse proxy above (for example [Caddy](https://caddyserver.com/), nginx, HAProxy) to handle HTTPs or similar.
|
||||
|
||||
go-away for now only accepts plaintext connections, although it can take _HTTP/2_ / _h2c_ connections if desired over the same port.
|
||||
|
||||
### Binary / Go
|
||||
|
||||
Requires Go 1.24+. Builds statically without CGo usage.
|
||||
|
||||
```shell
|
||||
git clone https://git.gammaspectra.live/git/go-away.git && cd go-away
|
||||
|
||||
CGO_ENABLED=0 go build -pgo=auto -v -trimpath -o ./go-away ./cmd/go-away
|
||||
|
||||
# Run on port 8080, forwarding matching requests on git.example.com to http://forgejo:3000
|
||||
./go-away --bind :8080 \
|
||||
--backend git.example.com=http://forgejo:3000 \
|
||||
--policy examples/forgejo.yml \
|
||||
--challenge-template forgejo --challenge-template-theme forgejo-dark
|
||||
|
||||
```
|
||||
|
||||
### Dockerfile
|
||||
|
||||
Available under [Dockerfile](Dockerfile). See the _docker compose_ below for the environment variables.
|
||||
|
||||
### docker compose
|
||||
|
||||
Example follows a hypothetical Forgejo server running on `http://forgejo:3000` serving `git.example.com`
|
||||
|
||||
```yaml
|
||||
networks:
|
||||
forgejo:
|
||||
external: false
|
||||
|
||||
volumes:
|
||||
goaway_cache:
|
||||
|
||||
services:
|
||||
go-away:
|
||||
image: git.gammaspectra.live/git/go-away:latest
|
||||
restart: always
|
||||
ports:
|
||||
- "3000:8080"
|
||||
networks:
|
||||
- forgejo
|
||||
depends_on:
|
||||
- forgejo
|
||||
volumes:
|
||||
- "goaway_cache:/cache"
|
||||
- "./examples/forgejo.yml:/policy.yml:ro"
|
||||
environment:
|
||||
#GOAWAY_BIND: ":8080"
|
||||
# Supported tcp, unix, and proxy (for enabling PROXY module for request unwrapping)
|
||||
#GOAWAY_BIND_NETWORK: "tcp"
|
||||
#GOAWAY_SOCKET_MODE: "0770"
|
||||
|
||||
# set to letsencrypt or other directory URL to enable HTTPS. Above ports will be TLS only.
|
||||
# enables request JA3N / JA4 client TLS fingerprinting
|
||||
# TLS fingerprints are served on X-TLS-Fingerprint-JA3N and X-TLS-Fingerprint-JA4 headers
|
||||
# TLS fingerprints can be matched against on CEL conditions
|
||||
#GOAWAY_ACME_AUTOCERT: ""
|
||||
|
||||
# Cache path for several services like certificates and caching network ranges
|
||||
# Can be semi-ephemeral, recommended to be mapped to a permanent volume
|
||||
#GOAWAY_CACHE="/cache"
|
||||
|
||||
# default is WARN, set to INFO to also see challenge successes and others
|
||||
#GOAWAY_SLOG_LEVEL: "INFO"
|
||||
|
||||
# this value is used to sign cookies and challenges. by default a new one is generated each time
|
||||
# set to generate to create one, then set the same value across all your instances
|
||||
#GOAWAY_JWT_PRIVATE_KEY_SEED: ""
|
||||
|
||||
# HTTP header that the client ip will be fetched from
|
||||
# Defaults to the connection ip itself, if set here make sure your upstream proxy sets this properly
|
||||
# Usually X-Forwarded-For is a good pick
|
||||
# Not necessary with GOAWAY_BIND_NETWORK: proxy
|
||||
GOAWAY_CLIENT_IP_HEADER: "X-Real-Ip"
|
||||
|
||||
# HTTP header that go-away will set the obtained ip will be set to
|
||||
# If left empty, the header on GOAWAY_CLIENT_IP_HEADER will be left as-is
|
||||
#GOAWAY_BACKEND_IP_HEADER: ""
|
||||
|
||||
GOAWAY_POLICY: "/policy.yml"
|
||||
|
||||
# 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"
|
||||
|
||||
GOAWAY_BACKEND: "git.example.com=http://forgejo:3000"
|
||||
|
||||
# additional backends can be specified via more command arguments
|
||||
# command: ["--backend", "ci.example.com=http://ci:3000"]
|
||||
|
||||
forgejo:
|
||||
# etc.
|
||||
|
||||
```
|
||||
|
||||
## Challenges
|
||||
|
||||
#### http
|
||||
|
||||
Verify incoming requests against a specified backend to allow the user through. Cookies and some other headers are passed.
|
||||
|
||||
For example, this allows verifying the user cookies against the backend to have the user skip all other challenges.
|
||||
|
||||
Example on Forgejo, checks that current user is authenticated:
|
||||
```yaml
|
||||
http-cookie-check:
|
||||
mode: http
|
||||
url: http://forgejo:3000/user/stopwatches
|
||||
# url: http://forgejo:3000/repo/search
|
||||
# url: http://forgejo:3000/notifications/new
|
||||
parameters:
|
||||
http-method: GET
|
||||
http-cookie: i_like_gitea
|
||||
http-code: 200
|
||||
```
|
||||
|
||||
#### preload-link
|
||||
|
||||
Requires HTTP/2+ response parsing and logic, silent challenge (does not display a challenge page).
|
||||
|
||||
Browsers that support [103 Early Hints](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status/103) are indicated to fetch a CSS resource via [Link](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Link) preload that solves the challenge.
|
||||
|
||||
The server waits until solved or defined timeout, then continues on other challenges if failed.
|
||||
|
||||
Example:
|
||||
```yaml
|
||||
self-preload-link:
|
||||
condition: '"Sec-Fetch-Mode" in headers && headers["Sec-Fetch-Mode"] == "navigate"'
|
||||
mode: "preload-link"
|
||||
runtime:
|
||||
# verifies that result = key
|
||||
mode: "key"
|
||||
probability: 0.1
|
||||
parameters:
|
||||
preload-early-hint-deadline: 3s
|
||||
key-code: 200
|
||||
key-mime: text/css
|
||||
key-content: ""
|
||||
```
|
||||
|
||||
#### header-refresh
|
||||
|
||||
Requires HTTP response parsing and logic, displays challenge site instantly.
|
||||
|
||||
Have the browser solve the challenge by following the URL listed on HTTP [Refresh](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Refresh) instantly.
|
||||
|
||||
|
||||
#### meta-refresh
|
||||
|
||||
Requires HTTP and HTML response parsing and logic, displays challenge site instantly.
|
||||
|
||||
Have the browser solve the challenge by following the URL listed on HTML `<meta http-equiv=refresh>` tag instantly. Equivalent to above.
|
||||
|
||||
#### resource-load
|
||||
|
||||
Requires HTTP and HTML response parsing and logic, displays challenge site.
|
||||
|
||||
Servers a challenge page with a linked resource that is loaded by the browser, which solves the challenge. Page refreshes a few seconds later via [Refresh](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Refresh).
|
||||
|
||||
Example:
|
||||
```yaml
|
||||
self-resource-load:
|
||||
mode: "resource-load"
|
||||
runtime:
|
||||
# verifies that result = key
|
||||
mode: "key"
|
||||
probability: 0.1
|
||||
parameters:
|
||||
key-code: 200
|
||||
key-mime: text/css
|
||||
key-content: ""
|
||||
```
|
||||
|
||||
#### cookie
|
||||
|
||||
Requires HTTP parsing and a Cookie Jar, silent challenge (does not display a challenge page unless failed).
|
||||
|
||||
Serves the client with a Set-Cookie that solves the challenge, and redirects it back to the same page. Browser must present the cookie to load.
|
||||
|
||||
Several tools implement this, but usually not mass scrapers.
|
||||
|
||||
#### js-pow-sha256
|
||||
|
||||
Requires JavaScript and workers, displays challenge site.
|
||||
|
||||
Has the user solve a Proof of Work using SHA256 hashes, with configurable difficulty.
|
||||
|
||||
Example:
|
||||
```yaml
|
||||
js-pow-sha256:
|
||||
# Asset must be under challenges/{name}/static/{asset}
|
||||
# Other files here will be available under that path
|
||||
mode: js
|
||||
asset: load.mjs
|
||||
parameters:
|
||||
# difficulty is number of bits that must be set to 0 from start
|
||||
# Anubis challenge difficulty 5 becomes 5 * 8 = 20
|
||||
difficulty: 20
|
||||
runtime:
|
||||
mode: wasm
|
||||
# Verify must be under challenges/{name}/runtime/{asset}
|
||||
asset: runtime.wasm
|
||||
probability: 0.02
|
||||
```
|
||||
See [examples/snippets/](examples/snippets/) for some defaults including indexer bots, challenges and other general matches.
|
||||
|
||||
|
||||
|
||||
## Why do this?
|
||||
In the past few years this small git instance has been hit by waves and waves of scraping.
|
||||
This was usually fought back by random useragent blocks for bots that did not follow [robots.txt](/robots.txt), until the past half year, where low-effort mass scraping was used more prominently.
|
||||
|
||||
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?**
|
||||
|
||||
* 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:
|
||||
* [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]
|
||||
|
||||
* 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, 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.
|
||||
|
||||
### Can't scrapers adapt?
|
||||
|
||||
Yes, they can. At the moment their spray-and-pray approach is cheap for them.
|
||||
|
||||
If they have to start adding an active browser in their scraping, that makes their collection expensive and slow.
|
||||
|
||||
This would more or less eliminate the high rate low effort passive scraping and replace it with an active model.
|
||||
|
||||
go-away offers a highly configurable set of challenges and rules that you can adapt to new ways.
|
||||
|
||||
## What's left?
|
||||
|
||||
go-away has most of the desired features from the original checklist that was made in its development.
|
||||
However, a few points are left before go-away can be called v1.0.0:
|
||||
|
||||
* [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 end users to pick fallback challenges if any fail, specially with custom ones.
|
||||
* [ ] Replace Anubis-like default template with own one.
|
||||
* [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.
|
||||
* [x] More defined way of picking HTTP/HTTP(s) listeners and certificates.
|
||||
* [x] Expose metrics for challenge solve rates and acting on them.
|
||||
* [ ] Metrics for common network ranges / AS / useragent
|
||||
|
||||
|
||||
|
||||
## Other Similar Projects
|
||||
|
||||
| Project | Source Code | Description | Method |
|
||||
|:----------------------------------------------------------------------------------------:|:---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------:|:---------------------------------------------------------------------------------------------------------------------------|:---------------------------------------------|
|
||||
| [Anubis](https://anubis.techaro.lol/) | [](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/) | [](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) | [](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) | [](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) | [](https://humungus.tedunangst.com/r/anticrawl)<br/>Go / None | Go http handler / proxy for regex based rules | Non-JS manual Challenge/Response |
|
||||
| [ngx_http_js_challenge_module](https://github.com/simon987/ngx_http_js_challenge_module) | [](https://github.com/simon987/ngx_http_js_challenge_module)<br/>C / [GPL v3.0](https://github.com/simon987/ngx_http_js_challenge_module/blob/master/LICENSE) | Simple javascript proof-of-work based access for Nginx with virtually no overhead. | JavaScript Challenge |
|
||||
| [haproxy-protection](https://gitgud.io/fatchan/haproxy-protection/) | [](https://gitgud.io/fatchan/haproxy-protection/)<br/> Lua / [GPL v3.0](https://gitgud.io/fatchan/haproxy-protection/-/blob/master/LICENSE.txt) | HAProxy configuration and lua scripts allowing a challenge-response page where users solve a captcha and/or proof-of-work. | JavaScript Challenge / Captcha |
|
||||
|
||||
## Development
|
||||
|
||||
### Compiling WASM runtime challenge modules
|
||||
|
||||
Custom WASM runtime modules follow the WASI `wasip1` preview syscall API.
|
||||
|
||||
It is recommended using TinyGo to compile / refresh modules, and some function helpers are provided.
|
||||
|
||||
If you want to use a different language or compiler, enable `wasip1` and the following interface must be exported:
|
||||
|
||||
```
|
||||
// Allocation is a combination of pointer location in WASM memory and size of it
|
||||
type Allocation uint64
|
||||
|
||||
func (p Allocation) Pointer() uint32 {
|
||||
return uint32(p >> 32)
|
||||
}
|
||||
func (p Allocation) Size() uint32 {
|
||||
return uint32(p)
|
||||
}
|
||||
This Go package can be used as a command on `git.gammaspectra.live/git/go-away/cmd/go-away` or a library under `git.gammaspectra.live/git/go-away/lib`
|
||||
|
||||
|
||||
// MakeChallenge MakeChallengeInput / MakeChallengeOutput are valid JSON.
|
||||
// See lib/challenge/interface.go for a definition
|
||||
func MakeChallenge(in Allocation[MakeChallengeInput]) Allocation[MakeChallengeOutput]
|
||||
|
||||
// VerifyChallenge VerifyChallengeInput is valid JSON.
|
||||
// See lib/challenge/interface.go for a definition
|
||||
func VerifyChallenge(in Allocation[VerifyChallengeInput]) VerifyChallengeOutput
|
||||
|
||||
func malloc(size uint32) uintptr
|
||||
func free(size uintptr)
|
||||
|
||||
```
|
||||
|
||||
Modules will be recreated for each call, so there is no state leftover
|
||||
@@ -1,9 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
set -o pipefail
|
||||
|
||||
cd "$(cd -P -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
|
||||
|
||||
|
||||
go run ./generate-poison -path ./poison/
|
||||
@@ -1,180 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"flag"
|
||||
"fmt"
|
||||
"github.com/andybalholm/brotli"
|
||||
"github.com/klauspost/compress/zstd"
|
||||
"io"
|
||||
"math/rand/v2"
|
||||
"os"
|
||||
"path"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type poisonCharacterGenerator struct {
|
||||
Header []byte
|
||||
AllowedBytes []byte
|
||||
Repeat int
|
||||
counter int
|
||||
}
|
||||
|
||||
func (r *poisonCharacterGenerator) Read(p []byte) (n int, err error) {
|
||||
if len(r.Header) > 0 {
|
||||
copy(p, r.Header)
|
||||
nn := min(len(r.Header), len(p))
|
||||
r.Header = r.Header[nn:]
|
||||
p = p[nn:]
|
||||
}
|
||||
|
||||
stride := min(len(p), r.Repeat)
|
||||
for i := 0; i < len(p); i += stride {
|
||||
copy(p[i:], bytes.Repeat([]byte{r.AllowedBytes[r.counter]}, stride))
|
||||
r.counter = (r.counter + 1) % len(r.AllowedBytes)
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
type poisonValuesGenerator struct {
|
||||
Header []byte
|
||||
AllowedValues [][]byte
|
||||
counter int
|
||||
}
|
||||
|
||||
func (r *poisonValuesGenerator) Read(p []byte) (n int, err error) {
|
||||
var i int
|
||||
|
||||
if len(r.Header) > 0 {
|
||||
copy(p, r.Header)
|
||||
nn := min(len(r.Header), len(p))
|
||||
r.Header = r.Header[nn:]
|
||||
i += nn
|
||||
|
||||
for i < len(p) {
|
||||
copy(p[i:], r.AllowedValues[r.counter])
|
||||
i += len(r.AllowedValues[r.counter])
|
||||
r.counter = (r.counter + 1) % len(r.AllowedValues)
|
||||
if r.counter == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for i < len(p) {
|
||||
buf := slices.Repeat(r.AllowedValues[r.counter], len(r.AllowedValues)-r.counter)
|
||||
copy(p[i:], buf)
|
||||
i += len(buf)
|
||||
r.counter = (r.counter + 1) % len(r.AllowedValues)
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
|
||||
outputPath := flag.String("path", "./", "path to poison files")
|
||||
|
||||
flag.Parse()
|
||||
|
||||
const Gigabyte = 1024 * 1024 * 1024
|
||||
|
||||
compressPoison(*outputPath, "text/html", &poisonValuesGenerator{
|
||||
Header: []byte(fmt.Sprintf("<!DOCTYPE html><html><head><title>%d</title></head><body>", rand.Uint64())),
|
||||
AllowedValues: [][]byte{
|
||||
[]byte("<div><div class=\"\"><h2></h2></div><br>\n"),
|
||||
[]byte("<span><span><p><span>\n"),
|
||||
[]byte("<p></span></script><h3><p><span>\n"),
|
||||
[]byte("<div><span><p></h1>"),
|
||||
[]byte("</div></div></div>\n"),
|
||||
[]byte("</p></p></p>"),
|
||||
[]byte("<h1>Are you a bot?</h1><img>\n"),
|
||||
[]byte("</span></span></span><script>{let a = (new XMLSerializer).serializeToString(document); console.log(a); let b = URL.createObjectURL(new Blob([a])); Array.from(document.getElementsByTagName(\"img\")).forEach((img) => {img.src = b;}); document.getElementsByTagName(\"body\")[0].prepend((new DOMParser()).parseFromString(a, \"text/html\"));}</script>"),
|
||||
},
|
||||
}, Gigabyte)
|
||||
}
|
||||
|
||||
var poisonEncodings = []string{"br", "zstd", "gzip"}
|
||||
|
||||
func compressPoison(outputPath, mime string, r io.Reader, maxSize int64) {
|
||||
r = io.LimitReader(r, maxSize)
|
||||
|
||||
var closers []func()
|
||||
var encoders []io.Writer
|
||||
var writers []io.Writer
|
||||
var readers []io.Reader
|
||||
|
||||
for _, encoding := range poisonEncodings {
|
||||
f, err := os.Create(path.Join(outputPath, strings.ReplaceAll(mime, "/", "_")+"."+encoding+".poison"))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
switch encoding {
|
||||
case "zstd":
|
||||
w, err := zstd.NewWriter(f, zstd.WithEncoderLevel(zstd.SpeedBestCompression), zstd.WithEncoderCRC(false), zstd.WithWindowSize(zstd.MaxWindowSize))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
encoders = append(encoders, w)
|
||||
closers = append(closers, func() {
|
||||
w.Close()
|
||||
f.Close()
|
||||
})
|
||||
case "br":
|
||||
w := brotli.NewWriterLevel(f, brotli.BestCompression)
|
||||
encoders = append(encoders, w)
|
||||
closers = append(closers, func() {
|
||||
w.Close()
|
||||
f.Close()
|
||||
})
|
||||
case "gzip":
|
||||
w, err := gzip.NewWriterLevel(f, gzip.BestCompression)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
encoders = append(encoders, w)
|
||||
closers = append(closers, func() {
|
||||
w.Close()
|
||||
f.Close()
|
||||
})
|
||||
}
|
||||
r, w := io.Pipe()
|
||||
readers = append(readers, r)
|
||||
writers = append(writers, w)
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i := range poisonEncodings {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
_, err := io.Copy(encoders[i], readers[i])
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
closers[i]()
|
||||
|
||||
// discard remaining data
|
||||
_, _ = io.Copy(io.Discard, readers[i])
|
||||
}()
|
||||
}
|
||||
|
||||
_, err := io.Copy(io.MultiWriter(writers...), r)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
for _, w := range writers {
|
||||
if pw, ok := w.(io.Closer); ok {
|
||||
pw.Close()
|
||||
} else {
|
||||
panic("writer is not a Closer")
|
||||
}
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
@@ -1,90 +1,44 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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"
|
||||
"gopkg.in/yaml.v3"
|
||||
"log"
|
||||
"github.com/goccy/go-yaml"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/pprof"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path"
|
||||
"runtime"
|
||||
"runtime/debug"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"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 internalPackageName = func() string {
|
||||
var internalCmdName = "go-away"
|
||||
var internalMainName = "go-away"
|
||||
var internalMainVersion = "dev"
|
||||
|
||||
func init() {
|
||||
buildInfo, ok := debug.ReadBuildInfo()
|
||||
if !ok {
|
||||
return "go-away"
|
||||
return
|
||||
}
|
||||
return buildInfo.Path
|
||||
}()
|
||||
internalCmdName = buildInfo.Path
|
||||
internalMainName = buildInfo.Main.Path
|
||||
internalMainVersion = buildInfo.Main.Version
|
||||
}
|
||||
|
||||
type MultiVar []string
|
||||
|
||||
@@ -97,60 +51,59 @@ 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")
|
||||
acmeAutocert := flag.String("acme-autocert", "", "enables HTTP(s) mode and uses the provided ACME server URL or available service (available: letsencrypt)")
|
||||
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")
|
||||
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.)")
|
||||
|
||||
dnsbl := flag.String("dnsbl", "dnsbl.dronebl.org", "blocklist for DNSBL (default DroneBL)")
|
||||
|
||||
cachePath := flag.String("cache", path.Join(os.TempDir(), "go_away_cache"), "path to temporary cache directory")
|
||||
|
||||
policyFile := flag.String("policy", "", "path to policy YAML file")
|
||||
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-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", internalPackageName, "package name to expose in .well-known url path")
|
||||
flag.StringVar(&opt.ChallengeTemplate, "challenge-template", opt.ChallengeTemplate, "name or path of the challenge template to use (anubis, forgejo)")
|
||||
|
||||
templateTheme := flag.String("challenge-template-theme", opt.ChallengeTemplateOverrides["Theme"], "override template theme to use (forgejo => [forgejo-auto, forgejo-dark, forgejo-light, gitea...])")
|
||||
templateLogo := flag.String("challenge-template-logo", opt.ChallengeTemplateOverrides["Logo"], "override template logo to use")
|
||||
|
||||
basePath := flag.String("path", "/.well-known/."+internalCmdName, "base path where to expose go-away package onto, challenges will be served from here")
|
||||
|
||||
jwtPrivateKeySeed := flag.String("jwt-private-key-seed", "", "Seed for the jwt private key, or on JWT_PRIVATE_KEY_SEED env. One be generated by passing \"generate\" as a value, follows RFC 8032 private key definition. Defaults to random")
|
||||
|
||||
var backends MultiVar
|
||||
flag.Var(&backends, "backend", "backend definition in the form of an.example.com=http://backend:1234 (can be specified multiple times)")
|
||||
|
||||
settingsFile := flag.String("config", "", "path to config override YAML file")
|
||||
|
||||
flag.Parse()
|
||||
|
||||
if *backendIpHeader == "" {
|
||||
*backendIpHeader = *clientIpHeader
|
||||
}
|
||||
|
||||
var err error
|
||||
|
||||
{
|
||||
@@ -164,10 +117,38 @@ 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, "go", runtime.Version(), "os", runtime.GOOS, "arch", runtime.GOARCH)
|
||||
|
||||
// preload missing settings
|
||||
opt.ChallengeTemplateOverrides["Theme"] = *templateTheme
|
||||
opt.ChallengeTemplateOverrides["Logo"] = *templateLogo
|
||||
|
||||
// load overrides
|
||||
if *settingsFile != "" {
|
||||
settingsData, err := os.ReadFile(*settingsFile)
|
||||
if err != nil {
|
||||
fatal(fmt.Errorf("could not read settings file: %w", err))
|
||||
}
|
||||
err = yaml.Unmarshal(settingsData, &opt)
|
||||
if err != nil {
|
||||
fatal(fmt.Errorf("could not parse settings file: %w", err))
|
||||
}
|
||||
}
|
||||
|
||||
var seed []byte
|
||||
@@ -183,7 +164,7 @@ func main() {
|
||||
if strings.ToLower(kValue) == "generate" {
|
||||
_, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
log.Fatal(fmt.Errorf("failed to generate private key: %w", err))
|
||||
fatal(fmt.Errorf("failed to generate private key: %w", err))
|
||||
}
|
||||
fmt.Printf("%x\n", priv.Seed())
|
||||
os.Exit(0)
|
||||
@@ -191,174 +172,210 @@ 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))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
policyData, err := os.ReadFile(*policyFile)
|
||||
if err != nil {
|
||||
log.Fatal(fmt.Errorf("failed to read policy file: %w", err))
|
||||
}
|
||||
|
||||
var p policy.Policy
|
||||
|
||||
if err = yaml.Unmarshal(policyData, &p); err != nil {
|
||||
log.Fatal(fmt.Errorf("failed to parse policy file: %w", err))
|
||||
}
|
||||
|
||||
createdBackends := make(map[string]http.Handler)
|
||||
|
||||
parsedBackends := make(map[string]string)
|
||||
//TODO: deprecate
|
||||
maps.Copy(parsedBackends, p.Backends)
|
||||
for _, backend := range backends {
|
||||
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", backend))
|
||||
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)
|
||||
createdBackends[k] = backend
|
||||
}
|
||||
|
||||
if len(createdBackends) == 0 {
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
var tlsConfig *tls.Config
|
||||
|
||||
if *acmeAutocert != "" {
|
||||
switch *acmeAutocert {
|
||||
case "letsencrypt":
|
||||
*acmeAutocert = acme.LetsEncryptURL
|
||||
}
|
||||
|
||||
acmeManager := newACMEManager(*acmeAutocert, createdBackends)
|
||||
if *cachePath != "" {
|
||||
err = os.MkdirAll(path.Join(*cachePath, "acme"), 0755)
|
||||
for _, n := range []string{"networks", "acme"} {
|
||||
err = os.MkdirAll(path.Join(*cachePath, n), 0755)
|
||||
if err != nil {
|
||||
log.Fatal(fmt.Errorf("failed to create acme cache directory: %w", err))
|
||||
fatal(fmt.Errorf("failed to create cache sub directory %s: %w", n, err))
|
||||
}
|
||||
acmeManager.Cache = autocert.DirCache(path.Join(*cachePath, "acme"))
|
||||
}
|
||||
slog.Warn(
|
||||
"acme-autocert enabled",
|
||||
"directory", *acmeAutocert,
|
||||
)
|
||||
tlsConfig = acmeManager.TLSConfig()
|
||||
|
||||
cache, err = utils.CacheDirectory(*cachePath)
|
||||
if err != nil {
|
||||
fatal(fmt.Errorf("failed to open cache directory: %w", err))
|
||||
}
|
||||
|
||||
acmeCache = path.Join(*cachePath, "acme")
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
loadPolicyState := func() (*lib.State, error) {
|
||||
policyData, err := os.ReadFile(*policyFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read policy file: %w", err)
|
||||
}
|
||||
|
||||
passThroughCtx, cancelFunc := context.WithCancel(context.Background())
|
||||
defer cancelFunc()
|
||||
p, err := policy.NewPolicy(bytes.NewReader(policyData), policySnippets...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse policy file: %w", err)
|
||||
}
|
||||
|
||||
if *passThrough {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
stateSettings := policy.StateSettings{
|
||||
Cache: cache,
|
||||
Backends: createdBackends,
|
||||
MainName: internalMainName,
|
||||
MainVersion: internalMainVersion,
|
||||
BasePath: *basePath,
|
||||
PrivateKeySeed: seed,
|
||||
ClientIpHeader: *clientIpHeader,
|
||||
BackendIpHeader: *backendIpHeader,
|
||||
ChallengeResponseCode: http.StatusTeapot,
|
||||
}
|
||||
|
||||
server := utils.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
backend, ok := createdBackends[r.Host]
|
||||
if !ok {
|
||||
http.Error(w, http.StatusText(http.StatusServiceUnavailable), http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
state, err := lib.NewState(*p, opt, stateSettings)
|
||||
|
||||
backend.ServeHTTP(w, r)
|
||||
}), tlsConfig)
|
||||
|
||||
listener, listenUrl := setupListener(*bindNetwork, *bind, *socketMode, *bindProxy)
|
||||
slog.Warn(
|
||||
"listening passthrough",
|
||||
"url", listenUrl,
|
||||
)
|
||||
defer listener.Close()
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
if tlsConfig != nil {
|
||||
if err := server.ServeTLS(listener, "", ""); !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Fatal(err)
|
||||
}
|
||||
} else {
|
||||
if err := server.Serve(listener); !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
<-passThroughCtx.Done()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := server.Shutdown(ctx); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
_ = server.Close()
|
||||
}()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create state: %w", err)
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
settings := lib.StateSettings{
|
||||
Backends: createdBackends,
|
||||
Debug: *debugMode,
|
||||
PackageName: *packageName,
|
||||
ChallengeTemplate: *challengeTemplate,
|
||||
ChallengeTemplateTheme: *challengeTemplateTheme,
|
||||
PrivateKeySeed: seed,
|
||||
ClientIpHeader: *clientIpHeader,
|
||||
BackendIpHeader: *backendIpHeader,
|
||||
if *check {
|
||||
_, err := loadPolicyState()
|
||||
if err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
slog.Info("load ok")
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
if *dnsbl != "" {
|
||||
settings.DNSBL = utils.NewDNSBL(*dnsbl, net.DefaultResolver)
|
||||
}
|
||||
|
||||
state, err := lib.NewState(p, settings)
|
||||
|
||||
if err != nil {
|
||||
log.Fatal(fmt.Errorf("failed to create state: %w", err))
|
||||
}
|
||||
|
||||
// cancel the existing server listener
|
||||
cancelFunc()
|
||||
wg.Wait()
|
||||
|
||||
listener, listenUrl := setupListener(*bindNetwork, *bind, *socketMode, *bindProxy)
|
||||
listener, listenUrl := opt.Bind.Listener()
|
||||
slog.Warn(
|
||||
"listening",
|
||||
"url", listenUrl,
|
||||
)
|
||||
|
||||
server := utils.NewServer(state, tlsConfig)
|
||||
server, swap, err := opt.Bind.Server(createdBackends, acmeCache)
|
||||
if err != nil {
|
||||
fatal(fmt.Errorf("failed to create server: %w", err))
|
||||
}
|
||||
|
||||
if tlsConfig != nil {
|
||||
server.ErrorLog = slog.NewLogLogger(slog.With("server", "http").Handler(), slog.LevelError)
|
||||
|
||||
go func() {
|
||||
handler, err := loadPolicyState()
|
||||
if err != nil {
|
||||
fatal(fmt.Errorf("failed to load policy state: %w", err))
|
||||
}
|
||||
|
||||
swap(handler)
|
||||
slog.Warn(
|
||||
"handler configuration loaded",
|
||||
)
|
||||
|
||||
// allow reloading from now on
|
||||
c := make(chan os.Signal, 1)
|
||||
signal.Notify(c, syscall.SIGHUP)
|
||||
for sig := range c {
|
||||
if sig != syscall.SIGHUP {
|
||||
continue
|
||||
}
|
||||
oldHandler := handler
|
||||
handler, err = loadPolicyState()
|
||||
if err != nil {
|
||||
slog.Error("handler configuration reload error", "err", err)
|
||||
continue
|
||||
}
|
||||
|
||||
swap(handler)
|
||||
slog.Warn("handler configuration reloaded")
|
||||
if oldHandler != nil {
|
||||
_ = oldHandler.Close()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
26
docker-entrypoint.sh
Executable file
26
docker-entrypoint.sh
Executable file
@@ -0,0 +1,26 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
if [ $# -eq 0 ] || [ "${1#-}" != "$1" ]; then
|
||||
set -- /bin/go-away \
|
||||
--bind "${GOAWAY_BIND}" --bind-network "${GOAWAY_BIND_NETWORK}" --socket-mode "${GOAWAY_SOCKET_MODE}" \
|
||||
--metrics-bind "${GOAWAY_METRICS_BIND}" --debug-bind "${GOAWAY_DEBUG_BIND}" \
|
||||
--config "${GOAWAY_CONFIG}" \
|
||||
--policy "${GOAWAY_POLICY}" --policy-snippets "/snippets" --policy-snippets "${GOAWAY_POLICY_SNIPPETS}" \
|
||||
--client-ip-header "${GOAWAY_CLIENT_IP_HEADER}" --backend-ip-header "${GOAWAY_BACKEND_IP_HEADER}" \
|
||||
--cache "${GOAWAY_CACHE}" \
|
||||
--challenge-template "${GOAWAY_CHALLENGE_TEMPLATE}" \
|
||||
--challenge-template-logo "${GOAWAY_CHALLENGE_TEMPLATE_LOGO}" \
|
||||
--challenge-template-theme "${GOAWAY_CHALLENGE_TEMPLATE_THEME}" \
|
||||
--slog-level "${GOAWAY_SLOG_LEVEL}" \
|
||||
--acme-autocert "${GOAWAY_ACME_AUTOCERT}" \
|
||||
--backend "${GOAWAY_BACKEND}" \
|
||||
"$@"
|
||||
fi
|
||||
|
||||
if [ "$1" = "go-away" ]; then
|
||||
shift
|
||||
set -- /bin/go-away "$@"
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
@@ -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;
|
||||
}
|
||||
Binary file not shown.
@@ -1,15 +1,59 @@
|
||||
package embed
|
||||
|
||||
import "embed"
|
||||
import (
|
||||
"embed"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
)
|
||||
|
||||
//go:embed assets
|
||||
var AssetsFs embed.FS
|
||||
var assetsFs embed.FS
|
||||
|
||||
//go:embed challenge
|
||||
var ChallengeFs embed.FS
|
||||
var challengeFs embed.FS
|
||||
|
||||
//go:embed templates
|
||||
var TemplatesFs embed.FS
|
||||
var templatesFs embed.FS
|
||||
|
||||
//go:embed poison/*.poison
|
||||
var PoisonFs embed.FS
|
||||
type FSInterface interface {
|
||||
fs.FS
|
||||
fs.ReadDirFS
|
||||
fs.ReadFileFS
|
||||
}
|
||||
|
||||
func trimPrefix(embedFS embed.FS, prefix string) FSInterface {
|
||||
subFS, err := fs.Sub(embedFS, prefix)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if properFS, ok := subFS.(FSInterface); ok {
|
||||
return properFS
|
||||
} else {
|
||||
panic("unsupported")
|
||||
}
|
||||
}
|
||||
|
||||
var ChallengeFs = trimPrefix(challengeFs, "challenge")
|
||||
|
||||
var TemplatesFs = trimPrefix(templatesFs, "templates")
|
||||
var AssetsFs = trimPrefix(assetsFs, "assets")
|
||||
|
||||
func GetFallbackFS(embedFS FSInterface, prefix string) (FSInterface, error) {
|
||||
var outFs fs.FS
|
||||
if stat, err := os.Stat(prefix); err == nil && stat.IsDir() {
|
||||
outFs = embedFS
|
||||
} else if _, err := embedFS.ReadDir(prefix); err == nil {
|
||||
outFs, err = fs.Sub(embedFS, prefix)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
return nil, err
|
||||
}
|
||||
if properFS, ok := outFs.(FSInterface); ok {
|
||||
return properFS, nil
|
||||
} else {
|
||||
return nil, errors.New("unsupported FS")
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,145 +1,20 @@
|
||||
<!DOCTYPE html>
|
||||
{{$logo := print .Path "/assets/static/logo.png?cacheBust=" .Random }}{{ if .Logo }}{{$logo = .Logo}}{{ end }}
|
||||
<html>
|
||||
<head>
|
||||
<title>{{ .Title }}</title>
|
||||
<link rel="stylesheet" href="{{ .Path }}/assets/static/anubis/style.css?cacheBust={{ .Random }}"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
{{ range $key, $value := .Meta }}
|
||||
{{ if eq $key "refresh"}}
|
||||
<meta http-equiv="{{ $key }}" content="{{ $value }}"/>
|
||||
{{else}}
|
||||
<meta name="{{ $key }}" content="{{ $value }}"/>
|
||||
{{end}}
|
||||
<meta name="referrer" content="origin"/>
|
||||
{{ range .MetaTags }}
|
||||
<meta {{ range $key, $value := . }}{{ $key | attr }}="{{ $value }}" {{end}}/>
|
||||
{{ end }}
|
||||
{{ range .Tags }}
|
||||
{{ . }}
|
||||
{{ range .LinkTags }}
|
||||
<link {{ range $key, $value := . }}{{ $key | attr }}="{{ $value }}" {{end}}/>
|
||||
{{ end }}
|
||||
{{ range .HeaderTags }}
|
||||
{{ . }}
|
||||
{{ end }}
|
||||
<style>
|
||||
body,
|
||||
html {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.centered-div {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.lds-roller,
|
||||
.lds-roller div,
|
||||
.lds-roller div:after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.lds-roller {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
}
|
||||
|
||||
.lds-roller div {
|
||||
animation: lds-roller 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite;
|
||||
transform-origin: 40px 40px;
|
||||
}
|
||||
|
||||
.lds-roller div:after {
|
||||
content: " ";
|
||||
display: block;
|
||||
position: absolute;
|
||||
width: 7.2px;
|
||||
height: 7.2px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
margin: -3.6px 0 0 -3.6px;
|
||||
}
|
||||
|
||||
.lds-roller div:nth-child(1) {
|
||||
animation-delay: -0.036s;
|
||||
}
|
||||
|
||||
.lds-roller div:nth-child(1):after {
|
||||
top: 62.62742px;
|
||||
left: 62.62742px;
|
||||
}
|
||||
|
||||
.lds-roller div:nth-child(2) {
|
||||
animation-delay: -0.072s;
|
||||
}
|
||||
|
||||
.lds-roller div:nth-child(2):after {
|
||||
top: 67.71281px;
|
||||
left: 56px;
|
||||
}
|
||||
|
||||
.lds-roller div:nth-child(3) {
|
||||
animation-delay: -0.108s;
|
||||
}
|
||||
|
||||
.lds-roller div:nth-child(3):after {
|
||||
top: 70.90963px;
|
||||
left: 48.28221px;
|
||||
}
|
||||
|
||||
.lds-roller div:nth-child(4) {
|
||||
animation-delay: -0.144s;
|
||||
}
|
||||
|
||||
.lds-roller div:nth-child(4):after {
|
||||
top: 72px;
|
||||
left: 40px;
|
||||
}
|
||||
|
||||
.lds-roller div:nth-child(5) {
|
||||
animation-delay: -0.18s;
|
||||
}
|
||||
|
||||
.lds-roller div:nth-child(5):after {
|
||||
top: 70.90963px;
|
||||
left: 31.71779px;
|
||||
}
|
||||
|
||||
.lds-roller div:nth-child(6) {
|
||||
animation-delay: -0.216s;
|
||||
}
|
||||
|
||||
.lds-roller div:nth-child(6):after {
|
||||
top: 67.71281px;
|
||||
left: 24px;
|
||||
}
|
||||
|
||||
.lds-roller div:nth-child(7) {
|
||||
animation-delay: -0.252s;
|
||||
}
|
||||
|
||||
.lds-roller div:nth-child(7):after {
|
||||
top: 62.62742px;
|
||||
left: 17.37258px;
|
||||
}
|
||||
|
||||
.lds-roller div:nth-child(8) {
|
||||
animation-delay: -0.288s;
|
||||
}
|
||||
|
||||
.lds-roller div:nth-child(8):after {
|
||||
top: 56px;
|
||||
left: 12.28719px;
|
||||
}
|
||||
|
||||
@keyframes lds-roller {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body id="top">
|
||||
<main>
|
||||
@@ -151,47 +26,32 @@
|
||||
<img
|
||||
id="image"
|
||||
style="width:100%;max-width:256px;"
|
||||
src="{{ .Path }}/assets/static/logo.png?cacheBust={{ .Random }}"
|
||||
src="{{ $logo }}"
|
||||
/>
|
||||
{{if .Challenge }}
|
||||
<p id="status">Loading challenge <em>{{ .Challenge }}</em>...</p>
|
||||
<script async type="module" src="{{ .Path }}/challenge/{{ .Challenge }}/challenge.mjs?cacheBust={{ .Random }}"></script>
|
||||
<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>
|
||||
|
||||
|
||||
@@ -199,9 +59,18 @@
|
||||
<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>
|
||||
|
||||
|
||||
{{ range .EndTags }}
|
||||
{{ . }}
|
||||
{{ end }}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,24 +1,20 @@
|
||||
<!DOCTYPE html>
|
||||
{{$theme := "forgejo-dark"}}
|
||||
{{ if .Theme }}
|
||||
{{$theme = .Theme}}
|
||||
{{ end }}
|
||||
{{$theme := "forgejo-auto"}}{{ if .Theme }}{{$theme = .Theme}}{{ end }}
|
||||
{{$logo := "/assets/img/logo.png"}}{{ if .Logo }}{{$logo = .Logo}}{{ end }}
|
||||
<html lang="en-US" data-theme="{{ $theme }}">
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
|
||||
<title>{{ .Title }}</title>
|
||||
<meta name="referrer" content="no-referrer">
|
||||
|
||||
{{ range $key, $value := .Meta }}
|
||||
{{ if eq $key "refresh"}}
|
||||
<meta http-equiv="{{ $key }}" content="{{ $value }}"/>
|
||||
{{else}}
|
||||
<meta name="{{ $key }}" content="{{ $value }}"/>
|
||||
{{end}}
|
||||
<meta name="referrer" content="origin">
|
||||
{{ range .MetaTags }}
|
||||
<meta {{ range $key, $value := . }}{{ $key | attr }}="{{ $value }}" {{end}}/>
|
||||
{{ end }}
|
||||
{{ range .Tags }}
|
||||
{{ . }}
|
||||
{{ range .LinkTags }}
|
||||
<link {{ range $key, $value := . }}{{ $key | attr }}="{{ $value }}" {{end}}/>
|
||||
{{ end }}
|
||||
{{ range .HeaderTags }}
|
||||
{{ . }}
|
||||
{{ end }}
|
||||
|
||||
|
||||
@@ -53,7 +49,7 @@
|
||||
<div class="ui stackable middle very relaxed page grid">
|
||||
<div class="sixteen wide center aligned centered column">
|
||||
<div>
|
||||
<img class="logo" id="image" src="/assets/img/logo.png" />
|
||||
<img class="logo" id="image" src="{{ $logo }}" />
|
||||
</div>
|
||||
<div class="hero">
|
||||
<h2 class="ui icon header title" id="title">
|
||||
@@ -61,37 +57,32 @@
|
||||
</h2>
|
||||
|
||||
{{if .Challenge }}
|
||||
<h3 id="status">Loading challenge <em>{{ .Challenge }}</em>...</h3>
|
||||
<script async type="module" src="{{ .Path }}/challenge/{{ .Challenge }}/challenge.mjs?cacheBust={{ .Random }}"></script>
|
||||
<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>
|
||||
@@ -107,8 +98,15 @@
|
||||
<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>
|
||||
|
||||
{{ range .EndTags }}
|
||||
{{ . }}
|
||||
{{ end }}
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
117
examples/config.yml
Normal file
117
examples/config.yml
Normal file
@@ -0,0 +1,117 @@
|
||||
# Configuration file
|
||||
# Parameters that exist both on config and cmdline will have cmdline as preference
|
||||
|
||||
bind:
|
||||
#address: ":8080"
|
||||
#network: "tcp"
|
||||
#socket-mode": "0770"
|
||||
|
||||
# Enable PROXY mode on this listener, to allow passing origin info. Default false
|
||||
#proxy: true
|
||||
|
||||
# Enable passthrough mode, which will allow traffic onto the backends while rules load. Default false
|
||||
#passthrough: true
|
||||
|
||||
# Enable TLS on this listener and obtain certificates via an ACME directory URL, or letsencrypt
|
||||
#tls-acme-autocert: "letsencrypt"
|
||||
|
||||
# Enable TLS on this listener and obtain certificates via a certificate and key file on disk
|
||||
# Only set one of tls-acme-autocert or tls-certificate+tls-key
|
||||
#tls-certificate: ""
|
||||
#tls-key: ""
|
||||
|
||||
# Bind the Go debug port
|
||||
#bind-debug: ":6060"
|
||||
|
||||
# Bind the Prometheus metrics onto /metrics path on this port
|
||||
#bind-metrics ":9090"
|
||||
|
||||
# These links will be shown on the presented challenge or error pages
|
||||
links:
|
||||
#- name: Privacy
|
||||
# url: "/privacy.html"
|
||||
|
||||
#- name: Contact
|
||||
# url: "mailto:admin@example.com"
|
||||
|
||||
#- name: Donations
|
||||
# url: "https://donations.example.com/abcd"
|
||||
|
||||
# HTML Template to use for challenge or error pages
|
||||
# External templates can be included by providing a disk path
|
||||
# Bundled templates:
|
||||
# anubis: An Anubis-like template with no configuration parameters. Supports Logo.
|
||||
# forgejo: Looks like native Forgejo. Includes logos and resources from your instance. Supports Theme, Logo.
|
||||
#
|
||||
#challenge-template: "anubis"
|
||||
|
||||
# Allows overriding specific settings set on templates. Key-Values will be passed to templates as-is
|
||||
challenge-template-overrides:
|
||||
# Set template theme if supported
|
||||
#Theme: "forgejo-auto"
|
||||
# Set logo on template if supported
|
||||
#Logo: "/my/custom/logo/path.png"
|
||||
|
||||
# Advanced backend configuration
|
||||
# Backends setup via cmdline will be added here
|
||||
backends:
|
||||
# Example HTTP backend and setting client ip header
|
||||
#"git.example.com":
|
||||
# url: "http://forgejo:3000"
|
||||
# ip-header: "X-Client-Ip"
|
||||
|
||||
# Example HTTP backend matching a non-standard port in Host
|
||||
# Standard ports are 80 and 443. Others will be sent in Host by browsers
|
||||
#"git.example.com:8080":
|
||||
# url: "http://forgejo:3000"
|
||||
# ip-header: "X-Client-Ip"
|
||||
|
||||
|
||||
# Example HTTPS backend with host/SNI override, HTTP/2 and no certificate verification
|
||||
#"ssl.example.com":
|
||||
# url: "https://127.0.0.1:8443"
|
||||
# host: ssl.example.com
|
||||
# http2-enabled: true
|
||||
# tls-skip-verify: true
|
||||
|
||||
# Example HTTPS transparent backend with host/SNI override, HTTP/2, and subdirectory
|
||||
#"ssl.example.com":
|
||||
# url: "https://ssl.example.com/subdirectory/"
|
||||
# host: ssl.example.com
|
||||
# http2-enabled: true
|
||||
# ip-header: "-"
|
||||
# transparent: true
|
||||
|
||||
# List of strings you can replace to alter the presentation on challenge/error templates
|
||||
# Can use other languages.
|
||||
# Note raw HTML is allowed, be careful with it.
|
||||
# Default strings exist in code, uncomment any to set it
|
||||
strings:
|
||||
#title_challenge: "Checking you are not a bot"
|
||||
#title_error: "Oh no!"
|
||||
#noscript_warning: "<p>Sadly, you may need to enable JavaScript to get past this challenge. This is required because AI companies have changed the social contract around how website hosting works.</p>"
|
||||
#details_title: "Why am I seeing this?"
|
||||
#details_text: >
|
||||
# <p>
|
||||
# You are seeing this because the administrator of this website has set up <a href="https://git.gammaspectra.live/git/go-away">go-away</a>
|
||||
# to protect the server against the scourge of <a href="https://thelibre.news/foss-infrastructure-is-under-attack-by-ai-companies/">AI companies aggressively scraping websites</a>.
|
||||
# </p>
|
||||
# <p>
|
||||
# Mass scraping can and does cause downtime for the websites, which makes their resources inaccessible for everyone.
|
||||
# </p>
|
||||
# <p>
|
||||
# Please note that some challenges requires the use of modern JavaScript features and some plugins may disable these.
|
||||
# Disable such plugins for this domain (for example, JShelter) if you encounter any issues.
|
||||
# </p>
|
||||
|
||||
#details_contact_admin_with_request_id: "If you have any issues contact the site administrator and provide the following Request Id"
|
||||
|
||||
#button_refresh_page: "Refresh page"
|
||||
|
||||
#status_loading_challenge: "Loading challenge"
|
||||
#status_starting_challenge: "Starting challenge"
|
||||
#status_loading: "Loading..."
|
||||
#status_calculating: "Calculating..."
|
||||
#status_challenge_success: "Challenge success!"
|
||||
#status_challenge_done_took: "Done! Took"
|
||||
#status_error: "Error:"
|
||||
@@ -1,189 +1,41 @@
|
||||
# Example cmdline (forward requests from upstream to port :8080)
|
||||
# $ go-away --bind :8080 --backend git.example.com=http://forgejo:3000 --policy examples/forgejo.yml --challenge-template forgejo --challenge-template-theme forgejo-dark
|
||||
# $ go-away --bind :8080 --backend git.example.com=http://forgejo:3000 --policy examples/forgejo.yml --policy-snippets example/snippets/ --challenge-template forgejo --challenge-template-theme forgejo-auto
|
||||
|
||||
|
||||
|
||||
# Define networks to be used later below
|
||||
networks:
|
||||
# todo: support direct ASN lookups
|
||||
# todo: cache these values
|
||||
# Networks will get included from snippets
|
||||
|
||||
huawei-cloud:
|
||||
# AS136907
|
||||
- url: https://raw.githubusercontent.com/ipverse/asn-ip/refs/heads/master/as/136907/aggregated.json
|
||||
jq-path: '.subnets.ipv4[], .subnets.ipv6[]'
|
||||
- asn: 136907
|
||||
alibaba-cloud:
|
||||
# AS45102
|
||||
- url: https://raw.githubusercontent.com/ipverse/asn-ip/refs/heads/master/as/45102/aggregated.json
|
||||
jq-path: '.subnets.ipv4[], .subnets.ipv6[]'
|
||||
- asn: 45102
|
||||
zenlayer-inc:
|
||||
# AS21859
|
||||
- url: https://raw.githubusercontent.com/ipverse/asn-ip/refs/heads/master/as/21859/aggregated.json
|
||||
jq-path: '.subnets.ipv4[], .subnets.ipv6[]'
|
||||
aws-cloud:
|
||||
- url: https://ip-ranges.amazonaws.com/ip-ranges.json
|
||||
jq-path: '(.prefixes[] | select(has("ip_prefix")) | .ip_prefix), (.prefixes[] | select(has("ipv6_prefix")) | .ipv6_prefix)'
|
||||
google-cloud:
|
||||
- url: https://www.gstatic.com/ipranges/cloud.json
|
||||
jq-path: '(.prefixes[] | select(has("ipv4Prefix")) | .ipv4Prefix), (.prefixes[] | select(has("ipv6Prefix")) | .ipv6Prefix)'
|
||||
oracle-cloud:
|
||||
- url: https://docs.oracle.com/en-us/iaas/tools/public_ip_ranges.json
|
||||
jq-path: '.regions[] | .cidrs[] | .cidr'
|
||||
azure-cloud:
|
||||
# todo: https://www.microsoft.com/en-us/download/details.aspx?id=56519 does not provide direct JSON
|
||||
- url: https://raw.githubusercontent.com/femueller/cloud-ip-ranges/refs/heads/master/microsoft-azure-ip-ranges.json
|
||||
jq-path: '.values[] | .properties.addressPrefixes[]'
|
||||
|
||||
digitalocean:
|
||||
- url: https://www.digitalocean.com/geo/google.csv
|
||||
regex: "(?P<prefix>(([0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+)|([0-9a-f:]+::))/[0-9]+),"
|
||||
linode:
|
||||
- url: https://geoip.linode.com/
|
||||
regex: "(?P<prefix>(([0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+)|([0-9a-f:]+::))/[0-9]+),"
|
||||
vultr:
|
||||
- url: "https://geofeed.constant.com/?json"
|
||||
jq-path: '.subnets[] | .ip_prefix'
|
||||
cloudflare:
|
||||
- url: https://www.cloudflare.com/ips-v4
|
||||
regex: "(?P<prefix>[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+/[0-9]+)"
|
||||
- url: https://www.cloudflare.com/ips-v6
|
||||
regex: "(?P<prefix>[0-9a-f:]+::/[0-9]+)"
|
||||
|
||||
icloud-private-relay:
|
||||
- url: https://mask-api.icloud.com/egress-ip-ranges.csv
|
||||
regex: "(?P<prefix>(([0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+)|([0-9a-f:]+::))/[0-9]+),"
|
||||
tunnelbroker-relay:
|
||||
# HE Tunnelbroker
|
||||
- url: https://tunnelbroker.net/export/google
|
||||
regex: "(?P<prefix>([0-9a-f:]+::)/[0-9]+),"
|
||||
|
||||
|
||||
googlebot:
|
||||
- url: https://developers.google.com/static/search/apis/ipranges/googlebot.json
|
||||
jq-path: '(.prefixes[] | select(has("ipv4Prefix")) | .ipv4Prefix), (.prefixes[] | select(has("ipv6Prefix")) | .ipv6Prefix)'
|
||||
bingbot:
|
||||
- url: https://www.bing.com/toolbox/bingbot.json
|
||||
jq-path: '(.prefixes[] | select(has("ipv4Prefix")) | .ipv4Prefix), (.prefixes[] | select(has("ipv6Prefix")) | .ipv6Prefix)'
|
||||
qwantbot:
|
||||
- url: https://help.qwant.com/wp-content/uploads/sites/2/2025/01/qwantbot.json
|
||||
jq-path: '(.prefixes[] | select(has("ipv4Prefix")) | .ipv4Prefix), (.prefixes[] | select(has("ipv6Prefix")) | .ipv6Prefix)'
|
||||
duckduckbot:
|
||||
- url: https://duckduckgo.com/duckduckgo-help-pages/results/duckduckbot/
|
||||
regex: "<li>(?P<prefix>[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+)</li>"
|
||||
yandexbot:
|
||||
# todo: detected as bot
|
||||
# - url: https://yandex.com/ips
|
||||
# regex: "<span>(?P<prefix>(([0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+)|([0-9a-f:]+::))/[0-9]+)[ \\\\t]*</span><br/>"
|
||||
- prefixes:
|
||||
- "5.45.192.0/18"
|
||||
- "5.255.192.0/18"
|
||||
- "37.9.64.0/18"
|
||||
- "37.140.128.0/18"
|
||||
- "77.88.0.0/18"
|
||||
- "84.252.160.0/19"
|
||||
- "87.250.224.0/19"
|
||||
- "90.156.176.0/22"
|
||||
- "93.158.128.0/18"
|
||||
- "95.108.128.0/17"
|
||||
- "141.8.128.0/18"
|
||||
- "178.154.128.0/18"
|
||||
- "185.32.187.0/24"
|
||||
- "2a02:6b8::/29"
|
||||
kagibot:
|
||||
- url: https://kagi.com/bot
|
||||
regex: "\\n(?P<prefix>[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+) "
|
||||
- asn: 21859
|
||||
|
||||
|
||||
challenges:
|
||||
js-pow-sha256:
|
||||
# Asset must be under challenges/{name}/static/{asset}
|
||||
# Other files here will be available under that path
|
||||
mode: js
|
||||
asset: load.mjs
|
||||
parameters:
|
||||
difficulty: 20
|
||||
runtime:
|
||||
mode: wasm
|
||||
# Verify must be under challenges/{name}/runtime/{asset}
|
||||
asset: runtime.wasm
|
||||
probability: 0.02
|
||||
|
||||
# Challenges with a cookie, self redirect (non-JS, requires HTTP parsing)
|
||||
self-cookie:
|
||||
mode: "cookie"
|
||||
|
||||
|
||||
# Challenges with a redirect via Link header with rel=preload and early hints (non-JS, requires HTTP parsing, fetching and logic)
|
||||
# Works on HTTP/2 and above!
|
||||
self-preload-link:
|
||||
condition: '"Sec-Fetch-Mode" in headers && headers["Sec-Fetch-Mode"] == "navigate"'
|
||||
mode: "preload-link"
|
||||
runtime:
|
||||
# verifies that result = key
|
||||
mode: "key"
|
||||
probability: 0.1
|
||||
parameters:
|
||||
preload-early-hint-deadline: 3s
|
||||
key-code: 200
|
||||
key-mime: text/css
|
||||
key-content: ""
|
||||
|
||||
# Challenges with a redirect via Refresh header (non-JS, requires HTTP parsing and logic)
|
||||
self-header-refresh:
|
||||
mode: "header-refresh"
|
||||
runtime:
|
||||
# verifies that result = key
|
||||
mode: "key"
|
||||
probability: 0.1
|
||||
|
||||
# Challenges with a redirect via Refresh meta (non-JS, requires HTML parsing and logic)
|
||||
self-meta-refresh:
|
||||
mode: "meta-refresh"
|
||||
runtime:
|
||||
# verifies that result = key
|
||||
mode: "key"
|
||||
probability: 0.1
|
||||
|
||||
# Challenges with loading a random CSS or image document (non-JS, requires HTML parsing and logic)
|
||||
self-resource-load:
|
||||
mode: "resource-load"
|
||||
runtime:
|
||||
# verifies that result = key
|
||||
mode: "key"
|
||||
probability: 0.1
|
||||
parameters:
|
||||
key-code: 200
|
||||
key-mime: text/css
|
||||
key-content: ""
|
||||
|
||||
# Challenges will get included from snippets
|
||||
|
||||
# Verifies the existence of a cookie and confirms it against some backend request, passing the entire client cookie contents
|
||||
http-cookie-check:
|
||||
mode: http
|
||||
url: http://forgejo:3000/user/stopwatches
|
||||
# url: http://forgejo:3000/repo/search
|
||||
# url: http://forgejo:3000/notifications/new
|
||||
runtime: http
|
||||
parameters:
|
||||
http-url: http://forgejo:3000/user/stopwatches
|
||||
# http-url: http://forgejo:3000/repo/search
|
||||
# http-url: http://forgejo:3000/notifications/new
|
||||
http-method: GET
|
||||
http-cookie: i_like_gitea
|
||||
http-code: 200
|
||||
verify-probability: 0.1
|
||||
|
||||
conditions:
|
||||
# Conditions will get replaced on rules AST when found as ($condition-name)
|
||||
# Checks to detect a headless chromium via headers only
|
||||
is-headless-chromium:
|
||||
- 'userAgent.contains("HeadlessChrome") || userAgent.contains("HeadlessChromium")'
|
||||
- '"Sec-Ch-Ua" in headers && (headers["Sec-Ch-Ua"].contains("HeadlessChrome") || headers["Sec-Ch-Ua"].contains("HeadlessChromium"))'
|
||||
#- '(userAgent.contains("Chrome/") || userAgent.contains("Chromium/")) && (!("Accept-Language" in headers) || !("Accept-Encoding" in headers))'
|
||||
|
||||
is-generic-browser:
|
||||
- 'userAgent.startsWith("Mozilla/") || userAgent.startsWith("Opera/")'
|
||||
|
||||
is-well-known-asset:
|
||||
- 'path == "/robots.txt"'
|
||||
- 'path.startsWith("/.well-known")'
|
||||
# Conditions will get included from snippets
|
||||
|
||||
is-static-asset:
|
||||
- 'path == "/favicon.ico"'
|
||||
- 'path == "/apple-touch-icon.png"'
|
||||
- 'path == "/apple-touch-icon-precomposed.png"'
|
||||
- 'path.startsWith("/assets/")'
|
||||
@@ -193,45 +45,18 @@ conditions:
|
||||
- 'path.startsWith("/user/avatar/")'
|
||||
- 'path.startsWith("/attachments/")'
|
||||
|
||||
is-git-ua:
|
||||
- 'userAgent.startsWith("git/") || userAgent.contains("libgit")'
|
||||
- 'userAgent.startsWith("go-git")'
|
||||
- 'userAgent.startsWith("JGit/") || userAgent.startsWith("JGit-")'
|
||||
# Golang proxy and initial fetch
|
||||
- 'userAgent.startsWith("GoModuleMirror/")'
|
||||
- 'userAgent.startsWith("Go-http-client/") && "go-get" in query && query["go-get"] == "1"'
|
||||
- '"Git-Protocol" in headers && headers["Git-Protocol"] == "version=2"'
|
||||
is-git-path:
|
||||
- 'path.matches("^/[^/]+/[^/]+/(git-upload-pack|git-receive-pack|HEAD|info/refs|info/lfs|objects)")'
|
||||
|
||||
is-generic-robot-ua:
|
||||
- 'userAgent.contains("compatible;") && !userAgent.contains("Trident/")'
|
||||
- 'userAgent.matches("\\+https?://")'
|
||||
- 'userAgent.contains("@")'
|
||||
- 'userAgent.matches("[bB]ot/[0-9]")'
|
||||
|
||||
is-tool-ua:
|
||||
- 'userAgent.startsWith("python-requests/")'
|
||||
- 'userAgent.startsWith("Python-urllib/")'
|
||||
- 'userAgent.startsWith("python-httpx/")'
|
||||
- 'userAgent.contains("aoihttp/")'
|
||||
- 'userAgent.startsWith("http.rb/")'
|
||||
- 'userAgent.startsWith("curl/")'
|
||||
- 'userAgent.startsWith("Wget/")'
|
||||
- 'userAgent.startsWith("libcurl/")'
|
||||
- 'userAgent.startsWith("okhttp/")'
|
||||
- 'userAgent.startsWith("Java/")'
|
||||
- 'userAgent.startsWith("Apache-HttpClient//")'
|
||||
- 'userAgent.startsWith("Go-http-client/")'
|
||||
- 'userAgent.startsWith("node-fetch/")'
|
||||
- 'userAgent.startsWith("reqwest/")'
|
||||
|
||||
is-suspicious-crawler:
|
||||
# TLS Fingerprint for specific agent without ALPN
|
||||
- '(userAgent.startsWith("Mozilla/") || userAgent.startsWith("Opera/")) && ("ja4" in fp && fp.ja4.matches("^t[0-9a-z]+00_")) && !(userAgent.contains("compatible;") || userAgent.contains("+http") || userAgent.contains("facebookexternalhit/") || userAgent.contains("Twitterbot/"))'
|
||||
# Old engines
|
||||
- 'userAgent.contains("Presto/") || userAgent.contains("Trident/")'
|
||||
# Old IE browsers
|
||||
- 'userAgent.matches("MSIE ([2-9]|10|11)\\.")'
|
||||
# Old Linux browsers
|
||||
- 'userAgent.contains("Linux i[63]86") || userAgent.contains("FreeBSD i[63]86")'
|
||||
- 'userAgent.matches("Linux i[63]86") || userAgent.matches("FreeBSD i[63]86")'
|
||||
# Old Windows browsers
|
||||
- 'userAgent.matches("Windows (3|95|98|CE)") || userAgent.matches("Windows NT [1-5]\\.")'
|
||||
# Old mobile browsers
|
||||
@@ -240,6 +65,7 @@ conditions:
|
||||
- 'userAgent.startsWith("Opera/")'
|
||||
#- 'userAgent.matches("Gecko/(201[0-9]|200[0-9])")'
|
||||
- 'userAgent.matches("^Mozilla/[1-4]")'
|
||||
|
||||
is-heavy-resource:
|
||||
- 'path.startsWith("/explore/")'
|
||||
- 'path.matches("^/[^/]+/[^/]+/src/commit/")'
|
||||
@@ -249,12 +75,14 @@ conditions:
|
||||
- 'path.matches("^/[^/]+/[^/]+/search/")'
|
||||
- 'path.matches("^/[^/]+/[^/]+/find/")'
|
||||
- 'path.matches("^/[^/]+/[^/]+/activity")'
|
||||
- 'path.matches("^/[^/]+/[^/]+/graph$")'
|
||||
# any search with a custom query
|
||||
- '"q" in query && query.q != ""'
|
||||
# user activity tab
|
||||
- 'path.matches("^/[^/]+$") && "tab" in query && query.tab == "activity"'
|
||||
|
||||
|
||||
# Rules are checked sequentially in order, from top to bottom
|
||||
rules:
|
||||
- name: allow-well-known-resources
|
||||
conditions:
|
||||
@@ -266,10 +94,20 @@ 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:
|
||||
- 'inNetwork("huawei-cloud", remoteAddress) || inNetwork("alibaba-cloud", remoteAddress) || inNetwork("zenlayer-inc", remoteAddress)'
|
||||
action: poison
|
||||
- 'remoteAddress.network("huawei-cloud") || remoteAddress.network("alibaba-cloud") || remoteAddress.network("zenlayer-inc")'
|
||||
action: drop
|
||||
|
||||
- name: undesired-crawlers
|
||||
conditions:
|
||||
@@ -280,7 +118,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
|
||||
@@ -293,7 +131,7 @@ rules:
|
||||
- 'userAgent.contains("Amazonbot") || userAgent.contains("Google-Extended") || userAgent.contains("PanguBot") || userAgent.contains("AI2Bot") || userAgent.contains("Diffbot") || userAgent.contains("cohere-training-data-crawler") || userAgent.contains("Applebot-Extended")'
|
||||
# SEO / Ads and marketing
|
||||
- 'userAgent.contains("BLEXBot")'
|
||||
action: poison
|
||||
action: drop
|
||||
|
||||
- name: unknown-crawlers
|
||||
conditions:
|
||||
@@ -302,22 +140,22 @@ rules:
|
||||
action: deny
|
||||
|
||||
# check a sequence of challenges for non logged in
|
||||
- name: suspicious-crawlers/0
|
||||
- name: suspicious-crawlers
|
||||
conditions: ['($is-suspicious-crawler)']
|
||||
action: check
|
||||
challenges: [js-pow-sha256, http-cookie-check]
|
||||
- name: suspicious-crawlers/1
|
||||
conditions: ['($is-suspicious-crawler)']
|
||||
action: check
|
||||
challenges: [self-preload-link]
|
||||
- name: suspicious-crawlers/2
|
||||
conditions: ['($is-suspicious-crawler)']
|
||||
action: check
|
||||
challenges: [self-header-refresh]
|
||||
- name: suspicious-crawlers/3
|
||||
conditions: ['($is-suspicious-crawler)']
|
||||
action: check
|
||||
challenges: [self-resource-load]
|
||||
action: none
|
||||
children:
|
||||
- name: 0
|
||||
action: check
|
||||
settings:
|
||||
challenges: [js-refresh, http-cookie-check]
|
||||
- name: 1
|
||||
action: check
|
||||
settings:
|
||||
challenges: [preload-link, resource-load]
|
||||
- name: 2
|
||||
action: check
|
||||
settings:
|
||||
challenges: [header-refresh]
|
||||
|
||||
- name: always-pow-challenge
|
||||
conditions:
|
||||
@@ -334,11 +172,13 @@ rules:
|
||||
# Match archive downloads from browsers and not tools
|
||||
- 'path.matches("^/[^/]+/[^/]+/archive/.*\\.(bundle|zip|tar\\.gz)") && ($is-generic-browser)'
|
||||
action: challenge
|
||||
challenges: [ js-pow-sha256 ]
|
||||
settings:
|
||||
challenges: [ js-refresh ]
|
||||
|
||||
- name: allow-git-operations
|
||||
conditions:
|
||||
- '($is-git-path)'
|
||||
# Includes repository and wiki git endpoints
|
||||
- 'path.matches("^/[^/]+/[^/]+\\.git")'
|
||||
- 'path.matches("^/[^/]+/[^/]+/") && ($is-git-ua)'
|
||||
action: pass
|
||||
@@ -369,44 +209,51 @@ rules:
|
||||
# OCI packages API and package managers
|
||||
- 'path.startsWith("/api/packages/") || path == "/api/packages"'
|
||||
- 'path.startsWith("/v2/") || path == "/v2"'
|
||||
- 'path.endsWith("/branches/list") || path.endsWith("/tags/list")'
|
||||
action: pass
|
||||
|
||||
- name: preview-fetchers
|
||||
conditions:
|
||||
- 'path.endsWith("/-/summary-card")'
|
||||
# These summary cards are included in most previews at the end of the url
|
||||
- 'path.endsWith("/-/summary-card") || path.matches("^/[^/]+/[^/]+/releases/summary-card/[^/]+$")'
|
||||
#- 'userAgent.contains("facebookexternalhit/")'
|
||||
- 'userAgent.contains("Twitterbot/")'
|
||||
- '"X-Purpose" in headers && headers["X-Purpose"] == "preview"'
|
||||
action: pass
|
||||
|
||||
- name: desired-crawlers
|
||||
conditions:
|
||||
- 'userAgent.contains("+https://kagi.com/bot") && inNetwork("kagibot", remoteAddress)'
|
||||
- '(userAgent.contains("+http://www.google.com/bot.html") || userAgent.contains("Google-InspectionTool") || userAgent.contains("Googlebot")) && inNetwork("googlebot", remoteAddress)'
|
||||
- 'userAgent.contains("+http://www.bing.com/bingbot.htm") && inNetwork("bingbot", remoteAddress)'
|
||||
- 'userAgent.contains("+http://duckduckgo.com/duckduckbot.html") && inNetwork("duckduckbot", remoteAddress)'
|
||||
- 'userAgent.contains("+https://help.qwant.com/bot/") && inNetwork("qwantbot", remoteAddress)'
|
||||
- 'userAgent.contains("+http://yandex.com/bots") && inNetwork("yandexbot", remoteAddress)'
|
||||
#- 'userAgent.contains("Twitterbot/")'
|
||||
action: pass
|
||||
|
||||
# Allow loading and embedding of core pages without challenges
|
||||
# Extended pages like linking to files or tabs are not covered here, but might be included in other challenges
|
||||
- name: homesite
|
||||
conditions:
|
||||
# Match root of site
|
||||
- 'path == "/"'
|
||||
|
||||
# Match root of any repository or user, or issue or pr
|
||||
# generic /*/*/ match gave too many options for scrapers to trigger random endpoints
|
||||
# edit this with preferential users/orgs for now
|
||||
# todo: create negative match?
|
||||
- 'path.matches("(?i)^/(WeebDataHoarder|P2Pool|mirror|git|S\\.O\\.N\\.G|FM10K|Sillycom|pwgen2155|kaitou|metonym)/[^/]+$")'
|
||||
# this is a negative match of endpoints that Forgejo holds as reserved as users or orgs
|
||||
# see https://codeberg.org/forgejo/forgejo/src/branch/forgejo/models/user/user.go#L582
|
||||
- '(path.matches("^/[^/]+/[^/]+/?$") || path.matches("^/[^/]+/[^/]+/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
|
||||
|
||||
# check a sequence of challenges
|
||||
- name: heavy-operations/0
|
||||
action: check
|
||||
challenges: [self-preload-link, self-header-refresh, js-pow-sha256, http-cookie-check]
|
||||
- name: heavy-operations
|
||||
conditions: ['($is-heavy-resource)']
|
||||
- name: heavy-operations/1
|
||||
action: none
|
||||
children:
|
||||
- name: 0
|
||||
action: check
|
||||
settings:
|
||||
challenges: [preload-link, header-refresh, js-refresh, http-cookie-check]
|
||||
- name: 1
|
||||
action: check
|
||||
settings:
|
||||
challenges: [ resource-load, js-refresh, http-cookie-check ]
|
||||
|
||||
- name: standard-bots
|
||||
action: check
|
||||
challenges: [self-resource-load, js-pow-sha256, http-cookie-check]
|
||||
conditions: ['($is-heavy-resource)']
|
||||
settings:
|
||||
challenges: [meta-refresh, resource-load]
|
||||
conditions:
|
||||
- '($is-generic-robot-ua)'
|
||||
|
||||
# Allow all source downloads not caught in browser above
|
||||
# todo: limit this as needed?
|
||||
@@ -419,17 +266,15 @@ rules:
|
||||
action: pass
|
||||
|
||||
# check DNSBL and serve harder challenges
|
||||
# todo: make this specific to score
|
||||
- name: undesired-dnsbl
|
||||
conditions:
|
||||
- 'inDNSBL(remoteAddress)'
|
||||
action: check
|
||||
challenges: [js-pow-sha256, http-cookie-check]
|
||||
|
||||
- name: suspicious-fetchers
|
||||
action: check
|
||||
challenges: [js-pow-sha256]
|
||||
conditions:
|
||||
- 'userAgent.contains("facebookexternalhit/") || userAgent.contains("facebookcatalog/")'
|
||||
settings:
|
||||
challenges: [dnsbl]
|
||||
# if DNSBL fails, check additional challenges
|
||||
fail: check
|
||||
fail-settings:
|
||||
challenges: [js-refresh, http-cookie-check]
|
||||
|
||||
# Allow PUT/DELETE/PATCH/POST requests in general
|
||||
- name: non-get-request
|
||||
@@ -437,17 +282,47 @@ rules:
|
||||
conditions:
|
||||
- '!(method == "HEAD" || method == "GET")'
|
||||
|
||||
# Enable fetching OpenGraph and other tags from backend on these paths
|
||||
- name: enable-meta-tags
|
||||
action: context
|
||||
conditions:
|
||||
- 'userAgent.contains("facebookexternalhit/") || userAgent.contains("Facebot/") || userAgent.contains("Twitterbot/")'
|
||||
- '($is-generic-robot-ua)'
|
||||
- '!($is-generic-browser)'
|
||||
settings:
|
||||
context-set:
|
||||
# Map OpenGraph or similar <meta> tags back to the reply, even if denied/challenged
|
||||
proxy-meta-tags: "true"
|
||||
# proxy-safe-link-tags: "true"
|
||||
|
||||
# Set additional response headers
|
||||
#response-headers:
|
||||
# X-Clacks-Overhead:
|
||||
# - GNU Terry Pratchett
|
||||
|
||||
|
||||
|
||||
- name: plaintext-browser
|
||||
action: challenge
|
||||
settings:
|
||||
challenges: [http-cookie-check, meta-refresh, cookie]
|
||||
conditions:
|
||||
- 'userAgent.startsWith("Lynx/")'
|
||||
|
||||
# Comment this rule out to not challenge tool-like user agents
|
||||
- name: standard-tools
|
||||
action: challenge
|
||||
challenges: [self-cookie]
|
||||
settings:
|
||||
challenges: [cookie]
|
||||
conditions:
|
||||
- '($is-generic-robot-ua)'
|
||||
- '($is-tool-ua)'
|
||||
- '!($is-generic-browser)'
|
||||
|
||||
- name: standard-browser
|
||||
action: challenge
|
||||
challenges: [http-cookie-check, self-preload-link, self-meta-refresh, self-resource-load, js-pow-sha256]
|
||||
settings:
|
||||
challenges: [http-cookie-check, preload-link, meta-refresh, resource-load, js-refresh, js-pow-sha256]
|
||||
conditions:
|
||||
- '($is-generic-browser)'
|
||||
|
||||
# If end of rules is reached, default is PASS
|
||||
|
||||
@@ -1,153 +1,27 @@
|
||||
# Example cmdline (forward requests from upstream to port :8080)
|
||||
# $ go-away --bind :8080 --backend site.example.com=http://site:3000 --policy examples/generic.yml --challenge-template anubis
|
||||
# $ go-away --bind :8080 --backend site.example.com=http://site:3000 --policy examples/generic.yml --policy-snippets example/snippets/ --challenge-template anubis
|
||||
|
||||
|
||||
|
||||
# Define networks to be used later below
|
||||
networks:
|
||||
|
||||
googlebot:
|
||||
- url: https://developers.google.com/static/search/apis/ipranges/googlebot.json
|
||||
jq-path: '(.prefixes[] | select(has("ipv4Prefix")) | .ipv4Prefix), (.prefixes[] | select(has("ipv6Prefix")) | .ipv6Prefix)'
|
||||
bingbot:
|
||||
- url: https://www.bing.com/toolbox/bingbot.json
|
||||
jq-path: '(.prefixes[] | select(has("ipv4Prefix")) | .ipv4Prefix), (.prefixes[] | select(has("ipv6Prefix")) | .ipv6Prefix)'
|
||||
qwantbot:
|
||||
- url: https://help.qwant.com/wp-content/uploads/sites/2/2025/01/qwantbot.json
|
||||
jq-path: '(.prefixes[] | select(has("ipv4Prefix")) | .ipv4Prefix), (.prefixes[] | select(has("ipv6Prefix")) | .ipv6Prefix)'
|
||||
duckduckbot:
|
||||
- url: https://duckduckgo.com/duckduckgo-help-pages/results/duckduckbot/
|
||||
regex: "<li>(?P<prefix>[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+)</li>"
|
||||
yandexbot:
|
||||
# todo: detected as bot
|
||||
# - url: https://yandex.com/ips
|
||||
# regex: "<span>(?P<prefix>(([0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+)|([0-9a-f:]+::))/[0-9]+)[ \\\\t]*</span><br/>"
|
||||
- prefixes:
|
||||
- "5.45.192.0/18"
|
||||
- "5.255.192.0/18"
|
||||
- "37.9.64.0/18"
|
||||
- "37.140.128.0/18"
|
||||
- "77.88.0.0/18"
|
||||
- "84.252.160.0/19"
|
||||
- "87.250.224.0/19"
|
||||
- "90.156.176.0/22"
|
||||
- "93.158.128.0/18"
|
||||
- "95.108.128.0/17"
|
||||
- "141.8.128.0/18"
|
||||
- "178.154.128.0/18"
|
||||
- "185.32.187.0/24"
|
||||
- "2a02:6b8::/29"
|
||||
kagibot:
|
||||
- url: https://kagi.com/bot
|
||||
regex: "\\n(?P<prefix>[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+) "
|
||||
# Networks will get included from snippets
|
||||
|
||||
|
||||
challenges:
|
||||
js-pow-sha256:
|
||||
# Asset must be under challenges/{name}/static/{asset}
|
||||
# Other files here will be available under that path
|
||||
mode: js
|
||||
asset: load.mjs
|
||||
parameters:
|
||||
difficulty: 15
|
||||
runtime:
|
||||
mode: wasm
|
||||
# Verify must be under challenges/{name}/runtime/{asset}
|
||||
asset: runtime.wasm
|
||||
probability: 0.02
|
||||
|
||||
# Challenges with a cookie, self redirect (non-JS, requires HTTP parsing)
|
||||
self-cookie:
|
||||
mode: "cookie"
|
||||
|
||||
|
||||
# Challenges with a redirect via Link header with rel=preload and early hints (non-JS, requires HTTP parsing, fetching and logic)
|
||||
# Works on HTTP/2 and above!
|
||||
self-preload-link:
|
||||
condition: '"Sec-Fetch-Mode" in headers && headers["Sec-Fetch-Mode"] == "navigate"'
|
||||
mode: "preload-link"
|
||||
runtime:
|
||||
# verifies that result = key
|
||||
mode: "key"
|
||||
probability: 0.1
|
||||
parameters:
|
||||
preload-early-hint-deadline: 3s
|
||||
key-code: 200
|
||||
key-mime: text/css
|
||||
key-content: ""
|
||||
|
||||
# Challenges with a redirect via Refresh header (non-JS, requires HTTP parsing and logic)
|
||||
self-header-refresh:
|
||||
mode: "header-refresh"
|
||||
runtime:
|
||||
# verifies that result = key
|
||||
mode: "key"
|
||||
probability: 0.1
|
||||
|
||||
# Challenges with a redirect via Refresh meta (non-JS, requires HTML parsing and logic)
|
||||
self-meta-refresh:
|
||||
mode: "meta-refresh"
|
||||
runtime:
|
||||
# verifies that result = key
|
||||
mode: "key"
|
||||
probability: 0.1
|
||||
|
||||
# Challenges with loading a random CSS or image document (non-JS, requires HTML parsing and logic)
|
||||
self-resource-load:
|
||||
mode: "resource-load"
|
||||
runtime:
|
||||
# verifies that result = key
|
||||
mode: "key"
|
||||
probability: 0.1
|
||||
parameters:
|
||||
key-code: 200
|
||||
key-mime: text/css
|
||||
key-content: ""
|
||||
|
||||
# Challenges will get included from snippets
|
||||
|
||||
conditions:
|
||||
# Conditions will get replaced on rules AST when found as ($condition-name)
|
||||
# Checks to detect a headless chromium via headers only
|
||||
is-headless-chromium:
|
||||
- 'userAgent.contains("HeadlessChrome") || userAgent.contains("HeadlessChromium")'
|
||||
- '"Sec-Ch-Ua" in headers && (headers["Sec-Ch-Ua"].contains("HeadlessChrome") || headers["Sec-Ch-Ua"].contains("HeadlessChromium"))'
|
||||
#- '(userAgent.contains("Chrome/") || userAgent.contains("Chromium/")) && (!("Accept-Language" in headers) || !("Accept-Encoding" in headers))'
|
||||
|
||||
is-generic-browser:
|
||||
- 'userAgent.startsWith("Mozilla/") || userAgent.startsWith("Opera/")'
|
||||
# Conditions will get included from snippets
|
||||
|
||||
is-well-known-asset:
|
||||
- 'path == "/robots.txt"'
|
||||
- 'path.startsWith("/.well-known")'
|
||||
|
||||
is-static-asset:
|
||||
- 'path == "/favicon.ico"'
|
||||
- 'path == "/apple-touch-icon.png"'
|
||||
- 'path == "/apple-touch-icon-precomposed.png"'
|
||||
- 'path.matches("\\.(manifest|ttf|woff|woff2|jpg|jpeg|gif|png|webp|avif|svg|mp4|webm|css|js|mjs|wasm)$")'
|
||||
|
||||
|
||||
is-generic-robot-ua:
|
||||
- 'userAgent.contains("compatible;") && !userAgent.contains("Trident/")'
|
||||
- 'userAgent.matches("\\+https?://")'
|
||||
- 'userAgent.contains("@")'
|
||||
- 'userAgent.matches("[bB]ot/[0-9]")'
|
||||
|
||||
is-tool-ua:
|
||||
- 'userAgent.startsWith("python-requests/")'
|
||||
- 'userAgent.startsWith("Python-urllib/")'
|
||||
- 'userAgent.startsWith("python-httpx/")'
|
||||
- 'userAgent.contains("aoihttp/")'
|
||||
- 'userAgent.startsWith("http.rb/")'
|
||||
- 'userAgent.startsWith("curl/")'
|
||||
- 'userAgent.startsWith("Wget/")'
|
||||
- 'userAgent.startsWith("libcurl/")'
|
||||
- 'userAgent.startsWith("okhttp/")'
|
||||
- 'userAgent.startsWith("Java/")'
|
||||
- 'userAgent.startsWith("Apache-HttpClient//")'
|
||||
- 'userAgent.startsWith("Go-http-client/")'
|
||||
- 'userAgent.startsWith("node-fetch/")'
|
||||
- 'userAgent.startsWith("reqwest/")'
|
||||
|
||||
is-suspicious-crawler:
|
||||
- 'userAgent.contains("Presto/") || userAgent.contains("Trident/")'
|
||||
# Old IE browsers
|
||||
@@ -164,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:
|
||||
@@ -176,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)'
|
||||
@@ -185,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
|
||||
@@ -198,7 +82,7 @@ rules:
|
||||
- 'userAgent.contains("Amazonbot") || userAgent.contains("Google-Extended") || userAgent.contains("PanguBot") || userAgent.contains("AI2Bot") || userAgent.contains("Diffbot") || userAgent.contains("cohere-training-data-crawler") || userAgent.contains("Applebot-Extended")'
|
||||
# SEO / Ads and marketing
|
||||
- 'userAgent.contains("BLEXBot")'
|
||||
action: deny
|
||||
action: drop
|
||||
|
||||
- name: unknown-crawlers
|
||||
conditions:
|
||||
@@ -207,32 +91,22 @@ rules:
|
||||
action: deny
|
||||
|
||||
# check a sequence of challenges
|
||||
- name: suspicious-crawlers/0
|
||||
- name: suspicious-crawlers
|
||||
conditions: ['($is-suspicious-crawler)']
|
||||
action: check
|
||||
challenges: [js-pow-sha256]
|
||||
- name: suspicious-crawlers/1
|
||||
conditions: ['($is-suspicious-crawler)']
|
||||
action: check
|
||||
challenges: [self-preload-link]
|
||||
- name: suspicious-crawlers/2
|
||||
conditions: ['($is-suspicious-crawler)']
|
||||
action: check
|
||||
challenges: [self-header-refresh]
|
||||
- name: suspicious-crawlers/3
|
||||
conditions: ['($is-suspicious-crawler)']
|
||||
action: check
|
||||
challenges: [self-resource-load]
|
||||
|
||||
- name: desired-crawlers
|
||||
conditions:
|
||||
- 'userAgent.contains("+https://kagi.com/bot") && inNetwork("kagibot", remoteAddress)'
|
||||
- '(userAgent.contains("+http://www.google.com/bot.html") || userAgent.contains("Google-InspectionTool") || userAgent.contains("Googlebot")) && inNetwork("googlebot", remoteAddress)'
|
||||
- 'userAgent.contains("+http://www.bing.com/bingbot.htm") && inNetwork("bingbot", remoteAddress)'
|
||||
- 'userAgent.contains("+http://duckduckgo.com/duckduckbot.html") && inNetwork("duckduckbot", remoteAddress)'
|
||||
- 'userAgent.contains("+https://help.qwant.com/bot/") && inNetwork("qwantbot", remoteAddress)'
|
||||
- 'userAgent.contains("+http://yandex.com/bots") && inNetwork("yandexbot", remoteAddress)'
|
||||
action: pass
|
||||
action: none
|
||||
children:
|
||||
- name: 0
|
||||
action: check
|
||||
settings:
|
||||
challenges: [js-refresh]
|
||||
- name: 1
|
||||
action: check
|
||||
settings:
|
||||
challenges: [preload-link, resource-load]
|
||||
- name: 2
|
||||
action: check
|
||||
settings:
|
||||
challenges: [header-refresh]
|
||||
|
||||
- name: homesite
|
||||
conditions:
|
||||
@@ -240,15 +114,20 @@ rules:
|
||||
action: pass
|
||||
|
||||
# check DNSBL and serve harder challenges
|
||||
# todo: make this specific to score
|
||||
- name: undesired-dnsbl
|
||||
conditions:
|
||||
- 'inDNSBL(remoteAddress)'
|
||||
action: check
|
||||
challenges: [js-pow-sha256]
|
||||
settings:
|
||||
challenges: [dnsbl]
|
||||
# if DNSBL fails, check additional challenges
|
||||
fail: check
|
||||
fail-settings:
|
||||
challenges: [js-refresh]
|
||||
|
||||
- name: suspicious-fetchers
|
||||
action: check
|
||||
challenges: [js-pow-sha256]
|
||||
settings:
|
||||
challenges: [js-refresh]
|
||||
conditions:
|
||||
- 'userAgent.contains("facebookexternalhit/") || userAgent.contains("facebookcatalog/")'
|
||||
|
||||
@@ -258,17 +137,41 @@ 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"
|
||||
|
||||
- name: standard-tools
|
||||
# Set additional response headers
|
||||
#response-headers:
|
||||
# X-Clacks-Overhead:
|
||||
# - GNU Terry Pratchett
|
||||
|
||||
- name: plaintext-browser
|
||||
action: challenge
|
||||
challenges: [self-cookie]
|
||||
settings:
|
||||
challenges: [meta-refresh, cookie]
|
||||
conditions:
|
||||
- '($is-generic-robot-ua)'
|
||||
- '($is-tool-ua)'
|
||||
- '!($is-generic-browser)'
|
||||
- 'userAgent.startsWith("Lynx/")'
|
||||
|
||||
# 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
|
||||
challenges: [self-preload-link, self-meta-refresh, self-resource-load, js-pow-sha256]
|
||||
settings:
|
||||
challenges: [preload-link, meta-refresh, resource-load, js-refresh]
|
||||
conditions:
|
||||
- '($is-generic-browser)'
|
||||
|
||||
# If end of rules is reached, default is PASS
|
||||
|
||||
8
examples/snippets/bot-betterstack.yml
Normal file
8
examples/snippets/bot-betterstack.yml
Normal file
@@ -0,0 +1,8 @@
|
||||
networks:
|
||||
betterstack:
|
||||
- url: https://uptime.betterstack.com/ips-by-cluster.json
|
||||
jq-path: '.[] | .[]'
|
||||
|
||||
conditions:
|
||||
is-bot-betterstack:
|
||||
- &is-bot-betterstack '((userAgent.startsWith("Better Stack Better Uptime Bot") || userAgent.startsWith("Better Uptime Bot") || userAgent == "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.17 Safari/537.36")) && remoteAddress.network("betterstack")'
|
||||
8
examples/snippets/bot-bingbot.yml
Normal file
8
examples/snippets/bot-bingbot.yml
Normal file
@@ -0,0 +1,8 @@
|
||||
networks:
|
||||
bingbot:
|
||||
- url: https://www.bing.com/toolbox/bingbot.json
|
||||
jq-path: '(.prefixes[] | select(has("ipv4Prefix")) | .ipv4Prefix), (.prefixes[] | select(has("ipv6Prefix")) | .ipv6Prefix)'
|
||||
|
||||
conditions:
|
||||
is-bot-bingbot:
|
||||
- &is-bot-bingbot 'userAgent.contains("+http://www.bing.com/bingbot.htm") && remoteAddress.network("bingbot")'
|
||||
8
examples/snippets/bot-duckduckbot.yml
Normal file
8
examples/snippets/bot-duckduckbot.yml
Normal file
@@ -0,0 +1,8 @@
|
||||
networks:
|
||||
duckduckbot:
|
||||
- url: https://duckduckgo.com/duckduckgo-help-pages/results/duckduckbot
|
||||
regex: "<li><div>(?P<prefix>[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+)</div></li>"
|
||||
|
||||
conditions:
|
||||
is-bot-duckduckbot:
|
||||
- &is-bot-duckduckbot 'userAgent.contains("+http://duckduckgo.com/duckduckbot.html") && remoteAddress.network("duckduckbot")'
|
||||
8
examples/snippets/bot-googlebot.yml
Normal file
8
examples/snippets/bot-googlebot.yml
Normal file
@@ -0,0 +1,8 @@
|
||||
networks:
|
||||
googlebot:
|
||||
- url: https://developers.google.com/static/search/apis/ipranges/googlebot.json
|
||||
jq-path: '(.prefixes[] | select(has("ipv4Prefix")) | .ipv4Prefix), (.prefixes[] | select(has("ipv6Prefix")) | .ipv6Prefix)'
|
||||
|
||||
conditions:
|
||||
is-bot-googlebot:
|
||||
- &is-bot-googlebot '(userAgent.contains("+http://www.google.com/bot.html") || userAgent.contains("Google-PageRenderer") || userAgent.contains("Google-InspectionTool") || userAgent.contains("Googlebot")) && remoteAddress.network("googlebot")'
|
||||
8
examples/snippets/bot-kagibot.yml
Normal file
8
examples/snippets/bot-kagibot.yml
Normal file
@@ -0,0 +1,8 @@
|
||||
networks:
|
||||
kagibot:
|
||||
- url: https://kagi.com/bot
|
||||
regex: "\\n(?P<prefix>[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+) "
|
||||
|
||||
conditions:
|
||||
is-bot-kagibot:
|
||||
- &is-bot-kagibot 'userAgent.contains("+https://kagi.com/bot") && remoteAddress.network("kagibot")'
|
||||
8
examples/snippets/bot-qwantbot.yml
Normal file
8
examples/snippets/bot-qwantbot.yml
Normal file
@@ -0,0 +1,8 @@
|
||||
networks:
|
||||
qwantbot:
|
||||
- url: https://help.qwant.com/wp-content/uploads/sites/2/2025/01/qwantbot.json
|
||||
jq-path: '(.prefixes[] | select(has("ipv4Prefix")) | .ipv4Prefix), (.prefixes[] | select(has("ipv6Prefix")) | .ipv6Prefix)'
|
||||
|
||||
conditions:
|
||||
is-bot-qwantbot:
|
||||
- &is-bot-qwantbot 'userAgent.contains("+https://help.qwant.com/bot/") && remoteAddress.network("qwantbot")'
|
||||
8
examples/snippets/bot-uptimerobot.yml
Normal file
8
examples/snippets/bot-uptimerobot.yml
Normal 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")'
|
||||
24
examples/snippets/bot-yandexbot.yml
Normal file
24
examples/snippets/bot-yandexbot.yml
Normal file
@@ -0,0 +1,24 @@
|
||||
networks:
|
||||
yandexbot:
|
||||
# todo: detected as bot
|
||||
# - url: https://yandex.com/ips
|
||||
# regex: "<span>(?P<prefix>(([0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+)|([0-9a-f:]+::))/[0-9]+)[ \\\\t]*</span><br/>"
|
||||
- prefixes:
|
||||
- "5.45.192.0/18"
|
||||
- "5.255.192.0/18"
|
||||
- "37.9.64.0/18"
|
||||
- "37.140.128.0/18"
|
||||
- "77.88.0.0/18"
|
||||
- "84.252.160.0/19"
|
||||
- "87.250.224.0/19"
|
||||
- "90.156.176.0/22"
|
||||
- "93.158.128.0/18"
|
||||
- "95.108.128.0/17"
|
||||
- "141.8.128.0/18"
|
||||
- "178.154.128.0/18"
|
||||
- "185.32.187.0/24"
|
||||
- "2a02:6b8::/29"
|
||||
|
||||
conditions:
|
||||
is-bot-yandexbot:
|
||||
- &is-bot-yandexbot 'userAgent.contains("+http://yandex.com/bots") && remoteAddress.network("yandexbot")'
|
||||
6
examples/snippets/challenge-dnsbl.yml
Normal file
6
examples/snippets/challenge-dnsbl.yml
Normal file
@@ -0,0 +1,6 @@
|
||||
challenges:
|
||||
dnsbl:
|
||||
runtime: dnsbl
|
||||
parameters:
|
||||
dnsbl-decay: 1h
|
||||
dnsbl-timeout: 1s
|
||||
15
examples/snippets/challenge-js-pow-sha256.yml
Normal file
15
examples/snippets/challenge-js-pow-sha256.yml
Normal file
@@ -0,0 +1,15 @@
|
||||
challenges:
|
||||
js-pow-sha256:
|
||||
runtime: js
|
||||
parameters:
|
||||
# specifies the folder path that assets are under
|
||||
# can be either embedded or external path
|
||||
# defaults to name of challenge
|
||||
path: "js-pow-sha256"
|
||||
# needs to be under static folder
|
||||
js-loader: load.mjs
|
||||
# needs to be under runtime folder
|
||||
wasm-runtime: runtime.wasm
|
||||
wasm-runtime-settings:
|
||||
difficulty: 20
|
||||
verify-probability: 0.02
|
||||
6
examples/snippets/challenge-js-refresh.yml
Normal file
6
examples/snippets/challenge-js-refresh.yml
Normal file
@@ -0,0 +1,6 @@
|
||||
challenges:
|
||||
js-refresh:
|
||||
# Challenges with a redirect via window.location (requires HTML parsing and JavaScript logic)
|
||||
runtime: "refresh"
|
||||
parameters:
|
||||
refresh-via: "javascript"
|
||||
28
examples/snippets/challenges-non-js.yml
Normal file
28
examples/snippets/challenges-non-js.yml
Normal file
@@ -0,0 +1,28 @@
|
||||
challenges:
|
||||
# Challenges with a cookie, self redirect (non-JS, requires HTTP parsing)
|
||||
cookie:
|
||||
runtime: "cookie"
|
||||
|
||||
# Challenges with a redirect via Link header with rel=preload and early hints (non-JS, requires HTTP parsing, fetching and logic)
|
||||
# Works on HTTP/2 and above!
|
||||
preload-link:
|
||||
condition: '"Sec-Fetch-Mode" in headers && headers["Sec-Fetch-Mode"] == "navigate"'
|
||||
runtime: "preload-link"
|
||||
parameters:
|
||||
preload-early-hint-deadline: 2s
|
||||
|
||||
# Challenges with a redirect via Refresh header (non-JS, requires HTTP parsing and logic)
|
||||
header-refresh:
|
||||
runtime: "refresh"
|
||||
parameters:
|
||||
refresh-via: "header"
|
||||
|
||||
# Challenges with a redirect via Refresh meta (non-JS, requires HTML parsing and logic)
|
||||
meta-refresh:
|
||||
runtime: "refresh"
|
||||
parameters:
|
||||
refresh-via: "meta"
|
||||
|
||||
# Challenges with loading a random CSS or image document (non-JS, requires HTML parsing and logic)
|
||||
resource-load:
|
||||
runtime: "resource-load"
|
||||
56
examples/snippets/conditions-generic.yml
Normal file
56
examples/snippets/conditions-generic.yml
Normal file
@@ -0,0 +1,56 @@
|
||||
conditions:
|
||||
is-well-known-asset:
|
||||
# general txt files or scraper
|
||||
- 'path == "/robots.txt" || path == "/security.txt"'
|
||||
|
||||
# ads txt files
|
||||
- 'path == "/app-ads.txt" || path == "/ads.txt"'
|
||||
|
||||
# generally requested by browsers
|
||||
- 'path == "/favicon.ico"'
|
||||
|
||||
# used by some applications
|
||||
- 'path == "/crossdomain.xml"'
|
||||
|
||||
# well-known paths
|
||||
- 'path.startsWith("/.well-known/")'
|
||||
|
||||
is-git-ua:
|
||||
- 'userAgent.startsWith("git/") || userAgent.contains("libgit")'
|
||||
- 'userAgent.startsWith("go-git")'
|
||||
- 'userAgent.startsWith("JGit/") || userAgent.startsWith("JGit-")'
|
||||
# Golang proxy and initial fetch
|
||||
- 'userAgent.startsWith("GoModuleMirror/")'
|
||||
- 'userAgent.startsWith("Go-http-client/") && "go-get" in query && query["go-get"] == "1"'
|
||||
- '"Git-Protocol" in headers && headers["Git-Protocol"] == "version=2"'
|
||||
|
||||
is-generic-browser:
|
||||
- 'userAgent.startsWith("Mozilla/") || userAgent.startsWith("Opera/")'
|
||||
|
||||
is-generic-robot-ua:
|
||||
- 'userAgent.matches("compatible[;)]") && !userAgent.contains("Trident/")'
|
||||
- 'userAgent.matches("\\+https?://")'
|
||||
- 'userAgent.contains("@")'
|
||||
- 'userAgent.matches("[bB]ot/[0-9]")'
|
||||
|
||||
is-tool-ua:
|
||||
- 'userAgent.startsWith("python-requests/")'
|
||||
- 'userAgent.startsWith("Python-urllib/")'
|
||||
- 'userAgent.startsWith("python-httpx/")'
|
||||
- 'userAgent.contains("aoihttp/")'
|
||||
- 'userAgent.startsWith("http.rb/")'
|
||||
- 'userAgent.startsWith("curl/")'
|
||||
- 'userAgent.startsWith("Wget/")'
|
||||
- 'userAgent.startsWith("libcurl/")'
|
||||
- 'userAgent.startsWith("okhttp/")'
|
||||
- 'userAgent.startsWith("Java/")'
|
||||
- 'userAgent.startsWith("Apache-HttpClient//")'
|
||||
- 'userAgent.startsWith("Go-http-client/")'
|
||||
- 'userAgent.startsWith("node-fetch/")'
|
||||
- 'userAgent.startsWith("reqwest/")'
|
||||
|
||||
# Checks to detect a headless chromium via headers only
|
||||
is-headless-chromium:
|
||||
- 'userAgent.contains("HeadlessChrome") || userAgent.contains("HeadlessChromium")'
|
||||
- '"Sec-Ch-Ua" in headers && (headers["Sec-Ch-Ua"].contains("HeadlessChrome") || headers["Sec-Ch-Ua"].contains("HeadlessChromium"))'
|
||||
#- '(userAgent.contains("Chrome/") || userAgent.contains("Chromium/")) && (!("Accept-Language" in headers) || !("Accept-Encoding" in headers))'
|
||||
37
examples/snippets/networks-other.yml
Normal file
37
examples/snippets/networks-other.yml
Normal file
@@ -0,0 +1,37 @@
|
||||
networks:
|
||||
aws-cloud:
|
||||
- url: https://ip-ranges.amazonaws.com/ip-ranges.json
|
||||
jq-path: '(.prefixes[] | select(has("ip_prefix")) | .ip_prefix), (.prefixes[] | select(has("ipv6_prefix")) | .ipv6_prefix)'
|
||||
google-cloud:
|
||||
- url: https://www.gstatic.com/ipranges/cloud.json
|
||||
jq-path: '(.prefixes[] | select(has("ipv4Prefix")) | .ipv4Prefix), (.prefixes[] | select(has("ipv6Prefix")) | .ipv6Prefix)'
|
||||
oracle-cloud:
|
||||
- url: https://docs.oracle.com/en-us/iaas/tools/public_ip_ranges.json
|
||||
jq-path: '.regions[] | .cidrs[] | .cidr'
|
||||
azure-cloud:
|
||||
# todo: https://www.microsoft.com/en-us/download/details.aspx?id=56519 does not provide direct JSON
|
||||
- url: https://raw.githubusercontent.com/femueller/cloud-ip-ranges/refs/heads/master/microsoft-azure-ip-ranges.json
|
||||
jq-path: '.values[] | .properties.addressPrefixes[]'
|
||||
|
||||
digitalocean:
|
||||
- url: https://www.digitalocean.com/geo/google.csv
|
||||
regex: "(?P<prefix>(([0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+)|([0-9a-f:]+::))/[0-9]+),"
|
||||
linode:
|
||||
- url: https://geoip.linode.com/
|
||||
regex: "(?P<prefix>(([0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+)|([0-9a-f:]+::))/[0-9]+),"
|
||||
vultr:
|
||||
- url: "https://geofeed.constant.com/?json"
|
||||
jq-path: '.subnets[] | .ip_prefix'
|
||||
cloudflare:
|
||||
- url: https://www.cloudflare.com/ips-v4
|
||||
regex: "(?P<prefix>[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+/[0-9]+)"
|
||||
- url: https://www.cloudflare.com/ips-v6
|
||||
regex: "(?P<prefix>[0-9a-f:]+::/[0-9]+)"
|
||||
|
||||
icloud-private-relay:
|
||||
- url: https://mask-api.icloud.com/egress-ip-ranges.csv
|
||||
regex: "(?P<prefix>(([0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+)|([0-9a-f:]+::))/[0-9]+),"
|
||||
tunnelbroker-relay:
|
||||
# HE Tunnelbroker
|
||||
- url: https://tunnelbroker.net/export/google
|
||||
regex: "(?P<prefix>([0-9a-f:]+::)/[0-9]+),"
|
||||
87
examples/spa.yml
Normal file
87
examples/spa.yml
Normal 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)'
|
||||
26
go.mod
26
go.mod
@@ -5,33 +5,37 @@ 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/andybalholm/brotli v1.1.1
|
||||
github.com/alphadose/haxmap v1.4.1
|
||||
github.com/go-jose/go-jose/v4 v4.1.0
|
||||
github.com/google/cel-go v0.24.1
|
||||
github.com/goccy/go-yaml v1.17.1
|
||||
github.com/google/cel-go v0.25.0
|
||||
github.com/itchyny/gojq v0.12.17
|
||||
github.com/klauspost/compress v1.18.0
|
||||
github.com/pires/go-proxyproto v0.8.0
|
||||
github.com/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
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
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 // 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-20250409194420-de1ac958c67a // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250409194420-de1ac958c67a // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250422160041-2d3770c4ea7f // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250422160041-2d3770c4ea7f // indirect
|
||||
google.golang.org/protobuf v1.36.6 // indirect
|
||||
)
|
||||
|
||||
// Used by github.com/antlr4-go/antlr v4.13.0 via github.com/google/cel-go
|
||||
// Ensure we have no other exp package usages by only proxying the slices functions in that package
|
||||
replace golang.org/x/exp v0.0.0 => ./utils/exp
|
||||
|
||||
43
go.sum
43
go.sum
@@ -1,18 +1,26 @@
|
||||
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/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
|
||||
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
|
||||
github.com/alphadose/haxmap v1.4.1 h1:VtD6VCxUkjNIfJk/aWdYFfOzrRddDFjmvmRmILg7x8Q=
|
||||
github.com/alphadose/haxmap v1.4.1/go.mod h1:rjHw1IAqbxm0S3U5tD16GoKsiAd8FWx5BJ2IYqXwgmM=
|
||||
github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ=
|
||||
github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw=
|
||||
github.com/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=
|
||||
github.com/go-jose/go-jose/v4 v4.1.0 h1:cYSYxd3pw5zd2FSXk2vGdn9igQU2PS8MuxrCOCl0FdY=
|
||||
github.com/go-jose/go-jose/v4 v4.1.0/go.mod h1:GG/vqmYm3Von2nYiB2vGTXzdoNKE5tix5tuc6iAd+sw=
|
||||
github.com/google/cel-go v0.24.1 h1:jsBCtxG8mM5wiUJDSGUqU0K7Mtr3w7Eyv00rw4DiZxI=
|
||||
github.com/google/cel-go v0.24.1/go.mod h1:Hdf9TqOaTNSFQA1ybQaRqATVoK7m/zcf7IMhGXP5zI8=
|
||||
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.25.0 h1:jsFw9Fhn+3y2kBbltZR4VEz5xKkcIFRPDnuEzAGv5GY=
|
||||
github.com/google/cel-go v0.25.0/go.mod h1:hjEb6r5SuOSlhCHmFoLzu8HGCERvIsDAbxDAyNU/MmI=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/itchyny/gojq v0.12.17 h1:8av8eGduDb5+rvEdaOO+zQUjA04MS0m3Ps8HiD+fceg=
|
||||
@@ -23,10 +31,22 @@ github.com/kevinpollet/nego v0.0.0-20211010160919-a65cd48cee43 h1:Pdirg1gwhEcGjM
|
||||
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=
|
||||
@@ -40,23 +60,24 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I=
|
||||
github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
github.com/yl2chen/cidranger v1.0.2 h1:lbOWZVCG1tCRX4u24kuM1Tb4nHqWkDxwLdoS+SevawU=
|
||||
github.com/yl2chen/cidranger v1.0.2/go.mod h1:9U1yz7WPYDwf0vpNWFaeRh0bjwz5RVgRy/9UEQfHl0g=
|
||||
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
|
||||
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
|
||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
|
||||
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-20250409194420-de1ac958c67a h1:OQ7sHVzkx6L57dQpzUS4ckfWJ51KDH74XHTDe23xWAs=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250409194420-de1ac958c67a/go.mod h1:2R6XrVC8Oc08GlNh8ujEpc7HkLiEZ16QeY7FxIs20ac=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250409194420-de1ac958c67a h1:GIqLhp/cYUkuGuiT+vJk8vhOP86L4+SP5j8yXgeVpvI=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250409194420-de1ac958c67a/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
|
||||
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-20250422160041-2d3770c4ea7f h1:N/PrbTw4kdkqNRzVfWPrBekzLuarFREcbFOiOLkXon4=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250422160041-2d3770c4ea7f/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
|
||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
37
go.work.sum
37
go.work.sum
@@ -1,37 +0,0 @@
|
||||
github.com/golang/protobuf v1.5.0 h1:LUVKkCeviFUMKqHa4tXIIij/lbhnMbP7Fn5wKdKkRh4=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
|
||||
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
|
||||
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.23.0 h1:Zb7khfcRGKk+kqfxFaP5tZqCnDZMjC5VtUBs87Hr6QM=
|
||||
golang.org/x/mod v0.23.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
|
||||
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
|
||||
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
|
||||
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
|
||||
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU=
|
||||
golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s=
|
||||
golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
|
||||
golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.30.0 h1:BgcpHewrV5AUp2G9MebG4XPFI1E2W41zU1SaqVA9vJY=
|
||||
golang.org/x/tools v0.30.0/go.mod h1:c347cR/OJfw5TI+GfX7RUPNMdDRRbjvYTS0jPyvsVtY=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/grpc v1.65.0 h1:bs/cUb4lp1G5iImFFd3u5ixQzweKizoZJAwBNLR42lc=
|
||||
google.golang.org/grpc v1.65.0/go.mod h1:WgYC2ypjlB0EiQi6wdKixMqukr6lBc0Vo+oOgjrM5ZQ=
|
||||
google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg=
|
||||
google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec=
|
||||
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
|
||||
37
lib/action/block.go
Normal file
37
lib/action/block.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package action
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"git.gammaspectra.live/git/go-away/lib/challenge"
|
||||
"git.gammaspectra.live/git/go-away/lib/policy"
|
||||
"github.com/goccy/go-yaml/ast"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func init() {
|
||||
Register[policy.RuleActionBLOCK] = func(state challenge.StateInterface, ruleName, ruleHash string, settings ast.Node) (Handler, error) {
|
||||
return Block{
|
||||
Code: http.StatusForbidden,
|
||||
RuleHash: ruleHash,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
type Block struct {
|
||||
Code int
|
||||
RuleHash string
|
||||
}
|
||||
|
||||
func (a Block) Handle(logger *slog.Logger, w http.ResponseWriter, r *http.Request, done func() (backend http.Handler)) (next bool, err error) {
|
||||
logger.Info("request blocked")
|
||||
data := challenge.RequestDataFromContext(r.Context())
|
||||
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
w.Header().Set("Connection", "close")
|
||||
data.ResponseHeaders(w)
|
||||
w.WriteHeader(a.Code)
|
||||
_, _ = w.Write([]byte(fmt.Errorf("access blocked: blocked by administrative rule %s/%s", data.Id.String(), a.RuleHash).Error()))
|
||||
|
||||
return false, nil
|
||||
}
|
||||
193
lib/action/challenge.go
Normal file
193
lib/action/challenge.go
Normal file
@@ -0,0 +1,193 @@
|
||||
package action
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"git.gammaspectra.live/git/go-away/lib/challenge"
|
||||
"git.gammaspectra.live/git/go-away/lib/policy"
|
||||
"github.com/goccy/go-yaml"
|
||||
"github.com/goccy/go-yaml/ast"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func init() {
|
||||
i := func(state challenge.StateInterface, ruleName, ruleHash string, settings ast.Node, cont bool) (Handler, error) {
|
||||
params := ChallengeDefaultSettings
|
||||
|
||||
if settings != nil {
|
||||
ymlData, err := settings.MarshalYAML()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = yaml.Unmarshal(ymlData, ¶ms)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if params.Code == 0 {
|
||||
params.Code = state.Settings().ChallengeResponseCode
|
||||
}
|
||||
|
||||
var regs []*challenge.Registration
|
||||
for _, regName := range params.Challenges {
|
||||
if reg, ok := state.GetChallengeByName(regName); ok {
|
||||
regs = append(regs, reg)
|
||||
} else {
|
||||
return nil, fmt.Errorf("challenge %s not found", regName)
|
||||
}
|
||||
}
|
||||
|
||||
if len(regs) == 0 {
|
||||
return nil, fmt.Errorf("no registered challenges found in rule %s", ruleName)
|
||||
}
|
||||
|
||||
passAction := policy.RuleAction(strings.ToUpper(params.PassAction))
|
||||
passHandler, ok := Register[passAction]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unknown pass action %s", params.PassAction)
|
||||
}
|
||||
|
||||
passActionHandler, err := passHandler(state, ruleName, ruleHash, params.PassSettings)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
failAction := policy.RuleAction(strings.ToUpper(params.FailAction))
|
||||
failHandler, ok := Register[failAction]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unknown pass action %s", params.FailAction)
|
||||
}
|
||||
|
||||
failActionHandler, err := failHandler(state, ruleName, ruleHash, params.FailSettings)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return Challenge{
|
||||
RuleHash: ruleHash,
|
||||
Code: params.Code,
|
||||
Continue: cont,
|
||||
Challenges: regs,
|
||||
|
||||
PassAction: passAction,
|
||||
PassActionHandler: passActionHandler,
|
||||
FailAction: failAction,
|
||||
FailActionHandler: failActionHandler,
|
||||
}, nil
|
||||
}
|
||||
Register[policy.RuleActionCHALLENGE] = func(state challenge.StateInterface, ruleName, ruleHash string, settings ast.Node) (Handler, error) {
|
||||
return i(state, ruleName, ruleHash, settings, false)
|
||||
}
|
||||
Register[policy.RuleActionCHECK] = func(state challenge.StateInterface, ruleName, ruleHash string, settings ast.Node) (Handler, error) {
|
||||
return i(state, ruleName, ruleHash, settings, true)
|
||||
}
|
||||
}
|
||||
|
||||
var ChallengeDefaultSettings = ChallengeSettings{
|
||||
PassAction: string(policy.RuleActionPASS),
|
||||
FailAction: string(policy.RuleActionDENY),
|
||||
}
|
||||
|
||||
type ChallengeSettings struct {
|
||||
Code int `yaml:"http-code"`
|
||||
Challenges []string `yaml:"challenges"`
|
||||
|
||||
PassAction string `yaml:"pass"`
|
||||
PassSettings ast.Node `yaml:"pass-settings"`
|
||||
|
||||
// FailAction Executed in case no challenges match or
|
||||
FailAction string `yaml:"fail"`
|
||||
FailSettings ast.Node `yaml:"fail-settings"`
|
||||
}
|
||||
|
||||
type Challenge struct {
|
||||
RuleHash string
|
||||
Code int
|
||||
Continue bool
|
||||
Challenges []*challenge.Registration
|
||||
|
||||
PassAction 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!
|
||||
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
|
||||
for _, reg := range a.Challenges {
|
||||
result := data.ChallengeVerify[reg.Id()]
|
||||
state := data.ChallengeState[reg.Id()]
|
||||
if result.Ok() || result == challenge.VerifyResultSkip || state == challenge.VerifyStatePass {
|
||||
// skip already ok'd challenges for some reason (TODO: why)
|
||||
// also skip skipped challenges due to preconditions
|
||||
continue
|
||||
}
|
||||
|
||||
expiry := data.Expiration(reg.Duration)
|
||||
key := challenge.GetChallengeKeyForRequest(data.State, reg, expiry, r)
|
||||
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 {
|
||||
case challenge.VerifyResultOK:
|
||||
data.State.ChallengePassed(r, reg, r.URL.String(), logger)
|
||||
if a.Continue {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
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
|
||||
continue
|
||||
case challenge.VerifyResultFail:
|
||||
err := fmt.Errorf("challenge %s failed on issuance", reg.Name)
|
||||
data.State.ChallengeFailed(r, reg, err, r.URL.String(), logger)
|
||||
|
||||
if reg.Class == challenge.ClassTransparent {
|
||||
// allow continuing transparent challenges
|
||||
continue
|
||||
}
|
||||
|
||||
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 {
|
||||
// allow continuing transparent challenges
|
||||
continue
|
||||
}
|
||||
// we cannot continue after issuance
|
||||
return false, nil
|
||||
|
||||
case challenge.VerifyResultSkip:
|
||||
// continue onto next one due to precondition
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// nothing matched, execute default action
|
||||
data.State.ActionHit(r, a.FailAction, logger)
|
||||
return a.FailActionHandler.Handle(logger, w, r, done)
|
||||
}
|
||||
49
lib/action/code.go
Normal file
49
lib/action/code.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package action
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"git.gammaspectra.live/git/go-away/lib/challenge"
|
||||
"git.gammaspectra.live/git/go-away/lib/policy"
|
||||
"github.com/goccy/go-yaml"
|
||||
"github.com/goccy/go-yaml/ast"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func init() {
|
||||
Register[policy.RuleActionCODE] = func(state challenge.StateInterface, ruleName, ruleHash string, settings ast.Node) (Handler, error) {
|
||||
params := CodeDefaultSettings
|
||||
|
||||
if settings != nil {
|
||||
ymlData, err := settings.MarshalYAML()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = yaml.Unmarshal(ymlData, ¶ms)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if params.Code == 0 {
|
||||
return nil, errors.New("http-code not set")
|
||||
}
|
||||
|
||||
return Code(params.Code), nil
|
||||
}
|
||||
}
|
||||
|
||||
var CodeDefaultSettings = CodeSettings{}
|
||||
|
||||
type CodeSettings struct {
|
||||
Code int `yaml:"http-code"`
|
||||
}
|
||||
|
||||
type Code int
|
||||
|
||||
func (a Code) Handle(logger *slog.Logger, w http.ResponseWriter, r *http.Request, done func() (backend http.Handler)) (next bool, err error) {
|
||||
challenge.RequestDataFromContext(r.Context()).ResponseHeaders(w)
|
||||
|
||||
w.WriteHeader(int(a))
|
||||
return false, nil
|
||||
}
|
||||
69
lib/action/context.go
Normal file
69
lib/action/context.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package action
|
||||
|
||||
import (
|
||||
"git.gammaspectra.live/git/go-away/lib/challenge"
|
||||
"git.gammaspectra.live/git/go-away/lib/policy"
|
||||
"github.com/goccy/go-yaml"
|
||||
"github.com/goccy/go-yaml/ast"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/textproto"
|
||||
)
|
||||
|
||||
func init() {
|
||||
Register[policy.RuleActionCONTEXT] = func(state challenge.StateInterface, ruleName, ruleHash string, settings ast.Node) (Handler, error) {
|
||||
params := ContextDefaultSettings
|
||||
|
||||
if settings != nil {
|
||||
ymlData, err := settings.MarshalYAML()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = yaml.Unmarshal(ymlData, ¶ms)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return Context{
|
||||
opts: params,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
var ContextDefaultSettings = ContextSettings{}
|
||||
|
||||
type ContextSettings struct {
|
||||
ContextSet map[string]string `yaml:"context-set"`
|
||||
ResponseHeaders map[string][]string `yaml:"response-headers"`
|
||||
RequestHeaders map[string][]string `yaml:"request-headers"`
|
||||
}
|
||||
|
||||
type Context struct {
|
||||
opts ContextSettings
|
||||
}
|
||||
|
||||
func (a Context) Handle(logger *slog.Logger, w http.ResponseWriter, r *http.Request, done func() (backend http.Handler)) (next bool, err error) {
|
||||
data := challenge.RequestDataFromContext(r.Context())
|
||||
for k, v := range a.opts.ContextSet {
|
||||
data.SetOpt(k, v)
|
||||
}
|
||||
|
||||
for k, v := range a.opts.ResponseHeaders {
|
||||
// do this to allow unsetting values that are sent automatically
|
||||
w.Header()[textproto.CanonicalMIMEHeaderKey(k)] = nil
|
||||
for _, val := range v {
|
||||
w.Header().Add(k, val)
|
||||
}
|
||||
}
|
||||
|
||||
for k, v := range a.opts.RequestHeaders {
|
||||
// do this to allow unsetting values that are sent automatically
|
||||
r.Header[textproto.CanonicalMIMEHeaderKey(k)] = nil
|
||||
for _, val := range v {
|
||||
r.Header.Add(k, val)
|
||||
}
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
31
lib/action/deny.go
Normal file
31
lib/action/deny.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package action
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"git.gammaspectra.live/git/go-away/lib/challenge"
|
||||
"git.gammaspectra.live/git/go-away/lib/policy"
|
||||
"github.com/goccy/go-yaml/ast"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func init() {
|
||||
Register[policy.RuleActionDENY] = func(state challenge.StateInterface, ruleName, ruleHash string, settings ast.Node) (Handler, error) {
|
||||
return Deny{
|
||||
Code: http.StatusForbidden,
|
||||
RuleHash: ruleHash,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
type Deny struct {
|
||||
Code int
|
||||
RuleHash string
|
||||
}
|
||||
|
||||
func (a Deny) Handle(logger *slog.Logger, w http.ResponseWriter, r *http.Request, done func() (backend http.Handler)) (next bool, err error) {
|
||||
logger.Info("request denied")
|
||||
data := challenge.RequestDataFromContext(r.Context())
|
||||
data.State.ErrorPage(w, r, a.Code, fmt.Errorf("access denied: denied by administrative rule %s/%s", data.Id.String(), a.RuleHash), "")
|
||||
return false, nil
|
||||
}
|
||||
39
lib/action/drop.go
Normal file
39
lib/action/drop.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package action
|
||||
|
||||
import (
|
||||
"git.gammaspectra.live/git/go-away/lib/challenge"
|
||||
"git.gammaspectra.live/git/go-away/lib/policy"
|
||||
"github.com/goccy/go-yaml/ast"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func init() {
|
||||
Register[policy.RuleActionDROP] = func(state challenge.StateInterface, ruleName, ruleHash string, settings ast.Node) (Handler, error) {
|
||||
return Drop{}, nil
|
||||
}
|
||||
}
|
||||
|
||||
type Drop struct {
|
||||
}
|
||||
|
||||
func (a Drop) Handle(logger *slog.Logger, w http.ResponseWriter, r *http.Request, done func() (backend http.Handler)) (next bool, err error) {
|
||||
logger.Info("request dropped")
|
||||
|
||||
if hj, ok := w.(http.Hijacker); ok {
|
||||
if conn, _, err := hj.Hijack(); err == nil {
|
||||
// drop without sending data
|
||||
_ = conn.Close()
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
// fallback
|
||||
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
w.Header().Set("Content-Length", "0")
|
||||
w.Header().Set("Connection", "close")
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
|
||||
return false, nil
|
||||
}
|
||||
21
lib/action/none.go
Normal file
21
lib/action/none.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package action
|
||||
|
||||
import (
|
||||
"git.gammaspectra.live/git/go-away/lib/challenge"
|
||||
"git.gammaspectra.live/git/go-away/lib/policy"
|
||||
"github.com/goccy/go-yaml/ast"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func init() {
|
||||
Register[policy.RuleActionNONE] = func(state challenge.StateInterface, ruleName, ruleHash string, settings ast.Node) (Handler, error) {
|
||||
return None{}, nil
|
||||
}
|
||||
}
|
||||
|
||||
type None struct{}
|
||||
|
||||
func (a None) Handle(logger *slog.Logger, w http.ResponseWriter, r *http.Request, done func() (backend http.Handler)) (next bool, err error) {
|
||||
return true, nil
|
||||
}
|
||||
23
lib/action/pass.go
Normal file
23
lib/action/pass.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package action
|
||||
|
||||
import (
|
||||
"git.gammaspectra.live/git/go-away/lib/challenge"
|
||||
"git.gammaspectra.live/git/go-away/lib/policy"
|
||||
"github.com/goccy/go-yaml/ast"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func init() {
|
||||
Register[policy.RuleActionPASS] = func(state challenge.StateInterface, ruleName, ruleHash string, settings ast.Node) (Handler, error) {
|
||||
return Pass{}, nil
|
||||
}
|
||||
}
|
||||
|
||||
type Pass struct{}
|
||||
|
||||
func (a Pass) Handle(logger *slog.Logger, w http.ResponseWriter, r *http.Request, done func() (backend http.Handler)) (next bool, err error) {
|
||||
logger.Debug("request passed")
|
||||
done().ServeHTTP(w, r)
|
||||
return false, nil
|
||||
}
|
||||
80
lib/action/proxy.go
Normal file
80
lib/action/proxy.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package action
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"git.gammaspectra.live/git/go-away/lib/challenge"
|
||||
"git.gammaspectra.live/git/go-away/lib/policy"
|
||||
"github.com/goccy/go-yaml"
|
||||
"github.com/goccy/go-yaml/ast"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
func init() {
|
||||
Register[policy.RuleActionPROXY] = func(state challenge.StateInterface, ruleName, ruleHash string, settings ast.Node) (Handler, error) {
|
||||
params := ProxyDefaultSettings
|
||||
|
||||
if settings != nil {
|
||||
ymlData, err := settings.MarshalYAML()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = yaml.Unmarshal(ymlData, ¶ms)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if params.Match != "" {
|
||||
expr, err := regexp.Compile(params.Match)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return Proxy{
|
||||
Match: expr,
|
||||
Rewrite: params.Rewrite,
|
||||
Backend: params.Backend,
|
||||
}, nil
|
||||
}
|
||||
|
||||
return Proxy{
|
||||
Backend: params.Backend,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
var ProxyDefaultSettings = ProxySettings{}
|
||||
|
||||
type ProxySettings struct {
|
||||
Match string `yaml:"proxy-match"`
|
||||
Rewrite string `yaml:"proxy-rewrite"`
|
||||
Backend string `yaml:"proxy-backend"`
|
||||
}
|
||||
|
||||
type Proxy struct {
|
||||
Match *regexp.Regexp
|
||||
Rewrite string
|
||||
Backend string
|
||||
}
|
||||
|
||||
func (a Proxy) Handle(logger *slog.Logger, w http.ResponseWriter, r *http.Request, done func() (backend http.Handler)) (next bool, err error) {
|
||||
data := challenge.RequestDataFromContext(r.Context())
|
||||
|
||||
backend := data.State.GetBackend(a.Backend)
|
||||
if backend == nil {
|
||||
return false, fmt.Errorf("backend for %s not found", a.Backend)
|
||||
}
|
||||
|
||||
if a.Match != nil {
|
||||
// rewrite query
|
||||
r.URL.Path = a.Match.ReplaceAllString(r.URL.Path, a.Rewrite)
|
||||
}
|
||||
|
||||
// set headers, ignore reply
|
||||
_ = done()
|
||||
backend.ServeHTTP(w, r)
|
||||
|
||||
return false, nil
|
||||
}
|
||||
20
lib/action/register.go
Normal file
20
lib/action/register.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package action
|
||||
|
||||
import (
|
||||
"git.gammaspectra.live/git/go-away/lib/challenge"
|
||||
"git.gammaspectra.live/git/go-away/lib/policy"
|
||||
"github.com/goccy/go-yaml/ast"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type Handler interface {
|
||||
// Handle An incoming request.
|
||||
// If next is true, continue processing
|
||||
// If next is false, stop processing. If passing to a backend, done() must be called beforehand to set headers.
|
||||
Handle(logger *slog.Logger, w http.ResponseWriter, r *http.Request, done func() (backend http.Handler)) (next bool, err error)
|
||||
}
|
||||
|
||||
type NewFunc func(state challenge.StateInterface, ruleName, ruleHash string, settings ast.Node) (Handler, error)
|
||||
|
||||
var Register = make(map[policy.RuleAction]NewFunc)
|
||||
128
lib/challenge.go
128
lib/challenge.go
@@ -1,125 +1,13 @@
|
||||
package lib
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"github.com/go-jose/go-jose/v4/jwt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
_ "git.gammaspectra.live/git/go-away/lib/challenge/cookie"
|
||||
_ "git.gammaspectra.live/git/go-away/lib/challenge/dnsbl"
|
||||
_ "git.gammaspectra.live/git/go-away/lib/challenge/http"
|
||||
_ "git.gammaspectra.live/git/go-away/lib/challenge/preload-link"
|
||||
_ "git.gammaspectra.live/git/go-away/lib/challenge/refresh"
|
||||
_ "git.gammaspectra.live/git/go-away/lib/challenge/resource-load"
|
||||
_ "git.gammaspectra.live/git/go-away/lib/challenge/wasm"
|
||||
)
|
||||
|
||||
type ChallengeInformation struct {
|
||||
Name string `json:"name"`
|
||||
Key []byte `json:"key"`
|
||||
Result []byte `json:"result"`
|
||||
|
||||
Expiry *jwt.NumericDate `json:"exp,omitempty"`
|
||||
NotBefore *jwt.NumericDate `json:"nbf,omitempty"`
|
||||
IssuedAt *jwt.NumericDate `json:"iat,omitempty"`
|
||||
}
|
||||
|
||||
func getRequestScheme(r *http.Request) string {
|
||||
if proto := r.Header.Get("X-Forwarded-Proto"); proto == "http" || proto == "https" {
|
||||
return proto
|
||||
}
|
||||
|
||||
if r.TLS != nil {
|
||||
return "https"
|
||||
}
|
||||
|
||||
return "http"
|
||||
}
|
||||
|
||||
func getRequestAddress(r *http.Request, clientHeader string) net.IP {
|
||||
var ipStr string
|
||||
if clientHeader != "" {
|
||||
ipStr = r.Header.Get(clientHeader)
|
||||
}
|
||||
if ipStr != "" {
|
||||
// handle X-Forwarded-For
|
||||
ipStr = strings.Split(ipStr, ",")[0]
|
||||
}
|
||||
|
||||
// fallback
|
||||
if ipStr == "" {
|
||||
parts := strings.Split(r.RemoteAddr, ":")
|
||||
// drop port
|
||||
ipStr = strings.Join(parts[:len(parts)-1], ":")
|
||||
}
|
||||
ipStr = strings.Trim(ipStr, "[]")
|
||||
return net.ParseIP(ipStr)
|
||||
}
|
||||
|
||||
type ChallengeKey []byte
|
||||
|
||||
const ChallengeKeySize = sha256.Size
|
||||
|
||||
func (k *ChallengeKey) Set(flags ChallengeKeyFlags) {
|
||||
(*k)[0] |= uint8(flags)
|
||||
}
|
||||
func (k *ChallengeKey) Get(flags ChallengeKeyFlags) ChallengeKeyFlags {
|
||||
return ChallengeKeyFlags((*k)[0] & uint8(flags))
|
||||
}
|
||||
func (k *ChallengeKey) Unset(flags ChallengeKeyFlags) {
|
||||
(*k)[0] = (*k)[0] & ^(uint8(flags))
|
||||
}
|
||||
|
||||
type ChallengeKeyFlags uint8
|
||||
|
||||
const (
|
||||
ChallengeKeyFlagIsIPv4 = ChallengeKeyFlags(1 << iota)
|
||||
)
|
||||
|
||||
func ChallengeKeyFromString(s string) (ChallengeKey, error) {
|
||||
b, err := hex.DecodeString(s)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(b) != ChallengeKeySize {
|
||||
return nil, errors.New("invalid challenge key")
|
||||
}
|
||||
return ChallengeKey(b), nil
|
||||
}
|
||||
|
||||
func (state *State) GetChallengeKeyForRequest(challengeName string, until time.Time, r *http.Request) ChallengeKey {
|
||||
data := RequestDataFromContext(r.Context())
|
||||
address := data.RemoteAddress
|
||||
hasher := sha256.New()
|
||||
hasher.Write([]byte("challenge\x00"))
|
||||
hasher.Write([]byte(challengeName))
|
||||
hasher.Write([]byte{0})
|
||||
hasher.Write(address.To16())
|
||||
hasher.Write([]byte{0})
|
||||
|
||||
// specific headers
|
||||
for _, k := range []string{
|
||||
"Accept-Language",
|
||||
// General browser information
|
||||
"User-Agent",
|
||||
// TODO: not sent in preload
|
||||
//"Sec-Ch-Ua",
|
||||
//"Sec-Ch-Ua-Platform",
|
||||
} {
|
||||
hasher.Write([]byte(r.Header.Get(k)))
|
||||
hasher.Write([]byte{0})
|
||||
}
|
||||
hasher.Write([]byte{0})
|
||||
_ = binary.Write(hasher, binary.LittleEndian, until.UTC().Unix())
|
||||
hasher.Write([]byte{0})
|
||||
hasher.Write(state.publicKey)
|
||||
hasher.Write([]byte{0})
|
||||
|
||||
sum := ChallengeKey(hasher.Sum(nil))
|
||||
|
||||
sum[0] = 0
|
||||
|
||||
if address.To4() != nil {
|
||||
// Is IPv4, mark
|
||||
sum.Set(ChallengeKeyFlagIsIPv4)
|
||||
}
|
||||
return ChallengeKey(sum)
|
||||
}
|
||||
// This file loads embedded challenge runtimes so their init() is called
|
||||
|
||||
47
lib/challenge/awaiter.go
Normal file
47
lib/challenge/awaiter.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package challenge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/alphadose/haxmap"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
type awaiterCallback func(result VerifyResult)
|
||||
|
||||
type Awaiter[K ~string | ~int64 | ~uint64] haxmap.Map[K, awaiterCallback]
|
||||
|
||||
func NewAwaiter[T ~string | ~int64 | ~uint64]() *Awaiter[T] {
|
||||
return (*Awaiter[T])(haxmap.New[T, awaiterCallback]())
|
||||
}
|
||||
|
||||
func (a *Awaiter[T]) Await(key T, ctx context.Context) VerifyResult {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
var result atomic.Int64
|
||||
|
||||
a.m().Set(key, func(receivedResult VerifyResult) {
|
||||
result.Store(int64(receivedResult))
|
||||
cancel()
|
||||
})
|
||||
// cleanup
|
||||
defer a.m().Del(key)
|
||||
|
||||
<-ctx.Done()
|
||||
|
||||
return VerifyResult(result.Load())
|
||||
}
|
||||
|
||||
func (a *Awaiter[T]) Solve(key T, result VerifyResult) {
|
||||
if f, ok := a.m().GetAndDel(key); ok && f != nil {
|
||||
f(result)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Awaiter[T]) m() *haxmap.Map[T, awaiterCallback] {
|
||||
return (*haxmap.Map[T, awaiterCallback])(a)
|
||||
}
|
||||
|
||||
func (a *Awaiter[T]) Close() error {
|
||||
return nil
|
||||
}
|
||||
34
lib/challenge/cookie/cookie.go
Normal file
34
lib/challenge/cookie/cookie.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package cookie
|
||||
|
||||
import (
|
||||
"git.gammaspectra.live/git/go-away/lib/challenge"
|
||||
"github.com/goccy/go-yaml/ast"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
func init() {
|
||||
challenge.Runtimes[Key] = FillRegistration
|
||||
}
|
||||
|
||||
const Key = "cookie"
|
||||
|
||||
func FillRegistration(state challenge.StateInterface, reg *challenge.Registration, parameters ast.Node) error {
|
||||
reg.Class = challenge.ClassBlocking
|
||||
|
||||
reg.IssueChallenge = func(w http.ResponseWriter, r *http.Request, key challenge.Key, expiry time.Time) challenge.VerifyResult {
|
||||
data := challenge.RequestDataFromContext(r.Context())
|
||||
data.IssueChallengeToken(reg, key, nil, expiry, true)
|
||||
|
||||
uri, err := challenge.RedirectUrl(r, reg)
|
||||
if err != nil {
|
||||
return challenge.VerifyResultFail
|
||||
}
|
||||
|
||||
data.ResponseHeaders(w)
|
||||
http.Redirect(w, r, uri.String(), http.StatusTemporaryRedirect)
|
||||
return challenge.VerifyResultNone
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
499
lib/challenge/data.go
Normal file
499
lib/challenge/data.go
Normal file
@@ -0,0 +1,499 @@
|
||||
package challenge
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
http_cel "codeberg.org/gone/http-cel"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"git.gammaspectra.live/git/go-away/utils"
|
||||
"github.com/go-jose/go-jose/v4"
|
||||
"github.com/go-jose/go-jose/v4/jwt"
|
||||
"github.com/google/cel-go/cel"
|
||||
"github.com/google/cel-go/common/types"
|
||||
"github.com/google/cel-go/common/types/traits"
|
||||
"maps"
|
||||
unsaferand "math/rand/v2"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/textproto"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type requestDataContextKey struct {
|
||||
}
|
||||
|
||||
func RequestDataFromContext(ctx context.Context) *RequestData {
|
||||
val := ctx.Value(requestDataContextKey{})
|
||||
if val == nil {
|
||||
return nil
|
||||
}
|
||||
return val.(*RequestData)
|
||||
}
|
||||
|
||||
type RequestId [16]byte
|
||||
|
||||
func (id RequestId) String() string {
|
||||
return hex.EncodeToString(id[:])
|
||||
}
|
||||
|
||||
type RequestData struct {
|
||||
Id RequestId
|
||||
Time time.Time
|
||||
ChallengeVerify map[Id]VerifyResult
|
||||
ChallengeState map[Id]VerifyState
|
||||
ChallengeMap TokenChallengeMap
|
||||
challengeMapModified bool
|
||||
|
||||
RemoteAddress netip.AddrPort
|
||||
State StateInterface
|
||||
cookieName string
|
||||
issuedChallenge string
|
||||
|
||||
ExtraHeaders http.Header
|
||||
|
||||
r *http.Request
|
||||
|
||||
fp map[string]string
|
||||
header traits.Mapper
|
||||
query traits.Mapper
|
||||
|
||||
opts map[string]string
|
||||
}
|
||||
|
||||
func CreateRequestData(r *http.Request, state StateInterface) (*http.Request, *RequestData) {
|
||||
|
||||
var data RequestData
|
||||
// generate random id, todo: is this fast?
|
||||
_, _ = rand.Read(data.Id[:])
|
||||
data.RemoteAddress = utils.GetRequestAddress(r, state.Settings().ClientIpHeader)
|
||||
data.ChallengeVerify = make(map[Id]VerifyResult, len(state.GetChallenges()))
|
||||
data.ChallengeState = make(map[Id]VerifyState, len(state.GetChallenges()))
|
||||
data.Time = time.Now().UTC()
|
||||
data.State = state
|
||||
|
||||
data.ExtraHeaders = make(http.Header)
|
||||
|
||||
data.fp = make(map[string]string, 2)
|
||||
|
||||
if fp := utils.GetTLSFingerprint(r); fp != nil {
|
||||
if ja3nPtr := fp.JA3N(); ja3nPtr != nil {
|
||||
ja3n := ja3nPtr.String()
|
||||
data.fp["ja3n"] = ja3n
|
||||
}
|
||||
if ja4Ptr := fp.JA4(); ja4Ptr != nil {
|
||||
ja4 := ja4Ptr.String()
|
||||
data.fp["ja4"] = ja4
|
||||
}
|
||||
}
|
||||
|
||||
q := r.URL.Query()
|
||||
|
||||
if q.Has(QueryArgChallenge) {
|
||||
data.issuedChallenge = q.Get(QueryArgChallenge)
|
||||
}
|
||||
|
||||
// delete query parameters that were set by go-away
|
||||
for k := range q {
|
||||
if strings.HasPrefix(k, QueryArgPrefix) {
|
||||
q.Del(k)
|
||||
}
|
||||
}
|
||||
|
||||
data.query = http_cel.NewValuesMap(q)
|
||||
data.header = http_cel.NewMIMEMap(textproto.MIMEHeader(r.Header))
|
||||
data.opts = make(map[string]string)
|
||||
|
||||
r = r.WithContext(context.WithValue(r.Context(), requestDataContextKey{}, &data))
|
||||
r = utils.SetRemoteAddress(r, data.RemoteAddress)
|
||||
data.r = r
|
||||
|
||||
data.cookieName = utils.DefaultCookiePrefix + hex.EncodeToString(data.cookieHostKey()) + "-state"
|
||||
|
||||
return r, &data
|
||||
}
|
||||
|
||||
func (d *RequestData) ResolveName(name string) (any, bool) {
|
||||
switch name {
|
||||
case "host":
|
||||
return d.r.Host, true
|
||||
case "method":
|
||||
return d.r.Method, true
|
||||
case "remoteAddress":
|
||||
return d.RemoteAddress.Addr().AsSlice(), true
|
||||
case "userAgent":
|
||||
return d.r.UserAgent(), true
|
||||
case "path":
|
||||
return d.r.URL.Path, true
|
||||
case "query":
|
||||
return d.query, true
|
||||
case "headers":
|
||||
return d.header, true
|
||||
case "fp":
|
||||
return d.fp, true
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
func (d *RequestData) Parent() cel.Activation {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *RequestData) NetworkPrefix() netip.Addr {
|
||||
address := d.RemoteAddress.Addr().Unmap()
|
||||
if address.Is4() {
|
||||
// Take a /24 for IPv4
|
||||
prefix, _ := address.Prefix(24)
|
||||
return prefix.Addr()
|
||||
} else {
|
||||
// Take a /64 for IPv6
|
||||
prefix, _ := address.Prefix(64)
|
||||
return prefix.Addr()
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
RequestOptBackendHost = "backend-host"
|
||||
RequestOptProxyMetaTags = "proxy-meta-tags"
|
||||
RequestOptProxySafeLinkTags = "proxy-safe-link-tags"
|
||||
)
|
||||
|
||||
func (d *RequestData) SetOpt(n, v string) {
|
||||
d.opts[n] = v
|
||||
}
|
||||
|
||||
func (d *RequestData) GetOpt(n, def string) string {
|
||||
v, ok := d.opts[n]
|
||||
if !ok {
|
||||
return def
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func (d *RequestData) GetOptBool(n string, def bool) bool {
|
||||
v, ok := d.opts[n]
|
||||
if !ok {
|
||||
return def
|
||||
}
|
||||
switch v {
|
||||
case "true", "t", "1", "yes", "yep", "y", "ok":
|
||||
return true
|
||||
case "false", "f", "0", "no", "nope", "n", "err":
|
||||
return false
|
||||
default:
|
||||
return def
|
||||
}
|
||||
}
|
||||
|
||||
func (d *RequestData) BackendHost() (http.Handler, string) {
|
||||
host := d.r.Host
|
||||
|
||||
if opt := d.GetOpt(RequestOptBackendHost, ""); opt != "" && opt != host {
|
||||
host = d.r.Host
|
||||
}
|
||||
|
||||
return d.State.GetBackend(host), host
|
||||
}
|
||||
|
||||
func (d *RequestData) ClearChallengeToken(reg *Registration) {
|
||||
delete(d.ChallengeMap, reg.Name)
|
||||
d.challengeMapModified = true
|
||||
}
|
||||
|
||||
func (d *RequestData) IssueChallengeToken(reg *Registration, key Key, result []byte, until time.Time, ok bool) {
|
||||
d.ChallengeMap[reg.Name] = TokenChallenge{
|
||||
Key: key[:],
|
||||
Result: result,
|
||||
Ok: ok,
|
||||
Expiry: jwt.NumericDate(until.Unix()),
|
||||
IssuedAt: jwt.NumericDate(time.Now().UTC().Unix()),
|
||||
}
|
||||
d.challengeMapModified = true
|
||||
}
|
||||
|
||||
var ErrVerifyKeyMismatch = errors.New("verify: key mismatch")
|
||||
var ErrVerifyVerifyMismatch = errors.New("verify: verification mismatch")
|
||||
var ErrTokenExpired = errors.New("token: expired")
|
||||
|
||||
func (d *RequestData) VerifyChallengeToken(reg *Registration, token TokenChallenge, expectedKey Key) (VerifyResult, VerifyState, error) {
|
||||
if token.Expiry.Time().Compare(time.Now()) < 0 {
|
||||
return VerifyResultFail, VerifyStateNone, ErrTokenExpired
|
||||
}
|
||||
if token.NotBefore.Time().Compare(time.Now()) > 0 {
|
||||
return VerifyResultFail, VerifyStateNone, errors.New("token not valid yet")
|
||||
}
|
||||
|
||||
if bytes.Compare(expectedKey[:], token.Key) != 0 {
|
||||
return VerifyResultFail, VerifyStateNone, ErrVerifyKeyMismatch
|
||||
}
|
||||
|
||||
if reg.Verify != nil {
|
||||
if unsaferand.Float64() < reg.VerifyProbability {
|
||||
// random spot check
|
||||
if ok, err := reg.Verify(expectedKey, token.Result, d.r); err != nil {
|
||||
return VerifyResultFail, VerifyStateFull, err
|
||||
} else if ok == VerifyResultNotOK {
|
||||
return VerifyResultNotOK, VerifyStateFull, nil
|
||||
} else if !ok.Ok() {
|
||||
return ok, VerifyStateFull, ErrVerifyVerifyMismatch
|
||||
} else {
|
||||
return ok, VerifyStateFull, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !token.Ok {
|
||||
return VerifyResultNotOK, VerifyStateBrief, nil
|
||||
}
|
||||
return VerifyResultOK, VerifyStateBrief, nil
|
||||
}
|
||||
|
||||
func (d *RequestData) verifyChallenge(reg *Registration, key Key) (verifyResult VerifyResult, verifyState VerifyState, err error) {
|
||||
|
||||
token, ok := d.ChallengeMap[reg.Name]
|
||||
if !ok {
|
||||
verifyResult = VerifyResultFail
|
||||
verifyState = VerifyStateNone
|
||||
} else {
|
||||
verifyResult, verifyState, err = d.VerifyChallengeToken(reg, token, key)
|
||||
if err != nil && !errors.Is(err, http.ErrNoCookie) {
|
||||
// clear invalid state
|
||||
d.ClearChallengeToken(reg)
|
||||
}
|
||||
|
||||
// prevent evaluating the challenge if not solved
|
||||
if !verifyResult.Ok() && reg.Condition != nil {
|
||||
out, _, err := reg.Condition.Eval(d)
|
||||
// verify eligibility
|
||||
if err != nil {
|
||||
d.State.Logger(d.r).Error(err.Error(), "challenge", reg.Name)
|
||||
} else if out != nil && out.Type() == types.BoolType {
|
||||
if out.Equal(types.True) != types.True {
|
||||
// skip challenge match due to precondition!
|
||||
verifyResult = VerifyResultSkip
|
||||
return verifyResult, verifyState, err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !verifyResult.Ok() && d.issuedChallenge == reg.Name {
|
||||
// we issued the challenge, must skip to prevent loops
|
||||
verifyResult = VerifyResultSkip
|
||||
}
|
||||
|
||||
return verifyResult, verifyState, err
|
||||
}
|
||||
|
||||
func (d *RequestData) EvaluateChallenges(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
challengeMap, err := d.verifyChallengeState()
|
||||
if err != nil {
|
||||
if !errors.Is(err, http.ErrNoCookie) {
|
||||
//clear invalid cookie and continue
|
||||
utils.ClearCookie(d.cookieName, w, r)
|
||||
}
|
||||
challengeMap = make(TokenChallengeMap)
|
||||
}
|
||||
d.ChallengeMap = challengeMap
|
||||
|
||||
for _, reg := range d.State.GetChallenges() {
|
||||
|
||||
key := GetChallengeKeyForRequest(d.State, reg, d.Expiration(reg.Duration), r)
|
||||
verifyResult, verifyState, err := d.verifyChallenge(reg, key)
|
||||
if err != nil {
|
||||
// clear invalid state
|
||||
d.ClearChallengeToken(reg)
|
||||
}
|
||||
|
||||
d.ChallengeVerify[reg.Id()] = verifyResult
|
||||
d.ChallengeState[reg.Id()] = verifyState
|
||||
}
|
||||
}
|
||||
|
||||
func (d *RequestData) Expiration(duration time.Duration) time.Time {
|
||||
return d.Time.Add(duration).Round(duration)
|
||||
}
|
||||
|
||||
func (d *RequestData) HasValidChallenge(id Id) bool {
|
||||
return d.ChallengeVerify[id].Ok()
|
||||
}
|
||||
|
||||
func (d *RequestData) ResponseHeaders(w http.ResponseWriter) {
|
||||
// send these to client so we consistently get the headers
|
||||
//w.Header().Set("Accept-CH", "Sec-CH-UA, Sec-CH-UA-Platform")
|
||||
//w.Header().Set("Critical-CH", "Sec-CH-UA, Sec-CH-UA-Platform")
|
||||
|
||||
if d.State.Settings().MainName != "" {
|
||||
w.Header().Add("Via", fmt.Sprintf("%s %s@%s", d.r.Proto, d.State.Settings().MainName, d.State.Settings().MainVersion))
|
||||
}
|
||||
|
||||
if d.challengeMapModified {
|
||||
expiration := d.Expiration(DefaultDuration)
|
||||
if token, err := d.issueChallengeState(expiration); err == nil {
|
||||
utils.SetCookie(d.cookieName, token, expiration, w, d.r)
|
||||
} else {
|
||||
d.State.Logger(d.r).Error("error while issuing cookie", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *RequestData) RequestHeaders(headers http.Header) {
|
||||
headers.Set("X-Away-Id", d.Id.String())
|
||||
|
||||
if d.State.Settings().BackendIpHeader != "" {
|
||||
if d.State.Settings().ClientIpHeader != "" {
|
||||
headers.Del(d.State.Settings().ClientIpHeader)
|
||||
}
|
||||
headers.Set(d.State.Settings().BackendIpHeader, d.RemoteAddress.String())
|
||||
}
|
||||
|
||||
for id, result := range d.ChallengeVerify {
|
||||
if result.Ok() {
|
||||
c, ok := d.State.GetChallenge(id)
|
||||
if !ok {
|
||||
panic("challenge not found")
|
||||
}
|
||||
|
||||
headers.Set(fmt.Sprintf("X-Away-Challenge-%s-Result", c.Name), result.String())
|
||||
headers.Set(fmt.Sprintf("X-Away-Challenge-%s-State", c.Name), d.ChallengeState[id].String())
|
||||
}
|
||||
}
|
||||
|
||||
if ja4, ok := d.fp["fp4"]; ok {
|
||||
headers.Set("X-TLS-Fingerprint-JA4", ja4)
|
||||
}
|
||||
|
||||
if ja3n, ok := d.fp["ja3n"]; ok {
|
||||
headers.Set("X-TLS-Fingerprint-JA3N", ja3n)
|
||||
}
|
||||
|
||||
maps.Copy(headers, d.ExtraHeaders)
|
||||
}
|
||||
|
||||
type Token struct {
|
||||
State TokenChallengeMap `json:"state"`
|
||||
|
||||
Expiry jwt.NumericDate `json:"exp,omitempty"`
|
||||
NotBefore jwt.NumericDate `json:"nbf,omitempty"`
|
||||
IssuedAt jwt.NumericDate `json:"iat,omitempty"`
|
||||
}
|
||||
|
||||
type TokenChallengeMap map[string]TokenChallenge
|
||||
|
||||
type TokenChallenge struct {
|
||||
Key []byte `json:"key"`
|
||||
Result []byte `json:"result,omitempty"`
|
||||
Ok bool `json:"ok"`
|
||||
|
||||
Expiry jwt.NumericDate `json:"exp,omitempty"`
|
||||
NotBefore jwt.NumericDate `json:"nbf,omitempty"`
|
||||
IssuedAt jwt.NumericDate `json:"iat,omitempty"`
|
||||
}
|
||||
|
||||
func (d *RequestData) verifyChallengeStateCookie(cookie *http.Cookie) (TokenChallengeMap, error) {
|
||||
cookie, err := d.r.Cookie(d.cookieName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cookie == nil {
|
||||
return nil, http.ErrNoCookie
|
||||
}
|
||||
encryptedToken, err := jwt.ParseSignedAndEncrypted(cookie.Value,
|
||||
[]jose.KeyAlgorithm{jose.DIRECT},
|
||||
[]jose.ContentEncryption{jose.A256GCM},
|
||||
[]jose.SignatureAlgorithm{jose.EdDSA},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
signedToken, err := encryptedToken.Decrypt(d.cookieKey())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var i Token
|
||||
err = signedToken.Claims(d.State.PublicKey(), &i)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if i.Expiry.Time().Compare(time.Now()) < 0 {
|
||||
return nil, ErrTokenExpired
|
||||
}
|
||||
if i.NotBefore.Time().Compare(time.Now()) > 0 {
|
||||
return nil, errors.New("token not valid yet")
|
||||
}
|
||||
|
||||
return i.State, nil
|
||||
}
|
||||
|
||||
func (d *RequestData) verifyChallengeState() (state TokenChallengeMap, err error) {
|
||||
cookies := d.r.CookiesNamed(d.cookieName)
|
||||
if len(cookies) == 0 {
|
||||
return nil, http.ErrNoCookie
|
||||
}
|
||||
for _, cookie := range cookies {
|
||||
state, err = d.verifyChallengeStateCookie(cookie)
|
||||
if err == nil {
|
||||
return state, nil
|
||||
}
|
||||
}
|
||||
return state, err
|
||||
}
|
||||
|
||||
func (d *RequestData) issueChallengeState(until time.Time) (string, error) {
|
||||
signer, err := jose.NewSigner(jose.SigningKey{
|
||||
Algorithm: jose.EdDSA,
|
||||
Key: d.State.PrivateKey(),
|
||||
}, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
encrypter, err := jose.NewEncrypter(jose.A256GCM, jose.Recipient{
|
||||
Algorithm: jose.DIRECT,
|
||||
Key: d.cookieKey(),
|
||||
}, (&jose.EncrypterOptions{
|
||||
Compression: jose.DEFLATE,
|
||||
}).WithContentType("JWT"))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return jwt.SignedAndEncrypted(signer, encrypter).Claims(Token{
|
||||
State: d.ChallengeMap,
|
||||
Expiry: jwt.NumericDate(until.Unix()),
|
||||
NotBefore: jwt.NumericDate(time.Now().UTC().AddDate(0, 0, -1).Unix()),
|
||||
IssuedAt: jwt.NumericDate(time.Now().UTC().Unix()),
|
||||
}).Serialize()
|
||||
}
|
||||
|
||||
func (d *RequestData) cookieKey() []byte {
|
||||
sum := sha256.New()
|
||||
sum.Write([]byte(d.r.Host))
|
||||
sum.Write([]byte{0})
|
||||
sum.Write(d.NetworkPrefix().AsSlice())
|
||||
sum.Write([]byte{0})
|
||||
sum.Write(d.State.PrivateKey())
|
||||
sum.Write([]byte{0})
|
||||
// version/compressor
|
||||
sum.Write([]byte("1.0/DEFLATE"))
|
||||
sum.Write([]byte{0})
|
||||
|
||||
return sum.Sum(nil)
|
||||
}
|
||||
|
||||
func (d *RequestData) cookieHostKey() []byte {
|
||||
sum := sha256.New()
|
||||
sum.Write([]byte(d.r.Host))
|
||||
sum.Write([]byte{0})
|
||||
sum.Write(d.NetworkPrefix().AsSlice())
|
||||
sum.Write([]byte{0})
|
||||
|
||||
return sum.Sum(nil)[:6]
|
||||
}
|
||||
137
lib/challenge/dnsbl/dnsbl.go
Normal file
137
lib/challenge/dnsbl/dnsbl.go
Normal file
@@ -0,0 +1,137 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"git.gammaspectra.live/git/go-away/lib/challenge"
|
||||
"git.gammaspectra.live/git/go-away/utils"
|
||||
"github.com/goccy/go-yaml"
|
||||
"github.com/goccy/go-yaml/ast"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
func init() {
|
||||
challenge.Runtimes[Key] = FillRegistration
|
||||
}
|
||||
|
||||
const Key = "dnsbl"
|
||||
|
||||
type Parameters struct {
|
||||
VerifyProbability float64 `yaml:"verify-probability"`
|
||||
Host string `yaml:"dnsbl-host"`
|
||||
Timeout time.Duration `yaml:"dnsbl-timeout"`
|
||||
Decay time.Duration `yaml:"dnsbl-decay"`
|
||||
}
|
||||
|
||||
var DefaultParameters = Parameters{
|
||||
VerifyProbability: 0.10,
|
||||
Timeout: time.Second * 1,
|
||||
Decay: time.Hour * 1,
|
||||
Host: "dnsbl.dronebl.org",
|
||||
}
|
||||
|
||||
func lookup(ctx context.Context, decay, timeout time.Duration, dnsbl *utils.DNSBL, decayMap *utils.DecayMap[[net.IPv6len]byte, utils.DNSBLResponse], ip net.IP) (utils.DNSBLResponse, error) {
|
||||
var key [net.IPv6len]byte
|
||||
copy(key[:], ip.To16())
|
||||
|
||||
result, ok := decayMap.Get(key)
|
||||
if ok {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
result, err := dnsbl.Lookup(ctx, ip)
|
||||
if err != nil {
|
||||
|
||||
}
|
||||
decayMap.Set(key, result, decay)
|
||||
|
||||
return result, err
|
||||
}
|
||||
|
||||
type closer chan struct{}
|
||||
|
||||
func (c closer) Close() error {
|
||||
select {
|
||||
case <-c:
|
||||
default:
|
||||
close(c)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func FillRegistration(state challenge.StateInterface, reg *challenge.Registration, parameters ast.Node) error {
|
||||
params := DefaultParameters
|
||||
|
||||
if parameters != nil {
|
||||
ymlData, err := parameters.MarshalYAML()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = yaml.Unmarshal(ymlData, ¶ms)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if params.Host == "" {
|
||||
return errors.New("empty host")
|
||||
}
|
||||
|
||||
reg.Class = challenge.ClassTransparent
|
||||
|
||||
if params.VerifyProbability <= 0 {
|
||||
//20% default
|
||||
params.VerifyProbability = 0.20
|
||||
} else if params.VerifyProbability > 1.0 {
|
||||
params.VerifyProbability = 1.0
|
||||
}
|
||||
reg.VerifyProbability = params.VerifyProbability
|
||||
|
||||
decayMap := utils.NewDecayMap[[net.IPv6len]byte, utils.DNSBLResponse]()
|
||||
|
||||
dnsbl := utils.NewDNSBL(params.Host, &net.Resolver{
|
||||
PreferGo: true,
|
||||
})
|
||||
|
||||
ob := make(closer)
|
||||
|
||||
go func() {
|
||||
ticker := time.NewTicker(params.Timeout / 3)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
decayMap.Decay()
|
||||
case <-ob:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// allow freeing the ticker/decay map
|
||||
reg.Object = ob
|
||||
|
||||
reg.IssueChallenge = func(w http.ResponseWriter, r *http.Request, key challenge.Key, expiry time.Time) challenge.VerifyResult {
|
||||
|
||||
data := challenge.RequestDataFromContext(r.Context())
|
||||
|
||||
result, err := lookup(r.Context(), params.Decay, params.Timeout, dnsbl, decayMap, data.RemoteAddress.Addr().Unmap().AsSlice())
|
||||
if err != nil {
|
||||
data.State.Logger(r).Debug("dnsbl lookup failed", "address", data.RemoteAddress.Addr().String(), "result", result, "err", err)
|
||||
}
|
||||
|
||||
if result.Bad() {
|
||||
data.IssueChallengeToken(reg, key, nil, expiry, false)
|
||||
return challenge.VerifyResultNotOK
|
||||
} else {
|
||||
data.IssueChallengeToken(reg, key, nil, expiry, true)
|
||||
return challenge.VerifyResultOK
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
199
lib/challenge/helper.go
Normal file
199
lib/challenge/helper.go
Normal file
@@ -0,0 +1,199 @@
|
||||
package challenge
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"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
|
||||
}
|
||||
|
||||
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[:])
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
QueryArgPrefix = "__goaway"
|
||||
QueryArgReferer = QueryArgPrefix + "_referer"
|
||||
QueryArgRedirect = QueryArgPrefix + "_redirect"
|
||||
QueryArgRequestId = QueryArgPrefix + "_id"
|
||||
QueryArgChallenge = QueryArgPrefix + "_challenge"
|
||||
QueryArgToken = QueryArgPrefix + "_token"
|
||||
)
|
||||
|
||||
const MakeChallengeUrlSuffix = "/make-challenge"
|
||||
const VerifyChallengeUrlSuffix = "/verify-challenge"
|
||||
|
||||
func GetVerifyInformation(r *http.Request, reg *Registration) (requestId RequestId, redirect, token string, err error) {
|
||||
|
||||
q := r.URL.Query()
|
||||
|
||||
if q.Get(QueryArgChallenge) != reg.Name {
|
||||
return RequestId{}, "", "", fmt.Errorf("unexpected challenge: got \"%s\"", q.Get(QueryArgChallenge))
|
||||
}
|
||||
|
||||
requestIdHex := q.Get(QueryArgRequestId)
|
||||
|
||||
if len(requestId) != hex.DecodedLen(len(requestIdHex)) {
|
||||
return RequestId{}, "", "", errors.New("invalid request id")
|
||||
}
|
||||
n, err := hex.Decode(requestId[:], []byte(requestIdHex))
|
||||
if err != nil {
|
||||
return RequestId{}, "", "", err
|
||||
} else if n != len(requestId) {
|
||||
return RequestId{}, "", "", errors.New("invalid request id")
|
||||
}
|
||||
|
||||
token = q.Get(QueryArgToken)
|
||||
redirect, err = utils.EnsureNoOpenRedirect(q.Get(QueryArgRedirect))
|
||||
if err != nil {
|
||||
return RequestId{}, "", "", err
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func VerifyUrl(r *http.Request, reg *Registration, token string) (*url.URL, error) {
|
||||
|
||||
redirectUrl, err := RedirectUrl(r, reg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
uri := new(url.URL)
|
||||
uri.Path = reg.Path + VerifyChallengeUrlSuffix
|
||||
|
||||
data := RequestDataFromContext(r.Context())
|
||||
values := uri.Query()
|
||||
values.Set(QueryArgRequestId, data.Id.String())
|
||||
values.Set(QueryArgRedirect, redirectUrl.String())
|
||||
values.Set(QueryArgToken, token)
|
||||
values.Set(QueryArgChallenge, reg.Name)
|
||||
uri.RawQuery = values.Encode()
|
||||
|
||||
return uri, nil
|
||||
}
|
||||
|
||||
func RedirectUrl(r *http.Request, reg *Registration) (*url.URL, error) {
|
||||
uri, err := url.ParseRequestURI(r.URL.String())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
data := RequestDataFromContext(r.Context())
|
||||
values := uri.Query()
|
||||
values.Set(QueryArgRequestId, data.Id.String())
|
||||
if ref := r.Referer(); ref != "" {
|
||||
values.Set(QueryArgReferer, r.Referer())
|
||||
}
|
||||
values.Set(QueryArgChallenge, reg.Name)
|
||||
uri.RawQuery = values.Encode()
|
||||
|
||||
return uri, nil
|
||||
}
|
||||
|
||||
func VerifyHandlerChallengeResponseFunc(state StateInterface, data *RequestData, w http.ResponseWriter, r *http.Request, verifyResult VerifyResult, err error, redirect string) {
|
||||
if err != nil {
|
||||
// Happy Eyeballs! auto retry
|
||||
if errors.Is(err, ErrMismatchedTokenHappyEyeballs) {
|
||||
reqUri := *r.URL
|
||||
q := reqUri.Query()
|
||||
|
||||
ref := q.Get(QueryArgReferer)
|
||||
// delete query parameters that were set by go-away
|
||||
for k := range q {
|
||||
if strings.HasPrefix(k, QueryArgPrefix) {
|
||||
q.Del(k)
|
||||
}
|
||||
}
|
||||
if ref != "" {
|
||||
q.Set(QueryArgReferer, ref)
|
||||
}
|
||||
reqUri.RawQuery = q.Encode()
|
||||
|
||||
data.ResponseHeaders(w)
|
||||
|
||||
http.Redirect(w, r, reqUri.String(), http.StatusTemporaryRedirect)
|
||||
return
|
||||
}
|
||||
state.ErrorPage(w, r, http.StatusBadRequest, err, redirect)
|
||||
return
|
||||
} else if !verifyResult.Ok() {
|
||||
state.ErrorPage(w, r, http.StatusForbidden, fmt.Errorf("access denied: failed challenge"), redirect)
|
||||
return
|
||||
}
|
||||
data.ResponseHeaders(w)
|
||||
http.Redirect(w, r, redirect, http.StatusTemporaryRedirect)
|
||||
}
|
||||
|
||||
func VerifyHandlerFunc(state StateInterface, reg *Registration, verify VerifyFunc, responseFunc func(state StateInterface, data *RequestData, w http.ResponseWriter, r *http.Request, verifyResult VerifyResult, err error, redirect string)) http.HandlerFunc {
|
||||
if verify == nil {
|
||||
verify = reg.Verify
|
||||
}
|
||||
if responseFunc == nil {
|
||||
responseFunc = VerifyHandlerChallengeResponseFunc
|
||||
}
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
data := RequestDataFromContext(r.Context())
|
||||
requestId, redirect, token, err := GetVerifyInformation(r, reg)
|
||||
if err != nil {
|
||||
state.ChallengeFailed(r, reg, err, "", nil)
|
||||
responseFunc(state, data, w, r, VerifyResultFail, fmt.Errorf("internal error: %w", err), "")
|
||||
return
|
||||
}
|
||||
data.Id = requestId
|
||||
|
||||
err = func() (err error) {
|
||||
expiration := data.Expiration(reg.Duration)
|
||||
key := GetChallengeKeyForRequest(state, reg, expiration, r)
|
||||
|
||||
verifyResult, err := verify(key, []byte(token), r)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if !verifyResult.Ok() {
|
||||
state.ChallengeFailed(r, reg, nil, redirect, nil)
|
||||
responseFunc(state, data, w, r, verifyResult, nil, redirect)
|
||||
return nil
|
||||
}
|
||||
|
||||
data.IssueChallengeToken(reg, key, []byte(token), expiration, true)
|
||||
data.ChallengeVerify[reg.id] = verifyResult
|
||||
state.ChallengePassed(r, reg, redirect, nil)
|
||||
|
||||
responseFunc(state, data, w, r, verifyResult, nil, redirect)
|
||||
return nil
|
||||
}()
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
151
lib/challenge/http/http.go
Normal file
151
lib/challenge/http/http.go
Normal file
@@ -0,0 +1,151 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
"git.gammaspectra.live/git/go-away/lib/challenge"
|
||||
"github.com/goccy/go-yaml"
|
||||
"github.com/goccy/go-yaml/ast"
|
||||
"io"
|
||||
"net/http"
|
||||
"slices"
|
||||
"time"
|
||||
)
|
||||
|
||||
func init() {
|
||||
challenge.Runtimes[Key] = FillRegistration
|
||||
}
|
||||
|
||||
const Key = "http"
|
||||
|
||||
type Parameters struct {
|
||||
VerifyProbability float64 `yaml:"verify-probability"`
|
||||
|
||||
HttpMethod string `yaml:"http-method"`
|
||||
HttpCode int `yaml:"http-code"`
|
||||
HttpCookie string `yaml:"http-cookie"`
|
||||
Url string `yaml:"http-url"`
|
||||
}
|
||||
|
||||
var DefaultParameters = Parameters{
|
||||
VerifyProbability: 0.20,
|
||||
HttpMethod: http.MethodGet,
|
||||
HttpCode: http.StatusOK,
|
||||
}
|
||||
|
||||
func FillRegistration(state challenge.StateInterface, reg *challenge.Registration, parameters ast.Node) error {
|
||||
params := DefaultParameters
|
||||
|
||||
if parameters != nil {
|
||||
ymlData, err := parameters.MarshalYAML()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = yaml.Unmarshal(ymlData, ¶ms)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if params.Url == "" {
|
||||
return errors.New("empty url")
|
||||
}
|
||||
|
||||
reg.Class = challenge.ClassTransparent
|
||||
|
||||
bindAuthValue := func(key challenge.Key, r *http.Request) ([]byte, error) {
|
||||
var cookieValue string
|
||||
if cookie, err := r.Cookie(params.HttpCookie); err != nil || cookie == nil {
|
||||
// skip check if we don't have cookie or it's expired
|
||||
return nil, http.ErrNoCookie
|
||||
} else {
|
||||
cookieValue = cookie.Value
|
||||
}
|
||||
|
||||
// bind hash of cookie contents
|
||||
sum := sha256.New()
|
||||
sum.Write([]byte(cookieValue))
|
||||
sum.Write([]byte{0})
|
||||
sum.Write(key[:])
|
||||
return sum.Sum(nil), nil
|
||||
}
|
||||
|
||||
if params.VerifyProbability <= 0 {
|
||||
//20% default
|
||||
params.VerifyProbability = 0.20
|
||||
} else if params.VerifyProbability > 1.0 {
|
||||
params.VerifyProbability = 1.0
|
||||
}
|
||||
reg.VerifyProbability = params.VerifyProbability
|
||||
|
||||
if params.HttpCookie != "" {
|
||||
// re-verify the cookie value
|
||||
// TODO: configure to verify with backend
|
||||
reg.Verify = func(key challenge.Key, token []byte, r *http.Request) (challenge.VerifyResult, error) {
|
||||
sum, err := bindAuthValue(key, r)
|
||||
if err != nil {
|
||||
return challenge.VerifyResultFail, err
|
||||
}
|
||||
if subtle.ConstantTimeCompare(sum, token) == 1 {
|
||||
return challenge.VerifyResultOK, nil
|
||||
}
|
||||
return challenge.VerifyResultFail, errors.New("invalid cookie value")
|
||||
}
|
||||
}
|
||||
|
||||
reg.IssueChallenge = func(w http.ResponseWriter, r *http.Request, key challenge.Key, expiry time.Time) challenge.VerifyResult {
|
||||
var sum []byte
|
||||
if params.HttpCookie != "" {
|
||||
if c, err := r.Cookie(params.HttpCookie); err != nil || c == nil {
|
||||
// skip check if we don't have cookie or it's expired
|
||||
return challenge.VerifyResultSkip
|
||||
} else {
|
||||
sum, err = bindAuthValue(key, r)
|
||||
if err != nil {
|
||||
return challenge.VerifyResultFail
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
request, err := http.NewRequest(params.HttpMethod, params.Url, nil)
|
||||
if err != nil {
|
||||
return challenge.VerifyResultFail
|
||||
}
|
||||
|
||||
var excludeHeaders = []string{"Host", "Content-Length"}
|
||||
for k, v := range r.Header {
|
||||
if slices.Contains(excludeHeaders, k) {
|
||||
// skip these parameters
|
||||
continue
|
||||
}
|
||||
request.Header[k] = v
|
||||
}
|
||||
// set id
|
||||
request.Header.Set("X-Away-Id", challenge.RequestDataFromContext(r.Context()).Id.String())
|
||||
|
||||
// set request info in X headers
|
||||
request.Header.Set("X-Away-Host", r.Host)
|
||||
request.Header.Set("X-Away-Path", r.URL.Path)
|
||||
request.Header.Set("X-Away-Query", r.URL.RawQuery)
|
||||
|
||||
response, err := state.Client().Do(request)
|
||||
if err != nil {
|
||||
return challenge.VerifyResultFail
|
||||
}
|
||||
defer response.Body.Close()
|
||||
defer io.Copy(io.Discard, response.Body)
|
||||
|
||||
data := challenge.RequestDataFromContext(r.Context())
|
||||
|
||||
if response.StatusCode != params.HttpCode {
|
||||
data.IssueChallengeToken(reg, key, sum, expiry, false)
|
||||
return challenge.VerifyResultNotOK
|
||||
} else {
|
||||
data.IssueChallengeToken(reg, key, sum, expiry, true)
|
||||
return challenge.VerifyResultOK
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
81
lib/challenge/key.go
Normal file
81
lib/challenge/key.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package challenge
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Key [KeySize]byte
|
||||
|
||||
const KeySize = sha256.Size
|
||||
|
||||
func (k *Key) Set(flags KeyFlags) {
|
||||
(*k)[0] |= uint8(flags)
|
||||
}
|
||||
func (k *Key) Get(flags KeyFlags) KeyFlags {
|
||||
return KeyFlags((*k)[0] & uint8(flags))
|
||||
}
|
||||
func (k *Key) Unset(flags KeyFlags) {
|
||||
(*k)[0] = (*k)[0] & ^(uint8(flags))
|
||||
}
|
||||
|
||||
type KeyFlags uint8
|
||||
|
||||
const (
|
||||
KeyFlagIsIPv4 = KeyFlags(1 << iota)
|
||||
)
|
||||
|
||||
func KeyFromString(s string) (Key, error) {
|
||||
b, err := hex.DecodeString(s)
|
||||
if err != nil {
|
||||
return Key{}, err
|
||||
}
|
||||
if len(b) != KeySize {
|
||||
return Key{}, errors.New("invalid challenge key")
|
||||
}
|
||||
return Key(b), nil
|
||||
}
|
||||
|
||||
func GetChallengeKeyForRequest(state StateInterface, reg *Registration, until time.Time, r *http.Request) Key {
|
||||
data := RequestDataFromContext(r.Context())
|
||||
|
||||
hasher := sha256.New()
|
||||
hasher.Write([]byte("challenge\x00"))
|
||||
hasher.Write([]byte(reg.Name))
|
||||
hasher.Write([]byte{0})
|
||||
keyAddr := data.NetworkPrefix().As16()
|
||||
hasher.Write(keyAddr[:])
|
||||
hasher.Write([]byte{0})
|
||||
|
||||
// specific headers
|
||||
for _, k := range []string{
|
||||
"Accept-Language",
|
||||
// General browser information
|
||||
"User-Agent",
|
||||
// TODO: not sent in preload
|
||||
//"Sec-Ch-Ua",
|
||||
//"Sec-Ch-Ua-Platform",
|
||||
} {
|
||||
hasher.Write([]byte(r.Header.Get(k)))
|
||||
hasher.Write([]byte{0})
|
||||
}
|
||||
hasher.Write([]byte{0})
|
||||
_ = binary.Write(hasher, binary.LittleEndian, until.UTC().Unix())
|
||||
hasher.Write([]byte{0})
|
||||
hasher.Write(state.PrivateKeyFingerprint())
|
||||
hasher.Write([]byte{0})
|
||||
|
||||
sum := Key(hasher.Sum(nil))
|
||||
|
||||
sum[0] = 0
|
||||
|
||||
if data.RemoteAddress.Addr().Unmap().Is4() {
|
||||
// Is IPv4, mark
|
||||
sum.Set(KeyFlagIsIPv4)
|
||||
}
|
||||
return Key(sum)
|
||||
}
|
||||
131
lib/challenge/preload-link/preload-link.go
Normal file
131
lib/challenge/preload-link/preload-link.go
Normal file
@@ -0,0 +1,131 @@
|
||||
package preload_link
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"git.gammaspectra.live/git/go-away/lib/challenge"
|
||||
"git.gammaspectra.live/git/go-away/utils"
|
||||
"github.com/goccy/go-yaml"
|
||||
"github.com/goccy/go-yaml/ast"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
func init() {
|
||||
challenge.Runtimes[Key] = FillRegistration
|
||||
}
|
||||
|
||||
const Key = "preload-link"
|
||||
|
||||
type Parameters struct {
|
||||
Deadline time.Duration `yaml:"preload-early-hint-deadline"`
|
||||
}
|
||||
|
||||
var DefaultParameters = Parameters{
|
||||
Deadline: time.Second * 2,
|
||||
}
|
||||
|
||||
func FillRegistration(state challenge.StateInterface, reg *challenge.Registration, parameters ast.Node) error {
|
||||
params := DefaultParameters
|
||||
|
||||
if parameters != nil {
|
||||
ymlData, err := parameters.MarshalYAML()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = yaml.Unmarshal(ymlData, ¶ms)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
verifier, issuer := challenge.NewKeyVerifier()
|
||||
reg.Verify = verifier
|
||||
|
||||
reg.Class = challenge.ClassTransparent
|
||||
|
||||
ob := challenge.NewAwaiter[string]()
|
||||
|
||||
reg.Object = ob
|
||||
|
||||
reg.IssueChallenge = func(w http.ResponseWriter, r *http.Request, key challenge.Key, expiry time.Time) challenge.VerifyResult {
|
||||
// this only works on HTTP/2 and HTTP/3
|
||||
|
||||
if r.ProtoMajor < 2 {
|
||||
// this can happen if we are an upgraded request from HTTP/1.1 to HTTP/2 in H2C
|
||||
if _, ok := w.(http.Pusher); !ok {
|
||||
return challenge.VerifyResultSkip
|
||||
}
|
||||
}
|
||||
|
||||
issuerKey := issuer(key)
|
||||
|
||||
uri, err := challenge.VerifyUrl(r, reg, issuerKey)
|
||||
if err != nil {
|
||||
return challenge.VerifyResultFail
|
||||
}
|
||||
|
||||
// remove redirect args
|
||||
values := uri.Query()
|
||||
values.Del(challenge.QueryArgRedirect)
|
||||
uri.RawQuery = values.Encode()
|
||||
|
||||
// Redirect URI must be absolute to work
|
||||
uri.Scheme = utils.GetRequestScheme(r)
|
||||
uri.Host = r.Host
|
||||
|
||||
w.Header().Set("Link", fmt.Sprintf("<%s>; rel=\"preload\"; as=\"style\"; fetchpriority=high", uri.String()))
|
||||
defer func() {
|
||||
// remove old header so it won't show on response!
|
||||
w.Header().Del("Link")
|
||||
}()
|
||||
w.WriteHeader(http.StatusEarlyHints)
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), params.Deadline)
|
||||
defer cancel()
|
||||
if result := ob.Await(issuerKey, ctx); result.Ok() {
|
||||
// this should serve!
|
||||
return challenge.VerifyResultOK
|
||||
} else if result == challenge.VerifyResultNone {
|
||||
// we hit timeout
|
||||
return challenge.VerifyResultFail
|
||||
} else {
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.HandleFunc("GET "+reg.Path+challenge.VerifyChallengeUrlSuffix, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/css; charset=utf-8")
|
||||
w.Header().Set("Content-Length", "0")
|
||||
|
||||
data := challenge.RequestDataFromContext(r.Context())
|
||||
key := challenge.GetChallengeKeyForRequest(state, reg, data.Expiration(reg.Duration), r)
|
||||
issuerKey := issuer(key)
|
||||
|
||||
_, _, token, err := challenge.GetVerifyInformation(r, reg)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
}
|
||||
|
||||
verifyResult, _ := verifier(key, []byte(token), r)
|
||||
|
||||
data.ResponseHeaders(w)
|
||||
|
||||
if !verifyResult.Ok() {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
} else {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
ob.Solve(issuerKey, verifyResult)
|
||||
if !verifyResult.Ok() {
|
||||
// also give data on other failure when mismatched
|
||||
ob.Solve(token, verifyResult)
|
||||
}
|
||||
})
|
||||
reg.Handler = mux
|
||||
|
||||
return nil
|
||||
}
|
||||
80
lib/challenge/refresh/refresh.go
Normal file
80
lib/challenge/refresh/refresh.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package refresh
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"git.gammaspectra.live/git/go-away/lib/challenge"
|
||||
"github.com/goccy/go-yaml"
|
||||
"github.com/goccy/go-yaml/ast"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
func init() {
|
||||
challenge.Runtimes["refresh"] = FillRegistration
|
||||
}
|
||||
|
||||
type Parameters struct {
|
||||
Mode string `yaml:"refresh-via"`
|
||||
}
|
||||
|
||||
var DefaultParameters = Parameters{
|
||||
Mode: "header",
|
||||
}
|
||||
|
||||
func FillRegistration(state challenge.StateInterface, reg *challenge.Registration, parameters ast.Node) error {
|
||||
params := DefaultParameters
|
||||
|
||||
if parameters != nil {
|
||||
ymlData, err := parameters.MarshalYAML()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = yaml.Unmarshal(ymlData, ¶ms)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
reg.Class = challenge.ClassBlocking
|
||||
|
||||
verifier, issuer := challenge.NewKeyVerifier()
|
||||
reg.Verify = verifier
|
||||
|
||||
reg.IssueChallenge = func(w http.ResponseWriter, r *http.Request, key challenge.Key, expiry time.Time) challenge.VerifyResult {
|
||||
uri, err := challenge.VerifyUrl(r, reg, issuer(key))
|
||||
if err != nil {
|
||||
return challenge.VerifyResultFail
|
||||
}
|
||||
|
||||
if params.Mode == "javascript" {
|
||||
data, err := json.Marshal(uri.String())
|
||||
if err != nil {
|
||||
return challenge.VerifyResultFail
|
||||
}
|
||||
state.ChallengePage(w, r, state.Settings().ChallengeResponseCode, reg, map[string]any{
|
||||
"EndTags": []template.HTML{
|
||||
template.HTML(fmt.Sprintf("<script type=\"text/javascript\">window.location = %s;</script>", string(data))),
|
||||
},
|
||||
})
|
||||
} else if params.Mode == "meta" {
|
||||
state.ChallengePage(w, r, state.Settings().ChallengeResponseCode, reg, map[string]any{
|
||||
"MetaTags": []map[string]string{
|
||||
{
|
||||
"http-equiv": "refresh",
|
||||
"content": "0; url=" + uri.String(),
|
||||
},
|
||||
},
|
||||
})
|
||||
} else {
|
||||
// self redirect!
|
||||
w.Header().Set("Refresh", "0; url="+uri.String())
|
||||
|
||||
state.ChallengePage(w, r, state.Settings().ChallengeResponseCode, reg, nil)
|
||||
}
|
||||
return challenge.VerifyResultNone
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
148
lib/challenge/register.go
Normal file
148
lib/challenge/register.go
Normal file
@@ -0,0 +1,148 @@
|
||||
package challenge
|
||||
|
||||
import (
|
||||
http_cel "codeberg.org/gone/http-cel"
|
||||
"fmt"
|
||||
"git.gammaspectra.live/git/go-away/lib/policy"
|
||||
"github.com/goccy/go-yaml/ast"
|
||||
"github.com/google/cel-go/cel"
|
||||
"io"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Register map[Id]*Registration
|
||||
|
||||
func (r Register) Get(id Id) (*Registration, bool) {
|
||||
c, ok := r[id]
|
||||
return c, ok
|
||||
}
|
||||
|
||||
func (r Register) GetByName(name string) (*Registration, Id, bool) {
|
||||
for id, c := range r {
|
||||
if c.Name == name {
|
||||
return c, id, true
|
||||
}
|
||||
}
|
||||
|
||||
return nil, 0, false
|
||||
}
|
||||
|
||||
var idCounter Id
|
||||
|
||||
// DefaultDuration TODO: adjust
|
||||
const DefaultDuration = time.Hour * 24 * 7
|
||||
|
||||
func (r Register) Create(state StateInterface, name string, pol policy.Challenge, replacer *strings.Replacer) (*Registration, Id, error) {
|
||||
runtime, ok := Runtimes[pol.Runtime]
|
||||
if !ok {
|
||||
return nil, 0, fmt.Errorf("unknown challenge runtime %s", pol.Runtime)
|
||||
}
|
||||
|
||||
reg := &Registration{
|
||||
Name: name,
|
||||
Path: path.Join(state.UrlPath(), "challenge", name),
|
||||
Duration: pol.Duration,
|
||||
}
|
||||
|
||||
if reg.Duration == 0 {
|
||||
reg.Duration = DefaultDuration
|
||||
}
|
||||
|
||||
// allow nesting
|
||||
var conditions []string
|
||||
for _, cond := range pol.Conditions {
|
||||
if replacer != nil {
|
||||
cond = replacer.Replace(cond)
|
||||
}
|
||||
conditions = append(conditions, cond)
|
||||
}
|
||||
|
||||
if len(conditions) > 0 {
|
||||
var err error
|
||||
reg.Condition, err = state.RegisterCondition(http_cel.OperatorOr, conditions...)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("error compiling condition: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if _, oldId, ok := r.GetByName(reg.Name); ok {
|
||||
reg.id = oldId
|
||||
} else {
|
||||
idCounter++
|
||||
reg.id = idCounter
|
||||
}
|
||||
|
||||
err := runtime(state, reg, pol.Parameters)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("error filling registration: %v", err)
|
||||
}
|
||||
r[reg.id] = reg
|
||||
return reg, reg.id, nil
|
||||
}
|
||||
|
||||
func (r Register) Add(c *Registration) Id {
|
||||
if _, oldId, ok := r.GetByName(c.Name); ok {
|
||||
c.id = oldId
|
||||
r[oldId] = c
|
||||
return oldId
|
||||
} else {
|
||||
idCounter++
|
||||
c.id = idCounter
|
||||
r[idCounter] = c
|
||||
return idCounter
|
||||
}
|
||||
}
|
||||
|
||||
type Registration struct {
|
||||
// id The assigned internal identifier
|
||||
id Id
|
||||
|
||||
// Name The unique name for this challenge
|
||||
Name string
|
||||
|
||||
// Class whether this challenge is transparent or otherwise
|
||||
Class Class
|
||||
|
||||
// Condition A CEL condition which is passed the same environment as general rules.
|
||||
// If nil, always true
|
||||
// If non-nil, must return true for this challenge to be allowed to be executed
|
||||
Condition cel.Program
|
||||
|
||||
// Path The url path that this challenge is hosted under for the Handler to be called.
|
||||
Path string
|
||||
|
||||
// Duration How long this challenge will be valid when passed
|
||||
Duration time.Duration
|
||||
|
||||
// Handler An HTTP handler for all requests coming on the Path
|
||||
// This handler will need to handle MakeChallengeUrlSuffix and VerifyChallengeUrlSuffix as well if needed
|
||||
// Recommended to use http.ServeMux
|
||||
Handler http.Handler
|
||||
|
||||
// Verify Verify an issued token
|
||||
Verify VerifyFunc
|
||||
VerifyProbability float64
|
||||
|
||||
// IssueChallenge Issues a challenge to a request.
|
||||
// If Class is ClassTransparent and VerifyResult is !VerifyResult.Ok(), continue with other challenges
|
||||
// TODO: have this return error as well
|
||||
IssueChallenge func(w http.ResponseWriter, r *http.Request, key Key, expiry time.Time) VerifyResult
|
||||
|
||||
// Object used to handle state or similar
|
||||
// Can be nil if no state is needed
|
||||
// If non-nil must implement io.Closer even if there's nothing to do
|
||||
Object io.Closer
|
||||
}
|
||||
|
||||
type VerifyFunc func(key Key, token []byte, r *http.Request) (VerifyResult, error)
|
||||
|
||||
func (reg Registration) Id() Id {
|
||||
return reg.id
|
||||
}
|
||||
|
||||
type FillRegistration func(state StateInterface, reg *Registration, parameters ast.Node) error
|
||||
|
||||
var Runtimes = make(map[string]FillRegistration)
|
||||
60
lib/challenge/resource-load/resource-load.go
Normal file
60
lib/challenge/resource-load/resource-load.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package resource_load
|
||||
|
||||
import (
|
||||
"git.gammaspectra.live/git/go-away/lib/challenge"
|
||||
"github.com/goccy/go-yaml/ast"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
func init() {
|
||||
challenge.Runtimes["resource-load"] = FillRegistrationHeader
|
||||
}
|
||||
|
||||
func FillRegistrationHeader(state challenge.StateInterface, reg *challenge.Registration, parameters ast.Node) error {
|
||||
reg.Class = challenge.ClassBlocking
|
||||
|
||||
verifier, issuer := challenge.NewKeyVerifier()
|
||||
reg.Verify = verifier
|
||||
|
||||
reg.IssueChallenge = func(w http.ResponseWriter, r *http.Request, key challenge.Key, expiry time.Time) challenge.VerifyResult {
|
||||
uri, err := challenge.VerifyUrl(r, reg, issuer(key))
|
||||
if err != nil {
|
||||
return challenge.VerifyResultFail
|
||||
}
|
||||
|
||||
// self redirect!
|
||||
//TODO: adjust deadline
|
||||
w.Header().Set("Refresh", "2; url="+r.URL.String())
|
||||
|
||||
state.ChallengePage(w, r, state.Settings().ChallengeResponseCode, reg, map[string]any{
|
||||
"LinkTags": []map[string]string{
|
||||
{
|
||||
"href": uri.String(),
|
||||
"rel": "stylesheet",
|
||||
"crossorigin": "use-credentials",
|
||||
},
|
||||
},
|
||||
})
|
||||
return challenge.VerifyResultNone
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.HandleFunc("GET "+reg.Path+challenge.VerifyChallengeUrlSuffix, challenge.VerifyHandlerFunc(state, reg, nil, func(state challenge.StateInterface, data *challenge.RequestData, w http.ResponseWriter, r *http.Request, verifyResult challenge.VerifyResult, err error, redirect string) {
|
||||
//TODO: add other types inside css that need to be loaded!
|
||||
w.Header().Set("Content-Type", "text/css; charset=utf-8")
|
||||
w.Header().Set("Content-Length", "0")
|
||||
|
||||
data.ResponseHeaders(w)
|
||||
|
||||
if !verifyResult.Ok() {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
} else {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
}))
|
||||
reg.Handler = mux
|
||||
|
||||
return nil
|
||||
}
|
||||
42
lib/challenge/script.go
Normal file
42
lib/challenge/script.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package challenge
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"git.gammaspectra.live/git/go-away/utils"
|
||||
"net/http"
|
||||
"text/template"
|
||||
)
|
||||
|
||||
//go:embed script.mjs
|
||||
var scriptData []byte
|
||||
|
||||
var scriptTemplate = template.Must(template.New("script.mjs").Parse(string(scriptData)))
|
||||
|
||||
func ServeChallengeScript(w http.ResponseWriter, r *http.Request, reg *Registration, params any, script string) {
|
||||
data := RequestDataFromContext(r.Context())
|
||||
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
w.Header().Set("Content-Type", "text/javascript; charset=utf-8")
|
||||
|
||||
paramData, err := json.Marshal(params)
|
||||
if err != nil {
|
||||
//TODO: log
|
||||
panic(err)
|
||||
}
|
||||
data.ResponseHeaders(w)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
err = scriptTemplate.Execute(w, map[string]any{
|
||||
"Id": data.Id.String(),
|
||||
"Path": reg.Path,
|
||||
"Parameters": paramData,
|
||||
"Random": utils.CacheBust(),
|
||||
"Challenge": reg.Name,
|
||||
"ChallengeScript": script,
|
||||
"Strings": data.State.Strings(),
|
||||
})
|
||||
if err != nil {
|
||||
//TODO: log
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
@@ -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,26 +41,25 @@ 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(() => {
|
||||
const redir = window.location.href;
|
||||
window.location.href = u("{{ .Path }}/verify-challenge", {
|
||||
result: result,
|
||||
redirect: redir,
|
||||
requestId: "{{ .Id }}",
|
||||
elapsedTime: t1 - t0,
|
||||
__goaway_token: result,
|
||||
__goaway_challenge: "{{ .Challenge }}",
|
||||
__goaway_redirect: redir,
|
||||
__goaway_id: "{{ .Id }}",
|
||||
__goaway_elapsedTime: t1 - t0,
|
||||
});
|
||||
}, 500);
|
||||
} catch (err) {
|
||||
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}`;
|
||||
}
|
||||
})();
|
||||
@@ -1,176 +0,0 @@
|
||||
package challenge
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ed25519"
|
||||
"errors"
|
||||
"git.gammaspectra.live/git/go-away/utils"
|
||||
"github.com/go-jose/go-jose/v4"
|
||||
"github.com/go-jose/go-jose/v4/jwt"
|
||||
"github.com/google/cel-go/cel"
|
||||
"math/rand/v2"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Result int
|
||||
|
||||
const (
|
||||
// ResultStop Stop testing other challenges and return
|
||||
ResultStop = Result(iota)
|
||||
// ResultContinue Test next
|
||||
ResultContinue
|
||||
// ResultPass passed, return and proxy
|
||||
ResultPass
|
||||
)
|
||||
|
||||
type Id int
|
||||
|
||||
type Challenge struct {
|
||||
Id Id
|
||||
Program cel.Program
|
||||
Name string
|
||||
Path string
|
||||
|
||||
Verify func(key []byte, result string, r *http.Request) (bool, error)
|
||||
VerifyProbability float64
|
||||
|
||||
ServeStatic http.Handler
|
||||
|
||||
ServeChallenge func(w http.ResponseWriter, r *http.Request, key []byte, expiry time.Time) Result
|
||||
|
||||
ServeScript http.Handler
|
||||
ServeScriptPath string
|
||||
|
||||
ServeMakeChallenge http.Handler
|
||||
ServeVerifyChallenge http.Handler
|
||||
}
|
||||
|
||||
type Token struct {
|
||||
Name string `json:"name"`
|
||||
Key []byte `json:"key"`
|
||||
Result []byte `json:"result,omitempty"`
|
||||
|
||||
Expiry *jwt.NumericDate `json:"exp,omitempty"`
|
||||
NotBefore *jwt.NumericDate `json:"nbf,omitempty"`
|
||||
IssuedAt *jwt.NumericDate `json:"iat,omitempty"`
|
||||
}
|
||||
|
||||
func (c Challenge) IssueChallengeToken(privateKey ed25519.PrivateKey, key, result []byte, until time.Time) (token string, err error) {
|
||||
signer, err := jose.NewSigner(jose.SigningKey{
|
||||
Algorithm: jose.EdDSA,
|
||||
Key: privateKey,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
expiry := jwt.NumericDate(until.Unix())
|
||||
notBefore := jwt.NumericDate(time.Now().UTC().AddDate(0, 0, -1).Unix())
|
||||
issuedAt := jwt.NumericDate(time.Now().UTC().Unix())
|
||||
|
||||
token, err = jwt.Signed(signer).Claims(Token{
|
||||
Name: c.Name,
|
||||
Key: key,
|
||||
Result: result,
|
||||
Expiry: &expiry,
|
||||
NotBefore: ¬Before,
|
||||
IssuedAt: &issuedAt,
|
||||
}).Serialize()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
type VerifyResult int
|
||||
|
||||
const (
|
||||
VerifyResultNONE = VerifyResult(iota)
|
||||
VerifyResultFAIL
|
||||
VerifyResultSKIP
|
||||
|
||||
// VerifyResultPASS Client just passed this challenge
|
||||
VerifyResultPASS
|
||||
VerifyResultOK
|
||||
VerifyResultBRIEF
|
||||
VerifyResultFULL
|
||||
)
|
||||
|
||||
func (r VerifyResult) Ok() bool {
|
||||
return r >= VerifyResultPASS
|
||||
}
|
||||
|
||||
func (r VerifyResult) String() string {
|
||||
switch r {
|
||||
case VerifyResultNONE:
|
||||
return "NONE"
|
||||
case VerifyResultFAIL:
|
||||
return "FAIL"
|
||||
case VerifyResultSKIP:
|
||||
return "SKIP"
|
||||
case VerifyResultPASS:
|
||||
return "PASS"
|
||||
case VerifyResultOK:
|
||||
return "OK"
|
||||
case VerifyResultBRIEF:
|
||||
return "BRIEF"
|
||||
case VerifyResultFULL:
|
||||
return "FULL"
|
||||
default:
|
||||
panic("unsupported")
|
||||
}
|
||||
}
|
||||
|
||||
var ErrVerifyKeyMismatch = errors.New("verify: key mismatch")
|
||||
var ErrVerifyVerifyMismatch = errors.New("verify: verification mismatch")
|
||||
|
||||
func (c Challenge) VerifyChallengeToken(publicKey ed25519.PublicKey, expectedKey []byte, r *http.Request) (VerifyResult, error) {
|
||||
cookie, err := r.Cookie(utils.CookiePrefix + c.Name)
|
||||
if err != nil {
|
||||
return VerifyResultNONE, err
|
||||
}
|
||||
if cookie == nil {
|
||||
return VerifyResultNONE, http.ErrNoCookie
|
||||
}
|
||||
|
||||
token, err := jwt.ParseSigned(cookie.Value, []jose.SignatureAlgorithm{jose.EdDSA})
|
||||
if err != nil {
|
||||
return VerifyResultFAIL, err
|
||||
}
|
||||
|
||||
var i Token
|
||||
err = token.Claims(publicKey, &i)
|
||||
if err != nil {
|
||||
return VerifyResultFAIL, err
|
||||
}
|
||||
|
||||
if i.Name != c.Name {
|
||||
return VerifyResultFAIL, errors.New("token invalid name")
|
||||
}
|
||||
if i.Expiry == nil && i.Expiry.Time().Compare(time.Now()) < 0 {
|
||||
return VerifyResultFAIL, errors.New("token expired")
|
||||
}
|
||||
if i.NotBefore == nil && i.NotBefore.Time().Compare(time.Now()) > 0 {
|
||||
return VerifyResultFAIL, errors.New("token not valid yet")
|
||||
}
|
||||
|
||||
if bytes.Compare(expectedKey, i.Key) != 0 {
|
||||
return VerifyResultFAIL, ErrVerifyKeyMismatch
|
||||
}
|
||||
|
||||
if c.Verify != nil {
|
||||
if rand.Float64() < c.VerifyProbability {
|
||||
// random spot check
|
||||
if ok, err := c.Verify(expectedKey, string(i.Result), r); err != nil {
|
||||
return VerifyResultFAIL, err
|
||||
} else if !ok {
|
||||
return VerifyResultFAIL, ErrVerifyVerifyMismatch
|
||||
}
|
||||
return VerifyResultFULL, nil
|
||||
} else {
|
||||
return VerifyResultBRIEF, nil
|
||||
}
|
||||
}
|
||||
return VerifyResultOK, nil
|
||||
}
|
||||
121
lib/challenge/types.go
Normal file
121
lib/challenge/types.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package challenge
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"git.gammaspectra.live/git/go-away/lib/policy"
|
||||
"git.gammaspectra.live/git/go-away/utils"
|
||||
"github.com/google/cel-go/cel"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type Id int64
|
||||
|
||||
type Class uint8
|
||||
|
||||
const (
|
||||
// ClassTransparent Transparent challenges work inline in the execution process.
|
||||
// These can pass or continue, so more challenges or requests can ve served afterward.
|
||||
ClassTransparent = Class(iota)
|
||||
|
||||
// ClassBlocking Blocking challenges must serve a different response to challenge the requester.
|
||||
// These can pass or stop, for example, due to serving a challenge
|
||||
ClassBlocking
|
||||
)
|
||||
|
||||
type VerifyState uint8
|
||||
|
||||
const (
|
||||
VerifyStateNone = VerifyState(iota)
|
||||
// VerifyStatePass Challenge was just passed on this request
|
||||
VerifyStatePass
|
||||
// VerifyStateBrief Challenge token was verified but didn't check the challenge
|
||||
VerifyStateBrief
|
||||
// VerifyStateFull Challenge token was verified and challenge verification was done
|
||||
VerifyStateFull
|
||||
)
|
||||
|
||||
func (r VerifyState) String() string {
|
||||
switch r {
|
||||
case VerifyStatePass:
|
||||
return "PASS"
|
||||
case VerifyStateBrief:
|
||||
return "BRIEF"
|
||||
case VerifyStateFull:
|
||||
return "FULL"
|
||||
default:
|
||||
panic("unsupported")
|
||||
}
|
||||
}
|
||||
|
||||
type VerifyResult uint8
|
||||
|
||||
const (
|
||||
// VerifyResultNone A negative pass result, without a token
|
||||
VerifyResultNone = VerifyResult(iota)
|
||||
// VerifyResultFail A negative pass result, with an invalid token
|
||||
VerifyResultFail
|
||||
// VerifyResultSkip Challenge was skipped due to precondition
|
||||
VerifyResultSkip
|
||||
// VerifyResultNotOK A negative pass result, with a valid token
|
||||
VerifyResultNotOK
|
||||
|
||||
// VerifyResultOK A positive pass result, with a valid token
|
||||
VerifyResultOK
|
||||
)
|
||||
|
||||
func (r VerifyResult) Ok() bool {
|
||||
return r >= VerifyResultOK
|
||||
}
|
||||
|
||||
func (r VerifyResult) String() string {
|
||||
switch r {
|
||||
case VerifyResultNone:
|
||||
return "None"
|
||||
case VerifyResultFail:
|
||||
return "Fail"
|
||||
case VerifyResultSkip:
|
||||
return "Skip"
|
||||
case VerifyResultNotOK:
|
||||
return "NotOK"
|
||||
case VerifyResultOK:
|
||||
return "OK"
|
||||
default:
|
||||
panic("unsupported")
|
||||
}
|
||||
}
|
||||
|
||||
type StateInterface interface {
|
||||
RegisterCondition(operator string, conditions ...string) (cel.Program, error)
|
||||
|
||||
Client() *http.Client
|
||||
PrivateKeyFingerprint() []byte
|
||||
PrivateKey() ed25519.PrivateKey
|
||||
PublicKey() ed25519.PublicKey
|
||||
|
||||
UrlPath() string
|
||||
|
||||
ChallengeFailed(r *http.Request, reg *Registration, err error, redirect string, logger *slog.Logger)
|
||||
ChallengePassed(r *http.Request, reg *Registration, redirect string, logger *slog.Logger)
|
||||
ChallengeIssued(r *http.Request, reg *Registration, redirect string, logger *slog.Logger)
|
||||
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
|
||||
|
||||
ChallengePage(w http.ResponseWriter, r *http.Request, status int, reg *Registration, params map[string]any)
|
||||
ErrorPage(w http.ResponseWriter, r *http.Request, status int, err error, redirect string)
|
||||
|
||||
GetChallenge(id Id) (*Registration, bool)
|
||||
GetChallengeByName(name string) (*Registration, bool)
|
||||
GetChallenges() Register
|
||||
|
||||
Settings() policy.StateSettings
|
||||
|
||||
Strings() utils.Strings
|
||||
|
||||
GetBackend(host string) http.Handler
|
||||
}
|
||||
@@ -111,6 +111,7 @@ type VerifyChallengeInput struct {
|
||||
|
||||
type VerifyChallengeOutput uint64
|
||||
|
||||
// TODO: expand allowed values
|
||||
const (
|
||||
VerifyChallengeOutputOK = VerifyChallengeOutput(iota)
|
||||
VerifyChallengeOutputFailed
|
||||
|
||||
186
lib/challenge/wasm/registration.go
Normal file
186
lib/challenge/wasm/registration.go
Normal file
@@ -0,0 +1,186 @@
|
||||
package wasm
|
||||
|
||||
import (
|
||||
"codeberg.org/meta/gzipped/v2"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"git.gammaspectra.live/git/go-away/embed"
|
||||
"git.gammaspectra.live/git/go-away/lib/challenge"
|
||||
_interface "git.gammaspectra.live/git/go-away/lib/challenge/wasm/interface"
|
||||
"git.gammaspectra.live/git/go-away/utils"
|
||||
"git.gammaspectra.live/git/go-away/utils/inline"
|
||||
"github.com/goccy/go-yaml"
|
||||
"github.com/goccy/go-yaml/ast"
|
||||
"github.com/tetratelabs/wazero/api"
|
||||
"html/template"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"path"
|
||||
"time"
|
||||
)
|
||||
|
||||
func init() {
|
||||
challenge.Runtimes["js"] = FillJavaScriptRegistration
|
||||
}
|
||||
|
||||
type Parameters struct {
|
||||
Path string `yaml:"path"`
|
||||
// Loader path to js/mjs file to use as challenge issuer
|
||||
Loader string `yaml:"js-loader"`
|
||||
|
||||
// Runtime path to WASM wasip1 runtime
|
||||
Runtime string `yaml:"wasm-runtime"`
|
||||
|
||||
Settings map[string]string `yaml:"wasm-runtime-settings"`
|
||||
|
||||
NativeCompiler bool `yaml:"wasm-native-compiler"`
|
||||
|
||||
VerifyProbability float64 `yaml:"verify-probability"`
|
||||
}
|
||||
|
||||
var DefaultParameters = Parameters{
|
||||
VerifyProbability: 0.1,
|
||||
NativeCompiler: true,
|
||||
}
|
||||
|
||||
func FillJavaScriptRegistration(state challenge.StateInterface, reg *challenge.Registration, parameters ast.Node) error {
|
||||
params := DefaultParameters
|
||||
|
||||
if parameters != nil {
|
||||
ymlData, err := parameters.MarshalYAML()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = yaml.Unmarshal(ymlData, ¶ms)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
reg.Class = challenge.ClassBlocking
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
||||
if params.Path == "" {
|
||||
params.Path = reg.Name
|
||||
}
|
||||
|
||||
assetsFs, err := embed.GetFallbackFS(embed.ChallengeFs, params.Path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if params.VerifyProbability <= 0 {
|
||||
//10% default
|
||||
params.VerifyProbability = 0.1
|
||||
} else if params.VerifyProbability > 1.0 {
|
||||
params.VerifyProbability = 1.0
|
||||
}
|
||||
|
||||
reg.VerifyProbability = params.VerifyProbability
|
||||
|
||||
ob := NewRunner(params.NativeCompiler)
|
||||
reg.Object = ob
|
||||
|
||||
wasmData, err := assetsFs.ReadFile(path.Join("runtime", params.Runtime))
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not load runtime: %w", err)
|
||||
}
|
||||
|
||||
err = ob.Compile("runtime", wasmData)
|
||||
if err != nil {
|
||||
return fmt.Errorf("compiling runtime: %w", err)
|
||||
}
|
||||
|
||||
reg.IssueChallenge = func(w http.ResponseWriter, r *http.Request, key challenge.Key, expiry time.Time) challenge.VerifyResult {
|
||||
state.ChallengePage(w, r, state.Settings().ChallengeResponseCode, reg, map[string]any{
|
||||
"EndTags": []template.HTML{
|
||||
template.HTML(fmt.Sprintf("<script async type=\"module\" src=\"%s?cacheBust=%s\"></script>", reg.Path+"/script.mjs", utils.CacheBust())),
|
||||
},
|
||||
})
|
||||
return challenge.VerifyResultNone
|
||||
}
|
||||
|
||||
reg.Verify = func(key challenge.Key, token []byte, r *http.Request) (challenge.VerifyResult, error) {
|
||||
var ok bool
|
||||
err = ob.Instantiate("runtime", func(ctx context.Context, mod api.Module) (err error) {
|
||||
in := _interface.VerifyChallengeInput{
|
||||
Key: key[:],
|
||||
Parameters: params.Settings,
|
||||
Result: token,
|
||||
}
|
||||
|
||||
out, err := VerifyChallengeCall(ctx, mod, in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if out == _interface.VerifyChallengeOutputError {
|
||||
return errors.New("error checking challenge")
|
||||
}
|
||||
ok = out == _interface.VerifyChallengeOutputOK
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return challenge.VerifyResultFail, err
|
||||
}
|
||||
if ok {
|
||||
return challenge.VerifyResultOK, nil
|
||||
}
|
||||
return challenge.VerifyResultFail, nil
|
||||
}
|
||||
|
||||
// serve assets if existent
|
||||
if staticFs, err := fs.Sub(assetsFs, "static"); err != nil {
|
||||
return fmt.Errorf("no static assets: %w", err)
|
||||
} else {
|
||||
mux.Handle("GET "+reg.Path+"/static/", http.StripPrefix(reg.Path+"/static/", gzipped.FileServer(gzipped.FS(staticFs))))
|
||||
}
|
||||
|
||||
mux.HandleFunc(reg.Path+challenge.MakeChallengeUrlSuffix, func(w http.ResponseWriter, r *http.Request) {
|
||||
data := challenge.RequestDataFromContext(r.Context())
|
||||
err := ob.Instantiate("runtime", func(ctx context.Context, mod api.Module) (err error) {
|
||||
key := challenge.GetChallengeKeyForRequest(state, reg, data.Expiration(reg.Duration), r)
|
||||
|
||||
in := _interface.MakeChallengeInput{
|
||||
Key: key[:],
|
||||
Parameters: params.Settings,
|
||||
Headers: inline.MIMEHeader(r.Header),
|
||||
}
|
||||
in.Data, err = io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out, err := MakeChallengeCall(ctx, mod, in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// set output headers
|
||||
for k, v := range out.Headers {
|
||||
w.Header()[k] = v
|
||||
}
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(out.Data)))
|
||||
w.WriteHeader(out.Code)
|
||||
_, _ = w.Write(out.Data)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
state.ErrorPage(w, r, http.StatusInternalServerError, err, "")
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
mux.HandleFunc(reg.Path+challenge.VerifyChallengeUrlSuffix, challenge.VerifyHandlerFunc(state, reg, nil, nil))
|
||||
|
||||
mux.HandleFunc("GET "+reg.Path+"/script.mjs", func(w http.ResponseWriter, r *http.Request) {
|
||||
challenge.ServeChallengeScript(w, r, reg, params.Settings, path.Join(reg.Path, "static", params.Loader))
|
||||
})
|
||||
|
||||
reg.Handler = mux
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -94,11 +94,13 @@ func (r *Runner) Compile(key string, binary []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Runner) Close() {
|
||||
func (r *Runner) Close() error {
|
||||
for _, module := range r.modules {
|
||||
module.Close(r.context)
|
||||
if err := module.Close(r.context); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
r.runtime.Close(r.context)
|
||||
return r.runtime.Close(r.context)
|
||||
}
|
||||
|
||||
var ErrModuleNotFound = errors.New("module not found")
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
package condition
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/google/cel-go/cel"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Condition struct {
|
||||
Expression *cel.Ast
|
||||
}
|
||||
|
||||
const (
|
||||
OperatorOr = "||"
|
||||
OperatorAnd = "&&"
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
@@ -1,79 +1,74 @@
|
||||
package lib
|
||||
|
||||
import (
|
||||
"context"
|
||||
http_cel "codeberg.org/gone/http-cel"
|
||||
"fmt"
|
||||
"github.com/google/cel-go/cel"
|
||||
"github.com/google/cel-go/common/ast"
|
||||
"github.com/google/cel-go/common/types"
|
||||
"github.com/google/cel-go/common/types/ref"
|
||||
"log/slog"
|
||||
"net"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (state *State) initConditions() (err error) {
|
||||
state.RulesEnv, err = cel.NewEnv(
|
||||
cel.DefaultUTCTimeZone(true),
|
||||
cel.Variable("remoteAddress", cel.BytesType),
|
||||
cel.Variable("host", cel.StringType),
|
||||
cel.Variable("method", cel.StringType),
|
||||
cel.Variable("userAgent", cel.StringType),
|
||||
cel.Variable("path", cel.StringType),
|
||||
cel.Variable("query", cel.MapType(cel.StringType, cel.StringType)),
|
||||
cel.Variable("fpJA3N", cel.StringType),
|
||||
cel.Variable("fpJA4", cel.StringType),
|
||||
// http.Header
|
||||
cel.Variable("headers", cel.MapType(cel.StringType, cel.StringType)),
|
||||
//TODO: dynamic type?
|
||||
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 {
|
||||
if state.Settings.DNSBL == nil {
|
||||
return types.Bool(false)
|
||||
}
|
||||
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 := val.Value().(type) {
|
||||
switch v := lhs.Value().(type) {
|
||||
case []byte:
|
||||
ip = v
|
||||
case net.IP:
|
||||
ip = v
|
||||
case string:
|
||||
ip = net.ParseIP(v)
|
||||
}
|
||||
|
||||
if ip == nil {
|
||||
panic(fmt.Errorf("invalid ip %v", val.Value()))
|
||||
panic(fmt.Errorf("invalid ip %v", lhs.Value()))
|
||||
}
|
||||
|
||||
var key [net.IPv6len]byte
|
||||
copy(key[:], ip.To16())
|
||||
|
||||
result, ok := state.DecayMap.Get(key)
|
||||
if ok {
|
||||
return types.Bool(result.Bad())
|
||||
val, ok := rhs.Value().(string)
|
||||
if !ok {
|
||||
panic(fmt.Errorf("invalid network value %v", rhs.Value()))
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
result, err := state.Settings.DNSBL.Lookup(ctx, ip)
|
||||
if err != nil {
|
||||
slog.Debug("dnsbl lookup failed", "address", ip.String(), "result", result, "err", err)
|
||||
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 {
|
||||
slog.Debug("dnsbl lookup", "address", ip.String(), "result", result)
|
||||
ok, err := network().Contains(ip)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return types.Bool(ok)
|
||||
}
|
||||
//TODO: configure decay
|
||||
state.DecayMap.Set(key, result, time.Hour)
|
||||
|
||||
return types.Bool(result.Bad())
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
cel.Function("inNetwork",
|
||||
cel.Overload("inNetwork_string_ip",
|
||||
[]*cel.Type{cel.StringType, cel.AnyType},
|
||||
[]*cel.Type{cel.StringType, cel.BytesType},
|
||||
cel.BoolType,
|
||||
cel.BinaryBinding(func(lhs ref.Val, rhs ref.Val) ref.Val {
|
||||
var ip net.IP
|
||||
@@ -82,8 +77,6 @@ func (state *State) initConditions() (err error) {
|
||||
ip = v
|
||||
case net.IP:
|
||||
ip = v
|
||||
case string:
|
||||
ip = net.ParseIP(v)
|
||||
}
|
||||
|
||||
if ip == nil {
|
||||
@@ -94,8 +87,9 @@ func (state *State) initConditions() (err error) {
|
||||
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]
|
||||
network, ok := state.networks[val]
|
||||
if !ok {
|
||||
_, ipNet, err := net.ParseCIDR(val)
|
||||
if err != nil {
|
||||
@@ -103,7 +97,7 @@ func (state *State) initConditions() (err error) {
|
||||
}
|
||||
return types.Bool(ipNet.Contains(ip))
|
||||
} else {
|
||||
ok, err := network.Contains(ip)
|
||||
ok, err := network().Contains(ip)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
@@ -118,3 +112,113 @@ func (state *State) initConditions() (err error) {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (state *State) RegisterCondition(operator string, conditions ...string) (cel.Program, error) {
|
||||
compiledAst, err := http_cel.NewAst(state.ProgramEnv(), operator, conditions...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if out := compiledAst.OutputType(); out == nil {
|
||||
return nil, fmt.Errorf("no output")
|
||||
} else if out != types.BoolType {
|
||||
return nil, fmt.Errorf("output type is not bool")
|
||||
}
|
||||
|
||||
walkExpr(compiledAst.NativeRep().Expr(), func(e ast.Expr) {
|
||||
if e.Kind() == ast.CallKind {
|
||||
call := e.AsCall()
|
||||
switch call.FunctionName() {
|
||||
// deprecated
|
||||
case "inNetwork":
|
||||
args := call.Args()
|
||||
if !call.IsMemberFunction() && len(args) == 2 {
|
||||
// we have a network select function
|
||||
switch args[1].Kind() {
|
||||
case ast.LiteralKind:
|
||||
lit := args[1].AsLiteral()
|
||||
if lit.Type() == types.StringType {
|
||||
if fn, ok := state.networks[lit.Value().(string)]; ok {
|
||||
// preload
|
||||
fn()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
case "network":
|
||||
args := call.Args()
|
||||
if call.IsMemberFunction() && len(args) == 1 {
|
||||
// we have a network select function
|
||||
switch args[0].Kind() {
|
||||
case ast.LiteralKind:
|
||||
lit := args[0].AsLiteral()
|
||||
if lit.Type() == types.StringType {
|
||||
if fn, ok := state.networks[lit.Value().(string)]; ok {
|
||||
// preload
|
||||
fn()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return http_cel.ProgramAst(state.ProgramEnv(), compiledAst)
|
||||
}
|
||||
|
||||
func walkExpr(e ast.Expr, fn func(ast.Expr)) {
|
||||
fn(e)
|
||||
|
||||
switch e.Kind() {
|
||||
case ast.CallKind:
|
||||
ee := e.AsCall()
|
||||
walkExpr(ee.Target(), fn)
|
||||
for _, arg := range ee.Args() {
|
||||
walkExpr(arg, fn)
|
||||
}
|
||||
case ast.ComprehensionKind:
|
||||
ee := e.AsComprehension()
|
||||
walkExpr(ee.Result(), fn)
|
||||
walkExpr(ee.IterRange(), fn)
|
||||
walkExpr(ee.AccuInit(), fn)
|
||||
walkExpr(ee.LoopCondition(), fn)
|
||||
walkExpr(ee.LoopStep(), fn)
|
||||
case ast.ListKind:
|
||||
ee := e.AsList()
|
||||
for _, element := range ee.Elements() {
|
||||
walkExpr(element, fn)
|
||||
}
|
||||
case ast.MapKind:
|
||||
ee := e.AsMap()
|
||||
for _, entry := range ee.Entries() {
|
||||
switch entry.Kind() {
|
||||
case ast.MapEntryKind:
|
||||
eee := entry.AsMapEntry()
|
||||
walkExpr(eee.Key(), fn)
|
||||
walkExpr(eee.Value(), fn)
|
||||
case ast.StructFieldKind:
|
||||
eee := entry.AsStructField()
|
||||
walkExpr(eee.Value(), fn)
|
||||
}
|
||||
}
|
||||
case ast.SelectKind:
|
||||
ee := e.AsSelect()
|
||||
walkExpr(ee.Operand(), fn)
|
||||
case ast.StructKind:
|
||||
ee := e.AsStruct()
|
||||
for _, field := range ee.Fields() {
|
||||
switch field.Kind() {
|
||||
case ast.MapEntryKind:
|
||||
eee := field.AsMapEntry()
|
||||
walkExpr(eee.Key(), fn)
|
||||
walkExpr(eee.Value(), fn)
|
||||
case ast.StructFieldKind:
|
||||
eee := field.AsStructField()
|
||||
walkExpr(eee.Value(), fn)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
723
lib/http.go
723
lib/http.go
@@ -1,141 +1,26 @@
|
||||
package lib
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"codeberg.org/meta/gzipped/v2"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"git.gammaspectra.live/git/go-away/embed"
|
||||
"git.gammaspectra.live/git/go-away/lib/action"
|
||||
"git.gammaspectra.live/git/go-away/lib/challenge"
|
||||
"git.gammaspectra.live/git/go-away/lib/policy"
|
||||
"git.gammaspectra.live/git/go-away/utils"
|
||||
"github.com/google/cel-go/common/types"
|
||||
"html/template"
|
||||
"io"
|
||||
"golang.org/x/net/html"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/pprof"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var templates map[string]*template.Template
|
||||
|
||||
var cacheBust string
|
||||
|
||||
// DefaultValidity TODO: adjust
|
||||
const DefaultValidity = time.Hour * 24 * 7
|
||||
|
||||
func init() {
|
||||
|
||||
buf := make([]byte, 16)
|
||||
_, _ = rand.Read(buf)
|
||||
cacheBust = base64.RawURLEncoding.EncodeToString(buf)
|
||||
|
||||
templates = make(map[string]*template.Template)
|
||||
|
||||
dir, err := embed.TemplatesFs.ReadDir("templates")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
for _, e := range dir {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
data, err := embed.TemplatesFs.ReadFile(filepath.Join("templates", 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) challengePage(w http.ResponseWriter, id string, status int, challenge string, params map[string]any) error {
|
||||
input := make(map[string]any)
|
||||
input["Id"] = id
|
||||
input["Random"] = cacheBust
|
||||
input["Challenge"] = challenge
|
||||
input["Path"] = state.UrlPath
|
||||
input["Theme"] = state.Settings.ChallengeTemplateTheme
|
||||
|
||||
maps.Copy(input, params)
|
||||
|
||||
if _, ok := input["Title"]; !ok {
|
||||
input["Title"] = "Checking you are not a bot"
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
|
||||
buf := bytes.NewBuffer(make([]byte, 0, 8192))
|
||||
|
||||
err := templates["challenge-"+state.Settings.ChallengeTemplate+".gohtml"].Execute(buf, input)
|
||||
if err != nil {
|
||||
_ = state.errorPage(w, id, http.StatusInternalServerError, err, "")
|
||||
} else {
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write(buf.Bytes())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (state *State) errorPage(w http.ResponseWriter, id string, status int, err error, redirect string) error {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
|
||||
buf := bytes.NewBuffer(make([]byte, 0, 8192))
|
||||
|
||||
err2 := templates["challenge-"+state.Settings.ChallengeTemplate+".gohtml"].Execute(buf, map[string]any{
|
||||
"Id": id,
|
||||
"Random": cacheBust,
|
||||
"Error": err.Error(),
|
||||
"Path": state.UrlPath,
|
||||
"Theme": state.Settings.ChallengeTemplateTheme,
|
||||
"Title": "Oh no! " + http.StatusText(status),
|
||||
"HideSpinner": true,
|
||||
"Challenge": "",
|
||||
"Redirect": redirect,
|
||||
})
|
||||
if err2 != nil {
|
||||
panic(err2)
|
||||
} else {
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write(buf.Bytes())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (state *State) addTiming(w http.ResponseWriter, name, desc string, duration time.Duration) {
|
||||
if state.Settings.Debug {
|
||||
w.Header().Add("Server-Timing", fmt.Sprintf("%s;desc=%s;dur=%d", name, strconv.Quote(desc), duration.Milliseconds()))
|
||||
}
|
||||
}
|
||||
|
||||
func GetLoggerForRequest(r *http.Request) *slog.Logger {
|
||||
data := RequestDataFromContext(r.Context())
|
||||
data := challenge.RequestDataFromContext(r.Context())
|
||||
args := []any{
|
||||
"request_id", hex.EncodeToString(data.Id[:]),
|
||||
"remote_address", data.RemoteAddress.String(),
|
||||
"request_id", data.Id.String(),
|
||||
"remote_address", data.RemoteAddress.Addr().String(),
|
||||
"user_agent", r.UserAgent(),
|
||||
"host", r.Host,
|
||||
"path", r.URL.Path,
|
||||
@@ -153,272 +38,283 @@ func GetLoggerForRequest(r *http.Request) *slog.Logger {
|
||||
return slog.With(args...)
|
||||
}
|
||||
|
||||
func (state *State) logger(r *http.Request) *slog.Logger {
|
||||
return GetLoggerForRequest(r)
|
||||
func (state *State) fetchTags(host string, backend http.Handler, r *http.Request, meta, link bool) []html.Node {
|
||||
uri := *r.URL
|
||||
q := uri.Query()
|
||||
for k := range q {
|
||||
if strings.HasPrefix(k, challenge.QueryArgPrefix) {
|
||||
q.Del(k)
|
||||
}
|
||||
}
|
||||
uri.RawQuery = q.Encode()
|
||||
|
||||
key := fmt.Sprintf("%s:%s", host, uri.String())
|
||||
|
||||
if v, ok := state.tagCache.Get(key); ok {
|
||||
return v
|
||||
}
|
||||
|
||||
result := utils.FetchTags(backend, &uri, func() (r []string) {
|
||||
if meta {
|
||||
r = append(r, "meta")
|
||||
} else if link {
|
||||
r = append(r, "link")
|
||||
}
|
||||
return r
|
||||
}()...)
|
||||
if result == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
entries := make([]html.Node, 0, len(result))
|
||||
for _, n := range result {
|
||||
if n.Namespace != "" {
|
||||
continue
|
||||
}
|
||||
|
||||
switch n.Data {
|
||||
case "link":
|
||||
safeAttributes := []string{"rel", "href", "hreflang", "media", "title", "type"}
|
||||
|
||||
var name string
|
||||
for _, attr := range n.Attr {
|
||||
if attr.Namespace != "" {
|
||||
continue
|
||||
}
|
||||
if attr.Key == "rel" {
|
||||
name = attr.Val
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
var keep bool
|
||||
if name == "icon" || name == "alternate icon" {
|
||||
keep = true
|
||||
} else if name == "alternate" || name == "canonical" || name == "search" {
|
||||
// urls to versions of document
|
||||
keep = true
|
||||
} else if name == "author" || name == "privacy-policy" || name == "license" || name == "copyright" || name == "terms-of-service" {
|
||||
keep = true
|
||||
} else if name == "manifest" {
|
||||
// web app manifest
|
||||
keep = true
|
||||
}
|
||||
|
||||
// prevent other arbitrary arguments
|
||||
if keep {
|
||||
newNode := html.Node{
|
||||
Type: html.ElementNode,
|
||||
Data: n.Data,
|
||||
}
|
||||
for _, attr := range n.Attr {
|
||||
if attr.Namespace != "" {
|
||||
continue
|
||||
}
|
||||
if slices.Contains(safeAttributes, attr.Key) {
|
||||
newNode.Attr = append(newNode.Attr, attr)
|
||||
}
|
||||
}
|
||||
if len(newNode.Attr) == 0 {
|
||||
continue
|
||||
}
|
||||
entries = append(entries, newNode)
|
||||
}
|
||||
|
||||
case "meta":
|
||||
|
||||
safeAttributes := []string{"name", "property", "content"}
|
||||
var name string
|
||||
for _, attr := range n.Attr {
|
||||
if attr.Namespace != "" {
|
||||
continue
|
||||
}
|
||||
if attr.Key == "name" {
|
||||
name = attr.Val
|
||||
break
|
||||
}
|
||||
if attr.Key == "property" && name == "" {
|
||||
name = attr.Val
|
||||
}
|
||||
}
|
||||
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// prevent unwanted keys like CSRF and other internal entries to pass through as much as possible
|
||||
|
||||
var keep bool
|
||||
if strings.HasPrefix("og:", name) || strings.HasPrefix("fb:", name) || strings.HasPrefix("twitter:", name) || strings.HasPrefix("profile:", name) {
|
||||
// social / OpenGraph tags
|
||||
keep = true
|
||||
} else if name == "vcs" || strings.HasPrefix("vcs:", name) {
|
||||
// source tags
|
||||
keep = true
|
||||
} else if name == "forge" || strings.HasPrefix("forge:", name) {
|
||||
// forge tags
|
||||
keep = true
|
||||
} else if strings.HasPrefix("citation_", name) {
|
||||
// citations for Google Scholar
|
||||
keep = true
|
||||
} else {
|
||||
switch name {
|
||||
case "theme-color", "color-scheme", "origin-trials":
|
||||
// modifies page presentation
|
||||
keep = true
|
||||
case "application-name", "origin", "author", "creator", "contact", "title", "description", "thumbnail", "rating":
|
||||
// standard content tags
|
||||
keep = true
|
||||
case "license", "license:uri", "rights", "rights-standard":
|
||||
// licensing standards
|
||||
keep = true
|
||||
case "go-import", "go-source":
|
||||
// golang tags
|
||||
keep = true
|
||||
case "apple-itunes-app", "appstore:bundle_id", "appstore:developer_url", "appstore:store_id", "google-play-app":
|
||||
// application linking
|
||||
keep = true
|
||||
|
||||
case "verify-v1", "google-site-verification", "p:domain_verify", "yandex-verification", "alexaverifyid":
|
||||
// site verification
|
||||
keep = true
|
||||
|
||||
case "keywords", "robots", "google", "googlebot", "bingbot", "pinterest", "Slurp":
|
||||
// scraper and search content directives
|
||||
keep = true
|
||||
}
|
||||
}
|
||||
|
||||
// prevent other arbitrary arguments
|
||||
if keep {
|
||||
newNode := html.Node{
|
||||
Type: html.ElementNode,
|
||||
Data: n.Data,
|
||||
}
|
||||
for _, attr := range n.Attr {
|
||||
if attr.Namespace != "" {
|
||||
continue
|
||||
}
|
||||
if slices.Contains(safeAttributes, attr.Key) {
|
||||
newNode.Attr = append(newNode.Attr, attr)
|
||||
}
|
||||
}
|
||||
if len(newNode.Attr) == 0 {
|
||||
continue
|
||||
}
|
||||
entries = append(entries, newNode)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
state.tagCache.Set(key, entries, time.Hour*6)
|
||||
return entries
|
||||
}
|
||||
|
||||
func (state *State) handleRequest(w http.ResponseWriter, r *http.Request) {
|
||||
host := r.Host
|
||||
|
||||
data := RequestDataFromContext(r.Context())
|
||||
data := challenge.RequestDataFromContext(r.Context())
|
||||
|
||||
backend, ok := state.Settings.Backends[host]
|
||||
if !ok {
|
||||
lg := state.Logger(r)
|
||||
|
||||
backend := state.GetBackend(host)
|
||||
if backend == nil {
|
||||
lg.Debug("no backend for host", "host", host)
|
||||
http.Error(w, http.StatusText(http.StatusServiceUnavailable), http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
lg := state.logger(r)
|
||||
|
||||
start := time.Now()
|
||||
|
||||
state.addTiming(w, "rule-env", "Setup the rule environment", time.Since(start))
|
||||
|
||||
var (
|
||||
ruleEvalDuration time.Duration
|
||||
)
|
||||
|
||||
serve := func() {
|
||||
state.addTiming(w, "rule-eval", "Evaluate access rules", ruleEvalDuration)
|
||||
backend.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
fail := func(code int, err error) {
|
||||
state.addTiming(w, "rule-eval", "Evaluate access rules", ruleEvalDuration)
|
||||
_ = state.errorPage(w, r.Header.Get("X-Away-Id"), code, err, "")
|
||||
}
|
||||
|
||||
setAwayState := func(rule RuleState) {
|
||||
r.Header.Set("X-Away-Rule", rule.Name)
|
||||
r.Header.Set("X-Away-Hash", rule.Hash)
|
||||
r.Header.Set("X-Away-Action", string(rule.Action))
|
||||
data.Headers(state, r.Header)
|
||||
}
|
||||
|
||||
for _, rule := range state.Rules {
|
||||
// skip rules that have host match
|
||||
if rule.Host != nil && *rule.Host != host {
|
||||
continue
|
||||
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
|
||||
}
|
||||
start = time.Now()
|
||||
out, _, err := rule.Program.Eval(data.ProgramEnv)
|
||||
ruleEvalDuration += time.Since(start)
|
||||
return backend
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
fail(http.StatusInternalServerError, err)
|
||||
lg.Error(err.Error(), "rule", rule.Name, "rule_hash", rule.Hash)
|
||||
panic(err)
|
||||
return
|
||||
} else if out != nil && out.Type() == types.BoolType {
|
||||
if out.Equal(types.True) == types.True {
|
||||
switch rule.Action {
|
||||
default:
|
||||
panic(fmt.Errorf("unknown action %s", rule.Action))
|
||||
case policy.RuleActionPASS:
|
||||
lg.Debug("request passed", "rule", rule.Name, "rule_hash", rule.Hash)
|
||||
setAwayState(rule)
|
||||
serve()
|
||||
return
|
||||
case policy.RuleActionCHALLENGE, policy.RuleActionCHECK:
|
||||
for _, challengeId := range rule.Challenges {
|
||||
if result := data.Challenges[challengeId]; !result.Ok() {
|
||||
continue
|
||||
} else {
|
||||
if rule.Action == policy.RuleActionCHECK {
|
||||
goto nextRule
|
||||
}
|
||||
cleanupRequest := func(r *http.Request, fromChallenge bool, ruleName string, ruleAction policy.RuleAction) {
|
||||
if fromChallenge {
|
||||
r.Header.Del("Referer")
|
||||
}
|
||||
q := r.URL.Query()
|
||||
|
||||
// we passed the challenge!
|
||||
lg.Debug("request passed", "rule", rule.Name, "rule_hash", rule.Hash, "challenge", state.Challenges[challengeId].Name)
|
||||
setAwayState(rule)
|
||||
serve()
|
||||
return
|
||||
}
|
||||
}
|
||||
if ref := q.Get(challenge.QueryArgReferer); ref != "" {
|
||||
r.Header.Set("Referer", ref)
|
||||
}
|
||||
|
||||
// none matched, issue first challenge in priority
|
||||
for _, challengeId := range rule.Challenges {
|
||||
result := data.Challenges[challengeId]
|
||||
if result.Ok() || result == challenge.VerifyResultSKIP {
|
||||
// skip already ok'd challenges for some reason, and also skip skipped challenges
|
||||
continue
|
||||
}
|
||||
c := state.Challenges[challengeId]
|
||||
if c.ServeChallenge != nil {
|
||||
result := c.ServeChallenge(w, r, state.GetChallengeKeyForRequest(c.Name, data.Expires, r), data.Expires)
|
||||
switch result {
|
||||
case challenge.ResultStop:
|
||||
lg.Info("request challenged", "rule", rule.Name, "rule_hash", rule.Hash, "challenge", c.Name)
|
||||
return
|
||||
case challenge.ResultContinue:
|
||||
continue
|
||||
case challenge.ResultPass:
|
||||
if rule.Action == policy.RuleActionCHECK {
|
||||
goto nextRule
|
||||
}
|
||||
state.logger(r).Warn("challenge passed", "rule", rule.Name, "rule_hash", rule.Hash, "challenge", c.Name)
|
||||
// delete query parameters that were set by go-away
|
||||
for k := range q {
|
||||
if strings.HasPrefix(k, challenge.QueryArgPrefix) {
|
||||
q.Del(k)
|
||||
}
|
||||
}
|
||||
r.URL.RawQuery = q.Encode()
|
||||
|
||||
// set pass if caller didn't set one
|
||||
if !data.Challenges[c.Id].Ok() {
|
||||
data.Challenges[c.Id] = challenge.VerifyResultPASS
|
||||
}
|
||||
data.ExtraHeaders.Set("X-Away-Rule", ruleName)
|
||||
data.ExtraHeaders.Set("X-Away-Action", string(ruleAction))
|
||||
|
||||
// we pass the challenge early!
|
||||
lg.Debug("request passed", "rule", rule.Name, "rule_hash", rule.Hash, "challenge", c.Name)
|
||||
setAwayState(rule)
|
||||
serve()
|
||||
return
|
||||
}
|
||||
} else {
|
||||
panic("challenge not found")
|
||||
}
|
||||
}
|
||||
case policy.RuleActionDENY:
|
||||
lg.Info("request denied", "rule", rule.Name, "rule_hash", rule.Hash)
|
||||
//TODO: config error code
|
||||
fail(http.StatusForbidden, fmt.Errorf("access denied: denied by administrative rule %s/%s", r.Header.Get("X-Away-Id"), rule.Hash))
|
||||
return
|
||||
case policy.RuleActionBLOCK:
|
||||
lg.Info("request blocked", "rule", rule.Name, "rule_hash", rule.Hash)
|
||||
//TODO: config error code
|
||||
//TODO: configure block
|
||||
fail(http.StatusForbidden, fmt.Errorf("access denied: blocked by administrative rule %s/%s", r.Header.Get("X-Away-Id"), rule.Hash))
|
||||
return
|
||||
case policy.RuleActionPOISON:
|
||||
lg.Info("request poisoned", "rule", rule.Name, "rule_hash", rule.Hash)
|
||||
|
||||
mime := "text/html"
|
||||
switch path.Ext(r.URL.Path) {
|
||||
case ".css":
|
||||
case ".json", ".js", ".mjs":
|
||||
|
||||
}
|
||||
|
||||
encodings := strings.Split(r.Header.Get("Accept-Encoding"), ",")
|
||||
for i, encoding := range encodings {
|
||||
encodings[i] = strings.TrimSpace(strings.ToLower(encoding))
|
||||
}
|
||||
|
||||
reader, encoding := state.getPoison(mime, encodings)
|
||||
if reader == nil {
|
||||
mime = "application/octet-stream"
|
||||
reader, encoding = state.getPoison(mime, encodings)
|
||||
}
|
||||
|
||||
if reader != nil {
|
||||
defer reader.Close()
|
||||
|
||||
w.Header().Set("Cache-Control", "max-age=0, private, must-revalidate, no-transform")
|
||||
w.Header().Set("Vary", "Accept-Encoding")
|
||||
w.Header().Set("Content-Type", mime)
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
if encoding != "" {
|
||||
w.Header().Set("Content-Encoding", encoding)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if flusher, ok := w.(http.Flusher); ok {
|
||||
// trigger chunked encoding
|
||||
flusher.Flush()
|
||||
}
|
||||
if r != nil {
|
||||
_, _ = io.Copy(w, reader)
|
||||
}
|
||||
return
|
||||
} else {
|
||||
http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
|
||||
}
|
||||
}
|
||||
// delete cookies set by go-away to prevent user tracking that way
|
||||
cookies := r.Cookies()
|
||||
r.Header.Del("Cookie")
|
||||
for _, c := range cookies {
|
||||
if !strings.HasPrefix(c.Name, utils.DefaultCookiePrefix) {
|
||||
r.AddCookie(c)
|
||||
}
|
||||
}
|
||||
|
||||
nextRule:
|
||||
// set response headers
|
||||
data.ResponseHeaders(w)
|
||||
}
|
||||
|
||||
serve()
|
||||
return
|
||||
for _, rule := range state.rules {
|
||||
next, err := rule.Evaluate(lg, w, r, func() http.Handler {
|
||||
cleanupRequest(r, true, rule.Name, rule.Action)
|
||||
return getBackend()
|
||||
})
|
||||
if err != nil {
|
||||
state.ErrorPage(w, r, http.StatusInternalServerError, err, "")
|
||||
panic(err)
|
||||
return
|
||||
}
|
||||
|
||||
if !next {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
state.RuleHit(r, "DEFAULT", lg)
|
||||
data.State.ActionHit(r, policy.RuleActionPASS, lg)
|
||||
|
||||
// default pass
|
||||
_, _ = action.Pass{}.Handle(lg, w, r, func() http.Handler {
|
||||
cleanupRequest(r, false, "DEFAULT", policy.RuleActionPASS)
|
||||
return getBackend()
|
||||
})
|
||||
}
|
||||
|
||||
func (state *State) setupRoutes() error {
|
||||
|
||||
state.Mux.HandleFunc("/", state.handleRequest)
|
||||
|
||||
if state.Settings.Debug {
|
||||
http.HandleFunc(state.UrlPath+"/debug/pprof/", pprof.Index)
|
||||
http.HandleFunc(state.UrlPath+"/debug/pprof/profile", pprof.Profile)
|
||||
http.HandleFunc(state.UrlPath+"/debug/pprof/symbol", pprof.Symbol)
|
||||
http.HandleFunc(state.UrlPath+"/debug/pprof/trace", pprof.Trace)
|
||||
}
|
||||
state.Mux.Handle("GET "+state.urlPath+"/assets/", http.StripPrefix(state.UrlPath()+"/assets/", gzipped.FileServer(gzipped.FS(embed.AssetsFs))))
|
||||
|
||||
state.Mux.Handle("GET "+state.UrlPath+"/assets/", http.StripPrefix(state.UrlPath, gzipped.FileServer(gzipped.FS(embed.AssetsFs))))
|
||||
for _, reg := range state.challenges {
|
||||
|
||||
for _, c := range state.Challenges {
|
||||
if c.ServeStatic != nil {
|
||||
state.Mux.Handle("GET "+c.Path+"/static/", c.ServeStatic)
|
||||
}
|
||||
|
||||
if c.ServeScript != nil {
|
||||
state.Mux.Handle("GET "+c.ServeScriptPath, c.ServeScript)
|
||||
}
|
||||
|
||||
if c.ServeMakeChallenge != nil {
|
||||
state.Mux.Handle(fmt.Sprintf("POST %s/make-challenge", c.Path), c.ServeMakeChallenge)
|
||||
}
|
||||
|
||||
if c.ServeVerifyChallenge != nil {
|
||||
state.Mux.Handle(fmt.Sprintf("GET %s/verify-challenge", c.Path), c.ServeVerifyChallenge)
|
||||
} else if c.Verify != nil {
|
||||
state.Mux.HandleFunc(fmt.Sprintf("GET %s/verify-challenge", c.Path), func(w http.ResponseWriter, r *http.Request) {
|
||||
redirect, err := utils.EnsureNoOpenRedirect(r.FormValue("redirect"))
|
||||
if redirect == "" {
|
||||
_ = state.errorPage(w, r.Header.Get("X-Away-Id"), http.StatusInternalServerError, err, "")
|
||||
return
|
||||
}
|
||||
|
||||
err = func() (err error) {
|
||||
|
||||
data := RequestDataFromContext(r.Context())
|
||||
|
||||
key := state.GetChallengeKeyForRequest(c.Name, data.Expires, r)
|
||||
result := r.FormValue("result")
|
||||
|
||||
requestId, err := hex.DecodeString(r.FormValue("requestId"))
|
||||
if err == nil {
|
||||
// override
|
||||
r.Header.Set("X-Away-Id", hex.EncodeToString(requestId))
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
ok, err := c.Verify(key, result, r)
|
||||
state.addTiming(w, "challenge-verify", "Verify client challenge", time.Since(start))
|
||||
|
||||
if err != nil {
|
||||
state.logger(r).Error(fmt.Errorf("challenge error: %w", err).Error(), "challenge", c.Name, "redirect", redirect)
|
||||
return err
|
||||
} else if !ok {
|
||||
state.logger(r).Warn("challenge failed", "challenge", c.Name, "redirect", redirect)
|
||||
utils.ClearCookie(utils.CookiePrefix+c.Name, w)
|
||||
_ = state.errorPage(w, r.Header.Get("X-Away-Id"), http.StatusForbidden, fmt.Errorf("access denied: failed challenge %s", c.Name), redirect)
|
||||
return nil
|
||||
}
|
||||
state.logger(r).Info("challenge passed", "challenge", c.Name, "redirect", redirect)
|
||||
|
||||
token, err := c.IssueChallengeToken(state.privateKey, key, []byte(result), data.Expires)
|
||||
if err != nil {
|
||||
utils.ClearCookie(utils.CookiePrefix+c.Name, w)
|
||||
} else {
|
||||
utils.SetCookie(utils.CookiePrefix+c.Name, token, data.Expires, w)
|
||||
}
|
||||
data.Challenges[c.Id] = challenge.VerifyResultPASS
|
||||
|
||||
http.Redirect(w, r, redirect, http.StatusTemporaryRedirect)
|
||||
return nil
|
||||
}()
|
||||
if err != nil {
|
||||
utils.ClearCookie(utils.CookiePrefix+c.Name, w)
|
||||
_ = state.errorPage(w, r.Header.Get("X-Away-Id"), http.StatusInternalServerError, err, redirect)
|
||||
return
|
||||
}
|
||||
})
|
||||
if reg.Handler != nil {
|
||||
state.Mux.Handle(reg.Path+"/", reg.Handler)
|
||||
} else if reg.Verify != nil {
|
||||
// default verify
|
||||
state.Mux.HandleFunc(reg.Path+challenge.VerifyChallengeUrlSuffix, challenge.VerifyHandlerFunc(state, reg, nil, nil))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -426,116 +322,9 @@ func (state *State) setupRoutes() error {
|
||||
}
|
||||
|
||||
func (state *State) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
r, data := challenge.CreateRequestData(r, state)
|
||||
|
||||
var data RequestData
|
||||
// generate random id, todo: is this fast?
|
||||
_, _ = rand.Read(data.Id[:])
|
||||
data.RemoteAddress = getRequestAddress(r, state.Settings.ClientIpHeader)
|
||||
data.Challenges = make(map[challenge.Id]challenge.VerifyResult, len(state.Challenges))
|
||||
data.Expires = time.Now().UTC().Add(DefaultValidity).Round(DefaultValidity)
|
||||
|
||||
var ja3n, ja4 string
|
||||
if fp := utils.GetTLSFingerprint(r); fp != nil {
|
||||
if ja3nPtr := fp.JA3N(); ja3nPtr != nil {
|
||||
ja3n = ja3nPtr.String()
|
||||
r.Header.Set("X-TLS-Fingerprint-JA3N", ja3n)
|
||||
}
|
||||
if ja4Ptr := fp.JA4(); ja4Ptr != nil {
|
||||
ja4 = ja4Ptr.String()
|
||||
r.Header.Set("X-TLS-Fingerprint-JA4", ja4)
|
||||
}
|
||||
}
|
||||
|
||||
data.ProgramEnv = map[string]any{
|
||||
"host": r.Host,
|
||||
"method": r.Method,
|
||||
"remoteAddress": data.RemoteAddress,
|
||||
"userAgent": r.UserAgent(),
|
||||
"path": r.URL.Path,
|
||||
"fpJA3N": ja3n,
|
||||
"fpJA4": ja4,
|
||||
"query": func() map[string]string {
|
||||
result := make(map[string]string)
|
||||
for k, v := range r.URL.Query() {
|
||||
result[k] = strings.Join(v, ",")
|
||||
}
|
||||
return result
|
||||
}(),
|
||||
"headers": func() map[string]string {
|
||||
result := make(map[string]string)
|
||||
for k, v := range r.Header {
|
||||
result[k] = strings.Join(v, ",")
|
||||
}
|
||||
return result
|
||||
}(),
|
||||
}
|
||||
|
||||
r = r.WithContext(context.WithValue(r.Context(), "_goaway_data", &data))
|
||||
|
||||
for _, c := range state.Challenges {
|
||||
key := state.GetChallengeKeyForRequest(c.Name, data.Expires, r)
|
||||
result, err := c.VerifyChallengeToken(state.publicKey, key, r)
|
||||
if err != nil && !errors.Is(err, http.ErrNoCookie) {
|
||||
// clear invalid cookie
|
||||
utils.ClearCookie(utils.CookiePrefix+c.Name, w)
|
||||
}
|
||||
|
||||
// prevent the challenge if not solved
|
||||
if !result.Ok() && c.Program != nil {
|
||||
out, _, err := c.Program.Eval(data.ProgramEnv)
|
||||
// verify eligibility
|
||||
if err != nil {
|
||||
state.logger(r).Error(err.Error(), "challenge", c.Name)
|
||||
} else if out != nil && out.Type() == types.BoolType {
|
||||
if out.Equal(types.True) != types.True {
|
||||
// skip challenge match!
|
||||
result = challenge.VerifyResultSKIP
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
data.Challenges[c.Id] = result
|
||||
}
|
||||
|
||||
r.Header.Set("X-Away-Id", hex.EncodeToString(data.Id[:]))
|
||||
if state.Settings.BackendIpHeader != "" {
|
||||
r.Header.Del(state.Settings.ClientIpHeader)
|
||||
r.Header.Set(state.Settings.BackendIpHeader, data.RemoteAddress.String())
|
||||
}
|
||||
w.Header().Add("Via", fmt.Sprintf("%s %s", r.Proto, "go-away"))
|
||||
|
||||
// send these to client so we consistently get the headers
|
||||
//w.Header().Set("Accept-CH", "Sec-CH-UA, Sec-CH-UA-Platform")
|
||||
//w.Header().Set("Critical-CH", "Sec-CH-UA, Sec-CH-UA-Platform")
|
||||
data.EvaluateChallenges(w, r)
|
||||
|
||||
state.Mux.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
func RequestDataFromContext(ctx context.Context) *RequestData {
|
||||
return ctx.Value("_goaway_data").(*RequestData)
|
||||
}
|
||||
|
||||
type RequestData struct {
|
||||
Id [16]byte
|
||||
ProgramEnv map[string]any
|
||||
Expires time.Time
|
||||
Challenges map[challenge.Id]challenge.VerifyResult
|
||||
RemoteAddress net.IP
|
||||
}
|
||||
|
||||
func (d *RequestData) HasValidChallenge(id challenge.Id) bool {
|
||||
return d.Challenges[id].Ok()
|
||||
}
|
||||
|
||||
func (d *RequestData) Headers(state *State, headers http.Header) {
|
||||
for id, result := range d.Challenges {
|
||||
if result.Ok() {
|
||||
c, ok := state.Challenges[id]
|
||||
if !ok {
|
||||
panic("challenge not found")
|
||||
}
|
||||
|
||||
headers.Set(fmt.Sprintf("X-Away-Challenge-%s-Result", c.Name), result.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
111
lib/interface.go
Normal file
111
lib/interface.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package lib
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"git.gammaspectra.live/git/go-away/lib/challenge"
|
||||
"git.gammaspectra.live/git/go-away/lib/policy"
|
||||
"git.gammaspectra.live/git/go-away/utils"
|
||||
"github.com/google/cel-go/cel"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// Defines challenge.StateInterface
|
||||
|
||||
var _ challenge.StateInterface
|
||||
|
||||
func (state *State) ProgramEnv() *cel.Env {
|
||||
return state.programEnv
|
||||
}
|
||||
|
||||
func (state *State) Client() *http.Client {
|
||||
return state.client
|
||||
}
|
||||
|
||||
func (state *State) PrivateKey() ed25519.PrivateKey {
|
||||
return state.privateKey
|
||||
}
|
||||
|
||||
func (state *State) PrivateKeyFingerprint() []byte {
|
||||
return state.privateKeyFingerprint
|
||||
}
|
||||
|
||||
func (state *State) PublicKey() ed25519.PublicKey {
|
||||
return state.publicKey
|
||||
}
|
||||
|
||||
func (state *State) UrlPath() string {
|
||||
return state.urlPath
|
||||
}
|
||||
|
||||
func (state *State) ChallengeFailed(r *http.Request, reg *challenge.Registration, err error, redirect string, logger *slog.Logger) {
|
||||
if logger == nil {
|
||||
logger = state.Logger(r)
|
||||
}
|
||||
logger.Warn("challenge failed", "challenge", reg.Name, "err", err, "redirect", redirect)
|
||||
|
||||
metrics.Challenge(reg.Name, "fail")
|
||||
}
|
||||
|
||||
func (state *State) ChallengePassed(r *http.Request, reg *challenge.Registration, redirect string, logger *slog.Logger) {
|
||||
if logger == nil {
|
||||
logger = state.Logger(r)
|
||||
}
|
||||
logger.Warn("challenge passed", "challenge", reg.Name, "redirect", redirect)
|
||||
|
||||
metrics.Challenge(reg.Name, "pass")
|
||||
}
|
||||
|
||||
func (state *State) ChallengeIssued(r *http.Request, reg *challenge.Registration, redirect string, logger *slog.Logger) {
|
||||
if logger == nil {
|
||||
logger = state.Logger(r)
|
||||
}
|
||||
logger.Info("challenge issued", "challenge", reg.Name, "redirect", redirect)
|
||||
|
||||
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) GetChallenge(id challenge.Id) (*challenge.Registration, bool) {
|
||||
reg, ok := state.challenges.Get(id)
|
||||
return reg, ok
|
||||
}
|
||||
|
||||
func (state *State) GetChallenges() challenge.Register {
|
||||
return state.challenges
|
||||
}
|
||||
|
||||
func (state *State) GetChallengeByName(name string) (*challenge.Registration, bool) {
|
||||
reg, _, ok := state.challenges.GetByName(name)
|
||||
return reg, ok
|
||||
}
|
||||
func (state *State) Settings() policy.StateSettings {
|
||||
return state.settings
|
||||
}
|
||||
|
||||
func (state *State) Strings() utils.Strings {
|
||||
return state.opt.Strings
|
||||
}
|
||||
|
||||
func (state *State) GetBackend(host string) http.Handler {
|
||||
return utils.SelectHTTPHandler(state.Settings().Backends, host)
|
||||
}
|
||||
50
lib/metrics.go
Normal file
50
lib/metrics.go
Normal 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()
|
||||
@@ -1,26 +0,0 @@
|
||||
package lib
|
||||
|
||||
import (
|
||||
"git.gammaspectra.live/git/go-away/embed"
|
||||
"io"
|
||||
"path"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var poisonEncodings = []string{"br", "zstd", "gzip"}
|
||||
|
||||
func (state *State) getPoison(mime string, encodings []string) (r io.ReadCloser, encoding string) {
|
||||
for _, encoding = range poisonEncodings {
|
||||
if !slices.Contains(encodings, encoding) {
|
||||
continue
|
||||
}
|
||||
|
||||
p := path.Join("poison", strings.ReplaceAll(mime, "/", "_")+"."+encoding+".poison")
|
||||
f, err := embed.PoisonFs.Open(p)
|
||||
if err == nil {
|
||||
return f, encoding
|
||||
}
|
||||
}
|
||||
return nil, ""
|
||||
}
|
||||
@@ -1,15 +1,15 @@
|
||||
package policy
|
||||
|
||||
import (
|
||||
"github.com/goccy/go-yaml/ast"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Challenge struct {
|
||||
Conditions []string `yaml:"conditions"`
|
||||
Mode string `yaml:"mode"`
|
||||
Asset *string `yaml:"asset,omitempty"`
|
||||
Url *string `yaml:"url,omitempty"`
|
||||
Runtime string `yaml:"runtime"`
|
||||
|
||||
Parameters map[string]string `json:"parameters,omitempty"`
|
||||
Runtime struct {
|
||||
Mode string `yaml:"mode,omitempty"`
|
||||
Asset string `yaml:"asset,omitempty"`
|
||||
Probability float64 `yaml:"probability,omitempty"`
|
||||
} `yaml:"runtime"`
|
||||
Duration time.Duration `yaml:"duration"`
|
||||
|
||||
Parameters ast.Node `yaml:"parameters,omitempty"`
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"git.gammaspectra.live/git/go-away/utils"
|
||||
"github.com/itchyny/gojq"
|
||||
"io"
|
||||
"net"
|
||||
@@ -13,16 +14,20 @@ import (
|
||||
)
|
||||
|
||||
type Network struct {
|
||||
// Fetches
|
||||
Url *string `yaml:"url,omitempty"`
|
||||
File *string `yaml:"file,omitempty"`
|
||||
ASN *int `yaml:"asn,omitempty"`
|
||||
|
||||
// Filtering
|
||||
JqPath *string `yaml:"jq-path,omitempty"`
|
||||
Regex *string `yaml:"regex,omitempty"`
|
||||
|
||||
Prefixes []string `yaml:"prefixes,omitempty"`
|
||||
}
|
||||
|
||||
func (n Network) FetchPrefixes(c *http.Client) (output []net.IPNet, err error) {
|
||||
func (n Network) FetchPrefixes(c *http.Client, whois *utils.RADb) (output []net.IPNet, err error) {
|
||||
|
||||
if len(n.Prefixes) > 0 {
|
||||
for _, prefix := range n.Prefixes {
|
||||
ipNet, err := parseCIDROrIP(prefix)
|
||||
@@ -40,7 +45,7 @@ func (n Network) FetchPrefixes(c *http.Client) (output []net.IPNet, err error) {
|
||||
return nil, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != 200 {
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("unexpected status code: %d", response.StatusCode)
|
||||
}
|
||||
reader = response.Body
|
||||
@@ -51,6 +56,12 @@ func (n Network) FetchPrefixes(c *http.Client) (output []net.IPNet, err error) {
|
||||
}
|
||||
defer file.Close()
|
||||
reader = file
|
||||
} else if n.ASN != nil {
|
||||
result, err := whois.FetchASNets(*n.ASN)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch ASN %d: %v", *n.ASN, err)
|
||||
}
|
||||
return result, nil
|
||||
} else {
|
||||
if len(output) > 0 {
|
||||
return output, nil
|
||||
@@ -115,3 +126,30 @@ func (n Network) FetchPrefixes(c *http.Client) (output []net.IPNet, err error) {
|
||||
}
|
||||
return output, nil
|
||||
}
|
||||
|
||||
func parseCIDROrIP(value string) (net.IPNet, error) {
|
||||
_, ipNet, err := net.ParseCIDR(value)
|
||||
if err != nil {
|
||||
ip := net.ParseIP(value)
|
||||
if ip == nil {
|
||||
return net.IPNet{}, fmt.Errorf("failed to parse CIDR: %s", err)
|
||||
}
|
||||
|
||||
if ip4 := ip.To4(); ip4 != nil {
|
||||
return net.IPNet{
|
||||
IP: ip4,
|
||||
// single ip
|
||||
Mask: net.CIDRMask(len(ip4)*8, len(ip4)*8),
|
||||
}, nil
|
||||
}
|
||||
return net.IPNet{
|
||||
IP: ip,
|
||||
// single ip
|
||||
Mask: net.CIDRMask(len(ip)*8, len(ip)*8),
|
||||
}, nil
|
||||
} else if ipNet != nil {
|
||||
return *ipNet, nil
|
||||
} else {
|
||||
return net.IPNet{}, errors.New("invalid CIDR")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,38 +1,13 @@
|
||||
package policy
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"bytes"
|
||||
"github.com/goccy/go-yaml"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
)
|
||||
|
||||
func parseCIDROrIP(value string) (net.IPNet, error) {
|
||||
_, ipNet, err := net.ParseCIDR(value)
|
||||
if err != nil {
|
||||
ip := net.ParseIP(value)
|
||||
if ip == nil {
|
||||
return net.IPNet{}, fmt.Errorf("failed to parse CIDR: %s", err)
|
||||
}
|
||||
|
||||
if ip4 := ip.To4(); ip4 != nil {
|
||||
return net.IPNet{
|
||||
IP: ip4,
|
||||
// single ip
|
||||
Mask: net.CIDRMask(len(ip4)*8, len(ip4)*8),
|
||||
}, nil
|
||||
}
|
||||
return net.IPNet{
|
||||
IP: ip,
|
||||
// single ip
|
||||
Mask: net.CIDRMask(len(ip)*8, len(ip)*8),
|
||||
}, nil
|
||||
} else if ipNet != nil {
|
||||
return *ipNet, nil
|
||||
} else {
|
||||
return net.IPNet{}, errors.New("invalid CIDR")
|
||||
}
|
||||
}
|
||||
|
||||
type Policy struct {
|
||||
|
||||
// Networks map of networks and prefixes to be loaded
|
||||
@@ -43,8 +18,81 @@ type Policy struct {
|
||||
Challenges map[string]Challenge `yaml:"challenges"`
|
||||
|
||||
Rules []Rule `yaml:"rules"`
|
||||
|
||||
// Backends
|
||||
// Deprecated
|
||||
Backends map[string]string `json:"backends"`
|
||||
}
|
||||
|
||||
func NewPolicy(r io.Reader, 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 len(snippetsDirectories) == 0 {
|
||||
err := yaml.NewDecoder(r).Decode(&p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
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
|
||||
for _, entry := range entries {
|
||||
var entryPolicy Policy
|
||||
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
|
||||
|
||||
for k, v := range entryPolicy.Networks {
|
||||
// add network if policy entry does not exist
|
||||
_, ok := p.Networks[k]
|
||||
if !ok {
|
||||
p.Networks[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
for k, v := range entryPolicy.Conditions {
|
||||
// add condition if policy entry does not exist
|
||||
_, ok := p.Conditions[k]
|
||||
if !ok {
|
||||
p.Conditions[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
for k, v := range entryPolicy.Challenges {
|
||||
// add challenge if policy entry does not exist
|
||||
_, ok := p.Challenges[k]
|
||||
if !ok {
|
||||
p.Challenges[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
@@ -1,22 +1,43 @@
|
||||
package policy
|
||||
|
||||
import "github.com/goccy/go-yaml/ast"
|
||||
|
||||
type RuleAction string
|
||||
|
||||
const (
|
||||
RuleActionPASS RuleAction = "PASS"
|
||||
RuleActionDENY RuleAction = "DENY"
|
||||
RuleActionBLOCK RuleAction = "BLOCK"
|
||||
// RuleActionNONE Does nothing. Useful for parent rules when children want to be specified
|
||||
RuleActionNONE RuleAction = "NONE"
|
||||
// RuleActionPASS Passes the connection immediately
|
||||
RuleActionPASS RuleAction = "PASS"
|
||||
// RuleActionDENY Denies the connection with a fancy page
|
||||
RuleActionDENY RuleAction = "DENY"
|
||||
// RuleActionBLOCK Denies the connection with a response code
|
||||
RuleActionBLOCK RuleAction = "BLOCK"
|
||||
// RuleActionCODE Returns a specified HTTP code
|
||||
RuleActionCODE RuleAction = "CODE"
|
||||
|
||||
// RuleActionDROP Drops the connection without sending a reply
|
||||
RuleActionDROP RuleAction = "DROP"
|
||||
|
||||
// RuleActionCHALLENGE Issues a challenge that when passed, passes the connection
|
||||
RuleActionCHALLENGE RuleAction = "CHALLENGE"
|
||||
RuleActionCHECK RuleAction = "CHECK"
|
||||
RuleActionPOISON RuleAction = "POISON"
|
||||
// RuleActionCHECK Issues a challenge that when passed, continues checking rules
|
||||
RuleActionCHECK RuleAction = "CHECK"
|
||||
|
||||
// RuleActionPROXY Proxies request to a backend, with optional path replacements
|
||||
RuleActionPROXY RuleAction = "PROXY"
|
||||
|
||||
// RuleActionCONTEXT Changes Request Context information or properties
|
||||
RuleActionCONTEXT RuleAction = "CONTEXT"
|
||||
)
|
||||
|
||||
type Rule struct {
|
||||
Name string `yaml:"name"`
|
||||
Host *string `yaml:"host"`
|
||||
Conditions []string `yaml:"conditions"`
|
||||
|
||||
Action string `yaml:"action"`
|
||||
|
||||
Challenges []string `yaml:"challenges"`
|
||||
Settings ast.Node `yaml:"settings"`
|
||||
|
||||
Children []Rule `yaml:"children"`
|
||||
}
|
||||
|
||||
19
lib/policy/state.go
Normal file
19
lib/policy/state.go
Normal 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
|
||||
}
|
||||
144
lib/rule.go
Normal file
144
lib/rule.go
Normal file
@@ -0,0 +1,144 @@
|
||||
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/policy"
|
||||
"github.com/google/cel-go/cel"
|
||||
"github.com/google/cel-go/common/types"
|
||||
"github.com/google/cel-go/common/types/ref"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type RuleState struct {
|
||||
Name string
|
||||
Hash string
|
||||
|
||||
Condition cel.Program
|
||||
|
||||
Action policy.RuleAction
|
||||
Handler action.Handler
|
||||
|
||||
Children []RuleState
|
||||
}
|
||||
|
||||
func NewRuleState(state challenge.StateInterface, r policy.Rule, replacer *strings.Replacer, parent *RuleState) (RuleState, error) {
|
||||
hasher := sha256.New()
|
||||
if parent != nil {
|
||||
hasher.Write([]byte(parent.Name))
|
||||
hasher.Write([]byte{0})
|
||||
r.Name = fmt.Sprintf("%s/%s", parent.Name, r.Name)
|
||||
}
|
||||
hasher.Write([]byte(r.Name))
|
||||
hasher.Write([]byte{0})
|
||||
hasher.Write(state.PrivateKeyFingerprint())
|
||||
sum := hasher.Sum(nil)
|
||||
|
||||
rule := RuleState{
|
||||
Name: r.Name,
|
||||
Hash: hex.EncodeToString(sum[:10]),
|
||||
Action: policy.RuleAction(strings.ToUpper(r.Action)),
|
||||
}
|
||||
|
||||
newHandler, ok := action.Register[rule.Action]
|
||||
if !ok {
|
||||
return RuleState{}, fmt.Errorf("unknown action %s", r.Action)
|
||||
}
|
||||
|
||||
actionHandler, err := newHandler(state, rule.Name, rule.Hash, r.Settings)
|
||||
if err != nil {
|
||||
return RuleState{}, err
|
||||
}
|
||||
rule.Handler = actionHandler
|
||||
|
||||
if len(r.Conditions) > 0 {
|
||||
// allow nesting
|
||||
var conditions []string
|
||||
for _, cond := range r.Conditions {
|
||||
cond = replacer.Replace(cond)
|
||||
conditions = append(conditions, cond)
|
||||
}
|
||||
|
||||
program, err := state.RegisterCondition(http_cel.OperatorOr, conditions...)
|
||||
if err != nil {
|
||||
return RuleState{}, fmt.Errorf("error compiling condition: %w", err)
|
||||
}
|
||||
rule.Condition = program
|
||||
}
|
||||
|
||||
if len(r.Children) > 0 {
|
||||
for _, child := range r.Children {
|
||||
childRule, err := NewRuleState(state, child, replacer, &rule)
|
||||
if err != nil {
|
||||
return RuleState{}, fmt.Errorf("child %s: %w", child.Name, err)
|
||||
}
|
||||
rule.Children = append(rule.Children, childRule)
|
||||
}
|
||||
}
|
||||
|
||||
return rule, nil
|
||||
}
|
||||
|
||||
func (rule RuleState) Evaluate(logger *slog.Logger, w http.ResponseWriter, r *http.Request, done func() http.Handler) (next bool, err error) {
|
||||
data := challenge.RequestDataFromContext(r.Context())
|
||||
var out ref.Val
|
||||
|
||||
lg := logger.With("rule", rule.Name, "rule_hash", rule.Hash, "action", string(rule.Action))
|
||||
if rule.Condition != nil {
|
||||
out, _, err = rule.Condition.Eval(data)
|
||||
} else {
|
||||
// default true
|
||||
out = types.Bool(true)
|
||||
}
|
||||
if err != nil {
|
||||
lg.Error(err.Error())
|
||||
return false, fmt.Errorf("error: evaluating administrative rule %s/%s: %w", data.Id.String(), rule.Hash, err)
|
||||
} else if out != nil && out.Type() == types.BoolType {
|
||||
if out.Equal(types.True) == types.True {
|
||||
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)
|
||||
r.Header.Set("X-Away-Action", string(rule.Action))
|
||||
|
||||
return done()
|
||||
})
|
||||
if err != nil {
|
||||
lg.Error(err.Error())
|
||||
return false, fmt.Errorf("error: executing administrative rule %s/%s: %w", data.Id.String(), rule.Hash, err)
|
||||
}
|
||||
|
||||
if !next {
|
||||
return next, nil
|
||||
}
|
||||
|
||||
for _, child := range rule.Children {
|
||||
next, err = child.Evaluate(logger, w, r, done)
|
||||
if err != nil {
|
||||
lg.Error(err.Error())
|
||||
return false, fmt.Errorf("error: executing administrative rule %s/%s: %w", data.Id.String(), rule.Hash, err)
|
||||
}
|
||||
|
||||
if !next {
|
||||
return next, nil
|
||||
}
|
||||
}
|
||||
} else {
|
||||
data.State.RuleMiss(r, rule.Name, logger)
|
||||
}
|
||||
} else if out != nil {
|
||||
err := fmt.Errorf("return type not Bool, got %s", out.Type().TypeName())
|
||||
lg.Error(err.Error())
|
||||
return false, fmt.Errorf("error: evaluating administrative rule %s/%s: %w", data.Id.String(), rule.Hash, err)
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
162
lib/settings/backend.go
Normal file
162
lib/settings/backend.go
Normal file
@@ -0,0 +1,162 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"git.gammaspectra.live/git/go-away/lib/challenge"
|
||||
"git.gammaspectra.live/git/go-away/utils"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Backend struct {
|
||||
// URL Target server backend path. Supports http/https/unix protocols.
|
||||
URL string `yaml:"url"`
|
||||
|
||||
// Host Override the Host header and TLS SNI with this value if specified
|
||||
Host string `yaml:"host"`
|
||||
|
||||
//ProxyProtocol uint8 `yaml:"proxy-protocol"`
|
||||
|
||||
// HTTP2Enabled Enable HTTP2 to backend
|
||||
HTTP2Enabled bool `yaml:"http2-enabled"`
|
||||
|
||||
// TLSSkipVerify Disable TLS certificate verification, if any
|
||||
TLSSkipVerify bool `yaml:"tls-skip-verify"`
|
||||
|
||||
// IpHeader HTTP header to set containing the IP header. Set - to forcefully ignore global defaults.
|
||||
IpHeader string `yaml:"ip-header"`
|
||||
|
||||
// GoDNS Resolve URL using the Go DNS server
|
||||
// Only relevant when running with CGO enabled
|
||||
GoDNS bool `yaml:"go-dns"`
|
||||
|
||||
// Transparent Do not add extra headers onto this backend
|
||||
// This prevents GoAway headers from being set, or other state
|
||||
Transparent bool `yaml:"transparent"`
|
||||
|
||||
// DialTimeout is the maximum amount of time a dial will wait for
|
||||
// a connect to complete.
|
||||
//
|
||||
// The default is no timeout.
|
||||
//
|
||||
// When using TCP and dialing a host name with multiple IP
|
||||
// addresses, the timeout may be divided between them.
|
||||
//
|
||||
// With or without a timeout, the operating system may impose
|
||||
// its own earlier timeout. For instance, TCP timeouts are
|
||||
// often around 3 minutes.
|
||||
DialTimeout time.Duration `yaml:"dial-timeout"`
|
||||
|
||||
// TLSHandshakeTimeout specifies the maximum amount of time to
|
||||
// wait for a TLS handshake. Zero means no timeout.
|
||||
TLSHandshakeTimeout time.Duration `yaml:"tls-handshake-timeout"`
|
||||
|
||||
// IdleConnTimeout is the maximum amount of time an idle
|
||||
// (keep-alive) connection will remain idle before closing
|
||||
// itself.
|
||||
// Zero means no limit.
|
||||
IdleConnTimeout time.Duration `yaml:"idle-conn-timeout"`
|
||||
|
||||
// ResponseHeaderTimeout, if non-zero, specifies the amount of
|
||||
// time to wait for a server's response headers after fully
|
||||
// writing the request (including its body, if any). This
|
||||
// time does not include the time to read the response body.
|
||||
ResponseHeaderTimeout time.Duration `yaml:"response-header-timeout"`
|
||||
|
||||
// ExpectContinueTimeout, if non-zero, specifies the amount of
|
||||
// time to wait for a server's first response headers after fully
|
||||
// writing the request headers if the request has an
|
||||
// "Expect: 100-continue" header. Zero means no timeout and
|
||||
// causes the body to be sent immediately, without
|
||||
// waiting for the server to approve.
|
||||
// This time does not include the time to send the request header.
|
||||
ExpectContinueTimeout time.Duration `yaml:"expect-continue-timeout"`
|
||||
}
|
||||
|
||||
func (b Backend) Create() (*httputil.ReverseProxy, error) {
|
||||
if b.IpHeader == "-" {
|
||||
b.IpHeader = ""
|
||||
}
|
||||
|
||||
proxy, err := utils.MakeReverseProxy(b.URL, b.GoDNS, b.DialTimeout)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
transport := proxy.Transport.(*http.Transport)
|
||||
|
||||
// set transport timeouts
|
||||
transport.TLSHandshakeTimeout = b.TLSHandshakeTimeout
|
||||
transport.IdleConnTimeout = b.IdleConnTimeout
|
||||
transport.ResponseHeaderTimeout = b.ResponseHeaderTimeout
|
||||
transport.ExpectContinueTimeout = b.ExpectContinueTimeout
|
||||
|
||||
if b.HTTP2Enabled {
|
||||
transport.ForceAttemptHTTP2 = true
|
||||
}
|
||||
|
||||
if b.TLSSkipVerify {
|
||||
transport.TLSClientConfig.InsecureSkipVerify = true
|
||||
}
|
||||
|
||||
if b.Host != "" {
|
||||
transport.TLSClientConfig.ServerName = b.Host
|
||||
}
|
||||
|
||||
if b.IpHeader != "" || b.Host != "" || !b.Transparent {
|
||||
director := proxy.Director
|
||||
proxy.Director = func(req *http.Request) {
|
||||
if b.IpHeader != "" && !b.Transparent {
|
||||
if ip := utils.GetRemoteAddress(req.Context()); ip != nil {
|
||||
req.Header.Set(b.IpHeader, ip.Addr().Unmap().String())
|
||||
}
|
||||
}
|
||||
if b.Host != "" {
|
||||
req.Host = b.Host
|
||||
}
|
||||
|
||||
if !b.Transparent {
|
||||
if data := challenge.RequestDataFromContext(req.Context()); data != nil {
|
||||
data.RequestHeaders(req.Header)
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
206
lib/settings/bind.go
Normal file
206
lib/settings/bind.go
Normal file
@@ -0,0 +1,206 @@
|
||||
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"
|
||||
"time"
|
||||
)
|
||||
|
||||
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"`
|
||||
|
||||
// ReadTimeout is the maximum duration for reading the entire
|
||||
// request, including the body. A zero or negative value means
|
||||
// there will be no timeout.
|
||||
//
|
||||
// Because ReadTimeout does not let Handlers make per-request
|
||||
// decisions on each request body's acceptable deadline or
|
||||
// upload rate, most users will prefer to use
|
||||
// ReadHeaderTimeout. It is valid to use them both.
|
||||
ReadTimeout time.Duration `yaml:"read-timeout"`
|
||||
|
||||
// ReadHeaderTimeout is the amount of time allowed to read
|
||||
// request headers. The connection's read deadline is reset
|
||||
// after reading the headers and the Handler can decide what
|
||||
// is considered too slow for the body. If zero, the value of
|
||||
// ReadTimeout is used. If negative, or if zero and ReadTimeout
|
||||
// is zero or negative, there is no timeout.
|
||||
ReadHeaderTimeout time.Duration `yaml:"read-header-timeout"`
|
||||
|
||||
// WriteTimeout is the maximum duration before timing out
|
||||
// writes of the response. It is reset whenever a new
|
||||
// request's header is read. Like ReadTimeout, it does not
|
||||
// let Handlers make decisions on a per-request basis.
|
||||
// A zero or negative value means there will be no timeout.
|
||||
WriteTimeout time.Duration `yaml:"write-timeout"`
|
||||
|
||||
// IdleTimeout is the maximum amount of time to wait for the
|
||||
// next request when keep-alives are enabled. If zero, the value
|
||||
// of ReadTimeout is used. If negative, or if zero and ReadTimeout
|
||||
// is zero or negative, there is no timeout.
|
||||
IdleTimeout time.Duration `yaml:"idle-timeout"`
|
||||
}
|
||||
|
||||
func (b *Bind) Listener() (net.Listener, string) {
|
||||
return setupListener(b.Network, b.Address, b.SocketMode, b.Proxy)
|
||||
}
|
||||
|
||||
func (b *Bind) Server(backends map[string]http.Handler, acmeCachePath string) (*http.Server, func(http.Handler), error) {
|
||||
|
||||
var tlsConfig *tls.Config
|
||||
|
||||
if b.TLSAcmeAutoCert != "" {
|
||||
switch b.TLSAcmeAutoCert {
|
||||
case "letsencrypt":
|
||||
b.TLSAcmeAutoCert = acme.LetsEncryptURL
|
||||
}
|
||||
|
||||
acmeManager := newACMEManager(b.TLSAcmeAutoCert, backends)
|
||||
if acmeCachePath != "" {
|
||||
err := os.MkdirAll(acmeCachePath, 0755)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to create acme cache directory: %w", err)
|
||||
}
|
||||
acmeManager.Cache = autocert.DirCache(acmeCachePath)
|
||||
}
|
||||
slog.Warn(
|
||||
"acme-autocert enabled",
|
||||
"directory", b.TLSAcmeAutoCert,
|
||||
)
|
||||
tlsConfig = acmeManager.TLSConfig()
|
||||
} else if b.TLSCertificate != "" && b.TLSPrivateKey != "" {
|
||||
tlsConfig = &tls.Config{}
|
||||
var err error
|
||||
tlsConfig.Certificates = make([]tls.Certificate, 1)
|
||||
tlsConfig.Certificates[0], err = tls.LoadX509KeyPair(b.TLSCertificate, b.TLSPrivateKey)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
slog.Warn(
|
||||
"TLS enabled",
|
||||
"certificate", b.TLSCertificate,
|
||||
)
|
||||
}
|
||||
|
||||
var serverHandler atomic.Pointer[http.Handler]
|
||||
server := utils.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if handler := serverHandler.Load(); handler == nil {
|
||||
http.Error(w, http.StatusText(http.StatusBadGateway), http.StatusBadGateway)
|
||||
} else {
|
||||
(*handler).ServeHTTP(w, r)
|
||||
}
|
||||
}), tlsConfig)
|
||||
|
||||
server.ReadTimeout = b.ReadTimeout
|
||||
server.ReadHeaderTimeout = b.ReadHeaderTimeout
|
||||
server.WriteTimeout = b.WriteTimeout
|
||||
server.IdleTimeout = b.IdleTimeout
|
||||
|
||||
swap := func(handler http.Handler) {
|
||||
serverHandler.Store(&handler)
|
||||
}
|
||||
|
||||
if b.Passthrough {
|
||||
// setup a passthrough handler temporarily
|
||||
swap(http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
backend := utils.SelectHTTPHandler(backends, r.Host)
|
||||
if backend == nil {
|
||||
slog.Debug("no backend for host", "host", r.Host)
|
||||
http.Error(w, http.StatusText(http.StatusBadGateway), http.StatusBadGateway)
|
||||
} else {
|
||||
backend.ServeHTTP(w, r)
|
||||
}
|
||||
})))
|
||||
}
|
||||
|
||||
return server, swap, nil
|
||||
|
||||
}
|
||||
|
||||
func setupListener(network, address, socketMode string, proxy bool) (net.Listener, string) {
|
||||
if network == "proxy" {
|
||||
network = "tcp"
|
||||
proxy = true
|
||||
}
|
||||
|
||||
formattedAddress := ""
|
||||
switch network {
|
||||
case "unix":
|
||||
formattedAddress = "unix:" + address
|
||||
case "tcp":
|
||||
formattedAddress = "http://localhost" + address
|
||||
default:
|
||||
formattedAddress = fmt.Sprintf(`(%s) %s`, network, address)
|
||||
}
|
||||
|
||||
listener, err := net.Listen(network, address)
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("failed to bind to %s: %w", formattedAddress, err))
|
||||
}
|
||||
|
||||
// additional permission handling for unix sockets
|
||||
if network == "unix" {
|
||||
mode, err := strconv.ParseUint(socketMode, 8, 0)
|
||||
if err != nil {
|
||||
listener.Close()
|
||||
panic(fmt.Errorf("could not parse socket mode %s: %w", socketMode, err))
|
||||
}
|
||||
|
||||
err = os.Chmod(address, os.FileMode(mode))
|
||||
if err != nil {
|
||||
listener.Close()
|
||||
panic(fmt.Errorf("could not change socket mode: %w", err))
|
||||
}
|
||||
}
|
||||
|
||||
if proxy {
|
||||
slog.Warn("listener PROXY enabled")
|
||||
formattedAddress += " +PROXY"
|
||||
listener = &proxyproto.Listener{
|
||||
Listener: listener,
|
||||
}
|
||||
}
|
||||
|
||||
return listener, formattedAddress
|
||||
}
|
||||
|
||||
func newACMEManager(clientDirectory string, backends map[string]http.Handler) *autocert.Manager {
|
||||
manager := &autocert.Manager{
|
||||
Prompt: autocert.AcceptTOS,
|
||||
HostPolicy: autocert.HostPolicy(func(ctx context.Context, host string) error {
|
||||
if utils.SelectHTTPHandler(backends, host) != nil {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("acme/autocert: host %s not configured in backends", host)
|
||||
}),
|
||||
Client: &acme.Client{
|
||||
HTTPClient: http.DefaultClient,
|
||||
DirectoryURL: clientDirectory,
|
||||
},
|
||||
}
|
||||
return manager
|
||||
}
|
||||
52
lib/settings/settings.go
Normal file
52
lib/settings/settings.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"git.gammaspectra.live/git/go-away/utils"
|
||||
"maps"
|
||||
)
|
||||
|
||||
type Settings struct {
|
||||
Bind Bind `yaml:"bind"`
|
||||
|
||||
Backends map[string]Backend `yaml:"backends"`
|
||||
|
||||
BindDebug string `yaml:"bind-debug"`
|
||||
BindMetrics string `yaml:"bind-metrics"`
|
||||
|
||||
Strings utils.Strings `yaml:"strings"`
|
||||
|
||||
// Links to add to challenge/error pages like privacy/impressum.
|
||||
Links []Link `yaml:"links"`
|
||||
|
||||
ChallengeTemplate string `yaml:"challenge-template"`
|
||||
|
||||
// ChallengeTemplateOverrides Key/Value overrides for the current chosen template
|
||||
ChallengeTemplateOverrides map[string]string `yaml:"challenge-template-overrides"`
|
||||
}
|
||||
|
||||
type Link struct {
|
||||
Name string `yaml:"name"`
|
||||
URL string `yaml:"url"`
|
||||
}
|
||||
|
||||
var DefaultSettings = Settings{
|
||||
Strings: DefaultStrings,
|
||||
ChallengeTemplate: "anubis",
|
||||
ChallengeTemplateOverrides: func() map[string]string {
|
||||
m := make(map[string]string)
|
||||
maps.Copy(m, map[string]string{
|
||||
"Theme": "",
|
||||
"Logo": "",
|
||||
})
|
||||
return m
|
||||
}(),
|
||||
|
||||
Bind: Bind{
|
||||
Address: ":8080",
|
||||
Network: "tcp",
|
||||
SocketMode: "0770",
|
||||
Proxy: false,
|
||||
TLSAcmeAutoCert: "",
|
||||
},
|
||||
Backends: make(map[string]Backend),
|
||||
}
|
||||
38
lib/settings/strings.go
Normal file
38
lib/settings/strings.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"git.gammaspectra.live/git/go-away/utils"
|
||||
)
|
||||
|
||||
var DefaultStrings = utils.NewStrings(map[string]string{
|
||||
"title_challenge": "Checking you are not a bot",
|
||||
"title_error": "Oh no!",
|
||||
|
||||
"noscript_warning": "<p>Sadly, you may need to enable JavaScript to get past this challenge. This is required because AI companies have changed the social contract around how website hosting works.</p>",
|
||||
|
||||
"details_title": "Why am I seeing this?",
|
||||
"details_text": `
|
||||
<p>
|
||||
You are seeing this because the administrator of this website has set up <a href="https://git.gammaspectra.live/git/go-away">go-away</a>
|
||||
to protect the server against the scourge of <a href="https://thelibre.news/foss-infrastructure-is-under-attack-by-ai-companies/">AI companies aggressively scraping websites</a>.
|
||||
</p>
|
||||
<p>
|
||||
Mass scraping can and does cause downtime for the websites, which makes their resources inaccessible for everyone.
|
||||
</p>
|
||||
<p>
|
||||
Please note that some challenges requires the use of modern JavaScript features and some plugins may disable these.
|
||||
Disable such plugins for this domain (for example, JShelter) if you encounter any issues.
|
||||
</p>
|
||||
`,
|
||||
"details_contact_admin_with_request_id": "If you have any issues contact the site administrator and provide the following Request Id",
|
||||
|
||||
"button_refresh_page": "Refresh page",
|
||||
|
||||
"status_loading_challenge": "Loading challenge",
|
||||
"status_starting_challenge": "Starting challenge",
|
||||
"status_loading": "Loading...",
|
||||
"status_calculating": "Calculating...",
|
||||
"status_challenge_success": "Challenge success!",
|
||||
"status_challenge_done_took": "Done! Took",
|
||||
"status_error": "Error:",
|
||||
})
|
||||
862
lib/state.go
862
lib/state.go
@@ -1,157 +1,99 @@
|
||||
package lib
|
||||
|
||||
import (
|
||||
"codeberg.org/meta/gzipped/v2"
|
||||
"context"
|
||||
http_cel "codeberg.org/gone/http-cel"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"git.gammaspectra.live/git/go-away/embed"
|
||||
"git.gammaspectra.live/git/go-away/lib/challenge"
|
||||
"git.gammaspectra.live/git/go-away/lib/challenge/wasm"
|
||||
"git.gammaspectra.live/git/go-away/lib/challenge/wasm/interface"
|
||||
"git.gammaspectra.live/git/go-away/lib/condition"
|
||||
"git.gammaspectra.live/git/go-away/lib/policy"
|
||||
"git.gammaspectra.live/git/go-away/lib/settings"
|
||||
"git.gammaspectra.live/git/go-away/utils"
|
||||
"git.gammaspectra.live/git/go-away/utils/inline"
|
||||
"github.com/google/cel-go/cel"
|
||||
"github.com/tetratelabs/wazero/api"
|
||||
"github.com/google/cel-go/common/types"
|
||||
"github.com/yl2chen/cidranger"
|
||||
"html/template"
|
||||
"io"
|
||||
"io/fs"
|
||||
"golang.org/x/net/html"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
type State struct {
|
||||
Client *http.Client
|
||||
Settings StateSettings
|
||||
UrlPath string
|
||||
Mux *http.ServeMux
|
||||
client *http.Client
|
||||
radb *utils.RADb
|
||||
urlPath string
|
||||
|
||||
Networks map[string]cidranger.Ranger
|
||||
programEnv *cel.Env
|
||||
|
||||
Wasm *wasm.Runner
|
||||
publicKey ed25519.PublicKey
|
||||
privateKey ed25519.PrivateKey
|
||||
privateKeyFingerprint []byte
|
||||
|
||||
Challenges map[challenge.Id]challenge.Challenge
|
||||
opt settings.Settings
|
||||
settings policy.StateSettings
|
||||
|
||||
RulesEnv *cel.Env
|
||||
networks map[string]func() cidranger.Ranger
|
||||
|
||||
Rules []RuleState
|
||||
challenges challenge.Register
|
||||
|
||||
publicKey ed25519.PublicKey
|
||||
privateKey ed25519.PrivateKey
|
||||
|
||||
Poison map[string][]byte
|
||||
|
||||
ChallengeSolve sync.Map
|
||||
|
||||
DecayMap *utils.DecayMap[[net.IPv6len]byte, utils.DNSBLResponse]
|
||||
rules []RuleState
|
||||
|
||||
close chan struct{}
|
||||
|
||||
tagCache *utils.DecayMap[string, []html.Node]
|
||||
|
||||
Mux *http.ServeMux
|
||||
}
|
||||
|
||||
func (state *State) AwaitChallenge(key []byte, ctx context.Context) challenge.VerifyResult {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
var result atomic.Int64
|
||||
|
||||
state.ChallengeSolve.Store(string(key), ChallengeCallback(func(receivedResult challenge.VerifyResult) {
|
||||
result.Store(int64(receivedResult))
|
||||
cancel()
|
||||
}))
|
||||
|
||||
<-ctx.Done()
|
||||
|
||||
return challenge.VerifyResult(result.Load())
|
||||
}
|
||||
|
||||
func (state *State) SolveChallenge(key []byte, result challenge.VerifyResult) {
|
||||
if f, ok := state.ChallengeSolve.LoadAndDelete(string(key)); ok && f != nil {
|
||||
if cb, ok := f.(ChallengeCallback); ok {
|
||||
cb(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type ChallengeCallback func(result challenge.VerifyResult)
|
||||
|
||||
type RuleState struct {
|
||||
Name string
|
||||
Hash string
|
||||
|
||||
Host *string
|
||||
|
||||
Program cel.Program
|
||||
Action policy.RuleAction
|
||||
Challenges []challenge.Id
|
||||
}
|
||||
|
||||
type StateSettings struct {
|
||||
Backends map[string]http.Handler
|
||||
PrivateKeySeed []byte
|
||||
Debug bool
|
||||
PackageName string
|
||||
ChallengeTemplate string
|
||||
ChallengeTemplateTheme string
|
||||
ClientIpHeader string
|
||||
BackendIpHeader string
|
||||
DNSBL *utils.DNSBL
|
||||
}
|
||||
|
||||
func NewState(p policy.Policy, settings StateSettings) (handler http.Handler, err error) {
|
||||
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.Client = &http.Client{
|
||||
state.settings = settings
|
||||
state.opt = opt
|
||||
metrics.Reset()
|
||||
state.client = &http.Client{
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
state.UrlPath = "/.well-known/." + state.Settings.PackageName
|
||||
|
||||
if state.Settings.DNSBL != nil {
|
||||
state.DecayMap = utils.NewDecayMap[[net.IPv6len]byte, utils.DNSBLResponse]()
|
||||
state.radb, err = utils.NewRADb()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize RADb client: %w", err)
|
||||
}
|
||||
|
||||
state.urlPath = state.Settings().BasePath
|
||||
|
||||
// set a reasonable configuration for default http proxy if there is none
|
||||
for _, backend := range state.Settings.Backends {
|
||||
for _, backend := range state.Settings().Backends {
|
||||
if proxy, ok := backend.(*httputil.ReverseProxy); ok {
|
||||
if proxy.ErrorHandler == nil {
|
||||
proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
|
||||
state.logger(r).Error(err.Error())
|
||||
_ = state.errorPage(w, r.Header.Get("X-Away-Id"), http.StatusBadGateway, err, "")
|
||||
state.Logger(r).Error(err.Error())
|
||||
state.ErrorPage(w, r, http.StatusBadGateway, err, "")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(state.Settings.PrivateKeySeed) > 0 {
|
||||
if len(state.Settings.PrivateKeySeed) != ed25519.SeedSize {
|
||||
return nil, fmt.Errorf("invalid private key seed length: %d", len(state.Settings.PrivateKeySeed))
|
||||
if len(state.Settings().PrivateKeySeed) > 0 {
|
||||
if len(state.Settings().PrivateKeySeed) != ed25519.SeedSize {
|
||||
return nil, fmt.Errorf("invalid private key seed length: %d", len(state.Settings().PrivateKeySeed))
|
||||
}
|
||||
|
||||
state.privateKey = ed25519.NewKeyFromSeed(state.Settings.PrivateKeySeed)
|
||||
state.privateKey = ed25519.NewKeyFromSeed(state.Settings().PrivateKeySeed)
|
||||
state.publicKey = state.privateKey.Public().(ed25519.PublicKey)
|
||||
|
||||
clear(state.Settings.PrivateKeySeed)
|
||||
clear(state.settings.PrivateKeySeed)
|
||||
|
||||
} else {
|
||||
state.publicKey, state.privateKey, err = ed25519.GenerateKey(rand.Reader)
|
||||
@@ -160,52 +102,108 @@ func NewState(p policy.Policy, settings StateSettings) (handler http.Handler, er
|
||||
}
|
||||
}
|
||||
|
||||
privateKeyFingerprint := sha256.Sum256(state.privateKey)
|
||||
fp := sha256.Sum256(state.privateKey)
|
||||
state.privateKeyFingerprint = fp[:]
|
||||
|
||||
if state.Settings.ChallengeTemplate == "" {
|
||||
state.Settings.ChallengeTemplate = "anubis"
|
||||
}
|
||||
if templates["challenge-"+state.opt.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.opt.ChallengeTemplate); err == nil && len(data) > 0 {
|
||||
name := path.Base(state.opt.ChallengeTemplate)
|
||||
err := initTemplate(name, string(data))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error loading template %s: %w", settings.ChallengeTemplate, err)
|
||||
return nil, fmt.Errorf("error loading template %s: %w", state.opt.ChallengeTemplate, err)
|
||||
}
|
||||
state.Settings.ChallengeTemplate = name
|
||||
state.opt.ChallengeTemplate = name
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no template defined for %s", settings.ChallengeTemplate)
|
||||
return nil, fmt.Errorf("no template defined for %s", state.opt.ChallengeTemplate)
|
||||
}
|
||||
|
||||
state.Networks = make(map[string]cidranger.Ranger)
|
||||
state.networks = make(map[string]func() cidranger.Ranger)
|
||||
|
||||
networkCache := utils.CachePrefix(state.Settings().Cache, "networks/")
|
||||
|
||||
for k, network := range p.Networks {
|
||||
ranger := cidranger.NewPCTrieRanger()
|
||||
for _, e := range network {
|
||||
if e.Url != nil {
|
||||
slog.Debug("loading network url list", "network", k, "url", *e.Url)
|
||||
}
|
||||
prefixes, err := e.FetchPrefixes(state.Client)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("networks %s: error fetching prefixes: %v", k, err)
|
||||
}
|
||||
for _, prefix := range prefixes {
|
||||
err = ranger.Insert(cidranger.NewBasicRangerEntry(prefix))
|
||||
state.networks[k] = sync.OnceValue[cidranger.Ranger](func() cidranger.Ranger {
|
||||
ranger := cidranger.NewPCTrieRanger()
|
||||
for i, e := range network {
|
||||
prefixes, err := func() ([]net.IPNet, error) {
|
||||
var useCache bool
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
var cached []net.IPNet
|
||||
if useCache && networkCache != nil {
|
||||
//TODO: add randomness
|
||||
cachedData, err := networkCache.Get(cacheKey, time.Hour*24)
|
||||
var l []string
|
||||
_ = json.Unmarshal(cachedData, &l)
|
||||
for _, n := range l {
|
||||
_, ipNet, err := net.ParseCIDR(n)
|
||||
if err == nil {
|
||||
cached = append(cached, *ipNet)
|
||||
}
|
||||
}
|
||||
if err == nil {
|
||||
// use
|
||||
return cached, nil
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
prefixes, err := e.FetchPrefixes(state.client, state.radb)
|
||||
if err != nil {
|
||||
if len(cached) > 0 {
|
||||
// use cached meanwhile
|
||||
return cached, err
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if useCache && networkCache != nil {
|
||||
var l []string
|
||||
for _, n := range prefixes {
|
||||
l = append(l, n.String())
|
||||
}
|
||||
cachedData, err := json.Marshal(l)
|
||||
if err == nil {
|
||||
_ = networkCache.Set(cacheKey, cachedData)
|
||||
}
|
||||
}
|
||||
return prefixes, nil
|
||||
}()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("networks %s: error inserting prefix %s: %v", k, prefix.String(), err)
|
||||
if e.Url != nil {
|
||||
slog.Error("error loading network list", "network", k, "url", *e.Url, "error", err)
|
||||
} else if e.ASN != nil {
|
||||
slog.Error("error loading ASN", "network", k, "asn", *e.ASN, "error", err)
|
||||
} else {
|
||||
slog.Error("error loading list", "network", k, "error", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
for _, prefix := range prefixes {
|
||||
err = ranger.Insert(cidranger.NewBasicRangerEntry(prefix))
|
||||
if err != nil {
|
||||
slog.Error("error inserting prefix", "network", k, "prefix", prefix.String(), "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
slog.Warn("loaded network prefixes", "network", k, "count", ranger.Len())
|
||||
|
||||
state.Networks[k] = ranger
|
||||
slog.Warn("loaded network prefixes", "network", k, "count", ranger.Len())
|
||||
return ranger
|
||||
})
|
||||
}
|
||||
|
||||
state.Wasm = wasm.NewRunner(true)
|
||||
|
||||
err = state.initConditions()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -213,11 +211,17 @@ func NewState(p policy.Policy, settings StateSettings) (handler http.Handler, er
|
||||
|
||||
var replacements []string
|
||||
for k, entries := range p.Conditions {
|
||||
ast, err := condition.FromStrings(state.RulesEnv, condition.OperatorOr, entries...)
|
||||
ast, err := http_cel.NewAst(state.programEnv, http_cel.OperatorOr, entries...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("conditions %s: error compiling conditions: %v", k, err)
|
||||
}
|
||||
|
||||
if out := ast.OutputType(); out == nil {
|
||||
return nil, fmt.Errorf("conditions %s: error compiling conditions: no output", k)
|
||||
} else if out != types.BoolType {
|
||||
return nil, fmt.Errorf("conditions %s: error compiling conditions: output type is not bool", k)
|
||||
}
|
||||
|
||||
cond, err := cel.AstToString(ast)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("conditions %s: error printing condition: %v", k, err)
|
||||
@@ -228,563 +232,25 @@ func NewState(p policy.Policy, settings StateSettings) (handler http.Handler, er
|
||||
}
|
||||
conditionReplacer := strings.NewReplacer(replacements...)
|
||||
|
||||
state.Challenges = make(map[challenge.Id]challenge.Challenge)
|
||||
|
||||
idCounter := challenge.Id(1)
|
||||
state.challenges = make(challenge.Register)
|
||||
|
||||
//TODO: move this to self-contained challenge files
|
||||
for challengeName, p := range p.Challenges {
|
||||
|
||||
// allow nesting
|
||||
var conditions []string
|
||||
for _, cond := range p.Conditions {
|
||||
cond = conditionReplacer.Replace(cond)
|
||||
conditions = append(conditions, cond)
|
||||
for challengeName, pol := range p.Challenges {
|
||||
_, _, err := state.challenges.Create(state, challengeName, pol, conditionReplacer)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("challenge %s: %w", challengeName, err)
|
||||
}
|
||||
|
||||
var program cel.Program
|
||||
if len(conditions) > 0 {
|
||||
ast, err := condition.FromStrings(state.RulesEnv, condition.OperatorOr, conditions...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("challenge %s: error compiling conditions: %v", challengeName, err)
|
||||
}
|
||||
program, err = state.RulesEnv.Program(ast)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("challenge %s: error compiling program: %v", challengeName, err)
|
||||
}
|
||||
}
|
||||
|
||||
c := challenge.Challenge{
|
||||
Id: idCounter,
|
||||
Program: program,
|
||||
Name: challengeName,
|
||||
Path: fmt.Sprintf("%s/challenge/%s", state.UrlPath, challengeName),
|
||||
VerifyProbability: p.Runtime.Probability,
|
||||
}
|
||||
idCounter++
|
||||
|
||||
if c.VerifyProbability <= 0 {
|
||||
//10% default
|
||||
c.VerifyProbability = 0.1
|
||||
} else if c.VerifyProbability > 1.0 {
|
||||
c.VerifyProbability = 1.0
|
||||
}
|
||||
|
||||
assetPath := c.Path + "/static/"
|
||||
subFs, err := fs.Sub(embed.ChallengeFs, fmt.Sprintf("challenge/%s/static", challengeName))
|
||||
if err == nil {
|
||||
c.ServeStatic = http.StripPrefix(
|
||||
assetPath,
|
||||
gzipped.FileServer(gzipped.FS(subFs)),
|
||||
)
|
||||
}
|
||||
|
||||
switch p.Mode {
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown challenge mode: %s", p.Mode)
|
||||
case "http":
|
||||
if p.Url == nil {
|
||||
return nil, fmt.Errorf("challenge %s: missing url", challengeName)
|
||||
}
|
||||
method := p.Parameters["http-method"]
|
||||
if method == "" {
|
||||
method = "GET"
|
||||
}
|
||||
|
||||
httpCode, _ := strconv.Atoi(p.Parameters["http-code"])
|
||||
if httpCode == 0 {
|
||||
httpCode = http.StatusOK
|
||||
}
|
||||
|
||||
expectedCookie := p.Parameters["http-cookie"]
|
||||
|
||||
c.Verify = func(key []byte, result string, r *http.Request) (bool, error) {
|
||||
var cookieValue string
|
||||
if expectedCookie != "" {
|
||||
if cookie, err := r.Cookie(expectedCookie); err != nil || cookie == nil {
|
||||
// skip check if we don't have cookie or it's expired
|
||||
return false, nil
|
||||
} else {
|
||||
cookieValue = cookie.Value
|
||||
}
|
||||
}
|
||||
// bind hash of cookie contents
|
||||
sum := sha256.New()
|
||||
sum.Write([]byte(cookieValue))
|
||||
sum.Write([]byte{0})
|
||||
sum.Write(key)
|
||||
sum.Write([]byte{0})
|
||||
sum.Write(state.publicKey)
|
||||
|
||||
if subtle.ConstantTimeCompare(sum.Sum(nil), []byte(result)) == 1 {
|
||||
return true, nil
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
c.ServeChallenge = func(w http.ResponseWriter, r *http.Request, key []byte, expiry time.Time) challenge.Result {
|
||||
|
||||
data := RequestDataFromContext(r.Context())
|
||||
|
||||
if result := data.Challenges[c.Id]; result.Ok() {
|
||||
return challenge.ResultPass
|
||||
}
|
||||
|
||||
var cookieValue string
|
||||
if expectedCookie != "" {
|
||||
if cookie, err := r.Cookie(expectedCookie); err != nil || cookie == nil {
|
||||
// skip check if we don't have cookie or it's expired
|
||||
return challenge.ResultContinue
|
||||
} else {
|
||||
cookieValue = cookie.Value
|
||||
}
|
||||
}
|
||||
|
||||
request, err := http.NewRequest(method, *p.Url, nil)
|
||||
if err != nil {
|
||||
return challenge.ResultContinue
|
||||
}
|
||||
|
||||
request.Header = r.Header
|
||||
response, err := state.Client.Do(request)
|
||||
if err != nil {
|
||||
return challenge.ResultContinue
|
||||
}
|
||||
defer response.Body.Close()
|
||||
defer io.Copy(io.Discard, response.Body)
|
||||
|
||||
if response.StatusCode != httpCode {
|
||||
utils.ClearCookie(utils.CookiePrefix+c.Name, w)
|
||||
// continue other challenges!
|
||||
|
||||
//TODO: negatively cache failure
|
||||
|
||||
return challenge.ResultContinue
|
||||
} else {
|
||||
// bind hash of cookie contents
|
||||
sum := sha256.New()
|
||||
sum.Write([]byte(cookieValue))
|
||||
sum.Write([]byte{0})
|
||||
sum.Write(key)
|
||||
sum.Write([]byte{0})
|
||||
sum.Write(state.publicKey)
|
||||
token, err := c.IssueChallengeToken(state.privateKey, key, sum.Sum(nil), expiry)
|
||||
if err != nil {
|
||||
utils.ClearCookie(utils.CookiePrefix+c.Name, w)
|
||||
} else {
|
||||
utils.SetCookie(utils.CookiePrefix+challengeName, token, expiry, w)
|
||||
}
|
||||
|
||||
data.Challenges[c.Id] = challenge.VerifyResultPASS
|
||||
|
||||
// we passed it!
|
||||
return challenge.ResultPass
|
||||
}
|
||||
}
|
||||
|
||||
case "cookie":
|
||||
c.ServeChallenge = func(w http.ResponseWriter, r *http.Request, key []byte, expiry time.Time) challenge.Result {
|
||||
if chall := r.URL.Query().Get("__goaway_challenge"); chall == challengeName {
|
||||
state.logger(r).Warn("challenge failed", "challenge", c.Name)
|
||||
utils.ClearCookie(utils.CookiePrefix+c.Name, w)
|
||||
_ = state.errorPage(w, r.Header.Get("X-Away-Id"), http.StatusForbidden, fmt.Errorf("access denied: failed challenge %s", c.Name), "")
|
||||
return challenge.ResultStop
|
||||
}
|
||||
|
||||
token, err := c.IssueChallengeToken(state.privateKey, key, nil, expiry)
|
||||
if err != nil {
|
||||
utils.ClearCookie(utils.CookiePrefix+challengeName, w)
|
||||
} else {
|
||||
utils.SetCookie(utils.CookiePrefix+challengeName, token, expiry, w)
|
||||
}
|
||||
|
||||
// self redirect!
|
||||
uri, err := url.ParseRequestURI(r.URL.String())
|
||||
values := uri.Query()
|
||||
values.Set("__goaway_challenge", challengeName)
|
||||
uri.RawQuery = values.Encode()
|
||||
|
||||
http.Redirect(w, r, uri.String(), http.StatusTemporaryRedirect)
|
||||
return challenge.ResultStop
|
||||
}
|
||||
case "meta-refresh":
|
||||
c.ServeChallenge = func(w http.ResponseWriter, r *http.Request, key []byte, expiry time.Time) challenge.Result {
|
||||
redirectUri := new(url.URL)
|
||||
redirectUri.Path = c.Path + "/verify-challenge"
|
||||
|
||||
values := make(url.Values)
|
||||
values.Set("result", hex.EncodeToString(key))
|
||||
values.Set("redirect", r.URL.String())
|
||||
values.Set("requestId", r.Header.Get("X-Away-Id"))
|
||||
|
||||
redirectUri.RawQuery = values.Encode()
|
||||
|
||||
_ = state.challengePage(w, r.Header.Get("X-Away-Id"), http.StatusTeapot, "", map[string]any{
|
||||
"Meta": map[string]string{
|
||||
"refresh": "0; url=" + redirectUri.String(),
|
||||
},
|
||||
})
|
||||
|
||||
return challenge.ResultStop
|
||||
}
|
||||
case "header-refresh":
|
||||
c.ServeChallenge = func(w http.ResponseWriter, r *http.Request, key []byte, expiry time.Time) challenge.Result {
|
||||
redirectUri := new(url.URL)
|
||||
redirectUri.Path = c.Path + "/verify-challenge"
|
||||
|
||||
values := make(url.Values)
|
||||
values.Set("result", hex.EncodeToString(key))
|
||||
values.Set("redirect", r.URL.String())
|
||||
values.Set("requestId", r.Header.Get("X-Away-Id"))
|
||||
|
||||
redirectUri.RawQuery = values.Encode()
|
||||
|
||||
// self redirect!
|
||||
w.Header().Set("Refresh", "0; url="+redirectUri.String())
|
||||
|
||||
_ = state.challengePage(w, r.Header.Get("X-Away-Id"), http.StatusTeapot, "", nil)
|
||||
|
||||
return challenge.ResultStop
|
||||
}
|
||||
case "preload-link":
|
||||
deadline, _ := time.ParseDuration(p.Parameters["preload-early-hint-deadline"])
|
||||
if deadline == 0 {
|
||||
deadline = time.Second * 3
|
||||
}
|
||||
|
||||
c.ServeChallenge = func(w http.ResponseWriter, r *http.Request, key []byte, expiry time.Time) challenge.Result {
|
||||
// this only works on HTTP/2 and HTTP/3
|
||||
|
||||
if r.ProtoMajor < 2 {
|
||||
// this can happen if we are an upgraded request from HTTP/1.1 to HTTP/2 in H2C
|
||||
if _, ok := w.(http.Pusher); !ok {
|
||||
return challenge.ResultContinue
|
||||
}
|
||||
}
|
||||
|
||||
data := RequestDataFromContext(r.Context())
|
||||
redirectUri := new(url.URL)
|
||||
redirectUri.Scheme = getRequestScheme(r)
|
||||
redirectUri.Host = r.Host
|
||||
redirectUri.Path = c.Path + "/verify-challenge"
|
||||
|
||||
values := make(url.Values)
|
||||
values.Set("result", hex.EncodeToString(key))
|
||||
values.Set("requestId", r.Header.Get("X-Away-Id"))
|
||||
|
||||
redirectUri.RawQuery = values.Encode()
|
||||
|
||||
w.Header().Set("Link", fmt.Sprintf("<%s>; rel=\"preload\"; as=\"style\"; fetchpriority=high", redirectUri.String()))
|
||||
defer func() {
|
||||
// remove old header so it won't show on response!
|
||||
w.Header().Del("Link")
|
||||
}()
|
||||
w.WriteHeader(http.StatusEarlyHints)
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), deadline)
|
||||
defer cancel()
|
||||
if result := state.AwaitChallenge(key, ctx); result.Ok() {
|
||||
data.Challenges[c.Id] = challenge.VerifyResultPASS
|
||||
|
||||
// this should serve!
|
||||
return challenge.ResultPass
|
||||
}
|
||||
|
||||
data.Challenges[c.Id] = challenge.VerifyResultFAIL
|
||||
// we failed, continue
|
||||
return challenge.ResultContinue
|
||||
}
|
||||
case "resource-load":
|
||||
c.ServeChallenge = func(w http.ResponseWriter, r *http.Request, key []byte, expiry time.Time) challenge.Result {
|
||||
redirectUri := new(url.URL)
|
||||
redirectUri.Path = c.Path + "/verify-challenge"
|
||||
|
||||
values := make(url.Values)
|
||||
values.Set("result", hex.EncodeToString(key))
|
||||
values.Set("requestId", r.Header.Get("X-Away-Id"))
|
||||
|
||||
redirectUri.RawQuery = values.Encode()
|
||||
|
||||
// self redirect!
|
||||
w.Header().Set("Refresh", "2; url="+r.URL.String())
|
||||
|
||||
_ = state.challengePage(w, r.Header.Get("X-Away-Id"), http.StatusTeapot, "", map[string]any{
|
||||
"Tags": []template.HTML{
|
||||
template.HTML(fmt.Sprintf("<link href=\"%s\" rel=\"stylesheet\" crossorigin=\"use-credentials\">", redirectUri.String())),
|
||||
},
|
||||
})
|
||||
|
||||
return challenge.ResultStop
|
||||
}
|
||||
case "js":
|
||||
c.ServeChallenge = func(w http.ResponseWriter, r *http.Request, key []byte, expiry time.Time) challenge.Result {
|
||||
_ = state.challengePage(w, r.Header.Get("X-Away-Id"), http.StatusTeapot, challengeName, nil)
|
||||
|
||||
return challenge.ResultStop
|
||||
}
|
||||
c.ServeScriptPath = c.Path + "/challenge.mjs"
|
||||
c.ServeScript = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
params, _ := json.Marshal(p.Parameters)
|
||||
|
||||
//TODO: move this to http.go as a template
|
||||
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
w.Header().Set("Content-Type", "text/javascript; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
err := templates["challenge.mjs"].Execute(w, map[string]any{
|
||||
"Path": c.Path,
|
||||
"Parameters": string(params),
|
||||
"Random": cacheBust,
|
||||
"Challenge": challengeName,
|
||||
"ChallengeScript": func() string {
|
||||
if p.Asset != nil {
|
||||
return assetPath + *p.Asset
|
||||
} else if p.Url != nil {
|
||||
return *p.Url
|
||||
} else {
|
||||
panic("not implemented")
|
||||
}
|
||||
}(),
|
||||
})
|
||||
if err != nil {
|
||||
//TODO: log
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// how to runtime
|
||||
switch p.Runtime.Mode {
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown challenge runtime mode: %s", p.Runtime.Mode)
|
||||
case "":
|
||||
case "http":
|
||||
case "key":
|
||||
mimeType := p.Parameters["key-mime"]
|
||||
if mimeType == "" {
|
||||
mimeType = "text/html; charset=utf-8"
|
||||
}
|
||||
|
||||
httpCode, _ := strconv.Atoi(p.Parameters["key-code"])
|
||||
if httpCode == 0 {
|
||||
httpCode = http.StatusTemporaryRedirect
|
||||
}
|
||||
|
||||
var content []byte
|
||||
if data, ok := p.Parameters["key-content"]; ok {
|
||||
content = []byte(data)
|
||||
}
|
||||
|
||||
c.Verify = func(key []byte, result string, r *http.Request) (bool, error) {
|
||||
resultBytes, err := hex.DecodeString(result)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if subtle.ConstantTimeCompare(resultBytes, key) != 1 {
|
||||
return false, nil
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
c.ServeVerifyChallenge = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
redirect, err := utils.EnsureNoOpenRedirect(r.FormValue("redirect"))
|
||||
if err != nil {
|
||||
_ = state.errorPage(w, r.Header.Get("X-Away-Id"), http.StatusBadRequest, err, "")
|
||||
return
|
||||
}
|
||||
|
||||
err = func() (err error) {
|
||||
|
||||
data := RequestDataFromContext(r.Context())
|
||||
|
||||
key := state.GetChallengeKeyForRequest(challengeName, data.Expires, r)
|
||||
result := r.FormValue("result")
|
||||
|
||||
requestId, err := hex.DecodeString(r.FormValue("requestId"))
|
||||
if err == nil {
|
||||
r.Header.Set("X-Away-Id", hex.EncodeToString(requestId))
|
||||
}
|
||||
|
||||
if ok, err := c.Verify(key, result, r); err != nil {
|
||||
return err
|
||||
} else if !ok {
|
||||
utils.ClearCookie(utils.CookiePrefix+challengeName, w)
|
||||
data.Challenges[c.Id] = challenge.VerifyResultFAIL
|
||||
state.SolveChallenge(key, challenge.VerifyResultFAIL)
|
||||
state.logger(r).Warn("challenge failed", "challenge", challengeName, "redirect", redirect)
|
||||
|
||||
// catch happy eyeballs IPv4 -> IPv6 migration, re-direct to try again
|
||||
if resultKey, err := ChallengeKeyFromString(result); err == nil && resultKey.Get(ChallengeKeyFlagIsIPv4) > 0 && key.Get(ChallengeKeyFlagIsIPv4) == 0 {
|
||||
|
||||
} else {
|
||||
_ = state.errorPage(w, r.Header.Get("X-Away-Id"), http.StatusForbidden, fmt.Errorf("access denied: failed challenge %s", challengeName), redirect)
|
||||
return nil
|
||||
}
|
||||
} else {
|
||||
state.logger(r).Warn("challenge passed", "challenge", challengeName, "redirect", redirect)
|
||||
|
||||
token, err := c.IssueChallengeToken(state.privateKey, key, []byte(result), data.Expires)
|
||||
if err != nil {
|
||||
utils.ClearCookie(utils.CookiePrefix+challengeName, w)
|
||||
} else {
|
||||
utils.SetCookie(utils.CookiePrefix+challengeName, token, data.Expires, w)
|
||||
}
|
||||
data.Challenges[c.Id] = challenge.VerifyResultPASS
|
||||
state.SolveChallenge(key, challenge.VerifyResultPASS)
|
||||
}
|
||||
|
||||
switch httpCode {
|
||||
case http.StatusMovedPermanently, http.StatusFound, http.StatusSeeOther, http.StatusTemporaryRedirect, http.StatusPermanentRedirect:
|
||||
if redirect == "" {
|
||||
_ = state.errorPage(w, r.Header.Get("X-Away-Id"), http.StatusBadRequest, errors.New("no redirect found"), "")
|
||||
return nil
|
||||
}
|
||||
http.Redirect(w, r, redirect, httpCode)
|
||||
default:
|
||||
w.Header().Set("Content-Type", mimeType)
|
||||
w.WriteHeader(httpCode)
|
||||
if content != nil {
|
||||
_, _ = w.Write(content)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}()
|
||||
if err != nil {
|
||||
utils.ClearCookie(utils.CookiePrefix+challengeName, w)
|
||||
_ = state.errorPage(w, r.Header.Get("X-Away-Id"), http.StatusInternalServerError, err, redirect)
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
case "wasm":
|
||||
wasmData, err := embed.ChallengeFs.ReadFile(fmt.Sprintf("challenge/%s/runtime/%s", challengeName, p.Runtime.Asset))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("c %s: could not load runtime: %w", challengeName, err)
|
||||
}
|
||||
err = state.Wasm.Compile(challengeName, wasmData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("c %s: compiling runtime: %w", challengeName, err)
|
||||
}
|
||||
|
||||
c.ServeMakeChallenge = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
err := state.Wasm.Instantiate(challengeName, func(ctx context.Context, mod api.Module) (err error) {
|
||||
|
||||
data := RequestDataFromContext(r.Context())
|
||||
|
||||
in := _interface.MakeChallengeInput{
|
||||
Key: state.GetChallengeKeyForRequest(challengeName, data.Expires, r),
|
||||
Parameters: p.Parameters,
|
||||
Headers: inline.MIMEHeader(r.Header),
|
||||
}
|
||||
in.Data, err = io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out, err := wasm.MakeChallengeCall(ctx, mod, in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// set output headers
|
||||
for k, v := range out.Headers {
|
||||
w.Header()[k] = v
|
||||
}
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(out.Data)))
|
||||
w.WriteHeader(out.Code)
|
||||
_, _ = w.Write(out.Data)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
_ = state.errorPage(w, r.Header.Get("X-Away-Id"), http.StatusInternalServerError, err, "")
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
c.Verify = func(key []byte, result string, r *http.Request) (ok bool, err error) {
|
||||
err = state.Wasm.Instantiate(challengeName, func(ctx context.Context, mod api.Module) (err error) {
|
||||
in := _interface.VerifyChallengeInput{
|
||||
Key: key,
|
||||
Parameters: p.Parameters,
|
||||
Result: []byte(result),
|
||||
}
|
||||
|
||||
out, err := wasm.VerifyChallengeCall(ctx, mod, in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if out == _interface.VerifyChallengeOutputError {
|
||||
return errors.New("error checking challenge")
|
||||
}
|
||||
ok = out == _interface.VerifyChallengeOutputOK
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return ok, nil
|
||||
}
|
||||
}
|
||||
|
||||
state.Challenges[c.Id] = c
|
||||
}
|
||||
|
||||
for _, rule := range p.Rules {
|
||||
hasher := sha256.New()
|
||||
hasher.Write([]byte(rule.Name))
|
||||
hasher.Write([]byte{0})
|
||||
if rule.Host != nil {
|
||||
hasher.Write([]byte(*rule.Host))
|
||||
}
|
||||
hasher.Write([]byte{0})
|
||||
hasher.Write(privateKeyFingerprint[:])
|
||||
sum := hasher.Sum(nil)
|
||||
|
||||
challenges := make([]challenge.Id, 0, len(rule.Challenges))
|
||||
|
||||
for _, challengeName := range rule.Challenges {
|
||||
c, ok := state.GetChallengeByName(challengeName)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("challenge %s not found", challengeName)
|
||||
}
|
||||
challenges = append(challenges, c.Id)
|
||||
}
|
||||
|
||||
r := RuleState{
|
||||
Name: rule.Name,
|
||||
Hash: hex.EncodeToString(sum[:8]),
|
||||
Host: rule.Host,
|
||||
Action: policy.RuleAction(strings.ToUpper(rule.Action)),
|
||||
Challenges: challenges,
|
||||
}
|
||||
|
||||
if (r.Action == policy.RuleActionCHALLENGE || r.Action == policy.RuleActionCHECK) && len(r.Challenges) == 0 {
|
||||
return nil, fmt.Errorf("no challenges found in rule %s", rule.Name)
|
||||
}
|
||||
|
||||
// allow nesting
|
||||
var conditions []string
|
||||
for _, cond := range rule.Conditions {
|
||||
cond = conditionReplacer.Replace(cond)
|
||||
conditions = append(conditions, cond)
|
||||
}
|
||||
|
||||
ast, err := condition.FromStrings(state.RulesEnv, condition.OperatorOr, conditions...)
|
||||
for _, r := range p.Rules {
|
||||
rule, err := NewRuleState(state, r, conditionReplacer, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("rules %s: error compiling conditions: %v", rule.Name, err)
|
||||
return nil, fmt.Errorf("rule %s: %w", r.Name, err)
|
||||
}
|
||||
program, err := state.RulesEnv.Program(ast)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("rules %s: error compiling program: %v", rule.Name, err)
|
||||
}
|
||||
r.Program = program
|
||||
|
||||
slog.Warn("loaded rule", "rule", r.Name, "hash", r.Hash, "action", rule.Action)
|
||||
slog.Warn("loaded rule", "rule", rule.Name, "hash", rule.Hash, "action", rule.Action, "children", len(rule.Children))
|
||||
|
||||
state.Rules = append(state.Rules, r)
|
||||
state.rules = append(state.rules, rule)
|
||||
}
|
||||
|
||||
state.Mux = http.NewServeMux()
|
||||
@@ -793,28 +259,38 @@ func NewState(p policy.Policy, settings StateSettings) (handler http.Handler, er
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if state.DecayMap != nil {
|
||||
go func() {
|
||||
ticker := time.NewTicker(17 * time.Minute)
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
state.DecayMap.Decay()
|
||||
case <-state.close:
|
||||
return
|
||||
}
|
||||
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) GetChallengeByName(name string) (challenge.Challenge, bool) {
|
||||
for _, c := range state.Challenges {
|
||||
if c.Name == name {
|
||||
return c, true
|
||||
func (state *State) Close() error {
|
||||
select {
|
||||
case <-state.close:
|
||||
default:
|
||||
close(state.close)
|
||||
for _, c := range state.challenges {
|
||||
if c.Object != nil {
|
||||
err := c.Object.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return challenge.Challenge{}, false
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
149
lib/template.go
Normal file
149
lib/template.go
Normal file
@@ -0,0 +1,149 @@
|
||||
package lib
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"git.gammaspectra.live/git/go-away/embed"
|
||||
"git.gammaspectra.live/git/go-away/lib/challenge"
|
||||
"git.gammaspectra.live/git/go-away/utils"
|
||||
"html/template"
|
||||
"maps"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
var templates map[string]*template.Template
|
||||
|
||||
func init() {
|
||||
|
||||
templates = make(map[string]*template.Template)
|
||||
|
||||
dir, err := embed.TemplatesFs.ReadDir(".")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
for _, e := range dir {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
data, err := embed.TemplatesFs.ReadFile(e.Name())
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
err = initTemplate(e.Name(), string(data))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func initTemplate(name, data string) error {
|
||||
tpl := template.New(name).Funcs(template.FuncMap{
|
||||
"attr": func(s string) template.HTMLAttr {
|
||||
return template.HTMLAttr(s)
|
||||
},
|
||||
"safe": func(s string) template.HTML {
|
||||
return template.HTML(s)
|
||||
},
|
||||
})
|
||||
_, err := tpl.Parse(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
templates[name] = tpl
|
||||
return nil
|
||||
}
|
||||
|
||||
func (state *State) addCachedTags(data *challenge.RequestData, r *http.Request, input map[string]any) {
|
||||
proxyMetaTags := data.GetOptBool(challenge.RequestOptProxyMetaTags, false)
|
||||
proxySafeLinkTags := data.GetOptBool(challenge.RequestOptProxySafeLinkTags, false)
|
||||
if proxyMetaTags || proxySafeLinkTags {
|
||||
backend, host := data.BackendHost()
|
||||
if tags := state.fetchTags(host, backend, r, proxyMetaTags, proxySafeLinkTags); len(tags) > 0 {
|
||||
metaTagMap, _ := input["MetaTags"].([]map[string]string)
|
||||
linkTagMap, _ := input["LinkTags"].([]map[string]string)
|
||||
|
||||
for _, tag := range tags {
|
||||
tagAttrs := make(map[string]string, len(tag.Attr))
|
||||
for _, v := range tag.Attr {
|
||||
tagAttrs[v.Key] = v.Val
|
||||
}
|
||||
metaTagMap = append(metaTagMap, tagAttrs)
|
||||
}
|
||||
input["MetaTags"] = metaTagMap
|
||||
input["LinkTags"] = linkTagMap
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (state *State) ChallengePage(w http.ResponseWriter, r *http.Request, status int, reg *challenge.Registration, params map[string]any) {
|
||||
data := challenge.RequestDataFromContext(r.Context())
|
||||
input := make(map[string]any)
|
||||
input["Id"] = data.Id.String()
|
||||
input["Random"] = utils.CacheBust()
|
||||
|
||||
input["Path"] = state.UrlPath()
|
||||
input["Links"] = state.opt.Links
|
||||
input["Strings"] = state.opt.Strings
|
||||
for k, v := range state.opt.ChallengeTemplateOverrides {
|
||||
input[k] = v
|
||||
}
|
||||
|
||||
if reg != nil {
|
||||
input["Challenge"] = reg.Name
|
||||
}
|
||||
|
||||
maps.Copy(input, params)
|
||||
|
||||
if _, ok := input["Title"]; !ok {
|
||||
input["Title"] = state.opt.Strings.Get("title_challenge")
|
||||
}
|
||||
|
||||
state.addCachedTags(data, r, input)
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
|
||||
buf := bytes.NewBuffer(make([]byte, 0, 8192))
|
||||
|
||||
err := templates["challenge-"+state.opt.ChallengeTemplate+".gohtml"].Execute(buf, input)
|
||||
if err != nil {
|
||||
state.ErrorPage(w, r, http.StatusInternalServerError, err, "")
|
||||
} else {
|
||||
data.ResponseHeaders(w)
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write(buf.Bytes())
|
||||
}
|
||||
}
|
||||
|
||||
func (state *State) ErrorPage(w http.ResponseWriter, r *http.Request, status int, err error, redirect string) {
|
||||
data := challenge.RequestDataFromContext(r.Context())
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
|
||||
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.opt.Strings.Get("title_error")) + " " + http.StatusText(status)),
|
||||
"Challenge": "",
|
||||
"Redirect": redirect,
|
||||
"Links": state.opt.Links,
|
||||
"Strings": state.opt.Strings,
|
||||
}
|
||||
for k, v := range state.opt.ChallengeTemplateOverrides {
|
||||
input[k] = v
|
||||
}
|
||||
|
||||
state.addCachedTags(data, r, input)
|
||||
|
||||
err2 := templates["challenge-"+state.opt.ChallengeTemplate+".gohtml"].Execute(buf, input)
|
||||
if err2 != nil {
|
||||
// nested errors!
|
||||
panic(err2)
|
||||
} else {
|
||||
data.ResponseHeaders(w)
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write(buf.Bytes())
|
||||
}
|
||||
}
|
||||
86
utils/cache.go
Normal file
86
utils/cache.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Cache interface {
|
||||
Get(key string, maxAge time.Duration) ([]byte, error)
|
||||
|
||||
Set(key string, value []byte) error
|
||||
}
|
||||
|
||||
func CachePrefix(c Cache, prefix string) Cache {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
return prefixCache{
|
||||
c: c,
|
||||
prefix: prefix,
|
||||
}
|
||||
}
|
||||
|
||||
func CacheDirectory(directory string) (Cache, error) {
|
||||
if stat, err := os.Stat(directory); err != nil {
|
||||
return nil, err
|
||||
} else if !stat.IsDir() {
|
||||
return nil, errors.New("not a directory")
|
||||
}
|
||||
return dirCache(directory), nil
|
||||
}
|
||||
|
||||
type prefixCache struct {
|
||||
c Cache
|
||||
prefix string
|
||||
}
|
||||
|
||||
func (c prefixCache) Get(key string, maxAge time.Duration) ([]byte, error) {
|
||||
return c.c.Get(c.prefix+key, maxAge)
|
||||
}
|
||||
|
||||
func (c prefixCache) Set(key string, value []byte) error {
|
||||
return c.c.Set(c.prefix+key, value)
|
||||
}
|
||||
|
||||
type dirCache string
|
||||
|
||||
var ErrExpired = errors.New("key expired")
|
||||
|
||||
func (d dirCache) Get(key string, maxAge time.Duration) ([]byte, error) {
|
||||
fname := path.Join(string(d), key)
|
||||
stat, err := os.Stat(fname)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if stat.IsDir() {
|
||||
return nil, errors.New("key is directory")
|
||||
}
|
||||
data, err := os.ReadFile(fname)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if stat.ModTime().Before(time.Now().Add(-maxAge)) {
|
||||
return data, ErrExpired
|
||||
} else {
|
||||
return data, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (d dirCache) Set(key string, value []byte) error {
|
||||
fname := path.Join(string(d), key)
|
||||
fs, err := os.Create(fname)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer fs.Close()
|
||||
_, err = fs.Write(value)
|
||||
fs.Sync()
|
||||
fs.Close()
|
||||
|
||||
_ = os.Chtimes(fname, time.Time{}, time.Now())
|
||||
return err
|
||||
}
|
||||
@@ -1,27 +1,41 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
var CookiePrefix = ".go-away-"
|
||||
var DefaultCookiePrefix = ".go-away-"
|
||||
|
||||
func SetCookie(name, value string, expiry time.Time, w http.ResponseWriter) {
|
||||
// getValidHost Gets a valid host for an http.Cookie Domain field
|
||||
// TODO: bug: does not work with IPv6, see https://github.com/golang/go/issues/65521
|
||||
func getValidHost(host string) string {
|
||||
ipStr, _, err := net.SplitHostPort(host)
|
||||
if err != nil {
|
||||
return host
|
||||
}
|
||||
return ipStr
|
||||
}
|
||||
|
||||
func SetCookie(name, value string, expiry time.Time, w http.ResponseWriter, r *http.Request) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: name,
|
||||
Value: value,
|
||||
Expires: expiry,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Path: "/",
|
||||
Domain: getValidHost(r.Host),
|
||||
})
|
||||
}
|
||||
func ClearCookie(name string, w http.ResponseWriter) {
|
||||
|
||||
func ClearCookie(name string, w http.ResponseWriter, r *http.Request) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: name,
|
||||
Value: "",
|
||||
Expires: time.Now().Add(-1 * time.Hour),
|
||||
MaxAge: -1,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Domain: getValidHost(r.Host),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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]),
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
module git.gammaspectra.live/git/go-away/utils/exp
|
||||
|
||||
go 1.24.0
|
||||
|
||||
toolchain go1.24.2
|
||||
@@ -1,7 +0,0 @@
|
||||
package slices
|
||||
|
||||
import "slices"
|
||||
|
||||
func EqualFunc[S1 ~[]E1, S2 ~[]E2, E1, E2 any](s1 S1, s2 S2, eq func(E1, E2) bool) bool {
|
||||
return slices.EqualFunc(s1, s2, eq)
|
||||
}
|
||||
@@ -16,31 +16,27 @@ import (
|
||||
)
|
||||
|
||||
func applyTLSFingerprinter(server *http.Server) {
|
||||
if server.TLSConfig == nil {
|
||||
return
|
||||
}
|
||||
server.TLSConfig = server.TLSConfig.Clone()
|
||||
|
||||
getCertificate := server.TLSConfig.GetCertificate
|
||||
if getCertificate == nil {
|
||||
server.TLSConfig.GetCertificate = func(clientHello *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||
ja3n, ja4 := buildTLSFingerprint(clientHello)
|
||||
ptr := clientHello.Context().Value(tlsFingerprintKey{})
|
||||
if fpPtr, ok := ptr.(*TLSFingerprint); ok && ptr != nil && fpPtr != nil {
|
||||
fpPtr.ja3n.Store(&ja3n)
|
||||
fpPtr.ja4.Store(&ja4)
|
||||
}
|
||||
getConfigForClient := server.TLSConfig.GetConfigForClient
|
||||
|
||||
if getConfigForClient == nil {
|
||||
getConfigForClient = func(info *tls.ClientHelloInfo) (*tls.Config, error) {
|
||||
return nil, nil
|
||||
}
|
||||
} else {
|
||||
server.TLSConfig.GetCertificate = func(clientHello *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||
ja3n, ja4 := buildTLSFingerprint(clientHello)
|
||||
ptr := clientHello.Context().Value(tlsFingerprintKey{})
|
||||
if fpPtr, ok := ptr.(*TLSFingerprint); ok && ptr != nil && fpPtr != nil {
|
||||
fpPtr.ja3n.Store(&ja3n)
|
||||
fpPtr.ja4.Store(&ja4)
|
||||
}
|
||||
}
|
||||
|
||||
return getCertificate(clientHello)
|
||||
server.TLSConfig.GetConfigForClient = func(clientHello *tls.ClientHelloInfo) (*tls.Config, error) {
|
||||
ja3n, ja4 := buildTLSFingerprint(clientHello)
|
||||
ptr := clientHello.Context().Value(tlsFingerprintKey{})
|
||||
if fpPtr, ok := ptr.(*TLSFingerprint); ok && ptr != nil && fpPtr != nil {
|
||||
fpPtr.ja3n.Store(&ja3n)
|
||||
fpPtr.ja4.Store(&ja4)
|
||||
}
|
||||
return getConfigForClient(clientHello)
|
||||
}
|
||||
server.ConnContext = func(ctx context.Context, c net.Conn) context.Context {
|
||||
return context.WithValue(ctx, tlsFingerprintKey{}, &TLSFingerprint{})
|
||||
|
||||
106
utils/http.go
106
utils/http.go
@@ -2,18 +2,21 @@ package utils
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func NewServer(handler http.Handler, tlsConfig *tls.Config) *http.Server {
|
||||
|
||||
if tlsConfig == nil {
|
||||
proto := new(http.Protocols)
|
||||
proto.SetHTTP1(true)
|
||||
@@ -34,6 +37,21 @@ func NewServer(handler http.Handler, tlsConfig *tls.Config) *http.Server {
|
||||
}
|
||||
}
|
||||
|
||||
func SelectHTTPHandler(backends map[string]http.Handler, host string) http.Handler {
|
||||
backend, ok := backends[host]
|
||||
if !ok {
|
||||
// do wildcard match
|
||||
wildcard := "*." + strings.Join(strings.Split(host, ".")[1:], ".")
|
||||
backend, ok = backends[wildcard]
|
||||
|
||||
if !ok {
|
||||
// return fallback
|
||||
backend = backends["*"]
|
||||
}
|
||||
}
|
||||
return backend
|
||||
}
|
||||
|
||||
func EnsureNoOpenRedirect(redirect string) (string, error) {
|
||||
uri, err := url.Parse(redirect)
|
||||
if err != nil {
|
||||
@@ -52,13 +70,14 @@ func EnsureNoOpenRedirect(redirect string) (string, error) {
|
||||
return uri.String(), nil
|
||||
}
|
||||
|
||||
func MakeReverseProxy(target string) (*httputil.ReverseProxy, error) {
|
||||
func MakeReverseProxy(target string, goDns bool, dialTimeout time.Duration) (*httputil.ReverseProxy, error) {
|
||||
u, err := url.Parse(target)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse target URL: %w", err)
|
||||
}
|
||||
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
transport.TLSClientConfig = &tls.Config{}
|
||||
|
||||
// https://github.com/oauth2-proxy/oauth2-proxy/blob/4e2100a2879ef06aea1411790327019c1a09217c/pkg/upstream/http.go#L124
|
||||
if u.Scheme == "unix" {
|
||||
@@ -67,15 +86,96 @@ func MakeReverseProxy(target string) (*httputil.ReverseProxy, error) {
|
||||
u.Path = ""
|
||||
// tell transport how to dial unix sockets
|
||||
transport.DialContext = func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||
dialer := net.Dialer{}
|
||||
dialer := net.Dialer{
|
||||
Timeout: dialTimeout,
|
||||
}
|
||||
return dialer.DialContext(ctx, "unix", addr)
|
||||
}
|
||||
// tell transport how to handle the unix url scheme
|
||||
transport.RegisterProtocol("unix", UnixRoundTripper{Transport: transport})
|
||||
} else if goDns {
|
||||
dialer := &net.Dialer{
|
||||
Resolver: &net.Resolver{
|
||||
PreferGo: true,
|
||||
},
|
||||
Timeout: dialTimeout,
|
||||
}
|
||||
transport.DialContext = dialer.DialContext
|
||||
} else {
|
||||
dialer := &net.Dialer{
|
||||
Timeout: dialTimeout,
|
||||
}
|
||||
transport.DialContext = dialer.DialContext
|
||||
}
|
||||
|
||||
rp := httputil.NewSingleHostReverseProxy(u)
|
||||
|
||||
rp.Transport = transport
|
||||
|
||||
return rp, nil
|
||||
}
|
||||
|
||||
func GetRequestScheme(r *http.Request) string {
|
||||
if proto := r.Header.Get("X-Forwarded-Proto"); proto == "http" || proto == "https" {
|
||||
return proto
|
||||
}
|
||||
|
||||
if r.TLS != nil {
|
||||
return "https"
|
||||
}
|
||||
|
||||
return "http"
|
||||
}
|
||||
|
||||
func GetRequestAddress(r *http.Request, clientHeader string) netip.AddrPort {
|
||||
strVal := r.RemoteAddr
|
||||
|
||||
if clientHeader != "" {
|
||||
strVal = r.Header.Get(clientHeader)
|
||||
}
|
||||
if strVal != "" {
|
||||
// handle X-Forwarded-For
|
||||
strVal = strings.Split(strVal, ",")[0]
|
||||
}
|
||||
|
||||
// fallback
|
||||
if strVal == "" {
|
||||
strVal = r.RemoteAddr
|
||||
}
|
||||
|
||||
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 {
|
||||
return cacheBust
|
||||
}
|
||||
|
||||
var cacheBust string
|
||||
|
||||
func init() {
|
||||
|
||||
buf := make([]byte, 16)
|
||||
_, _ = rand.Read(buf)
|
||||
cacheBust = base64.RawURLEncoding.EncodeToString(buf)
|
||||
}
|
||||
|
||||
169
utils/radb.go
Normal file
169
utils/radb.go
Normal file
@@ -0,0 +1,169 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type RADb struct {
|
||||
target string
|
||||
dialer net.Dialer
|
||||
}
|
||||
|
||||
const RADBServer = "whois.radb.net:43"
|
||||
|
||||
func NewRADb() (*RADb, error) {
|
||||
|
||||
host, port, err := net.SplitHostPort(RADBServer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &RADb{
|
||||
target: fmt.Sprintf("%s:%s", host, port),
|
||||
dialer: net.Dialer{
|
||||
Timeout: 5 * time.Second,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
var whoisRouteRegex = regexp.MustCompile("(?P<prefix>(([0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+)|([0-9a-f:]+::))/[0-9]+)")
|
||||
|
||||
func (db *RADb) query(fn func(n int, record []byte) error, queries ...string) error {
|
||||
|
||||
conn, err := db.dialer.Dial("tcp", db.target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
if len(queries) > 1 {
|
||||
// enable persistent conn
|
||||
_ = conn.SetDeadline(time.Now().Add(time.Second * 5))
|
||||
_, err = conn.Write([]byte("!!\n"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
scanner := bufio.NewScanner(conn)
|
||||
scanner.Split(bufio.ScanLines)
|
||||
// 16 MiB lines
|
||||
const bufferSize = 1024 * 1024 * 16
|
||||
scanner.Buffer(make([]byte, 0, bufferSize), bufferSize)
|
||||
|
||||
for _, q := range queries {
|
||||
|
||||
_ = conn.SetDeadline(time.Now().Add(time.Second * 5))
|
||||
_, err = conn.Write([]byte(strings.TrimSpace(q) + "\n"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
n := 0
|
||||
|
||||
for scanner.Scan() {
|
||||
buf := bytes.Trim(scanner.Bytes(), "\r\n")
|
||||
if bytes.HasPrefix(buf, []byte("%")) || bytes.Equal(buf, []byte("C")) {
|
||||
// end of record
|
||||
break
|
||||
}
|
||||
err = fn(n, buf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n++
|
||||
}
|
||||
|
||||
if scanner.Err() != nil {
|
||||
return scanner.Err()
|
||||
}
|
||||
}
|
||||
|
||||
if len(queries) > 1 {
|
||||
// exit
|
||||
_ = conn.SetDeadline(time.Now().Add(time.Second * 5))
|
||||
_, err = conn.Write([]byte("q\n"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *RADb) FetchIPInfo(ip net.IP) (result []string, err error) {
|
||||
var ipNet net.IPNet
|
||||
if ip4 := ip.To4(); ip4 != nil {
|
||||
ipNet = net.IPNet{
|
||||
IP: ip4,
|
||||
// single ip
|
||||
Mask: net.CIDRMask(len(ip4)*8, len(ip4)*8),
|
||||
}
|
||||
} else {
|
||||
ipNet = net.IPNet{
|
||||
IP: ip,
|
||||
// single ip
|
||||
Mask: net.CIDRMask(len(ip)*8, len(ip)*8),
|
||||
}
|
||||
}
|
||||
|
||||
err = db.query(func(n int, record []byte) error {
|
||||
result = append(result, string(record))
|
||||
return nil
|
||||
}, fmt.Sprintf("!r%s,l", ipNet.String()))
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (db *RADb) FetchASNets(asn int) (result []net.IPNet, err error) {
|
||||
|
||||
ix := whoisRouteRegex.SubexpIndex("prefix")
|
||||
if ix == -1 {
|
||||
panic("invalid regex prefix")
|
||||
}
|
||||
|
||||
var data []byte
|
||||
|
||||
err = db.query(func(n int, record []byte) error {
|
||||
if n == 0 {
|
||||
// do not append ASN number reply
|
||||
return nil
|
||||
}
|
||||
// pad data
|
||||
if n == 1 {
|
||||
data = append(data, ' ')
|
||||
}
|
||||
data = append(data, record...)
|
||||
return nil
|
||||
},
|
||||
// See https://www.radb.net/query/help
|
||||
// fetch IPv4 routes
|
||||
fmt.Sprintf("!gas%d", asn),
|
||||
// fetch IPv6 routes
|
||||
fmt.Sprintf("!6as%d", asn),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
matches := whoisRouteRegex.FindAllSubmatch(data, -1)
|
||||
for _, match := range matches {
|
||||
_, ipNet, err := net.ParseCIDR(string(match[ix]))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid CIDR %s: %w", string(match[ix]), err)
|
||||
}
|
||||
result = append(result, *ipNet)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
26
utils/strings.go
Normal file
26
utils/strings.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"maps"
|
||||
)
|
||||
|
||||
type Strings map[string]string
|
||||
|
||||
func (s Strings) set(v map[string]string) Strings {
|
||||
maps.Copy(s, v)
|
||||
return s
|
||||
}
|
||||
|
||||
func (s Strings) Get(value string) template.HTML {
|
||||
v, ok := (s)[value]
|
||||
if !ok {
|
||||
// fallback
|
||||
return template.HTML("string:" + value)
|
||||
}
|
||||
return template.HTML(v)
|
||||
}
|
||||
|
||||
func NewStrings[T ~map[string]string](v T) Strings {
|
||||
return make(Strings).set(v)
|
||||
}
|
||||
55
utils/tagfetcher.go
Normal file
55
utils/tagfetcher.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"golang.org/x/net/html"
|
||||
"mime"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"slices"
|
||||
)
|
||||
|
||||
func FetchTags(backend http.Handler, uri *url.URL, kinds ...string) (result []html.Node) {
|
||||
writer := httptest.NewRecorder()
|
||||
backend.ServeHTTP(writer, &http.Request{
|
||||
Method: http.MethodGet,
|
||||
URL: uri,
|
||||
Header: http.Header{
|
||||
"User-Agent": []string{"Mozilla 5.0 (compatible; go-away/1.0 fetch-tags) TwitterBot/1.0"},
|
||||
"Accept": []string{"text/html,application/xhtml+xml"},
|
||||
},
|
||||
Close: true,
|
||||
})
|
||||
response := writer.Result()
|
||||
if response == nil {
|
||||
return nil
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return nil
|
||||
}
|
||||
|
||||
if contentType, _, _ := mime.ParseMediaType(response.Header.Get("Content-Type")); contentType != "text/html" && contentType != "application/xhtml+xml" {
|
||||
return nil
|
||||
}
|
||||
|
||||
//TODO: handle non UTF-8 documents
|
||||
node, err := html.ParseWithOptions(response.Body, html.ParseOptionEnableScripting(false))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
for n := range node.Descendants() {
|
||||
if n.Type == html.ElementNode && slices.Contains(kinds, n.Data) {
|
||||
result = append(result, html.Node{
|
||||
Type: n.Type,
|
||||
DataAtom: n.DataAtom,
|
||||
Data: n.Data,
|
||||
Namespace: n.Namespace,
|
||||
Attr: n.Attr,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
Reference in New Issue
Block a user