Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74a067ae10 | ||
|
|
3bbd50764a | ||
|
|
49e46e7e9f | ||
|
|
cd372e1512 | ||
|
|
cef915b353 | ||
|
|
10ceca02c9 | ||
|
|
71b99f9d12 | ||
|
|
cb02fb20e9 | ||
|
|
57755112ea | ||
|
|
6bb7ca979d | ||
|
|
a0224cb21c | ||
|
|
612362dbe5 | ||
|
|
d56d621f7a | ||
|
|
9719c0ff39 | ||
|
|
3b11792594 | ||
|
|
d83fe3653a | ||
|
|
1cc95a5fa7 | ||
|
|
ead41055ca | ||
|
|
1c7fe1bed9 | ||
|
|
27b25082b9 |
@@ -1,5 +1,5 @@
|
|||||||
// yaml_stream.jsonnet
|
// yaml_stream.jsonnet
|
||||||
local Build(go, alpine, os, arch) = {
|
local Build(mirror, go, alpine, os, arch) = {
|
||||||
kind: "pipeline",
|
kind: "pipeline",
|
||||||
type: "docker",
|
type: "docker",
|
||||||
name: "build-" + go + "-alpine" + alpine + "-" + arch,
|
name: "build-" + go + "-alpine" + alpine + "-" + arch,
|
||||||
@@ -17,17 +17,37 @@ local Build(go, alpine, os, arch) = {
|
|||||||
{
|
{
|
||||||
name: "build",
|
name: "build",
|
||||||
image: "golang:" + go +"-alpine" + alpine,
|
image: "golang:" + go +"-alpine" + alpine,
|
||||||
|
mirror: mirror,
|
||||||
commands: [
|
commands: [
|
||||||
"apk update",
|
"apk update",
|
||||||
"apk add --no-cache git",
|
"apk add --no-cache git",
|
||||||
"mkdir .bin",
|
"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",
|
"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: "test-wasm-success",
|
name: "test-wasm-success",
|
||||||
image: "alpine:" + alpine,
|
image: "alpine:" + alpine,
|
||||||
|
mirror: mirror,
|
||||||
depends_on: ["build"],
|
depends_on: ["build"],
|
||||||
commands: [
|
commands: [
|
||||||
"./.bin/test-wasm-runtime -wasm ./embed/challenge/js-pow-sha256/runtime/runtime.wasm " +
|
"./.bin/test-wasm-runtime -wasm ./embed/challenge/js-pow-sha256/runtime/runtime.wasm " +
|
||||||
@@ -40,6 +60,7 @@ local Build(go, alpine, os, arch) = {
|
|||||||
{
|
{
|
||||||
name: "test-wasm-fail",
|
name: "test-wasm-fail",
|
||||||
image: "alpine:" + alpine,
|
image: "alpine:" + alpine,
|
||||||
|
mirror: mirror,
|
||||||
depends_on: ["build"],
|
depends_on: ["build"],
|
||||||
commands: [
|
commands: [
|
||||||
"./.bin/test-wasm-runtime -wasm ./embed/challenge/js-pow-sha256/runtime/runtime.wasm " +
|
"./.bin/test-wasm-runtime -wasm ./embed/challenge/js-pow-sha256/runtime/runtime.wasm " +
|
||||||
@@ -52,7 +73,7 @@ local Build(go, alpine, os, arch) = {
|
|||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
local Publish(registry, repo, secret, go, alpine, os, arch, trigger, platforms, extra) = {
|
local Publish(mirror, registry, repo, secret, go, alpine, os, arch, trigger, platforms, extra) = {
|
||||||
kind: "pipeline",
|
kind: "pipeline",
|
||||||
type: "docker",
|
type: "docker",
|
||||||
name: "publish-" + go + "-alpine" + alpine + "-" + secret,
|
name: "publish-" + go + "-alpine" + alpine + "-" + secret,
|
||||||
@@ -62,6 +83,15 @@ local Publish(registry, repo, secret, go, alpine, os, arch, trigger, platforms,
|
|||||||
},
|
},
|
||||||
trigger: trigger,
|
trigger: trigger,
|
||||||
steps: [
|
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",
|
name: "docker",
|
||||||
image: "plugins/buildx",
|
image: "plugins/buildx",
|
||||||
@@ -71,13 +101,15 @@ local Publish(registry, repo, secret, go, alpine, os, arch, trigger, platforms,
|
|||||||
SOURCE_DATE_EPOCH: 0,
|
SOURCE_DATE_EPOCH: 0,
|
||||||
TZ: "UTC",
|
TZ: "UTC",
|
||||||
LC_ALL: "C",
|
LC_ALL: "C",
|
||||||
|
PLUGIN_BUILDER_CONFIG: "buildkitd.toml",
|
||||||
|
PLUGIN_BUILDER_DRIVER: "docker-container",
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
registry: registry,
|
registry: registry,
|
||||||
repo: repo,
|
repo: repo,
|
||||||
|
mirror: mirror,
|
||||||
compress: true,
|
compress: true,
|
||||||
platform: platforms,
|
platform: platforms,
|
||||||
builder_driver: "docker-container",
|
|
||||||
build_args: {
|
build_args: {
|
||||||
from_builder: "golang:" + go +"-alpine" + alpine,
|
from_builder: "golang:" + go +"-alpine" + alpine,
|
||||||
from: "alpine:" + alpine,
|
from: "alpine:" + alpine,
|
||||||
@@ -100,17 +132,19 @@ local containerArchitectures = ["linux/amd64", "linux/arm64", "linux/riscv64"];
|
|||||||
local alpineVersion = "3.21";
|
local alpineVersion = "3.21";
|
||||||
local goVersion = "1.24";
|
local goVersion = "1.24";
|
||||||
|
|
||||||
|
local mirror = "https://mirror.gcr.io";
|
||||||
|
|
||||||
[
|
[
|
||||||
Build(goVersion, alpineVersion, "linux", "amd64"),
|
Build(mirror, goVersion, alpineVersion, "linux", "amd64"),
|
||||||
Build(goVersion, alpineVersion, "linux", "arm64"),
|
Build(mirror, goVersion, alpineVersion, "linux", "arm64"),
|
||||||
|
|
||||||
# latest
|
# latest
|
||||||
Publish("git.gammaspectra.live", "git.gammaspectra.live/git/go-away", "git", goVersion, alpineVersion, "linux", "amd64", {event: ["push"], branch: ["master"], }, containerArchitectures, {tags: ["latest"],}) + {name: "publish-latest-git"},
|
Publish(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("codeberg.org", "codeberg.org/weebdatahoarder/go-away", "codeberg", goVersion, alpineVersion, "linux", "amd64", {event: ["push"], branch: ["master"], }, containerArchitectures, {tags: ["latest"],}) + {name: "publish-latest-codeberg"},
|
Publish(mirror, "codeberg.org", "codeberg.org/weebdatahoarder/go-away", "codeberg", goVersion, alpineVersion, "linux", "amd64", {event: ["push"], branch: ["master"], }, containerArchitectures, {tags: ["latest"],}) + {name: "publish-latest-codeberg"},
|
||||||
Publish("ghcr.io", "ghcr.io/weebdatahoarder/go-away", "github", goVersion, alpineVersion, "linux", "amd64", {event: ["push"], branch: ["master"], }, containerArchitectures, {tags: ["latest"],}) + {name: "publish-latest-github"},
|
Publish(mirror, "ghcr.io", "ghcr.io/weebdatahoarder/go-away", "github", goVersion, alpineVersion, "linux", "amd64", {event: ["push"], branch: ["master"], }, containerArchitectures, {tags: ["latest"],}) + {name: "publish-latest-github"},
|
||||||
|
|
||||||
# modern
|
# modern
|
||||||
Publish("git.gammaspectra.live", "git.gammaspectra.live/git/go-away", "git", goVersion, alpineVersion, "linux", "amd64", {event: ["promote", "tag"], target: ["production"], }, containerArchitectures, {auto_tag: true,}),
|
Publish(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("codeberg.org", "codeberg.org/weebdatahoarder/go-away", "codeberg", goVersion, alpineVersion, "linux", "amd64", {event: ["promote", "tag"], target: ["production"], }, containerArchitectures, {auto_tag: true,}),
|
Publish(mirror, "codeberg.org", "codeberg.org/weebdatahoarder/go-away", "codeberg", goVersion, alpineVersion, "linux", "amd64", {event: ["promote", "tag"], target: ["production"], }, containerArchitectures, {auto_tag: true,}),
|
||||||
Publish("ghcr.io", "ghcr.io/weebdatahoarder/go-away", "github", goVersion, alpineVersion, "linux", "amd64", {event: ["promote", "tag"], target: ["production"], }, containerArchitectures, {auto_tag: true,}),
|
Publish(mirror, "ghcr.io", "ghcr.io/weebdatahoarder/go-away", "github", goVersion, alpineVersion, "linux", "amd64", {event: ["promote", "tag"], target: ["production"], }, containerArchitectures, {auto_tag: true,}),
|
||||||
]
|
]
|
||||||
104
.drone.yml
104
.drone.yml
@@ -14,10 +14,27 @@ steps:
|
|||||||
- apk update
|
- apk update
|
||||||
- apk add --no-cache git
|
- apk add --no-cache git
|
||||||
- mkdir .bin
|
- 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
|
- go build -v -o ./.bin/test-wasm-runtime ./cmd/test-wasm-runtime
|
||||||
image: golang:1.24-alpine3.21
|
image: golang:1.24-alpine3.21
|
||||||
|
mirror: https://mirror.gcr.io
|
||||||
name: build
|
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:
|
- commands:
|
||||||
- ./.bin/test-wasm-runtime -wasm ./embed/challenge/js-pow-sha256/runtime/runtime.wasm
|
- ./.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
|
-make-challenge ./embed/challenge/js-pow-sha256/test/make-challenge.json -make-challenge-out
|
||||||
@@ -27,6 +44,7 @@ steps:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- build
|
- build
|
||||||
image: alpine:3.21
|
image: alpine:3.21
|
||||||
|
mirror: https://mirror.gcr.io
|
||||||
name: test-wasm-success
|
name: test-wasm-success
|
||||||
- commands:
|
- commands:
|
||||||
- ./.bin/test-wasm-runtime -wasm ./embed/challenge/js-pow-sha256/runtime/runtime.wasm
|
- ./.bin/test-wasm-runtime -wasm ./embed/challenge/js-pow-sha256/runtime/runtime.wasm
|
||||||
@@ -37,6 +55,7 @@ steps:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- build
|
- build
|
||||||
image: alpine:3.21
|
image: alpine:3.21
|
||||||
|
mirror: https://mirror.gcr.io
|
||||||
name: test-wasm-fail
|
name: test-wasm-fail
|
||||||
type: docker
|
type: docker
|
||||||
---
|
---
|
||||||
@@ -55,10 +74,27 @@ steps:
|
|||||||
- apk update
|
- apk update
|
||||||
- apk add --no-cache git
|
- apk add --no-cache git
|
||||||
- mkdir .bin
|
- 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
|
- go build -v -o ./.bin/test-wasm-runtime ./cmd/test-wasm-runtime
|
||||||
image: golang:1.24-alpine3.21
|
image: golang:1.24-alpine3.21
|
||||||
|
mirror: https://mirror.gcr.io
|
||||||
name: build
|
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:
|
- commands:
|
||||||
- ./.bin/test-wasm-runtime -wasm ./embed/challenge/js-pow-sha256/runtime/runtime.wasm
|
- ./.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
|
-make-challenge ./embed/challenge/js-pow-sha256/test/make-challenge.json -make-challenge-out
|
||||||
@@ -68,6 +104,7 @@ steps:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- build
|
- build
|
||||||
image: alpine:3.21
|
image: alpine:3.21
|
||||||
|
mirror: https://mirror.gcr.io
|
||||||
name: test-wasm-success
|
name: test-wasm-success
|
||||||
- commands:
|
- commands:
|
||||||
- ./.bin/test-wasm-runtime -wasm ./embed/challenge/js-pow-sha256/runtime/runtime.wasm
|
- ./.bin/test-wasm-runtime -wasm ./embed/challenge/js-pow-sha256/runtime/runtime.wasm
|
||||||
@@ -78,6 +115,7 @@ steps:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- build
|
- build
|
||||||
image: alpine:3.21
|
image: alpine:3.21
|
||||||
|
mirror: https://mirror.gcr.io
|
||||||
name: test-wasm-fail
|
name: test-wasm-fail
|
||||||
type: docker
|
type: docker
|
||||||
---
|
---
|
||||||
@@ -87,9 +125,17 @@ platform:
|
|||||||
arch: amd64
|
arch: amd64
|
||||||
os: linux
|
os: linux
|
||||||
steps:
|
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:
|
- environment:
|
||||||
DOCKER_BUILDKIT: "1"
|
DOCKER_BUILDKIT: "1"
|
||||||
LC_ALL: C
|
LC_ALL: C
|
||||||
|
PLUGIN_BUILDER_CONFIG: buildkitd.toml
|
||||||
|
PLUGIN_BUILDER_DRIVER: docker-container
|
||||||
SOURCE_DATE_EPOCH: 0
|
SOURCE_DATE_EPOCH: 0
|
||||||
TZ: UTC
|
TZ: UTC
|
||||||
image: plugins/buildx
|
image: plugins/buildx
|
||||||
@@ -100,8 +146,8 @@ steps:
|
|||||||
build_args:
|
build_args:
|
||||||
from: alpine:3.21
|
from: alpine:3.21
|
||||||
from_builder: golang:1.24-alpine3.21
|
from_builder: golang:1.24-alpine3.21
|
||||||
builder_driver: docker-container
|
|
||||||
compress: true
|
compress: true
|
||||||
|
mirror: https://mirror.gcr.io
|
||||||
password:
|
password:
|
||||||
from_secret: git_password
|
from_secret: git_password
|
||||||
platform:
|
platform:
|
||||||
@@ -127,9 +173,17 @@ platform:
|
|||||||
arch: amd64
|
arch: amd64
|
||||||
os: linux
|
os: linux
|
||||||
steps:
|
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:
|
- environment:
|
||||||
DOCKER_BUILDKIT: "1"
|
DOCKER_BUILDKIT: "1"
|
||||||
LC_ALL: C
|
LC_ALL: C
|
||||||
|
PLUGIN_BUILDER_CONFIG: buildkitd.toml
|
||||||
|
PLUGIN_BUILDER_DRIVER: docker-container
|
||||||
SOURCE_DATE_EPOCH: 0
|
SOURCE_DATE_EPOCH: 0
|
||||||
TZ: UTC
|
TZ: UTC
|
||||||
image: plugins/buildx
|
image: plugins/buildx
|
||||||
@@ -140,8 +194,8 @@ steps:
|
|||||||
build_args:
|
build_args:
|
||||||
from: alpine:3.21
|
from: alpine:3.21
|
||||||
from_builder: golang:1.24-alpine3.21
|
from_builder: golang:1.24-alpine3.21
|
||||||
builder_driver: docker-container
|
|
||||||
compress: true
|
compress: true
|
||||||
|
mirror: https://mirror.gcr.io
|
||||||
password:
|
password:
|
||||||
from_secret: codeberg_password
|
from_secret: codeberg_password
|
||||||
platform:
|
platform:
|
||||||
@@ -167,9 +221,17 @@ platform:
|
|||||||
arch: amd64
|
arch: amd64
|
||||||
os: linux
|
os: linux
|
||||||
steps:
|
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:
|
- environment:
|
||||||
DOCKER_BUILDKIT: "1"
|
DOCKER_BUILDKIT: "1"
|
||||||
LC_ALL: C
|
LC_ALL: C
|
||||||
|
PLUGIN_BUILDER_CONFIG: buildkitd.toml
|
||||||
|
PLUGIN_BUILDER_DRIVER: docker-container
|
||||||
SOURCE_DATE_EPOCH: 0
|
SOURCE_DATE_EPOCH: 0
|
||||||
TZ: UTC
|
TZ: UTC
|
||||||
image: plugins/buildx
|
image: plugins/buildx
|
||||||
@@ -180,8 +242,8 @@ steps:
|
|||||||
build_args:
|
build_args:
|
||||||
from: alpine:3.21
|
from: alpine:3.21
|
||||||
from_builder: golang:1.24-alpine3.21
|
from_builder: golang:1.24-alpine3.21
|
||||||
builder_driver: docker-container
|
|
||||||
compress: true
|
compress: true
|
||||||
|
mirror: https://mirror.gcr.io
|
||||||
password:
|
password:
|
||||||
from_secret: github_password
|
from_secret: github_password
|
||||||
platform:
|
platform:
|
||||||
@@ -207,9 +269,17 @@ platform:
|
|||||||
arch: amd64
|
arch: amd64
|
||||||
os: linux
|
os: linux
|
||||||
steps:
|
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:
|
- environment:
|
||||||
DOCKER_BUILDKIT: "1"
|
DOCKER_BUILDKIT: "1"
|
||||||
LC_ALL: C
|
LC_ALL: C
|
||||||
|
PLUGIN_BUILDER_CONFIG: buildkitd.toml
|
||||||
|
PLUGIN_BUILDER_DRIVER: docker-container
|
||||||
SOURCE_DATE_EPOCH: 0
|
SOURCE_DATE_EPOCH: 0
|
||||||
TZ: UTC
|
TZ: UTC
|
||||||
image: plugins/buildx
|
image: plugins/buildx
|
||||||
@@ -221,8 +291,8 @@ steps:
|
|||||||
build_args:
|
build_args:
|
||||||
from: alpine:3.21
|
from: alpine:3.21
|
||||||
from_builder: golang:1.24-alpine3.21
|
from_builder: golang:1.24-alpine3.21
|
||||||
builder_driver: docker-container
|
|
||||||
compress: true
|
compress: true
|
||||||
|
mirror: https://mirror.gcr.io
|
||||||
password:
|
password:
|
||||||
from_secret: git_password
|
from_secret: git_password
|
||||||
platform:
|
platform:
|
||||||
@@ -247,9 +317,17 @@ platform:
|
|||||||
arch: amd64
|
arch: amd64
|
||||||
os: linux
|
os: linux
|
||||||
steps:
|
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:
|
- environment:
|
||||||
DOCKER_BUILDKIT: "1"
|
DOCKER_BUILDKIT: "1"
|
||||||
LC_ALL: C
|
LC_ALL: C
|
||||||
|
PLUGIN_BUILDER_CONFIG: buildkitd.toml
|
||||||
|
PLUGIN_BUILDER_DRIVER: docker-container
|
||||||
SOURCE_DATE_EPOCH: 0
|
SOURCE_DATE_EPOCH: 0
|
||||||
TZ: UTC
|
TZ: UTC
|
||||||
image: plugins/buildx
|
image: plugins/buildx
|
||||||
@@ -261,8 +339,8 @@ steps:
|
|||||||
build_args:
|
build_args:
|
||||||
from: alpine:3.21
|
from: alpine:3.21
|
||||||
from_builder: golang:1.24-alpine3.21
|
from_builder: golang:1.24-alpine3.21
|
||||||
builder_driver: docker-container
|
|
||||||
compress: true
|
compress: true
|
||||||
|
mirror: https://mirror.gcr.io
|
||||||
password:
|
password:
|
||||||
from_secret: codeberg_password
|
from_secret: codeberg_password
|
||||||
platform:
|
platform:
|
||||||
@@ -287,9 +365,17 @@ platform:
|
|||||||
arch: amd64
|
arch: amd64
|
||||||
os: linux
|
os: linux
|
||||||
steps:
|
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:
|
- environment:
|
||||||
DOCKER_BUILDKIT: "1"
|
DOCKER_BUILDKIT: "1"
|
||||||
LC_ALL: C
|
LC_ALL: C
|
||||||
|
PLUGIN_BUILDER_CONFIG: buildkitd.toml
|
||||||
|
PLUGIN_BUILDER_DRIVER: docker-container
|
||||||
SOURCE_DATE_EPOCH: 0
|
SOURCE_DATE_EPOCH: 0
|
||||||
TZ: UTC
|
TZ: UTC
|
||||||
image: plugins/buildx
|
image: plugins/buildx
|
||||||
@@ -301,8 +387,8 @@ steps:
|
|||||||
build_args:
|
build_args:
|
||||||
from: alpine:3.21
|
from: alpine:3.21
|
||||||
from_builder: golang:1.24-alpine3.21
|
from_builder: golang:1.24-alpine3.21
|
||||||
builder_driver: docker-container
|
|
||||||
compress: true
|
compress: true
|
||||||
|
mirror: https://mirror.gcr.io
|
||||||
password:
|
password:
|
||||||
from_secret: github_password
|
from_secret: github_password
|
||||||
platform:
|
platform:
|
||||||
@@ -322,6 +408,6 @@ trigger:
|
|||||||
type: docker
|
type: docker
|
||||||
---
|
---
|
||||||
kind: signature
|
kind: signature
|
||||||
hmac: f27dd6fbc73d3dd6e26739576a02b6bf0f9d1c43ee9d6d1439afacdf4e4dbf96
|
hmac: ad13c88b81cd1c6ebd4bb8d33479ffe8e67bc8caefdcc1c06dd1a6b75476bcd7
|
||||||
|
|
||||||
...
|
...
|
||||||
|
|||||||
@@ -13,14 +13,15 @@ For example, this allows verifying the user cookies against the backend to have
|
|||||||
Example on Forgejo, checks that current user is authenticated:
|
Example on Forgejo, checks that current user is authenticated:
|
||||||
```yaml
|
```yaml
|
||||||
http-cookie-check:
|
http-cookie-check:
|
||||||
mode: http
|
runtime: http
|
||||||
url: http://forgejo:3000/user/stopwatches
|
|
||||||
# url: http://forgejo:3000/repo/search
|
|
||||||
# url: http://forgejo:3000/notifications/new
|
|
||||||
parameters:
|
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-method: GET
|
||||||
http-cookie: i_like_gitea
|
http-cookie: i_like_gitea
|
||||||
http-code: 200
|
http-code: 200
|
||||||
|
verify-probability: 0.1
|
||||||
```
|
```
|
||||||
|
|
||||||
### preload-link
|
### preload-link
|
||||||
@@ -33,18 +34,45 @@ The server waits until solved or defined timeout, then continues on other challe
|
|||||||
|
|
||||||
Example:
|
Example:
|
||||||
```yaml
|
```yaml
|
||||||
self-preload-link:
|
preload-link:
|
||||||
condition: '"Sec-Fetch-Mode" in headers && headers["Sec-Fetch-Mode"] == "navigate"'
|
condition: '"Sec-Fetch-Mode" in headers && headers["Sec-Fetch-Mode"] == "navigate"'
|
||||||
mode: "preload-link"
|
runtime: "preload-link"
|
||||||
runtime:
|
|
||||||
# verifies that result = key
|
|
||||||
mode: "key"
|
|
||||||
probability: 0.1
|
|
||||||
parameters:
|
parameters:
|
||||||
preload-early-hint-deadline: 3s
|
preload-early-hint-deadline: 3s
|
||||||
key-code: 200
|
```
|
||||||
key-mime: text/css
|
|
||||||
key-content: ""
|
### dnsbl
|
||||||
|
|
||||||
|
You can configure a [DNSBL (Domain Name System blocklist)](https://en.wikipedia.org/wiki/Domain_Name_System_blocklist) to be queried.
|
||||||
|
|
||||||
|
This allows you to serve harder or different challenges to higher risk clients, or block them from specific sections.
|
||||||
|
|
||||||
|
Only rules that match a DNSBL challenge will cause a query to be sent, meaning the bulk of requests will not be sent to this service upstream.
|
||||||
|
|
||||||
|
Results will be temporarily cached.
|
||||||
|
|
||||||
|
By default, [DroneBL](https://dronebl.org/) is used.
|
||||||
|
|
||||||
|
Example challenge definition and rule:
|
||||||
|
```yaml
|
||||||
|
challenges:
|
||||||
|
dnsbl:
|
||||||
|
runtime: dnsbl
|
||||||
|
parameters:
|
||||||
|
# dnsbl-host: "dnsbl.dronebl.org"
|
||||||
|
dnsbl-decay: 1h
|
||||||
|
dnsbl-timeout: 1s
|
||||||
|
|
||||||
|
rules:
|
||||||
|
# check DNSBL and serve harder challenges
|
||||||
|
- name: undesired-dnsbl
|
||||||
|
action: check
|
||||||
|
settings:
|
||||||
|
challenges: [dnsbl]
|
||||||
|
# if DNSBL fails, check additional challenges
|
||||||
|
fail: check
|
||||||
|
fail-settings:
|
||||||
|
challenges: [js-pow-sha256]
|
||||||
```
|
```
|
||||||
|
|
||||||
## Non-JavaScript
|
## Non-JavaScript
|
||||||
@@ -76,20 +104,6 @@ Requires HTTP and HTML response parsing and logic, displays challenge site.
|
|||||||
|
|
||||||
Servers a challenge page with a linked resource that is loaded by the browser, which solves the challenge. Page refreshes a few seconds later via [Refresh](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Refresh).
|
Servers a challenge page with a linked resource that is loaded by the browser, which solves the challenge. Page refreshes a few seconds later via [Refresh](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Refresh).
|
||||||
|
|
||||||
Example:
|
|
||||||
```yaml
|
|
||||||
self-resource-load:
|
|
||||||
mode: "resource-load"
|
|
||||||
runtime:
|
|
||||||
# verifies that result = key
|
|
||||||
mode: "key"
|
|
||||||
probability: 0.1
|
|
||||||
parameters:
|
|
||||||
key-code: 200
|
|
||||||
key-mime: text/css
|
|
||||||
key-content: ""
|
|
||||||
```
|
|
||||||
|
|
||||||
## Custom JavaScript
|
## Custom JavaScript
|
||||||
|
|
||||||
### js-pow-sha256
|
### js-pow-sha256
|
||||||
@@ -101,18 +115,18 @@ Has the user solve a Proof of Work using SHA256 hashes, with configurable diffic
|
|||||||
Example:
|
Example:
|
||||||
```yaml
|
```yaml
|
||||||
js-pow-sha256:
|
js-pow-sha256:
|
||||||
# Asset must be under challenges/{name}/static/{asset}
|
runtime: js
|
||||||
# Other files here will be available under that path
|
|
||||||
mode: js
|
|
||||||
asset: load.mjs
|
|
||||||
parameters:
|
parameters:
|
||||||
# difficulty is number of bits that must be set to 0 from start
|
# specifies the folder path that assets are under
|
||||||
# Anubis challenge difficulty 5 becomes 5 * 8 = 20
|
# can be either embedded or external path
|
||||||
difficulty: 20
|
# defaults to name of challenge
|
||||||
runtime:
|
path: "js-pow-sha256"
|
||||||
mode: wasm
|
# needs to be under static folder
|
||||||
# Verify must be under challenges/{name}/runtime/{asset}
|
js-loader: load.mjs
|
||||||
asset: runtime.wasm
|
# needs to be under runtime folder
|
||||||
probability: 0.02
|
wasm-runtime: runtime.wasm
|
||||||
|
wasm-runtime-settings:
|
||||||
|
difficulty: 20
|
||||||
|
verify-probability: 0.02
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
10
Dockerfile
10
Dockerfile
@@ -1,5 +1,5 @@
|
|||||||
ARG from_builder=golang:1.24-alpine3.21
|
ARG from_builder=docker.io/golang:1.24-alpine3.21
|
||||||
ARG from=alpine:3.21
|
ARG from=docker.io/alpine:3.21
|
||||||
|
|
||||||
ARG BUILDPLATFORM
|
ARG BUILDPLATFORM
|
||||||
|
|
||||||
@@ -39,6 +39,7 @@ ENV GOAWAY_BIND=":8080"
|
|||||||
ENV GOAWAY_BIND_NETWORK="tcp"
|
ENV GOAWAY_BIND_NETWORK="tcp"
|
||||||
ENV GOAWAY_SOCKET_MODE="0770"
|
ENV GOAWAY_SOCKET_MODE="0770"
|
||||||
ENV GOAWAY_POLICY="/policy.yml"
|
ENV GOAWAY_POLICY="/policy.yml"
|
||||||
|
ENV GOAWAY_POLICY_SNIPPETS="/policy/snippets"
|
||||||
ENV GOAWAY_CHALLENGE_TEMPLATE="anubis"
|
ENV GOAWAY_CHALLENGE_TEMPLATE="anubis"
|
||||||
ENV GOAWAY_CHALLENGE_TEMPLATE_THEME=""
|
ENV GOAWAY_CHALLENGE_TEMPLATE_THEME=""
|
||||||
ENV GOAWAY_SLOG_LEVEL="WARN"
|
ENV GOAWAY_SLOG_LEVEL="WARN"
|
||||||
@@ -46,7 +47,6 @@ ENV GOAWAY_CLIENT_IP_HEADER=""
|
|||||||
ENV GOAWAY_BACKEND_IP_HEADER=""
|
ENV GOAWAY_BACKEND_IP_HEADER=""
|
||||||
ENV GOAWAY_JWT_PRIVATE_KEY_SEED=""
|
ENV GOAWAY_JWT_PRIVATE_KEY_SEED=""
|
||||||
ENV GOAWAY_BACKEND=""
|
ENV GOAWAY_BACKEND=""
|
||||||
ENV GOAWAY_DNSBL="dnsbl.dronebl.org"
|
|
||||||
ENV GOAWAY_ACME_AUTOCERT=""
|
ENV GOAWAY_ACME_AUTOCERT=""
|
||||||
ENV GOAWAY_CACHE="/cache"
|
ENV GOAWAY_CACHE="/cache"
|
||||||
|
|
||||||
@@ -56,9 +56,9 @@ EXPOSE 8080/udp
|
|||||||
ENV JWT_PRIVATE_KEY_SEED="${GOAWAY_JWT_PRIVATE_KEY_SEED}"
|
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}" \
|
ENTRYPOINT /bin/go-away --bind "${GOAWAY_BIND}" --bind-network "${GOAWAY_BIND_NETWORK}" --socket-mode "${GOAWAY_SOCKET_MODE}" \
|
||||||
--policy ${GOAWAY_POLICY} --client-ip-header "${GOAWAY_CLIENT_IP_HEADER}" --backend-ip-header "${GOAWAY_BACKEND_IP_HEADER}" \
|
--policy "${GOAWAY_POLICY}" --policy-snippets "${GOAWAY_POLICY_SNIPPETS}" \
|
||||||
|
--client-ip-header "${GOAWAY_CLIENT_IP_HEADER}" --backend-ip-header "${GOAWAY_BACKEND_IP_HEADER}" \
|
||||||
--cache "${GOAWAY_CACHE}" \
|
--cache "${GOAWAY_CACHE}" \
|
||||||
--dnsbl "${GOAWAY_DNSBL}" \
|
|
||||||
--challenge-template "${GOAWAY_CHALLENGE_TEMPLATE}" --challenge-template-theme "${GOAWAY_CHALLENGE_TEMPLATE_THEME}" \
|
--challenge-template "${GOAWAY_CHALLENGE_TEMPLATE}" --challenge-template-theme "${GOAWAY_CHALLENGE_TEMPLATE_THEME}" \
|
||||||
--slog-level "${GOAWAY_SLOG_LEVEL}" \
|
--slog-level "${GOAWAY_SLOG_LEVEL}" \
|
||||||
--acme-autocert "${GOAWAY_ACME_AUTOCERT}" \
|
--acme-autocert "${GOAWAY_ACME_AUTOCERT}" \
|
||||||
|
|||||||
110
README.md
110
README.md
@@ -26,7 +26,19 @@ If you have some suggestion or issue, feel free to open a [New Issue](https://gi
|
|||||||
|
|
||||||
For real-time chat and other support join IRC on [#go-away](ircs://irc.libera.chat/#go-away) on Libera.Chat [[WebIRC]](https://web.libera.chat/?nick=Guest?#go-away). The channel may not be monitored at all times, feel free to ping the operators there.
|
For real-time chat and other support join IRC on [#go-away](ircs://irc.libera.chat/#go-away) on Libera.Chat [[WebIRC]](https://web.libera.chat/?nick=Guest?#go-away). The channel may not be monitored at all times, feel free to ping the operators there.
|
||||||
|
|
||||||
A source code mirror exists on [sourcehut](https://git.sr.ht/~datahoarder/go-away), [Codeberg.org](https://codeberg.org/WeebDataHoarder/go-away), and [GitHub](https://github.com/WeebDataHoarder/go-away).
|
## Code Mirrors
|
||||||
|
|
||||||
|
Source code is automatically pushed to the following mirrors. Packages are also mirrored on Codeberg and GitHub.
|
||||||
|
|
||||||
|
[](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/WeebDataHoarder/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).
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
@@ -42,26 +54,19 @@ Rules and conditions are served with this environment:
|
|||||||
|
|
||||||
```
|
```
|
||||||
remoteAddress (net.IP) - Connecting client remote address from headers or properties
|
remoteAddress (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
|
host (string) - HTTP Host
|
||||||
method (string) - HTTP Method/Verb
|
method (string) - HTTP Method/Verb
|
||||||
userAgent (string) - HTTP User-Agent header
|
userAgent (string) - HTTP User-Agent header
|
||||||
path (string) - HTTP request Path
|
path (string) - HTTP request Path
|
||||||
query (map[string]string) - HTTP request Query arguments
|
query (map[string]string) - HTTP request Query arguments
|
||||||
headers (map[string]string) - HTTP request headers
|
headers (map[string]string) - HTTP request headers
|
||||||
|
fp (map[string]string) - Available fingerprints
|
||||||
|
|
||||||
Only available when TLS is enabled
|
Only available when TLS is enabled
|
||||||
fpJA3N (string) JA3N TLS Fingerprint
|
fp.ja3n (string) JA3N TLS Fingerprint
|
||||||
fpJA4 (string) JA4 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
|
### Template support
|
||||||
@@ -77,14 +82,23 @@ External templates for your site can be loaded specifying a full path to the `.g
|
|||||||
|
|
||||||
### Extended rule actions
|
### Extended rule actions
|
||||||
|
|
||||||
In addition to the common PASS / CHALLENGE / DENY rules, we offer CHECK and POISON.
|
In addition to the common PASS / CHALLENGE / DENY rules, go-away offers more actions that can be extended via code.
|
||||||
|
|
||||||
|
| Action | Behavior | Terminating |
|
||||||
|
|:---------:|:------------------------------------------------------------------------|:-----------:|
|
||||||
|
| PASS | Passes the request to the backend immediately | Yes |
|
||||||
|
| DENY | Denies the request with a descriptive page | Yes |
|
||||||
|
| BLOCK | Denies the request with a response code | Yes |
|
||||||
|
| DROP | Drops the connection without sending a reply | Yes |
|
||||||
|
| CHALLENGE | Issues a challenge that when passed, acts like PASS | Yes |
|
||||||
|
| CHECK | Issues a challenge that when passed, continues executing rules | No |
|
||||||
|
| PROXY | Proxies request to a different backend, with optional path replacements | Yes |
|
||||||
|
|
||||||
|
|
||||||
CHECK allows the client to be challenged but continue matching rules after these, for example, chaining a list of challenges that must be passed.
|
CHECK allows the client to be challenged but continue matching rules after these, for example, chaining a list of challenges that must be passed.
|
||||||
For example, you could use this to implement browser in checks without explicitly allowing all requests, and later deferring to a secondary check/challenge.
|
For example, you could use this to implement browser in checks without explicitly allowing all requests, and later deferring to a secondary check/challenge.
|
||||||
|
|
||||||
POISON sends defined responses to bad clients that will annoy them.
|
PROXY allows the operator to send matching requests to a different backend, for example, a poison generator or a scraping maze.
|
||||||
This must be configured by the operator, some networks have been seen to only stop when served back this output.
|
|
||||||
Currently, an HTML payload exists that uncompressed to about one GiB of nonsense DOM. You could use this to send garbage for would-be training data.
|
|
||||||
|
|
||||||
### Multiple challenge matching
|
### Multiple challenge matching
|
||||||
|
|
||||||
@@ -94,16 +108,17 @@ For example:
|
|||||||
```yaml
|
```yaml
|
||||||
- name: standard-browser
|
- name: standard-browser
|
||||||
action: challenge
|
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:
|
conditions:
|
||||||
- '($is-generic-browser)'
|
- '($is-generic-browser)'
|
||||||
```
|
```
|
||||||
|
|
||||||
This rule has the user be checked against a backend, then attempts pass a few browser challenges.
|
This rule has the user be checked against a backend, then attempts pass a few browser challenges.
|
||||||
|
|
||||||
In this case the processing would stop at `self-meta-refresh` due to the behavior of earlier challenges (cookie check and preload link allow failing / continue due to being silent, while meta-refresh requires displaying a challenge page).
|
In this case the processing would stop at `meta-refresh` due to the behavior of earlier challenges (cookie check and preload link allow failing / continue due to being silent, while meta-refresh requires displaying a challenge page).
|
||||||
|
|
||||||
Any of these listed challenges being passed in the past will allow the client through, including non-offered `self-resource-load` and `js-pow-sha256`.
|
Any of these listed challenges being passed in the past will allow the client through, including non-offered `resource-load` and `js-pow-sha256`.
|
||||||
|
|
||||||
### Non-Javascript challenges
|
### Non-Javascript challenges
|
||||||
|
|
||||||
@@ -144,19 +159,6 @@ This can be targeted on conditions or other application logic.
|
|||||||
|
|
||||||
Read more about [JA3](https://medium.com/salesforce-engineering/tls-fingerprinting-with-ja3-and-ja3s-247362855967) and [JA4](https://github.com/FoxIO-LLC/ja4/blob/main/technical_details/README.md).
|
Read more about [JA3](https://medium.com/salesforce-engineering/tls-fingerprinting-with-ja3-and-ja3s-247362855967) and [JA4](https://github.com/FoxIO-LLC/ja4/blob/main/technical_details/README.md).
|
||||||
|
|
||||||
|
|
||||||
### DNSBL
|
|
||||||
|
|
||||||
You can configure a [DNSBL (Domain Name System blocklist)](https://en.wikipedia.org/wiki/Domain_Name_System_blocklist) to be queried on rules and conditions.
|
|
||||||
|
|
||||||
This allows you to serve harder or different challenges to higher risk clients, or block them from specific sections.
|
|
||||||
|
|
||||||
Only rules that match DNSBL will cause a query to be sent, meaning the bulk of requests will not be sent to this service upstream.
|
|
||||||
|
|
||||||
Results will be temporarily cached
|
|
||||||
|
|
||||||
By default, [DroneBL](https://dronebl.org/) is used.
|
|
||||||
|
|
||||||
### Network range and automated filtering
|
### Network range and automated filtering
|
||||||
|
|
||||||
Some specific search spiders do follow _robots.txt_ and are well behaved. However, many actors can reuse user agents, so the origin network ranges must be checked.
|
Some specific search spiders do follow _robots.txt_ and are well behaved. However, many actors can reuse user agents, so the origin network ranges must be checked.
|
||||||
@@ -194,6 +196,8 @@ By default, a random temporary key is generated every run.
|
|||||||
|
|
||||||
Multiple backends are supported, and rules specific on backend can be defined, and conditions and rules can match this as well.
|
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.
|
This allows one instance to run multiple domains or subdomains.
|
||||||
|
|
||||||
### Package path
|
### Package path
|
||||||
@@ -215,7 +219,6 @@ This is tracked by tagging challenges with a readable flag indicating the type o
|
|||||||
The policy file at [examples/forgejo.yml](examples/forgejo.yml) provides a ready template to be used on your own Forgejo instance.
|
The policy file at [examples/forgejo.yml](examples/forgejo.yml) provides a ready template to be used on your own Forgejo instance.
|
||||||
|
|
||||||
Important notes:
|
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.
|
* 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.
|
* 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.
|
* Check the conditions and base rules to change your challenges offered and other ordering.
|
||||||
@@ -233,6 +236,14 @@ Important notes:
|
|||||||
* Add or modify rules to target specific pages on your site as desired.
|
* 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.
|
* By default Googlebot / Bingbot / DuckDuckBot / Kagibot / Qwantbot / Yandexbot are allowed by useragent and network ranges.
|
||||||
|
|
||||||
|
### Snippets
|
||||||
|
|
||||||
|
You can define snippets to be included. YAML anchors/aliases are supported.
|
||||||
|
|
||||||
|
See [examples/snippets/](examples/snippets/) for some defaults including indexer bots, challenges and other general matches.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## Why do this?
|
## Why do this?
|
||||||
In the past few years this small git instance has been hit by waves and waves of scraping.
|
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.
|
This was usually fought back by random useragent blocks for bots that did not follow [robots.txt](/robots.txt), until the past half year, where low-effort mass scraping was used more prominently.
|
||||||
@@ -280,16 +291,16 @@ go-away offers a highly configurable set of challenges and rules that you can ad
|
|||||||
go-away has most of the desired features from the original checklist that was made in its development.
|
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:
|
However, a few points are left before go-away can be called v1.0.0:
|
||||||
|
|
||||||
* [ ] Several parts of the code are going through a refactor, which won't impact end users or operators.
|
* [x] Several parts of the code are going through a refactor, which won't impact end users or operators.
|
||||||
* [ ] Documentation is lacking and a more extensive one with inline example is in the works.
|
* [ ] Documentation is lacking and a more extensive one with inline example is in the works.
|
||||||
* [ ] Policy file syntax is going to stay mostly unchanged, except in the challenges definition section.
|
* [x] Policy file syntax is going to stay mostly unchanged, except in the challenges definition section.
|
||||||
* [ ] Allow users to pick fallback challenges if any fail, specially with custom ones.
|
* [ ] Allow users to pick fallback challenges if any fail, specially with custom ones.
|
||||||
* [ ] Replace Anubis-like default template with own one.
|
* [ ] Replace Anubis-like default template with own one.
|
||||||
* [ ] Define strings and multi-language support for quick modification by operators without custom templates.
|
* [ ] Define strings and multi-language support for quick modification by operators without custom templates.
|
||||||
* [ ] Have highly tested paths that match examples.
|
* [ ] Have highly tested paths that match examples.
|
||||||
* [ ] Caching of temporary fetches, for example, network ranges.
|
* [x] Caching of temporary fetches, for example, network ranges.
|
||||||
* [ ] Allow live and dynamic policy reloading.
|
* [x] Allow live and dynamic policy reloading.
|
||||||
* [ ] Multiple domains / subdomains -> one backend handling, CEL rules for backends
|
* [x] Multiple domains / subdomains -> one backend handling, CEL rules for backends
|
||||||
* [ ] Merge all rules and conditions into one large AST for higher performance.
|
* [ ] Merge all rules and conditions into one large AST for higher performance.
|
||||||
* [ ] Explore exposing a module for direct Caddy usage.
|
* [ ] Explore exposing a module for direct Caddy usage.
|
||||||
* [ ] More defined way of picking HTTP/HTTP(s) listeners and certificates.
|
* [ ] More defined way of picking HTTP/HTTP(s) listeners and certificates.
|
||||||
@@ -330,6 +341,8 @@ Available under [Dockerfile](Dockerfile). See the _docker compose_ below for the
|
|||||||
|
|
||||||
Example follows a hypothetical Forgejo server running on `http://forgejo:3000` serving `git.example.com`
|
Example follows a hypothetical Forgejo server running on `http://forgejo:3000` serving `git.example.com`
|
||||||
|
|
||||||
|
Container images are published under `git.gammaspectra.live/git/go-away`, `codeberg.org/weebdatahoarder/go-away` and `ghcr.io/weebdatahoarder/go-away`
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
networks:
|
networks:
|
||||||
forgejo:
|
forgejo:
|
||||||
@@ -340,6 +353,8 @@ volumes:
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
go-away:
|
go-away:
|
||||||
|
# image: codeberg.org/weebdatahoarder/go-away:latest
|
||||||
|
# image: ghcr.io/weebdatahoarder/go-away:latest
|
||||||
image: git.gammaspectra.live/git/go-away:latest
|
image: git.gammaspectra.live/git/go-away:latest
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
@@ -351,6 +366,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- "goaway_cache:/cache"
|
- "goaway_cache:/cache"
|
||||||
- "./examples/forgejo.yml:/policy.yml:ro"
|
- "./examples/forgejo.yml:/policy.yml:ro"
|
||||||
|
- "./examples/snippets/:/policy/snippets/:ro"
|
||||||
environment:
|
environment:
|
||||||
#GOAWAY_BIND: ":8080"
|
#GOAWAY_BIND: ":8080"
|
||||||
# Supported tcp, unix, and proxy (for enabling PROXY module for request unwrapping)
|
# Supported tcp, unix, and proxy (for enabling PROXY module for request unwrapping)
|
||||||
@@ -385,15 +401,15 @@ services:
|
|||||||
#GOAWAY_BACKEND_IP_HEADER: ""
|
#GOAWAY_BACKEND_IP_HEADER: ""
|
||||||
|
|
||||||
GOAWAY_POLICY: "/policy.yml"
|
GOAWAY_POLICY: "/policy.yml"
|
||||||
|
|
||||||
|
GOAWAY_POLICY_SNIPPETS: "/policy/snippets"
|
||||||
|
|
||||||
# Template, and theme for the template to pick. defaults to an anubis-like one
|
# 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
|
# An file path can be specified. See embed/templates for a few examples
|
||||||
GOAWAY_CHALLENGE_TEMPLATE: forgejo
|
GOAWAY_CHALLENGE_TEMPLATE: forgejo
|
||||||
GOAWAY_CHALLENGE_TEMPLATE_THEME: forgejo-dark
|
GOAWAY_CHALLENGE_TEMPLATE_THEME: forgejo-dark
|
||||||
|
|
||||||
# specify a DNSBL for usage in conditions. Defaults to DroneBL
|
# Backend to match. Can be subdomain or full wildcards, "*.example.com" or "*"
|
||||||
# GOAWAY_DNSBL: "dnsbl.dronebl.org"
|
|
||||||
|
|
||||||
GOAWAY_BACKEND: "git.example.com=http://forgejo:3000"
|
GOAWAY_BACKEND: "git.example.com=http://forgejo:3000"
|
||||||
|
|
||||||
# additional backends can be specified via more command arguments
|
# additional backends can be specified via more command arguments
|
||||||
@@ -407,8 +423,12 @@ services:
|
|||||||
|
|
||||||
|
|
||||||
## Other Similar Projects
|
## Other Similar Projects
|
||||||
* [Anubis](https://anubis.techaro.lol/): Proxy that uses JavaScript proof of work to weight request based on rules [[source]](https://github.com/TecharoHQ/anubis)
|
|
||||||
* [anticrawl](https://flak.tedunangst.com/post/anticrawl): Go http handler / proxy for regex based rules [[source]](https://humungus.tedunangst.com/r/anticrawl)
|
| Project | Forge | Description |
|
||||||
|
|:-------------------------------------------------------:|:-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------:|:----------------------------------------------------------------------------------------------------------------|
|
||||||
|
| [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 |
|
||||||
|
| [powxy](https://sr.ht/~runxiyu/powxy/) | [](https://git.sr.ht/~runxiyu/powxy)<br/> Go / [BSD 2-Clause](https://git.sr.ht/~runxiyu/powxy/tree/master/item/LICENSE) | Powxy is a reverse proxy that protects your upstream service by challenging clients with SHA-256 proof-of-work. |
|
||||||
|
| [anticrawl](https://flak.tedunangst.com/post/anticrawl) | [[source]](https://humungus.tedunangst.com/r/anticrawl)<br/>Go / None | Go http handler / proxy for regex based rules |
|
||||||
|
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|||||||
@@ -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,7 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"bytes"
|
||||||
"crypto/ed25519"
|
"crypto/ed25519"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
@@ -15,19 +15,18 @@ import (
|
|||||||
"github.com/pires/go-proxyproto"
|
"github.com/pires/go-proxyproto"
|
||||||
"golang.org/x/crypto/acme"
|
"golang.org/x/crypto/acme"
|
||||||
"golang.org/x/crypto/acme/autocert"
|
"golang.org/x/crypto/acme/autocert"
|
||||||
"gopkg.in/yaml.v3"
|
|
||||||
"log"
|
"log"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"maps"
|
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"os/signal"
|
||||||
"path"
|
"path"
|
||||||
"runtime/debug"
|
"runtime/debug"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync/atomic"
|
||||||
"time"
|
"syscall"
|
||||||
)
|
)
|
||||||
|
|
||||||
func setupListener(network, address, socketMode string, proxy bool) (net.Listener, string) {
|
func setupListener(network, address, socketMode string, proxy bool) (net.Listener, string) {
|
||||||
@@ -77,14 +76,19 @@ func setupListener(network, address, socketMode string, proxy bool) (net.Listene
|
|||||||
return listener, formattedAddress
|
return listener, formattedAddress
|
||||||
}
|
}
|
||||||
|
|
||||||
var internalPackageName = func() string {
|
var internalCmdName = "go-away"
|
||||||
|
var internalMainName = "go-away"
|
||||||
|
var internalMainVersion = "dev"
|
||||||
|
|
||||||
|
func init() {
|
||||||
buildInfo, ok := debug.ReadBuildInfo()
|
buildInfo, ok := debug.ReadBuildInfo()
|
||||||
if !ok {
|
if !ok {
|
||||||
return "go-away"
|
return
|
||||||
}
|
}
|
||||||
return buildInfo.Path
|
internalCmdName = buildInfo.Path
|
||||||
}()
|
internalMainName = buildInfo.Main.Path
|
||||||
|
internalMainVersion = buildInfo.Main.Version
|
||||||
|
}
|
||||||
|
|
||||||
type MultiVar []string
|
type MultiVar []string
|
||||||
|
|
||||||
@@ -129,20 +133,20 @@ func main() {
|
|||||||
slogLevel := flag.String("slog-level", "WARN", "logging level (see https://pkg.go.dev/log/slog#hdr-Levels)")
|
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")
|
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")
|
passThrough := flag.Bool("passthrough", false, "passthrough mode sends all requests to matching backends until state is loaded")
|
||||||
|
check := flag.Bool("check", false, "check configuration and policies, then exit")
|
||||||
acmeAutocert := flag.String("acme-autocert", "", "enables HTTP(s) mode and uses the provided ACME server URL or available service (available: letsencrypt)")
|
acmeAutocert := flag.String("acme-autocert", "", "enables HTTP(s) mode and uses the provided ACME server URL or available service (available: letsencrypt)")
|
||||||
|
|
||||||
clientIpHeader := flag.String("client-ip-header", "", "Client HTTP header to fetch their IP address from (X-Real-Ip, X-Client-Ip, X-Forwarded-For, Cf-Connecting-Ip, etc.)")
|
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.)")
|
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")
|
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")
|
policyFile := flag.String("policy", "", "path to policy YAML file")
|
||||||
|
policySnippets := flag.String("policy-snippets", "", "path to YAML snippets folder")
|
||||||
challengeTemplate := flag.String("challenge-template", "anubis", "name or path of the challenge template to use (anubis, forgejo)")
|
challengeTemplate := flag.String("challenge-template", "anubis", "name or path of the challenge template to use (anubis, forgejo)")
|
||||||
challengeTemplateTheme := flag.String("challenge-template-theme", "", "name of the challenge template theme to use (forgejo => [forgejo-auto, forgejo-dark, forgejo-light, gitea...])")
|
challengeTemplateTheme := flag.String("challenge-template-theme", "", "name of the challenge template theme to use (forgejo => [forgejo-auto, forgejo-dark, forgejo-light, gitea...])")
|
||||||
|
|
||||||
packageName := flag.String("package-path", internalPackageName, "package name to expose in .well-known url path")
|
packageName := flag.String("package-path", internalCmdName, "package name to expose in .well-known url path")
|
||||||
|
|
||||||
jwtPrivateKeySeed := flag.String("jwt-private-key-seed", "", "Seed for the jwt private key, or on JWT_PRIVATE_KEY_SEED env. One be generated by passing \"generate\" as a value, follows RFC 8032 private key definition. Defaults to random")
|
jwtPrivateKeySeed := flag.String("jwt-private-key-seed", "", "Seed for the jwt private key, or on JWT_PRIVATE_KEY_SEED env. One be generated by passing \"generate\" as a value, follows RFC 8032 private key definition. Defaults to random")
|
||||||
|
|
||||||
@@ -170,6 +174,8 @@ func main() {
|
|||||||
slog.SetDefault(slog.New(h))
|
slog.SetDefault(slog.New(h))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
slog.Info("go-away", "package", internalMainName, "version", internalMainVersion, "cmd", internalCmdName)
|
||||||
|
|
||||||
var seed []byte
|
var seed []byte
|
||||||
|
|
||||||
var kValue string
|
var kValue string
|
||||||
@@ -200,22 +206,9 @@ func main() {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
policyData, err := os.ReadFile(*policyFile)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(fmt.Errorf("failed to read policy file: %w", err))
|
|
||||||
}
|
|
||||||
|
|
||||||
var p policy.Policy
|
|
||||||
|
|
||||||
if err = yaml.Unmarshal(policyData, &p); err != nil {
|
|
||||||
log.Fatal(fmt.Errorf("failed to parse policy file: %w", err))
|
|
||||||
}
|
|
||||||
|
|
||||||
createdBackends := make(map[string]http.Handler)
|
createdBackends := make(map[string]http.Handler)
|
||||||
|
|
||||||
parsedBackends := make(map[string]string)
|
parsedBackends := make(map[string]string)
|
||||||
//TODO: deprecate
|
|
||||||
maps.Copy(parsedBackends, p.Backends)
|
|
||||||
for _, backend := range backends {
|
for _, backend := range backends {
|
||||||
parts := strings.Split(backend, "=")
|
parts := strings.Split(backend, "=")
|
||||||
if len(parts) != 2 {
|
if len(parts) != 2 {
|
||||||
@@ -234,11 +227,27 @@ func main() {
|
|||||||
createdBackends[k] = backend
|
createdBackends[k] = backend
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(createdBackends) == 0 {
|
||||||
|
log.Fatal(fmt.Errorf("no backends defined in policy file"))
|
||||||
|
}
|
||||||
|
|
||||||
|
var cache utils.Cache
|
||||||
if *cachePath != "" {
|
if *cachePath != "" {
|
||||||
err = os.MkdirAll(*cachePath, 0755)
|
err = os.MkdirAll(*cachePath, 0755)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(fmt.Errorf("failed to create cache directory: %w", err))
|
log.Fatal(fmt.Errorf("failed to create cache directory: %w", err))
|
||||||
}
|
}
|
||||||
|
for _, n := range []string{"networks", "acme"} {
|
||||||
|
err = os.MkdirAll(path.Join(*cachePath, n), 0755)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(fmt.Errorf("failed to create cache sub directory %s: %w", n, err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cache, err = utils.CacheDirectory(*cachePath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(fmt.Errorf("failed to open cache directory: %w", err))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var tlsConfig *tls.Config
|
var tlsConfig *tls.Config
|
||||||
@@ -264,99 +273,112 @@ func main() {
|
|||||||
tlsConfig = acmeManager.TLSConfig()
|
tlsConfig = acmeManager.TLSConfig()
|
||||||
}
|
}
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
loadPolicyState := func() (http.Handler, error) {
|
||||||
|
policyData, err := os.ReadFile(*policyFile)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read policy file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
passThroughCtx, cancelFunc := context.WithCancel(context.Background())
|
p, err := policy.NewPolicy(bytes.NewReader(policyData), *policySnippets)
|
||||||
defer cancelFunc()
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse policy file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
if *passThrough {
|
settings := policy.Settings{
|
||||||
wg.Add(1)
|
Cache: cache,
|
||||||
go func() {
|
Backends: createdBackends,
|
||||||
defer wg.Done()
|
Debug: *debugMode,
|
||||||
|
MainName: internalMainName,
|
||||||
|
MainVersion: internalMainVersion,
|
||||||
|
PackageName: *packageName,
|
||||||
|
ChallengeTemplate: *challengeTemplate,
|
||||||
|
ChallengeTemplateTheme: *challengeTemplateTheme,
|
||||||
|
PrivateKeySeed: seed,
|
||||||
|
ClientIpHeader: *clientIpHeader,
|
||||||
|
BackendIpHeader: *backendIpHeader,
|
||||||
|
ChallengeResponseCode: http.StatusTeapot,
|
||||||
|
}
|
||||||
|
|
||||||
server := utils.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
state, err := lib.NewState(*p, settings)
|
||||||
backend, ok := createdBackends[r.Host]
|
|
||||||
if !ok {
|
|
||||||
http.Error(w, http.StatusText(http.StatusServiceUnavailable), http.StatusServiceUnavailable)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
backend.ServeHTTP(w, r)
|
if err != nil {
|
||||||
}), tlsConfig)
|
return nil, fmt.Errorf("failed to create state: %w", err)
|
||||||
|
}
|
||||||
listener, listenUrl := setupListener(*bindNetwork, *bind, *socketMode, *bindProxy)
|
return state, nil
|
||||||
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()
|
|
||||||
}()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
settings := lib.StateSettings{
|
if *check {
|
||||||
Backends: createdBackends,
|
_, err := loadPolicyState()
|
||||||
Debug: *debugMode,
|
if err != nil {
|
||||||
PackageName: *packageName,
|
slog.Error(err.Error())
|
||||||
ChallengeTemplate: *challengeTemplate,
|
os.Exit(1)
|
||||||
ChallengeTemplateTheme: *challengeTemplateTheme,
|
}
|
||||||
PrivateKeySeed: seed,
|
slog.Info("load ok")
|
||||||
ClientIpHeader: *clientIpHeader,
|
os.Exit(0)
|
||||||
BackendIpHeader: *backendIpHeader,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 := setupListener(*bindNetwork, *bind, *socketMode, *bindProxy)
|
||||||
slog.Warn(
|
slog.Warn(
|
||||||
"listening",
|
"listening",
|
||||||
"url", listenUrl,
|
"url", listenUrl,
|
||||||
)
|
)
|
||||||
|
|
||||||
server := utils.NewServer(state, tlsConfig)
|
var serverHandler atomic.Pointer[http.Handler]
|
||||||
|
server := utils.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if handler := serverHandler.Load(); handler == nil {
|
||||||
|
http.Error(w, http.StatusText(http.StatusBadGateway), http.StatusBadGateway)
|
||||||
|
} else {
|
||||||
|
(*handler).ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
}), tlsConfig)
|
||||||
|
|
||||||
|
if *passThrough {
|
||||||
|
// setup a passthrough handler temporarily
|
||||||
|
fn := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
backend := utils.SelectHTTPHandler(createdBackends, r.Host)
|
||||||
|
if backend == nil {
|
||||||
|
http.Error(w, http.StatusText(http.StatusBadGateway), http.StatusBadGateway)
|
||||||
|
} else {
|
||||||
|
backend.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
serverHandler.Store(&fn)
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
handler, err := loadPolicyState()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(fmt.Errorf("failed to load policy state: %w", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
serverHandler.Store(&handler)
|
||||||
|
slog.Warn(
|
||||||
|
"handler configuration loaded",
|
||||||
|
)
|
||||||
|
|
||||||
|
// allow reloading from now on
|
||||||
|
c := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(c, syscall.SIGHUP)
|
||||||
|
for sig := range c {
|
||||||
|
if sig != syscall.SIGHUP {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
handler, err = loadPolicyState()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("handler configuration reload error", "err", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
serverHandler.Store(&handler)
|
||||||
|
slog.Warn("handler configuration reloaded")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
if tlsConfig != nil {
|
if tlsConfig != nil {
|
||||||
if err := server.ServeTLS(listener, "", ""); !errors.Is(err, http.ErrServerClosed) {
|
if err := server.ServeTLS(listener, "", ""); !errors.Is(err, http.ErrServerClosed) {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
||||||
if err := server.Serve(listener); !errors.Is(err, http.ErrServerClosed) {
|
if err := server.Serve(listener); !errors.Is(err, http.ErrServerClosed) {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
@@ -1,15 +1,59 @@
|
|||||||
package embed
|
package embed
|
||||||
|
|
||||||
import "embed"
|
import (
|
||||||
|
"embed"
|
||||||
|
"errors"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
//go:embed assets
|
//go:embed assets
|
||||||
var AssetsFs embed.FS
|
var assetsFs embed.FS
|
||||||
|
|
||||||
//go:embed challenge
|
//go:embed challenge
|
||||||
var ChallengeFs embed.FS
|
var challengeFs embed.FS
|
||||||
|
|
||||||
//go:embed templates
|
//go:embed templates
|
||||||
var TemplatesFs embed.FS
|
var templatesFs embed.FS
|
||||||
|
|
||||||
//go:embed poison/*.poison
|
type FSInterface interface {
|
||||||
var PoisonFs embed.FS
|
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.
@@ -11,7 +11,7 @@
|
|||||||
<meta name="{{ $key }}" content="{{ $value }}"/>
|
<meta name="{{ $key }}" content="{{ $value }}"/>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{ end }}
|
{{ end }}
|
||||||
{{ range .Tags }}
|
{{ range .HeaderTags }}
|
||||||
{{ . }}
|
{{ . }}
|
||||||
{{ end }}
|
{{ end }}
|
||||||
<style>
|
<style>
|
||||||
@@ -155,7 +155,6 @@
|
|||||||
/>
|
/>
|
||||||
{{if .Challenge }}
|
{{if .Challenge }}
|
||||||
<p id="status">Loading challenge <em>{{ .Challenge }}</em>...</p>
|
<p id="status">Loading challenge <em>{{ .Challenge }}</em>...</p>
|
||||||
<script async type="module" src="{{ .Path }}/challenge/{{ .Challenge }}/challenge.mjs?cacheBust={{ .Random }}"></script>
|
|
||||||
{{else if .Error}}
|
{{else if .Error}}
|
||||||
<p id="status">Error: {{ .Error }}</p>
|
<p id="status">Error: {{ .Error }}</p>
|
||||||
{{else}}
|
{{else}}
|
||||||
@@ -202,6 +201,11 @@
|
|||||||
</p>
|
</p>
|
||||||
</center>
|
</center>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
|
||||||
|
{{ range .EndTags }}
|
||||||
|
{{ . }}
|
||||||
|
{{ end }}
|
||||||
</main>
|
</main>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
<meta name="{{ $key }}" content="{{ $value }}"/>
|
<meta name="{{ $key }}" content="{{ $value }}"/>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{ end }}
|
{{ end }}
|
||||||
{{ range .Tags }}
|
{{ range .HeaderTags }}
|
||||||
{{ . }}
|
{{ . }}
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
||||||
@@ -62,7 +62,6 @@
|
|||||||
|
|
||||||
{{if .Challenge }}
|
{{if .Challenge }}
|
||||||
<h3 id="status">Loading challenge <em>{{ .Challenge }}</em>...</h3>
|
<h3 id="status">Loading challenge <em>{{ .Challenge }}</em>...</h3>
|
||||||
<script async type="module" src="{{ .Path }}/challenge/{{ .Challenge }}/challenge.mjs?cacheBust={{ .Random }}"></script>
|
|
||||||
{{else if .Error}}
|
{{else if .Error}}
|
||||||
<h3 id="status">Error: {{ .Error }}</h3>
|
<h3 id="status">Error: {{ .Error }}</h3>
|
||||||
{{else}}
|
{{else}}
|
||||||
@@ -110,5 +109,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
{{ range .EndTags }}
|
||||||
|
{{ . }}
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,189 +1,41 @@
|
|||||||
# Example cmdline (forward requests from upstream to port :8080)
|
# Example cmdline (forward requests from upstream to port :8080)
|
||||||
# $ go-away --bind :8080 --backend git.example.com=http://forgejo:3000 --policy examples/forgejo.yml --challenge-template forgejo --challenge-template-theme forgejo-auto
|
# $ go-away --bind :8080 --backend git.example.com=http://forgejo:3000 --policy examples/forgejo.yml --policy-snippets example/snippets/ --challenge-template forgejo --challenge-template-theme forgejo-auto
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Define networks to be used later below
|
# Define networks to be used later below
|
||||||
networks:
|
networks:
|
||||||
# todo: support direct ASN lookups
|
# Networks will get included from snippets
|
||||||
# todo: cache these values
|
|
||||||
huawei-cloud:
|
huawei-cloud:
|
||||||
# AS136907
|
- asn: 136907
|
||||||
- url: https://raw.githubusercontent.com/ipverse/asn-ip/refs/heads/master/as/136907/aggregated.json
|
|
||||||
jq-path: '.subnets.ipv4[], .subnets.ipv6[]'
|
|
||||||
alibaba-cloud:
|
alibaba-cloud:
|
||||||
# AS45102
|
- asn: 45102
|
||||||
- url: https://raw.githubusercontent.com/ipverse/asn-ip/refs/heads/master/as/45102/aggregated.json
|
|
||||||
jq-path: '.subnets.ipv4[], .subnets.ipv6[]'
|
|
||||||
zenlayer-inc:
|
zenlayer-inc:
|
||||||
# AS21859
|
- asn: 21859
|
||||||
- url: https://raw.githubusercontent.com/ipverse/asn-ip/refs/heads/master/as/21859/aggregated.json
|
|
||||||
jq-path: '.subnets.ipv4[], .subnets.ipv6[]'
|
|
||||||
aws-cloud:
|
|
||||||
- url: https://ip-ranges.amazonaws.com/ip-ranges.json
|
|
||||||
jq-path: '(.prefixes[] | select(has("ip_prefix")) | .ip_prefix), (.prefixes[] | select(has("ipv6_prefix")) | .ipv6_prefix)'
|
|
||||||
google-cloud:
|
|
||||||
- url: https://www.gstatic.com/ipranges/cloud.json
|
|
||||||
jq-path: '(.prefixes[] | select(has("ipv4Prefix")) | .ipv4Prefix), (.prefixes[] | select(has("ipv6Prefix")) | .ipv6Prefix)'
|
|
||||||
oracle-cloud:
|
|
||||||
- url: https://docs.oracle.com/en-us/iaas/tools/public_ip_ranges.json
|
|
||||||
jq-path: '.regions[] | .cidrs[] | .cidr'
|
|
||||||
azure-cloud:
|
|
||||||
# todo: https://www.microsoft.com/en-us/download/details.aspx?id=56519 does not provide direct JSON
|
|
||||||
- url: https://raw.githubusercontent.com/femueller/cloud-ip-ranges/refs/heads/master/microsoft-azure-ip-ranges.json
|
|
||||||
jq-path: '.values[] | .properties.addressPrefixes[]'
|
|
||||||
|
|
||||||
digitalocean:
|
|
||||||
- url: https://www.digitalocean.com/geo/google.csv
|
|
||||||
regex: "(?P<prefix>(([0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+)|([0-9a-f:]+::))/[0-9]+),"
|
|
||||||
linode:
|
|
||||||
- url: https://geoip.linode.com/
|
|
||||||
regex: "(?P<prefix>(([0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+)|([0-9a-f:]+::))/[0-9]+),"
|
|
||||||
vultr:
|
|
||||||
- url: "https://geofeed.constant.com/?json"
|
|
||||||
jq-path: '.subnets[] | .ip_prefix'
|
|
||||||
cloudflare:
|
|
||||||
- url: https://www.cloudflare.com/ips-v4
|
|
||||||
regex: "(?P<prefix>[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+/[0-9]+)"
|
|
||||||
- url: https://www.cloudflare.com/ips-v6
|
|
||||||
regex: "(?P<prefix>[0-9a-f:]+::/[0-9]+)"
|
|
||||||
|
|
||||||
icloud-private-relay:
|
|
||||||
- url: https://mask-api.icloud.com/egress-ip-ranges.csv
|
|
||||||
regex: "(?P<prefix>(([0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+)|([0-9a-f:]+::))/[0-9]+),"
|
|
||||||
tunnelbroker-relay:
|
|
||||||
# HE Tunnelbroker
|
|
||||||
- url: https://tunnelbroker.net/export/google
|
|
||||||
regex: "(?P<prefix>([0-9a-f:]+::)/[0-9]+),"
|
|
||||||
|
|
||||||
|
|
||||||
googlebot:
|
|
||||||
- url: https://developers.google.com/static/search/apis/ipranges/googlebot.json
|
|
||||||
jq-path: '(.prefixes[] | select(has("ipv4Prefix")) | .ipv4Prefix), (.prefixes[] | select(has("ipv6Prefix")) | .ipv6Prefix)'
|
|
||||||
bingbot:
|
|
||||||
- url: https://www.bing.com/toolbox/bingbot.json
|
|
||||||
jq-path: '(.prefixes[] | select(has("ipv4Prefix")) | .ipv4Prefix), (.prefixes[] | select(has("ipv6Prefix")) | .ipv6Prefix)'
|
|
||||||
qwantbot:
|
|
||||||
- url: https://help.qwant.com/wp-content/uploads/sites/2/2025/01/qwantbot.json
|
|
||||||
jq-path: '(.prefixes[] | select(has("ipv4Prefix")) | .ipv4Prefix), (.prefixes[] | select(has("ipv6Prefix")) | .ipv6Prefix)'
|
|
||||||
duckduckbot:
|
|
||||||
- url: https://duckduckgo.com/duckduckgo-help-pages/results/duckduckbot
|
|
||||||
regex: "<li><div>(?P<prefix>[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+)</div></li>"
|
|
||||||
yandexbot:
|
|
||||||
# todo: detected as bot
|
|
||||||
# - url: https://yandex.com/ips
|
|
||||||
# regex: "<span>(?P<prefix>(([0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+)|([0-9a-f:]+::))/[0-9]+)[ \\\\t]*</span><br/>"
|
|
||||||
- prefixes:
|
|
||||||
- "5.45.192.0/18"
|
|
||||||
- "5.255.192.0/18"
|
|
||||||
- "37.9.64.0/18"
|
|
||||||
- "37.140.128.0/18"
|
|
||||||
- "77.88.0.0/18"
|
|
||||||
- "84.252.160.0/19"
|
|
||||||
- "87.250.224.0/19"
|
|
||||||
- "90.156.176.0/22"
|
|
||||||
- "93.158.128.0/18"
|
|
||||||
- "95.108.128.0/17"
|
|
||||||
- "141.8.128.0/18"
|
|
||||||
- "178.154.128.0/18"
|
|
||||||
- "185.32.187.0/24"
|
|
||||||
- "2a02:6b8::/29"
|
|
||||||
kagibot:
|
|
||||||
- url: https://kagi.com/bot
|
|
||||||
regex: "\\n(?P<prefix>[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+) "
|
|
||||||
|
|
||||||
|
|
||||||
challenges:
|
challenges:
|
||||||
js-pow-sha256:
|
# Challenges will get included from snippets
|
||||||
# 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: ""
|
|
||||||
|
|
||||||
|
|
||||||
# Verifies the existence of a cookie and confirms it against some backend request, passing the entire client cookie contents
|
# Verifies the existence of a cookie and confirms it against some backend request, passing the entire client cookie contents
|
||||||
http-cookie-check:
|
http-cookie-check:
|
||||||
mode: http
|
runtime: http
|
||||||
url: http://forgejo:3000/user/stopwatches
|
|
||||||
# url: http://forgejo:3000/repo/search
|
|
||||||
# url: http://forgejo:3000/notifications/new
|
|
||||||
parameters:
|
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-method: GET
|
||||||
http-cookie: i_like_gitea
|
http-cookie: i_like_gitea
|
||||||
http-code: 200
|
http-code: 200
|
||||||
|
verify-probability: 0.1
|
||||||
|
|
||||||
conditions:
|
conditions:
|
||||||
# Conditions will get replaced on rules AST when found as ($condition-name)
|
# 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:
|
# Conditions will get included from snippets
|
||||||
- 'userAgent.startsWith("Mozilla/") || userAgent.startsWith("Opera/")'
|
|
||||||
|
|
||||||
is-well-known-asset:
|
|
||||||
- 'path == "/robots.txt"'
|
|
||||||
- 'path.startsWith("/.well-known")'
|
|
||||||
|
|
||||||
is-static-asset:
|
is-static-asset:
|
||||||
- 'path == "/favicon.ico"'
|
|
||||||
- 'path == "/apple-touch-icon.png"'
|
- 'path == "/apple-touch-icon.png"'
|
||||||
- 'path == "/apple-touch-icon-precomposed.png"'
|
- 'path == "/apple-touch-icon-precomposed.png"'
|
||||||
- 'path.startsWith("/assets/")'
|
- 'path.startsWith("/assets/")'
|
||||||
@@ -193,42 +45,12 @@ conditions:
|
|||||||
- 'path.startsWith("/user/avatar/")'
|
- 'path.startsWith("/user/avatar/")'
|
||||||
- 'path.startsWith("/attachments/")'
|
- '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:
|
is-git-path:
|
||||||
- 'path.matches("^/[^/]+/[^/]+/(git-upload-pack|git-receive-pack|HEAD|info/refs|info/lfs|objects)")'
|
- '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:
|
is-suspicious-crawler:
|
||||||
# TLS Fingerprint for specific agent without ALPN
|
# TLS Fingerprint for specific agent without ALPN
|
||||||
- '(userAgent.startsWith("Mozilla/") || userAgent.startsWith("Opera/")) && fpJA4.matches("^t[0-9a-z]+00_")'
|
- '(userAgent.startsWith("Mozilla/") || userAgent.startsWith("Opera/")) && ("ja4" in fp && fp.ja4.matches("^t[0-9a-z]+00_"))'
|
||||||
# Old engines
|
# Old engines
|
||||||
- 'userAgent.contains("Presto/") || userAgent.contains("Trident/")'
|
- 'userAgent.contains("Presto/") || userAgent.contains("Trident/")'
|
||||||
# Old IE browsers
|
# Old IE browsers
|
||||||
@@ -243,6 +65,7 @@ conditions:
|
|||||||
- 'userAgent.startsWith("Opera/")'
|
- 'userAgent.startsWith("Opera/")'
|
||||||
#- 'userAgent.matches("Gecko/(201[0-9]|200[0-9])")'
|
#- 'userAgent.matches("Gecko/(201[0-9]|200[0-9])")'
|
||||||
- 'userAgent.matches("^Mozilla/[1-4]")'
|
- 'userAgent.matches("^Mozilla/[1-4]")'
|
||||||
|
|
||||||
is-heavy-resource:
|
is-heavy-resource:
|
||||||
- 'path.startsWith("/explore/")'
|
- 'path.startsWith("/explore/")'
|
||||||
- 'path.matches("^/[^/]+/[^/]+/src/commit/")'
|
- 'path.matches("^/[^/]+/[^/]+/src/commit/")'
|
||||||
@@ -271,8 +94,8 @@ rules:
|
|||||||
|
|
||||||
- name: undesired-networks
|
- name: undesired-networks
|
||||||
conditions:
|
conditions:
|
||||||
- 'inNetwork("huawei-cloud", remoteAddress) || inNetwork("alibaba-cloud", remoteAddress) || inNetwork("zenlayer-inc", remoteAddress)'
|
- 'remoteAddress.network("huawei-cloud") || remoteAddress.network("alibaba-cloud") || remoteAddress.network("zenlayer-inc")'
|
||||||
action: poison
|
action: drop
|
||||||
|
|
||||||
- name: undesired-crawlers
|
- name: undesired-crawlers
|
||||||
conditions:
|
conditions:
|
||||||
@@ -296,7 +119,7 @@ rules:
|
|||||||
- 'userAgent.contains("Amazonbot") || userAgent.contains("Google-Extended") || userAgent.contains("PanguBot") || userAgent.contains("AI2Bot") || userAgent.contains("Diffbot") || userAgent.contains("cohere-training-data-crawler") || userAgent.contains("Applebot-Extended")'
|
- '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
|
# SEO / Ads and marketing
|
||||||
- 'userAgent.contains("BLEXBot")'
|
- 'userAgent.contains("BLEXBot")'
|
||||||
action: poison
|
action: drop
|
||||||
|
|
||||||
- name: unknown-crawlers
|
- name: unknown-crawlers
|
||||||
conditions:
|
conditions:
|
||||||
@@ -305,22 +128,22 @@ rules:
|
|||||||
action: deny
|
action: deny
|
||||||
|
|
||||||
# check a sequence of challenges for non logged in
|
# check a sequence of challenges for non logged in
|
||||||
- name: suspicious-crawlers/0
|
- name: suspicious-crawlers
|
||||||
conditions: ['($is-suspicious-crawler)']
|
conditions: ['($is-suspicious-crawler)']
|
||||||
action: check
|
action: none
|
||||||
challenges: [js-pow-sha256, http-cookie-check]
|
children:
|
||||||
- name: suspicious-crawlers/1
|
- name: 0
|
||||||
conditions: ['($is-suspicious-crawler)']
|
action: check
|
||||||
action: check
|
settings:
|
||||||
challenges: [self-preload-link]
|
challenges: [js-pow-sha256, http-cookie-check]
|
||||||
- name: suspicious-crawlers/2
|
- name: 1
|
||||||
conditions: ['($is-suspicious-crawler)']
|
action: check
|
||||||
action: check
|
settings:
|
||||||
challenges: [self-header-refresh]
|
challenges: [preload-link, resource-load]
|
||||||
- name: suspicious-crawlers/3
|
- name: 2
|
||||||
conditions: ['($is-suspicious-crawler)']
|
action: check
|
||||||
action: check
|
settings:
|
||||||
challenges: [self-resource-load]
|
challenges: [header-refresh]
|
||||||
|
|
||||||
- name: always-pow-challenge
|
- name: always-pow-challenge
|
||||||
conditions:
|
conditions:
|
||||||
@@ -337,7 +160,8 @@ rules:
|
|||||||
# Match archive downloads from browsers and not tools
|
# Match archive downloads from browsers and not tools
|
||||||
- 'path.matches("^/[^/]+/[^/]+/archive/.*\\.(bundle|zip|tar\\.gz)") && ($is-generic-browser)'
|
- 'path.matches("^/[^/]+/[^/]+/archive/.*\\.(bundle|zip|tar\\.gz)") && ($is-generic-browser)'
|
||||||
action: challenge
|
action: challenge
|
||||||
challenges: [ js-pow-sha256 ]
|
settings:
|
||||||
|
challenges: [ js-pow-sha256 ]
|
||||||
|
|
||||||
- name: allow-git-operations
|
- name: allow-git-operations
|
||||||
conditions:
|
conditions:
|
||||||
@@ -393,32 +217,37 @@ rules:
|
|||||||
# generic /*/*/ match gave too many options for scrapers to trigger random endpoints
|
# generic /*/*/ match gave too many options for scrapers to trigger random endpoints
|
||||||
# this is a negative match of endpoints that Forgejo holds as reserved as users or orgs
|
# 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
|
# see https://codeberg.org/forgejo/forgejo/src/branch/forgejo/models/user/user.go#L582
|
||||||
- '(path.matches("^/[^/]+/[^/]+/?$") || path.matches("^/[^/]+/[^/]+/(issues|pulls)/[0-9]+$") || (path.matches("^/[^/]+/?$") && size(query) == 0)) && !path.matches("(?i)^/(api|metrics|v2|assets|attachments|avatar|avatars|repo-avatars|captcha|login|org|repo|user|admin|devtest|explore|issues|pulls|milestones|notifications|ghost)(/|$)")'
|
- '(path.matches("^/[^/]+/[^/]+/?$") || path.matches("^/[^/]+/[^/]+/badges/") || path.matches("^/[^/]+/[^/]+/(issues|pulls)/[0-9]+$") || (path.matches("^/[^/]+/?$") && size(query) == 0)) && !path.matches("(?i)^/(api|metrics|v2|assets|attachments|avatar|avatars|repo-avatars|captcha|login|org|repo|user|admin|devtest|explore|issues|pulls|milestones|notifications|ghost)(/|$)")'
|
||||||
action: pass
|
action: pass
|
||||||
|
|
||||||
- name: desired-crawlers
|
- name: desired-crawlers
|
||||||
conditions:
|
conditions:
|
||||||
- 'userAgent.contains("+https://kagi.com/bot") && inNetwork("kagibot", remoteAddress)'
|
- *is-bot-googlebot
|
||||||
- '(userAgent.contains("+http://www.google.com/bot.html") || userAgent.contains("Google-PageRenderer") || userAgent.contains("Google-InspectionTool") || userAgent.contains("Googlebot")) && inNetwork("googlebot", remoteAddress)'
|
- *is-bot-bingbot
|
||||||
- 'userAgent.contains("+http://www.bing.com/bingbot.htm") && inNetwork("bingbot", remoteAddress)'
|
- *is-bot-duckduckbot
|
||||||
- 'userAgent.contains("+http://duckduckgo.com/duckduckbot.html") && inNetwork("duckduckbot", remoteAddress)'
|
- *is-bot-kagibot
|
||||||
- 'userAgent.contains("+https://help.qwant.com/bot/") && inNetwork("qwantbot", remoteAddress)'
|
- *is-bot-qwantbot
|
||||||
- 'userAgent.contains("+http://yandex.com/bots") && inNetwork("yandexbot", remoteAddress)'
|
- *is-bot-yandexbot
|
||||||
action: pass
|
action: pass
|
||||||
|
|
||||||
# check a sequence of challenges
|
# check a sequence of challenges
|
||||||
- name: heavy-operations/0
|
- name: heavy-operations
|
||||||
action: check
|
|
||||||
challenges: [self-preload-link, self-header-refresh, js-pow-sha256, http-cookie-check]
|
|
||||||
conditions: ['($is-heavy-resource)']
|
|
||||||
- name: heavy-operations/1
|
|
||||||
action: check
|
|
||||||
challenges: [self-resource-load, js-pow-sha256, http-cookie-check]
|
|
||||||
conditions: ['($is-heavy-resource)']
|
conditions: ['($is-heavy-resource)']
|
||||||
|
action: none
|
||||||
|
children:
|
||||||
|
- name: 0
|
||||||
|
action: check
|
||||||
|
settings:
|
||||||
|
challenges: [preload-link, header-refresh, js-pow-sha256, http-cookie-check]
|
||||||
|
- name: 1
|
||||||
|
action: check
|
||||||
|
settings:
|
||||||
|
challenges: [ resource-load, js-pow-sha256, http-cookie-check ]
|
||||||
|
|
||||||
- name: standard-bots
|
- name: standard-bots
|
||||||
action: check
|
action: check
|
||||||
challenges: [self-meta-refresh, self-resource-load]
|
settings:
|
||||||
|
challenges: [meta-refresh, resource-load]
|
||||||
conditions:
|
conditions:
|
||||||
- '($is-generic-robot-ua)'
|
- '($is-generic-robot-ua)'
|
||||||
|
|
||||||
@@ -433,15 +262,20 @@ rules:
|
|||||||
action: pass
|
action: pass
|
||||||
|
|
||||||
# check DNSBL and serve harder challenges
|
# check DNSBL and serve harder challenges
|
||||||
|
# todo: make this specific to score
|
||||||
- name: undesired-dnsbl
|
- name: undesired-dnsbl
|
||||||
conditions:
|
|
||||||
- 'inDNSBL(remoteAddress)'
|
|
||||||
action: check
|
action: check
|
||||||
challenges: [js-pow-sha256, http-cookie-check]
|
settings:
|
||||||
|
challenges: [dnsbl]
|
||||||
|
# if DNSBL fails, check additional challenges
|
||||||
|
fail: check
|
||||||
|
fail-settings:
|
||||||
|
challenges: [js-pow-sha256, http-cookie-check]
|
||||||
|
|
||||||
- name: suspicious-fetchers
|
- name: suspicious-fetchers
|
||||||
action: check
|
action: check
|
||||||
challenges: [js-pow-sha256]
|
settings:
|
||||||
|
challenges: [js-pow-sha256]
|
||||||
conditions:
|
conditions:
|
||||||
- 'userAgent.contains("facebookexternalhit/") || userAgent.contains("facebookcatalog/")'
|
- 'userAgent.contains("facebookexternalhit/") || userAgent.contains("facebookcatalog/")'
|
||||||
|
|
||||||
@@ -453,19 +287,22 @@ rules:
|
|||||||
|
|
||||||
- name: plaintext-browser
|
- name: plaintext-browser
|
||||||
action: challenge
|
action: challenge
|
||||||
challenges: [http-cookie-check, self-meta-refresh, self-cookie]
|
settings:
|
||||||
|
challenges: [http-cookie-check, meta-refresh, cookie]
|
||||||
conditions:
|
conditions:
|
||||||
- 'userAgent.startsWith("Lynx/")'
|
- 'userAgent.startsWith("Lynx/")'
|
||||||
|
|
||||||
- name: standard-tools
|
- name: standard-tools
|
||||||
action: challenge
|
action: challenge
|
||||||
challenges: [self-cookie]
|
settings:
|
||||||
|
challenges: [cookie]
|
||||||
conditions:
|
conditions:
|
||||||
- '($is-tool-ua)'
|
- '($is-tool-ua)'
|
||||||
- '!($is-generic-browser)'
|
- '!($is-generic-browser)'
|
||||||
|
|
||||||
- name: standard-browser
|
- name: standard-browser
|
||||||
action: challenge
|
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:
|
conditions:
|
||||||
- '($is-generic-browser)'
|
- '($is-generic-browser)'
|
||||||
|
|||||||
@@ -1,153 +1,27 @@
|
|||||||
# Example cmdline (forward requests from upstream to port :8080)
|
# 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
|
# Define networks to be used later below
|
||||||
networks:
|
networks:
|
||||||
|
# Networks will get included from snippets
|
||||||
googlebot:
|
|
||||||
- url: https://developers.google.com/static/search/apis/ipranges/googlebot.json
|
|
||||||
jq-path: '(.prefixes[] | select(has("ipv4Prefix")) | .ipv4Prefix), (.prefixes[] | select(has("ipv6Prefix")) | .ipv6Prefix)'
|
|
||||||
bingbot:
|
|
||||||
- url: https://www.bing.com/toolbox/bingbot.json
|
|
||||||
jq-path: '(.prefixes[] | select(has("ipv4Prefix")) | .ipv4Prefix), (.prefixes[] | select(has("ipv6Prefix")) | .ipv6Prefix)'
|
|
||||||
qwantbot:
|
|
||||||
- url: https://help.qwant.com/wp-content/uploads/sites/2/2025/01/qwantbot.json
|
|
||||||
jq-path: '(.prefixes[] | select(has("ipv4Prefix")) | .ipv4Prefix), (.prefixes[] | select(has("ipv6Prefix")) | .ipv6Prefix)'
|
|
||||||
duckduckbot:
|
|
||||||
- url: https://duckduckgo.com/duckduckgo-help-pages/results/duckduckbot
|
|
||||||
regex: "<li><div>(?P<prefix>[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+)</div></li>"
|
|
||||||
yandexbot:
|
|
||||||
# todo: detected as bot
|
|
||||||
# - url: https://yandex.com/ips
|
|
||||||
# regex: "<span>(?P<prefix>(([0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+)|([0-9a-f:]+::))/[0-9]+)[ \\\\t]*</span><br/>"
|
|
||||||
- prefixes:
|
|
||||||
- "5.45.192.0/18"
|
|
||||||
- "5.255.192.0/18"
|
|
||||||
- "37.9.64.0/18"
|
|
||||||
- "37.140.128.0/18"
|
|
||||||
- "77.88.0.0/18"
|
|
||||||
- "84.252.160.0/19"
|
|
||||||
- "87.250.224.0/19"
|
|
||||||
- "90.156.176.0/22"
|
|
||||||
- "93.158.128.0/18"
|
|
||||||
- "95.108.128.0/17"
|
|
||||||
- "141.8.128.0/18"
|
|
||||||
- "178.154.128.0/18"
|
|
||||||
- "185.32.187.0/24"
|
|
||||||
- "2a02:6b8::/29"
|
|
||||||
kagibot:
|
|
||||||
- url: https://kagi.com/bot
|
|
||||||
regex: "\\n(?P<prefix>[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+) "
|
|
||||||
|
|
||||||
|
|
||||||
challenges:
|
challenges:
|
||||||
js-pow-sha256:
|
# Challenges will get included from snippets
|
||||||
# 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: ""
|
|
||||||
|
|
||||||
conditions:
|
conditions:
|
||||||
# Conditions will get replaced on rules AST when found as ($condition-name)
|
# 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:
|
# Conditions will get included from snippets
|
||||||
- 'userAgent.startsWith("Mozilla/") || userAgent.startsWith("Opera/")'
|
|
||||||
|
|
||||||
is-well-known-asset:
|
|
||||||
- 'path == "/robots.txt"'
|
|
||||||
- 'path.startsWith("/.well-known")'
|
|
||||||
|
|
||||||
is-static-asset:
|
is-static-asset:
|
||||||
- 'path == "/favicon.ico"'
|
|
||||||
- 'path == "/apple-touch-icon.png"'
|
- 'path == "/apple-touch-icon.png"'
|
||||||
- 'path == "/apple-touch-icon-precomposed.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)$")'
|
- '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:
|
is-suspicious-crawler:
|
||||||
- 'userAgent.contains("Presto/") || userAgent.contains("Trident/")'
|
- 'userAgent.contains("Presto/") || userAgent.contains("Trident/")'
|
||||||
# Old IE browsers
|
# Old IE browsers
|
||||||
@@ -198,7 +72,7 @@ rules:
|
|||||||
- 'userAgent.contains("Amazonbot") || userAgent.contains("Google-Extended") || userAgent.contains("PanguBot") || userAgent.contains("AI2Bot") || userAgent.contains("Diffbot") || userAgent.contains("cohere-training-data-crawler") || userAgent.contains("Applebot-Extended")'
|
- '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
|
# SEO / Ads and marketing
|
||||||
- 'userAgent.contains("BLEXBot")'
|
- 'userAgent.contains("BLEXBot")'
|
||||||
action: deny
|
action: drop
|
||||||
|
|
||||||
- name: unknown-crawlers
|
- name: unknown-crawlers
|
||||||
conditions:
|
conditions:
|
||||||
@@ -207,31 +81,31 @@ rules:
|
|||||||
action: deny
|
action: deny
|
||||||
|
|
||||||
# check a sequence of challenges
|
# check a sequence of challenges
|
||||||
- name: suspicious-crawlers/0
|
- name: suspicious-crawlers
|
||||||
conditions: ['($is-suspicious-crawler)']
|
conditions: ['($is-suspicious-crawler)']
|
||||||
action: check
|
action: none
|
||||||
challenges: [js-pow-sha256]
|
children:
|
||||||
- name: suspicious-crawlers/1
|
- name: 0
|
||||||
conditions: ['($is-suspicious-crawler)']
|
action: check
|
||||||
action: check
|
settings:
|
||||||
challenges: [self-preload-link]
|
challenges: [js-pow-sha256]
|
||||||
- name: suspicious-crawlers/2
|
- name: 1
|
||||||
conditions: ['($is-suspicious-crawler)']
|
action: check
|
||||||
action: check
|
settings:
|
||||||
challenges: [self-header-refresh]
|
challenges: [preload-link, resource-load]
|
||||||
- name: suspicious-crawlers/3
|
- name: 2
|
||||||
conditions: ['($is-suspicious-crawler)']
|
action: check
|
||||||
action: check
|
settings:
|
||||||
challenges: [self-resource-load]
|
challenges: [header-refresh]
|
||||||
|
|
||||||
- name: desired-crawlers
|
- name: desired-crawlers
|
||||||
conditions:
|
conditions:
|
||||||
- 'userAgent.contains("+https://kagi.com/bot") && inNetwork("kagibot", remoteAddress)'
|
- *is-bot-googlebot
|
||||||
- '(userAgent.contains("+http://www.google.com/bot.html") || userAgent.contains("Google-InspectionTool") || userAgent.contains("Googlebot")) && inNetwork("googlebot", remoteAddress)'
|
- *is-bot-bingbot
|
||||||
- 'userAgent.contains("+http://www.bing.com/bingbot.htm") && inNetwork("bingbot", remoteAddress)'
|
- *is-bot-duckduckbot
|
||||||
- 'userAgent.contains("+http://duckduckgo.com/duckduckbot.html") && inNetwork("duckduckbot", remoteAddress)'
|
- *is-bot-kagibot
|
||||||
- 'userAgent.contains("+https://help.qwant.com/bot/") && inNetwork("qwantbot", remoteAddress)'
|
- *is-bot-qwantbot
|
||||||
- 'userAgent.contains("+http://yandex.com/bots") && inNetwork("yandexbot", remoteAddress)'
|
- *is-bot-yandexbot
|
||||||
action: pass
|
action: pass
|
||||||
|
|
||||||
- name: homesite
|
- name: homesite
|
||||||
@@ -240,15 +114,20 @@ rules:
|
|||||||
action: pass
|
action: pass
|
||||||
|
|
||||||
# check DNSBL and serve harder challenges
|
# check DNSBL and serve harder challenges
|
||||||
|
# todo: make this specific to score
|
||||||
- name: undesired-dnsbl
|
- name: undesired-dnsbl
|
||||||
conditions:
|
|
||||||
- 'inDNSBL(remoteAddress)'
|
|
||||||
action: check
|
action: check
|
||||||
challenges: [js-pow-sha256]
|
settings:
|
||||||
|
challenges: [dnsbl]
|
||||||
|
# if DNSBL fails, check additional challenges
|
||||||
|
fail: check
|
||||||
|
fail-settings:
|
||||||
|
challenges: [js-pow-sha256]
|
||||||
|
|
||||||
- name: suspicious-fetchers
|
- name: suspicious-fetchers
|
||||||
action: check
|
action: check
|
||||||
challenges: [js-pow-sha256]
|
settings:
|
||||||
|
challenges: [js-pow-sha256]
|
||||||
conditions:
|
conditions:
|
||||||
- 'userAgent.contains("facebookexternalhit/") || userAgent.contains("facebookcatalog/")'
|
- 'userAgent.contains("facebookexternalhit/") || userAgent.contains("facebookcatalog/")'
|
||||||
|
|
||||||
@@ -260,13 +139,15 @@ rules:
|
|||||||
|
|
||||||
- name: plaintext-browser
|
- name: plaintext-browser
|
||||||
action: challenge
|
action: challenge
|
||||||
challenges: [self-meta-refresh, self-cookie]
|
settings:
|
||||||
|
challenges: [meta-refresh, cookie]
|
||||||
conditions:
|
conditions:
|
||||||
- 'userAgent.startsWith("Lynx/")'
|
- 'userAgent.startsWith("Lynx/")'
|
||||||
|
|
||||||
- name: standard-tools
|
- name: standard-tools
|
||||||
action: challenge
|
action: challenge
|
||||||
challenges: [self-cookie]
|
settings:
|
||||||
|
challenges: [cookie]
|
||||||
conditions:
|
conditions:
|
||||||
- '($is-generic-robot-ua)'
|
- '($is-generic-robot-ua)'
|
||||||
- '($is-tool-ua)'
|
- '($is-tool-ua)'
|
||||||
@@ -274,6 +155,7 @@ rules:
|
|||||||
|
|
||||||
- name: standard-browser
|
- name: standard-browser
|
||||||
action: challenge
|
action: challenge
|
||||||
challenges: [self-preload-link, self-meta-refresh, self-resource-load, js-pow-sha256]
|
settings:
|
||||||
|
challenges: [preload-link, meta-refresh, resource-load, js-pow-sha256]
|
||||||
conditions:
|
conditions:
|
||||||
- '($is-generic-browser)'
|
- '($is-generic-browser)'
|
||||||
|
|||||||
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")'
|
||||||
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
|
||||||
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: 3s
|
||||||
|
|
||||||
|
# Challenges with a redirect via Refresh header (non-JS, requires HTTP parsing and logic)
|
||||||
|
header-refresh:
|
||||||
|
runtime: "refresh"
|
||||||
|
parameters:
|
||||||
|
refresh-via: "header"
|
||||||
|
|
||||||
|
# Challenges with a redirect via Refresh meta (non-JS, requires HTML parsing and logic)
|
||||||
|
meta-refresh:
|
||||||
|
runtime: "refresh"
|
||||||
|
parameters:
|
||||||
|
refresh-via: "meta"
|
||||||
|
|
||||||
|
# Challenges with loading a random CSS or image document (non-JS, requires HTML parsing and logic)
|
||||||
|
resource-load:
|
||||||
|
runtime: "resource-load"
|
||||||
45
examples/snippets/conditions-generic.yml
Normal file
45
examples/snippets/conditions-generic.yml
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
conditions:
|
||||||
|
is-well-known-asset:
|
||||||
|
- 'path == "/robots.txt"'
|
||||||
|
- 'path == "/favicon.ico"'
|
||||||
|
- 'path.startsWith("/.well-known")'
|
||||||
|
|
||||||
|
is-git-ua:
|
||||||
|
- 'userAgent.startsWith("git/") || userAgent.contains("libgit")'
|
||||||
|
- 'userAgent.startsWith("go-git")'
|
||||||
|
- 'userAgent.startsWith("JGit/") || userAgent.startsWith("JGit-")'
|
||||||
|
# Golang proxy and initial fetch
|
||||||
|
- 'userAgent.startsWith("GoModuleMirror/")'
|
||||||
|
- 'userAgent.startsWith("Go-http-client/") && "go-get" in query && query["go-get"] == "1"'
|
||||||
|
- '"Git-Protocol" in headers && headers["Git-Protocol"] == "version=2"'
|
||||||
|
|
||||||
|
is-generic-browser:
|
||||||
|
- 'userAgent.startsWith("Mozilla/") || userAgent.startsWith("Opera/")'
|
||||||
|
|
||||||
|
is-generic-robot-ua:
|
||||||
|
- 'userAgent.matches("compatible[;)]") && !userAgent.contains("Trident/")'
|
||||||
|
- 'userAgent.matches("\\+https?://")'
|
||||||
|
- 'userAgent.contains("@")'
|
||||||
|
- 'userAgent.matches("[bB]ot/[0-9]")'
|
||||||
|
|
||||||
|
is-tool-ua:
|
||||||
|
- 'userAgent.startsWith("python-requests/")'
|
||||||
|
- 'userAgent.startsWith("Python-urllib/")'
|
||||||
|
- 'userAgent.startsWith("python-httpx/")'
|
||||||
|
- 'userAgent.contains("aoihttp/")'
|
||||||
|
- 'userAgent.startsWith("http.rb/")'
|
||||||
|
- 'userAgent.startsWith("curl/")'
|
||||||
|
- 'userAgent.startsWith("Wget/")'
|
||||||
|
- 'userAgent.startsWith("libcurl/")'
|
||||||
|
- 'userAgent.startsWith("okhttp/")'
|
||||||
|
- 'userAgent.startsWith("Java/")'
|
||||||
|
- 'userAgent.startsWith("Apache-HttpClient//")'
|
||||||
|
- 'userAgent.startsWith("Go-http-client/")'
|
||||||
|
- 'userAgent.startsWith("node-fetch/")'
|
||||||
|
- 'userAgent.startsWith("reqwest/")'
|
||||||
|
|
||||||
|
# Checks to detect a headless chromium via headers only
|
||||||
|
is-headless-chromium:
|
||||||
|
- 'userAgent.contains("HeadlessChrome") || userAgent.contains("HeadlessChromium")'
|
||||||
|
- '"Sec-Ch-Ua" in headers && (headers["Sec-Ch-Ua"].contains("HeadlessChrome") || headers["Sec-Ch-Ua"].contains("HeadlessChromium"))'
|
||||||
|
#- '(userAgent.contains("Chrome/") || userAgent.contains("Chromium/")) && (!("Accept-Language" in headers) || !("Accept-Encoding" in headers))'
|
||||||
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]+),"
|
||||||
11
go.mod
11
go.mod
@@ -6,16 +6,15 @@ toolchain go1.24.2
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
codeberg.org/meta/gzipped/v2 v2.0.0-20231111234332-aa70c3194756
|
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/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/itchyny/gojq v0.12.17
|
||||||
github.com/klauspost/compress v1.18.0
|
|
||||||
github.com/pires/go-proxyproto v0.8.0
|
github.com/pires/go-proxyproto v0.8.0
|
||||||
github.com/tetratelabs/wazero v1.9.0
|
github.com/tetratelabs/wazero v1.9.0
|
||||||
github.com/yl2chen/cidranger v1.0.2
|
github.com/yl2chen/cidranger v1.0.2
|
||||||
golang.org/x/crypto v0.37.0
|
golang.org/x/crypto v0.37.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -27,7 +26,7 @@ require (
|
|||||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
|
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
|
||||||
golang.org/x/net v0.39.0 // indirect
|
golang.org/x/net v0.39.0 // indirect
|
||||||
golang.org/x/text v0.24.0 // indirect
|
golang.org/x/text v0.24.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20250414145226-207652e42e2e // indirect
|
google.golang.org/genproto/googleapis/api v0.0.0-20250422160041-2d3770c4ea7f // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250414145226-207652e42e2e // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20250422160041-2d3770c4ea7f // indirect
|
||||||
google.golang.org/protobuf v1.36.6 // indirect
|
google.golang.org/protobuf v1.36.6 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
17
go.sum
17
go.sum
@@ -2,8 +2,8 @@ cel.dev/expr v0.23.1 h1:K4KOtPCJQjVggkARsjG9RWXP6O4R73aHeJMa/dmCQQg=
|
|||||||
cel.dev/expr v0.23.1/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw=
|
cel.dev/expr v0.23.1/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw=
|
||||||
codeberg.org/meta/gzipped/v2 v2.0.0-20231111234332-aa70c3194756 h1:bDqEUEYt4UJy8mfLCZeJuXx+xNJvdqTbkE4Ci11NQYU=
|
codeberg.org/meta/gzipped/v2 v2.0.0-20231111234332-aa70c3194756 h1:bDqEUEYt4UJy8mfLCZeJuXx+xNJvdqTbkE4Ci11NQYU=
|
||||||
codeberg.org/meta/gzipped/v2 v2.0.0-20231111234332-aa70c3194756/go.mod h1:aJ/ghJW7viYfwZ6OizDst+uJgbb6r/Hvoqhmi1OPTTw=
|
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/alphadose/haxmap v1.4.1 h1:VtD6VCxUkjNIfJk/aWdYFfOzrRddDFjmvmRmILg7x8Q=
|
||||||
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
|
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 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ=
|
||||||
github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw=
|
github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
@@ -11,8 +11,12 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
|||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/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 h1:cYSYxd3pw5zd2FSXk2vGdn9igQU2PS8MuxrCOCl0FdY=
|
||||||
github.com/go-jose/go-jose/v4 v4.1.0/go.mod h1:GG/vqmYm3Von2nYiB2vGTXzdoNKE5tix5tuc6iAd+sw=
|
github.com/go-jose/go-jose/v4 v4.1.0/go.mod h1:GG/vqmYm3Von2nYiB2vGTXzdoNKE5tix5tuc6iAd+sw=
|
||||||
|
github.com/goccy/go-yaml v1.17.1 h1:LI34wktB2xEE3ONG/2Ar54+/HJVBriAGJ55PHls4YuY=
|
||||||
|
github.com/goccy/go-yaml v1.17.1/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||||
github.com/google/cel-go v0.24.1 h1:jsBCtxG8mM5wiUJDSGUqU0K7Mtr3w7Eyv00rw4DiZxI=
|
github.com/google/cel-go v0.24.1 h1:jsBCtxG8mM5wiUJDSGUqU0K7Mtr3w7Eyv00rw4DiZxI=
|
||||||
github.com/google/cel-go v0.24.1/go.mod h1:Hdf9TqOaTNSFQA1ybQaRqATVoK7m/zcf7IMhGXP5zI8=
|
github.com/google/cel-go v0.24.1/go.mod h1:Hdf9TqOaTNSFQA1ybQaRqATVoK7m/zcf7IMhGXP5zI8=
|
||||||
|
github.com/google/cel-go v0.25.0 h1:jsFw9Fhn+3y2kBbltZR4VEz5xKkcIFRPDnuEzAGv5GY=
|
||||||
|
github.com/google/cel-go v0.25.0/go.mod h1:hjEb6r5SuOSlhCHmFoLzu8HGCERvIsDAbxDAyNU/MmI=
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
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=
|
github.com/itchyny/gojq v0.12.17 h1:8av8eGduDb5+rvEdaOO+zQUjA04MS0m3Ps8HiD+fceg=
|
||||||
@@ -21,8 +25,6 @@ github.com/itchyny/timefmt-go v0.1.6 h1:ia3s54iciXDdzWzwaVKXZPbiXzxxnv1SPGFfM/my
|
|||||||
github.com/itchyny/timefmt-go v0.1.6/go.mod h1:RRDZYC5s9ErkjQvTvvU7keJjxUYzIISJGxm9/mAERQg=
|
github.com/itchyny/timefmt-go v0.1.6/go.mod h1:RRDZYC5s9ErkjQvTvvU7keJjxUYzIISJGxm9/mAERQg=
|
||||||
github.com/kevinpollet/nego v0.0.0-20211010160919-a65cd48cee43 h1:Pdirg1gwhEcGjMLyuSxGn9664p+P8J9SrfMgpFwrDyg=
|
github.com/kevinpollet/nego v0.0.0-20211010160919-a65cd48cee43 h1:Pdirg1gwhEcGjMLyuSxGn9664p+P8J9SrfMgpFwrDyg=
|
||||||
github.com/kevinpollet/nego v0.0.0-20211010160919-a65cd48cee43/go.mod h1:ahLMuLCUyDdXqtqGyuwGev7/PGtO7r7ocvdwDuEN/3E=
|
github.com/kevinpollet/nego v0.0.0-20211010160919-a65cd48cee43/go.mod h1:ahLMuLCUyDdXqtqGyuwGev7/PGtO7r7ocvdwDuEN/3E=
|
||||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
|
||||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
|
||||||
github.com/pires/go-proxyproto v0.8.0 h1:5unRmEAPbHXHuLjDg01CxJWf91cw3lKHc/0xzKpXEe0=
|
github.com/pires/go-proxyproto v0.8.0 h1:5unRmEAPbHXHuLjDg01CxJWf91cw3lKHc/0xzKpXEe0=
|
||||||
github.com/pires/go-proxyproto v0.8.0/go.mod h1:iknsfgnH8EkjrMeMyvfKByp9TiBZCKZM0jx2xmKqnVY=
|
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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
@@ -40,8 +42,6 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf
|
|||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/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 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I=
|
||||||
github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM=
|
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 h1:lbOWZVCG1tCRX4u24kuM1Tb4nHqWkDxwLdoS+SevawU=
|
||||||
github.com/yl2chen/cidranger v1.0.2/go.mod h1:9U1yz7WPYDwf0vpNWFaeRh0bjwz5RVgRy/9UEQfHl0g=
|
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 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
|
||||||
@@ -54,11 +54,14 @@ golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
|
|||||||
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
|
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20250414145226-207652e42e2e h1:UdXH7Kzbj+Vzastr5nVfccbmFsmYNygVLSPk1pEfDoY=
|
google.golang.org/genproto/googleapis/api v0.0.0-20250414145226-207652e42e2e h1:UdXH7Kzbj+Vzastr5nVfccbmFsmYNygVLSPk1pEfDoY=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20250414145226-207652e42e2e/go.mod h1:085qFyf2+XaZlRdCgKNCIZ3afY2p4HHZdoIRpId8F4A=
|
google.golang.org/genproto/googleapis/api v0.0.0-20250414145226-207652e42e2e/go.mod h1:085qFyf2+XaZlRdCgKNCIZ3afY2p4HHZdoIRpId8F4A=
|
||||||
|
google.golang.org/genproto/googleapis/api v0.0.0-20250422160041-2d3770c4ea7f h1:tjZsroqekhC63+WMqzmWyW5Twj/ZfR5HAlpd5YQ1Vs0=
|
||||||
|
google.golang.org/genproto/googleapis/api v0.0.0-20250422160041-2d3770c4ea7f/go.mod h1:Cd8IzgPo5Akum2c9R6FsXNaZbH3Jpa2gpHlW89FqlyQ=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250414145226-207652e42e2e h1:ztQaXfzEXTmCBvbtWYRhJxW+0iJcz2qXfd38/e9l7bA=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20250414145226-207652e42e2e h1:ztQaXfzEXTmCBvbtWYRhJxW+0iJcz2qXfd38/e9l7bA=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250414145226-207652e42e2e/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20250414145226-207652e42e2e/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20250422160041-2d3770c4ea7f h1:N/PrbTw4kdkqNRzVfWPrBekzLuarFREcbFOiOLkXon4=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20250422160041-2d3770c4ea7f/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
|
||||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
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/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.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=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
80
lib/action/backend.go
Normal file
80
lib/action/backend.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
|
||||||
|
}
|
||||||
36
lib/action/block.go
Normal file
36
lib/action/block.go
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
package action
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"git.gammaspectra.live/git/go-away/lib/challenge"
|
||||||
|
"git.gammaspectra.live/git/go-away/lib/policy"
|
||||||
|
"github.com/goccy/go-yaml/ast"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
Register[policy.RuleActionBLOCK] = func(state challenge.StateInterface, ruleName, ruleHash string, settings ast.Node) (Handler, error) {
|
||||||
|
return Block{
|
||||||
|
Code: http.StatusForbidden,
|
||||||
|
RuleHash: ruleHash,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Block struct {
|
||||||
|
Code int
|
||||||
|
RuleHash string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a Block) Handle(logger *slog.Logger, w http.ResponseWriter, r *http.Request, done func() (backend http.Handler)) (next bool, err error) {
|
||||||
|
logger.Info("request blocked")
|
||||||
|
data := challenge.RequestDataFromContext(r.Context())
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/plain")
|
||||||
|
w.Header().Set("Connection", "close")
|
||||||
|
w.WriteHeader(a.Code)
|
||||||
|
_, _ = w.Write([]byte(fmt.Errorf("access blocked: blocked by administrative rule %s/%s", data.Id.String(), a.RuleHash).Error()))
|
||||||
|
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
178
lib/action/challenge.go
Normal file
178
lib/action/challenge.go
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
package action
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"git.gammaspectra.live/git/go-away/lib/challenge"
|
||||||
|
"git.gammaspectra.live/git/go-away/lib/policy"
|
||||||
|
"github.com/goccy/go-yaml"
|
||||||
|
"github.com/goccy/go-yaml/ast"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
i := func(state challenge.StateInterface, ruleName, ruleHash string, settings ast.Node, cont bool) (Handler, error) {
|
||||||
|
params := ChallengeDefaultSettings
|
||||||
|
|
||||||
|
if settings != nil {
|
||||||
|
ymlData, err := settings.MarshalYAML()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
err = yaml.Unmarshal(ymlData, ¶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)
|
||||||
|
}
|
||||||
|
|
||||||
|
passHandler, ok := Register[policy.RuleAction(strings.ToUpper(params.PassAction))]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("unknown pass action %s", params.PassAction)
|
||||||
|
}
|
||||||
|
|
||||||
|
passActionHandler, err := passHandler(state, ruleName, ruleHash, params.PassSettings)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
failHandler, ok := Register[policy.RuleAction(strings.ToUpper(params.FailAction))]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("unknown pass action %s", params.FailAction)
|
||||||
|
}
|
||||||
|
|
||||||
|
failActionHandler, err := failHandler(state, ruleName, ruleHash, params.FailSettings)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return Challenge{
|
||||||
|
RuleHash: ruleHash,
|
||||||
|
Code: params.Code,
|
||||||
|
Continue: cont,
|
||||||
|
Challenges: regs,
|
||||||
|
|
||||||
|
PassAction: passActionHandler,
|
||||||
|
FailAction: failActionHandler,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
Register[policy.RuleActionCHALLENGE] = func(state challenge.StateInterface, ruleName, ruleHash string, settings ast.Node) (Handler, error) {
|
||||||
|
return i(state, ruleName, ruleHash, settings, false)
|
||||||
|
}
|
||||||
|
Register[policy.RuleActionCHECK] = func(state challenge.StateInterface, ruleName, ruleHash string, settings ast.Node) (Handler, error) {
|
||||||
|
return i(state, ruleName, ruleHash, settings, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var ChallengeDefaultSettings = ChallengeSettings{
|
||||||
|
PassAction: string(policy.RuleActionPASS),
|
||||||
|
FailAction: string(policy.RuleActionDENY),
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChallengeSettings struct {
|
||||||
|
Code int `yaml:"http-code"`
|
||||||
|
Challenges []string `yaml:"challenges"`
|
||||||
|
|
||||||
|
PassAction string `yaml:"pass"`
|
||||||
|
PassSettings ast.Node `yaml:"pass-settings"`
|
||||||
|
|
||||||
|
// FailAction Executed in case no challenges match or
|
||||||
|
FailAction string `yaml:"fail"`
|
||||||
|
FailSettings ast.Node `yaml:"fail-settings"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Challenge struct {
|
||||||
|
RuleHash string
|
||||||
|
Code int
|
||||||
|
Continue bool
|
||||||
|
Challenges []*challenge.Registration
|
||||||
|
|
||||||
|
PassAction Handler
|
||||||
|
FailAction Handler
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a Challenge) Handle(logger *slog.Logger, w http.ResponseWriter, r *http.Request, done func() (backend http.Handler)) (next bool, err error) {
|
||||||
|
data := challenge.RequestDataFromContext(r.Context())
|
||||||
|
for _, reg := range a.Challenges {
|
||||||
|
if data.HasValidChallenge(reg.Id()) {
|
||||||
|
if a.Continue {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// we passed!
|
||||||
|
return a.PassAction.Handle(logger.With("challenge", reg.Name), w, r, done)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// none matched, issue challenges in sequential priority
|
||||||
|
for _, reg := range a.Challenges {
|
||||||
|
result := data.ChallengeVerify[reg.Id()]
|
||||||
|
state := data.ChallengeState[reg.Id()]
|
||||||
|
if result.Ok() || result == challenge.VerifyResultSkip || state == challenge.VerifyStatePass {
|
||||||
|
// skip already ok'd challenges for some reason (TODO: why)
|
||||||
|
// also skip skipped challenges due to preconditions
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
expiry := data.Expiration(reg.Duration)
|
||||||
|
key := challenge.GetChallengeKeyForRequest(data.State, reg, expiry, r)
|
||||||
|
data.State.ChallengeIssued(r, reg, r.URL.String(), logger)
|
||||||
|
result = reg.IssueChallenge(w, r, key, expiry)
|
||||||
|
data.ChallengeVerify[reg.Id()] = result
|
||||||
|
data.ChallengeState[reg.Id()] = challenge.VerifyStatePass
|
||||||
|
switch result {
|
||||||
|
case challenge.VerifyResultOK:
|
||||||
|
data.State.ChallengePassed(r, reg, r.URL.String(), logger)
|
||||||
|
if a.Continue {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return a.PassAction.Handle(logger.With("challenge", reg.Name), w, r, done)
|
||||||
|
case challenge.VerifyResultNotOK:
|
||||||
|
// we have had the challenge checked, but it's not ok!
|
||||||
|
// safe to continue
|
||||||
|
continue
|
||||||
|
case challenge.VerifyResultFail:
|
||||||
|
err := fmt.Errorf("challenge %s failed on issuance", reg.Name)
|
||||||
|
data.State.ChallengeFailed(r, reg, err, r.URL.String(), logger)
|
||||||
|
|
||||||
|
if reg.Class == challenge.ClassTransparent {
|
||||||
|
// allow continuing transparent challenges
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
return a.FailAction.Handle(logger, w, r, done)
|
||||||
|
case challenge.VerifyResultNone:
|
||||||
|
// challenge was issued
|
||||||
|
if reg.Class == challenge.ClassTransparent {
|
||||||
|
// allow continuing transparent challenges
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// we cannot continue after issuance
|
||||||
|
return false, nil
|
||||||
|
|
||||||
|
case challenge.VerifyResultSkip:
|
||||||
|
// continue onto next one due to precondition
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// nothing matched, execute default action
|
||||||
|
return a.FailAction.Handle(logger, w, r, done)
|
||||||
|
}
|
||||||
47
lib/action/code.go
Normal file
47
lib/action/code.go
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
package action
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"git.gammaspectra.live/git/go-away/lib/challenge"
|
||||||
|
"git.gammaspectra.live/git/go-away/lib/policy"
|
||||||
|
"github.com/goccy/go-yaml"
|
||||||
|
"github.com/goccy/go-yaml/ast"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
Register[policy.RuleActionCODE] = func(state challenge.StateInterface, ruleName, ruleHash string, settings ast.Node) (Handler, error) {
|
||||||
|
params := CodeDefaultSettings
|
||||||
|
|
||||||
|
if settings != nil {
|
||||||
|
ymlData, err := settings.MarshalYAML()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
err = yaml.Unmarshal(ymlData, ¶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) {
|
||||||
|
w.WriteHeader(int(a))
|
||||||
|
return false, 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
|
||||||
|
}
|
||||||
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
|
package lib
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/sha256"
|
_ "git.gammaspectra.live/git/go-away/lib/challenge/cookie"
|
||||||
"encoding/binary"
|
_ "git.gammaspectra.live/git/go-away/lib/challenge/dnsbl"
|
||||||
"encoding/hex"
|
_ "git.gammaspectra.live/git/go-away/lib/challenge/http"
|
||||||
"errors"
|
_ "git.gammaspectra.live/git/go-away/lib/challenge/preload-link"
|
||||||
"github.com/go-jose/go-jose/v4/jwt"
|
_ "git.gammaspectra.live/git/go-away/lib/challenge/refresh"
|
||||||
"net"
|
_ "git.gammaspectra.live/git/go-away/lib/challenge/resource-load"
|
||||||
"net/http"
|
_ "git.gammaspectra.live/git/go-away/lib/challenge/wasm"
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type ChallengeInformation struct {
|
// This file loads embedded challenge runtimes so their init() is called
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
38
lib/challenge/cookie/cookie.go
Normal file
38
lib/challenge/cookie/cookie.go
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
package cookie
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.gammaspectra.live/git/go-away/lib/challenge"
|
||||||
|
"git.gammaspectra.live/git/go-away/utils"
|
||||||
|
"github.com/goccy/go-yaml/ast"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
challenge.Runtimes[Key] = FillRegistration
|
||||||
|
}
|
||||||
|
|
||||||
|
const Key = "cookie"
|
||||||
|
|
||||||
|
func FillRegistration(state challenge.StateInterface, reg *challenge.Registration, parameters ast.Node) error {
|
||||||
|
reg.Class = challenge.ClassBlocking
|
||||||
|
|
||||||
|
reg.IssueChallenge = func(w http.ResponseWriter, r *http.Request, key challenge.Key, expiry time.Time) challenge.VerifyResult {
|
||||||
|
token, err := reg.IssueChallengeToken(state.PrivateKey(), key, nil, expiry, true)
|
||||||
|
if err != nil {
|
||||||
|
return challenge.VerifyResultFail
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.SetCookie(challenge.RequestDataFromContext(r.Context()).CookiePrefix+reg.Name, token, expiry, w, r)
|
||||||
|
|
||||||
|
uri, err := challenge.RedirectUrl(r, reg)
|
||||||
|
if err != nil {
|
||||||
|
return challenge.VerifyResultFail
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Redirect(w, r, uri.String(), http.StatusTemporaryRedirect)
|
||||||
|
return challenge.VerifyResultNone
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
190
lib/challenge/data.go
Normal file
190
lib/challenge/data.go
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
package challenge
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"git.gammaspectra.live/git/go-away/lib/condition"
|
||||||
|
"git.gammaspectra.live/git/go-away/utils"
|
||||||
|
"github.com/google/cel-go/cel"
|
||||||
|
"github.com/google/cel-go/common/types"
|
||||||
|
"github.com/google/cel-go/common/types/traits"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/textproto"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type requestDataContextKey struct {
|
||||||
|
}
|
||||||
|
|
||||||
|
func RequestDataFromContext(ctx context.Context) *RequestData {
|
||||||
|
return ctx.Value(requestDataContextKey{}).(*RequestData)
|
||||||
|
}
|
||||||
|
|
||||||
|
type RequestId [16]byte
|
||||||
|
|
||||||
|
func (id RequestId) String() string {
|
||||||
|
return hex.EncodeToString(id[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
type RequestData struct {
|
||||||
|
Id RequestId
|
||||||
|
Time time.Time
|
||||||
|
ChallengeVerify map[Id]VerifyResult
|
||||||
|
ChallengeState map[Id]VerifyState
|
||||||
|
RemoteAddress net.IP
|
||||||
|
State StateInterface
|
||||||
|
CookiePrefix string
|
||||||
|
|
||||||
|
r *http.Request
|
||||||
|
|
||||||
|
fp map[string]string
|
||||||
|
header traits.Mapper
|
||||||
|
query traits.Mapper
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreateRequestData(r *http.Request, state StateInterface) (*http.Request, *RequestData) {
|
||||||
|
|
||||||
|
var data RequestData
|
||||||
|
// generate random id, todo: is this fast?
|
||||||
|
_, _ = rand.Read(data.Id[:])
|
||||||
|
data.RemoteAddress = utils.GetRequestAddress(r, state.Settings().ClientIpHeader)
|
||||||
|
data.ChallengeVerify = make(map[Id]VerifyResult, len(state.GetChallenges()))
|
||||||
|
data.ChallengeState = make(map[Id]VerifyState, len(state.GetChallenges()))
|
||||||
|
data.Time = time.Now().UTC()
|
||||||
|
data.State = state
|
||||||
|
data.r = r
|
||||||
|
|
||||||
|
data.fp = make(map[string]string, 2)
|
||||||
|
|
||||||
|
if fp := utils.GetTLSFingerprint(r); fp != nil {
|
||||||
|
if ja3nPtr := fp.JA3N(); ja3nPtr != nil {
|
||||||
|
ja3n := ja3nPtr.String()
|
||||||
|
data.fp["ja3n"] = ja3n
|
||||||
|
r.Header.Set("X-TLS-Fingerprint-JA3N", ja3n)
|
||||||
|
}
|
||||||
|
if ja4Ptr := fp.JA4(); ja4Ptr != nil {
|
||||||
|
ja4 := ja4Ptr.String()
|
||||||
|
data.fp["ja4"] = ja4
|
||||||
|
r.Header.Set("X-TLS-Fingerprint-JA4", ja4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data.query = condition.NewValuesMap(r.URL.Query())
|
||||||
|
data.header = condition.NewMIMEMap(textproto.MIMEHeader(r.Header))
|
||||||
|
|
||||||
|
sum := sha256.New()
|
||||||
|
sum.Write([]byte(r.Host))
|
||||||
|
sum.Write([]byte{0})
|
||||||
|
sum.Write(state.PublicKey())
|
||||||
|
sum.Write([]byte{0})
|
||||||
|
data.CookiePrefix = utils.CookiePrefix + hex.EncodeToString(sum.Sum(nil)[:4]) + "-"
|
||||||
|
|
||||||
|
r = r.WithContext(context.WithValue(r.Context(), requestDataContextKey{}, &data))
|
||||||
|
|
||||||
|
return r, &data
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *RequestData) ResolveName(name string) (any, bool) {
|
||||||
|
switch name {
|
||||||
|
case "host":
|
||||||
|
return d.r.Host, true
|
||||||
|
case "method":
|
||||||
|
return d.r.Method, true
|
||||||
|
case "remoteAddress":
|
||||||
|
return d.RemoteAddress, true
|
||||||
|
case "userAgent":
|
||||||
|
return d.r.UserAgent(), true
|
||||||
|
case "path":
|
||||||
|
return d.r.URL.Path, true
|
||||||
|
case "query":
|
||||||
|
return d.query, true
|
||||||
|
case "headers":
|
||||||
|
return d.header, true
|
||||||
|
case "fp":
|
||||||
|
return d.fp, true
|
||||||
|
default:
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *RequestData) Parent() cel.Activation {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *RequestData) EvaluateChallenges(w http.ResponseWriter, r *http.Request) {
|
||||||
|
q := r.URL.Query()
|
||||||
|
var issuedChallenge string
|
||||||
|
if q.Has(QueryArgChallenge) {
|
||||||
|
issuedChallenge = q.Get(QueryArgChallenge)
|
||||||
|
}
|
||||||
|
for _, reg := range d.State.GetChallenges() {
|
||||||
|
key := GetChallengeKeyForRequest(d.State, reg, d.Expiration(reg.Duration), r)
|
||||||
|
verifyResult, verifyState, err := reg.VerifyChallengeToken(d.State.PublicKey(), key, r)
|
||||||
|
if err != nil && !errors.Is(err, http.ErrNoCookie) {
|
||||||
|
// clear invalid cookie
|
||||||
|
utils.ClearCookie(d.CookiePrefix+reg.Name, w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// prevent evaluating the challenge if not solved
|
||||||
|
if !verifyResult.Ok() && reg.Condition != nil {
|
||||||
|
out, _, err := reg.Condition.Eval(d)
|
||||||
|
// verify eligibility
|
||||||
|
if err != nil {
|
||||||
|
d.State.Logger(r).Error(err.Error(), "challenge", reg.Name)
|
||||||
|
} else if out != nil && out.Type() == types.BoolType {
|
||||||
|
if out.Equal(types.True) != types.True {
|
||||||
|
// skip challenge match due to precondition!
|
||||||
|
verifyResult = VerifyResultSkip
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !verifyResult.Ok() && issuedChallenge == reg.Name {
|
||||||
|
// we issued the challenge, must skip to prevent loops
|
||||||
|
verifyResult = VerifyResultSkip
|
||||||
|
}
|
||||||
|
d.ChallengeVerify[reg.Id()] = verifyResult
|
||||||
|
d.ChallengeState[reg.Id()] = verifyState
|
||||||
|
}
|
||||||
|
|
||||||
|
if d.State.Settings().BackendIpHeader != "" {
|
||||||
|
if d.State.Settings().ClientIpHeader != "" {
|
||||||
|
r.Header.Del(d.State.Settings().ClientIpHeader)
|
||||||
|
}
|
||||||
|
r.Header.Set(d.State.Settings().BackendIpHeader, d.RemoteAddress.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// send these to client so we consistently get the headers
|
||||||
|
//w.Header().Set("Accept-CH", "Sec-CH-UA, Sec-CH-UA-Platform")
|
||||||
|
//w.Header().Set("Critical-CH", "Sec-CH-UA, Sec-CH-UA-Platform")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *RequestData) Expiration(duration time.Duration) time.Time {
|
||||||
|
return d.Time.Add(duration).Round(duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *RequestData) HasValidChallenge(id Id) bool {
|
||||||
|
return d.ChallengeVerify[id].Ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *RequestData) Headers(headers http.Header) {
|
||||||
|
headers.Set("X-Away-Id", d.Id.String())
|
||||||
|
|
||||||
|
for id, result := range d.ChallengeVerify {
|
||||||
|
if result.Ok() {
|
||||||
|
c, ok := d.State.GetChallenge(id)
|
||||||
|
if !ok {
|
||||||
|
panic("challenge not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
headers.Set(fmt.Sprintf("X-Away-Challenge-%s-Result", c.Name), result.String())
|
||||||
|
headers.Set(fmt.Sprintf("X-Away-Challenge-%s-State", c.Name), d.ChallengeState[id].String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
149
lib/challenge/dnsbl/dnsbl.go
Normal file
149
lib/challenge/dnsbl/dnsbl.go
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"git.gammaspectra.live/git/go-away/lib/challenge"
|
||||||
|
"git.gammaspectra.live/git/go-away/utils"
|
||||||
|
"github.com/goccy/go-yaml"
|
||||||
|
"github.com/goccy/go-yaml/ast"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
challenge.Runtimes[Key] = FillRegistration
|
||||||
|
}
|
||||||
|
|
||||||
|
const Key = "dnsbl"
|
||||||
|
|
||||||
|
type Parameters struct {
|
||||||
|
VerifyProbability float64 `yaml:"verify-probability"`
|
||||||
|
Host string `yaml:"dnsbl-host"`
|
||||||
|
Timeout time.Duration `yaml:"dnsbl-timeout"`
|
||||||
|
Decay time.Duration `yaml:"dnsbl-decay"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var DefaultParameters = Parameters{
|
||||||
|
VerifyProbability: 0.10,
|
||||||
|
Timeout: time.Second * 1,
|
||||||
|
Decay: time.Hour * 1,
|
||||||
|
Host: "dnsbl.dronebl.org",
|
||||||
|
}
|
||||||
|
|
||||||
|
func lookup(ctx context.Context, decay, timeout time.Duration, dnsbl *utils.DNSBL, decayMap *utils.DecayMap[[net.IPv6len]byte, utils.DNSBLResponse], ip net.IP) (utils.DNSBLResponse, error) {
|
||||||
|
var key [net.IPv6len]byte
|
||||||
|
copy(key[:], ip.To16())
|
||||||
|
|
||||||
|
result, ok := decayMap.Get(key)
|
||||||
|
if ok {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
|
defer cancel()
|
||||||
|
result, err := dnsbl.Lookup(ctx, ip)
|
||||||
|
if err != nil {
|
||||||
|
|
||||||
|
}
|
||||||
|
decayMap.Set(key, result, decay)
|
||||||
|
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
|
||||||
|
type closer chan struct{}
|
||||||
|
|
||||||
|
func (c closer) Close() error {
|
||||||
|
select {
|
||||||
|
case <-c:
|
||||||
|
default:
|
||||||
|
close(c)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func FillRegistration(state challenge.StateInterface, reg *challenge.Registration, parameters ast.Node) error {
|
||||||
|
params := DefaultParameters
|
||||||
|
|
||||||
|
if parameters != nil {
|
||||||
|
ymlData, err := parameters.MarshalYAML()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
err = yaml.Unmarshal(ymlData, ¶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)
|
||||||
|
if err != nil {
|
||||||
|
data.State.Logger(r).Debug("dnsbl lookup failed", "address", data.RemoteAddress.String(), "result", result, "err", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return challenge.VerifyResultFail
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.Bad() {
|
||||||
|
token, err := reg.IssueChallengeToken(state.PrivateKey(), key, nil, expiry, false)
|
||||||
|
if err != nil {
|
||||||
|
return challenge.VerifyResultFail
|
||||||
|
}
|
||||||
|
utils.SetCookie(data.CookiePrefix+reg.Name, token, expiry, w, r)
|
||||||
|
return challenge.VerifyResultNotOK
|
||||||
|
} else {
|
||||||
|
token, err := reg.IssueChallengeToken(state.PrivateKey(), key, nil, expiry, true)
|
||||||
|
if err != nil {
|
||||||
|
return challenge.VerifyResultFail
|
||||||
|
}
|
||||||
|
utils.SetCookie(data.CookiePrefix+reg.Name, token, expiry, w, r)
|
||||||
|
return challenge.VerifyResultOK
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
166
lib/challenge/helper.go
Normal file
166
lib/challenge/helper.go
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
package challenge
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"git.gammaspectra.live/git/go-away/utils"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewKeyVerifier() (verify VerifyFunc, issue func(key Key) string) {
|
||||||
|
return func(key Key, token []byte, r *http.Request) (VerifyResult, error) {
|
||||||
|
expectedKey, err := hex.DecodeString(string(token))
|
||||||
|
if err != nil {
|
||||||
|
return VerifyResultFail, err
|
||||||
|
}
|
||||||
|
if subtle.ConstantTimeCompare(key[:], expectedKey) == 1 {
|
||||||
|
return VerifyResultOK, nil
|
||||||
|
}
|
||||||
|
return VerifyResultFail, errors.New("invalid token")
|
||||||
|
}, func(key Key) string {
|
||||||
|
return hex.EncodeToString(key[:])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
QueryArgPrefix = "__goaway"
|
||||||
|
QueryArgReferer = QueryArgPrefix + "_referer"
|
||||||
|
QueryArgRedirect = QueryArgPrefix + "_redirect"
|
||||||
|
QueryArgRequestId = QueryArgPrefix + "_id"
|
||||||
|
QueryArgChallenge = QueryArgPrefix + "_challenge"
|
||||||
|
QueryArgToken = QueryArgPrefix + "_token"
|
||||||
|
)
|
||||||
|
|
||||||
|
const MakeChallengeUrlSuffix = "/make-challenge"
|
||||||
|
const VerifyChallengeUrlSuffix = "/verify-challenge"
|
||||||
|
|
||||||
|
func GetVerifyInformation(r *http.Request, reg *Registration) (requestId RequestId, redirect, token string, err error) {
|
||||||
|
|
||||||
|
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())
|
||||||
|
values.Set(QueryArgReferer, r.Referer())
|
||||||
|
values.Set(QueryArgChallenge, reg.Name)
|
||||||
|
uri.RawQuery = values.Encode()
|
||||||
|
|
||||||
|
return uri, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func VerifyHandlerChallengeResponseFunc(state StateInterface, data *RequestData, w http.ResponseWriter, r *http.Request, verifyResult VerifyResult, err error, redirect string) {
|
||||||
|
if err != nil {
|
||||||
|
state.ErrorPage(w, r, http.StatusBadRequest, err, redirect)
|
||||||
|
return
|
||||||
|
} else if !verifyResult.Ok() {
|
||||||
|
state.ErrorPage(w, r, http.StatusForbidden, fmt.Errorf("access denied: failed challenge"), redirect)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, redirect, http.StatusTemporaryRedirect)
|
||||||
|
}
|
||||||
|
|
||||||
|
func VerifyHandlerFunc(state StateInterface, reg *Registration, verify VerifyFunc, responseFunc func(state StateInterface, data *RequestData, w http.ResponseWriter, r *http.Request, verifyResult VerifyResult, err error, redirect string)) http.HandlerFunc {
|
||||||
|
if verify == nil {
|
||||||
|
verify = reg.Verify
|
||||||
|
}
|
||||||
|
if responseFunc == nil {
|
||||||
|
responseFunc = VerifyHandlerChallengeResponseFunc
|
||||||
|
}
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
data := RequestDataFromContext(r.Context())
|
||||||
|
requestId, redirect, token, err := GetVerifyInformation(r, reg)
|
||||||
|
if err != nil {
|
||||||
|
state.ChallengeFailed(r, reg, err, "", nil)
|
||||||
|
responseFunc(state, data, w, r, VerifyResultFail, fmt.Errorf("internal error: %w", err), "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data.Id = requestId
|
||||||
|
|
||||||
|
err = func() (err error) {
|
||||||
|
expiration := data.Expiration(reg.Duration)
|
||||||
|
key := GetChallengeKeyForRequest(state, reg, expiration, r)
|
||||||
|
|
||||||
|
verifyResult, err := verify(key, []byte(token), r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
} else if !verifyResult.Ok() {
|
||||||
|
utils.ClearCookie(data.CookiePrefix+reg.Name, w, r)
|
||||||
|
state.ChallengeFailed(r, reg, nil, redirect, nil)
|
||||||
|
responseFunc(state, data, w, r, verifyResult, nil, redirect)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
challengeToken, err := reg.IssueChallengeToken(state.PrivateKey(), key, []byte(token), expiration, true)
|
||||||
|
if err != nil {
|
||||||
|
utils.ClearCookie(data.CookiePrefix+reg.Name, w, r)
|
||||||
|
} else {
|
||||||
|
utils.SetCookie(data.CookiePrefix+reg.Name, challengeToken, expiration, w, r)
|
||||||
|
}
|
||||||
|
data.ChallengeVerify[reg.id] = verifyResult
|
||||||
|
state.ChallengePassed(r, reg, redirect, nil)
|
||||||
|
|
||||||
|
responseFunc(state, data, w, r, verifyResult, nil, redirect)
|
||||||
|
return nil
|
||||||
|
}()
|
||||||
|
if err != nil {
|
||||||
|
utils.ClearCookie(data.CookiePrefix+reg.Name, w, r)
|
||||||
|
state.ChallengeFailed(r, reg, err, redirect, nil)
|
||||||
|
responseFunc(state, data, w, r, VerifyResultFail, fmt.Errorf("access denied: error in challenge %s: %w", reg.Name, err), redirect)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
160
lib/challenge/http/http.go
Normal file
160
lib/challenge/http/http.go
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/subtle"
|
||||||
|
"errors"
|
||||||
|
"git.gammaspectra.live/git/go-away/lib/challenge"
|
||||||
|
"git.gammaspectra.live/git/go-away/utils"
|
||||||
|
"github.com/goccy/go-yaml"
|
||||||
|
"github.com/goccy/go-yaml/ast"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"slices"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
challenge.Runtimes[Key] = FillRegistration
|
||||||
|
}
|
||||||
|
|
||||||
|
const Key = "http"
|
||||||
|
|
||||||
|
type Parameters struct {
|
||||||
|
VerifyProbability float64 `yaml:"verify-probability"`
|
||||||
|
|
||||||
|
HttpMethod string `yaml:"http-method"`
|
||||||
|
HttpCode int `yaml:"http-code"`
|
||||||
|
HttpCookie string `yaml:"http-cookie"`
|
||||||
|
Url string `yaml:"http-url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var DefaultParameters = Parameters{
|
||||||
|
VerifyProbability: 0.20,
|
||||||
|
HttpMethod: http.MethodGet,
|
||||||
|
HttpCode: http.StatusOK,
|
||||||
|
}
|
||||||
|
|
||||||
|
func FillRegistration(state challenge.StateInterface, reg *challenge.Registration, parameters ast.Node) error {
|
||||||
|
params := DefaultParameters
|
||||||
|
|
||||||
|
if parameters != nil {
|
||||||
|
ymlData, err := parameters.MarshalYAML()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
err = yaml.Unmarshal(ymlData, ¶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 {
|
||||||
|
token, err := reg.IssueChallengeToken(state.PrivateKey(), key, sum, expiry, false)
|
||||||
|
if err != nil {
|
||||||
|
return challenge.VerifyResultFail
|
||||||
|
}
|
||||||
|
utils.SetCookie(data.CookiePrefix+reg.Name, token, expiry, w, r)
|
||||||
|
return challenge.VerifyResultNotOK
|
||||||
|
} else {
|
||||||
|
token, err := reg.IssueChallengeToken(state.PrivateKey(), key, sum, expiry, true)
|
||||||
|
if err != nil {
|
||||||
|
return challenge.VerifyResultFail
|
||||||
|
}
|
||||||
|
utils.SetCookie(data.CookiePrefix+reg.Name, token, expiry, w, r)
|
||||||
|
return challenge.VerifyResultOK
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
80
lib/challenge/key.go
Normal file
80
lib/challenge/key.go
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
package challenge
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/binary"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Key [KeySize]byte
|
||||||
|
|
||||||
|
const KeySize = sha256.Size
|
||||||
|
|
||||||
|
func (k *Key) Set(flags KeyFlags) {
|
||||||
|
(*k)[0] |= uint8(flags)
|
||||||
|
}
|
||||||
|
func (k *Key) Get(flags KeyFlags) KeyFlags {
|
||||||
|
return KeyFlags((*k)[0] & uint8(flags))
|
||||||
|
}
|
||||||
|
func (k *Key) Unset(flags KeyFlags) {
|
||||||
|
(*k)[0] = (*k)[0] & ^(uint8(flags))
|
||||||
|
}
|
||||||
|
|
||||||
|
type KeyFlags uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
KeyFlagIsIPv4 = KeyFlags(1 << iota)
|
||||||
|
)
|
||||||
|
|
||||||
|
func KeyFromString(s string) (Key, error) {
|
||||||
|
b, err := hex.DecodeString(s)
|
||||||
|
if err != nil {
|
||||||
|
return Key{}, err
|
||||||
|
}
|
||||||
|
if len(b) != KeySize {
|
||||||
|
return Key{}, errors.New("invalid challenge key")
|
||||||
|
}
|
||||||
|
return Key(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetChallengeKeyForRequest(state StateInterface, reg *Registration, until time.Time, r *http.Request) Key {
|
||||||
|
data := RequestDataFromContext(r.Context())
|
||||||
|
address := data.RemoteAddress
|
||||||
|
hasher := sha256.New()
|
||||||
|
hasher.Write([]byte("challenge\x00"))
|
||||||
|
hasher.Write([]byte(reg.Name))
|
||||||
|
hasher.Write([]byte{0})
|
||||||
|
hasher.Write(address.To16())
|
||||||
|
hasher.Write([]byte{0})
|
||||||
|
|
||||||
|
// specific headers
|
||||||
|
for _, k := range []string{
|
||||||
|
"Accept-Language",
|
||||||
|
// General browser information
|
||||||
|
"User-Agent",
|
||||||
|
// TODO: not sent in preload
|
||||||
|
//"Sec-Ch-Ua",
|
||||||
|
//"Sec-Ch-Ua-Platform",
|
||||||
|
} {
|
||||||
|
hasher.Write([]byte(r.Header.Get(k)))
|
||||||
|
hasher.Write([]byte{0})
|
||||||
|
}
|
||||||
|
hasher.Write([]byte{0})
|
||||||
|
_ = binary.Write(hasher, binary.LittleEndian, until.UTC().Unix())
|
||||||
|
hasher.Write([]byte{0})
|
||||||
|
hasher.Write(state.PublicKey())
|
||||||
|
hasher.Write([]byte{0})
|
||||||
|
|
||||||
|
sum := Key(hasher.Sum(nil))
|
||||||
|
|
||||||
|
sum[0] = 0
|
||||||
|
|
||||||
|
if address.To4() != nil {
|
||||||
|
// Is IPv4, mark
|
||||||
|
sum.Set(KeyFlagIsIPv4)
|
||||||
|
}
|
||||||
|
return Key(sum)
|
||||||
|
}
|
||||||
128
lib/challenge/preload-link/preload-link.go
Normal file
128
lib/challenge/preload-link/preload-link.go
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
package preload_link
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"git.gammaspectra.live/git/go-away/lib/challenge"
|
||||||
|
"git.gammaspectra.live/git/go-away/utils"
|
||||||
|
"github.com/goccy/go-yaml"
|
||||||
|
"github.com/goccy/go-yaml/ast"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
challenge.Runtimes[Key] = FillRegistration
|
||||||
|
}
|
||||||
|
|
||||||
|
const Key = "preload-link"
|
||||||
|
|
||||||
|
type Parameters struct {
|
||||||
|
Deadline time.Duration `yaml:"preload-early-hint-deadline"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var DefaultParameters = Parameters{
|
||||||
|
Deadline: time.Second * 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
func FillRegistration(state challenge.StateInterface, reg *challenge.Registration, parameters ast.Node) error {
|
||||||
|
params := DefaultParameters
|
||||||
|
|
||||||
|
if parameters != nil {
|
||||||
|
ymlData, err := parameters.MarshalYAML()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
err = yaml.Unmarshal(ymlData, ¶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)
|
||||||
|
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
|
||||||
|
}
|
||||||
64
lib/challenge/refresh/refresh.go
Normal file
64
lib/challenge/refresh/refresh.go
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
package refresh
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.gammaspectra.live/git/go-away/lib/challenge"
|
||||||
|
"github.com/goccy/go-yaml"
|
||||||
|
"github.com/goccy/go-yaml/ast"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
challenge.Runtimes["refresh"] = FillRegistration
|
||||||
|
}
|
||||||
|
|
||||||
|
type Parameters struct {
|
||||||
|
Mode string `yaml:"refresh-mode"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var DefaultParameters = Parameters{
|
||||||
|
Mode: "header",
|
||||||
|
}
|
||||||
|
|
||||||
|
func FillRegistration(state challenge.StateInterface, reg *challenge.Registration, parameters ast.Node) error {
|
||||||
|
params := DefaultParameters
|
||||||
|
|
||||||
|
if parameters != nil {
|
||||||
|
ymlData, err := parameters.MarshalYAML()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
err = yaml.Unmarshal(ymlData, ¶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 == "meta" {
|
||||||
|
state.ChallengePage(w, r, state.Settings().ChallengeResponseCode, reg, map[string]any{
|
||||||
|
"Meta": map[string]string{
|
||||||
|
"refresh": "0; url=" + uri.String(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// self redirect!
|
||||||
|
w.Header().Set("Refresh", "0; url="+uri.String())
|
||||||
|
|
||||||
|
state.ChallengePage(w, r, state.Settings().ChallengeResponseCode, reg, nil)
|
||||||
|
}
|
||||||
|
return challenge.VerifyResultNone
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
251
lib/challenge/register.go
Normal file
251
lib/challenge/register.go
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
package challenge
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/ed25519"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"git.gammaspectra.live/git/go-away/lib/condition"
|
||||||
|
"git.gammaspectra.live/git/go-away/lib/policy"
|
||||||
|
"github.com/go-jose/go-jose/v4"
|
||||||
|
"github.com/go-jose/go-jose/v4/jwt"
|
||||||
|
"github.com/goccy/go-yaml/ast"
|
||||||
|
"github.com/google/cel-go/cel"
|
||||||
|
"io"
|
||||||
|
"math/rand/v2"
|
||||||
|
"net/http"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Register map[Id]*Registration
|
||||||
|
|
||||||
|
func (r Register) Get(id Id) (*Registration, bool) {
|
||||||
|
c, ok := r[id]
|
||||||
|
return c, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r Register) GetByName(name string) (*Registration, Id, bool) {
|
||||||
|
for id, c := range r {
|
||||||
|
if c.Name == name {
|
||||||
|
return c, id, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
var idCounter Id
|
||||||
|
|
||||||
|
// DefaultDuration TODO: adjust
|
||||||
|
const DefaultDuration = time.Hour * 24 * 7
|
||||||
|
|
||||||
|
func (r Register) Create(state StateInterface, name string, pol policy.Challenge, replacer *strings.Replacer) (*Registration, Id, error) {
|
||||||
|
runtime, ok := Runtimes[pol.Runtime]
|
||||||
|
if !ok {
|
||||||
|
return nil, 0, fmt.Errorf("unknown challenge runtime %s", pol.Runtime)
|
||||||
|
}
|
||||||
|
|
||||||
|
reg := &Registration{
|
||||||
|
Name: name,
|
||||||
|
Path: path.Join(state.UrlPath(), "challenge", name),
|
||||||
|
Duration: pol.Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
if reg.Duration == 0 {
|
||||||
|
reg.Duration = DefaultDuration
|
||||||
|
}
|
||||||
|
|
||||||
|
// allow nesting
|
||||||
|
var conditions []string
|
||||||
|
for _, cond := range pol.Conditions {
|
||||||
|
if replacer != nil {
|
||||||
|
cond = replacer.Replace(cond)
|
||||||
|
}
|
||||||
|
conditions = append(conditions, cond)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(conditions) > 0 {
|
||||||
|
ast, err := condition.FromStrings(state.ProgramEnv(), condition.OperatorOr, conditions...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("error compiling conditions: %v", err)
|
||||||
|
}
|
||||||
|
reg.Condition, err = condition.Program(state.ProgramEnv(), ast)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("error compiling program: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, oldId, ok := r.GetByName(reg.Name); ok {
|
||||||
|
reg.id = oldId
|
||||||
|
} else {
|
||||||
|
idCounter++
|
||||||
|
reg.id = idCounter
|
||||||
|
}
|
||||||
|
|
||||||
|
err := runtime(state, reg, pol.Parameters)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("error filling registration: %v", err)
|
||||||
|
}
|
||||||
|
r[reg.id] = reg
|
||||||
|
return reg, reg.id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r Register) Add(c *Registration) Id {
|
||||||
|
if _, oldId, ok := r.GetByName(c.Name); ok {
|
||||||
|
c.id = oldId
|
||||||
|
r[oldId] = c
|
||||||
|
return oldId
|
||||||
|
} else {
|
||||||
|
idCounter++
|
||||||
|
c.id = idCounter
|
||||||
|
r[idCounter] = c
|
||||||
|
return idCounter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Registration struct {
|
||||||
|
// id The assigned internal identifier
|
||||||
|
id Id
|
||||||
|
|
||||||
|
// Name The unique name for this challenge
|
||||||
|
Name string
|
||||||
|
|
||||||
|
// Class whether this challenge is transparent or otherwise
|
||||||
|
Class Class
|
||||||
|
|
||||||
|
// Condition A CEL condition which is passed the same environment as general rules.
|
||||||
|
// If nil, always true
|
||||||
|
// If non-nil, must return true for this challenge to be allowed to be executed
|
||||||
|
Condition cel.Program
|
||||||
|
|
||||||
|
// Path The url path that this challenge is hosted under for the Handler to be called.
|
||||||
|
Path string
|
||||||
|
|
||||||
|
// Duration How long this challenge will be valid when passed
|
||||||
|
Duration time.Duration
|
||||||
|
|
||||||
|
// Handler An HTTP handler for all requests coming on the Path
|
||||||
|
// This handler will need to handle MakeChallengeUrlSuffix and VerifyChallengeUrlSuffix as well if needed
|
||||||
|
// Recommended to use http.ServeMux
|
||||||
|
Handler http.Handler
|
||||||
|
|
||||||
|
// Verify Verify an issued token
|
||||||
|
Verify VerifyFunc
|
||||||
|
VerifyProbability float64
|
||||||
|
|
||||||
|
// IssueChallenge Issues a challenge to a request.
|
||||||
|
// If Class is ClassTransparent and VerifyResult is !VerifyResult.Ok(), continue with other challenges
|
||||||
|
// TODO: have this return error as well
|
||||||
|
IssueChallenge func(w http.ResponseWriter, r *http.Request, key Key, expiry time.Time) VerifyResult
|
||||||
|
|
||||||
|
// Object used to handle state or similar
|
||||||
|
// Can be nil if no state is needed
|
||||||
|
// If non-nil must implement io.Closer even if there's nothing to do
|
||||||
|
Object io.Closer
|
||||||
|
}
|
||||||
|
|
||||||
|
type VerifyFunc func(key Key, token []byte, r *http.Request) (VerifyResult, error)
|
||||||
|
|
||||||
|
type Token struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Key []byte `json:"key"`
|
||||||
|
Result []byte `json:"result,omitempty"`
|
||||||
|
Ok bool `json:"ok"`
|
||||||
|
|
||||||
|
Expiry jwt.NumericDate `json:"exp,omitempty"`
|
||||||
|
NotBefore jwt.NumericDate `json:"nbf,omitempty"`
|
||||||
|
IssuedAt jwt.NumericDate `json:"iat,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (reg Registration) Id() Id {
|
||||||
|
return reg.id
|
||||||
|
}
|
||||||
|
|
||||||
|
func (reg Registration) IssueChallengeToken(privateKey ed25519.PrivateKey, key Key, result []byte, until time.Time, ok bool) (token string, err error) {
|
||||||
|
signer, err := jose.NewSigner(jose.SigningKey{
|
||||||
|
Algorithm: jose.EdDSA,
|
||||||
|
Key: privateKey,
|
||||||
|
}, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err = jwt.Signed(signer).Claims(Token{
|
||||||
|
Name: reg.Name,
|
||||||
|
Key: key[:],
|
||||||
|
Result: result,
|
||||||
|
Ok: ok,
|
||||||
|
Expiry: jwt.NumericDate(until.Unix()),
|
||||||
|
NotBefore: jwt.NumericDate(time.Now().UTC().AddDate(0, 0, -1).Unix()),
|
||||||
|
IssuedAt: jwt.NumericDate(time.Now().UTC().Unix()),
|
||||||
|
}).Serialize()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var ErrVerifyKeyMismatch = errors.New("verify: key mismatch")
|
||||||
|
var ErrVerifyVerifyMismatch = errors.New("verify: verification mismatch")
|
||||||
|
var ErrTokenExpired = errors.New("token: expired")
|
||||||
|
|
||||||
|
func (reg Registration) VerifyChallengeToken(publicKey ed25519.PublicKey, expectedKey Key, r *http.Request) (VerifyResult, VerifyState, error) {
|
||||||
|
cookie, err := r.Cookie(RequestDataFromContext(r.Context()).CookiePrefix + reg.Name)
|
||||||
|
if err != nil {
|
||||||
|
return VerifyResultNone, VerifyStateNone, err
|
||||||
|
}
|
||||||
|
if cookie == nil {
|
||||||
|
return VerifyResultNone, VerifyStateNone, http.ErrNoCookie
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := jwt.ParseSigned(cookie.Value, []jose.SignatureAlgorithm{jose.EdDSA})
|
||||||
|
if err != nil {
|
||||||
|
return VerifyResultFail, VerifyStateNone, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var i Token
|
||||||
|
err = token.Claims(publicKey, &i)
|
||||||
|
if err != nil {
|
||||||
|
return VerifyResultFail, VerifyStateNone, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if i.Name != reg.Name {
|
||||||
|
return VerifyResultFail, VerifyStateNone, errors.New("token invalid name")
|
||||||
|
}
|
||||||
|
if i.Expiry.Time().Compare(time.Now()) < 0 {
|
||||||
|
return VerifyResultFail, VerifyStateNone, ErrTokenExpired
|
||||||
|
}
|
||||||
|
if i.NotBefore.Time().Compare(time.Now()) > 0 {
|
||||||
|
return VerifyResultFail, VerifyStateNone, errors.New("token not valid yet")
|
||||||
|
}
|
||||||
|
|
||||||
|
if bytes.Compare(expectedKey[:], i.Key) != 0 {
|
||||||
|
return VerifyResultFail, VerifyStateNone, ErrVerifyKeyMismatch
|
||||||
|
}
|
||||||
|
|
||||||
|
if reg.Verify != nil {
|
||||||
|
if rand.Float64() < reg.VerifyProbability {
|
||||||
|
// random spot check
|
||||||
|
if ok, err := reg.Verify(expectedKey, i.Result, r); err != nil {
|
||||||
|
return VerifyResultFail, VerifyStateFull, err
|
||||||
|
} else if ok == VerifyResultNotOK {
|
||||||
|
return VerifyResultNotOK, VerifyStateFull, nil
|
||||||
|
} else if !ok.Ok() {
|
||||||
|
return ok, VerifyStateFull, ErrVerifyVerifyMismatch
|
||||||
|
} else {
|
||||||
|
return ok, VerifyStateFull, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !i.Ok {
|
||||||
|
return VerifyResultNotOK, VerifyStateBrief, nil
|
||||||
|
}
|
||||||
|
return VerifyResultOK, VerifyStateBrief, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type FillRegistration func(state StateInterface, reg *Registration, parameters ast.Node) error
|
||||||
|
|
||||||
|
var Runtimes = make(map[string]FillRegistration)
|
||||||
55
lib/challenge/resource-load/resource-load.go
Normal file
55
lib/challenge/resource-load/resource-load.go
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
package resource_load
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"git.gammaspectra.live/git/go-away/lib/challenge"
|
||||||
|
"github.com/goccy/go-yaml/ast"
|
||||||
|
"html/template"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
challenge.Runtimes["resource-load"] = FillRegistrationHeader
|
||||||
|
}
|
||||||
|
|
||||||
|
func FillRegistrationHeader(state challenge.StateInterface, reg *challenge.Registration, parameters ast.Node) error {
|
||||||
|
reg.Class = challenge.ClassBlocking
|
||||||
|
|
||||||
|
verifier, issuer := challenge.NewKeyVerifier()
|
||||||
|
reg.Verify = verifier
|
||||||
|
|
||||||
|
reg.IssueChallenge = func(w http.ResponseWriter, r *http.Request, key challenge.Key, expiry time.Time) challenge.VerifyResult {
|
||||||
|
uri, err := challenge.VerifyUrl(r, reg, issuer(key))
|
||||||
|
if err != nil {
|
||||||
|
return challenge.VerifyResultFail
|
||||||
|
}
|
||||||
|
|
||||||
|
// self redirect!
|
||||||
|
//TODO: adjust deadline
|
||||||
|
w.Header().Set("Refresh", "2; url="+r.URL.String())
|
||||||
|
|
||||||
|
state.ChallengePage(w, r, state.Settings().ChallengeResponseCode, reg, map[string]any{
|
||||||
|
"HeaderTags": []template.HTML{
|
||||||
|
template.HTML(fmt.Sprintf("<link href=\"%s\" rel=\"stylesheet\" crossorigin=\"use-credentials\">", uri.String())),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return challenge.VerifyResultNone
|
||||||
|
}
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
|
mux.HandleFunc("GET "+reg.Path+challenge.VerifyChallengeUrlSuffix, challenge.VerifyHandlerFunc(state, reg, nil, func(state challenge.StateInterface, data *challenge.RequestData, w http.ResponseWriter, r *http.Request, verifyResult challenge.VerifyResult, err error, redirect string) {
|
||||||
|
//TODO: add other types inside css that need to be loaded!
|
||||||
|
w.Header().Set("Content-Type", "text/css; charset=utf-8")
|
||||||
|
w.Header().Set("Content-Length", "0")
|
||||||
|
if !verifyResult.Ok() {
|
||||||
|
w.WriteHeader(http.StatusForbidden)
|
||||||
|
} else {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
reg.Handler = mux
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
41
lib/challenge/script.go
Normal file
41
lib/challenge/script.go
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
package challenge
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "embed"
|
||||||
|
"encoding/json"
|
||||||
|
"git.gammaspectra.live/git/go-away/utils"
|
||||||
|
"net/http"
|
||||||
|
"text/template"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed script.mjs
|
||||||
|
var scriptData []byte
|
||||||
|
|
||||||
|
var scriptTemplate = template.Must(template.New("script.mjs").Parse(string(scriptData)))
|
||||||
|
|
||||||
|
func ServeChallengeScript(w http.ResponseWriter, r *http.Request, reg *Registration, params any, script string) {
|
||||||
|
data := RequestDataFromContext(r.Context())
|
||||||
|
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||||
|
w.Header().Set("Content-Type", "text/javascript; charset=utf-8")
|
||||||
|
|
||||||
|
paramData, err := json.Marshal(params)
|
||||||
|
if err != nil {
|
||||||
|
//TODO: log
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
|
||||||
|
err = scriptTemplate.Execute(w, map[string]any{
|
||||||
|
"Id": data.Id.String(),
|
||||||
|
"Path": reg.Path,
|
||||||
|
"Parameters": paramData,
|
||||||
|
"Random": utils.CacheBust(),
|
||||||
|
"Challenge": reg.Name,
|
||||||
|
"ChallengeScript": script,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
//TODO: log
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -54,10 +54,11 @@ const u = (url = "", params = {}) => {
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const redir = window.location.href;
|
const redir = window.location.href;
|
||||||
window.location.href = u("{{ .Path }}/verify-challenge", {
|
window.location.href = u("{{ .Path }}/verify-challenge", {
|
||||||
result: result,
|
__goaway_token: result,
|
||||||
redirect: redir,
|
__goaway_challenge: "{{ .Challenge }}",
|
||||||
requestId: "{{ .Id }}",
|
__goaway_redirect: redir,
|
||||||
elapsedTime: t1 - t0,
|
__goaway_id: "{{ .Id }}",
|
||||||
|
__goaway_elapsedTime: t1 - t0,
|
||||||
});
|
});
|
||||||
}, 500);
|
}, 500);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
112
lib/challenge/types.go
Normal file
112
lib/challenge/types.go
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
package challenge
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ed25519"
|
||||||
|
"git.gammaspectra.live/git/go-away/lib/policy"
|
||||||
|
"github.com/google/cel-go/cel"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Id int64
|
||||||
|
|
||||||
|
type Class uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
// ClassTransparent Transparent challenges work inline in the execution process.
|
||||||
|
// These can pass or continue, so more challenges or requests can ve served afterward.
|
||||||
|
ClassTransparent = Class(iota)
|
||||||
|
|
||||||
|
// ClassBlocking Blocking challenges must serve a different response to challenge the requester.
|
||||||
|
// These can pass or stop, for example, due to serving a challenge
|
||||||
|
ClassBlocking
|
||||||
|
)
|
||||||
|
|
||||||
|
type VerifyState uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
VerifyStateNone = VerifyState(iota)
|
||||||
|
// VerifyStatePass Challenge was just passed on this request
|
||||||
|
VerifyStatePass
|
||||||
|
// VerifyStateBrief Challenge token was verified but didn't check the challenge
|
||||||
|
VerifyStateBrief
|
||||||
|
// VerifyStateFull Challenge token was verified and challenge verification was done
|
||||||
|
VerifyStateFull
|
||||||
|
)
|
||||||
|
|
||||||
|
func (r VerifyState) String() string {
|
||||||
|
switch r {
|
||||||
|
case VerifyStatePass:
|
||||||
|
return "PASS"
|
||||||
|
case VerifyStateBrief:
|
||||||
|
return "BRIEF"
|
||||||
|
case VerifyStateFull:
|
||||||
|
return "FULL"
|
||||||
|
default:
|
||||||
|
panic("unsupported")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type VerifyResult uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
// VerifyResultNone A negative pass result, without a token
|
||||||
|
VerifyResultNone = VerifyResult(iota)
|
||||||
|
// VerifyResultFail A negative pass result, with an invalid token
|
||||||
|
VerifyResultFail
|
||||||
|
// VerifyResultSkip Challenge was skipped due to precondition
|
||||||
|
VerifyResultSkip
|
||||||
|
// VerifyResultNotOK A negative pass result, with a valid token
|
||||||
|
VerifyResultNotOK
|
||||||
|
|
||||||
|
// VerifyResultOK A positive pass result, with a valid token
|
||||||
|
VerifyResultOK
|
||||||
|
)
|
||||||
|
|
||||||
|
func (r VerifyResult) Ok() bool {
|
||||||
|
return r >= VerifyResultOK
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r VerifyResult) String() string {
|
||||||
|
switch r {
|
||||||
|
case VerifyResultNone:
|
||||||
|
return "None"
|
||||||
|
case VerifyResultFail:
|
||||||
|
return "Fail"
|
||||||
|
case VerifyResultSkip:
|
||||||
|
return "Skip"
|
||||||
|
case VerifyResultNotOK:
|
||||||
|
return "NotOK"
|
||||||
|
case VerifyResultOK:
|
||||||
|
return "OK"
|
||||||
|
default:
|
||||||
|
panic("unsupported")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type StateInterface interface {
|
||||||
|
ProgramEnv() *cel.Env
|
||||||
|
|
||||||
|
Client() *http.Client
|
||||||
|
PrivateKey() ed25519.PrivateKey
|
||||||
|
PublicKey() ed25519.PublicKey
|
||||||
|
|
||||||
|
UrlPath() string
|
||||||
|
|
||||||
|
ChallengeFailed(r *http.Request, reg *Registration, err error, redirect string, logger *slog.Logger)
|
||||||
|
ChallengePassed(r *http.Request, reg *Registration, redirect string, logger *slog.Logger)
|
||||||
|
ChallengeIssued(r *http.Request, reg *Registration, redirect string, logger *slog.Logger)
|
||||||
|
|
||||||
|
Logger(r *http.Request) *slog.Logger
|
||||||
|
|
||||||
|
ChallengePage(w http.ResponseWriter, r *http.Request, status int, reg *Registration, params map[string]any)
|
||||||
|
ErrorPage(w http.ResponseWriter, r *http.Request, status int, err error, redirect string)
|
||||||
|
|
||||||
|
GetChallenge(id Id) (*Registration, bool)
|
||||||
|
GetChallengeByName(name string) (*Registration, bool)
|
||||||
|
GetChallenges() Register
|
||||||
|
|
||||||
|
Settings() policy.Settings
|
||||||
|
|
||||||
|
GetBackend(host string) http.Handler
|
||||||
|
}
|
||||||
@@ -111,6 +111,7 @@ type VerifyChallengeInput struct {
|
|||||||
|
|
||||||
type VerifyChallengeOutput uint64
|
type VerifyChallengeOutput uint64
|
||||||
|
|
||||||
|
// TODO: expand allowed values
|
||||||
const (
|
const (
|
||||||
VerifyChallengeOutputOK = VerifyChallengeOutput(iota)
|
VerifyChallengeOutputOK = VerifyChallengeOutput(iota)
|
||||||
VerifyChallengeOutputFailed
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Runner) Close() {
|
func (r *Runner) Close() error {
|
||||||
for _, module := range r.modules {
|
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")
|
var ErrModuleNotFound = errors.New("module not found")
|
||||||
|
|||||||
@@ -3,6 +3,12 @@ package condition
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/google/cel-go/cel"
|
"github.com/google/cel-go/cel"
|
||||||
|
"github.com/google/cel-go/common/types"
|
||||||
|
"github.com/google/cel-go/common/types/ref"
|
||||||
|
"github.com/google/cel-go/ext"
|
||||||
|
"github.com/yl2chen/cidranger"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -15,6 +21,124 @@ const (
|
|||||||
OperatorAnd = "&&"
|
OperatorAnd = "&&"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func NewRulesEnvironment(networks map[string]cidranger.Ranger) (*cel.Env, error) {
|
||||||
|
|
||||||
|
return cel.NewEnv(
|
||||||
|
ext.Strings(
|
||||||
|
ext.StringsLocale("en_US"),
|
||||||
|
ext.StringsValidateFormatCalls(true),
|
||||||
|
),
|
||||||
|
cel.DefaultUTCTimeZone(true),
|
||||||
|
//TODO: custom type for remoteAddress
|
||||||
|
cel.Variable("remoteAddress", cel.BytesType),
|
||||||
|
cel.Variable("host", cel.StringType),
|
||||||
|
cel.Variable("method", cel.StringType),
|
||||||
|
cel.Variable("userAgent", cel.StringType),
|
||||||
|
cel.Variable("path", cel.StringType),
|
||||||
|
cel.Variable("query", cel.MapType(cel.StringType, cel.StringType)),
|
||||||
|
cel.Variable("fp", cel.MapType(cel.StringType, cel.StringType)),
|
||||||
|
// http.Header
|
||||||
|
cel.Variable("headers", cel.MapType(cel.StringType, cel.StringType)),
|
||||||
|
//TODO: dynamic type?
|
||||||
|
cel.Function("inDNSBL",
|
||||||
|
cel.Overload("inDNSBL_ip",
|
||||||
|
[]*cel.Type{cel.AnyType},
|
||||||
|
cel.BoolType,
|
||||||
|
cel.UnaryBinding(func(val ref.Val) ref.Val {
|
||||||
|
slog.Error("inDNSBL function has been deprecated, replace with dnsbl challenge")
|
||||||
|
return types.Bool(false)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
cel.Function("network",
|
||||||
|
cel.MemberOverload("netIP_network_string",
|
||||||
|
[]*cel.Type{cel.BytesType, cel.StringType},
|
||||||
|
cel.BoolType,
|
||||||
|
cel.BinaryBinding(func(lhs ref.Val, rhs ref.Val) ref.Val {
|
||||||
|
var ip net.IP
|
||||||
|
switch v := lhs.Value().(type) {
|
||||||
|
case []byte:
|
||||||
|
ip = v
|
||||||
|
case net.IP:
|
||||||
|
ip = v
|
||||||
|
}
|
||||||
|
|
||||||
|
if ip == nil {
|
||||||
|
panic(fmt.Errorf("invalid ip %v", lhs.Value()))
|
||||||
|
}
|
||||||
|
|
||||||
|
val, ok := rhs.Value().(string)
|
||||||
|
if !ok {
|
||||||
|
panic(fmt.Errorf("invalid network value %v", rhs.Value()))
|
||||||
|
}
|
||||||
|
|
||||||
|
network, ok := networks[val]
|
||||||
|
if !ok {
|
||||||
|
_, ipNet, err := net.ParseCIDR(val)
|
||||||
|
if err != nil {
|
||||||
|
panic("network not found")
|
||||||
|
}
|
||||||
|
return types.Bool(ipNet.Contains(ip))
|
||||||
|
} else {
|
||||||
|
ok, err := network.Contains(ip)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return types.Bool(ok)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
cel.Function("inNetwork",
|
||||||
|
cel.Overload("inNetwork_string_ip",
|
||||||
|
[]*cel.Type{cel.StringType, cel.BytesType},
|
||||||
|
cel.BoolType,
|
||||||
|
cel.BinaryBinding(func(lhs ref.Val, rhs ref.Val) ref.Val {
|
||||||
|
var ip net.IP
|
||||||
|
switch v := rhs.Value().(type) {
|
||||||
|
case []byte:
|
||||||
|
ip = v
|
||||||
|
case net.IP:
|
||||||
|
ip = v
|
||||||
|
}
|
||||||
|
|
||||||
|
if ip == nil {
|
||||||
|
panic(fmt.Errorf("invalid ip %v", rhs.Value()))
|
||||||
|
}
|
||||||
|
|
||||||
|
val, ok := lhs.Value().(string)
|
||||||
|
if !ok {
|
||||||
|
panic(fmt.Errorf("invalid value %v", lhs.Value()))
|
||||||
|
}
|
||||||
|
slog.Debug(fmt.Sprintf("inNetwork function has been deprecated and will be removed in a future release, use remoteAddress.network(\"%s\") instead", val))
|
||||||
|
|
||||||
|
network, ok := networks[val]
|
||||||
|
if !ok {
|
||||||
|
_, ipNet, err := net.ParseCIDR(val)
|
||||||
|
if err != nil {
|
||||||
|
panic("network not found")
|
||||||
|
}
|
||||||
|
return types.Bool(ipNet.Contains(ip))
|
||||||
|
} else {
|
||||||
|
ok, err := network.Contains(ip)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return types.Bool(ok)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Program(env *cel.Env, ast *cel.Ast) (cel.Program, error) {
|
||||||
|
return env.Program(ast,
|
||||||
|
cel.EvalOptions(cel.OptOptimize),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func FromStrings(env *cel.Env, operator string, conditions ...string) (*cel.Ast, error) {
|
func FromStrings(env *cel.Env, operator string, conditions ...string) (*cel.Ast, error) {
|
||||||
var asts []*cel.Ast
|
var asts []*cel.Ast
|
||||||
for _, c := range conditions {
|
for _, c := range conditions {
|
||||||
|
|||||||
158
lib/condition/map.go
Normal file
158
lib/condition/map.go
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
package condition
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"github.com/google/cel-go/common/types"
|
||||||
|
"github.com/google/cel-go/common/types/ref"
|
||||||
|
"github.com/google/cel-go/common/types/traits"
|
||||||
|
"net/textproto"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type mimeLike struct {
|
||||||
|
m textproto.MIMEHeader
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a mimeLike) ConvertToNative(typeDesc reflect.Type) (any, error) {
|
||||||
|
return nil, fmt.Errorf("type conversion error from map to '%v'", typeDesc)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a mimeLike) ConvertToType(typeVal ref.Type) ref.Val {
|
||||||
|
switch typeVal {
|
||||||
|
case types.MapType:
|
||||||
|
return a
|
||||||
|
case types.TypeType:
|
||||||
|
return types.MapType
|
||||||
|
}
|
||||||
|
return types.NewErr("type conversion error from '%s' to '%s'", types.MapType, typeVal)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a mimeLike) Equal(other ref.Val) ref.Val {
|
||||||
|
return types.Bool(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a mimeLike) Type() ref.Type {
|
||||||
|
return types.MapType
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a mimeLike) Value() any {
|
||||||
|
return a.m
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a mimeLike) Contains(key ref.Val) ref.Val {
|
||||||
|
_, found := a.Find(key)
|
||||||
|
return types.Bool(found)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a mimeLike) Get(key ref.Val) ref.Val {
|
||||||
|
v, found := a.Find(key)
|
||||||
|
if !found {
|
||||||
|
return types.ValOrErr(v, "no such key: %v", key)
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a mimeLike) Iterator() traits.Iterator {
|
||||||
|
panic("implement me")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a mimeLike) IsZeroValue() bool {
|
||||||
|
return len(a.m) == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a mimeLike) Size() ref.Val {
|
||||||
|
return types.Int(len(a.m))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a mimeLike) Find(key ref.Val) (ref.Val, bool) {
|
||||||
|
k, ok := key.(types.String)
|
||||||
|
if !ok {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
return singleVal(a.m.Values(string(k)), true)
|
||||||
|
}
|
||||||
|
|
||||||
|
type valuesLike struct {
|
||||||
|
m map[string][]string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a valuesLike) ConvertToNative(typeDesc reflect.Type) (any, error) {
|
||||||
|
return nil, fmt.Errorf("type conversion error from map to '%v'", typeDesc)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a valuesLike) ConvertToType(typeVal ref.Type) ref.Val {
|
||||||
|
switch typeVal {
|
||||||
|
case types.MapType:
|
||||||
|
return a
|
||||||
|
case types.TypeType:
|
||||||
|
return types.MapType
|
||||||
|
}
|
||||||
|
return types.NewErr("type conversion error from '%s' to '%s'", types.MapType, typeVal)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a valuesLike) Equal(other ref.Val) ref.Val {
|
||||||
|
return types.Bool(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a valuesLike) Type() ref.Type {
|
||||||
|
return types.MapType
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a valuesLike) Value() any {
|
||||||
|
return a.m
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a valuesLike) Contains(key ref.Val) ref.Val {
|
||||||
|
_, found := a.Find(key)
|
||||||
|
return types.Bool(found)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a valuesLike) Get(key ref.Val) ref.Val {
|
||||||
|
v, found := a.Find(key)
|
||||||
|
if !found {
|
||||||
|
return types.ValOrErr(v, "no such key: %v", key)
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a valuesLike) Iterator() traits.Iterator {
|
||||||
|
panic("implement me")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a valuesLike) IsZeroValue() bool {
|
||||||
|
return len(a.m) == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a valuesLike) Size() ref.Val {
|
||||||
|
return types.Int(len(a.m))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a valuesLike) Find(key ref.Val) (ref.Val, bool) {
|
||||||
|
k, ok := key.(types.String)
|
||||||
|
if !ok {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
val, ok := a.m[string(k)]
|
||||||
|
return singleVal(val, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
func singleVal(values []string, ok bool) (ref.Val, bool) {
|
||||||
|
if len(values) == 0 || !ok {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if len(values) > 1 {
|
||||||
|
return types.String(strings.Join(values, ",")), true
|
||||||
|
}
|
||||||
|
return types.String(values[0]), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMIMEMap(m textproto.MIMEHeader) traits.Mapper {
|
||||||
|
return mimeLike{m: m}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewValuesMap(m map[string][]string) traits.Mapper {
|
||||||
|
return valuesLike{m: m}
|
||||||
|
}
|
||||||
@@ -1,118 +1,11 @@
|
|||||||
package lib
|
package lib
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"git.gammaspectra.live/git/go-away/lib/condition"
|
||||||
"fmt"
|
|
||||||
"github.com/google/cel-go/cel"
|
|
||||||
"github.com/google/cel-go/common/types"
|
|
||||||
"github.com/google/cel-go/common/types/ref"
|
|
||||||
"log/slog"
|
|
||||||
"net"
|
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func (state *State) initConditions() (err error) {
|
func (state *State) initConditions() (err error) {
|
||||||
state.RulesEnv, err = cel.NewEnv(
|
state.programEnv, err = condition.NewRulesEnvironment(state.networks)
|
||||||
cel.DefaultUTCTimeZone(true),
|
|
||||||
cel.Variable("remoteAddress", cel.BytesType),
|
|
||||||
cel.Variable("host", cel.StringType),
|
|
||||||
cel.Variable("method", cel.StringType),
|
|
||||||
cel.Variable("userAgent", cel.StringType),
|
|
||||||
cel.Variable("path", cel.StringType),
|
|
||||||
cel.Variable("query", cel.MapType(cel.StringType, cel.StringType)),
|
|
||||||
cel.Variable("fpJA3N", cel.StringType),
|
|
||||||
cel.Variable("fpJA4", cel.StringType),
|
|
||||||
// http.Header
|
|
||||||
cel.Variable("headers", cel.MapType(cel.StringType, cel.StringType)),
|
|
||||||
//TODO: dynamic type?
|
|
||||||
cel.Function("inDNSBL",
|
|
||||||
cel.Overload("inDNSBL_ip",
|
|
||||||
[]*cel.Type{cel.AnyType},
|
|
||||||
cel.BoolType,
|
|
||||||
cel.UnaryBinding(func(val ref.Val) ref.Val {
|
|
||||||
if state.Settings.DNSBL == nil {
|
|
||||||
return types.Bool(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
var ip net.IP
|
|
||||||
switch v := val.Value().(type) {
|
|
||||||
case []byte:
|
|
||||||
ip = v
|
|
||||||
case net.IP:
|
|
||||||
ip = v
|
|
||||||
case string:
|
|
||||||
ip = net.ParseIP(v)
|
|
||||||
}
|
|
||||||
|
|
||||||
if ip == nil {
|
|
||||||
panic(fmt.Errorf("invalid ip %v", val.Value()))
|
|
||||||
}
|
|
||||||
|
|
||||||
var key [net.IPv6len]byte
|
|
||||||
copy(key[:], ip.To16())
|
|
||||||
|
|
||||||
result, ok := state.DecayMap.Get(key)
|
|
||||||
if ok {
|
|
||||||
return types.Bool(result.Bad())
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
|
||||||
defer cancel()
|
|
||||||
result, err := state.Settings.DNSBL.Lookup(ctx, ip)
|
|
||||||
if err != nil {
|
|
||||||
slog.Debug("dnsbl lookup failed", "address", ip.String(), "result", result, "err", err)
|
|
||||||
} else {
|
|
||||||
slog.Debug("dnsbl lookup", "address", ip.String(), "result", result)
|
|
||||||
}
|
|
||||||
//TODO: configure decay
|
|
||||||
state.DecayMap.Set(key, result, time.Hour)
|
|
||||||
|
|
||||||
return types.Bool(result.Bad())
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
cel.Function("inNetwork",
|
|
||||||
cel.Overload("inNetwork_string_ip",
|
|
||||||
[]*cel.Type{cel.StringType, cel.AnyType},
|
|
||||||
cel.BoolType,
|
|
||||||
cel.BinaryBinding(func(lhs ref.Val, rhs ref.Val) ref.Val {
|
|
||||||
var ip net.IP
|
|
||||||
switch v := rhs.Value().(type) {
|
|
||||||
case []byte:
|
|
||||||
ip = v
|
|
||||||
case net.IP:
|
|
||||||
ip = v
|
|
||||||
case string:
|
|
||||||
ip = net.ParseIP(v)
|
|
||||||
}
|
|
||||||
|
|
||||||
if ip == nil {
|
|
||||||
panic(fmt.Errorf("invalid ip %v", rhs.Value()))
|
|
||||||
}
|
|
||||||
|
|
||||||
val, ok := lhs.Value().(string)
|
|
||||||
if !ok {
|
|
||||||
panic(fmt.Errorf("invalid value %v", lhs.Value()))
|
|
||||||
}
|
|
||||||
|
|
||||||
network, ok := state.Networks[val]
|
|
||||||
if !ok {
|
|
||||||
_, ipNet, err := net.ParseCIDR(val)
|
|
||||||
if err != nil {
|
|
||||||
panic("network not found")
|
|
||||||
}
|
|
||||||
return types.Bool(ipNet.Contains(ip))
|
|
||||||
} else {
|
|
||||||
ok, err := network.Contains(ip)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
return types.Bool(ok)
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
499
lib/http.go
499
lib/http.go
@@ -1,28 +1,16 @@
|
|||||||
package lib
|
package lib
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"codeberg.org/meta/gzipped/v2"
|
"codeberg.org/meta/gzipped/v2"
|
||||||
"context"
|
|
||||||
"crypto/rand"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/hex"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"git.gammaspectra.live/git/go-away/embed"
|
"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/challenge"
|
||||||
"git.gammaspectra.live/git/go-away/lib/policy"
|
|
||||||
"git.gammaspectra.live/git/go-away/utils"
|
"git.gammaspectra.live/git/go-away/utils"
|
||||||
"github.com/google/cel-go/common/types"
|
|
||||||
"html/template"
|
"html/template"
|
||||||
"io"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"maps"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/pprof"
|
"net/http/pprof"
|
||||||
"path"
|
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -30,20 +18,11 @@ import (
|
|||||||
|
|
||||||
var templates map[string]*template.Template
|
var templates map[string]*template.Template
|
||||||
|
|
||||||
var cacheBust string
|
|
||||||
|
|
||||||
// DefaultValidity TODO: adjust
|
|
||||||
const DefaultValidity = time.Hour * 24 * 7
|
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
|
|
||||||
buf := make([]byte, 16)
|
|
||||||
_, _ = rand.Read(buf)
|
|
||||||
cacheBust = base64.RawURLEncoding.EncodeToString(buf)
|
|
||||||
|
|
||||||
templates = make(map[string]*template.Template)
|
templates = make(map[string]*template.Template)
|
||||||
|
|
||||||
dir, err := embed.TemplatesFs.ReadDir("templates")
|
dir, err := embed.TemplatesFs.ReadDir(".")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
@@ -51,7 +30,7 @@ func init() {
|
|||||||
if e.IsDir() {
|
if e.IsDir() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
data, err := embed.TemplatesFs.ReadFile(filepath.Join("templates", e.Name()))
|
data, err := embed.TemplatesFs.ReadFile(e.Name())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
@@ -72,69 +51,16 @@ func initTemplate(name, data string) error {
|
|||||||
return nil
|
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) {
|
func (state *State) addTiming(w http.ResponseWriter, name, desc string, duration time.Duration) {
|
||||||
if state.Settings.Debug {
|
if state.Settings().Debug {
|
||||||
w.Header().Add("Server-Timing", fmt.Sprintf("%s;desc=%s;dur=%d", name, strconv.Quote(desc), duration.Milliseconds()))
|
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 {
|
func GetLoggerForRequest(r *http.Request) *slog.Logger {
|
||||||
data := RequestDataFromContext(r.Context())
|
data := challenge.RequestDataFromContext(r.Context())
|
||||||
args := []any{
|
args := []any{
|
||||||
"request_id", hex.EncodeToString(data.Id[:]),
|
"request_id", data.Id.String(),
|
||||||
"remote_address", data.RemoteAddress.String(),
|
"remote_address", data.RemoteAddress.String(),
|
||||||
"user_agent", r.UserAgent(),
|
"user_agent", r.UserAgent(),
|
||||||
"host", r.Host,
|
"host", r.Host,
|
||||||
@@ -153,272 +79,96 @@ func GetLoggerForRequest(r *http.Request) *slog.Logger {
|
|||||||
return slog.With(args...)
|
return slog.With(args...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (state *State) logger(r *http.Request) *slog.Logger {
|
|
||||||
return GetLoggerForRequest(r)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (state *State) handleRequest(w http.ResponseWriter, r *http.Request) {
|
func (state *State) handleRequest(w http.ResponseWriter, r *http.Request) {
|
||||||
host := r.Host
|
host := r.Host
|
||||||
|
|
||||||
data := RequestDataFromContext(r.Context())
|
data := challenge.RequestDataFromContext(r.Context())
|
||||||
|
|
||||||
backend, ok := state.Settings.Backends[host]
|
backend := state.GetBackend(host)
|
||||||
if !ok {
|
if backend == nil {
|
||||||
http.Error(w, http.StatusText(http.StatusServiceUnavailable), http.StatusServiceUnavailable)
|
http.Error(w, http.StatusText(http.StatusServiceUnavailable), http.StatusServiceUnavailable)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
lg := state.logger(r)
|
lg := state.Logger(r)
|
||||||
|
|
||||||
start := time.Now()
|
cleanupRequest := func(r *http.Request, fromChallenge bool) {
|
||||||
|
if fromChallenge {
|
||||||
state.addTiming(w, "rule-env", "Setup the rule environment", time.Since(start))
|
r.Header.Del("Referer")
|
||||||
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
start = time.Now()
|
q := r.URL.Query()
|
||||||
out, _, err := rule.Program.Eval(data.ProgramEnv)
|
|
||||||
ruleEvalDuration += time.Since(start)
|
|
||||||
|
|
||||||
if err != nil {
|
if ref := q.Get(challenge.QueryArgReferer); ref != "" {
|
||||||
fail(http.StatusInternalServerError, err)
|
r.Header.Set("Referer", ref)
|
||||||
lg.Error(err.Error(), "rule", rule.Name, "rule_hash", rule.Hash)
|
}
|
||||||
panic(err)
|
|
||||||
return
|
|
||||||
} else if out != nil && out.Type() == types.BoolType {
|
|
||||||
if out.Equal(types.True) == types.True {
|
|
||||||
switch rule.Action {
|
|
||||||
default:
|
|
||||||
panic(fmt.Errorf("unknown action %s", rule.Action))
|
|
||||||
case policy.RuleActionPASS:
|
|
||||||
lg.Debug("request passed", "rule", rule.Name, "rule_hash", rule.Hash)
|
|
||||||
setAwayState(rule)
|
|
||||||
serve()
|
|
||||||
return
|
|
||||||
case policy.RuleActionCHALLENGE, policy.RuleActionCHECK:
|
|
||||||
for _, challengeId := range rule.Challenges {
|
|
||||||
if result := data.Challenges[challengeId]; !result.Ok() {
|
|
||||||
continue
|
|
||||||
} else {
|
|
||||||
if rule.Action == policy.RuleActionCHECK {
|
|
||||||
goto nextRule
|
|
||||||
}
|
|
||||||
|
|
||||||
// we passed the challenge!
|
// delete query parameters that were set by go-away
|
||||||
lg.Debug("request passed", "rule", rule.Name, "rule_hash", rule.Hash, "challenge", state.Challenges[challengeId].Name)
|
for k := range q {
|
||||||
setAwayState(rule)
|
if strings.HasPrefix(k, challenge.QueryArgPrefix) {
|
||||||
serve()
|
q.Del(k)
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// none matched, issue first challenge in priority
|
|
||||||
for _, challengeId := range rule.Challenges {
|
|
||||||
result := data.Challenges[challengeId]
|
|
||||||
if result.Ok() || result == challenge.VerifyResultSKIP {
|
|
||||||
// skip already ok'd challenges for some reason, and also skip skipped challenges
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
c := state.Challenges[challengeId]
|
|
||||||
if c.ServeChallenge != nil {
|
|
||||||
result := c.ServeChallenge(w, r, state.GetChallengeKeyForRequest(c.Name, data.Expires, r), data.Expires)
|
|
||||||
switch result {
|
|
||||||
case challenge.ResultStop:
|
|
||||||
lg.Info("request challenged", "rule", rule.Name, "rule_hash", rule.Hash, "challenge", c.Name)
|
|
||||||
return
|
|
||||||
case challenge.ResultContinue:
|
|
||||||
continue
|
|
||||||
case challenge.ResultPass:
|
|
||||||
if rule.Action == policy.RuleActionCHECK {
|
|
||||||
goto nextRule
|
|
||||||
}
|
|
||||||
state.logger(r).Warn("challenge passed", "rule", rule.Name, "rule_hash", rule.Hash, "challenge", c.Name)
|
|
||||||
|
|
||||||
// set pass if caller didn't set one
|
|
||||||
if !data.Challenges[c.Id].Ok() {
|
|
||||||
data.Challenges[c.Id] = challenge.VerifyResultPASS
|
|
||||||
}
|
|
||||||
|
|
||||||
// we pass the challenge early!
|
|
||||||
lg.Debug("request passed", "rule", rule.Name, "rule_hash", rule.Hash, "challenge", c.Name)
|
|
||||||
setAwayState(rule)
|
|
||||||
serve()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
panic("challenge not found")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case policy.RuleActionDENY:
|
|
||||||
lg.Info("request denied", "rule", rule.Name, "rule_hash", rule.Hash)
|
|
||||||
//TODO: config error code
|
|
||||||
fail(http.StatusForbidden, fmt.Errorf("access denied: denied by administrative rule %s/%s", r.Header.Get("X-Away-Id"), rule.Hash))
|
|
||||||
return
|
|
||||||
case policy.RuleActionBLOCK:
|
|
||||||
lg.Info("request blocked", "rule", rule.Name, "rule_hash", rule.Hash)
|
|
||||||
//TODO: config error code
|
|
||||||
//TODO: configure block
|
|
||||||
fail(http.StatusForbidden, fmt.Errorf("access denied: blocked by administrative rule %s/%s", r.Header.Get("X-Away-Id"), rule.Hash))
|
|
||||||
return
|
|
||||||
case policy.RuleActionPOISON:
|
|
||||||
lg.Info("request poisoned", "rule", rule.Name, "rule_hash", rule.Hash)
|
|
||||||
|
|
||||||
mime := "text/html"
|
|
||||||
switch path.Ext(r.URL.Path) {
|
|
||||||
case ".css":
|
|
||||||
case ".json", ".js", ".mjs":
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
encodings := strings.Split(r.Header.Get("Accept-Encoding"), ",")
|
|
||||||
for i, encoding := range encodings {
|
|
||||||
encodings[i] = strings.TrimSpace(strings.ToLower(encoding))
|
|
||||||
}
|
|
||||||
|
|
||||||
reader, encoding := state.getPoison(mime, encodings)
|
|
||||||
if reader == nil {
|
|
||||||
mime = "application/octet-stream"
|
|
||||||
reader, encoding = state.getPoison(mime, encodings)
|
|
||||||
}
|
|
||||||
|
|
||||||
if reader != nil {
|
|
||||||
defer reader.Close()
|
|
||||||
|
|
||||||
w.Header().Set("Cache-Control", "max-age=0, private, must-revalidate, no-transform")
|
|
||||||
w.Header().Set("Vary", "Accept-Encoding")
|
|
||||||
w.Header().Set("Content-Type", mime)
|
|
||||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
||||||
if encoding != "" {
|
|
||||||
w.Header().Set("Content-Encoding", encoding)
|
|
||||||
}
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
if flusher, ok := w.(http.Flusher); ok {
|
|
||||||
// trigger chunked encoding
|
|
||||||
flusher.Flush()
|
|
||||||
}
|
|
||||||
if r != nil {
|
|
||||||
_, _ = io.Copy(w, reader)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
r.URL.RawQuery = q.Encode()
|
||||||
|
|
||||||
nextRule:
|
data.Headers(r.Header)
|
||||||
|
|
||||||
|
// delete cookies set by go-away to prevent user tracking that way
|
||||||
|
cookies := r.Cookies()
|
||||||
|
r.Header.Del("Cookie")
|
||||||
|
for _, c := range cookies {
|
||||||
|
if !strings.HasPrefix(c.Name, utils.CookiePrefix) {
|
||||||
|
r.AddCookie(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
serve()
|
for _, rule := range state.rules {
|
||||||
return
|
next, err := rule.Evaluate(lg, w, r, func() http.Handler {
|
||||||
|
cleanupRequest(r, true)
|
||||||
|
return backend
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
state.ErrorPage(w, r, http.StatusInternalServerError, err, "")
|
||||||
|
panic(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !next {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// default pass
|
||||||
|
_, _ = action.Pass{}.Handle(lg, w, r, func() http.Handler {
|
||||||
|
r.Header.Set("X-Away-Rule", "DEFAULT")
|
||||||
|
r.Header.Set("X-Away-Action", "PASS")
|
||||||
|
|
||||||
|
cleanupRequest(r, false)
|
||||||
|
return backend
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (state *State) setupRoutes() error {
|
func (state *State) setupRoutes() error {
|
||||||
|
|
||||||
state.Mux.HandleFunc("/", state.handleRequest)
|
state.Mux.HandleFunc("/", state.handleRequest)
|
||||||
|
|
||||||
if state.Settings.Debug {
|
if state.Settings().Debug {
|
||||||
http.HandleFunc(state.UrlPath+"/debug/pprof/", pprof.Index)
|
//TODO: split this to a different listener, metrics listener
|
||||||
http.HandleFunc(state.UrlPath+"/debug/pprof/profile", pprof.Profile)
|
http.HandleFunc(state.urlPath+"/debug/pprof/", pprof.Index)
|
||||||
http.HandleFunc(state.UrlPath+"/debug/pprof/symbol", pprof.Symbol)
|
http.HandleFunc(state.urlPath+"/debug/pprof/profile", pprof.Profile)
|
||||||
http.HandleFunc(state.UrlPath+"/debug/pprof/trace", pprof.Trace)
|
http.HandleFunc(state.urlPath+"/debug/pprof/symbol", pprof.Symbol)
|
||||||
|
http.HandleFunc(state.urlPath+"/debug/pprof/trace", pprof.Trace)
|
||||||
}
|
}
|
||||||
|
|
||||||
state.Mux.Handle("GET "+state.UrlPath+"/assets/", http.StripPrefix(state.UrlPath, gzipped.FileServer(gzipped.FS(embed.AssetsFs))))
|
state.Mux.Handle("GET "+state.urlPath+"/assets/", http.StripPrefix(state.UrlPath()+"/assets/", gzipped.FileServer(gzipped.FS(embed.AssetsFs))))
|
||||||
|
|
||||||
for _, c := range state.Challenges {
|
for _, reg := range state.challenges {
|
||||||
if c.ServeStatic != nil {
|
|
||||||
state.Mux.Handle("GET "+c.Path+"/static/", c.ServeStatic)
|
|
||||||
}
|
|
||||||
|
|
||||||
if c.ServeScript != nil {
|
if reg.Handler != nil {
|
||||||
state.Mux.Handle("GET "+c.ServeScriptPath, c.ServeScript)
|
state.Mux.Handle(reg.Path+"/", reg.Handler)
|
||||||
}
|
} else if reg.Verify != nil {
|
||||||
|
// default verify
|
||||||
if c.ServeMakeChallenge != nil {
|
state.Mux.HandleFunc(reg.Path+challenge.VerifyChallengeUrlSuffix, challenge.VerifyHandlerFunc(state, reg, nil, 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
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -426,116 +176,13 @@ func (state *State) setupRoutes() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (state *State) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
func (state *State) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
r, data := challenge.CreateRequestData(r, state)
|
||||||
|
|
||||||
var data RequestData
|
data.EvaluateChallenges(w, r)
|
||||||
// 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 state.Settings().MainName != "" {
|
||||||
if fp := utils.GetTLSFingerprint(r); fp != nil {
|
w.Header().Add("Via", fmt.Sprintf("%s %s@%s", r.Proto, state.Settings().MainName, state.Settings().MainVersion))
|
||||||
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")
|
|
||||||
|
|
||||||
state.Mux.ServeHTTP(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())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
145
lib/interface.go
Normal file
145
lib/interface.go
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
package lib
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/ed25519"
|
||||||
|
"git.gammaspectra.live/git/go-away/lib/challenge"
|
||||||
|
"git.gammaspectra.live/git/go-away/lib/policy"
|
||||||
|
"git.gammaspectra.live/git/go-away/utils"
|
||||||
|
"github.com/google/cel-go/cel"
|
||||||
|
"log/slog"
|
||||||
|
"maps"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Defines challenge.StateInterface
|
||||||
|
|
||||||
|
var _ challenge.StateInterface
|
||||||
|
|
||||||
|
func (state *State) ProgramEnv() *cel.Env {
|
||||||
|
return state.programEnv
|
||||||
|
}
|
||||||
|
|
||||||
|
func (state *State) Client() *http.Client {
|
||||||
|
return state.client
|
||||||
|
}
|
||||||
|
|
||||||
|
func (state *State) PrivateKey() ed25519.PrivateKey {
|
||||||
|
return state.privateKey
|
||||||
|
}
|
||||||
|
|
||||||
|
func (state *State) PublicKey() ed25519.PublicKey {
|
||||||
|
return state.publicKey
|
||||||
|
}
|
||||||
|
|
||||||
|
func (state *State) UrlPath() string {
|
||||||
|
return state.urlPath
|
||||||
|
}
|
||||||
|
|
||||||
|
func (state *State) ChallengeFailed(r *http.Request, reg *challenge.Registration, err error, redirect string, logger *slog.Logger) {
|
||||||
|
if logger == nil {
|
||||||
|
logger = state.Logger(r)
|
||||||
|
}
|
||||||
|
logger.Warn("challenge failed", "challenge", reg.Name, "err", err, "redirect", redirect)
|
||||||
|
|
||||||
|
//TODO: metrics
|
||||||
|
}
|
||||||
|
|
||||||
|
func (state *State) ChallengePassed(r *http.Request, reg *challenge.Registration, redirect string, logger *slog.Logger) {
|
||||||
|
if logger == nil {
|
||||||
|
logger = state.Logger(r)
|
||||||
|
}
|
||||||
|
logger.Warn("challenge passed", "challenge", reg.Name, "redirect", redirect)
|
||||||
|
|
||||||
|
//TODO: metrics
|
||||||
|
}
|
||||||
|
|
||||||
|
func (state *State) ChallengeIssued(r *http.Request, reg *challenge.Registration, redirect string, logger *slog.Logger) {
|
||||||
|
if logger == nil {
|
||||||
|
logger = state.Logger(r)
|
||||||
|
}
|
||||||
|
logger.Info("challenge issued", "challenge", reg.Name, "redirect", redirect)
|
||||||
|
|
||||||
|
//TODO: metrics
|
||||||
|
}
|
||||||
|
|
||||||
|
func (state *State) Logger(r *http.Request) *slog.Logger {
|
||||||
|
return GetLoggerForRequest(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (state *State) ChallengePage(w http.ResponseWriter, r *http.Request, status int, reg *challenge.Registration, params map[string]any) {
|
||||||
|
data := challenge.RequestDataFromContext(r.Context())
|
||||||
|
input := make(map[string]any)
|
||||||
|
input["Id"] = data.Id.String()
|
||||||
|
input["Random"] = utils.CacheBust()
|
||||||
|
if reg != nil {
|
||||||
|
input["Challenge"] = reg.Name
|
||||||
|
input["Path"] = state.UrlPath()
|
||||||
|
}
|
||||||
|
input["Theme"] = state.Settings().ChallengeTemplateTheme
|
||||||
|
|
||||||
|
maps.Copy(input, params)
|
||||||
|
|
||||||
|
if _, ok := input["Title"]; !ok {
|
||||||
|
input["Title"] = "Checking you are not a bot"
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
|
||||||
|
buf := bytes.NewBuffer(make([]byte, 0, 8192))
|
||||||
|
|
||||||
|
err := templates["challenge-"+state.Settings().ChallengeTemplate+".gohtml"].Execute(buf, input)
|
||||||
|
if err != nil {
|
||||||
|
state.ErrorPage(w, r, http.StatusInternalServerError, err, "")
|
||||||
|
} else {
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_, _ = w.Write(buf.Bytes())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (state *State) ErrorPage(w http.ResponseWriter, r *http.Request, status int, err error, redirect string) {
|
||||||
|
data := challenge.RequestDataFromContext(r.Context())
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
|
||||||
|
buf := bytes.NewBuffer(make([]byte, 0, 8192))
|
||||||
|
|
||||||
|
err2 := templates["challenge-"+state.Settings().ChallengeTemplate+".gohtml"].Execute(buf, map[string]any{
|
||||||
|
"Id": data.Id.String(),
|
||||||
|
"Random": utils.CacheBust(),
|
||||||
|
"Error": err.Error(),
|
||||||
|
"Path": state.UrlPath(),
|
||||||
|
"Theme": state.Settings().ChallengeTemplateTheme,
|
||||||
|
"Title": "Oh no! " + http.StatusText(status),
|
||||||
|
"HideSpinner": true,
|
||||||
|
"Challenge": "",
|
||||||
|
"Redirect": redirect,
|
||||||
|
})
|
||||||
|
if err2 != nil {
|
||||||
|
// nested errors!
|
||||||
|
panic(err2)
|
||||||
|
} else {
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_, _ = w.Write(buf.Bytes())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (state *State) GetChallenge(id challenge.Id) (*challenge.Registration, bool) {
|
||||||
|
reg, ok := state.challenges.Get(id)
|
||||||
|
return reg, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (state *State) GetChallenges() challenge.Register {
|
||||||
|
return state.challenges
|
||||||
|
}
|
||||||
|
|
||||||
|
func (state *State) GetChallengeByName(name string) (*challenge.Registration, bool) {
|
||||||
|
reg, _, ok := state.challenges.GetByName(name)
|
||||||
|
return reg, ok
|
||||||
|
}
|
||||||
|
func (state *State) Settings() policy.Settings {
|
||||||
|
return state.settings
|
||||||
|
}
|
||||||
|
|
||||||
|
func (state *State) GetBackend(host string) http.Handler {
|
||||||
|
return utils.SelectHTTPHandler(state.Settings().Backends, host)
|
||||||
|
}
|
||||||
@@ -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
|
package policy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/goccy/go-yaml/ast"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
type Challenge struct {
|
type Challenge struct {
|
||||||
Conditions []string `yaml:"conditions"`
|
Conditions []string `yaml:"conditions"`
|
||||||
Mode string `yaml:"mode"`
|
Runtime string `yaml:"runtime"`
|
||||||
Asset *string `yaml:"asset,omitempty"`
|
|
||||||
Url *string `yaml:"url,omitempty"`
|
|
||||||
|
|
||||||
Parameters map[string]string `json:"parameters,omitempty"`
|
Duration time.Duration `yaml:"duration"`
|
||||||
Runtime struct {
|
|
||||||
Mode string `yaml:"mode,omitempty"`
|
Parameters ast.Node `yaml:"parameters,omitempty"`
|
||||||
Asset string `yaml:"asset,omitempty"`
|
|
||||||
Probability float64 `yaml:"probability,omitempty"`
|
|
||||||
} `yaml:"runtime"`
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"git.gammaspectra.live/git/go-away/utils"
|
||||||
"github.com/itchyny/gojq"
|
"github.com/itchyny/gojq"
|
||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
@@ -13,16 +14,20 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Network struct {
|
type Network struct {
|
||||||
|
// Fetches
|
||||||
Url *string `yaml:"url,omitempty"`
|
Url *string `yaml:"url,omitempty"`
|
||||||
File *string `yaml:"file,omitempty"`
|
File *string `yaml:"file,omitempty"`
|
||||||
|
ASN *int `yaml:"asn,omitempty"`
|
||||||
|
|
||||||
|
// Filtering
|
||||||
JqPath *string `yaml:"jq-path,omitempty"`
|
JqPath *string `yaml:"jq-path,omitempty"`
|
||||||
Regex *string `yaml:"regex,omitempty"`
|
Regex *string `yaml:"regex,omitempty"`
|
||||||
|
|
||||||
Prefixes []string `yaml:"prefixes,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 {
|
if len(n.Prefixes) > 0 {
|
||||||
for _, prefix := range n.Prefixes {
|
for _, prefix := range n.Prefixes {
|
||||||
ipNet, err := parseCIDROrIP(prefix)
|
ipNet, err := parseCIDROrIP(prefix)
|
||||||
@@ -51,6 +56,12 @@ func (n Network) FetchPrefixes(c *http.Client) (output []net.IPNet, err error) {
|
|||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
reader = file
|
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 {
|
} else {
|
||||||
if len(output) > 0 {
|
if len(output) > 0 {
|
||||||
return output, nil
|
return output, nil
|
||||||
@@ -115,3 +126,30 @@ func (n Network) FetchPrefixes(c *http.Client) (output []net.IPNet, err error) {
|
|||||||
}
|
}
|
||||||
return output, nil
|
return output, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseCIDROrIP(value string) (net.IPNet, error) {
|
||||||
|
_, ipNet, err := net.ParseCIDR(value)
|
||||||
|
if err != nil {
|
||||||
|
ip := net.ParseIP(value)
|
||||||
|
if ip == nil {
|
||||||
|
return net.IPNet{}, fmt.Errorf("failed to parse CIDR: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ip4 := ip.To4(); ip4 != nil {
|
||||||
|
return net.IPNet{
|
||||||
|
IP: ip4,
|
||||||
|
// single ip
|
||||||
|
Mask: net.CIDRMask(len(ip4)*8, len(ip4)*8),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
return net.IPNet{
|
||||||
|
IP: ip,
|
||||||
|
// single ip
|
||||||
|
Mask: net.CIDRMask(len(ip)*8, len(ip)*8),
|
||||||
|
}, nil
|
||||||
|
} else if ipNet != nil {
|
||||||
|
return *ipNet, nil
|
||||||
|
} else {
|
||||||
|
return net.IPNet{}, errors.New("invalid CIDR")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
22
lib/policy/options.go
Normal file
22
lib/policy/options.go
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
package policy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.gammaspectra.live/git/go-away/utils"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Settings struct {
|
||||||
|
Cache utils.Cache
|
||||||
|
Backends map[string]http.Handler
|
||||||
|
PrivateKeySeed []byte
|
||||||
|
Debug bool
|
||||||
|
MainName string
|
||||||
|
MainVersion string
|
||||||
|
PackageName string
|
||||||
|
ChallengeTemplate string
|
||||||
|
ChallengeTemplateTheme string
|
||||||
|
ClientIpHeader string
|
||||||
|
BackendIpHeader string
|
||||||
|
|
||||||
|
ChallengeResponseCode int
|
||||||
|
}
|
||||||
@@ -1,38 +1,13 @@
|
|||||||
package policy
|
package policy
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"bytes"
|
||||||
"fmt"
|
"github.com/goccy/go-yaml"
|
||||||
"net"
|
"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 {
|
type Policy struct {
|
||||||
|
|
||||||
// Networks map of networks and prefixes to be loaded
|
// Networks map of networks and prefixes to be loaded
|
||||||
@@ -43,8 +18,70 @@ type Policy struct {
|
|||||||
Challenges map[string]Challenge `yaml:"challenges"`
|
Challenges map[string]Challenge `yaml:"challenges"`
|
||||||
|
|
||||||
Rules []Rule `yaml:"rules"`
|
Rules []Rule `yaml:"rules"`
|
||||||
|
}
|
||||||
// Backends
|
|
||||||
// Deprecated
|
func NewPolicy(r io.Reader, snippetsDirectory string) (*Policy, error) {
|
||||||
Backends map[string]string `json:"backends"`
|
var p Policy
|
||||||
|
p.Networks = make(map[string][]Network)
|
||||||
|
p.Conditions = make(map[string][]string)
|
||||||
|
p.Challenges = make(map[string]Challenge)
|
||||||
|
|
||||||
|
if snippetsDirectory == "" {
|
||||||
|
err := yaml.NewDecoder(r).Decode(&p)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
err := yaml.NewDecoder(r, yaml.ReferenceDirs(snippetsDirectory)).Decode(&p)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// add specific entries from snippets
|
||||||
|
entries, err := os.ReadDir(snippetsDirectory)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, entry := range entries {
|
||||||
|
var entryPolicy Policy
|
||||||
|
if !entry.IsDir() {
|
||||||
|
entryData, err := os.ReadFile(path.Join(snippetsDirectory, entry.Name()))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
err = yaml.NewDecoder(bytes.NewReader(entryData), yaml.ReferenceDirs(snippetsDirectory)).Decode(&entryPolicy)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// add networks / conditions / challenges definitions if they don't exist already
|
||||||
|
|
||||||
|
for k, v := range entryPolicy.Networks {
|
||||||
|
// add network if policy entry does not exist
|
||||||
|
_, ok := p.Networks[k]
|
||||||
|
if !ok {
|
||||||
|
p.Networks[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for k, v := range entryPolicy.Conditions {
|
||||||
|
// add condition if policy entry does not exist
|
||||||
|
_, ok := p.Conditions[k]
|
||||||
|
if !ok {
|
||||||
|
p.Conditions[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for k, v := range entryPolicy.Challenges {
|
||||||
|
// add challenge if policy entry does not exist
|
||||||
|
_, ok := p.Challenges[k]
|
||||||
|
if !ok {
|
||||||
|
p.Challenges[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &p, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,40 @@
|
|||||||
package policy
|
package policy
|
||||||
|
|
||||||
|
import "github.com/goccy/go-yaml/ast"
|
||||||
|
|
||||||
type RuleAction string
|
type RuleAction string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
RuleActionPASS RuleAction = "PASS"
|
// RuleActionNONE Does nothing. Useful for parent rules when children want to be specified
|
||||||
RuleActionDENY RuleAction = "DENY"
|
RuleActionNONE RuleAction = "NONE"
|
||||||
RuleActionBLOCK RuleAction = "BLOCK"
|
// 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"
|
RuleActionCHALLENGE RuleAction = "CHALLENGE"
|
||||||
RuleActionCHECK RuleAction = "CHECK"
|
// RuleActionCHECK Issues a challenge that when passed, continues checking rules
|
||||||
RuleActionPOISON RuleAction = "POISON"
|
RuleActionCHECK RuleAction = "CHECK"
|
||||||
|
|
||||||
|
// RuleActionPROXY Proxies request to a backend, with optional path replacements
|
||||||
|
RuleActionPROXY RuleAction = "PROXY"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Rule struct {
|
type Rule struct {
|
||||||
Name string `yaml:"name"`
|
Name string `yaml:"name"`
|
||||||
Host *string `yaml:"host"`
|
|
||||||
Conditions []string `yaml:"conditions"`
|
Conditions []string `yaml:"conditions"`
|
||||||
|
|
||||||
Action string `yaml:"action"`
|
Action string `yaml:"action"`
|
||||||
|
|
||||||
Challenges []string `yaml:"challenges"`
|
Settings ast.Node `yaml:"settings"`
|
||||||
|
|
||||||
|
Children []Rule `yaml:"children"`
|
||||||
}
|
}
|
||||||
|
|||||||
141
lib/rule.go
Normal file
141
lib/rule.go
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
package lib
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"git.gammaspectra.live/git/go-away/lib/action"
|
||||||
|
"git.gammaspectra.live/git/go-away/lib/challenge"
|
||||||
|
"git.gammaspectra.live/git/go-away/lib/condition"
|
||||||
|
"git.gammaspectra.live/git/go-away/lib/policy"
|
||||||
|
"github.com/google/cel-go/cel"
|
||||||
|
"github.com/google/cel-go/common/types"
|
||||||
|
"github.com/google/cel-go/common/types/ref"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RuleState struct {
|
||||||
|
Name string
|
||||||
|
Hash string
|
||||||
|
|
||||||
|
Condition cel.Program
|
||||||
|
|
||||||
|
Action policy.RuleAction
|
||||||
|
Handler action.Handler
|
||||||
|
|
||||||
|
Children []RuleState
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRuleState(state challenge.StateInterface, r policy.Rule, replacer *strings.Replacer, parent *RuleState) (RuleState, error) {
|
||||||
|
fp := sha256.Sum256(state.PrivateKey())
|
||||||
|
hasher := sha256.New()
|
||||||
|
if parent != nil {
|
||||||
|
hasher.Write([]byte(parent.Name))
|
||||||
|
hasher.Write([]byte{0})
|
||||||
|
r.Name = fmt.Sprintf("%s/%s", parent.Name, r.Name)
|
||||||
|
}
|
||||||
|
hasher.Write([]byte(r.Name))
|
||||||
|
hasher.Write([]byte{0})
|
||||||
|
hasher.Write(fp[:])
|
||||||
|
sum := hasher.Sum(nil)
|
||||||
|
|
||||||
|
rule := RuleState{
|
||||||
|
Name: r.Name,
|
||||||
|
Hash: hex.EncodeToString(sum[:10]),
|
||||||
|
Action: policy.RuleAction(strings.ToUpper(r.Action)),
|
||||||
|
}
|
||||||
|
|
||||||
|
newHandler, ok := action.Register[rule.Action]
|
||||||
|
if !ok {
|
||||||
|
return RuleState{}, fmt.Errorf("unknown action %s", r.Action)
|
||||||
|
}
|
||||||
|
|
||||||
|
actionHandler, err := newHandler(state, rule.Name, rule.Hash, r.Settings)
|
||||||
|
if err != nil {
|
||||||
|
return RuleState{}, err
|
||||||
|
}
|
||||||
|
rule.Handler = actionHandler
|
||||||
|
|
||||||
|
if len(r.Conditions) > 0 {
|
||||||
|
// allow nesting
|
||||||
|
var conditions []string
|
||||||
|
for _, cond := range r.Conditions {
|
||||||
|
cond = replacer.Replace(cond)
|
||||||
|
conditions = append(conditions, cond)
|
||||||
|
}
|
||||||
|
|
||||||
|
ast, err := condition.FromStrings(state.ProgramEnv(), condition.OperatorOr, conditions...)
|
||||||
|
if err != nil {
|
||||||
|
return RuleState{}, fmt.Errorf("error compiling conditions: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
program, err := condition.Program(state.ProgramEnv(), ast)
|
||||||
|
if err != nil {
|
||||||
|
return RuleState{}, fmt.Errorf("error compiling program: %w", err)
|
||||||
|
}
|
||||||
|
rule.Condition = program
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(r.Children) > 0 {
|
||||||
|
for _, child := range r.Children {
|
||||||
|
childRule, err := NewRuleState(state, child, replacer, &rule)
|
||||||
|
if err != nil {
|
||||||
|
return RuleState{}, fmt.Errorf("child %s: %w", child.Name, err)
|
||||||
|
}
|
||||||
|
rule.Children = append(rule.Children, childRule)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rule, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rule RuleState) Evaluate(logger *slog.Logger, w http.ResponseWriter, r *http.Request, done func() http.Handler) (next bool, err error) {
|
||||||
|
data := challenge.RequestDataFromContext(r.Context())
|
||||||
|
var out ref.Val
|
||||||
|
|
||||||
|
lg := logger.With("rule", rule.Name, "rule_hash", rule.Hash, "action", string(rule.Action))
|
||||||
|
if rule.Condition != nil {
|
||||||
|
out, _, err = rule.Condition.Eval(data)
|
||||||
|
} else {
|
||||||
|
// default true
|
||||||
|
out = types.Bool(true)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
lg.Error(err.Error())
|
||||||
|
return false, fmt.Errorf("error: evaluating administrative rule %s/%s: %w", data.Id.String(), rule.Hash, err)
|
||||||
|
} else if out != nil && out.Type() == types.BoolType {
|
||||||
|
if out.Equal(types.True) == types.True {
|
||||||
|
next, err = rule.Handler.Handle(lg, w, r, func() http.Handler {
|
||||||
|
r.Header.Set("X-Away-Rule", rule.Name)
|
||||||
|
r.Header.Set("X-Away-Hash", rule.Hash)
|
||||||
|
r.Header.Set("X-Away-Action", string(rule.Action))
|
||||||
|
|
||||||
|
return done()
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
lg.Error(err.Error())
|
||||||
|
return false, fmt.Errorf("error: executing administrative rule %s/%s: %w", data.Id.String(), rule.Hash, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !next {
|
||||||
|
return next, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, child := range rule.Children {
|
||||||
|
next, err = child.Evaluate(logger, w, r, done)
|
||||||
|
if err != nil {
|
||||||
|
lg.Error(err.Error())
|
||||||
|
return false, fmt.Errorf("error: executing administrative rule %s/%s: %w", data.Id.String(), rule.Hash, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !next {
|
||||||
|
return next, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
795
lib/state.go
795
lib/state.go
@@ -1,157 +1,86 @@
|
|||||||
package lib
|
package lib
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"codeberg.org/meta/gzipped/v2"
|
|
||||||
"context"
|
|
||||||
"crypto/ed25519"
|
"crypto/ed25519"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"crypto/sha256"
|
|
||||||
"crypto/subtle"
|
|
||||||
"encoding/hex"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"git.gammaspectra.live/git/go-away/embed"
|
|
||||||
"git.gammaspectra.live/git/go-away/lib/challenge"
|
"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/condition"
|
||||||
"git.gammaspectra.live/git/go-away/lib/policy"
|
"git.gammaspectra.live/git/go-away/lib/policy"
|
||||||
"git.gammaspectra.live/git/go-away/utils"
|
"git.gammaspectra.live/git/go-away/utils"
|
||||||
"git.gammaspectra.live/git/go-away/utils/inline"
|
|
||||||
"github.com/google/cel-go/cel"
|
"github.com/google/cel-go/cel"
|
||||||
"github.com/tetratelabs/wazero/api"
|
|
||||||
"github.com/yl2chen/cidranger"
|
"github.com/yl2chen/cidranger"
|
||||||
"html/template"
|
|
||||||
"io"
|
|
||||||
"io/fs"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httputil"
|
"net/http/httputil"
|
||||||
"net/url"
|
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"sync/atomic"
|
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type State struct {
|
type State struct {
|
||||||
Client *http.Client
|
client *http.Client
|
||||||
Settings StateSettings
|
radb *utils.RADb
|
||||||
UrlPath string
|
urlPath string
|
||||||
Mux *http.ServeMux
|
|
||||||
|
|
||||||
Networks map[string]cidranger.Ranger
|
programEnv *cel.Env
|
||||||
|
|
||||||
Wasm *wasm.Runner
|
|
||||||
|
|
||||||
Challenges map[challenge.Id]challenge.Challenge
|
|
||||||
|
|
||||||
RulesEnv *cel.Env
|
|
||||||
|
|
||||||
Rules []RuleState
|
|
||||||
|
|
||||||
publicKey ed25519.PublicKey
|
publicKey ed25519.PublicKey
|
||||||
privateKey ed25519.PrivateKey
|
privateKey ed25519.PrivateKey
|
||||||
|
|
||||||
Poison map[string][]byte
|
settings policy.Settings
|
||||||
|
|
||||||
ChallengeSolve sync.Map
|
networks map[string]cidranger.Ranger
|
||||||
|
|
||||||
DecayMap *utils.DecayMap[[net.IPv6len]byte, utils.DNSBLResponse]
|
challenges challenge.Register
|
||||||
|
|
||||||
|
rules []RuleState
|
||||||
|
|
||||||
close chan struct{}
|
close chan struct{}
|
||||||
|
|
||||||
|
Mux *http.ServeMux
|
||||||
}
|
}
|
||||||
|
|
||||||
func (state *State) AwaitChallenge(key []byte, ctx context.Context) challenge.VerifyResult {
|
func NewState(p policy.Policy, settings policy.Settings) (handler http.Handler, err error) {
|
||||||
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)
|
state := new(State)
|
||||||
state.close = make(chan struct{})
|
state.close = make(chan struct{})
|
||||||
state.Settings = settings
|
state.settings = settings
|
||||||
state.Client = &http.Client{
|
state.client = &http.Client{
|
||||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||||
return http.ErrUseLastResponse
|
return http.ErrUseLastResponse
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
state.UrlPath = "/.well-known/." + state.Settings.PackageName
|
state.radb, err = utils.NewRADb()
|
||||||
|
if err != nil {
|
||||||
if state.Settings.DNSBL != nil {
|
return nil, fmt.Errorf("failed to initialize RADb client: %w", err)
|
||||||
state.DecayMap = utils.NewDecayMap[[net.IPv6len]byte, utils.DNSBLResponse]()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
state.urlPath = "/.well-known/." + state.Settings().PackageName
|
||||||
|
|
||||||
// set a reasonable configuration for default http proxy if there is none
|
// 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, ok := backend.(*httputil.ReverseProxy); ok {
|
||||||
if proxy.ErrorHandler == nil {
|
if proxy.ErrorHandler == nil {
|
||||||
proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
|
proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
|
||||||
state.logger(r).Error(err.Error())
|
state.Logger(r).Error(err.Error())
|
||||||
_ = state.errorPage(w, r.Header.Get("X-Away-Id"), http.StatusBadGateway, err, "")
|
state.ErrorPage(w, r, http.StatusBadGateway, err, "")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(state.Settings.PrivateKeySeed) > 0 {
|
if len(state.Settings().PrivateKeySeed) > 0 {
|
||||||
if len(state.Settings.PrivateKeySeed) != ed25519.SeedSize {
|
if len(state.Settings().PrivateKeySeed) != ed25519.SeedSize {
|
||||||
return nil, fmt.Errorf("invalid private key seed length: %d", len(state.Settings.PrivateKeySeed))
|
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)
|
state.publicKey = state.privateKey.Public().(ed25519.PublicKey)
|
||||||
|
|
||||||
clear(state.Settings.PrivateKeySeed)
|
clear(state.settings.PrivateKeySeed)
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
state.publicKey, state.privateKey, err = ed25519.GenerateKey(rand.Reader)
|
state.publicKey, state.privateKey, err = ed25519.GenerateKey(rand.Reader)
|
||||||
@@ -160,53 +89,99 @@ func NewState(p policy.Policy, settings StateSettings) (handler http.Handler, er
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
privateKeyFingerprint := sha256.Sum256(state.privateKey)
|
if state.Settings().ChallengeTemplate == "" {
|
||||||
|
state.settings.ChallengeTemplate = "anubis"
|
||||||
if state.Settings.ChallengeTemplate == "" {
|
|
||||||
state.Settings.ChallengeTemplate = "anubis"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if templates["challenge-"+state.Settings.ChallengeTemplate+".gohtml"] == nil {
|
if templates["challenge-"+state.Settings().ChallengeTemplate+".gohtml"] == nil {
|
||||||
|
|
||||||
if data, err := os.ReadFile(state.Settings.ChallengeTemplate); err == nil && len(data) > 0 {
|
if data, err := os.ReadFile(state.Settings().ChallengeTemplate); err == nil && len(data) > 0 {
|
||||||
name := path.Base(state.Settings.ChallengeTemplate)
|
name := path.Base(state.Settings().ChallengeTemplate)
|
||||||
err := initTemplate(name, string(data))
|
err := initTemplate(name, string(data))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("error loading template %s: %w", settings.ChallengeTemplate, err)
|
return nil, fmt.Errorf("error loading template %s: %w", settings.ChallengeTemplate, err)
|
||||||
}
|
}
|
||||||
state.Settings.ChallengeTemplate = name
|
state.settings.ChallengeTemplate = name
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, fmt.Errorf("no template defined for %s", settings.ChallengeTemplate)
|
return nil, fmt.Errorf("no template defined for %s", settings.ChallengeTemplate)
|
||||||
}
|
}
|
||||||
|
|
||||||
state.Networks = make(map[string]cidranger.Ranger)
|
state.networks = make(map[string]cidranger.Ranger)
|
||||||
|
|
||||||
|
networkCache := utils.CachePrefix(state.Settings().Cache, "networks/")
|
||||||
|
|
||||||
for k, network := range p.Networks {
|
for k, network := range p.Networks {
|
||||||
|
|
||||||
ranger := cidranger.NewPCTrieRanger()
|
ranger := cidranger.NewPCTrieRanger()
|
||||||
for _, e := range network {
|
for i, e := range network {
|
||||||
if e.Url != nil {
|
prefixes, err := func() ([]net.IPNet, error) {
|
||||||
slog.Debug("loading network url list", "network", k, "url", *e.Url)
|
var useCache bool
|
||||||
}
|
if e.Url != nil {
|
||||||
prefixes, err := e.FetchPrefixes(state.Client)
|
slog.Debug("loading network url list", "network", k, "url", *e.Url)
|
||||||
if err != nil {
|
useCache = true
|
||||||
slog.Error("error fetching network url list", "network", k, "url", *e.Url)
|
} else if e.ASN != nil {
|
||||||
continue
|
slog.Debug("loading ASN", "network", k, "asn", *e.ASN)
|
||||||
}
|
useCache = true
|
||||||
|
}
|
||||||
|
|
||||||
|
cacheKey := fmt.Sprintf("%s-%d", k, i)
|
||||||
|
var cached []net.IPNet
|
||||||
|
if useCache && networkCache != nil {
|
||||||
|
//TODO: add randomness
|
||||||
|
cachedData, err := networkCache.Get(cacheKey, time.Hour*24)
|
||||||
|
var l []string
|
||||||
|
_ = json.Unmarshal(cachedData, &l)
|
||||||
|
for _, n := range l {
|
||||||
|
_, ipNet, err := net.ParseCIDR(n)
|
||||||
|
if err == nil {
|
||||||
|
cached = append(cached, *ipNet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
// use
|
||||||
|
return cached, nil
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
prefixes, err := e.FetchPrefixes(state.client, state.radb)
|
||||||
|
if err != nil {
|
||||||
|
if len(cached) > 0 {
|
||||||
|
// use cached meanwhile
|
||||||
|
return cached, err
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if useCache && networkCache != nil {
|
||||||
|
var l []string
|
||||||
|
for _, n := range prefixes {
|
||||||
|
l = append(l, n.String())
|
||||||
|
}
|
||||||
|
cachedData, err := json.Marshal(l)
|
||||||
|
if err == nil {
|
||||||
|
_ = networkCache.Set(cacheKey, cachedData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return prefixes, nil
|
||||||
|
}()
|
||||||
for _, prefix := range prefixes {
|
for _, prefix := range prefixes {
|
||||||
err = ranger.Insert(cidranger.NewBasicRangerEntry(prefix))
|
err = ranger.Insert(cidranger.NewBasicRangerEntry(prefix))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("networks %s: error inserting prefix %s: %v", k, prefix.String(), err)
|
return nil, fmt.Errorf("networks %s: error inserting prefix %s: %v", k, prefix.String(), err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("error loading network list", "network", k, "url", *e.Url, "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
slog.Warn("loaded network prefixes", "network", k, "count", ranger.Len())
|
slog.Warn("loaded network prefixes", "network", k, "count", ranger.Len())
|
||||||
|
|
||||||
state.Networks[k] = ranger
|
state.networks[k] = ranger
|
||||||
}
|
}
|
||||||
|
|
||||||
state.Wasm = wasm.NewRunner(true)
|
|
||||||
|
|
||||||
err = state.initConditions()
|
err = state.initConditions()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -214,7 +189,7 @@ func NewState(p policy.Policy, settings StateSettings) (handler http.Handler, er
|
|||||||
|
|
||||||
var replacements []string
|
var replacements []string
|
||||||
for k, entries := range p.Conditions {
|
for k, entries := range p.Conditions {
|
||||||
ast, err := condition.FromStrings(state.RulesEnv, condition.OperatorOr, entries...)
|
ast, err := condition.FromStrings(state.programEnv, condition.OperatorOr, entries...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("conditions %s: error compiling conditions: %v", k, err)
|
return nil, fmt.Errorf("conditions %s: error compiling conditions: %v", k, err)
|
||||||
}
|
}
|
||||||
@@ -229,563 +204,26 @@ func NewState(p policy.Policy, settings StateSettings) (handler http.Handler, er
|
|||||||
}
|
}
|
||||||
conditionReplacer := strings.NewReplacer(replacements...)
|
conditionReplacer := strings.NewReplacer(replacements...)
|
||||||
|
|
||||||
state.Challenges = make(map[challenge.Id]challenge.Challenge)
|
state.challenges = make(challenge.Register)
|
||||||
|
|
||||||
idCounter := challenge.Id(1)
|
|
||||||
|
|
||||||
//TODO: move this to self-contained challenge files
|
//TODO: move this to self-contained challenge files
|
||||||
for challengeName, p := range p.Challenges {
|
for challengeName, pol := range p.Challenges {
|
||||||
|
_, _, err := state.challenges.Create(state, challengeName, pol, conditionReplacer)
|
||||||
// allow nesting
|
if err != nil {
|
||||||
var conditions []string
|
return nil, fmt.Errorf("challenge %s: %w", challengeName, err)
|
||||||
for _, cond := range p.Conditions {
|
|
||||||
cond = conditionReplacer.Replace(cond)
|
|
||||||
conditions = append(conditions, cond)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
for _, r := 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))
|
rule, err := NewRuleState(state, r, conditionReplacer, nil)
|
||||||
|
|
||||||
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...)
|
|
||||||
if err != 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()
|
state.Mux = http.NewServeMux()
|
||||||
@@ -794,28 +232,5 @@ func NewState(p policy.Policy, settings StateSettings) (handler http.Handler, er
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if state.DecayMap != nil {
|
|
||||||
go func() {
|
|
||||||
ticker := time.NewTicker(17 * time.Minute)
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ticker.C:
|
|
||||||
state.DecayMap.Decay()
|
|
||||||
case <-state.close:
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
return state, nil
|
return state, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (state *State) GetChallengeByName(name string) (challenge.Challenge, bool) {
|
|
||||||
for _, c := range state.Challenges {
|
|
||||||
if c.Name == name {
|
|
||||||
return c, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return challenge.Challenge{}, false
|
|
||||||
}
|
|
||||||
|
|||||||
86
utils/cache.go
Normal file
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
|
package utils
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
var CookiePrefix = ".go-away-"
|
var CookiePrefix = ".go-away-"
|
||||||
|
|
||||||
func SetCookie(name, value string, expiry time.Time, w http.ResponseWriter) {
|
// getValidHost Gets a valid host for an http.Cookie Domain field
|
||||||
|
// TODO: bug: does not work with IPv6, see https://github.com/golang/go/issues/65521
|
||||||
|
func getValidHost(host string) string {
|
||||||
|
ipStr, _, err := net.SplitHostPort(host)
|
||||||
|
if err != nil {
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
return ipStr
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetCookie(name, value string, expiry time.Time, w http.ResponseWriter, r *http.Request) {
|
||||||
http.SetCookie(w, &http.Cookie{
|
http.SetCookie(w, &http.Cookie{
|
||||||
Name: name,
|
Name: name,
|
||||||
Value: value,
|
Value: value,
|
||||||
Expires: expiry,
|
Expires: expiry,
|
||||||
SameSite: http.SameSiteLaxMode,
|
SameSite: http.SameSiteLaxMode,
|
||||||
Path: "/",
|
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{
|
http.SetCookie(w, &http.Cookie{
|
||||||
Name: name,
|
Name: name,
|
||||||
Value: "",
|
Value: "",
|
||||||
Expires: time.Now().Add(-1 * time.Hour),
|
Expires: time.Now().Add(-1 * time.Hour),
|
||||||
MaxAge: -1,
|
MaxAge: -1,
|
||||||
SameSite: http.SameSiteLaxMode,
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
Domain: getValidHost(r.Host),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,31 +16,27 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func applyTLSFingerprinter(server *http.Server) {
|
func applyTLSFingerprinter(server *http.Server) {
|
||||||
|
if server.TLSConfig == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
server.TLSConfig = server.TLSConfig.Clone()
|
server.TLSConfig = server.TLSConfig.Clone()
|
||||||
|
|
||||||
getCertificate := server.TLSConfig.GetCertificate
|
getConfigForClient := server.TLSConfig.GetConfigForClient
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if getConfigForClient == nil {
|
||||||
|
getConfigForClient = func(info *tls.ClientHelloInfo) (*tls.Config, error) {
|
||||||
return nil, nil
|
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 {
|
server.ConnContext = func(ctx context.Context, c net.Conn) context.Context {
|
||||||
return context.WithValue(ctx, tlsFingerprintKey{}, &TLSFingerprint{})
|
return context.WithValue(ctx, tlsFingerprintKey{}, &TLSFingerprint{})
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ package utils
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
|
"encoding/base64"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
@@ -13,7 +15,6 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func NewServer(handler http.Handler, tlsConfig *tls.Config) *http.Server {
|
func NewServer(handler http.Handler, tlsConfig *tls.Config) *http.Server {
|
||||||
|
|
||||||
if tlsConfig == nil {
|
if tlsConfig == nil {
|
||||||
proto := new(http.Protocols)
|
proto := new(http.Protocols)
|
||||||
proto.SetHTTP1(true)
|
proto.SetHTTP1(true)
|
||||||
@@ -34,6 +35,21 @@ func NewServer(handler http.Handler, tlsConfig *tls.Config) *http.Server {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func SelectHTTPHandler(backends map[string]http.Handler, host string) http.Handler {
|
||||||
|
backend, ok := backends[host]
|
||||||
|
if !ok {
|
||||||
|
// do wildcard match
|
||||||
|
wildcard := "*." + strings.Join(strings.Split(host, ".")[1:], ".")
|
||||||
|
backend, ok = backends[wildcard]
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
// return fallback
|
||||||
|
backend = backends["*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return backend
|
||||||
|
}
|
||||||
|
|
||||||
func EnsureNoOpenRedirect(redirect string) (string, error) {
|
func EnsureNoOpenRedirect(redirect string) (string, error) {
|
||||||
uri, err := url.Parse(redirect)
|
uri, err := url.Parse(redirect)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -79,3 +95,46 @@ func MakeReverseProxy(target string) (*httputil.ReverseProxy, error) {
|
|||||||
|
|
||||||
return rp, nil
|
return rp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func GetRequestScheme(r *http.Request) string {
|
||||||
|
if proto := r.Header.Get("X-Forwarded-Proto"); proto == "http" || proto == "https" {
|
||||||
|
return proto
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.TLS != nil {
|
||||||
|
return "https"
|
||||||
|
}
|
||||||
|
|
||||||
|
return "http"
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetRequestAddress(r *http.Request, clientHeader string) net.IP {
|
||||||
|
var ipStr string
|
||||||
|
if clientHeader != "" {
|
||||||
|
ipStr = r.Header.Get(clientHeader)
|
||||||
|
}
|
||||||
|
if ipStr != "" {
|
||||||
|
// handle X-Forwarded-For
|
||||||
|
ipStr = strings.Split(ipStr, ",")[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// fallback
|
||||||
|
if ipStr == "" {
|
||||||
|
ipStr, _, _ = net.SplitHostPort(r.RemoteAddr)
|
||||||
|
}
|
||||||
|
ipStr = strings.Trim(ipStr, "[]")
|
||||||
|
return net.ParseIP(ipStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func CacheBust() string {
|
||||||
|
return cacheBust
|
||||||
|
}
|
||||||
|
|
||||||
|
var cacheBust string
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
|
||||||
|
buf := make([]byte, 16)
|
||||||
|
_, _ = rand.Read(buf)
|
||||||
|
cacheBust = base64.RawURLEncoding.EncodeToString(buf)
|
||||||
|
}
|
||||||
|
|||||||
167
utils/radb.go
Normal file
167
utils/radb.go
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RADb struct {
|
||||||
|
target string
|
||||||
|
dialer net.Dialer
|
||||||
|
}
|
||||||
|
|
||||||
|
const RADBServer = "whois.radb.net:43"
|
||||||
|
|
||||||
|
func NewRADb() (*RADb, error) {
|
||||||
|
|
||||||
|
host, port, err := net.SplitHostPort(RADBServer)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &RADb{
|
||||||
|
target: fmt.Sprintf("%s:%s", host, port),
|
||||||
|
dialer: net.Dialer{
|
||||||
|
Timeout: 5 * time.Second,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var whoisRouteRegex = regexp.MustCompile("(?P<prefix>(([0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+)|([0-9a-f:]+::))/[0-9]+)")
|
||||||
|
|
||||||
|
func (db *RADb) query(fn func(n int, record []byte) error, queries ...string) error {
|
||||||
|
|
||||||
|
conn, err := db.dialer.Dial("tcp", db.target)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
if len(queries) > 1 {
|
||||||
|
// enable persistent conn
|
||||||
|
_ = conn.SetDeadline(time.Now().Add(time.Second * 5))
|
||||||
|
_, err = conn.Write([]byte("!!\n"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(conn)
|
||||||
|
scanner.Split(bufio.ScanLines)
|
||||||
|
|
||||||
|
for _, q := range queries {
|
||||||
|
|
||||||
|
_ = conn.SetDeadline(time.Now().Add(time.Second * 5))
|
||||||
|
_, err = conn.Write([]byte(strings.TrimSpace(q) + "\n"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
n := 0
|
||||||
|
|
||||||
|
for scanner.Scan() {
|
||||||
|
buf := bytes.Trim(scanner.Bytes(), "\r\n")
|
||||||
|
if bytes.HasPrefix(buf, []byte("%")) || bytes.Equal(buf, []byte("C")) {
|
||||||
|
// end of record
|
||||||
|
break
|
||||||
|
}
|
||||||
|
err = fn(n, buf)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(queries) > 1 {
|
||||||
|
// exit
|
||||||
|
_ = conn.SetDeadline(time.Now().Add(time.Second * 5))
|
||||||
|
_, err = conn.Write([]byte("q\n"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
db, _ := NewRADb()
|
||||||
|
db.FetchIPInfo(net.ParseIP("162.158.62.1"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *RADb) FetchIPInfo(ip net.IP) (result []string, err error) {
|
||||||
|
var ipNet net.IPNet
|
||||||
|
if ip4 := ip.To4(); ip4 != nil {
|
||||||
|
ipNet = net.IPNet{
|
||||||
|
IP: ip4,
|
||||||
|
// single ip
|
||||||
|
Mask: net.CIDRMask(len(ip4)*8, len(ip4)*8),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ipNet = net.IPNet{
|
||||||
|
IP: ip,
|
||||||
|
// single ip
|
||||||
|
Mask: net.CIDRMask(len(ip)*8, len(ip)*8),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = db.query(func(n int, record []byte) error {
|
||||||
|
result = append(result, string(record))
|
||||||
|
return nil
|
||||||
|
}, fmt.Sprintf("!r%s,l", ipNet.String()))
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *RADb) FetchASNets(asn int) (result []net.IPNet, err error) {
|
||||||
|
|
||||||
|
ix := whoisRouteRegex.SubexpIndex("prefix")
|
||||||
|
if ix == -1 {
|
||||||
|
panic("invalid regex prefix")
|
||||||
|
}
|
||||||
|
|
||||||
|
var data []byte
|
||||||
|
|
||||||
|
err = db.query(func(n int, record []byte) error {
|
||||||
|
if n == 0 {
|
||||||
|
// do not append ASN number reply
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// pad data
|
||||||
|
if n == 1 {
|
||||||
|
data = append(data, ' ')
|
||||||
|
}
|
||||||
|
data = append(data, record...)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
// See https://www.radb.net/query/help
|
||||||
|
// fetch IPv4 routes
|
||||||
|
fmt.Sprintf("!gas%d", asn),
|
||||||
|
// fetch IPv6 routes
|
||||||
|
fmt.Sprintf("!6as%d", asn),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
matches := whoisRouteRegex.FindAllSubmatch(data, -1)
|
||||||
|
for _, match := range matches {
|
||||||
|
_, ipNet, err := net.ParseCIDR(string(match[ix]))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid CIDR %s: %w", string(match[ix]), err)
|
||||||
|
}
|
||||||
|
result = append(result, *ipNet)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user