├── .dockerignore
├── .drone.jsonnet
├── .drone.yml
├── .gitignore
├── Dockerfile
├── LICENSE
├── README.md
├── build-compress.sh
├── build-wasm.sh
├── cmd
├── go-away
│ └── main.go
└── test-wasm-runtime
│ └── main.go
├── docker-entrypoint.sh
├── embed
├── assets
│ └── static
│ │ ├── anubis
│ │ ├── geist.woff2
│ │ ├── iosevka-curly.woff2
│ │ ├── podkova.woff2
│ │ └── style.css
│ │ └── logo.png
├── challenge
│ └── js-pow-sha256
│ │ ├── runtime
│ │ ├── runtime.go
│ │ └── runtime.wasm
│ │ ├── static
│ │ └── load.mjs
│ │ └── test
│ │ ├── make-challenge-out.json
│ │ ├── make-challenge.json
│ │ ├── verify-challenge-fail.json
│ │ └── verify-challenge.json
├── embed.go
└── templates
│ ├── challenge-anubis.gohtml
│ └── challenge-forgejo.gohtml
├── examples
├── config.yml
├── forgejo.yml
├── generic.yml
├── snippets
│ ├── bot-betterstack.yml
│ ├── bot-bingbot.yml
│ ├── bot-duckduckbot.yml
│ ├── bot-googlebot.yml
│ ├── bot-kagibot.yml
│ ├── bot-qwantbot.yml
│ ├── bot-uptimerobot.yml
│ ├── bot-yandexbot.yml
│ ├── challenge-dnsbl.yml
│ ├── challenge-js-pow-sha256.yml
│ ├── challenge-js-refresh.yml
│ ├── challenges-non-js.yml
│ ├── conditions-generic.yml
│ ├── networks-other.yml
│ └── networks-private.yml
└── spa.yml
├── go.mod
├── go.sum
├── lib
├── action
│ ├── block.go
│ ├── challenge.go
│ ├── code.go
│ ├── context.go
│ ├── deny.go
│ ├── drop.go
│ ├── none.go
│ ├── pass.go
│ ├── proxy.go
│ └── register.go
├── challenge.go
├── challenge
│ ├── awaiter.go
│ ├── cookie
│ │ └── cookie.go
│ ├── data.go
│ ├── dnsbl
│ │ └── dnsbl.go
│ ├── helper.go
│ ├── http
│ │ └── http.go
│ ├── key.go
│ ├── preload-link
│ │ └── preload-link.go
│ ├── refresh
│ │ └── refresh.go
│ ├── register.go
│ ├── resource-load
│ │ └── resource-load.go
│ ├── script.go
│ ├── script.mjs
│ ├── types.go
│ └── wasm
│ │ ├── interface
│ │ ├── interface.go
│ │ ├── interface_generic.go
│ │ └── interface_tinygo.go
│ │ ├── registration.go
│ │ ├── runner.go
│ │ └── utils.go
├── conditions.go
├── http.go
├── interface.go
├── metrics.go
├── policy
│ ├── challenge.go
│ ├── network.go
│ ├── policy.go
│ ├── rule.go
│ └── state.go
├── rule.go
├── settings
│ ├── backend.go
│ ├── bind.go
│ ├── settings.go
│ └── strings.go
├── state.go
└── template.go
└── utils
├── cache.go
├── cookie.go
├── decaymap.go
├── dnsbl.go
├── fingerprint.go
├── http.go
├── inline
├── hex.go
└── mime.go
├── radb.go
├── strings.go
├── tagfetcher.go
└── unix.go
/.dockerignore:
--------------------------------------------------------------------------------
1 | /.bin
2 | *.gz
3 | *.br
4 | *.zst
--------------------------------------------------------------------------------
/.drone.jsonnet:
--------------------------------------------------------------------------------
1 | // yaml_stream.jsonnet
2 | local Build(mirror, go, alpine, os, arch) = {
3 | kind: "pipeline",
4 | type: "docker",
5 | name: "build-" + go + "-alpine" + alpine + "-" + arch,
6 | platform: {
7 | os: os,
8 | arch: arch
9 | },
10 | environment: {
11 | GOTOOLCHAIN: "local",
12 | CGO_ENABLED: "0",
13 | GOOS: os,
14 | GOARCH: arch,
15 | },
16 | steps: [
17 | {
18 | name: "build",
19 | image: "golang:" + go +"-alpine" + alpine,
20 | mirror: mirror,
21 | commands: [
22 | "apk update",
23 | "apk add --no-cache git",
24 | "mkdir .bin",
25 | "go build -v -pgo=auto -v -trimpath -ldflags=-buildid= -o ./.bin/go-away ./cmd/go-away",
26 | "go build -v -o ./.bin/test-wasm-runtime ./cmd/test-wasm-runtime",
27 | ],
28 | },
29 | {
30 | name: "check-policy-forgejo",
31 | image: "alpine:" + alpine,
32 | mirror: mirror,
33 | depends_on: ["build"],
34 | commands: [
35 | "./.bin/go-away --check --slog-level DEBUG --backend example.com=http://127.0.0.1:80 --policy examples/forgejo.yml --policy-snippets examples/snippets/"
36 | ],
37 | },
38 | {
39 | name: "check-policy-generic",
40 | image: "alpine:" + alpine,
41 | mirror: mirror,
42 | depends_on: ["build"],
43 | commands: [
44 | "./.bin/go-away --check --slog-level DEBUG --backend example.com=http://127.0.0.1:80 --policy examples/generic.yml --policy-snippets examples/snippets/"
45 | ],
46 | },
47 | {
48 | name: "check-policy-spa",
49 | image: "alpine:" + alpine,
50 | mirror: mirror,
51 | depends_on: ["build"],
52 | commands: [
53 | "./.bin/go-away --check --slog-level DEBUG --backend example.com=http://127.0.0.1:80 --policy examples/spa.yml --policy-snippets examples/snippets/"
54 | ],
55 | },
56 | {
57 | name: "test-wasm-success",
58 | image: "alpine:" + alpine,
59 | mirror: mirror,
60 | depends_on: ["build"],
61 | commands: [
62 | "./.bin/test-wasm-runtime -wasm ./embed/challenge/js-pow-sha256/runtime/runtime.wasm " +
63 | "-make-challenge ./embed/challenge/js-pow-sha256/test/make-challenge.json " +
64 | "-make-challenge-out ./embed/challenge/js-pow-sha256/test/make-challenge-out.json " +
65 | "-verify-challenge ./embed/challenge/js-pow-sha256/test/verify-challenge.json " +
66 | "-verify-challenge-out 0",
67 | ],
68 | },
69 | {
70 | name: "test-wasm-fail",
71 | image: "alpine:" + alpine,
72 | mirror: mirror,
73 | depends_on: ["build"],
74 | commands: [
75 | "./.bin/test-wasm-runtime -wasm ./embed/challenge/js-pow-sha256/runtime/runtime.wasm " +
76 | "-make-challenge ./embed/challenge/js-pow-sha256/test/make-challenge.json " +
77 | "-make-challenge-out ./embed/challenge/js-pow-sha256/test/make-challenge-out.json " +
78 | "-verify-challenge ./embed/challenge/js-pow-sha256/test/verify-challenge-fail.json " +
79 | "-verify-challenge-out 1",
80 | ],
81 | },
82 | ]
83 | };
84 |
85 | local Publish(mirror, registry, repo, secret, go, alpine, os, arch, trigger, platforms, extra) = {
86 | kind: "pipeline",
87 | type: "docker",
88 | name: "publish-" + go + "-alpine" + alpine + "-" + secret,
89 | platform: {
90 | os: os,
91 | arch: arch,
92 | },
93 | trigger: trigger,
94 | steps: [
95 | {
96 | name: "setup-buildkitd",
97 | image: "alpine:" + alpine,
98 | mirror: mirror,
99 | commands: [
100 | "echo '[registry.\"docker.io\"]' > buildkitd.toml",
101 | "echo ' mirrors = [\"mirror.gcr.io\"]' >> buildkitd.toml"
102 | ],
103 | },
104 | {
105 | name: "docker",
106 | image: "plugins/buildx",
107 | privileged: true,
108 | environment: {
109 | DOCKER_BUILDKIT: "1",
110 | SOURCE_DATE_EPOCH: 0,
111 | TZ: "UTC",
112 | LC_ALL: "C",
113 | PLUGIN_BUILDER_CONFIG: "buildkitd.toml",
114 | PLUGIN_BUILDER_DRIVER: "docker-container",
115 | },
116 | settings: {
117 | registry: registry,
118 | repo: repo,
119 | mirror: mirror,
120 | compress: true,
121 | platform: platforms,
122 | build_args: {
123 | from_builder: "golang:" + go +"-alpine" + alpine,
124 | from: "alpine:" + alpine,
125 | },
126 | auto_tag_suffix: "alpine" + alpine,
127 | username: {
128 | from_secret: secret + "_username",
129 | },
130 | password: {
131 | from_secret: secret + "_password",
132 | },
133 | } + extra,
134 | },
135 | ]
136 | };
137 |
138 | #
139 | local containerArchitectures = ["linux/amd64", "linux/arm64", "linux/riscv64"];
140 |
141 | local alpineVersion = "3.21";
142 | local goVersion = "1.24";
143 |
144 | local mirror = "https://mirror.gcr.io";
145 |
146 | [
147 | Build(mirror, goVersion, alpineVersion, "linux", "amd64") + {"trigger": {event: ["push", "tag"], }},
148 | Build(mirror, goVersion, alpineVersion, "linux", "arm64") + {"trigger": {event: ["push", "tag"], }},
149 |
150 | # Test PRs
151 | Build(mirror, goVersion, alpineVersion, "linux", "amd64") + {"name": "test-pr", "trigger": {event: ["pull_request"], }},
152 |
153 | # latest
154 | 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"},
155 | Publish(mirror, "codeberg.org", "codeberg.org/gone/go-away", "codeberg", goVersion, alpineVersion, "linux", "amd64", {event: ["push"], branch: ["master"], }, containerArchitectures, {tags: ["latest"],}) + {name: "publish-latest-codeberg"},
156 | 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"},
157 |
158 | # modern
159 | 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,}),
160 | Publish(mirror, "codeberg.org", "codeberg.org/gone/go-away", "codeberg", goVersion, alpineVersion, "linux", "amd64", {event: ["promote", "tag"], target: ["production"], }, containerArchitectures, {auto_tag: true,}),
161 | Publish(mirror, "ghcr.io", "ghcr.io/weebdatahoarder/go-away", "github", goVersion, alpineVersion, "linux", "amd64", {event: ["promote", "tag"], target: ["production"], }, containerArchitectures, {auto_tag: true,}),
162 | ]
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /.bin
2 | *.gz
3 | *.br
4 | *.zst
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | ARG from_builder=docker.io/golang:1.24-alpine3.21
2 | ARG from=docker.io/alpine:3.21
3 |
4 | ARG BUILDPLATFORM
5 |
6 | FROM --platform=$BUILDPLATFORM ${from_builder} AS build
7 |
8 | ARG TARGETARCH
9 | ARG TARGETOS
10 | ARG GOTOOLCHAIN=local
11 |
12 | RUN apk update && apk add --no-cache \
13 | bash \
14 | git \
15 | zopfli brotli zstd
16 |
17 | ENV GOBIN="/go/bin"
18 |
19 | COPY . .
20 |
21 | RUN ./build-compress.sh
22 |
23 | ENV CGO_ENABLED=0
24 | ENV GOOS=${TARGETOS}
25 | ENV GOARCH=${TARGETARCH}
26 | ENV GOTOOLCHAIN=${GOTOOLCHAIN}
27 |
28 | RUN go build -pgo=auto -v -trimpath -ldflags=-buildid= -o "${GOBIN}/go-away" ./cmd/go-away
29 | RUN test -e "${GOBIN}/go-away"
30 |
31 |
32 | FROM --platform=$TARGETPLATFORM ${from}
33 |
34 | COPY --from=build /go/bin/go-away /bin/go-away
35 | COPY examples/snippets/ /snippets/
36 | COPY docker-entrypoint.sh /
37 |
38 | ENV TZ UTC
39 |
40 | ENV GOAWAY_METRICS_BIND=""
41 | ENV GOAWAY_DEBUG_BIND=""
42 |
43 | ENV GOAWAY_BIND=":8080"
44 | ENV GOAWAY_BIND_NETWORK="tcp"
45 | ENV GOAWAY_SOCKET_MODE="0770"
46 | ENV GOAWAY_CONFIG=""
47 | ENV GOAWAY_POLICY="/policy.yml"
48 | ENV GOAWAY_POLICY_SNIPPETS=""
49 | ENV GOAWAY_CHALLENGE_TEMPLATE="anubis"
50 | ENV GOAWAY_CHALLENGE_TEMPLATE_THEME=""
51 | ENV GOAWAY_CHALLENGE_TEMPLATE_LOGO=""
52 | ENV GOAWAY_SLOG_LEVEL="WARN"
53 | ENV GOAWAY_CLIENT_IP_HEADER=""
54 | ENV GOAWAY_BACKEND_IP_HEADER=""
55 | ENV GOAWAY_JWT_PRIVATE_KEY_SEED=""
56 | ENV GOAWAY_BACKEND=""
57 | ENV GOAWAY_ACME_AUTOCERT=""
58 | ENV GOAWAY_CACHE="/cache"
59 |
60 |
61 | EXPOSE 8080/tcp
62 | EXPOSE 8080/udp
63 | EXPOSE 9090/tcp
64 | EXPOSE 6060/tcp
65 |
66 | ENV JWT_PRIVATE_KEY_SEED="${GOAWAY_JWT_PRIVATE_KEY_SEED}"
67 |
68 | ENTRYPOINT ["/docker-entrypoint.sh"]
69 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) 2025 WeebDataHoarder, go-away Contributors
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a copy
4 | of this software and associated documentation files (the "Software"), to deal
5 | in the Software without restriction, including without limitation the rights
6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | copies of the Software, and to permit persons to whom the Software is
8 | furnished to do so, subject to the following conditions:
9 |
10 | The above copyright notice and this permission notice shall be included in all
11 | copies or substantial portions of the Software.
12 |
13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19 | SOFTWARE.
--------------------------------------------------------------------------------
/build-compress.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | set -e
4 | set -o pipefail
5 |
6 | cd "$(cd -P -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
7 |
8 | do_compress () {
9 | find "$1" \( -type f -name "*.wasm" -o -name "*.css" -o -name "*.js" -o -name "*.mjs" \) -exec zopfli {} \;
10 | find "$1" \( -type f -name "*.wasm" -o -name "*.css" -o -name "*.js" -o -name "*.mjs" \) -exec brotli -v -f -9 -o {}.br {} \;
11 | #find "$1" \( -type f -name "*.wasm" -o -name "*.css" -o -name "*.js" -o -name "*.mjs" \) -exec zstd -v -f -19 -o {}.zst {} \;
12 | }
13 |
14 | do_compress embed/challenge/
15 | do_compress embed/assets/
--------------------------------------------------------------------------------
/build-wasm.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | set -e
4 | set -o pipefail
5 |
6 | cd "$(cd -P -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
7 |
8 | mkdir -p .bin/ 2>/dev/null
9 |
10 | # Setup tinygo first
11 | if [[ ! -d .bin/tinygo ]]; then
12 | git clone --depth=1 --branch v0.37.0 https://github.com/tinygo-org/tinygo.git .bin/tinygo
13 | pushd .bin/tinygo
14 | git submodule update --init --recursive
15 |
16 | go mod download -x && go mod verify
17 |
18 | make binaryen STATIC=1
19 | make wasi-libc
20 |
21 | make llvm-source
22 | make llvm-build
23 |
24 | make build/release
25 | else
26 | pushd .bin/tinygo
27 | fi
28 |
29 | export TINYGOROOT="$(realpath ./build/release/tinygo/)"
30 | export PATH="$PATH:$(realpath ./build/release/tinygo/bin/)"
31 |
32 | popd
33 |
34 | go generate ./...
--------------------------------------------------------------------------------
/cmd/test-wasm-runtime/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "flag"
7 | "fmt"
8 | "git.gammaspectra.live/git/go-away/lib/challenge/wasm"
9 | "git.gammaspectra.live/git/go-away/lib/challenge/wasm/interface"
10 | "github.com/tetratelabs/wazero/api"
11 | "os"
12 | "reflect"
13 | "slices"
14 | )
15 |
16 | func main() {
17 |
18 | pathToTest := flag.String("wasm", "", "Path to test file")
19 | makeChallenge := flag.String("make-challenge", "", "Path to contents for MakeChallenge input")
20 | makeChallengeOutput := flag.String("make-challenge-out", "", "Path to contents for expected MakeChallenge output")
21 | verifyChallenge := flag.String("verify-challenge", "", "Path to contents for VerifyChallenge input")
22 | verifyChallengeOutput := flag.Uint64("verify-challenge-out", uint64(_interface.VerifyChallengeOutputOK), "Path to contents for expected VerifyChallenge output")
23 |
24 | flag.Parse()
25 |
26 | if *pathToTest == "" || *makeChallenge == "" || *makeChallengeOutput == "" || *verifyChallenge == "" {
27 | flag.PrintDefaults()
28 | os.Exit(1)
29 | }
30 |
31 | wasmData, err := os.ReadFile(*pathToTest)
32 | if err != nil {
33 | panic(err)
34 | }
35 |
36 | runner := wasm.NewRunner(true)
37 | defer runner.Close()
38 |
39 | err = runner.Compile("test", wasmData)
40 | if err != nil {
41 | panic(err)
42 | }
43 |
44 | makeData, err := os.ReadFile(*makeChallenge)
45 | if err != nil {
46 | panic(err)
47 | }
48 | var makeIn _interface.MakeChallengeInput
49 | err = json.Unmarshal(makeData, &makeIn)
50 | if err != nil {
51 | panic(err)
52 | }
53 |
54 | makeOutData, err := os.ReadFile(*makeChallengeOutput)
55 | if err != nil {
56 | panic(err)
57 | }
58 | var makeOut _interface.MakeChallengeOutput
59 | err = json.Unmarshal(makeOutData, &makeOut)
60 | if err != nil {
61 | panic(err)
62 | }
63 |
64 | verifyData, err := os.ReadFile(*verifyChallenge)
65 | if err != nil {
66 | panic(err)
67 | }
68 | var verifyIn _interface.VerifyChallengeInput
69 | err = json.Unmarshal(verifyData, &verifyIn)
70 | if err != nil {
71 | panic(err)
72 | }
73 |
74 | if slices.Compare(makeIn.Key, verifyIn.Key) != 0 {
75 | panic("challenge keys do not match")
76 | }
77 |
78 | err = runner.Instantiate("test", func(ctx context.Context, mod api.Module) error {
79 | out, err := wasm.MakeChallengeCall(ctx, mod, makeIn)
80 | if err != nil {
81 | return err
82 | }
83 |
84 | if !reflect.DeepEqual(*out, makeOut) {
85 | return fmt.Errorf("challenge output did not match expected output, got %v, expected %v", *out, makeOut)
86 | }
87 | return nil
88 | })
89 | if err != nil {
90 | panic(err)
91 | }
92 |
93 | err = runner.Instantiate("test", func(ctx context.Context, mod api.Module) error {
94 | out, err := wasm.VerifyChallengeCall(ctx, mod, verifyIn)
95 | if err != nil {
96 | return err
97 | }
98 |
99 | if out != _interface.VerifyChallengeOutput(*verifyChallengeOutput) {
100 | return fmt.Errorf("verify output did not match expected output, got %d expected %d", out, _interface.VerifyChallengeOutput(*verifyChallengeOutput))
101 | }
102 | return nil
103 | })
104 | if err != nil {
105 | panic(err)
106 | }
107 |
108 | }
109 |
--------------------------------------------------------------------------------
/docker-entrypoint.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | set -e
3 |
4 | if [ $# -eq 0 ] || [ "${1#-}" != "$1" ]; then
5 | set -- /bin/go-away \
6 | --bind "${GOAWAY_BIND}" --bind-network "${GOAWAY_BIND_NETWORK}" --socket-mode "${GOAWAY_SOCKET_MODE}" \
7 | --metrics-bind "${GOAWAY_METRICS_BIND}" --debug-bind "${GOAWAY_DEBUG_BIND}" \
8 | --config "${GOAWAY_CONFIG}" \
9 | --policy "${GOAWAY_POLICY}" --policy-snippets "/snippets" --policy-snippets "${GOAWAY_POLICY_SNIPPETS}" \
10 | --client-ip-header "${GOAWAY_CLIENT_IP_HEADER}" --backend-ip-header "${GOAWAY_BACKEND_IP_HEADER}" \
11 | --cache "${GOAWAY_CACHE}" \
12 | --challenge-template "${GOAWAY_CHALLENGE_TEMPLATE}" \
13 | --challenge-template-logo "${GOAWAY_CHALLENGE_TEMPLATE_LOGO}" \
14 | --challenge-template-theme "${GOAWAY_CHALLENGE_TEMPLATE_THEME}" \
15 | --slog-level "${GOAWAY_SLOG_LEVEL}" \
16 | --acme-autocert "${GOAWAY_ACME_AUTOCERT}" \
17 | --backend "${GOAWAY_BACKEND}" \
18 | "$@"
19 | fi
20 |
21 | if [ "$1" = "go-away" ]; then
22 | shift
23 | set -- /bin/go-away "$@"
24 | fi
25 |
26 | exec "$@"
27 |
--------------------------------------------------------------------------------
/embed/assets/static/anubis/geist.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/WeebDataHoarder/go-away/c16f0863aed613b4badc4d6028fb9ef5bd7d1a68/embed/assets/static/anubis/geist.woff2
--------------------------------------------------------------------------------
/embed/assets/static/anubis/iosevka-curly.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/WeebDataHoarder/go-away/c16f0863aed613b4badc4d6028fb9ef5bd7d1a68/embed/assets/static/anubis/iosevka-curly.woff2
--------------------------------------------------------------------------------
/embed/assets/static/anubis/podkova.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/WeebDataHoarder/go-away/c16f0863aed613b4badc4d6028fb9ef5bd7d1a68/embed/assets/static/anubis/podkova.woff2
--------------------------------------------------------------------------------
/embed/assets/static/anubis/style.css:
--------------------------------------------------------------------------------
1 | @font-face {
2 | font-family: "Geist";
3 | font-style: normal;
4 | font-weight: 100 900;
5 | font-display: swap;
6 | src: url("geist.woff2") format("woff2");
7 | }
8 |
9 | @font-face {
10 | font-family: "Podkova";
11 | font-style: normal;
12 | font-weight: 400 800;
13 | font-display: swap;
14 | src: url("podkova.woff2") format("woff2");
15 | }
16 |
17 | @font-face {
18 | font-family: "Iosevka Curly";
19 | font-style: monospace;
20 | font-display: swap;
21 | src: url("iosevka-curly.woff2") format("woff2");
22 | }
23 |
24 | main {
25 | font-family: Geist, sans-serif;
26 | max-width: 50rem;
27 | padding: 2rem;
28 | margin: auto;
29 | }
30 |
31 | ::selection {
32 | background: #d3869b;
33 | }
34 |
35 | body {
36 | background: #1d2021;
37 | color: #f9f5d7;
38 | }
39 |
40 | pre {
41 | background-color: #3c3836;
42 | padding: 1em;
43 | border: 0;
44 | font-family: Iosevka Curly Iaso, monospace;
45 | }
46 |
47 | a,
48 | a:active,
49 | a:visited {
50 | color: #b16286;
51 | background-color: #282828;
52 | }
53 |
54 | h1,
55 | h2,
56 | h3,
57 | h4,
58 | h5 {
59 | margin-bottom: 0.1rem;
60 | font-family: Podkova, serif;
61 | }
62 |
63 | blockquote {
64 | border-left: 1px solid #bdae93;
65 | margin: 0.5em 10px;
66 | padding: 0.5em 10px;
67 | }
68 |
69 | footer {
70 | text-align: center;
71 | }
72 |
73 | @media (prefers-color-scheme: light) {
74 | body {
75 | background: #f9f5d7;
76 | color: #1d2021;
77 | }
78 |
79 | pre {
80 | background-color: #ebdbb2;
81 | padding: 1em;
82 | border: 0;
83 | }
84 |
85 | a,
86 | a:active,
87 | a:visited {
88 | color: #b16286;
89 | background-color: #fbf1c7;
90 | }
91 |
92 | h1,
93 | h2,
94 | h3,
95 | h4,
96 | h5 {
97 | margin-bottom: 0.1rem;
98 | }
99 |
100 | blockquote {
101 | border-left: 1px solid #655c54;
102 | margin: 0.5em 10px;
103 | padding: 0.5em 10px;
104 | }
105 | }
106 |
107 | body,
108 | html {
109 | height: 100%;
110 | display: flex;
111 | justify-content: center;
112 | align-items: center;
113 | margin-left: auto;
114 | margin-right: auto;
115 | }
116 |
117 | .centered-div {
118 | text-align: center;
119 | }
--------------------------------------------------------------------------------
/embed/assets/static/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/WeebDataHoarder/go-away/c16f0863aed613b4badc4d6028fb9ef5bd7d1a68/embed/assets/static/logo.png
--------------------------------------------------------------------------------
/embed/challenge/js-pow-sha256/runtime/runtime.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "crypto/sha256"
5 | "crypto/subtle"
6 | "encoding/binary"
7 | "git.gammaspectra.live/git/go-away/lib/challenge/wasm/interface"
8 | "git.gammaspectra.live/git/go-away/utils/inline"
9 | "math/bits"
10 | "strconv"
11 | )
12 |
13 | //go:generate tinygo build -target wasip1 -buildmode=c-shared -opt=2 -scheduler=none -gc=leaking -no-debug -o runtime.wasm runtime.go
14 | func main() {
15 |
16 | }
17 |
18 | func getChallenge(key []byte, params map[string]string) ([]byte, uint64) {
19 | difficulty := uint64(20)
20 | var err error
21 | if diffStr, ok := params["difficulty"]; ok {
22 | difficulty, err = strconv.ParseUint(diffStr, 10, 64)
23 | if err != nil {
24 | panic(err)
25 | }
26 | }
27 | hasher := sha256.New()
28 | hasher.Write(binary.LittleEndian.AppendUint64(nil, difficulty))
29 | hasher.Write(key)
30 | return hasher.Sum(nil), difficulty
31 | }
32 |
33 | //go:wasmexport MakeChallenge
34 | func MakeChallenge(in _interface.Allocation) (out _interface.Allocation) {
35 | return _interface.MakeChallengeDecode(func(in _interface.MakeChallengeInput, out *_interface.MakeChallengeOutput) {
36 | c, difficulty := getChallenge(in.Key, in.Parameters)
37 |
38 | // create target
39 | target := make([]byte, len(c))
40 | nBits := difficulty
41 | for i := 0; i < len(target); i++ {
42 | var v uint8
43 | for j := 0; j < 8; j++ {
44 | v <<= 1
45 | if nBits == 0 {
46 | v |= 1
47 | } else {
48 | nBits--
49 | }
50 | }
51 | target[i] = v
52 | }
53 |
54 | dst := make([]byte, inline.EncodedLen(len(c)))
55 | dst = dst[:inline.Encode(dst, c)]
56 |
57 | targetDst := make([]byte, inline.EncodedLen(len(target)))
58 | targetDst = targetDst[:inline.Encode(targetDst, target)]
59 |
60 | out.Data = []byte("{\"challenge\": \"" + string(dst) + "\", \"target\": \"" + string(targetDst) + "\", \"difficulty\": " + strconv.FormatUint(difficulty, 10) + "}")
61 | out.Headers.Set("Content-Type", "application/json; charset=utf-8")
62 | }, in)
63 | }
64 |
65 | //go:wasmexport VerifyChallenge
66 | func VerifyChallenge(in _interface.Allocation) (out _interface.VerifyChallengeOutput) {
67 | return _interface.VerifyChallengeDecode(func(in _interface.VerifyChallengeInput) _interface.VerifyChallengeOutput {
68 | c, difficulty := getChallenge(in.Key, in.Parameters)
69 |
70 | result := make([]byte, inline.DecodedLen(len(in.Result)))
71 | n, err := inline.Decode(result, in.Result)
72 | if err != nil {
73 | return _interface.VerifyChallengeOutputError
74 | }
75 | result = result[:n]
76 |
77 | if len(result) < 8 {
78 | return _interface.VerifyChallengeOutputError
79 | }
80 |
81 | // verify we used same challenge
82 | if subtle.ConstantTimeCompare(result[:len(result)-8], c) != 1 {
83 | return _interface.VerifyChallengeOutputFailed
84 | }
85 |
86 | hash := sha256.Sum256(result)
87 |
88 | var leadingZeroesCount int
89 | for i := 0; i < len(hash); i++ {
90 | leadingZeroes := bits.LeadingZeros8(hash[i])
91 | leadingZeroesCount += leadingZeroes
92 | if leadingZeroes < 8 {
93 | break
94 | }
95 | }
96 |
97 | if leadingZeroesCount < int(difficulty) {
98 | return _interface.VerifyChallengeOutputFailed
99 | }
100 |
101 | return _interface.VerifyChallengeOutputOK
102 | }, in)
103 | }
104 |
--------------------------------------------------------------------------------
/embed/challenge/js-pow-sha256/runtime/runtime.wasm:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/WeebDataHoarder/go-away/c16f0863aed613b4badc4d6028fb9ef5bd7d1a68/embed/challenge/js-pow-sha256/runtime/runtime.wasm
--------------------------------------------------------------------------------
/embed/challenge/js-pow-sha256/static/load.mjs:
--------------------------------------------------------------------------------
1 | let _worker;
2 | let _webWorkerURL;
3 | let _challenge;
4 | let _target;
5 | let _difficulty;
6 |
7 | async function setup(config) {
8 | const { challenge, target, difficulty } = await fetch(config.Path + "/make-challenge", { method: "POST" })
9 | .then(r => {
10 | if (!r.ok) {
11 | throw new Error("Failed to fetch config");
12 | }
13 | return r.json();
14 | })
15 | .catch(err => {
16 | throw err;
17 | });
18 |
19 | _challenge = challenge;
20 | _target = target;
21 | _difficulty = difficulty;
22 |
23 | _webWorkerURL = URL.createObjectURL(new Blob([
24 | '(', processTask(challenge, difficulty), ')()'
25 | ], { type: 'application/javascript' }));
26 | _worker = new Worker(_webWorkerURL);
27 |
28 | return `Difficulty ${difficulty}`
29 | }
30 |
31 | function challenge() {
32 | return new Promise((resolve, reject) => {
33 | _worker.onmessage = (event) => {
34 | _worker.terminate();
35 | resolve(event.data);
36 | };
37 |
38 | _worker.onerror = (event) => {
39 | _worker.terminate();
40 | reject();
41 | };
42 |
43 | _worker.postMessage({
44 | challenge: _challenge,
45 | target: _target,
46 | difficulty: _difficulty,
47 | });
48 |
49 | URL.revokeObjectURL(_webWorkerURL);
50 | });
51 | }
52 |
53 | function processTask() {
54 | return function () {
55 |
56 | const decodeHex = (str) => {
57 | let result = new Uint8Array(str.length>>1)
58 | for (let i = 0; i < str.length; i += 2){
59 | result[i>>1] = parseInt(str.substring(i, i + 2), 16)
60 | }
61 |
62 | return result
63 | }
64 |
65 | const encodeHex = (buf) => {
66 | return buf.reduce((a, b) => a + b.toString(16).padStart(2, '0'), '')
67 | }
68 |
69 | const lessThan = (buf, target) => {
70 | for(let i = 0; i < buf.length; ++i){
71 | if (buf[i] < target[i]){
72 | return true;
73 | } else if (buf[i] > target[i]){
74 | return false;
75 | }
76 | }
77 |
78 | return false
79 | }
80 |
81 | const increment = (number) => {
82 | for ( let i = 0; i < number.length; i++ ) {
83 | if(number[i]===255){
84 | number[i] = 0;
85 | } else {
86 | number[i]++;
87 | break;
88 | }
89 | }
90 | }
91 |
92 | addEventListener('message', async (event) => {
93 | let data = decodeHex(event.data.challenge);
94 | let target = decodeHex(event.data.target);
95 |
96 | let nonce = new Uint8Array(8);
97 | let buf = new Uint8Array(data.length + nonce.length);
98 | buf.set(data, 0);
99 |
100 | while(true) {
101 | buf.set(nonce, data.length);
102 | let result = new Uint8Array(await crypto.subtle.digest("SHA-256", buf))
103 |
104 | if (lessThan(result, target)){
105 | const nonceNumber = Number(new BigUint64Array(nonce.buffer).at(0))
106 | postMessage({
107 | result: encodeHex(buf),
108 | info: `iterations ${nonceNumber}`,
109 | });
110 | return
111 | }
112 | increment(nonce)
113 | }
114 |
115 | });
116 | }.toString();
117 | }
118 |
119 | export { setup, challenge }
--------------------------------------------------------------------------------
/embed/challenge/js-pow-sha256/test/make-challenge-out.json:
--------------------------------------------------------------------------------
1 | {
2 | "Code": 200,
3 | "Data": "eyJjaGFsbGVuZ2UiOiAiMzc5NjRjZGI4NmM4YjRmZTI0YWU5NjU0Y2Q0MGIwZDZkNjgyOTZjZTg0NmY0NTc1YmE0MTRlNDFiMDdmMjg5MyIsICJ0YXJnZXQiOiAiMDAwMDBmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZiIsICJkaWZmaWN1bHR5IjogMjB9",
4 | "Error": "",
5 | "Headers": {
6 | "Content-Type": [
7 | "application/json; charset=utf-8"
8 | ]
9 | }
10 | }
--------------------------------------------------------------------------------
/embed/challenge/js-pow-sha256/test/make-challenge.json:
--------------------------------------------------------------------------------
1 | {
2 | "Data": "",
3 | "Headers": {
4 | "Accept": [
5 | "*/*"
6 | ],
7 | "Accept-Encoding": [
8 | "gzip, deflate, br, zstd"
9 | ],
10 | "Accept-Language": [
11 | "en-US,en;q=0.9"
12 | ],
13 | "Cache-Control": [
14 | "no-cache"
15 | ],
16 | "Connection": [
17 | "keep-alive"
18 | ],
19 | "Content-Length": [
20 | "0"
21 | ],
22 | "Dnt": [
23 | "1"
24 | ],
25 | "Origin": [
26 | "http://127.0.0.1:8787"
27 | ],
28 | "Pragma": [
29 | "no-cache"
30 | ],
31 | "Sec-Ch-Ua": [
32 | "\"Google Chrome\";v=\"135\", \"Not-A.Brand\";v=\"8\", \"Chromium\";v=\"135\""
33 | ],
34 | "Sec-Ch-Ua-Mobile": [
35 | "?0"
36 | ],
37 | "Sec-Ch-Ua-Platform": [
38 | "\"Linux\""
39 | ],
40 | "Sec-Fetch-Dest": [
41 | "empty"
42 | ],
43 | "Sec-Fetch-Mode": [
44 | "cors"
45 | ],
46 | "Sec-Fetch-Site": [
47 | "same-origin"
48 | ],
49 | "User-Agent": [
50 | "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36"
51 | ],
52 | "X-Away-Id": [
53 | "cf33e115f699c50822c4e56ed3c610dc"
54 | ]
55 | },
56 | "Key": "Pl02g55pPapXdVc3SVfMZQGymmyE0dTCpq0qm8ax9ss=",
57 | "Parameters": {
58 | "difficulty": "20"
59 | }
60 | }
--------------------------------------------------------------------------------
/embed/challenge/js-pow-sha256/test/verify-challenge-fail.json:
--------------------------------------------------------------------------------
1 | {
2 | "Key":"Pl02g55pPapXdVc3SVfMZQGymmyE0dTCpq0qm8ax9ss=",
3 | "Parameters":{
4 | "difficulty":"20"
5 | },
6 | "Result":"Mzc5NjRjZGI4NmM4YjRmZTI0YWU5NjU0Y2Q0MGIwZDZkNjgyOTZjZTg0NmY0NTc1YmE0MTRlNDFiMDdmMjg5MzY4Y2QxMTAwMDAwMDAwMDE="
7 | }
--------------------------------------------------------------------------------
/embed/challenge/js-pow-sha256/test/verify-challenge.json:
--------------------------------------------------------------------------------
1 | {
2 | "Key":"Pl02g55pPapXdVc3SVfMZQGymmyE0dTCpq0qm8ax9ss=",
3 | "Parameters":{
4 | "difficulty":"20"
5 | },
6 | "Result":"Mzc5NjRjZGI4NmM4YjRmZTI0YWU5NjU0Y2Q0MGIwZDZkNjgyOTZjZTg0NmY0NTc1YmE0MTRlNDFiMDdmMjg5MzY4Y2QxMTAwMDAwMDAwMDA="
7 | }
--------------------------------------------------------------------------------
/embed/embed.go:
--------------------------------------------------------------------------------
1 | package embed
2 |
3 | import (
4 | "embed"
5 | "errors"
6 | "io/fs"
7 | "os"
8 | )
9 |
10 | //go:embed assets
11 | var assetsFs embed.FS
12 |
13 | //go:embed challenge
14 | var challengeFs embed.FS
15 |
16 | //go:embed templates
17 | var templatesFs embed.FS
18 |
19 | type FSInterface interface {
20 | fs.FS
21 | fs.ReadDirFS
22 | fs.ReadFileFS
23 | }
24 |
25 | func trimPrefix(embedFS embed.FS, prefix string) FSInterface {
26 | subFS, err := fs.Sub(embedFS, prefix)
27 | if err != nil {
28 | panic(err)
29 | }
30 | if properFS, ok := subFS.(FSInterface); ok {
31 | return properFS
32 | } else {
33 | panic("unsupported")
34 | }
35 | }
36 |
37 | var ChallengeFs = trimPrefix(challengeFs, "challenge")
38 |
39 | var TemplatesFs = trimPrefix(templatesFs, "templates")
40 | var AssetsFs = trimPrefix(assetsFs, "assets")
41 |
42 | func GetFallbackFS(embedFS FSInterface, prefix string) (FSInterface, error) {
43 | var outFs fs.FS
44 | if stat, err := os.Stat(prefix); err == nil && stat.IsDir() {
45 | outFs = embedFS
46 | } else if _, err := embedFS.ReadDir(prefix); err == nil {
47 | outFs, err = fs.Sub(embedFS, prefix)
48 | if err != nil {
49 | return nil, err
50 | }
51 | } else {
52 | return nil, err
53 | }
54 | if properFS, ok := outFs.(FSInterface); ok {
55 | return properFS, nil
56 | } else {
57 | return nil, errors.New("unsupported FS")
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/embed/templates/challenge-anubis.gohtml:
--------------------------------------------------------------------------------
1 |
2 | {{$logo := print .Path "/assets/static/logo.png?cacheBust=" .Random }}{{ if .Logo }}{{$logo = .Logo}}{{ end }}
3 |
4 |
5 | {{ .Title }}
6 |
7 |
8 |
9 | {{ range .MetaTags }}
10 |
11 | {{ end }}
12 | {{ range .LinkTags }}
13 |
14 | {{ end }}
15 | {{ range .HeaderTags }}
16 | {{ . }}
17 | {{ end }}
18 |
19 |
20 |
21 |
22 | {{ .Title }}
23 |
24 |
25 |
26 |
31 | {{if .Challenge }}
32 |
{{ .Strings.Get "status_loading_challenge" }} {{ .Challenge }} ...
33 | {{else if .Error}}
34 |
{{ .Strings.Get "status_error" }} {{ .Error }}
35 | {{else}}
36 |
{{ .Strings.Get "status_loading" }}
37 | {{end}}
38 |
39 | {{ .Strings.Get "details_title" }}
40 |
41 | {{.Strings.Get "details_text"}}
42 |
43 |
44 | {{if .Redirect }}
45 |
{{ .Strings.Get "button_refresh_page" }}
46 | {{end}}
47 |
48 | {{if .EndTags }}
49 |
50 | {{ .Strings.Get "noscript_warning" }}
51 |
52 | {{end}}
53 |
54 |
{{ .Strings.Get "details_contact_admin_with_request_id" }}: {{ .Id }}
55 |
56 |
57 |
58 |
69 |
70 |
71 | {{ range .EndTags }}
72 | {{ . }}
73 | {{ end }}
74 |
75 |
76 |
--------------------------------------------------------------------------------
/embed/templates/challenge-forgejo.gohtml:
--------------------------------------------------------------------------------
1 |
2 | {{$theme := "forgejo-auto"}}{{ if .Theme }}{{$theme = .Theme}}{{ end }}
3 | {{$logo := "/assets/img/logo.png"}}{{ if .Logo }}{{$logo = .Logo}}{{ end }}
4 |
5 |
6 |
7 |
8 | {{ .Title }}
9 |
10 | {{ range .MetaTags }}
11 |
12 | {{ end }}
13 | {{ range .LinkTags }}
14 |
15 | {{ end }}
16 | {{ range .HeaderTags }}
17 | {{ . }}
18 | {{ end }}
19 |
20 |
21 |
22 |
23 |
24 |
25 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
58 |
59 | {{if .Challenge }}
60 |
{{ .Strings.Get "status_loading_challenge" }} {{ .Challenge }} ...
61 | {{else if .Error}}
62 |
{{ .Strings.Get "status_error" }} {{ .Error }}
63 | {{else}}
64 |
{{ .Strings.Get "status_loading" }}
65 | {{end}}
66 |
67 |
68 | {{ .Strings.Get "details_title" }}
69 |
70 | {{.Strings.Get "details_text"}}
71 |
72 |
73 | {{if .Redirect }}
74 |
77 | {{end}}
78 |
79 | {{if .EndTags }}
80 |
81 | {{ .Strings.Get "noscript_warning" }}
82 |
83 | {{end}}
84 |
85 |
{{ .Strings.Get "details_contact_admin_with_request_id" }}: {{ .Id }}
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
106 |
107 | {{ range .EndTags }}
108 | {{ . }}
109 | {{ end }}
110 |
111 |
112 |
113 |
--------------------------------------------------------------------------------
/examples/config.yml:
--------------------------------------------------------------------------------
1 | # Configuration file
2 | # Parameters that exist both on config and cmdline will have cmdline as preference
3 |
4 | bind:
5 | #address: ":8080"
6 | #network: "tcp"
7 | #socket-mode": "0770"
8 |
9 | # Enable PROXY mode on this listener, to allow passing origin info. Default false
10 | #proxy: true
11 |
12 | # Enable passthrough mode, which will allow traffic onto the backends while rules load. Default false
13 | #passthrough: true
14 |
15 | # Enable TLS on this listener and obtain certificates via an ACME directory URL, or letsencrypt
16 | #tls-acme-autocert: "letsencrypt"
17 |
18 | # Enable TLS on this listener and obtain certificates via a certificate and key file on disk
19 | # Only set one of tls-acme-autocert or tls-certificate+tls-key
20 | #tls-certificate: ""
21 | #tls-key: ""
22 |
23 | # Bind the Go debug port
24 | #bind-debug: ":6060"
25 |
26 | # Bind the Prometheus metrics onto /metrics path on this port
27 | #bind-metrics: ":9090"
28 |
29 | # These links will be shown on the presented challenge or error pages
30 | links:
31 | #- name: Privacy
32 | # url: "/privacy.html"
33 |
34 | #- name: Contact
35 | # url: "mailto:admin@example.com"
36 |
37 | #- name: Donations
38 | # url: "https://donations.example.com/abcd"
39 |
40 | # HTML Template to use for challenge or error pages
41 | # External templates can be included by providing a disk path
42 | # Bundled templates:
43 | # anubis: An Anubis-like template with no configuration parameters. Supports Logo.
44 | # forgejo: Looks like native Forgejo. Includes logos and resources from your instance. Supports Theme, Logo.
45 | #
46 | #challenge-template: "anubis"
47 |
48 | # Allows overriding specific settings set on templates. Key-Values will be passed to templates as-is
49 | challenge-template-overrides:
50 | # Set template theme if supported
51 | #Theme: "forgejo-auto"
52 | # Set logo on template if supported
53 | #Logo: "/my/custom/logo/path.png"
54 |
55 | # Advanced backend configuration
56 | # Backends setup via cmdline will be added here
57 | backends:
58 | # Example HTTP backend and setting client ip header
59 | #"git.example.com":
60 | # url: "http://forgejo:3000"
61 | # ip-header: "X-Client-Ip"
62 |
63 | # Example HTTP backend matching a non-standard port in Host
64 | # Standard ports are 80 and 443. Others will be sent in Host by browsers
65 | #"git.example.com:8080":
66 | # url: "http://forgejo:3000"
67 | # ip-header: "X-Client-Ip"
68 |
69 |
70 | # Example HTTPS backend with host/SNI override, HTTP/2 and no certificate verification
71 | #"ssl.example.com":
72 | # url: "https://127.0.0.1:8443"
73 | # host: ssl.example.com
74 | # http2-enabled: true
75 | # tls-skip-verify: true
76 |
77 | # Example HTTPS transparent backend with host/SNI override, HTTP/2, and subdirectory
78 | #"ssl.example.com":
79 | # url: "https://ssl.example.com/subdirectory/"
80 | # host: ssl.example.com
81 | # http2-enabled: true
82 | # ip-header: "-"
83 | # transparent: true
84 |
85 | # List of strings you can replace to alter the presentation on challenge/error templates
86 | # Can use other languages.
87 | # Note raw HTML is allowed, be careful with it.
88 | # Default strings exist in code, uncomment any to set it
89 | strings:
90 | #title_challenge: "Checking you are not a bot"
91 | #title_error: "Oh no!"
92 | #noscript_warning: "Sadly, you may need to enable JavaScript to get past this challenge. This is required because AI companies have changed the social contract around how website hosting works.
"
93 | #details_title: "Why am I seeing this?"
94 | #details_text: >
95 | #
96 | # You are seeing this because the administrator of this website has set up go-away
97 | # to protect the server against the scourge of AI companies aggressively scraping websites .
98 | #
99 | #
100 | # Mass scraping can and does cause downtime for the websites, which makes their resources inaccessible for everyone.
101 | #
102 | #
103 | # Please note that some challenges requires the use of modern JavaScript features and some plugins may disable these.
104 | # Disable such plugins for this domain (for example, JShelter) if you encounter any issues.
105 | #
106 |
107 | #details_contact_admin_with_request_id: "If you have any issues contact the site administrator and provide the following Request Id"
108 |
109 | #button_refresh_page: "Refresh page"
110 |
111 | #status_loading_challenge: "Loading challenge"
112 | #status_starting_challenge: "Starting challenge"
113 | #status_loading: "Loading..."
114 | #status_calculating: "Calculating..."
115 | #status_challenge_success: "Challenge success!"
116 | #status_challenge_done_took: "Done! Took"
117 | #status_error: "Error:"
--------------------------------------------------------------------------------
/examples/generic.yml:
--------------------------------------------------------------------------------
1 | # Example cmdline (forward requests from upstream to port :8080)
2 | # $ go-away --bind :8080 --backend site.example.com=http://site:3000 --policy examples/generic.yml --policy-snippets example/snippets/ --challenge-template anubis
3 |
4 |
5 |
6 | # Define networks to be used later below
7 | networks:
8 | # Networks will get included from snippets
9 |
10 |
11 | challenges:
12 | # Challenges will get included from snippets
13 |
14 | conditions:
15 | # Conditions will get replaced on rules AST when found as ($condition-name)
16 |
17 | # Conditions will get included from snippets
18 |
19 |
20 | is-static-asset:
21 | - 'path == "/apple-touch-icon.png"'
22 | - 'path == "/apple-touch-icon-precomposed.png"'
23 | - 'path.matches("\\.(manifest|ttf|woff|woff2|jpg|jpeg|gif|png|webp|avif|svg|mp4|webm|css|js|mjs|wasm)$")'
24 |
25 | is-suspicious-crawler:
26 | - 'userAgent.contains("Presto/") || userAgent.contains("Trident/")'
27 | # Old IE browsers
28 | - 'userAgent.matches("MSIE ([2-9]|10|11)\\.")'
29 | # Old Linux browsers
30 | - 'userAgent.matches("Linux i[63]86") || userAgent.matches("FreeBSD i[63]86")'
31 | # Old Windows browsers
32 | - 'userAgent.matches("Windows (3|95|98|CE)") || userAgent.matches("Windows NT [1-5]\\.")'
33 | # Old mobile browsers
34 | - 'userAgent.matches("Android [1-5]\\.") || userAgent.matches("(iPad|iPhone) OS [1-9]_")'
35 | # Old generic browsers
36 | - 'userAgent.startsWith("Opera/")'
37 | #- 'userAgent.matches("Gecko/(201[0-9]|200[0-9])")'
38 | - 'userAgent.matches("^Mozilla/[1-4]")'
39 |
40 |
41 | # Rules are checked sequentially in order, from top to bottom
42 | rules:
43 | - name: allow-well-known-resources
44 | conditions:
45 | - '($is-well-known-asset)'
46 | action: pass
47 |
48 | - name: allow-static-resources
49 | conditions:
50 | - '($is-static-asset)'
51 | action: pass
52 |
53 | - name: desired-crawlers
54 | conditions:
55 | - *is-bot-googlebot
56 | - *is-bot-bingbot
57 | - *is-bot-duckduckbot
58 | - *is-bot-kagibot
59 | - *is-bot-qwantbot
60 | - *is-bot-yandexbot
61 | action: pass
62 |
63 | # Matches private networks and localhost.
64 | # Uncomment this if you want to let your own tools this way
65 | # - name: allow-private-networks
66 | # conditions:
67 | # # Allows localhost and private networks CIDR
68 | # - *is-network-localhost
69 | # - *is-network-private
70 | # action: pass
71 |
72 | - name: undesired-crawlers
73 | conditions:
74 | - '($is-headless-chromium)'
75 | - 'userAgent.startsWith("Lightpanda/")'
76 | - 'userAgent.startsWith("masscan/")'
77 | # Typo'd opera botnet
78 | - 'userAgent.matches("^Opera/[0-9.]+\\.\\(")'
79 | # AI bullshit stuff, they do not respect robots.txt even while they read it
80 | # TikTok Bytedance AI training
81 | - 'userAgent.contains("Bytedance") || userAgent.contains("Bytespider") || userAgent.contains("TikTokSpider")'
82 | # Meta AI training; The Meta-ExternalAgent crawler crawls the web for use cases such as training AI models or improving products by indexing content directly.
83 | - 'userAgent.contains("meta-externalagent/") || userAgent.contains("meta-externalfetcher/") || userAgent.contains("FacebookBot")'
84 | # Anthropic AI training and usage
85 | - 'userAgent.contains("ClaudeBot") || userAgent.contains("Claude-User")|| userAgent.contains("Claude-SearchBot")'
86 | # Common Crawl AI crawlers
87 | - 'userAgent.contains("CCBot")'
88 | # ChatGPT AI crawlers https://platform.openai.com/docs/bots
89 | - 'userAgent.contains("GPTBot") || userAgent.contains("OAI-SearchBot") || userAgent.contains("ChatGPT-User")'
90 | # Other AI crawlers
91 | - '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")'
92 | # SEO / Ads and marketing
93 | - 'userAgent.contains("BLEXBot")'
94 | action: drop
95 |
96 | - name: unknown-crawlers
97 | conditions:
98 | # No user agent set
99 | - 'userAgent == ""'
100 | action: deny
101 |
102 | # check a sequence of challenges
103 | - name: suspicious-crawlers
104 | conditions: ['($is-suspicious-crawler)']
105 | action: none
106 | children:
107 | - name: 0
108 | action: check
109 | settings:
110 | challenges: [js-refresh]
111 | - name: 1
112 | action: check
113 | settings:
114 | challenges: [preload-link, resource-load]
115 | - name: 2
116 | action: check
117 | settings:
118 | challenges: [header-refresh]
119 |
120 | - name: homesite
121 | conditions:
122 | - 'path == "/"'
123 | action: pass
124 |
125 | # check DNSBL and serve harder challenges
126 | # todo: make this specific to score
127 | - name: undesired-dnsbl
128 | action: check
129 | settings:
130 | challenges: [dnsbl]
131 | # if DNSBL fails, check additional challenges
132 | fail: check
133 | fail-settings:
134 | challenges: [js-refresh]
135 |
136 | - name: suspicious-fetchers
137 | action: check
138 | settings:
139 | challenges: [js-refresh]
140 | conditions:
141 | - 'userAgent.contains("facebookexternalhit/") || userAgent.contains("facebookcatalog/")'
142 |
143 | # Allow PUT/DELETE/PATCH/POST requests in general
144 | - name: non-get-request
145 | action: pass
146 | conditions:
147 | - '!(method == "HEAD" || method == "GET")'
148 |
149 | # Enable fetching OpenGraph and other tags from backend on these paths
150 | - name: enable-meta-tags
151 | action: context
152 | settings:
153 | context-set:
154 | # Map OpenGraph or similar tags back to the reply, even if denied/challenged
155 | proxy-meta-tags: "true"
156 |
157 | # Set additional response headers
158 | #response-headers:
159 | # X-Clacks-Overhead:
160 | # - GNU Terry Pratchett
161 |
162 | - name: plaintext-browser
163 | action: challenge
164 | settings:
165 | challenges: [meta-refresh, cookie]
166 | conditions:
167 | - 'userAgent.startsWith("Lynx/")'
168 |
169 | # Uncomment this rule out to challenge tool-like user agents
170 | #- name: standard-tools
171 | # action: challenge
172 | # settings:
173 | # challenges: [cookie]
174 | # conditions:
175 | # - '($is-generic-robot-ua)'
176 | # - '($is-tool-ua)'
177 | # - '!($is-generic-browser)'
178 |
179 | - name: standard-browser
180 | action: challenge
181 | settings:
182 | challenges: [preload-link, meta-refresh, resource-load, js-refresh]
183 | conditions:
184 | - '($is-generic-browser)'
185 |
186 | # If end of rules is reached, default is PASS
187 |
--------------------------------------------------------------------------------
/examples/snippets/bot-betterstack.yml:
--------------------------------------------------------------------------------
1 | networks:
2 | betterstack:
3 | - url: https://uptime.betterstack.com/ips-by-cluster.json
4 | jq-path: '.[] | .[]'
5 |
6 | conditions:
7 | is-bot-betterstack:
8 | - &is-bot-betterstack '((userAgent.startsWith("Better Stack Better Uptime Bot") || userAgent.startsWith("Better Uptime Bot") || userAgent == "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.17 Safari/537.36")) && remoteAddress.network("betterstack")'
--------------------------------------------------------------------------------
/examples/snippets/bot-bingbot.yml:
--------------------------------------------------------------------------------
1 | networks:
2 | bingbot:
3 | - url: https://www.bing.com/toolbox/bingbot.json
4 | jq-path: '(.prefixes[] | select(has("ipv4Prefix")) | .ipv4Prefix), (.prefixes[] | select(has("ipv6Prefix")) | .ipv6Prefix)'
5 |
6 | conditions:
7 | is-bot-bingbot:
8 | - &is-bot-bingbot 'userAgent.contains("+http://www.bing.com/bingbot.htm") && remoteAddress.network("bingbot")'
--------------------------------------------------------------------------------
/examples/snippets/bot-duckduckbot.yml:
--------------------------------------------------------------------------------
1 | networks:
2 | duckduckbot:
3 | - url: https://duckduckgo.com/duckduckgo-help-pages/results/duckduckbot
4 | regex: "(?P
[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+) "
5 |
6 | conditions:
7 | is-bot-duckduckbot:
8 | - &is-bot-duckduckbot 'userAgent.contains("+http://duckduckgo.com/duckduckbot.html") && remoteAddress.network("duckduckbot")'
--------------------------------------------------------------------------------
/examples/snippets/bot-googlebot.yml:
--------------------------------------------------------------------------------
1 | networks:
2 | googlebot:
3 | - url: https://developers.google.com/static/search/apis/ipranges/googlebot.json
4 | jq-path: '(.prefixes[] | select(has("ipv4Prefix")) | .ipv4Prefix), (.prefixes[] | select(has("ipv6Prefix")) | .ipv6Prefix)'
5 |
6 | conditions:
7 | is-bot-googlebot:
8 | - &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")'
--------------------------------------------------------------------------------
/examples/snippets/bot-kagibot.yml:
--------------------------------------------------------------------------------
1 | networks:
2 | kagibot:
3 | - url: https://kagi.com/bot
4 | regex: "\\n(?P[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+) "
5 |
6 | conditions:
7 | is-bot-kagibot:
8 | - &is-bot-kagibot 'userAgent.contains("+https://kagi.com/bot") && remoteAddress.network("kagibot")'
--------------------------------------------------------------------------------
/examples/snippets/bot-qwantbot.yml:
--------------------------------------------------------------------------------
1 | networks:
2 | qwantbot:
3 | - url: https://help.qwant.com/wp-content/uploads/sites/2/2025/01/qwantbot.json
4 | jq-path: '(.prefixes[] | select(has("ipv4Prefix")) | .ipv4Prefix), (.prefixes[] | select(has("ipv6Prefix")) | .ipv6Prefix)'
5 |
6 | conditions:
7 | is-bot-qwantbot:
8 | - &is-bot-qwantbot 'userAgent.contains("+https://help.qwant.com/bot/") && remoteAddress.network("qwantbot")'
--------------------------------------------------------------------------------
/examples/snippets/bot-uptimerobot.yml:
--------------------------------------------------------------------------------
1 | networks:
2 | uptimerobot:
3 | - url: https://uptimerobot.com/inc/files/ips/IPv4andIPv6.txt
4 | regex: "(?P[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+(/[0-9]+)?|[0-9a-f:]+:.+)"
5 |
6 | conditions:
7 | is-bot-uptimerobot:
8 | - &is-bot-uptimerobot 'userAgent.contains("http://www.uptimerobot.com/") && remoteAddress.network("uptimerobot")'
9 |
--------------------------------------------------------------------------------
/examples/snippets/bot-yandexbot.yml:
--------------------------------------------------------------------------------
1 | networks:
2 | yandexbot:
3 | # todo: detected as bot
4 | # - url: https://yandex.com/ips
5 | # regex: "(?P(([0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+)|([0-9a-f:]+::))/[0-9]+)[ \\\\t]* "
6 | - prefixes:
7 | - "5.45.192.0/18"
8 | - "5.255.192.0/18"
9 | - "37.9.64.0/18"
10 | - "37.140.128.0/18"
11 | - "77.88.0.0/18"
12 | - "84.252.160.0/19"
13 | - "87.250.224.0/19"
14 | - "90.156.176.0/22"
15 | - "93.158.128.0/18"
16 | - "95.108.128.0/17"
17 | - "141.8.128.0/18"
18 | - "178.154.128.0/18"
19 | - "185.32.187.0/24"
20 | - "2a02:6b8::/29"
21 |
22 | conditions:
23 | is-bot-yandexbot:
24 | - &is-bot-yandexbot 'userAgent.contains("+http://yandex.com/bots") && remoteAddress.network("yandexbot")'
--------------------------------------------------------------------------------
/examples/snippets/challenge-dnsbl.yml:
--------------------------------------------------------------------------------
1 | challenges:
2 | dnsbl:
3 | runtime: dnsbl
4 | parameters:
5 | dnsbl-decay: 1h
6 | dnsbl-timeout: 1s
--------------------------------------------------------------------------------
/examples/snippets/challenge-js-pow-sha256.yml:
--------------------------------------------------------------------------------
1 | challenges:
2 | js-pow-sha256:
3 | runtime: js
4 | parameters:
5 | # specifies the folder path that assets are under
6 | # can be either embedded or external path
7 | # defaults to name of challenge
8 | path: "js-pow-sha256"
9 | # needs to be under static folder
10 | js-loader: load.mjs
11 | # needs to be under runtime folder
12 | wasm-runtime: runtime.wasm
13 | wasm-runtime-settings:
14 | difficulty: 20
15 | verify-probability: 0.02
--------------------------------------------------------------------------------
/examples/snippets/challenge-js-refresh.yml:
--------------------------------------------------------------------------------
1 | challenges:
2 | js-refresh:
3 | # Challenges with a redirect via window.location (requires HTML parsing and JavaScript logic)
4 | runtime: "refresh"
5 | parameters:
6 | refresh-via: "javascript"
--------------------------------------------------------------------------------
/examples/snippets/challenges-non-js.yml:
--------------------------------------------------------------------------------
1 | challenges:
2 | # Challenges with a cookie, self redirect (non-JS, requires HTTP parsing)
3 | cookie:
4 | runtime: "cookie"
5 |
6 | # Challenges with a redirect via Link header with rel=preload and early hints (non-JS, requires HTTP parsing, fetching and logic)
7 | # Works on HTTP/2 and above!
8 | preload-link:
9 | condition: '"Sec-Fetch-Mode" in headers && headers["Sec-Fetch-Mode"] == "navigate"'
10 | runtime: "preload-link"
11 | parameters:
12 | preload-early-hint-deadline: 2s
13 |
14 | # Challenges with a redirect via Refresh header (non-JS, requires HTTP parsing and logic)
15 | header-refresh:
16 | runtime: "refresh"
17 | parameters:
18 | refresh-via: "header"
19 |
20 | # Challenges with a redirect via Refresh meta (non-JS, requires HTML parsing and logic)
21 | meta-refresh:
22 | runtime: "refresh"
23 | parameters:
24 | refresh-via: "meta"
25 |
26 | # Challenges with loading a random CSS or image document (non-JS, requires HTML parsing and logic)
27 | resource-load:
28 | runtime: "resource-load"
--------------------------------------------------------------------------------
/examples/snippets/conditions-generic.yml:
--------------------------------------------------------------------------------
1 | conditions:
2 | is-well-known-asset:
3 | # general txt files or scraper
4 | - 'path == "/robots.txt" || path == "/security.txt"'
5 |
6 | # ads txt files
7 | - 'path == "/app-ads.txt" || path == "/ads.txt"'
8 |
9 | # generally requested by browsers
10 | - 'path == "/favicon.ico"'
11 |
12 | # used by some applications
13 | - 'path == "/crossdomain.xml"'
14 |
15 | # well-known paths
16 | - 'path.startsWith("/.well-known/")'
17 |
18 | is-git-ua:
19 | - 'userAgent.startsWith("git/") || userAgent.contains("libgit")'
20 | - 'userAgent.startsWith("go-git")'
21 | - 'userAgent.startsWith("JGit/") || userAgent.startsWith("JGit-")'
22 | # Golang proxy and initial fetch
23 | - 'userAgent.startsWith("GoModuleMirror/")'
24 | - 'userAgent.startsWith("Go-http-client/") && "go-get" in query && query["go-get"] == "1"'
25 | - '"Git-Protocol" in headers && headers["Git-Protocol"] == "version=2"'
26 |
27 | is-generic-browser:
28 | - 'userAgent.startsWith("Mozilla/") || userAgent.startsWith("Opera/")'
29 |
30 | is-generic-robot-ua:
31 | - 'userAgent.matches("compatible[;)]") && !userAgent.contains("Trident/")'
32 | - 'userAgent.matches("\\+https?://")'
33 | - 'userAgent.contains("@")'
34 | - 'userAgent.matches("[bB]ot/[0-9]")'
35 |
36 | is-tool-ua:
37 | - 'userAgent.startsWith("python-requests/")'
38 | - 'userAgent.startsWith("Python-urllib/")'
39 | - 'userAgent.startsWith("python-httpx/")'
40 | - 'userAgent.contains("aoihttp/")'
41 | - 'userAgent.startsWith("http.rb/")'
42 | - 'userAgent.startsWith("curl/")'
43 | - 'userAgent.startsWith("Wget/")'
44 | - 'userAgent.startsWith("libcurl/")'
45 | - 'userAgent.startsWith("okhttp/")'
46 | - 'userAgent.startsWith("Java/")'
47 | - 'userAgent.startsWith("Apache-HttpClient//")'
48 | - 'userAgent.startsWith("Go-http-client/")'
49 | - 'userAgent.startsWith("node-fetch/")'
50 | - 'userAgent.startsWith("reqwest/")'
51 |
52 | # Checks to detect a headless chromium via headers only
53 | is-headless-chromium:
54 | - 'userAgent.contains("HeadlessChrome") || userAgent.contains("HeadlessChromium")'
55 | - '"Sec-Ch-Ua" in headers && (headers["Sec-Ch-Ua"].contains("HeadlessChrome") || headers["Sec-Ch-Ua"].contains("HeadlessChromium"))'
56 | #- '(userAgent.contains("Chrome/") || userAgent.contains("Chromium/")) && (!("Accept-Language" in headers) || !("Accept-Encoding" in headers))'
--------------------------------------------------------------------------------
/examples/snippets/networks-other.yml:
--------------------------------------------------------------------------------
1 | networks:
2 | aws-cloud:
3 | - url: https://ip-ranges.amazonaws.com/ip-ranges.json
4 | jq-path: '(.prefixes[] | select(has("ip_prefix")) | .ip_prefix), (.prefixes[] | select(has("ipv6_prefix")) | .ipv6_prefix)'
5 | google-cloud:
6 | - url: https://www.gstatic.com/ipranges/cloud.json
7 | jq-path: '(.prefixes[] | select(has("ipv4Prefix")) | .ipv4Prefix), (.prefixes[] | select(has("ipv6Prefix")) | .ipv6Prefix)'
8 | oracle-cloud:
9 | - url: https://docs.oracle.com/en-us/iaas/tools/public_ip_ranges.json
10 | jq-path: '.regions[] | .cidrs[] | .cidr'
11 | azure-cloud:
12 | # todo: https://www.microsoft.com/en-us/download/details.aspx?id=56519 does not provide direct JSON
13 | - url: https://raw.githubusercontent.com/femueller/cloud-ip-ranges/refs/heads/master/microsoft-azure-ip-ranges.json
14 | jq-path: '.values[] | .properties.addressPrefixes[]'
15 |
16 | digitalocean:
17 | - url: https://www.digitalocean.com/geo/google.csv
18 | regex: "(?P(([0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+)|([0-9a-f:]+::))/[0-9]+),"
19 | linode:
20 | - url: https://geoip.linode.com/
21 | regex: "(?P(([0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+)|([0-9a-f:]+::))/[0-9]+),"
22 | vultr:
23 | - url: "https://geofeed.constant.com/?json"
24 | jq-path: '.subnets[] | .ip_prefix'
25 | cloudflare:
26 | - url: https://www.cloudflare.com/ips-v4
27 | regex: "(?P[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+/[0-9]+)"
28 | - url: https://www.cloudflare.com/ips-v6
29 | regex: "(?P[0-9a-f:]+::/[0-9]+)"
30 |
31 | icloud-private-relay:
32 | - url: https://mask-api.icloud.com/egress-ip-ranges.csv
33 | regex: "(?P(([0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+)|([0-9a-f:]+::))/[0-9]+),"
34 | tunnelbroker-relay:
35 | # HE Tunnelbroker
36 | - url: https://tunnelbroker.net/export/google
37 | regex: "(?P([0-9a-f:]+::)/[0-9]+),"
38 |
--------------------------------------------------------------------------------
/examples/snippets/networks-private.yml:
--------------------------------------------------------------------------------
1 | networks:
2 | localhost:
3 | # localhost and loopback addresses
4 | - prefixes:
5 | - "127.0.0.0/8"
6 | - "::1/128"
7 | private:
8 | # Private network CIDR blocks
9 | - prefixes:
10 | # private networks
11 | - "10.0.0.0/8"
12 | - "172.16.0.0/12"
13 | - "192.168.0.0/16"
14 | - "fc00::/7"
15 | # CGNAT
16 | - "100.64.0.0/10"
17 |
18 | conditions:
19 | is-network-localhost:
20 | - &is-network-localhost 'remoteAddress.network("localhost")'
21 | is-network-private:
22 | - &is-network-private 'remoteAddress.network("private")'
--------------------------------------------------------------------------------
/examples/spa.yml:
--------------------------------------------------------------------------------
1 | # Example cmdline (forward requests from upstream to port :8080)
2 | # $ go-away --bind :8080 --backend site.example.com=http://site:3000 --policy examples/spa.yml --policy-snippets example/snippets/ --challenge-template anubis
3 |
4 |
5 |
6 | # Define networks to be used later below
7 | networks:
8 | # Networks will get included from snippets
9 |
10 |
11 | challenges:
12 | # Challenges will get included from snippets
13 |
14 | conditions:
15 | # Conditions will get replaced on rules AST when found as ($condition-name)
16 |
17 |
18 | is-static-asset:
19 | - 'path == "/apple-touch-icon.png"'
20 | - 'path == "/apple-touch-icon-precomposed.png"'
21 | - 'path.matches("\\.(manifest|ttf|woff|woff2|jpg|jpeg|gif|png|webp|avif|svg|mp4|webm|css|js|mjs|wasm)$")'
22 | # Add other paths where you have static assets
23 | # - 'path.startsWith("/static/") || path.startsWith("/assets/")'
24 |
25 |
26 | # Rules are checked sequentially in order, from top to bottom
27 | rules:
28 | - name: allow-well-known-resources
29 | conditions:
30 | - '($is-well-known-asset)'
31 | action: pass
32 |
33 | - name: allow-static-resources
34 | conditions:
35 | - '($is-static-asset)'
36 | action: pass
37 |
38 | - name: unknown-crawlers
39 | conditions:
40 | # No user agent set
41 | - 'userAgent == ""'
42 | action: deny
43 |
44 | # Enable fetching OpenGraph and other tags from backend on index
45 | - name: enable-meta-tags
46 | action: context
47 | conditions:
48 | - 'path == "/" || path == "/index.html"'
49 | settings:
50 | context-set:
51 | # Map OpenGraph or similar tags back to the reply, even if denied/challenged
52 | proxy-meta-tags: "true"
53 |
54 | # Challenge incoming visitors so challenge is remembered on api endpoints
55 | # API requests will have this challenge stored
56 | - name: index
57 | conditions:
58 | - 'path == "/" || path == "/index.html"'
59 | settings:
60 | challenges: [ preload-link, header-refresh ]
61 | action: challenge
62 |
63 | # Allow PUT/DELETE/PATCH/POST requests in general
64 | - name: non-get-request
65 | action: pass
66 | conditions:
67 | - '!(method == "HEAD" || method == "GET")'
68 |
69 | # Challenge rest of endpoints (SPA API etc.)
70 | # Above rule on index ensures clients have passed a challenge beforehand
71 | - name: standard-browser
72 | action: challenge
73 | settings:
74 | challenges: [ preload-link, header-refresh ]
75 | # Fallback on cookie challenge
76 | fail: challenge
77 | fail-settings:
78 | challenges: [ cookie ]
79 | conditions:
80 | - '($is-generic-browser)'
81 |
82 | - name: other-fetchers
83 | action: challenge
84 | settings:
85 | challenges: [ cookie ]
86 | conditions:
87 | - '!($is-generic-browser)'
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module git.gammaspectra.live/git/go-away
2 |
3 | go 1.24.0
4 |
5 | toolchain go1.24.2
6 |
7 | require (
8 | codeberg.org/gone/http-cel v1.0.0
9 | codeberg.org/meta/gzipped/v2 v2.0.0-20231111234332-aa70c3194756
10 | github.com/alphadose/haxmap v1.4.1
11 | github.com/go-jose/go-jose/v4 v4.1.0
12 | github.com/goccy/go-yaml v1.17.1
13 | github.com/google/cel-go v0.25.0
14 | github.com/itchyny/gojq v0.12.17
15 | github.com/pires/go-proxyproto v0.8.0
16 | github.com/prometheus/client_golang v1.22.0
17 | github.com/tetratelabs/wazero v1.9.0
18 | github.com/yl2chen/cidranger v1.0.2
19 | golang.org/x/crypto v0.37.0
20 | )
21 |
22 | require (
23 | cel.dev/expr v0.23.1 // indirect
24 | github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
25 | github.com/beorn7/perks v1.0.1 // indirect
26 | github.com/cespare/xxhash/v2 v2.3.0 // indirect
27 | github.com/itchyny/timefmt-go v0.1.6 // indirect
28 | github.com/kevinpollet/nego v0.0.0-20211010160919-a65cd48cee43 // indirect
29 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
30 | github.com/prometheus/client_model v0.6.2 // indirect
31 | github.com/prometheus/common v0.63.0 // indirect
32 | github.com/prometheus/procfs v0.16.1 // indirect
33 | github.com/stoewer/go-strcase v1.3.0 // indirect
34 | golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
35 | golang.org/x/net v0.39.0 // indirect
36 | golang.org/x/sys v0.32.0 // indirect
37 | golang.org/x/text v0.24.0 // indirect
38 | google.golang.org/genproto/googleapis/api v0.0.0-20250422160041-2d3770c4ea7f // indirect
39 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250422160041-2d3770c4ea7f // indirect
40 | google.golang.org/protobuf v1.36.6 // indirect
41 | )
42 |
--------------------------------------------------------------------------------
/lib/action/block.go:
--------------------------------------------------------------------------------
1 | package action
2 |
3 | import (
4 | "fmt"
5 | "git.gammaspectra.live/git/go-away/lib/challenge"
6 | "git.gammaspectra.live/git/go-away/lib/policy"
7 | "github.com/goccy/go-yaml/ast"
8 | "log/slog"
9 | "net/http"
10 | )
11 |
12 | func init() {
13 | Register[policy.RuleActionBLOCK] = func(state challenge.StateInterface, ruleName, ruleHash string, settings ast.Node) (Handler, error) {
14 | return Block{
15 | Code: http.StatusForbidden,
16 | RuleHash: ruleHash,
17 | }, nil
18 | }
19 | }
20 |
21 | type Block struct {
22 | Code int
23 | RuleHash string
24 | }
25 |
26 | func (a Block) Handle(logger *slog.Logger, w http.ResponseWriter, r *http.Request, done func() (backend http.Handler)) (next bool, err error) {
27 | logger.Info("request blocked")
28 | data := challenge.RequestDataFromContext(r.Context())
29 |
30 | w.Header().Set("Content-Type", "text/plain")
31 | w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
32 | w.Header().Set("Connection", "close")
33 |
34 | data.ResponseHeaders(w)
35 | w.WriteHeader(a.Code)
36 | _, _ = w.Write([]byte(fmt.Errorf("access blocked: blocked by administrative rule %s/%s", data.Id.String(), a.RuleHash).Error()))
37 |
38 | return false, nil
39 | }
40 |
--------------------------------------------------------------------------------
/lib/action/challenge.go:
--------------------------------------------------------------------------------
1 | package action
2 |
3 | import (
4 | "fmt"
5 | "git.gammaspectra.live/git/go-away/lib/challenge"
6 | "git.gammaspectra.live/git/go-away/lib/policy"
7 | "github.com/goccy/go-yaml"
8 | "github.com/goccy/go-yaml/ast"
9 | "log/slog"
10 | "net/http"
11 | "strings"
12 | )
13 |
14 | func init() {
15 | i := func(state challenge.StateInterface, ruleName, ruleHash string, settings ast.Node, cont bool) (Handler, error) {
16 | params := ChallengeDefaultSettings
17 |
18 | if settings != nil {
19 | ymlData, err := settings.MarshalYAML()
20 | if err != nil {
21 | return nil, err
22 | }
23 | err = yaml.Unmarshal(ymlData, ¶ms)
24 | if err != nil {
25 | return nil, err
26 | }
27 | }
28 |
29 | if params.Code == 0 {
30 | params.Code = state.Settings().ChallengeResponseCode
31 | }
32 |
33 | var regs []*challenge.Registration
34 | for _, regName := range params.Challenges {
35 | if reg, ok := state.GetChallengeByName(regName); ok {
36 | regs = append(regs, reg)
37 | } else {
38 | return nil, fmt.Errorf("challenge %s not found", regName)
39 | }
40 | }
41 |
42 | if len(regs) == 0 {
43 | return nil, fmt.Errorf("no registered challenges found in rule %s", ruleName)
44 | }
45 |
46 | passAction := policy.RuleAction(strings.ToUpper(params.PassAction))
47 | passHandler, ok := Register[passAction]
48 | if !ok {
49 | return nil, fmt.Errorf("unknown pass action %s", params.PassAction)
50 | }
51 |
52 | passActionHandler, err := passHandler(state, ruleName, ruleHash, params.PassSettings)
53 | if err != nil {
54 | return nil, err
55 | }
56 |
57 | failAction := policy.RuleAction(strings.ToUpper(params.FailAction))
58 | failHandler, ok := Register[failAction]
59 | if !ok {
60 | return nil, fmt.Errorf("unknown pass action %s", params.FailAction)
61 | }
62 |
63 | failActionHandler, err := failHandler(state, ruleName, ruleHash, params.FailSettings)
64 | if err != nil {
65 | return nil, err
66 | }
67 |
68 | return Challenge{
69 | RuleHash: ruleHash,
70 | Code: params.Code,
71 | Continue: cont,
72 | Challenges: regs,
73 |
74 | PassAction: passAction,
75 | PassActionHandler: passActionHandler,
76 | FailAction: failAction,
77 | FailActionHandler: failActionHandler,
78 | }, nil
79 | }
80 | Register[policy.RuleActionCHALLENGE] = func(state challenge.StateInterface, ruleName, ruleHash string, settings ast.Node) (Handler, error) {
81 | return i(state, ruleName, ruleHash, settings, false)
82 | }
83 | Register[policy.RuleActionCHECK] = func(state challenge.StateInterface, ruleName, ruleHash string, settings ast.Node) (Handler, error) {
84 | return i(state, ruleName, ruleHash, settings, true)
85 | }
86 | }
87 |
88 | var ChallengeDefaultSettings = ChallengeSettings{
89 | PassAction: string(policy.RuleActionPASS),
90 | FailAction: string(policy.RuleActionDENY),
91 | }
92 |
93 | type ChallengeSettings struct {
94 | Code int `yaml:"http-code"`
95 | Challenges []string `yaml:"challenges"`
96 |
97 | PassAction string `yaml:"pass"`
98 | PassSettings ast.Node `yaml:"pass-settings"`
99 |
100 | // FailAction Executed in case no challenges match or
101 | FailAction string `yaml:"fail"`
102 | FailSettings ast.Node `yaml:"fail-settings"`
103 | }
104 |
105 | type Challenge struct {
106 | RuleHash string
107 | Code int
108 | Continue bool
109 | Challenges []*challenge.Registration
110 |
111 | PassAction policy.RuleAction
112 | PassActionHandler Handler
113 | FailAction policy.RuleAction
114 | FailActionHandler Handler
115 | }
116 |
117 | func (a Challenge) Handle(logger *slog.Logger, w http.ResponseWriter, r *http.Request, done func() (backend http.Handler)) (next bool, err error) {
118 | data := challenge.RequestDataFromContext(r.Context())
119 | for _, reg := range a.Challenges {
120 | if data.HasValidChallenge(reg.Id()) {
121 |
122 | data.State.ChallengeChecked(r, reg, r.URL.String(), logger)
123 |
124 | if a.Continue {
125 | return true, nil
126 | }
127 |
128 | // we passed!
129 | data.State.ActionHit(r, a.PassAction, logger)
130 | return a.PassActionHandler.Handle(logger.With("challenge", reg.Name), w, r, done)
131 | }
132 | }
133 | // none matched, issue challenges in sequential priority
134 | for _, reg := range a.Challenges {
135 | result := data.ChallengeVerify[reg.Id()]
136 | state := data.ChallengeState[reg.Id()]
137 | if result.Ok() || result == challenge.VerifyResultSkip || state == challenge.VerifyStatePass {
138 | // skip already ok'd challenges for some reason (TODO: why)
139 | // also skip skipped challenges due to preconditions
140 | continue
141 | }
142 |
143 | expiry := data.Expiration(reg.Duration)
144 | key := challenge.GetChallengeKeyForRequest(data.State, reg, expiry, r)
145 | result = reg.IssueChallenge(w, r, key, expiry)
146 | if result != challenge.VerifyResultSkip {
147 | data.State.ChallengeIssued(r, reg, r.URL.String(), logger)
148 | }
149 | data.ChallengeVerify[reg.Id()] = result
150 | data.ChallengeState[reg.Id()] = challenge.VerifyStatePass
151 | switch result {
152 | case challenge.VerifyResultOK:
153 | data.State.ChallengePassed(r, reg, r.URL.String(), logger)
154 | if a.Continue {
155 | return true, nil
156 | }
157 |
158 | data.State.ActionHit(r, a.PassAction, logger)
159 | return a.PassActionHandler.Handle(logger.With("challenge", reg.Name), w, r, done)
160 | case challenge.VerifyResultNotOK:
161 | // we have had the challenge checked, but it's not ok!
162 | // safe to continue
163 | continue
164 | case challenge.VerifyResultFail:
165 | err := fmt.Errorf("challenge %s failed on issuance", reg.Name)
166 | data.State.ChallengeFailed(r, reg, err, r.URL.String(), logger)
167 |
168 | if reg.Class == challenge.ClassTransparent {
169 | // allow continuing transparent challenges
170 | continue
171 | }
172 |
173 | data.State.ActionHit(r, a.FailAction, logger)
174 | return a.FailActionHandler.Handle(logger, w, r, done)
175 | case challenge.VerifyResultNone:
176 | // challenge was issued
177 | if reg.Class == challenge.ClassTransparent {
178 | // allow continuing transparent challenges
179 | continue
180 | }
181 | // we cannot continue after issuance
182 | return false, nil
183 |
184 | case challenge.VerifyResultSkip:
185 | // continue onto next one due to precondition
186 | continue
187 | }
188 | }
189 |
190 | // nothing matched, execute default action
191 | data.State.ActionHit(r, a.FailAction, logger)
192 | return a.FailActionHandler.Handle(logger, w, r, done)
193 | }
194 |
--------------------------------------------------------------------------------
/lib/action/code.go:
--------------------------------------------------------------------------------
1 | package action
2 |
3 | import (
4 | "errors"
5 | "git.gammaspectra.live/git/go-away/lib/challenge"
6 | "git.gammaspectra.live/git/go-away/lib/policy"
7 | "github.com/goccy/go-yaml"
8 | "github.com/goccy/go-yaml/ast"
9 | "log/slog"
10 | "net/http"
11 | )
12 |
13 | func init() {
14 | Register[policy.RuleActionCODE] = func(state challenge.StateInterface, ruleName, ruleHash string, settings ast.Node) (Handler, error) {
15 | params := CodeDefaultSettings
16 |
17 | if settings != nil {
18 | ymlData, err := settings.MarshalYAML()
19 | if err != nil {
20 | return nil, err
21 | }
22 | err = yaml.Unmarshal(ymlData, ¶ms)
23 | if err != nil {
24 | return nil, err
25 | }
26 | }
27 |
28 | if params.Code == 0 {
29 | return nil, errors.New("http-code not set")
30 | }
31 |
32 | return Code(params.Code), nil
33 | }
34 | }
35 |
36 | var CodeDefaultSettings = CodeSettings{}
37 |
38 | type CodeSettings struct {
39 | Code int `yaml:"http-code"`
40 | }
41 |
42 | type Code int
43 |
44 | func (a Code) Handle(logger *slog.Logger, w http.ResponseWriter, r *http.Request, done func() (backend http.Handler)) (next bool, err error) {
45 | data := challenge.RequestDataFromContext(r.Context())
46 |
47 | w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
48 |
49 | data.ResponseHeaders(w)
50 |
51 | w.WriteHeader(int(a))
52 | return false, nil
53 | }
54 |
--------------------------------------------------------------------------------
/lib/action/context.go:
--------------------------------------------------------------------------------
1 | package action
2 |
3 | import (
4 | "git.gammaspectra.live/git/go-away/lib/challenge"
5 | "git.gammaspectra.live/git/go-away/lib/policy"
6 | "github.com/goccy/go-yaml"
7 | "github.com/goccy/go-yaml/ast"
8 | "log/slog"
9 | "net/http"
10 | "net/textproto"
11 | )
12 |
13 | func init() {
14 | Register[policy.RuleActionCONTEXT] = func(state challenge.StateInterface, ruleName, ruleHash string, settings ast.Node) (Handler, error) {
15 | params := ContextDefaultSettings
16 |
17 | if settings != nil {
18 | ymlData, err := settings.MarshalYAML()
19 | if err != nil {
20 | return nil, err
21 | }
22 | err = yaml.Unmarshal(ymlData, ¶ms)
23 | if err != nil {
24 | return nil, err
25 | }
26 | }
27 |
28 | return Context{
29 | opts: params,
30 | }, nil
31 | }
32 | }
33 |
34 | var ContextDefaultSettings = ContextSettings{}
35 |
36 | type ContextSettings struct {
37 | ContextSet map[string]string `yaml:"context-set"`
38 | ResponseHeaders map[string][]string `yaml:"response-headers"`
39 | RequestHeaders map[string][]string `yaml:"request-headers"`
40 | }
41 |
42 | type Context struct {
43 | opts ContextSettings
44 | }
45 |
46 | func (a Context) Handle(logger *slog.Logger, w http.ResponseWriter, r *http.Request, done func() (backend http.Handler)) (next bool, err error) {
47 | data := challenge.RequestDataFromContext(r.Context())
48 | for k, v := range a.opts.ContextSet {
49 | data.SetOpt(k, v)
50 | }
51 |
52 | for k, v := range a.opts.ResponseHeaders {
53 | // do this to allow unsetting values that are sent automatically
54 | w.Header()[textproto.CanonicalMIMEHeaderKey(k)] = nil
55 | for _, val := range v {
56 | w.Header().Add(k, val)
57 | }
58 | }
59 |
60 | for k, v := range a.opts.RequestHeaders {
61 | // do this to allow unsetting values that are sent automatically
62 | r.Header[textproto.CanonicalMIMEHeaderKey(k)] = nil
63 | for _, val := range v {
64 | r.Header.Add(k, val)
65 | }
66 | }
67 |
68 | return true, nil
69 | }
70 |
--------------------------------------------------------------------------------
/lib/action/deny.go:
--------------------------------------------------------------------------------
1 | package action
2 |
3 | import (
4 | "fmt"
5 | "git.gammaspectra.live/git/go-away/lib/challenge"
6 | "git.gammaspectra.live/git/go-away/lib/policy"
7 | "github.com/goccy/go-yaml/ast"
8 | "log/slog"
9 | "net/http"
10 | )
11 |
12 | func init() {
13 | Register[policy.RuleActionDENY] = func(state challenge.StateInterface, ruleName, ruleHash string, settings ast.Node) (Handler, error) {
14 | return Deny{
15 | Code: http.StatusForbidden,
16 | RuleHash: ruleHash,
17 | }, nil
18 | }
19 | }
20 |
21 | type Deny struct {
22 | Code int
23 | RuleHash string
24 | }
25 |
26 | func (a Deny) Handle(logger *slog.Logger, w http.ResponseWriter, r *http.Request, done func() (backend http.Handler)) (next bool, err error) {
27 | logger.Info("request denied")
28 | data := challenge.RequestDataFromContext(r.Context())
29 | data.State.ErrorPage(w, r, a.Code, fmt.Errorf("access denied: denied by administrative rule %s/%s", data.Id.String(), a.RuleHash), "")
30 | return false, nil
31 | }
32 |
--------------------------------------------------------------------------------
/lib/action/drop.go:
--------------------------------------------------------------------------------
1 | package action
2 |
3 | import (
4 | "git.gammaspectra.live/git/go-away/lib/challenge"
5 | "git.gammaspectra.live/git/go-away/lib/policy"
6 | "github.com/goccy/go-yaml/ast"
7 | "log/slog"
8 | "net/http"
9 | )
10 |
11 | func init() {
12 | Register[policy.RuleActionDROP] = func(state challenge.StateInterface, ruleName, ruleHash string, settings ast.Node) (Handler, error) {
13 | return Drop{}, nil
14 | }
15 | }
16 |
17 | type Drop struct {
18 | }
19 |
20 | func (a Drop) Handle(logger *slog.Logger, w http.ResponseWriter, r *http.Request, done func() (backend http.Handler)) (next bool, err error) {
21 | logger.Info("request dropped")
22 |
23 | if hj, ok := w.(http.Hijacker); ok {
24 | if conn, _, err := hj.Hijack(); err == nil {
25 | // drop without sending data
26 | _ = conn.Close()
27 | return false, nil
28 | }
29 | }
30 |
31 | // fallback
32 |
33 | w.Header().Set("Content-Type", "text/plain")
34 | w.Header().Set("Content-Length", "0")
35 | w.Header().Set("Connection", "close")
36 | w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
37 |
38 | w.WriteHeader(http.StatusForbidden)
39 |
40 | return false, nil
41 | }
42 |
--------------------------------------------------------------------------------
/lib/action/none.go:
--------------------------------------------------------------------------------
1 | package action
2 |
3 | import (
4 | "git.gammaspectra.live/git/go-away/lib/challenge"
5 | "git.gammaspectra.live/git/go-away/lib/policy"
6 | "github.com/goccy/go-yaml/ast"
7 | "log/slog"
8 | "net/http"
9 | )
10 |
11 | func init() {
12 | Register[policy.RuleActionNONE] = func(state challenge.StateInterface, ruleName, ruleHash string, settings ast.Node) (Handler, error) {
13 | return None{}, nil
14 | }
15 | }
16 |
17 | type None struct{}
18 |
19 | func (a None) Handle(logger *slog.Logger, w http.ResponseWriter, r *http.Request, done func() (backend http.Handler)) (next bool, err error) {
20 | return true, nil
21 | }
22 |
--------------------------------------------------------------------------------
/lib/action/pass.go:
--------------------------------------------------------------------------------
1 | package action
2 |
3 | import (
4 | "git.gammaspectra.live/git/go-away/lib/challenge"
5 | "git.gammaspectra.live/git/go-away/lib/policy"
6 | "github.com/goccy/go-yaml/ast"
7 | "log/slog"
8 | "net/http"
9 | )
10 |
11 | func init() {
12 | Register[policy.RuleActionPASS] = func(state challenge.StateInterface, ruleName, ruleHash string, settings ast.Node) (Handler, error) {
13 | return Pass{}, nil
14 | }
15 | }
16 |
17 | type Pass struct{}
18 |
19 | func (a Pass) Handle(logger *slog.Logger, w http.ResponseWriter, r *http.Request, done func() (backend http.Handler)) (next bool, err error) {
20 | logger.Debug("request passed")
21 | done().ServeHTTP(w, r)
22 | return false, nil
23 | }
24 |
--------------------------------------------------------------------------------
/lib/action/proxy.go:
--------------------------------------------------------------------------------
1 | package action
2 |
3 | import (
4 | "fmt"
5 | "git.gammaspectra.live/git/go-away/lib/challenge"
6 | "git.gammaspectra.live/git/go-away/lib/policy"
7 | "github.com/goccy/go-yaml"
8 | "github.com/goccy/go-yaml/ast"
9 | "log/slog"
10 | "net/http"
11 | "regexp"
12 | )
13 |
14 | func init() {
15 | Register[policy.RuleActionPROXY] = func(state challenge.StateInterface, ruleName, ruleHash string, settings ast.Node) (Handler, error) {
16 | params := ProxyDefaultSettings
17 |
18 | if settings != nil {
19 | ymlData, err := settings.MarshalYAML()
20 | if err != nil {
21 | return nil, err
22 | }
23 | err = yaml.Unmarshal(ymlData, ¶ms)
24 | if err != nil {
25 | return nil, err
26 | }
27 | }
28 |
29 | if params.Match != "" {
30 | expr, err := regexp.Compile(params.Match)
31 | if err != nil {
32 | return nil, err
33 | }
34 |
35 | return Proxy{
36 | Match: expr,
37 | Rewrite: params.Rewrite,
38 | Backend: params.Backend,
39 | }, nil
40 | }
41 |
42 | return Proxy{
43 | Backend: params.Backend,
44 | }, nil
45 | }
46 | }
47 |
48 | var ProxyDefaultSettings = ProxySettings{}
49 |
50 | type ProxySettings struct {
51 | Match string `yaml:"proxy-match"`
52 | Rewrite string `yaml:"proxy-rewrite"`
53 | Backend string `yaml:"proxy-backend"`
54 | }
55 |
56 | type Proxy struct {
57 | Match *regexp.Regexp
58 | Rewrite string
59 | Backend string
60 | }
61 |
62 | func (a Proxy) Handle(logger *slog.Logger, w http.ResponseWriter, r *http.Request, done func() (backend http.Handler)) (next bool, err error) {
63 | data := challenge.RequestDataFromContext(r.Context())
64 |
65 | backend := data.State.GetBackend(a.Backend)
66 | if backend == nil {
67 | return false, fmt.Errorf("backend for %s not found", a.Backend)
68 | }
69 |
70 | if a.Match != nil {
71 | // rewrite query
72 | r.URL.Path = a.Match.ReplaceAllString(r.URL.Path, a.Rewrite)
73 | }
74 |
75 | // set headers, ignore reply
76 | _ = done()
77 | backend.ServeHTTP(w, r)
78 |
79 | return false, nil
80 | }
81 |
--------------------------------------------------------------------------------
/lib/action/register.go:
--------------------------------------------------------------------------------
1 | package action
2 |
3 | import (
4 | "git.gammaspectra.live/git/go-away/lib/challenge"
5 | "git.gammaspectra.live/git/go-away/lib/policy"
6 | "github.com/goccy/go-yaml/ast"
7 | "log/slog"
8 | "net/http"
9 | )
10 |
11 | type Handler interface {
12 | // Handle An incoming request.
13 | // If next is true, continue processing
14 | // If next is false, stop processing. If passing to a backend, done() must be called beforehand to set headers.
15 | Handle(logger *slog.Logger, w http.ResponseWriter, r *http.Request, done func() (backend http.Handler)) (next bool, err error)
16 | }
17 |
18 | type NewFunc func(state challenge.StateInterface, ruleName, ruleHash string, settings ast.Node) (Handler, error)
19 |
20 | var Register = make(map[policy.RuleAction]NewFunc)
21 |
--------------------------------------------------------------------------------
/lib/challenge.go:
--------------------------------------------------------------------------------
1 | package lib
2 |
3 | import (
4 | _ "git.gammaspectra.live/git/go-away/lib/challenge/cookie"
5 | _ "git.gammaspectra.live/git/go-away/lib/challenge/dnsbl"
6 | _ "git.gammaspectra.live/git/go-away/lib/challenge/http"
7 | _ "git.gammaspectra.live/git/go-away/lib/challenge/preload-link"
8 | _ "git.gammaspectra.live/git/go-away/lib/challenge/refresh"
9 | _ "git.gammaspectra.live/git/go-away/lib/challenge/resource-load"
10 | _ "git.gammaspectra.live/git/go-away/lib/challenge/wasm"
11 | )
12 |
13 | // This file loads embedded challenge runtimes so their init() is called
14 |
--------------------------------------------------------------------------------
/lib/challenge/awaiter.go:
--------------------------------------------------------------------------------
1 | package challenge
2 |
3 | import (
4 | "context"
5 | "github.com/alphadose/haxmap"
6 | "sync/atomic"
7 | )
8 |
9 | type awaiterCallback func(result VerifyResult)
10 |
11 | type Awaiter[K ~string | ~int64 | ~uint64] haxmap.Map[K, awaiterCallback]
12 |
13 | func NewAwaiter[T ~string | ~int64 | ~uint64]() *Awaiter[T] {
14 | return (*Awaiter[T])(haxmap.New[T, awaiterCallback]())
15 | }
16 |
17 | func (a *Awaiter[T]) Await(key T, ctx context.Context) VerifyResult {
18 | ctx, cancel := context.WithCancel(ctx)
19 | defer cancel()
20 |
21 | var result atomic.Int64
22 |
23 | a.m().Set(key, func(receivedResult VerifyResult) {
24 | result.Store(int64(receivedResult))
25 | cancel()
26 | })
27 | // cleanup
28 | defer a.m().Del(key)
29 |
30 | <-ctx.Done()
31 |
32 | return VerifyResult(result.Load())
33 | }
34 |
35 | func (a *Awaiter[T]) Solve(key T, result VerifyResult) {
36 | if f, ok := a.m().GetAndDel(key); ok && f != nil {
37 | f(result)
38 | }
39 | }
40 |
41 | func (a *Awaiter[T]) m() *haxmap.Map[T, awaiterCallback] {
42 | return (*haxmap.Map[T, awaiterCallback])(a)
43 | }
44 |
45 | func (a *Awaiter[T]) Close() error {
46 | return nil
47 | }
48 |
--------------------------------------------------------------------------------
/lib/challenge/cookie/cookie.go:
--------------------------------------------------------------------------------
1 | package cookie
2 |
3 | import (
4 | "git.gammaspectra.live/git/go-away/lib/challenge"
5 | "github.com/goccy/go-yaml/ast"
6 | "net/http"
7 | "time"
8 | )
9 |
10 | func init() {
11 | challenge.Runtimes[Key] = FillRegistration
12 | }
13 |
14 | const Key = "cookie"
15 |
16 | func FillRegistration(state challenge.StateInterface, reg *challenge.Registration, parameters ast.Node) error {
17 | reg.Class = challenge.ClassBlocking
18 |
19 | reg.IssueChallenge = func(w http.ResponseWriter, r *http.Request, key challenge.Key, expiry time.Time) challenge.VerifyResult {
20 | data := challenge.RequestDataFromContext(r.Context())
21 | data.IssueChallengeToken(reg, key, nil, expiry, true)
22 |
23 | uri, err := challenge.RedirectUrl(r, reg)
24 | if err != nil {
25 | return challenge.VerifyResultFail
26 | }
27 |
28 | data.ResponseHeaders(w)
29 | http.Redirect(w, r, uri.String(), http.StatusTemporaryRedirect)
30 | return challenge.VerifyResultNone
31 | }
32 |
33 | return nil
34 | }
35 |
--------------------------------------------------------------------------------
/lib/challenge/dnsbl/dnsbl.go:
--------------------------------------------------------------------------------
1 | package http
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "git.gammaspectra.live/git/go-away/lib/challenge"
7 | "git.gammaspectra.live/git/go-away/utils"
8 | "github.com/goccy/go-yaml"
9 | "github.com/goccy/go-yaml/ast"
10 | "net"
11 | "net/http"
12 | "time"
13 | )
14 |
15 | func init() {
16 | challenge.Runtimes[Key] = FillRegistration
17 | }
18 |
19 | const Key = "dnsbl"
20 |
21 | type Parameters struct {
22 | VerifyProbability float64 `yaml:"verify-probability"`
23 | Host string `yaml:"dnsbl-host"`
24 | Timeout time.Duration `yaml:"dnsbl-timeout"`
25 | Decay time.Duration `yaml:"dnsbl-decay"`
26 | }
27 |
28 | var DefaultParameters = Parameters{
29 | VerifyProbability: 0.10,
30 | Timeout: time.Second * 1,
31 | Decay: time.Hour * 1,
32 | Host: "dnsbl.dronebl.org",
33 | }
34 |
35 | 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) {
36 | var key [net.IPv6len]byte
37 | copy(key[:], ip.To16())
38 |
39 | result, ok := decayMap.Get(key)
40 | if ok {
41 | return result, nil
42 | }
43 |
44 | ctx, cancel := context.WithTimeout(context.Background(), timeout)
45 | defer cancel()
46 | result, err := dnsbl.Lookup(ctx, ip)
47 | if err != nil {
48 |
49 | }
50 | decayMap.Set(key, result, decay)
51 |
52 | return result, err
53 | }
54 |
55 | type closer chan struct{}
56 |
57 | func (c closer) Close() error {
58 | select {
59 | case <-c:
60 | default:
61 | close(c)
62 | }
63 | return nil
64 | }
65 |
66 | func FillRegistration(state challenge.StateInterface, reg *challenge.Registration, parameters ast.Node) error {
67 | params := DefaultParameters
68 |
69 | if parameters != nil {
70 | ymlData, err := parameters.MarshalYAML()
71 | if err != nil {
72 | return err
73 | }
74 | err = yaml.Unmarshal(ymlData, ¶ms)
75 | if err != nil {
76 | return err
77 | }
78 | }
79 |
80 | if params.Host == "" {
81 | return errors.New("empty host")
82 | }
83 |
84 | reg.Class = challenge.ClassTransparent
85 |
86 | if params.VerifyProbability <= 0 {
87 | //20% default
88 | params.VerifyProbability = 0.20
89 | } else if params.VerifyProbability > 1.0 {
90 | params.VerifyProbability = 1.0
91 | }
92 | reg.VerifyProbability = params.VerifyProbability
93 |
94 | decayMap := utils.NewDecayMap[[net.IPv6len]byte, utils.DNSBLResponse]()
95 |
96 | dnsbl := utils.NewDNSBL(params.Host, &net.Resolver{
97 | PreferGo: true,
98 | })
99 |
100 | ob := make(closer)
101 |
102 | go func() {
103 | ticker := time.NewTicker(params.Timeout / 3)
104 | defer ticker.Stop()
105 | for {
106 | select {
107 | case <-ticker.C:
108 | decayMap.Decay()
109 | case <-ob:
110 | return
111 | }
112 | }
113 | }()
114 |
115 | // allow freeing the ticker/decay map
116 | reg.Object = ob
117 |
118 | reg.IssueChallenge = func(w http.ResponseWriter, r *http.Request, key challenge.Key, expiry time.Time) challenge.VerifyResult {
119 |
120 | data := challenge.RequestDataFromContext(r.Context())
121 |
122 | result, err := lookup(r.Context(), params.Decay, params.Timeout, dnsbl, decayMap, data.RemoteAddress.Addr().Unmap().AsSlice())
123 | if err != nil {
124 | data.State.Logger(r).Debug("dnsbl lookup failed", "address", data.RemoteAddress.Addr().String(), "result", result, "err", err)
125 | }
126 |
127 | if result.Bad() {
128 | data.IssueChallengeToken(reg, key, nil, expiry, false)
129 | return challenge.VerifyResultNotOK
130 | } else {
131 | data.IssueChallengeToken(reg, key, nil, expiry, true)
132 | return challenge.VerifyResultOK
133 | }
134 | }
135 |
136 | return nil
137 | }
138 |
--------------------------------------------------------------------------------
/lib/challenge/helper.go:
--------------------------------------------------------------------------------
1 | package challenge
2 |
3 | import (
4 | "crypto/subtle"
5 | "encoding/hex"
6 | "errors"
7 | "fmt"
8 | "git.gammaspectra.live/git/go-away/utils"
9 | "net/http"
10 | "net/url"
11 | "strconv"
12 | "strings"
13 | "time"
14 | )
15 |
16 | var ErrInvalidToken = errors.New("invalid token")
17 | var ErrMismatchedToken = errors.New("mismatched token")
18 | var ErrMismatchedTokenHappyEyeballs = errors.New("mismatched token: IPv4 to IPv6 upgrade detected, retrying")
19 |
20 | func NewKeyVerifier() (verify VerifyFunc, issue func(key Key) string) {
21 | return func(key Key, token []byte, r *http.Request) (VerifyResult, error) {
22 | expectedKey, err := hex.DecodeString(string(token))
23 | if err != nil {
24 | return VerifyResultFail, err
25 | }
26 | if len(expectedKey) != KeySize {
27 | return VerifyResultFail, ErrInvalidToken
28 | }
29 | if subtle.ConstantTimeCompare(key[:], expectedKey) == 1 {
30 | return VerifyResultOK, nil
31 | }
32 |
33 | kk := Key(expectedKey)
34 | // IPv4 -> IPv6 Happy Eyeballs
35 | if key.Get(KeyFlagIsIPv4) == 0 && kk.Get(KeyFlagIsIPv4) > 0 {
36 | return VerifyResultOK, ErrMismatchedTokenHappyEyeballs
37 | }
38 |
39 | return VerifyResultFail, ErrMismatchedToken
40 | }, func(key Key) string {
41 | return hex.EncodeToString(key[:])
42 | }
43 | }
44 |
45 | const (
46 | QueryArgPrefix = "__goaway"
47 | QueryArgReferer = QueryArgPrefix + "_referer"
48 | QueryArgRedirect = QueryArgPrefix + "_redirect"
49 | QueryArgRequestId = QueryArgPrefix + "_id"
50 | QueryArgChallenge = QueryArgPrefix + "_challenge"
51 | QueryArgToken = QueryArgPrefix + "_token"
52 | QueryArgBust = QueryArgPrefix + "_bust"
53 | )
54 |
55 | const MakeChallengeUrlSuffix = "/make-challenge"
56 | const VerifyChallengeUrlSuffix = "/verify-challenge"
57 |
58 | func GetVerifyInformation(r *http.Request, reg *Registration) (requestId RequestId, redirect, token string, err error) {
59 |
60 | q := r.URL.Query()
61 |
62 | if q.Get(QueryArgChallenge) != reg.Name {
63 | return RequestId{}, "", "", fmt.Errorf("unexpected challenge: got \"%s\"", q.Get(QueryArgChallenge))
64 | }
65 |
66 | requestIdHex := q.Get(QueryArgRequestId)
67 |
68 | if len(requestId) != hex.DecodedLen(len(requestIdHex)) {
69 | return RequestId{}, "", "", errors.New("invalid request id")
70 | }
71 | n, err := hex.Decode(requestId[:], []byte(requestIdHex))
72 | if err != nil {
73 | return RequestId{}, "", "", err
74 | } else if n != len(requestId) {
75 | return RequestId{}, "", "", errors.New("invalid request id")
76 | }
77 |
78 | token = q.Get(QueryArgToken)
79 | redirect, err = utils.EnsureNoOpenRedirect(q.Get(QueryArgRedirect))
80 | if err != nil {
81 | return RequestId{}, "", "", err
82 | }
83 | return
84 | }
85 |
86 | func VerifyUrl(r *http.Request, reg *Registration, token string) (*url.URL, error) {
87 |
88 | redirectUrl, err := RedirectUrl(r, reg)
89 | if err != nil {
90 | return nil, err
91 | }
92 |
93 | uri := new(url.URL)
94 | uri.Path = reg.Path + VerifyChallengeUrlSuffix
95 |
96 | data := RequestDataFromContext(r.Context())
97 | values := uri.Query()
98 | values.Set(QueryArgRequestId, data.Id.String())
99 | values.Set(QueryArgRedirect, redirectUrl.String())
100 | values.Set(QueryArgToken, token)
101 | values.Set(QueryArgChallenge, reg.Name)
102 | values.Set(QueryArgBust, strconv.FormatInt(time.Now().UTC().UnixMilli(), 10))
103 | uri.RawQuery = values.Encode()
104 |
105 | return uri, nil
106 | }
107 |
108 | func RedirectUrl(r *http.Request, reg *Registration) (*url.URL, error) {
109 | uri, err := url.ParseRequestURI(r.URL.String())
110 | if err != nil {
111 | return nil, err
112 | }
113 |
114 | data := RequestDataFromContext(r.Context())
115 | values := uri.Query()
116 | values.Set(QueryArgRequestId, data.Id.String())
117 | if ref := r.Referer(); ref != "" {
118 | values.Set(QueryArgReferer, r.Referer())
119 | }
120 | values.Set(QueryArgChallenge, reg.Name)
121 | uri.RawQuery = values.Encode()
122 |
123 | return uri, nil
124 | }
125 |
126 | func VerifyHandlerChallengeResponseFunc(state StateInterface, data *RequestData, w http.ResponseWriter, r *http.Request, verifyResult VerifyResult, err error, redirect string) {
127 | if err != nil {
128 | // Happy Eyeballs! auto retry
129 | if errors.Is(err, ErrMismatchedTokenHappyEyeballs) {
130 | reqUri := *r.URL
131 | q := reqUri.Query()
132 |
133 | ref := q.Get(QueryArgReferer)
134 | // delete query parameters that were set by go-away
135 | for k := range q {
136 | if strings.HasPrefix(k, QueryArgPrefix) {
137 | q.Del(k)
138 | }
139 | }
140 | if ref != "" {
141 | q.Set(QueryArgReferer, ref)
142 | }
143 | reqUri.RawQuery = q.Encode()
144 |
145 | data.ResponseHeaders(w)
146 |
147 | http.Redirect(w, r, reqUri.String(), http.StatusTemporaryRedirect)
148 | return
149 | }
150 | state.ErrorPage(w, r, http.StatusBadRequest, err, redirect)
151 | return
152 | } else if !verifyResult.Ok() {
153 | state.ErrorPage(w, r, http.StatusForbidden, fmt.Errorf("access denied: failed challenge"), redirect)
154 | return
155 | }
156 | data.ResponseHeaders(w)
157 | http.Redirect(w, r, redirect, http.StatusTemporaryRedirect)
158 | }
159 |
160 | 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 {
161 | if verify == nil {
162 | verify = reg.Verify
163 | }
164 | if responseFunc == nil {
165 | responseFunc = VerifyHandlerChallengeResponseFunc
166 | }
167 | return func(w http.ResponseWriter, r *http.Request) {
168 | data := RequestDataFromContext(r.Context())
169 | requestId, redirect, token, err := GetVerifyInformation(r, reg)
170 | if err != nil {
171 | state.ChallengeFailed(r, reg, err, "", nil)
172 | responseFunc(state, data, w, r, VerifyResultFail, fmt.Errorf("internal error: %w", err), "")
173 | return
174 | }
175 | data.Id = requestId
176 |
177 | err = func() (err error) {
178 | expiration := data.Expiration(reg.Duration)
179 | key := GetChallengeKeyForRequest(state, reg, expiration, r)
180 |
181 | verifyResult, err := verify(key, []byte(token), r)
182 | if err != nil {
183 | return err
184 | } else if !verifyResult.Ok() {
185 | state.ChallengeFailed(r, reg, nil, redirect, nil)
186 | responseFunc(state, data, w, r, verifyResult, nil, redirect)
187 | return nil
188 | }
189 |
190 | data.IssueChallengeToken(reg, key, []byte(token), expiration, true)
191 | data.ChallengeVerify[reg.id] = verifyResult
192 | state.ChallengePassed(r, reg, redirect, nil)
193 |
194 | responseFunc(state, data, w, r, verifyResult, nil, redirect)
195 | return nil
196 | }()
197 | if err != nil {
198 | state.ChallengeFailed(r, reg, err, redirect, nil)
199 | responseFunc(state, data, w, r, VerifyResultFail, fmt.Errorf("access denied: error in challenge %s: %w", reg.Name, err), redirect)
200 | return
201 | }
202 | }
203 | }
204 |
--------------------------------------------------------------------------------
/lib/challenge/http/http.go:
--------------------------------------------------------------------------------
1 | package http
2 |
3 | import (
4 | "crypto/sha256"
5 | "crypto/subtle"
6 | "errors"
7 | "git.gammaspectra.live/git/go-away/lib/challenge"
8 | "github.com/goccy/go-yaml"
9 | "github.com/goccy/go-yaml/ast"
10 | "io"
11 | "net/http"
12 | "slices"
13 | "time"
14 | )
15 |
16 | func init() {
17 | challenge.Runtimes[Key] = FillRegistration
18 | }
19 |
20 | const Key = "http"
21 |
22 | type Parameters struct {
23 | VerifyProbability float64 `yaml:"verify-probability"`
24 |
25 | HttpMethod string `yaml:"http-method"`
26 | HttpCode int `yaml:"http-code"`
27 | HttpCookie string `yaml:"http-cookie"`
28 | Url string `yaml:"http-url"`
29 | }
30 |
31 | var DefaultParameters = Parameters{
32 | VerifyProbability: 0.20,
33 | HttpMethod: http.MethodGet,
34 | HttpCode: http.StatusOK,
35 | }
36 |
37 | func FillRegistration(state challenge.StateInterface, reg *challenge.Registration, parameters ast.Node) error {
38 | params := DefaultParameters
39 |
40 | if parameters != nil {
41 | ymlData, err := parameters.MarshalYAML()
42 | if err != nil {
43 | return err
44 | }
45 | err = yaml.Unmarshal(ymlData, ¶ms)
46 | if err != nil {
47 | return err
48 | }
49 | }
50 |
51 | if params.Url == "" {
52 | return errors.New("empty url")
53 | }
54 |
55 | reg.Class = challenge.ClassTransparent
56 |
57 | bindAuthValue := func(key challenge.Key, r *http.Request) ([]byte, error) {
58 | var cookieValue string
59 | if cookie, err := r.Cookie(params.HttpCookie); err != nil || cookie == nil {
60 | // skip check if we don't have cookie or it's expired
61 | return nil, http.ErrNoCookie
62 | } else {
63 | cookieValue = cookie.Value
64 | }
65 |
66 | // bind hash of cookie contents
67 | sum := sha256.New()
68 | sum.Write([]byte(cookieValue))
69 | sum.Write([]byte{0})
70 | sum.Write(key[:])
71 | return sum.Sum(nil), nil
72 | }
73 |
74 | if params.VerifyProbability <= 0 {
75 | //20% default
76 | params.VerifyProbability = 0.20
77 | } else if params.VerifyProbability > 1.0 {
78 | params.VerifyProbability = 1.0
79 | }
80 | reg.VerifyProbability = params.VerifyProbability
81 |
82 | if params.HttpCookie != "" {
83 | // re-verify the cookie value
84 | // TODO: configure to verify with backend
85 | reg.Verify = func(key challenge.Key, token []byte, r *http.Request) (challenge.VerifyResult, error) {
86 | sum, err := bindAuthValue(key, r)
87 | if err != nil {
88 | return challenge.VerifyResultFail, err
89 | }
90 | if subtle.ConstantTimeCompare(sum, token) == 1 {
91 | return challenge.VerifyResultOK, nil
92 | }
93 | return challenge.VerifyResultFail, errors.New("invalid cookie value")
94 | }
95 | }
96 |
97 | reg.IssueChallenge = func(w http.ResponseWriter, r *http.Request, key challenge.Key, expiry time.Time) challenge.VerifyResult {
98 | var sum []byte
99 | if params.HttpCookie != "" {
100 | if c, err := r.Cookie(params.HttpCookie); err != nil || c == nil {
101 | // skip check if we don't have cookie or it's expired
102 | return challenge.VerifyResultSkip
103 | } else {
104 | sum, err = bindAuthValue(key, r)
105 | if err != nil {
106 | return challenge.VerifyResultFail
107 | }
108 | }
109 | }
110 |
111 | data := challenge.RequestDataFromContext(r.Context())
112 |
113 | request, err := http.NewRequest(params.HttpMethod, params.Url, nil)
114 | if err != nil {
115 | return challenge.VerifyResultFail
116 | }
117 |
118 | var excludeHeaders = []string{"Host", "Content-Length", "Upgrade", "Accept-Encoding", "Range"}
119 | for k, v := range r.Header {
120 | if slices.Contains(excludeHeaders, k) {
121 | // skip these parameters
122 | continue
123 | }
124 | request.Header[k] = v
125 | }
126 |
127 | // set id, ip, and other headers
128 | data.RequestHeaders(request.Header)
129 |
130 | // set request info in X headers
131 | request.Header.Set("X-Away-Method", r.Method)
132 | request.Header.Set("X-Away-Host", r.Host)
133 | request.Header.Set("X-Away-Path", r.URL.Path)
134 | request.Header.Set("X-Away-Query", r.URL.RawQuery)
135 |
136 | response, err := state.Client().Do(request)
137 | if err != nil {
138 | return challenge.VerifyResultFail
139 | }
140 | defer response.Body.Close()
141 | defer io.Copy(io.Discard, response.Body)
142 |
143 | if response.StatusCode != params.HttpCode {
144 | data.IssueChallengeToken(reg, key, sum, expiry, false)
145 | return challenge.VerifyResultNotOK
146 | } else {
147 | data.IssueChallengeToken(reg, key, sum, expiry, true)
148 | return challenge.VerifyResultOK
149 | }
150 | }
151 |
152 | return nil
153 | }
154 |
--------------------------------------------------------------------------------
/lib/challenge/key.go:
--------------------------------------------------------------------------------
1 | package challenge
2 |
3 | import (
4 | "crypto/sha256"
5 | "encoding/binary"
6 | "encoding/hex"
7 | "errors"
8 | "net/http"
9 | "time"
10 | )
11 |
12 | type Key [KeySize]byte
13 |
14 | const KeySize = sha256.Size
15 |
16 | func (k *Key) Set(flags KeyFlags) {
17 | (*k)[0] |= uint8(flags)
18 | }
19 | func (k *Key) Get(flags KeyFlags) KeyFlags {
20 | return KeyFlags((*k)[0] & uint8(flags))
21 | }
22 | func (k *Key) Unset(flags KeyFlags) {
23 | (*k)[0] = (*k)[0] & ^(uint8(flags))
24 | }
25 |
26 | type KeyFlags uint8
27 |
28 | const (
29 | KeyFlagIsIPv4 = KeyFlags(1 << iota)
30 | )
31 |
32 | func KeyFromString(s string) (Key, error) {
33 | b, err := hex.DecodeString(s)
34 | if err != nil {
35 | return Key{}, err
36 | }
37 | if len(b) != KeySize {
38 | return Key{}, errors.New("invalid challenge key")
39 | }
40 | return Key(b), nil
41 | }
42 |
43 | func GetChallengeKeyForRequest(state StateInterface, reg *Registration, until time.Time, r *http.Request) Key {
44 | data := RequestDataFromContext(r.Context())
45 |
46 | hasher := sha256.New()
47 | hasher.Write([]byte("challenge\x00"))
48 | hasher.Write([]byte(reg.Name))
49 | hasher.Write([]byte{0})
50 | keyAddr := data.NetworkPrefix().As16()
51 | hasher.Write(keyAddr[:])
52 | hasher.Write([]byte{0})
53 |
54 | // specific headers
55 | for _, k := range reg.KeyHeaders {
56 | hasher.Write([]byte(k))
57 | hasher.Write([]byte{0})
58 | for _, v := range r.Header.Values(k) {
59 | hasher.Write([]byte(v))
60 | hasher.Write([]byte{1})
61 | }
62 | hasher.Write([]byte{0})
63 | }
64 | hasher.Write([]byte{0})
65 | _ = binary.Write(hasher, binary.LittleEndian, until.UTC().Unix())
66 | hasher.Write([]byte{0})
67 | hasher.Write(state.PrivateKeyFingerprint())
68 | hasher.Write([]byte{0})
69 |
70 | sum := Key(hasher.Sum(nil))
71 |
72 | sum[0] = 0
73 |
74 | if data.RemoteAddress.Addr().Unmap().Is4() {
75 | // Is IPv4, mark
76 | sum.Set(KeyFlagIsIPv4)
77 | }
78 | return Key(sum)
79 | }
80 |
--------------------------------------------------------------------------------
/lib/challenge/preload-link/preload-link.go:
--------------------------------------------------------------------------------
1 | package preload_link
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "git.gammaspectra.live/git/go-away/lib/challenge"
7 | "git.gammaspectra.live/git/go-away/utils"
8 | "github.com/goccy/go-yaml"
9 | "github.com/goccy/go-yaml/ast"
10 | "net/http"
11 | "time"
12 | )
13 |
14 | func init() {
15 | challenge.Runtimes[Key] = FillRegistration
16 | }
17 |
18 | const Key = "preload-link"
19 |
20 | type Parameters struct {
21 | Deadline time.Duration `yaml:"preload-early-hint-deadline"`
22 | }
23 |
24 | var DefaultParameters = Parameters{
25 | Deadline: time.Second * 2,
26 | }
27 |
28 | func FillRegistration(state challenge.StateInterface, reg *challenge.Registration, parameters ast.Node) error {
29 | params := DefaultParameters
30 |
31 | if parameters != nil {
32 | ymlData, err := parameters.MarshalYAML()
33 | if err != nil {
34 | return err
35 | }
36 | err = yaml.Unmarshal(ymlData, ¶ms)
37 | if err != nil {
38 | return err
39 | }
40 | }
41 |
42 | verifier, issuer := challenge.NewKeyVerifier()
43 | reg.Verify = verifier
44 |
45 | reg.Class = challenge.ClassTransparent
46 |
47 | // some of regular headers are not sent in default headers
48 | reg.KeyHeaders = challenge.MinimalKeyHeaders
49 |
50 | ob := challenge.NewAwaiter[string]()
51 |
52 | reg.Object = ob
53 |
54 | reg.IssueChallenge = func(w http.ResponseWriter, r *http.Request, key challenge.Key, expiry time.Time) challenge.VerifyResult {
55 | // this only works on HTTP/2 and HTTP/3
56 |
57 | if r.ProtoMajor < 2 {
58 | // this can happen if we are an upgraded request from HTTP/1.1 to HTTP/2 in H2C
59 | if _, ok := w.(http.Pusher); !ok {
60 | return challenge.VerifyResultSkip
61 | }
62 | }
63 |
64 | issuerKey := issuer(key)
65 |
66 | uri, err := challenge.VerifyUrl(r, reg, issuerKey)
67 | if err != nil {
68 | return challenge.VerifyResultFail
69 | }
70 |
71 | // remove redirect args
72 | values := uri.Query()
73 | values.Del(challenge.QueryArgRedirect)
74 | uri.RawQuery = values.Encode()
75 |
76 | // Redirect URI must be absolute to work
77 | uri.Scheme = utils.GetRequestScheme(r)
78 | uri.Host = r.Host
79 |
80 | w.Header().Set("Link", fmt.Sprintf("<%s>; rel=\"preload\"; as=\"style\"; fetchpriority=high", uri.String()))
81 | defer func() {
82 | // remove old header so it won't show on response!
83 | w.Header().Del("Link")
84 | }()
85 | w.WriteHeader(http.StatusEarlyHints)
86 |
87 | ctx, cancel := context.WithTimeout(r.Context(), params.Deadline)
88 | defer cancel()
89 | if result := ob.Await(issuerKey, ctx); result.Ok() {
90 | // this should serve!
91 | return challenge.VerifyResultOK
92 | } else if result == challenge.VerifyResultNone {
93 | // we hit timeout
94 | return challenge.VerifyResultFail
95 | } else {
96 | return result
97 | }
98 | }
99 |
100 | mux := http.NewServeMux()
101 |
102 | mux.HandleFunc("GET "+reg.Path+challenge.VerifyChallengeUrlSuffix, func(w http.ResponseWriter, r *http.Request) {
103 | w.Header().Set("Content-Type", "text/css; charset=utf-8")
104 | w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
105 | w.Header().Set("Content-Length", "0")
106 |
107 | data := challenge.RequestDataFromContext(r.Context())
108 | key := challenge.GetChallengeKeyForRequest(state, reg, data.Expiration(reg.Duration), r)
109 | issuerKey := issuer(key)
110 |
111 | _, _, token, err := challenge.GetVerifyInformation(r, reg)
112 | if err != nil {
113 | w.WriteHeader(http.StatusBadRequest)
114 | }
115 |
116 | verifyResult, _ := verifier(key, []byte(token), r)
117 |
118 | data.ResponseHeaders(w)
119 |
120 | if !verifyResult.Ok() {
121 | w.WriteHeader(http.StatusUnauthorized)
122 | } else {
123 | w.WriteHeader(http.StatusOK)
124 | }
125 |
126 | ob.Solve(issuerKey, verifyResult)
127 | if !verifyResult.Ok() {
128 | // also give data on other failure when mismatched
129 | ob.Solve(token, verifyResult)
130 | }
131 | })
132 | reg.Handler = mux
133 |
134 | return nil
135 | }
136 |
--------------------------------------------------------------------------------
/lib/challenge/refresh/refresh.go:
--------------------------------------------------------------------------------
1 | package refresh
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "git.gammaspectra.live/git/go-away/lib/challenge"
7 | "github.com/goccy/go-yaml"
8 | "github.com/goccy/go-yaml/ast"
9 | "html/template"
10 | "net/http"
11 | "time"
12 | )
13 |
14 | func init() {
15 | challenge.Runtimes["refresh"] = FillRegistration
16 | }
17 |
18 | type Parameters struct {
19 | Mode string `yaml:"refresh-via"`
20 | }
21 |
22 | var DefaultParameters = Parameters{
23 | Mode: "header",
24 | }
25 |
26 | func FillRegistration(state challenge.StateInterface, reg *challenge.Registration, parameters ast.Node) error {
27 | params := DefaultParameters
28 |
29 | if parameters != nil {
30 | ymlData, err := parameters.MarshalYAML()
31 | if err != nil {
32 | return err
33 | }
34 | err = yaml.Unmarshal(ymlData, ¶ms)
35 | if err != nil {
36 | return err
37 | }
38 | }
39 |
40 | reg.Class = challenge.ClassBlocking
41 |
42 | verifier, issuer := challenge.NewKeyVerifier()
43 | reg.Verify = verifier
44 |
45 | reg.IssueChallenge = func(w http.ResponseWriter, r *http.Request, key challenge.Key, expiry time.Time) challenge.VerifyResult {
46 | uri, err := challenge.VerifyUrl(r, reg, issuer(key))
47 | if err != nil {
48 | return challenge.VerifyResultFail
49 | }
50 |
51 | if params.Mode == "javascript" {
52 | data, err := json.Marshal(uri.String())
53 | if err != nil {
54 | return challenge.VerifyResultFail
55 | }
56 | state.ChallengePage(w, r, state.Settings().ChallengeResponseCode, reg, map[string]any{
57 | "EndTags": []template.HTML{
58 | template.HTML(fmt.Sprintf("", string(data))),
59 | },
60 | })
61 | } else if params.Mode == "meta" {
62 | state.ChallengePage(w, r, state.Settings().ChallengeResponseCode, reg, map[string]any{
63 | "MetaTags": []map[string]string{
64 | {
65 | "http-equiv": "refresh",
66 | "content": "0; url=" + uri.String(),
67 | },
68 | },
69 | })
70 | } else {
71 | // self redirect!
72 | w.Header().Set("Refresh", "0; url="+uri.String())
73 |
74 | state.ChallengePage(w, r, state.Settings().ChallengeResponseCode, reg, nil)
75 | }
76 | return challenge.VerifyResultNone
77 | }
78 |
79 | return nil
80 | }
81 |
--------------------------------------------------------------------------------
/lib/challenge/register.go:
--------------------------------------------------------------------------------
1 | package challenge
2 |
3 | import (
4 | http_cel "codeberg.org/gone/http-cel"
5 | "fmt"
6 | "git.gammaspectra.live/git/go-away/lib/policy"
7 | "github.com/goccy/go-yaml/ast"
8 | "github.com/google/cel-go/cel"
9 | "io"
10 | "net/http"
11 | "path"
12 | "strings"
13 | "time"
14 | )
15 |
16 | type Register map[Id]*Registration
17 |
18 | func (r Register) Get(id Id) (*Registration, bool) {
19 | c, ok := r[id]
20 | return c, ok
21 | }
22 |
23 | func (r Register) GetByName(name string) (*Registration, Id, bool) {
24 | for id, c := range r {
25 | if c.Name == name {
26 | return c, id, true
27 | }
28 | }
29 |
30 | return nil, 0, false
31 | }
32 |
33 | var idCounter Id
34 |
35 | // DefaultDuration TODO: adjust
36 | const DefaultDuration = time.Hour * 24 * 7
37 |
38 | var DefaultKeyHeaders = []string{
39 | // General browser information
40 | "User-Agent",
41 | // Accept headers
42 | "Accept-Language",
43 | "Accept-Encoding",
44 |
45 | // NOTE: not sent in preload
46 | "Sec-Ch-Ua",
47 | "Sec-Ch-Ua-Platform",
48 | }
49 |
50 | var MinimalKeyHeaders = []string{
51 | "Accept-Language",
52 | // General browser information
53 | "User-Agent",
54 | }
55 |
56 | func (r Register) Create(state StateInterface, name string, pol policy.Challenge, replacer *strings.Replacer) (*Registration, Id, error) {
57 | runtime, ok := Runtimes[pol.Runtime]
58 | if !ok {
59 | return nil, 0, fmt.Errorf("unknown challenge runtime %s", pol.Runtime)
60 | }
61 |
62 | reg := &Registration{
63 | Name: name,
64 | Path: path.Join(state.UrlPath(), "challenge", name),
65 | Duration: pol.Duration,
66 | KeyHeaders: DefaultKeyHeaders,
67 | }
68 |
69 | if reg.Duration == 0 {
70 | reg.Duration = DefaultDuration
71 | }
72 |
73 | // allow nesting
74 | var conditions []string
75 | for _, cond := range pol.Conditions {
76 | if replacer != nil {
77 | cond = replacer.Replace(cond)
78 | }
79 | conditions = append(conditions, cond)
80 | }
81 |
82 | if len(conditions) > 0 {
83 | var err error
84 | reg.Condition, err = state.RegisterCondition(http_cel.OperatorOr, conditions...)
85 | if err != nil {
86 | return nil, 0, fmt.Errorf("error compiling condition: %w", err)
87 | }
88 | }
89 |
90 | if _, oldId, ok := r.GetByName(reg.Name); ok {
91 | reg.id = oldId
92 | } else {
93 | idCounter++
94 | reg.id = idCounter
95 | }
96 |
97 | err := runtime(state, reg, pol.Parameters)
98 | if err != nil {
99 | return nil, 0, fmt.Errorf("error filling registration: %v", err)
100 | }
101 | r[reg.id] = reg
102 | return reg, reg.id, nil
103 | }
104 |
105 | func (r Register) Add(c *Registration) Id {
106 | if _, oldId, ok := r.GetByName(c.Name); ok {
107 | c.id = oldId
108 | r[oldId] = c
109 | return oldId
110 | } else {
111 | idCounter++
112 | c.id = idCounter
113 | r[idCounter] = c
114 | return idCounter
115 | }
116 | }
117 |
118 | type Registration struct {
119 | // id The assigned internal identifier
120 | id Id
121 |
122 | // Name The unique name for this challenge
123 | Name string
124 |
125 | // Class whether this challenge is transparent or otherwise
126 | Class Class
127 |
128 | // Condition A CEL condition which is passed the same environment as general rules.
129 | // If nil, always true
130 | // If non-nil, must return true for this challenge to be allowed to be executed
131 | Condition cel.Program
132 |
133 | // Path The url path that this challenge is hosted under for the Handler to be called.
134 | Path string
135 |
136 | // Duration How long this challenge will be valid when passed
137 | Duration time.Duration
138 |
139 | // Handler An HTTP handler for all requests coming on the Path
140 | // This handler will need to handle MakeChallengeUrlSuffix and VerifyChallengeUrlSuffix as well if needed
141 | // Recommended to use http.ServeMux
142 | Handler http.Handler
143 |
144 | // Verify Verify an issued token
145 | Verify VerifyFunc
146 | VerifyProbability float64
147 |
148 | // KeyHeaders The client headers used in key generation, in this order
149 | KeyHeaders []string
150 |
151 | // IssueChallenge Issues a challenge to a request.
152 | // If Class is ClassTransparent and VerifyResult is !VerifyResult.Ok(), continue with other challenges
153 | // TODO: have this return error as well
154 | IssueChallenge func(w http.ResponseWriter, r *http.Request, key Key, expiry time.Time) VerifyResult
155 |
156 | // Object used to handle state or similar
157 | // Can be nil if no state is needed
158 | // If non-nil must implement io.Closer even if there's nothing to do
159 | Object io.Closer
160 | }
161 |
162 | type VerifyFunc func(key Key, token []byte, r *http.Request) (VerifyResult, error)
163 |
164 | func (reg Registration) Id() Id {
165 | return reg.id
166 | }
167 |
168 | type FillRegistration func(state StateInterface, reg *Registration, parameters ast.Node) error
169 |
170 | var Runtimes = make(map[string]FillRegistration)
171 |
--------------------------------------------------------------------------------
/lib/challenge/resource-load/resource-load.go:
--------------------------------------------------------------------------------
1 | package resource_load
2 |
3 | import (
4 | "git.gammaspectra.live/git/go-away/lib/challenge"
5 | "github.com/goccy/go-yaml/ast"
6 | "net/http"
7 | "time"
8 | )
9 |
10 | func init() {
11 | challenge.Runtimes["resource-load"] = FillRegistrationHeader
12 | }
13 |
14 | func FillRegistrationHeader(state challenge.StateInterface, reg *challenge.Registration, parameters ast.Node) error {
15 | reg.Class = challenge.ClassBlocking
16 |
17 | verifier, issuer := challenge.NewKeyVerifier()
18 | reg.Verify = verifier
19 |
20 | reg.IssueChallenge = func(w http.ResponseWriter, r *http.Request, key challenge.Key, expiry time.Time) challenge.VerifyResult {
21 | uri, err := challenge.VerifyUrl(r, reg, issuer(key))
22 | if err != nil {
23 | return challenge.VerifyResultFail
24 | }
25 |
26 | redirectUri, err := challenge.RedirectUrl(r, reg)
27 | if err != nil {
28 | return challenge.VerifyResultFail
29 | }
30 | // self redirect!
31 | //TODO: adjust deadline
32 | w.Header().Set("Refresh", "2; url="+redirectUri.String())
33 |
34 | state.ChallengePage(w, r, state.Settings().ChallengeResponseCode, reg, map[string]any{
35 | "LinkTags": []map[string]string{
36 | {
37 | "href": uri.String(),
38 | "rel": "stylesheet",
39 | "crossorigin": "use-credentials",
40 | },
41 | },
42 | })
43 | return challenge.VerifyResultNone
44 | }
45 |
46 | mux := http.NewServeMux()
47 |
48 | 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) {
49 | //TODO: add other types inside css that need to be loaded!
50 | w.Header().Set("Content-Type", "text/css; charset=utf-8")
51 | w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
52 | w.Header().Set("Content-Length", "0")
53 |
54 | data.ResponseHeaders(w)
55 |
56 | if !verifyResult.Ok() {
57 | w.WriteHeader(http.StatusForbidden)
58 | } else {
59 | w.WriteHeader(http.StatusOK)
60 | }
61 | }))
62 | reg.Handler = mux
63 |
64 | return nil
65 | }
66 |
--------------------------------------------------------------------------------
/lib/challenge/script.go:
--------------------------------------------------------------------------------
1 | package challenge
2 |
3 | import (
4 | _ "embed"
5 | "encoding/json"
6 | "git.gammaspectra.live/git/go-away/utils"
7 | "net/http"
8 | "text/template"
9 | )
10 |
11 | //go:embed script.mjs
12 | var scriptData []byte
13 |
14 | var scriptTemplate = template.Must(template.New("script.mjs").Parse(string(scriptData)))
15 |
16 | func ServeChallengeScript(w http.ResponseWriter, r *http.Request, reg *Registration, params any, script string) {
17 | data := RequestDataFromContext(r.Context())
18 | w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
19 | w.Header().Set("Content-Type", "text/javascript; charset=utf-8")
20 |
21 | paramData, err := json.Marshal(params)
22 | if err != nil {
23 | //TODO: log
24 | panic(err)
25 | }
26 |
27 | data.ResponseHeaders(w)
28 | w.WriteHeader(http.StatusOK)
29 |
30 | err = scriptTemplate.Execute(w, map[string]any{
31 | "Id": data.Id.String(),
32 | "Path": reg.Path,
33 | "Parameters": paramData,
34 | "Random": utils.StaticCacheBust(),
35 | "Challenge": reg.Name,
36 | "ChallengeScript": script,
37 | "Strings": data.State.Strings(),
38 | })
39 | if err != nil {
40 | //TODO: log
41 | panic(err)
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/lib/challenge/script.mjs:
--------------------------------------------------------------------------------
1 | import {setup, challenge} from "{{ .ChallengeScript }}";
2 |
3 |
4 | // from Xeact
5 | const u = (url = "", params = {}) => {
6 | let result = new URL(url, window.location.href);
7 | Object.entries(params).forEach((kv) => {
8 | let [k, v] = kv;
9 | result.searchParams.set(k, v);
10 | });
11 | return result.toString();
12 | };
13 |
14 | (async () => {
15 | const status = document.getElementById('status');
16 | const title = document.getElementById('title');
17 |
18 | status.innerText = '{{ .Strings.Get "status_starting_challenge" }} {{ .Challenge }}...';
19 |
20 | try {
21 | const info = await setup({
22 | Path: "{{ .Path }}",
23 | Parameters: "{{ .Parameters }}"
24 | });
25 |
26 | if (info != "") {
27 | status.innerText = '{{ .Strings.Get "status_calculating" }} ' + info
28 | } else {
29 | status.innerText = '{{ .Strings.Get "status_calculating" }}';
30 | }
31 | } catch (err) {
32 | title.innerHTML = '{{ .Strings.Get "title_error" }}';
33 | status.innerHTML = `{{ .Strings.Get "status_error" }} ${err.message}`;
34 | return
35 | }
36 |
37 |
38 | try {
39 | const t0 = Date.now();
40 | const { result, info } = await challenge();
41 | const t1 = Date.now();
42 | console.log({ result, info });
43 |
44 | title.innerHTML = '{{ .Strings.Get "status_challenge_success" }}';
45 | if (info != "") {
46 | status.innerHTML = `{{ .Strings.Get "status_challenge_done_took" }} ${t1 - t0}ms, ${info}`;
47 | } else {
48 | status.innerHTML = `{{ .Strings.Get "status_challenge_done_took" }} ${t1 - t0}ms`;
49 | }
50 |
51 | setTimeout(() => {
52 | const redir = window.location.href;
53 | window.location.href = u("{{ .Path }}/verify-challenge", {
54 | __goaway_token: result,
55 | __goaway_challenge: "{{ .Challenge }}",
56 | __goaway_redirect: redir,
57 | __goaway_id: "{{ .Id }}",
58 | __goaway_elapsedTime: t1 - t0,
59 | });
60 | }, 500);
61 | } catch (err) {
62 | title.innerHTML = '{{ .Strings.Get "title_error" }}';
63 | status.innerHTML = `{{ .Strings.Get "status_error" }} ${err.message}`;
64 | }
65 | })();
--------------------------------------------------------------------------------
/lib/challenge/types.go:
--------------------------------------------------------------------------------
1 | package challenge
2 |
3 | import (
4 | "crypto/ed25519"
5 | "git.gammaspectra.live/git/go-away/lib/policy"
6 | "git.gammaspectra.live/git/go-away/utils"
7 | "github.com/google/cel-go/cel"
8 | "log/slog"
9 | "net/http"
10 | )
11 |
12 | type Id int64
13 |
14 | type Class uint8
15 |
16 | const (
17 | // ClassTransparent Transparent challenges work inline in the execution process.
18 | // These can pass or continue, so more challenges or requests can ve served afterward.
19 | ClassTransparent = Class(iota)
20 |
21 | // ClassBlocking Blocking challenges must serve a different response to challenge the requester.
22 | // These can pass or stop, for example, due to serving a challenge
23 | ClassBlocking
24 | )
25 |
26 | type VerifyState uint8
27 |
28 | const (
29 | VerifyStateNone = VerifyState(iota)
30 | // VerifyStatePass Challenge was just passed on this request
31 | VerifyStatePass
32 | // VerifyStateBrief Challenge token was verified but didn't check the challenge
33 | VerifyStateBrief
34 | // VerifyStateFull Challenge token was verified and challenge verification was done
35 | VerifyStateFull
36 | )
37 |
38 | func (r VerifyState) String() string {
39 | switch r {
40 | case VerifyStatePass:
41 | return "PASS"
42 | case VerifyStateBrief:
43 | return "BRIEF"
44 | case VerifyStateFull:
45 | return "FULL"
46 | default:
47 | panic("unsupported")
48 | }
49 | }
50 |
51 | type VerifyResult uint8
52 |
53 | const (
54 | // VerifyResultNone A negative pass result, without a token
55 | VerifyResultNone = VerifyResult(iota)
56 | // VerifyResultFail A negative pass result, with an invalid token
57 | VerifyResultFail
58 | // VerifyResultSkip Challenge was skipped due to precondition
59 | VerifyResultSkip
60 | // VerifyResultNotOK A negative pass result, with a valid token
61 | VerifyResultNotOK
62 |
63 | // VerifyResultOK A positive pass result, with a valid token
64 | VerifyResultOK
65 | )
66 |
67 | func (r VerifyResult) Ok() bool {
68 | return r >= VerifyResultOK
69 | }
70 |
71 | func (r VerifyResult) String() string {
72 | switch r {
73 | case VerifyResultNone:
74 | return "None"
75 | case VerifyResultFail:
76 | return "Fail"
77 | case VerifyResultSkip:
78 | return "Skip"
79 | case VerifyResultNotOK:
80 | return "NotOK"
81 | case VerifyResultOK:
82 | return "OK"
83 | default:
84 | panic("unsupported")
85 | }
86 | }
87 |
88 | type StateInterface interface {
89 | RegisterCondition(operator string, conditions ...string) (cel.Program, error)
90 |
91 | Client() *http.Client
92 | PrivateKeyFingerprint() []byte
93 | PrivateKey() ed25519.PrivateKey
94 | PublicKey() ed25519.PublicKey
95 |
96 | UrlPath() string
97 |
98 | ChallengeFailed(r *http.Request, reg *Registration, err error, redirect string, logger *slog.Logger)
99 | ChallengePassed(r *http.Request, reg *Registration, redirect string, logger *slog.Logger)
100 | ChallengeIssued(r *http.Request, reg *Registration, redirect string, logger *slog.Logger)
101 | ChallengeChecked(r *http.Request, reg *Registration, redirect string, logger *slog.Logger)
102 |
103 | RuleHit(r *http.Request, name string, logger *slog.Logger)
104 | RuleMiss(r *http.Request, name string, logger *slog.Logger)
105 | ActionHit(r *http.Request, name policy.RuleAction, logger *slog.Logger)
106 |
107 | Logger(r *http.Request) *slog.Logger
108 |
109 | ChallengePage(w http.ResponseWriter, r *http.Request, status int, reg *Registration, params map[string]any)
110 | ErrorPage(w http.ResponseWriter, r *http.Request, status int, err error, redirect string)
111 |
112 | GetChallenge(id Id) (*Registration, bool)
113 | GetChallengeByName(name string) (*Registration, bool)
114 | GetChallenges() Register
115 |
116 | Settings() policy.StateSettings
117 |
118 | Strings() utils.Strings
119 |
120 | GetBackend(host string) http.Handler
121 | }
122 |
--------------------------------------------------------------------------------
/lib/challenge/wasm/interface/interface.go:
--------------------------------------------------------------------------------
1 | package _interface
2 |
3 | import (
4 | "encoding/json"
5 | "git.gammaspectra.live/git/go-away/utils/inline"
6 | )
7 |
8 | // Allocation is a combination of pointer location in WASM memory and size of it
9 | type Allocation uint64
10 |
11 | func NewAllocation(ptr, size uint32) Allocation {
12 | return Allocation((uint64(ptr) << uint64(32)) | uint64(size))
13 | }
14 |
15 | func (p Allocation) Pointer() uint32 {
16 | return uint32(p >> 32)
17 | }
18 | func (p Allocation) Size() uint32 {
19 | return uint32(p)
20 | }
21 |
22 | func MakeChallengeDecode(callback func(in MakeChallengeInput, out *MakeChallengeOutput), in Allocation) (out Allocation) {
23 | outStruct := &MakeChallengeOutput{}
24 | var inStruct MakeChallengeInput
25 |
26 | inData := PtrToBytes(in.Pointer(), in.Size())
27 |
28 | err := json.Unmarshal(inData, &inStruct)
29 | if err != nil {
30 | outStruct.Code = 500
31 | outStruct.Error = err.Error()
32 | } else {
33 | outStruct.Code = 200
34 | outStruct.Headers = make(inline.MIMEHeader)
35 |
36 | func() {
37 | // encapsulate err
38 | defer func() {
39 | if recovered := recover(); recovered != nil {
40 | if outStruct.Code == 200 {
41 | outStruct.Code = 500
42 | }
43 | if err, ok := recovered.(error); ok {
44 | outStruct.Error = err.Error()
45 | } else {
46 | outStruct.Error = "error"
47 | }
48 | }
49 | }()
50 | callback(inStruct, outStruct)
51 | }()
52 | }
53 |
54 | if len(outStruct.Headers) == 0 {
55 | outStruct.Headers = nil
56 | }
57 |
58 | outData, err := json.Marshal(outStruct)
59 | if err != nil {
60 | panic(err)
61 | }
62 |
63 | return NewAllocation(BytesToLeakedPtr(outData))
64 | }
65 |
66 | func VerifyChallengeDecode(callback func(in VerifyChallengeInput) VerifyChallengeOutput, in Allocation) (out VerifyChallengeOutput) {
67 | var inStruct VerifyChallengeInput
68 |
69 | inData := PtrToBytes(in.Pointer(), in.Size())
70 |
71 | err := json.Unmarshal(inData, &inStruct)
72 | if err != nil {
73 | return VerifyChallengeOutputError
74 | } else {
75 | func() {
76 | // encapsulate err
77 | defer func() {
78 | if recovered := recover(); recovered != nil {
79 | out = VerifyChallengeOutputError
80 | }
81 | }()
82 | out = callback(inStruct)
83 | }()
84 | }
85 |
86 | return out
87 | }
88 |
89 | type MakeChallengeInput struct {
90 | Key []byte
91 |
92 | Parameters map[string]string
93 |
94 | Headers inline.MIMEHeader
95 | Data []byte
96 | }
97 |
98 | type MakeChallengeOutput struct {
99 | Data []byte
100 | Code int
101 | Headers inline.MIMEHeader
102 | Error string
103 | }
104 |
105 | type VerifyChallengeInput struct {
106 | Key []byte
107 | Parameters map[string]string
108 |
109 | Result []byte
110 | }
111 |
112 | type VerifyChallengeOutput uint64
113 |
114 | // TODO: expand allowed values
115 | const (
116 | VerifyChallengeOutputOK = VerifyChallengeOutput(iota)
117 | VerifyChallengeOutputFailed
118 | VerifyChallengeOutputError
119 | )
120 |
--------------------------------------------------------------------------------
/lib/challenge/wasm/interface/interface_generic.go:
--------------------------------------------------------------------------------
1 | //go:build !tinygo || !wasip1
2 |
3 | package _interface
4 |
5 | func PtrToBytes(ptr uint32, size uint32) []byte { panic("not implemented") }
6 | func BytesToPtr(s []byte) (uint32, uint32) { panic("not implemented") }
7 | func BytesToLeakedPtr(s []byte) (uint32, uint32) { panic("not implemented") }
8 | func PtrToString(ptr uint32, size uint32) string { panic("not implemented") }
9 | func StringToPtr(s string) (uint32, uint32) { panic("not implemented") }
10 | func StringToLeakedPtr(s string) (uint32, uint32) { panic("not implemented") }
11 |
--------------------------------------------------------------------------------
/lib/challenge/wasm/interface/interface_tinygo.go:
--------------------------------------------------------------------------------
1 | //go:build tinygo
2 |
3 | package _interface
4 |
5 | // #include
6 | import "C"
7 | import (
8 | "unsafe"
9 | )
10 |
11 | // PtrToBytes returns a byte slice from WebAssembly compatible numeric types
12 | // representing its pointer and length.
13 | func PtrToBytes(ptr uint32, size uint32) []byte {
14 | return unsafe.Slice((*byte)(unsafe.Pointer(uintptr(ptr))), size)
15 | }
16 |
17 | // BytesToPtr returns a pointer and size pair for the given byte slice in a way
18 | // compatible with WebAssembly numeric types.
19 | // The returned pointer aliases the slice hence the slice must be kept alive
20 | // until ptr is no longer needed.
21 | func BytesToPtr(s []byte) (uint32, uint32) {
22 | ptr := unsafe.Pointer(unsafe.SliceData(s))
23 | return uint32(uintptr(ptr)), uint32(len(s))
24 | }
25 |
26 | // BytesToLeakedPtr returns a pointer and size pair for the given byte slice in a way
27 | // compatible with WebAssembly numeric types.
28 | // The pointer is not automatically managed by TinyGo hence it must be freed by the host.
29 | func BytesToLeakedPtr(s []byte) (uint32, uint32) {
30 | size := C.ulong(len(s))
31 | ptr := unsafe.Pointer(C.malloc(size))
32 | copy(unsafe.Slice((*byte)(ptr), size), s)
33 | return uint32(uintptr(ptr)), uint32(size)
34 | }
35 |
36 | // PtrToString returns a string from WebAssembly compatible numeric types
37 | // representing its pointer and length.
38 | func PtrToString(ptr uint32, size uint32) string {
39 | return unsafe.String((*byte)(unsafe.Pointer(uintptr(ptr))), size)
40 | }
41 |
42 | // StringToPtr returns a pointer and size pair for the given string in a way
43 | // compatible with WebAssembly numeric types.
44 | // The returned pointer aliases the string hence the string must be kept alive
45 | // until ptr is no longer needed.
46 | func StringToPtr(s string) (uint32, uint32) {
47 | ptr := unsafe.Pointer(unsafe.StringData(s))
48 | return uint32(uintptr(ptr)), uint32(len(s))
49 | }
50 |
51 | // StringToLeakedPtr returns a pointer and size pair for the given string in a way
52 | // compatible with WebAssembly numeric types.
53 | // The pointer is not automatically managed by TinyGo hence it must be freed by the host.
54 | func StringToLeakedPtr(s string) (uint32, uint32) {
55 | size := C.ulong(len(s))
56 | ptr := unsafe.Pointer(C.malloc(size))
57 | copy(unsafe.Slice((*byte)(ptr), size), s)
58 | return uint32(uintptr(ptr)), uint32(size)
59 | }
60 |
--------------------------------------------------------------------------------
/lib/challenge/wasm/registration.go:
--------------------------------------------------------------------------------
1 | package wasm
2 |
3 | import (
4 | "codeberg.org/meta/gzipped/v2"
5 | "context"
6 | "errors"
7 | "fmt"
8 | "git.gammaspectra.live/git/go-away/embed"
9 | "git.gammaspectra.live/git/go-away/lib/challenge"
10 | _interface "git.gammaspectra.live/git/go-away/lib/challenge/wasm/interface"
11 | "git.gammaspectra.live/git/go-away/utils"
12 | "git.gammaspectra.live/git/go-away/utils/inline"
13 | "github.com/goccy/go-yaml"
14 | "github.com/goccy/go-yaml/ast"
15 | "github.com/tetratelabs/wazero/api"
16 | "html/template"
17 | "io"
18 | "io/fs"
19 | "net/http"
20 | "path"
21 | "time"
22 | )
23 |
24 | func init() {
25 | challenge.Runtimes["js"] = FillJavaScriptRegistration
26 | }
27 |
28 | type Parameters struct {
29 | Path string `yaml:"path"`
30 | // Loader path to js/mjs file to use as challenge issuer
31 | Loader string `yaml:"js-loader"`
32 |
33 | // Runtime path to WASM wasip1 runtime
34 | Runtime string `yaml:"wasm-runtime"`
35 |
36 | Settings map[string]string `yaml:"wasm-runtime-settings"`
37 |
38 | NativeCompiler bool `yaml:"wasm-native-compiler"`
39 |
40 | VerifyProbability float64 `yaml:"verify-probability"`
41 | }
42 |
43 | var DefaultParameters = Parameters{
44 | VerifyProbability: 0.1,
45 | NativeCompiler: true,
46 | }
47 |
48 | func FillJavaScriptRegistration(state challenge.StateInterface, reg *challenge.Registration, parameters ast.Node) error {
49 | params := DefaultParameters
50 |
51 | if parameters != nil {
52 | ymlData, err := parameters.MarshalYAML()
53 | if err != nil {
54 | return err
55 | }
56 | err = yaml.Unmarshal(ymlData, ¶ms)
57 | if err != nil {
58 | return err
59 | }
60 | }
61 |
62 | reg.Class = challenge.ClassBlocking
63 |
64 | mux := http.NewServeMux()
65 |
66 | if params.Path == "" {
67 | params.Path = reg.Name
68 | }
69 |
70 | assetsFs, err := embed.GetFallbackFS(embed.ChallengeFs, params.Path)
71 | if err != nil {
72 | return err
73 | }
74 |
75 | if params.VerifyProbability <= 0 {
76 | //10% default
77 | params.VerifyProbability = 0.1
78 | } else if params.VerifyProbability > 1.0 {
79 | params.VerifyProbability = 1.0
80 | }
81 |
82 | reg.VerifyProbability = params.VerifyProbability
83 |
84 | ob := NewRunner(params.NativeCompiler)
85 | reg.Object = ob
86 |
87 | wasmData, err := assetsFs.ReadFile(path.Join("runtime", params.Runtime))
88 | if err != nil {
89 | return fmt.Errorf("could not load runtime: %w", err)
90 | }
91 |
92 | err = ob.Compile("runtime", wasmData)
93 | if err != nil {
94 | return fmt.Errorf("compiling runtime: %w", err)
95 | }
96 |
97 | reg.IssueChallenge = func(w http.ResponseWriter, r *http.Request, key challenge.Key, expiry time.Time) challenge.VerifyResult {
98 | state.ChallengePage(w, r, state.Settings().ChallengeResponseCode, reg, map[string]any{
99 | "EndTags": []template.HTML{
100 | template.HTML(fmt.Sprintf("", reg.Path+"/script.mjs", utils.StaticCacheBust())),
101 | },
102 | })
103 | return challenge.VerifyResultNone
104 | }
105 |
106 | reg.Verify = func(key challenge.Key, token []byte, r *http.Request) (challenge.VerifyResult, error) {
107 | var ok bool
108 | err = ob.Instantiate("runtime", func(ctx context.Context, mod api.Module) (err error) {
109 | in := _interface.VerifyChallengeInput{
110 | Key: key[:],
111 | Parameters: params.Settings,
112 | Result: token,
113 | }
114 |
115 | out, err := VerifyChallengeCall(ctx, mod, in)
116 | if err != nil {
117 | return err
118 | }
119 |
120 | if out == _interface.VerifyChallengeOutputError {
121 | return errors.New("error checking challenge")
122 | }
123 | ok = out == _interface.VerifyChallengeOutputOK
124 | return nil
125 | })
126 | if err != nil {
127 | return challenge.VerifyResultFail, err
128 | }
129 | if ok {
130 | return challenge.VerifyResultOK, nil
131 | }
132 | return challenge.VerifyResultFail, nil
133 | }
134 |
135 | // serve assets if existent
136 | if staticFs, err := fs.Sub(assetsFs, "static"); err != nil {
137 | return fmt.Errorf("no static assets: %w", err)
138 | } else {
139 | mux.Handle("GET "+reg.Path+"/static/", http.StripPrefix(reg.Path+"/static/", gzipped.FileServer(gzipped.FS(staticFs))))
140 | }
141 |
142 | mux.HandleFunc(reg.Path+challenge.MakeChallengeUrlSuffix, func(w http.ResponseWriter, r *http.Request) {
143 | data := challenge.RequestDataFromContext(r.Context())
144 | err := ob.Instantiate("runtime", func(ctx context.Context, mod api.Module) (err error) {
145 | key := challenge.GetChallengeKeyForRequest(state, reg, data.Expiration(reg.Duration), r)
146 |
147 | in := _interface.MakeChallengeInput{
148 | Key: key[:],
149 | Parameters: params.Settings,
150 | Headers: inline.MIMEHeader(r.Header),
151 | }
152 | in.Data, err = io.ReadAll(r.Body)
153 | if err != nil {
154 | return err
155 | }
156 |
157 | out, err := MakeChallengeCall(ctx, mod, in)
158 | if err != nil {
159 | return err
160 | }
161 |
162 | // set output headers
163 | for k, v := range out.Headers {
164 | w.Header()[k] = v
165 | }
166 | w.Header().Set("Content-Length", fmt.Sprintf("%d", len(out.Data)))
167 | w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
168 |
169 | data.ResponseHeaders(w)
170 | w.WriteHeader(out.Code)
171 | _, _ = w.Write(out.Data)
172 | return nil
173 | })
174 | if err != nil {
175 | state.ErrorPage(w, r, http.StatusInternalServerError, err, "")
176 | return
177 | }
178 | })
179 |
180 | mux.HandleFunc(reg.Path+challenge.VerifyChallengeUrlSuffix, challenge.VerifyHandlerFunc(state, reg, nil, nil))
181 |
182 | mux.HandleFunc("GET "+reg.Path+"/script.mjs", func(w http.ResponseWriter, r *http.Request) {
183 | challenge.ServeChallengeScript(w, r, reg, params.Settings, path.Join(reg.Path, "static", params.Loader))
184 | })
185 |
186 | reg.Handler = mux
187 |
188 | return nil
189 | }
190 |
--------------------------------------------------------------------------------
/lib/challenge/wasm/runner.go:
--------------------------------------------------------------------------------
1 | package wasm
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "fmt"
7 | "github.com/tetratelabs/wazero"
8 | "github.com/tetratelabs/wazero/api"
9 | "github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1"
10 | "slices"
11 | )
12 |
13 | type Runner struct {
14 | context context.Context
15 | runtime wazero.Runtime
16 |
17 | modules map[string]wazero.CompiledModule
18 | }
19 |
20 | func NewRunner(useNativeCompiler bool) *Runner {
21 | var r Runner
22 | r.context = context.Background()
23 | var runtimeConfig wazero.RuntimeConfig
24 | if useNativeCompiler {
25 | runtimeConfig = wazero.NewRuntimeConfigCompiler()
26 | } else {
27 | runtimeConfig = wazero.NewRuntimeConfigInterpreter()
28 | }
29 | r.runtime = wazero.NewRuntimeWithConfig(r.context, runtimeConfig)
30 | wasi_snapshot_preview1.MustInstantiate(r.context, r.runtime)
31 |
32 | r.modules = make(map[string]wazero.CompiledModule)
33 |
34 | return &r
35 | }
36 |
37 | func (r *Runner) Compile(key string, binary []byte) error {
38 | module, err := r.runtime.CompileModule(r.context, binary)
39 | if err != nil {
40 | return err
41 | }
42 |
43 | // check interface
44 | functions := module.ExportedFunctions()
45 | if f, ok := functions["MakeChallenge"]; ok {
46 | if slices.Compare(f.ParamTypes(), []api.ValueType{api.ValueTypeI64}) != 0 {
47 | return fmt.Errorf("MakeChallenge does not follow parameter interface")
48 | }
49 | if slices.Compare(f.ResultTypes(), []api.ValueType{api.ValueTypeI64}) != 0 {
50 | return fmt.Errorf("MakeChallenge does not follow result interface")
51 | }
52 | } else {
53 | module.Close(r.context)
54 | return errors.New("no MakeChallenge exported")
55 | }
56 |
57 | if f, ok := functions["VerifyChallenge"]; ok {
58 | if slices.Compare(f.ParamTypes(), []api.ValueType{api.ValueTypeI64}) != 0 {
59 | return fmt.Errorf("VerifyChallenge does not follow parameter interface")
60 | }
61 | if slices.Compare(f.ResultTypes(), []api.ValueType{api.ValueTypeI64}) != 0 {
62 | return fmt.Errorf("VerifyChallenge does not follow result interface")
63 | }
64 | } else {
65 | module.Close(r.context)
66 | return errors.New("no VerifyChallenge exported")
67 | }
68 |
69 | if f, ok := functions["malloc"]; ok {
70 | if slices.Compare(f.ParamTypes(), []api.ValueType{api.ValueTypeI32}) != 0 {
71 | return fmt.Errorf("malloc does not follow parameter interface")
72 | }
73 | if slices.Compare(f.ResultTypes(), []api.ValueType{api.ValueTypeI32}) != 0 {
74 | return fmt.Errorf("malloc does not follow result interface")
75 | }
76 | } else {
77 | module.Close(r.context)
78 | return errors.New("no malloc exported")
79 | }
80 |
81 | if f, ok := functions["free"]; ok {
82 | if slices.Compare(f.ParamTypes(), []api.ValueType{api.ValueTypeI32}) != 0 {
83 | return fmt.Errorf("free does not follow parameter interface")
84 | }
85 | if slices.Compare(f.ResultTypes(), []api.ValueType{}) != 0 {
86 | return fmt.Errorf("free does not follow result interface")
87 | }
88 | } else {
89 | module.Close(r.context)
90 | return errors.New("no free exported")
91 | }
92 |
93 | r.modules[key] = module
94 | return nil
95 | }
96 |
97 | func (r *Runner) Close() error {
98 | for _, module := range r.modules {
99 | if err := module.Close(r.context); err != nil {
100 | return err
101 | }
102 | }
103 | return r.runtime.Close(r.context)
104 | }
105 |
106 | var ErrModuleNotFound = errors.New("module not found")
107 |
108 | func (r *Runner) Instantiate(key string, f func(ctx context.Context, mod api.Module) error) (err error) {
109 | compiledModule, ok := r.modules[key]
110 | if !ok {
111 | return ErrModuleNotFound
112 | }
113 | mod, err := r.runtime.InstantiateModule(
114 | r.context,
115 | compiledModule,
116 | wazero.NewModuleConfig().WithName(key).WithStartFunctions("_initialize"),
117 | )
118 | if err != nil {
119 | return err
120 | }
121 | defer mod.Close(r.context)
122 |
123 | return f(r.context, mod)
124 | }
125 |
--------------------------------------------------------------------------------
/lib/challenge/wasm/utils.go:
--------------------------------------------------------------------------------
1 | package wasm
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "errors"
7 | "git.gammaspectra.live/git/go-away/lib/challenge/wasm/interface"
8 | "github.com/tetratelabs/wazero/api"
9 | )
10 |
11 | func MakeChallengeCall(ctx context.Context, mod api.Module, in _interface.MakeChallengeInput) (*_interface.MakeChallengeOutput, error) {
12 | makeChallengeFunc := mod.ExportedFunction("MakeChallenge")
13 | malloc := mod.ExportedFunction("malloc")
14 | free := mod.ExportedFunction("free")
15 |
16 | inData, err := json.Marshal(in)
17 | if err != nil {
18 | return nil, err
19 | }
20 |
21 | mallocResult, err := malloc.Call(ctx, uint64(len(inData)))
22 | if err != nil {
23 | return nil, err
24 | }
25 | defer free.Call(ctx, mallocResult[0])
26 | if !mod.Memory().Write(uint32(mallocResult[0]), inData) {
27 | return nil, errors.New("could not write memory")
28 | }
29 | result, err := makeChallengeFunc.Call(ctx, uint64(_interface.NewAllocation(uint32(mallocResult[0]), uint32(len(inData)))))
30 | if err != nil {
31 | return nil, err
32 | }
33 | resultPtr := _interface.Allocation(result[0])
34 | outData, ok := mod.Memory().Read(resultPtr.Pointer(), resultPtr.Size())
35 | if !ok {
36 | return nil, errors.New("could not read result")
37 | }
38 | defer free.Call(ctx, uint64(resultPtr.Pointer()))
39 |
40 | var out _interface.MakeChallengeOutput
41 | err = json.Unmarshal(outData, &out)
42 | if err != nil {
43 | return nil, err
44 | }
45 | return &out, nil
46 | }
47 |
48 | func VerifyChallengeCall(ctx context.Context, mod api.Module, in _interface.VerifyChallengeInput) (_interface.VerifyChallengeOutput, error) {
49 | verifyChallengeFunc := mod.ExportedFunction("VerifyChallenge")
50 | malloc := mod.ExportedFunction("malloc")
51 | free := mod.ExportedFunction("free")
52 |
53 | inData, err := json.Marshal(in)
54 | if err != nil {
55 | return _interface.VerifyChallengeOutputError, err
56 | }
57 |
58 | mallocResult, err := malloc.Call(ctx, uint64(len(inData)))
59 | if err != nil {
60 | return _interface.VerifyChallengeOutputError, err
61 | }
62 | defer free.Call(ctx, mallocResult[0])
63 | if !mod.Memory().Write(uint32(mallocResult[0]), inData) {
64 | return _interface.VerifyChallengeOutputError, errors.New("could not write memory")
65 | }
66 | result, err := verifyChallengeFunc.Call(ctx, uint64(_interface.NewAllocation(uint32(mallocResult[0]), uint32(len(inData)))))
67 | if err != nil {
68 | return _interface.VerifyChallengeOutputError, err
69 | }
70 |
71 | return _interface.VerifyChallengeOutput(result[0]), nil
72 | }
73 |
--------------------------------------------------------------------------------
/lib/conditions.go:
--------------------------------------------------------------------------------
1 | package lib
2 |
3 | import (
4 | http_cel "codeberg.org/gone/http-cel"
5 | "fmt"
6 | "github.com/google/cel-go/cel"
7 | "github.com/google/cel-go/common/ast"
8 | "github.com/google/cel-go/common/types"
9 | "github.com/google/cel-go/common/types/ref"
10 | "log/slog"
11 | "net"
12 | )
13 |
14 | func (state *State) initConditions() (err error) {
15 | state.programEnv, err = http_cel.NewEnvironment(
16 |
17 | cel.Variable("fp", cel.MapType(cel.StringType, cel.StringType)),
18 | cel.Function("inDNSBL",
19 | cel.Overload("inDNSBL_ip",
20 | []*cel.Type{cel.AnyType},
21 | cel.BoolType,
22 | cel.UnaryBinding(func(val ref.Val) ref.Val {
23 | slog.Error("inDNSBL function has been deprecated, replace with dnsbl challenge")
24 | return types.Bool(false)
25 | }),
26 | ),
27 | ),
28 |
29 | cel.Function("network",
30 | cel.MemberOverload("netIP_network_string",
31 | []*cel.Type{cel.BytesType, cel.StringType},
32 | cel.BoolType,
33 | cel.BinaryBinding(func(lhs ref.Val, rhs ref.Val) ref.Val {
34 | var ip net.IP
35 | switch v := lhs.Value().(type) {
36 | case []byte:
37 | ip = v
38 | case net.IP:
39 | ip = v
40 | }
41 |
42 | if ip == nil {
43 | panic(fmt.Errorf("invalid ip %v", lhs.Value()))
44 | }
45 |
46 | val, ok := rhs.Value().(string)
47 | if !ok {
48 | panic(fmt.Errorf("invalid network value %v", rhs.Value()))
49 | }
50 |
51 | network, ok := state.networks[val]
52 | if !ok {
53 | _, ipNet, err := net.ParseCIDR(val)
54 | if err != nil {
55 | panic("network not found")
56 | }
57 | return types.Bool(ipNet.Contains(ip))
58 | } else {
59 | ok, err := network().Contains(ip)
60 | if err != nil {
61 | panic(err)
62 | }
63 | return types.Bool(ok)
64 | }
65 | }),
66 | ),
67 | ),
68 |
69 | cel.Function("inNetwork",
70 | cel.Overload("inNetwork_string_ip",
71 | []*cel.Type{cel.StringType, cel.BytesType},
72 | cel.BoolType,
73 | cel.BinaryBinding(func(lhs ref.Val, rhs ref.Val) ref.Val {
74 | var ip net.IP
75 | switch v := rhs.Value().(type) {
76 | case []byte:
77 | ip = v
78 | case net.IP:
79 | ip = v
80 | }
81 |
82 | if ip == nil {
83 | panic(fmt.Errorf("invalid ip %v", rhs.Value()))
84 | }
85 |
86 | val, ok := lhs.Value().(string)
87 | if !ok {
88 | panic(fmt.Errorf("invalid value %v", lhs.Value()))
89 | }
90 | slog.Debug(fmt.Sprintf("inNetwork function has been deprecated and will be removed in a future release, use remoteAddress.network(\"%s\") instead", val))
91 |
92 | network, ok := state.networks[val]
93 | if !ok {
94 | _, ipNet, err := net.ParseCIDR(val)
95 | if err != nil {
96 | panic("network not found")
97 | }
98 | return types.Bool(ipNet.Contains(ip))
99 | } else {
100 | ok, err := network().Contains(ip)
101 | if err != nil {
102 | panic(err)
103 | }
104 | return types.Bool(ok)
105 | }
106 | }),
107 | ),
108 | ),
109 | )
110 | if err != nil {
111 | return err
112 | }
113 | return nil
114 | }
115 |
116 | func (state *State) RegisterCondition(operator string, conditions ...string) (cel.Program, error) {
117 | compiledAst, err := http_cel.NewAst(state.ProgramEnv(), operator, conditions...)
118 | if err != nil {
119 | return nil, err
120 | }
121 |
122 | if out := compiledAst.OutputType(); out == nil {
123 | return nil, fmt.Errorf("no output")
124 | } else if out != types.BoolType {
125 | return nil, fmt.Errorf("output type is not bool")
126 | }
127 |
128 | walkExpr(compiledAst.NativeRep().Expr(), func(e ast.Expr) {
129 | if e.Kind() == ast.CallKind {
130 | call := e.AsCall()
131 | switch call.FunctionName() {
132 | // deprecated
133 | case "inNetwork":
134 | args := call.Args()
135 | if !call.IsMemberFunction() && len(args) == 2 {
136 | // we have a network select function
137 | switch args[1].Kind() {
138 | case ast.LiteralKind:
139 | lit := args[1].AsLiteral()
140 | if lit.Type() == types.StringType {
141 | if fn, ok := state.networks[lit.Value().(string)]; ok {
142 | // preload
143 | fn()
144 | }
145 | }
146 | }
147 |
148 | }
149 | case "network":
150 | args := call.Args()
151 | if call.IsMemberFunction() && len(args) == 1 {
152 | // we have a network select function
153 | switch args[0].Kind() {
154 | case ast.LiteralKind:
155 | lit := args[0].AsLiteral()
156 | if lit.Type() == types.StringType {
157 | if fn, ok := state.networks[lit.Value().(string)]; ok {
158 | // preload
159 | fn()
160 | }
161 | }
162 | }
163 |
164 | }
165 | }
166 | }
167 | })
168 |
169 | return http_cel.ProgramAst(state.ProgramEnv(), compiledAst)
170 | }
171 |
172 | func walkExpr(e ast.Expr, fn func(ast.Expr)) {
173 | fn(e)
174 |
175 | switch e.Kind() {
176 | case ast.CallKind:
177 | ee := e.AsCall()
178 | walkExpr(ee.Target(), fn)
179 | for _, arg := range ee.Args() {
180 | walkExpr(arg, fn)
181 | }
182 | case ast.ComprehensionKind:
183 | ee := e.AsComprehension()
184 | walkExpr(ee.Result(), fn)
185 | walkExpr(ee.IterRange(), fn)
186 | walkExpr(ee.AccuInit(), fn)
187 | walkExpr(ee.LoopCondition(), fn)
188 | walkExpr(ee.LoopStep(), fn)
189 | case ast.ListKind:
190 | ee := e.AsList()
191 | for _, element := range ee.Elements() {
192 | walkExpr(element, fn)
193 | }
194 | case ast.MapKind:
195 | ee := e.AsMap()
196 | for _, entry := range ee.Entries() {
197 | switch entry.Kind() {
198 | case ast.MapEntryKind:
199 | eee := entry.AsMapEntry()
200 | walkExpr(eee.Key(), fn)
201 | walkExpr(eee.Value(), fn)
202 | case ast.StructFieldKind:
203 | eee := entry.AsStructField()
204 | walkExpr(eee.Value(), fn)
205 | }
206 | }
207 | case ast.SelectKind:
208 | ee := e.AsSelect()
209 | walkExpr(ee.Operand(), fn)
210 | case ast.StructKind:
211 | ee := e.AsStruct()
212 | for _, field := range ee.Fields() {
213 | switch field.Kind() {
214 | case ast.MapEntryKind:
215 | eee := field.AsMapEntry()
216 | walkExpr(eee.Key(), fn)
217 | walkExpr(eee.Value(), fn)
218 | case ast.StructFieldKind:
219 | eee := field.AsStructField()
220 | walkExpr(eee.Value(), fn)
221 | }
222 | }
223 | }
224 | }
225 |
--------------------------------------------------------------------------------
/lib/interface.go:
--------------------------------------------------------------------------------
1 | package lib
2 |
3 | import (
4 | "crypto/ed25519"
5 | "git.gammaspectra.live/git/go-away/lib/challenge"
6 | "git.gammaspectra.live/git/go-away/lib/policy"
7 | "git.gammaspectra.live/git/go-away/utils"
8 | "github.com/google/cel-go/cel"
9 | "log/slog"
10 | "net/http"
11 | )
12 |
13 | // Defines challenge.StateInterface
14 |
15 | var _ challenge.StateInterface
16 |
17 | func (state *State) ProgramEnv() *cel.Env {
18 | return state.programEnv
19 | }
20 |
21 | func (state *State) Client() *http.Client {
22 | return state.client
23 | }
24 |
25 | func (state *State) PrivateKey() ed25519.PrivateKey {
26 | return state.privateKey
27 | }
28 |
29 | func (state *State) PrivateKeyFingerprint() []byte {
30 | return state.privateKeyFingerprint
31 | }
32 |
33 | func (state *State) PublicKey() ed25519.PublicKey {
34 | return state.publicKey
35 | }
36 |
37 | func (state *State) UrlPath() string {
38 | return state.urlPath
39 | }
40 |
41 | func (state *State) ChallengeFailed(r *http.Request, reg *challenge.Registration, err error, redirect string, logger *slog.Logger) {
42 | if logger == nil {
43 | logger = state.Logger(r)
44 | }
45 | logger.Warn("challenge failed", "challenge", reg.Name, "err", err, "redirect", redirect)
46 |
47 | metrics.Challenge(reg.Name, "fail")
48 | }
49 |
50 | func (state *State) ChallengePassed(r *http.Request, reg *challenge.Registration, redirect string, logger *slog.Logger) {
51 | if logger == nil {
52 | logger = state.Logger(r)
53 | }
54 | logger.Warn("challenge passed", "challenge", reg.Name, "redirect", redirect)
55 |
56 | metrics.Challenge(reg.Name, "pass")
57 | }
58 |
59 | func (state *State) ChallengeIssued(r *http.Request, reg *challenge.Registration, redirect string, logger *slog.Logger) {
60 | if logger == nil {
61 | logger = state.Logger(r)
62 | }
63 | logger.Info("challenge issued", "challenge", reg.Name, "redirect", redirect)
64 |
65 | metrics.Challenge(reg.Name, "issue")
66 | }
67 |
68 | func (state *State) ChallengeChecked(r *http.Request, reg *challenge.Registration, redirect string, logger *slog.Logger) {
69 | metrics.Challenge(reg.Name, "check")
70 | }
71 |
72 | func (state *State) RuleHit(r *http.Request, name string, logger *slog.Logger) {
73 | metrics.Rule(name, "hit")
74 | }
75 |
76 | func (state *State) RuleMiss(r *http.Request, name string, logger *slog.Logger) {
77 | metrics.Rule(name, "miss")
78 | }
79 |
80 | func (state *State) ActionHit(r *http.Request, name policy.RuleAction, logger *slog.Logger) {
81 | metrics.Action(name)
82 | }
83 |
84 | func (state *State) Logger(r *http.Request) *slog.Logger {
85 | return GetLoggerForRequest(r)
86 | }
87 |
88 | func (state *State) GetChallenge(id challenge.Id) (*challenge.Registration, bool) {
89 | reg, ok := state.challenges.Get(id)
90 | return reg, ok
91 | }
92 |
93 | func (state *State) GetChallenges() challenge.Register {
94 | return state.challenges
95 | }
96 |
97 | func (state *State) GetChallengeByName(name string) (*challenge.Registration, bool) {
98 | reg, _, ok := state.challenges.GetByName(name)
99 | return reg, ok
100 | }
101 | func (state *State) Settings() policy.StateSettings {
102 | return state.settings
103 | }
104 |
105 | func (state *State) Strings() utils.Strings {
106 | return state.opt.Strings
107 | }
108 |
109 | func (state *State) GetBackend(host string) http.Handler {
110 | return utils.SelectHTTPHandler(state.Settings().Backends, host)
111 | }
112 |
--------------------------------------------------------------------------------
/lib/metrics.go:
--------------------------------------------------------------------------------
1 | package lib
2 |
3 | import (
4 | "git.gammaspectra.live/git/go-away/lib/policy"
5 | "github.com/prometheus/client_golang/prometheus"
6 | "github.com/prometheus/client_golang/prometheus/promauto"
7 | )
8 |
9 | type stateMetrics struct {
10 | rules *prometheus.CounterVec
11 | actions *prometheus.CounterVec
12 | challenges *prometheus.CounterVec
13 | }
14 |
15 | func newMetrics() *stateMetrics {
16 | return &stateMetrics{
17 | rules: promauto.NewCounterVec(prometheus.CounterOpts{
18 | Name: "go-away_rule_results",
19 | Help: "The number of rule hits or misses",
20 | }, []string{"rule", "result"}),
21 | actions: promauto.NewCounterVec(prometheus.CounterOpts{
22 | Name: "go-away_action_results",
23 | Help: "The number of each action issued",
24 | }, []string{"action"}),
25 | challenges: promauto.NewCounterVec(prometheus.CounterOpts{
26 | Name: "go-away_challenge_results",
27 | Help: "The number of challenges issued, passed or explicitly failed",
28 | }, []string{"challenge", "action"}),
29 | }
30 | }
31 |
32 | func (metrics *stateMetrics) Rule(name, result string) {
33 | metrics.rules.With(prometheus.Labels{"rule": name, "result": result}).Inc()
34 | }
35 |
36 | func (metrics *stateMetrics) Action(action policy.RuleAction) {
37 | metrics.actions.With(prometheus.Labels{"action": string(action)}).Inc()
38 | }
39 |
40 | func (metrics *stateMetrics) Challenge(name, result string) {
41 | metrics.challenges.With(prometheus.Labels{"challenge": name, "action": result}).Inc()
42 | }
43 |
44 | func (metrics *stateMetrics) Reset() {
45 | metrics.rules.Reset()
46 | metrics.actions.Reset()
47 | metrics.challenges.Reset()
48 | }
49 |
50 | var metrics = newMetrics()
51 |
--------------------------------------------------------------------------------
/lib/policy/challenge.go:
--------------------------------------------------------------------------------
1 | package policy
2 |
3 | import (
4 | "github.com/goccy/go-yaml/ast"
5 | "time"
6 | )
7 |
8 | type Challenge struct {
9 | Conditions []string `yaml:"conditions"`
10 | Runtime string `yaml:"runtime"`
11 |
12 | Duration time.Duration `yaml:"duration"`
13 |
14 | Parameters ast.Node `yaml:"parameters,omitempty"`
15 | }
16 |
--------------------------------------------------------------------------------
/lib/policy/network.go:
--------------------------------------------------------------------------------
1 | package policy
2 |
3 | import (
4 | "encoding/json"
5 | "errors"
6 | "fmt"
7 | "git.gammaspectra.live/git/go-away/utils"
8 | "github.com/itchyny/gojq"
9 | "io"
10 | "net"
11 | "net/http"
12 | "os"
13 | "regexp"
14 | )
15 |
16 | type Network struct {
17 | // Fetches
18 | Url *string `yaml:"url,omitempty"`
19 | File *string `yaml:"file,omitempty"`
20 | ASN *int `yaml:"asn,omitempty"`
21 |
22 | // Filtering
23 | JqPath *string `yaml:"jq-path,omitempty"`
24 | Regex *string `yaml:"regex,omitempty"`
25 |
26 | Prefixes []string `yaml:"prefixes,omitempty"`
27 | }
28 |
29 | func (n Network) FetchPrefixes(c *http.Client, whois *utils.RADb) (output []net.IPNet, err error) {
30 |
31 | if len(n.Prefixes) > 0 {
32 | for _, prefix := range n.Prefixes {
33 | ipNet, err := parseCIDROrIP(prefix)
34 | if err != nil {
35 | return nil, err
36 | }
37 | output = append(output, ipNet)
38 | }
39 | }
40 |
41 | var reader io.Reader
42 | if n.Url != nil {
43 | response, err := c.Get(*n.Url)
44 | if err != nil {
45 | return nil, err
46 | }
47 | defer response.Body.Close()
48 | if response.StatusCode != http.StatusOK {
49 | return nil, fmt.Errorf("unexpected status code: %d", response.StatusCode)
50 | }
51 | reader = response.Body
52 | } else if n.File != nil {
53 | file, err := os.Open(*n.File)
54 | if err != nil {
55 | return nil, err
56 | }
57 | defer file.Close()
58 | reader = file
59 | } else if n.ASN != nil {
60 | result, err := whois.FetchASNets(*n.ASN)
61 | if err != nil {
62 | return nil, fmt.Errorf("failed to fetch ASN %d: %v", *n.ASN, err)
63 | }
64 | return result, nil
65 | } else {
66 | if len(output) > 0 {
67 | return output, nil
68 | }
69 | return nil, errors.New("no url, file or prefixes specified")
70 | }
71 |
72 | data, err := io.ReadAll(reader)
73 | if err != nil {
74 | return nil, err
75 | }
76 |
77 | if n.JqPath != nil {
78 | var jsonData any
79 | err = json.Unmarshal(data, &jsonData)
80 | if err != nil {
81 | return nil, err
82 | }
83 |
84 | query, err := gojq.Parse(*n.JqPath)
85 | if err != nil {
86 | return nil, err
87 | }
88 | iter := query.Run(jsonData)
89 | for {
90 | value, more := iter.Next()
91 | if !more {
92 | break
93 | }
94 |
95 | if strValue, ok := value.(string); ok {
96 | ipNet, err := parseCIDROrIP(strValue)
97 | if err != nil {
98 | return nil, err
99 | }
100 | output = append(output, ipNet)
101 | } else {
102 | return nil, fmt.Errorf("invalid value from jq-query: %v", value)
103 | }
104 | }
105 | return output, nil
106 | } else if n.Regex != nil {
107 | expr, err := regexp.Compile(*n.Regex)
108 | if err != nil {
109 | return nil, err
110 | }
111 | prefixName := expr.SubexpIndex("prefix")
112 | if prefixName == -1 {
113 | return nil, fmt.Errorf("invalid regex %q: could not find prefix named match", *n.Regex)
114 | }
115 | matches := expr.FindAllSubmatch(data, -1)
116 | for _, match := range matches {
117 | matchName := string(match[prefixName])
118 | ipNet, err := parseCIDROrIP(matchName)
119 | if err != nil {
120 | return nil, err
121 | }
122 | output = append(output, ipNet)
123 | }
124 | } else {
125 | return nil, errors.New("no jq-path or regex specified")
126 | }
127 | return output, nil
128 | }
129 |
130 | func parseCIDROrIP(value string) (net.IPNet, error) {
131 | _, ipNet, err := net.ParseCIDR(value)
132 | if err != nil {
133 | ip := net.ParseIP(value)
134 | if ip == nil {
135 | return net.IPNet{}, fmt.Errorf("failed to parse CIDR: %s", err)
136 | }
137 |
138 | if ip4 := ip.To4(); ip4 != nil {
139 | return net.IPNet{
140 | IP: ip4,
141 | // single ip
142 | Mask: net.CIDRMask(len(ip4)*8, len(ip4)*8),
143 | }, nil
144 | }
145 | return net.IPNet{
146 | IP: ip,
147 | // single ip
148 | Mask: net.CIDRMask(len(ip)*8, len(ip)*8),
149 | }, nil
150 | } else if ipNet != nil {
151 | return *ipNet, nil
152 | } else {
153 | return net.IPNet{}, errors.New("invalid CIDR")
154 | }
155 | }
156 |
--------------------------------------------------------------------------------
/lib/policy/policy.go:
--------------------------------------------------------------------------------
1 | package policy
2 |
3 | import (
4 | "bytes"
5 | "github.com/goccy/go-yaml"
6 | "io"
7 | "os"
8 | "path"
9 | )
10 |
11 | type Policy struct {
12 |
13 | // Networks map of networks and prefixes to be loaded
14 | Networks map[string][]Network `yaml:"networks"`
15 |
16 | Conditions map[string][]string `yaml:"conditions"`
17 |
18 | Challenges map[string]Challenge `yaml:"challenges"`
19 |
20 | Rules []Rule `yaml:"rules"`
21 | }
22 |
23 | func NewPolicy(r io.Reader, snippetsDirectories ...string) (*Policy, error) {
24 | var p Policy
25 | p.Networks = make(map[string][]Network)
26 | p.Conditions = make(map[string][]string)
27 | p.Challenges = make(map[string]Challenge)
28 |
29 | if len(snippetsDirectories) == 0 {
30 | err := yaml.NewDecoder(r).Decode(&p)
31 | if err != nil {
32 | return nil, err
33 | }
34 | } else {
35 | var entries []string
36 | for _, dir := range snippetsDirectories {
37 | if dir == "" {
38 | // skip nil directories
39 | continue
40 | }
41 | dirFiles, err := os.ReadDir(dir)
42 | if err != nil {
43 | return nil, err
44 | }
45 | for _, file := range dirFiles {
46 | if file.IsDir() {
47 | continue
48 | }
49 | entries = append(entries, path.Join(dir, file.Name()))
50 | }
51 | }
52 |
53 | err := yaml.NewDecoder(r, yaml.ReferenceFiles(entries...)).Decode(&p)
54 | if err != nil {
55 | return nil, err
56 | }
57 |
58 | // add specific entries from snippets
59 | for _, entry := range entries {
60 | var entryPolicy Policy
61 | entryData, err := os.ReadFile(entry)
62 | if err != nil {
63 | return nil, err
64 | }
65 | err = yaml.NewDecoder(bytes.NewReader(entryData), yaml.ReferenceFiles(entries...)).Decode(&entryPolicy)
66 | if err != nil {
67 | return nil, err
68 | }
69 |
70 | // add networks / conditions / challenges definitions if they don't exist already
71 |
72 | for k, v := range entryPolicy.Networks {
73 | // add network if policy entry does not exist
74 | _, ok := p.Networks[k]
75 | if !ok {
76 | p.Networks[k] = v
77 | }
78 | }
79 |
80 | for k, v := range entryPolicy.Conditions {
81 | // add condition if policy entry does not exist
82 | _, ok := p.Conditions[k]
83 | if !ok {
84 | p.Conditions[k] = v
85 | }
86 | }
87 |
88 | for k, v := range entryPolicy.Challenges {
89 | // add challenge if policy entry does not exist
90 | _, ok := p.Challenges[k]
91 | if !ok {
92 | p.Challenges[k] = v
93 | }
94 | }
95 | }
96 | }
97 | return &p, nil
98 | }
99 |
--------------------------------------------------------------------------------
/lib/policy/rule.go:
--------------------------------------------------------------------------------
1 | package policy
2 |
3 | import "github.com/goccy/go-yaml/ast"
4 |
5 | type RuleAction string
6 |
7 | const (
8 | // RuleActionNONE Does nothing. Useful for parent rules when children want to be specified
9 | RuleActionNONE RuleAction = "NONE"
10 | // RuleActionPASS Passes the connection immediately
11 | RuleActionPASS RuleAction = "PASS"
12 | // RuleActionDENY Denies the connection with a fancy page
13 | RuleActionDENY RuleAction = "DENY"
14 | // RuleActionBLOCK Denies the connection with a response code
15 | RuleActionBLOCK RuleAction = "BLOCK"
16 | // RuleActionCODE Returns a specified HTTP code
17 | RuleActionCODE RuleAction = "CODE"
18 |
19 | // RuleActionDROP Drops the connection without sending a reply
20 | RuleActionDROP RuleAction = "DROP"
21 |
22 | // RuleActionCHALLENGE Issues a challenge that when passed, passes the connection
23 | RuleActionCHALLENGE RuleAction = "CHALLENGE"
24 | // RuleActionCHECK Issues a challenge that when passed, continues checking rules
25 | RuleActionCHECK RuleAction = "CHECK"
26 |
27 | // RuleActionPROXY Proxies request to a backend, with optional path replacements
28 | RuleActionPROXY RuleAction = "PROXY"
29 |
30 | // RuleActionCONTEXT Changes Request Context information or properties
31 | RuleActionCONTEXT RuleAction = "CONTEXT"
32 | )
33 |
34 | type Rule struct {
35 | Name string `yaml:"name"`
36 | Conditions []string `yaml:"conditions"`
37 |
38 | Action string `yaml:"action"`
39 |
40 | Settings ast.Node `yaml:"settings"`
41 |
42 | Children []Rule `yaml:"children"`
43 | }
44 |
--------------------------------------------------------------------------------
/lib/policy/state.go:
--------------------------------------------------------------------------------
1 | package policy
2 |
3 | import (
4 | "git.gammaspectra.live/git/go-away/utils"
5 | "net/http"
6 | )
7 |
8 | type StateSettings struct {
9 | Cache utils.Cache
10 | Backends map[string]http.Handler
11 | PrivateKeySeed []byte
12 | MainName string
13 | MainVersion string
14 | BasePath string
15 | ClientIpHeader string
16 | BackendIpHeader string
17 |
18 | ChallengeResponseCode int
19 | }
20 |
--------------------------------------------------------------------------------
/lib/rule.go:
--------------------------------------------------------------------------------
1 | package lib
2 |
3 | import (
4 | http_cel "codeberg.org/gone/http-cel"
5 | "crypto/sha256"
6 | "encoding/hex"
7 | "fmt"
8 | "git.gammaspectra.live/git/go-away/lib/action"
9 | "git.gammaspectra.live/git/go-away/lib/challenge"
10 | "git.gammaspectra.live/git/go-away/lib/policy"
11 | "github.com/google/cel-go/cel"
12 | "github.com/google/cel-go/common/types"
13 | "github.com/google/cel-go/common/types/ref"
14 | "log/slog"
15 | "net/http"
16 | "strings"
17 | )
18 |
19 | type RuleState struct {
20 | Name string
21 | Hash string
22 |
23 | Condition cel.Program
24 |
25 | Action policy.RuleAction
26 | Handler action.Handler
27 |
28 | Children []RuleState
29 | }
30 |
31 | func NewRuleState(state challenge.StateInterface, r policy.Rule, replacer *strings.Replacer, parent *RuleState) (RuleState, error) {
32 | hasher := sha256.New()
33 | if parent != nil {
34 | hasher.Write([]byte(parent.Name))
35 | hasher.Write([]byte{0})
36 | r.Name = fmt.Sprintf("%s/%s", parent.Name, r.Name)
37 | }
38 | hasher.Write([]byte(r.Name))
39 | hasher.Write([]byte{0})
40 | hasher.Write(state.PrivateKeyFingerprint())
41 | sum := hasher.Sum(nil)
42 |
43 | rule := RuleState{
44 | Name: r.Name,
45 | Hash: hex.EncodeToString(sum[:10]),
46 | Action: policy.RuleAction(strings.ToUpper(r.Action)),
47 | }
48 |
49 | newHandler, ok := action.Register[rule.Action]
50 | if !ok {
51 | return RuleState{}, fmt.Errorf("unknown action %s", r.Action)
52 | }
53 |
54 | actionHandler, err := newHandler(state, rule.Name, rule.Hash, r.Settings)
55 | if err != nil {
56 | return RuleState{}, err
57 | }
58 | rule.Handler = actionHandler
59 |
60 | if len(r.Conditions) > 0 {
61 | // allow nesting
62 | var conditions []string
63 | for _, cond := range r.Conditions {
64 | cond = replacer.Replace(cond)
65 | conditions = append(conditions, cond)
66 | }
67 |
68 | program, err := state.RegisterCondition(http_cel.OperatorOr, conditions...)
69 | if err != nil {
70 | return RuleState{}, fmt.Errorf("error compiling condition: %w", err)
71 | }
72 | rule.Condition = program
73 | }
74 |
75 | if len(r.Children) > 0 {
76 | for _, child := range r.Children {
77 | childRule, err := NewRuleState(state, child, replacer, &rule)
78 | if err != nil {
79 | return RuleState{}, fmt.Errorf("child %s: %w", child.Name, err)
80 | }
81 | rule.Children = append(rule.Children, childRule)
82 | }
83 | }
84 |
85 | return rule, nil
86 | }
87 |
88 | func (rule RuleState) Evaluate(logger *slog.Logger, w http.ResponseWriter, r *http.Request, done func() http.Handler) (next bool, err error) {
89 | data := challenge.RequestDataFromContext(r.Context())
90 | var out ref.Val
91 |
92 | lg := logger.With("rule", rule.Name, "rule_hash", rule.Hash, "action", string(rule.Action))
93 | if rule.Condition != nil {
94 | out, _, err = rule.Condition.Eval(data)
95 | } else {
96 | // default true
97 | out = types.Bool(true)
98 | }
99 | if err != nil {
100 | lg.Error(err.Error())
101 | return false, fmt.Errorf("error: evaluating administrative rule %s/%s: %w", data.Id.String(), rule.Hash, err)
102 | } else if out != nil && out.Type() == types.BoolType {
103 | if out.Equal(types.True) == types.True {
104 | data.State.RuleHit(r, rule.Name, logger)
105 |
106 | data.State.ActionHit(r, rule.Action, logger)
107 | next, err = rule.Handler.Handle(lg, w, r, func() http.Handler {
108 | r.Header.Set("X-Away-Rule", rule.Name)
109 | r.Header.Set("X-Away-Hash", rule.Hash)
110 | r.Header.Set("X-Away-Action", string(rule.Action))
111 |
112 | return done()
113 | })
114 | if err != nil {
115 | lg.Error(err.Error())
116 | return false, fmt.Errorf("error: executing administrative rule %s/%s: %w", data.Id.String(), rule.Hash, err)
117 | }
118 |
119 | if !next {
120 | return next, nil
121 | }
122 |
123 | for _, child := range rule.Children {
124 | next, err = child.Evaluate(logger, w, r, done)
125 | if err != nil {
126 | lg.Error(err.Error())
127 | return false, fmt.Errorf("error: executing administrative rule %s/%s: %w", data.Id.String(), rule.Hash, err)
128 | }
129 |
130 | if !next {
131 | return next, nil
132 | }
133 | }
134 | } else {
135 | data.State.RuleMiss(r, rule.Name, logger)
136 | }
137 | } else if out != nil {
138 | err := fmt.Errorf("return type not Bool, got %s", out.Type().TypeName())
139 | lg.Error(err.Error())
140 | return false, fmt.Errorf("error: evaluating administrative rule %s/%s: %w", data.Id.String(), rule.Hash, err)
141 | }
142 |
143 | return true, nil
144 | }
145 |
--------------------------------------------------------------------------------
/lib/settings/backend.go:
--------------------------------------------------------------------------------
1 | package settings
2 |
3 | import (
4 | "git.gammaspectra.live/git/go-away/lib/challenge"
5 | "git.gammaspectra.live/git/go-away/utils"
6 | "net/http"
7 | "net/http/httputil"
8 | "time"
9 | )
10 |
11 | type Backend struct {
12 | // URL Target server backend path. Supports http/https/unix protocols.
13 | URL string `yaml:"url"`
14 |
15 | // Host Override the Host header and TLS SNI with this value if specified
16 | Host string `yaml:"host"`
17 |
18 | //ProxyProtocol uint8 `yaml:"proxy-protocol"`
19 |
20 | // HTTP2Enabled Enable HTTP2 to backend
21 | HTTP2Enabled bool `yaml:"http2-enabled"`
22 |
23 | // TLSSkipVerify Disable TLS certificate verification, if any
24 | TLSSkipVerify bool `yaml:"tls-skip-verify"`
25 |
26 | // IpHeader HTTP header to set containing the IP header. Set - to forcefully ignore global defaults.
27 | IpHeader string `yaml:"ip-header"`
28 |
29 | // GoDNS Resolve URL using the Go DNS server
30 | // Only relevant when running with CGO enabled
31 | GoDNS bool `yaml:"go-dns"`
32 |
33 | // Transparent Do not add extra headers onto this backend
34 | // This prevents GoAway headers from being set, or other state
35 | Transparent bool `yaml:"transparent"`
36 |
37 | // DialTimeout is the maximum amount of time a dial will wait for
38 | // a connect to complete.
39 | //
40 | // The default is no timeout.
41 | //
42 | // When using TCP and dialing a host name with multiple IP
43 | // addresses, the timeout may be divided between them.
44 | //
45 | // With or without a timeout, the operating system may impose
46 | // its own earlier timeout. For instance, TCP timeouts are
47 | // often around 3 minutes.
48 | DialTimeout time.Duration `yaml:"dial-timeout"`
49 |
50 | // TLSHandshakeTimeout specifies the maximum amount of time to
51 | // wait for a TLS handshake. Zero means no timeout.
52 | TLSHandshakeTimeout time.Duration `yaml:"tls-handshake-timeout"`
53 |
54 | // IdleConnTimeout is the maximum amount of time an idle
55 | // (keep-alive) connection will remain idle before closing
56 | // itself.
57 | // Zero means no limit.
58 | IdleConnTimeout time.Duration `yaml:"idle-conn-timeout"`
59 |
60 | // ResponseHeaderTimeout, if non-zero, specifies the amount of
61 | // time to wait for a server's response headers after fully
62 | // writing the request (including its body, if any). This
63 | // time does not include the time to read the response body.
64 | ResponseHeaderTimeout time.Duration `yaml:"response-header-timeout"`
65 |
66 | // ExpectContinueTimeout, if non-zero, specifies the amount of
67 | // time to wait for a server's first response headers after fully
68 | // writing the request headers if the request has an
69 | // "Expect: 100-continue" header. Zero means no timeout and
70 | // causes the body to be sent immediately, without
71 | // waiting for the server to approve.
72 | // This time does not include the time to send the request header.
73 | ExpectContinueTimeout time.Duration `yaml:"expect-continue-timeout"`
74 | }
75 |
76 | func (b Backend) Create() (*httputil.ReverseProxy, error) {
77 | if b.IpHeader == "-" {
78 | b.IpHeader = ""
79 | }
80 |
81 | proxy, err := utils.MakeReverseProxy(b.URL, b.GoDNS, b.DialTimeout)
82 | if err != nil {
83 | return nil, err
84 | }
85 |
86 | transport := proxy.Transport.(*http.Transport)
87 |
88 | // set transport timeouts
89 | transport.TLSHandshakeTimeout = b.TLSHandshakeTimeout
90 | transport.IdleConnTimeout = b.IdleConnTimeout
91 | transport.ResponseHeaderTimeout = b.ResponseHeaderTimeout
92 | transport.ExpectContinueTimeout = b.ExpectContinueTimeout
93 |
94 | if b.HTTP2Enabled {
95 | transport.ForceAttemptHTTP2 = true
96 | }
97 |
98 | if b.TLSSkipVerify {
99 | transport.TLSClientConfig.InsecureSkipVerify = true
100 | }
101 |
102 | if b.Host != "" {
103 | transport.TLSClientConfig.ServerName = b.Host
104 | }
105 |
106 | if b.IpHeader != "" || b.Host != "" || !b.Transparent {
107 | director := proxy.Director
108 | proxy.Director = func(req *http.Request) {
109 | if !b.Transparent {
110 | if data := challenge.RequestDataFromContext(req.Context()); data != nil {
111 | data.RequestHeaders(req.Header)
112 | }
113 | }
114 |
115 | if b.IpHeader != "" && !b.Transparent {
116 | if ip := utils.GetRemoteAddress(req.Context()); ip != nil {
117 | req.Header.Set(b.IpHeader, ip.Addr().Unmap().String())
118 | }
119 | }
120 | if b.Host != "" {
121 | req.Host = b.Host
122 | }
123 | director(req)
124 | }
125 | }
126 |
127 | /*if b.ProxyProtocol > 0 {
128 | dialContext := transport.DialContext
129 | if dialContext == nil {
130 | dialContext = (&net.Dialer{}).DialContext
131 | }
132 | transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
133 | conn, err := dialContext(ctx, network, addr)
134 | if err != nil {
135 | return nil, err
136 | }
137 | addrPort := utils.GetRemoteAddress(ctx)
138 | if addrPort == nil {
139 | // pass as is
140 | hdr := proxyproto.HeaderProxyFromAddrs(b.ProxyProtocol, conn.LocalAddr(), conn.RemoteAddr())
141 | _, err = hdr.WriteTo(conn)
142 | if err != nil {
143 | conn.Close()
144 | return nil, err
145 | }
146 | } else {
147 | // set proper headers!
148 | hdr := proxyproto.HeaderProxyFromAddrs(b.ProxyProtocol, net.TCPAddrFromAddrPort(*addrPort), conn.RemoteAddr())
149 | _, err = hdr.WriteTo(conn)
150 | if err != nil {
151 | conn.Close()
152 | return nil, err
153 | }
154 | }
155 | return conn, nil
156 | }
157 | }*/
158 |
159 | proxy.Transport = transport
160 |
161 | return proxy, nil
162 | }
163 |
--------------------------------------------------------------------------------
/lib/settings/bind.go:
--------------------------------------------------------------------------------
1 | package settings
2 |
3 | import (
4 | "context"
5 | "crypto/tls"
6 | "fmt"
7 | "git.gammaspectra.live/git/go-away/utils"
8 | "github.com/pires/go-proxyproto"
9 | "golang.org/x/crypto/acme"
10 | "golang.org/x/crypto/acme/autocert"
11 | "log/slog"
12 | "net"
13 | "net/http"
14 | "os"
15 | "strconv"
16 | "sync/atomic"
17 | "time"
18 | )
19 |
20 | type Bind struct {
21 | Address string `yaml:"address"`
22 | Network string `yaml:"network"`
23 | SocketMode string `yaml:"socket-mode"`
24 | Proxy bool `yaml:"proxy"`
25 |
26 | Passthrough bool `yaml:"passthrough"`
27 |
28 | // TLSAcmeAutoCert URL to ACME directory, or letsencrypt
29 | TLSAcmeAutoCert string `yaml:"tls-acme-autocert"`
30 |
31 | // TLSCertificate Alternate to TLSAcmeAutoCert
32 | TLSCertificate string `yaml:"tls-certificate"`
33 | // TLSPrivateKey Alternate to TLSAcmeAutoCert
34 | TLSPrivateKey string `yaml:"tls-key"`
35 |
36 | // ReadTimeout is the maximum duration for reading the entire
37 | // request, including the body. A zero or negative value means
38 | // there will be no timeout.
39 | //
40 | // Because ReadTimeout does not let Handlers make per-request
41 | // decisions on each request body's acceptable deadline or
42 | // upload rate, most users will prefer to use
43 | // ReadHeaderTimeout. It is valid to use them both.
44 | ReadTimeout time.Duration `yaml:"read-timeout"`
45 |
46 | // ReadHeaderTimeout is the amount of time allowed to read
47 | // request headers. The connection's read deadline is reset
48 | // after reading the headers and the Handler can decide what
49 | // is considered too slow for the body. If zero, the value of
50 | // ReadTimeout is used. If negative, or if zero and ReadTimeout
51 | // is zero or negative, there is no timeout.
52 | ReadHeaderTimeout time.Duration `yaml:"read-header-timeout"`
53 |
54 | // WriteTimeout is the maximum duration before timing out
55 | // writes of the response. It is reset whenever a new
56 | // request's header is read. Like ReadTimeout, it does not
57 | // let Handlers make decisions on a per-request basis.
58 | // A zero or negative value means there will be no timeout.
59 | WriteTimeout time.Duration `yaml:"write-timeout"`
60 |
61 | // IdleTimeout is the maximum amount of time to wait for the
62 | // next request when keep-alives are enabled. If zero, the value
63 | // of ReadTimeout is used. If negative, or if zero and ReadTimeout
64 | // is zero or negative, there is no timeout.
65 | IdleTimeout time.Duration `yaml:"idle-timeout"`
66 | }
67 |
68 | func (b *Bind) Listener() (net.Listener, string) {
69 | return setupListener(b.Network, b.Address, b.SocketMode, b.Proxy)
70 | }
71 |
72 | func (b *Bind) Server(backends map[string]http.Handler, acmeCachePath string) (*http.Server, func(http.Handler), error) {
73 |
74 | var tlsConfig *tls.Config
75 |
76 | if b.TLSAcmeAutoCert != "" {
77 | switch b.TLSAcmeAutoCert {
78 | case "letsencrypt":
79 | b.TLSAcmeAutoCert = acme.LetsEncryptURL
80 | }
81 |
82 | acmeManager := newACMEManager(b.TLSAcmeAutoCert, backends)
83 | if acmeCachePath != "" {
84 | err := os.MkdirAll(acmeCachePath, 0755)
85 | if err != nil {
86 | return nil, nil, fmt.Errorf("failed to create acme cache directory: %w", err)
87 | }
88 | acmeManager.Cache = autocert.DirCache(acmeCachePath)
89 | }
90 | slog.Warn(
91 | "acme-autocert enabled",
92 | "directory", b.TLSAcmeAutoCert,
93 | )
94 | tlsConfig = acmeManager.TLSConfig()
95 | } else if b.TLSCertificate != "" && b.TLSPrivateKey != "" {
96 | tlsConfig = &tls.Config{}
97 | var err error
98 | tlsConfig.Certificates = make([]tls.Certificate, 1)
99 | tlsConfig.Certificates[0], err = tls.LoadX509KeyPair(b.TLSCertificate, b.TLSPrivateKey)
100 | if err != nil {
101 | return nil, nil, err
102 | }
103 | slog.Warn(
104 | "TLS enabled",
105 | "certificate", b.TLSCertificate,
106 | )
107 | }
108 |
109 | var serverHandler atomic.Pointer[http.Handler]
110 | server := utils.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
111 | if handler := serverHandler.Load(); handler == nil {
112 | http.Error(w, http.StatusText(http.StatusBadGateway), http.StatusBadGateway)
113 | } else {
114 | (*handler).ServeHTTP(w, r)
115 | }
116 | }), tlsConfig)
117 |
118 | server.ReadTimeout = b.ReadTimeout
119 | server.ReadHeaderTimeout = b.ReadHeaderTimeout
120 | server.WriteTimeout = b.WriteTimeout
121 | server.IdleTimeout = b.IdleTimeout
122 |
123 | swap := func(handler http.Handler) {
124 | serverHandler.Store(&handler)
125 | }
126 |
127 | if b.Passthrough {
128 | // setup a passthrough handler temporarily
129 | swap(http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
130 | backend := utils.SelectHTTPHandler(backends, r.Host)
131 | if backend == nil {
132 | slog.Debug("no backend for host", "host", r.Host)
133 | http.Error(w, http.StatusText(http.StatusBadGateway), http.StatusBadGateway)
134 | } else {
135 | backend.ServeHTTP(w, r)
136 | }
137 | })))
138 | }
139 |
140 | return server, swap, nil
141 |
142 | }
143 |
144 | func setupListener(network, address, socketMode string, proxy bool) (net.Listener, string) {
145 | if network == "proxy" {
146 | network = "tcp"
147 | proxy = true
148 | }
149 |
150 | formattedAddress := ""
151 | switch network {
152 | case "unix":
153 | formattedAddress = "unix:" + address
154 | case "tcp":
155 | formattedAddress = "http://localhost" + address
156 | default:
157 | formattedAddress = fmt.Sprintf(`(%s) %s`, network, address)
158 | }
159 |
160 | listener, err := net.Listen(network, address)
161 | if err != nil {
162 | panic(fmt.Errorf("failed to bind to %s: %w", formattedAddress, err))
163 | }
164 |
165 | // additional permission handling for unix sockets
166 | if network == "unix" {
167 | mode, err := strconv.ParseUint(socketMode, 8, 0)
168 | if err != nil {
169 | listener.Close()
170 | panic(fmt.Errorf("could not parse socket mode %s: %w", socketMode, err))
171 | }
172 |
173 | err = os.Chmod(address, os.FileMode(mode))
174 | if err != nil {
175 | listener.Close()
176 | panic(fmt.Errorf("could not change socket mode: %w", err))
177 | }
178 | }
179 |
180 | if proxy {
181 | slog.Warn("listener PROXY enabled")
182 | formattedAddress += " +PROXY"
183 | listener = &proxyproto.Listener{
184 | Listener: listener,
185 | }
186 | }
187 |
188 | return listener, formattedAddress
189 | }
190 |
191 | func newACMEManager(clientDirectory string, backends map[string]http.Handler) *autocert.Manager {
192 | manager := &autocert.Manager{
193 | Prompt: autocert.AcceptTOS,
194 | HostPolicy: autocert.HostPolicy(func(ctx context.Context, host string) error {
195 | if utils.SelectHTTPHandler(backends, host) != nil {
196 | return nil
197 | }
198 | return fmt.Errorf("acme/autocert: host %s not configured in backends", host)
199 | }),
200 | Client: &acme.Client{
201 | HTTPClient: http.DefaultClient,
202 | DirectoryURL: clientDirectory,
203 | },
204 | }
205 | return manager
206 | }
207 |
--------------------------------------------------------------------------------
/lib/settings/settings.go:
--------------------------------------------------------------------------------
1 | package settings
2 |
3 | import (
4 | "git.gammaspectra.live/git/go-away/utils"
5 | "maps"
6 | )
7 |
8 | type Settings struct {
9 | Bind Bind `yaml:"bind"`
10 |
11 | Backends map[string]Backend `yaml:"backends"`
12 |
13 | BindDebug string `yaml:"bind-debug"`
14 | BindMetrics string `yaml:"bind-metrics"`
15 |
16 | Strings utils.Strings `yaml:"strings"`
17 |
18 | // Links to add to challenge/error pages like privacy/impressum.
19 | Links []Link `yaml:"links"`
20 |
21 | ChallengeTemplate string `yaml:"challenge-template"`
22 |
23 | // ChallengeTemplateOverrides Key/Value overrides for the current chosen template
24 | ChallengeTemplateOverrides map[string]string `yaml:"challenge-template-overrides"`
25 | }
26 |
27 | type Link struct {
28 | Name string `yaml:"name"`
29 | URL string `yaml:"url"`
30 | }
31 |
32 | var DefaultSettings = Settings{
33 | Strings: DefaultStrings,
34 | ChallengeTemplate: "anubis",
35 | ChallengeTemplateOverrides: func() map[string]string {
36 | m := make(map[string]string)
37 | maps.Copy(m, map[string]string{
38 | "Theme": "",
39 | "Logo": "",
40 | })
41 | return m
42 | }(),
43 |
44 | Bind: Bind{
45 | Address: ":8080",
46 | Network: "tcp",
47 | SocketMode: "0770",
48 | Proxy: false,
49 | TLSAcmeAutoCert: "",
50 | },
51 | Backends: make(map[string]Backend),
52 | }
53 |
--------------------------------------------------------------------------------
/lib/settings/strings.go:
--------------------------------------------------------------------------------
1 | package settings
2 |
3 | import (
4 | "git.gammaspectra.live/git/go-away/utils"
5 | )
6 |
7 | var DefaultStrings = utils.NewStrings(map[string]string{
8 | "title_challenge": "Checking you are not a bot",
9 | "title_error": "Oh no!",
10 |
11 | "noscript_warning": "Sadly, you may need to enable JavaScript to get past this challenge. This is required because AI companies have changed the social contract around how website hosting works.
",
12 |
13 | "details_title": "Why am I seeing this?",
14 | "details_text": `
15 |
16 | You are seeing this because the administrator of this website has set up go-away
17 | to protect the server against the scourge of AI companies aggressively scraping websites .
18 |
19 |
20 | Mass scraping can and does cause downtime for the websites, which makes their resources inaccessible for everyone.
21 |
22 |
23 | Please note that some challenges requires the use of modern JavaScript features and some plugins may disable these.
24 | Disable such plugins for this domain (for example, JShelter) if you encounter any issues.
25 |
26 | `,
27 | "details_contact_admin_with_request_id": "If you have any issues contact the site administrator and provide the following Request Id",
28 |
29 | "button_refresh_page": "Refresh page",
30 |
31 | "status_loading_challenge": "Loading challenge",
32 | "status_starting_challenge": "Starting challenge",
33 | "status_loading": "Loading...",
34 | "status_calculating": "Calculating...",
35 | "status_challenge_success": "Challenge success!",
36 | "status_challenge_done_took": "Done! Took",
37 | "status_error": "Error:",
38 | })
39 |
--------------------------------------------------------------------------------
/lib/template.go:
--------------------------------------------------------------------------------
1 | package lib
2 |
3 | import (
4 | "bytes"
5 | "git.gammaspectra.live/git/go-away/embed"
6 | "git.gammaspectra.live/git/go-away/lib/challenge"
7 | "git.gammaspectra.live/git/go-away/utils"
8 | "html/template"
9 | "maps"
10 | "net/http"
11 | )
12 |
13 | var templates map[string]*template.Template
14 |
15 | func init() {
16 |
17 | templates = make(map[string]*template.Template)
18 |
19 | dir, err := embed.TemplatesFs.ReadDir(".")
20 | if err != nil {
21 | panic(err)
22 | }
23 | for _, e := range dir {
24 | if e.IsDir() {
25 | continue
26 | }
27 | data, err := embed.TemplatesFs.ReadFile(e.Name())
28 | if err != nil {
29 | panic(err)
30 | }
31 | err = initTemplate(e.Name(), string(data))
32 | if err != nil {
33 | panic(err)
34 | }
35 | }
36 | }
37 |
38 | func initTemplate(name, data string) error {
39 | tpl := template.New(name).Funcs(template.FuncMap{
40 | "attr": func(s string) template.HTMLAttr {
41 | return template.HTMLAttr(s)
42 | },
43 | "safe": func(s string) template.HTML {
44 | return template.HTML(s)
45 | },
46 | })
47 | _, err := tpl.Parse(data)
48 | if err != nil {
49 | return err
50 | }
51 | templates[name] = tpl
52 | return nil
53 | }
54 |
55 | func (state *State) addCachedTags(data *challenge.RequestData, r *http.Request, input map[string]any) {
56 | proxyMetaTags := data.GetOptBool(challenge.RequestOptProxyMetaTags, false)
57 | proxySafeLinkTags := data.GetOptBool(challenge.RequestOptProxySafeLinkTags, false)
58 | if proxyMetaTags || proxySafeLinkTags {
59 | backend, host := data.BackendHost()
60 | if tags := state.fetchTags(host, backend, r, proxyMetaTags, proxySafeLinkTags); len(tags) > 0 {
61 | metaTagMap, _ := input["MetaTags"].([]map[string]string)
62 | linkTagMap, _ := input["LinkTags"].([]map[string]string)
63 |
64 | for _, tag := range tags {
65 | tagAttrs := make(map[string]string, len(tag.Attr))
66 | for _, v := range tag.Attr {
67 | tagAttrs[v.Key] = v.Val
68 | }
69 | metaTagMap = append(metaTagMap, tagAttrs)
70 | }
71 | input["MetaTags"] = metaTagMap
72 | input["LinkTags"] = linkTagMap
73 | }
74 | }
75 | }
76 |
77 | func (state *State) ChallengePage(w http.ResponseWriter, r *http.Request, status int, reg *challenge.Registration, params map[string]any) {
78 | data := challenge.RequestDataFromContext(r.Context())
79 | input := make(map[string]any)
80 | input["Id"] = data.Id.String()
81 | input["Random"] = utils.StaticCacheBust()
82 |
83 | input["Path"] = state.UrlPath()
84 | input["Links"] = state.opt.Links
85 | input["Strings"] = state.opt.Strings
86 | for k, v := range state.opt.ChallengeTemplateOverrides {
87 | input[k] = v
88 | }
89 |
90 | if reg != nil {
91 | input["Challenge"] = reg.Name
92 | }
93 |
94 | maps.Copy(input, params)
95 |
96 | if _, ok := input["Title"]; !ok {
97 | input["Title"] = state.opt.Strings.Get("title_challenge")
98 | }
99 |
100 | state.addCachedTags(data, r, input)
101 |
102 | w.Header().Set("Content-Type", "text/html; charset=utf-8")
103 | w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
104 |
105 | buf := bytes.NewBuffer(make([]byte, 0, 8192))
106 |
107 | err := templates["challenge-"+state.opt.ChallengeTemplate+".gohtml"].Execute(buf, input)
108 | if err != nil {
109 | state.ErrorPage(w, r, http.StatusInternalServerError, err, "")
110 | } else {
111 | data.ResponseHeaders(w)
112 | w.WriteHeader(status)
113 | _, _ = w.Write(buf.Bytes())
114 | }
115 | }
116 |
117 | func (state *State) ErrorPage(w http.ResponseWriter, r *http.Request, status int, err error, redirect string) {
118 | data := challenge.RequestDataFromContext(r.Context())
119 | w.Header().Set("Content-Type", "text/html; charset=utf-8")
120 | w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
121 |
122 | buf := bytes.NewBuffer(make([]byte, 0, 8192))
123 |
124 | input := map[string]any{
125 | "Id": data.Id.String(),
126 | "Random": utils.StaticCacheBust(),
127 | "Error": err.Error(),
128 | "Path": state.UrlPath(),
129 | "Theme": "",
130 | "Title": template.HTML(string(state.opt.Strings.Get("title_error")) + " " + http.StatusText(status)),
131 | "Challenge": "",
132 | "Redirect": redirect,
133 | "Links": state.opt.Links,
134 | "Strings": state.opt.Strings,
135 | }
136 | for k, v := range state.opt.ChallengeTemplateOverrides {
137 | input[k] = v
138 | }
139 |
140 | state.addCachedTags(data, r, input)
141 |
142 | err2 := templates["challenge-"+state.opt.ChallengeTemplate+".gohtml"].Execute(buf, input)
143 | if err2 != nil {
144 | // nested errors!
145 | panic(err2)
146 | } else {
147 | data.ResponseHeaders(w)
148 | w.WriteHeader(status)
149 | _, _ = w.Write(buf.Bytes())
150 | }
151 | }
152 |
--------------------------------------------------------------------------------
/utils/cache.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import (
4 | "errors"
5 | "os"
6 | "path"
7 | "time"
8 | )
9 |
10 | type Cache interface {
11 | Get(key string, maxAge time.Duration) ([]byte, error)
12 |
13 | Set(key string, value []byte) error
14 | }
15 |
16 | func CachePrefix(c Cache, prefix string) Cache {
17 | if c == nil {
18 | return nil
19 | }
20 | return prefixCache{
21 | c: c,
22 | prefix: prefix,
23 | }
24 | }
25 |
26 | func CacheDirectory(directory string) (Cache, error) {
27 | if stat, err := os.Stat(directory); err != nil {
28 | return nil, err
29 | } else if !stat.IsDir() {
30 | return nil, errors.New("not a directory")
31 | }
32 | return dirCache(directory), nil
33 | }
34 |
35 | type prefixCache struct {
36 | c Cache
37 | prefix string
38 | }
39 |
40 | func (c prefixCache) Get(key string, maxAge time.Duration) ([]byte, error) {
41 | return c.c.Get(c.prefix+key, maxAge)
42 | }
43 |
44 | func (c prefixCache) Set(key string, value []byte) error {
45 | return c.c.Set(c.prefix+key, value)
46 | }
47 |
48 | type dirCache string
49 |
50 | var ErrExpired = errors.New("key expired")
51 |
52 | func (d dirCache) Get(key string, maxAge time.Duration) ([]byte, error) {
53 | fname := path.Join(string(d), key)
54 | stat, err := os.Stat(fname)
55 | if err != nil {
56 | return nil, err
57 | }
58 | if stat.IsDir() {
59 | return nil, errors.New("key is directory")
60 | }
61 | data, err := os.ReadFile(fname)
62 | if err != nil {
63 | return nil, err
64 | }
65 |
66 | if stat.ModTime().Before(time.Now().Add(-maxAge)) {
67 | return data, ErrExpired
68 | } else {
69 | return data, nil
70 | }
71 | }
72 |
73 | func (d dirCache) Set(key string, value []byte) error {
74 | fname := path.Join(string(d), key)
75 | fs, err := os.Create(fname)
76 | if err != nil {
77 | return err
78 | }
79 | defer fs.Close()
80 | _, err = fs.Write(value)
81 | fs.Sync()
82 | fs.Close()
83 |
84 | _ = os.Chtimes(fname, time.Time{}, time.Now())
85 | return err
86 | }
87 |
--------------------------------------------------------------------------------
/utils/cookie.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import (
4 | "net"
5 | "net/http"
6 | "time"
7 | )
8 |
9 | var DefaultCookiePrefix = ".go-away-"
10 |
11 | // getValidHost Gets a valid host for an http.Cookie Domain field
12 | // TODO: bug: does not work with IPv6, see https://github.com/golang/go/issues/65521
13 | func getValidHost(host string) string {
14 | ipStr, _, err := net.SplitHostPort(host)
15 | if err != nil {
16 | return host
17 | }
18 | return ipStr
19 | }
20 |
21 | func SetCookie(name, value string, expiry time.Time, w http.ResponseWriter, r *http.Request) {
22 | http.SetCookie(w, &http.Cookie{
23 | Name: name,
24 | Value: value,
25 | Expires: expiry,
26 | SameSite: http.SameSiteLaxMode,
27 | Path: "/",
28 | Domain: getValidHost(r.Host),
29 | })
30 | }
31 |
32 | func ClearCookie(name string, w http.ResponseWriter, r *http.Request) {
33 | http.SetCookie(w, &http.Cookie{
34 | Name: name,
35 | Value: "",
36 | Expires: time.Now().Add(-1 * time.Hour),
37 | MaxAge: -1,
38 | SameSite: http.SameSiteLaxMode,
39 | Domain: getValidHost(r.Host),
40 | })
41 | }
42 |
--------------------------------------------------------------------------------
/utils/decaymap.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import (
4 | "sync"
5 | "time"
6 | )
7 |
8 | func zilch[T any]() T {
9 | var zero T
10 | return zero
11 | }
12 |
13 | type DecayMap[K comparable, V any] struct {
14 | data map[K]DecayMapEntry[V]
15 | lock sync.RWMutex
16 | }
17 |
18 | type DecayMapEntry[V any] struct {
19 | Value V
20 | expiry time.Time
21 | }
22 |
23 | func NewDecayMap[K comparable, V any]() *DecayMap[K, V] {
24 | return &DecayMap[K, V]{
25 | data: make(map[K]DecayMapEntry[V]),
26 | }
27 | }
28 |
29 | func (m *DecayMap[K, V]) Get(key K) (V, bool) {
30 | m.lock.RLock()
31 | value, ok := m.data[key]
32 | m.lock.RUnlock()
33 |
34 | if !ok {
35 | return zilch[V](), false
36 | }
37 |
38 | if time.Now().After(value.expiry) {
39 | m.lock.Lock()
40 | // Since previously reading m.data[key], the value may have been updated.
41 | // Delete the entry only if the expiry time is still the same.
42 | if m.data[key].expiry == value.expiry {
43 | delete(m.data, key)
44 | }
45 | m.lock.Unlock()
46 |
47 | return zilch[V](), false
48 | }
49 |
50 | return value.Value, true
51 | }
52 |
53 | func (m *DecayMap[K, V]) Set(key K, value V, ttl time.Duration) {
54 | m.lock.Lock()
55 | defer m.lock.Unlock()
56 |
57 | m.data[key] = DecayMapEntry[V]{
58 | Value: value,
59 | expiry: time.Now().Add(ttl),
60 | }
61 | }
62 |
63 | func (m *DecayMap[K, V]) Decay() {
64 | m.lock.Lock()
65 | defer m.lock.Unlock()
66 |
67 | now := time.Now()
68 | for key, entry := range m.data {
69 | if now.After(entry.expiry) {
70 | delete(m.data, key)
71 | }
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/utils/dnsbl.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import (
4 | "context"
5 | "net"
6 | "strconv"
7 | )
8 |
9 | type DNSBL struct {
10 | target string
11 | resolver *net.Resolver
12 | }
13 |
14 | func NewDNSBL(target string, resolver *net.Resolver) *DNSBL {
15 | if resolver == nil {
16 | resolver = net.DefaultResolver
17 | }
18 | return &DNSBL{
19 | target: target,
20 | resolver: resolver,
21 | }
22 | }
23 |
24 | var nibbleTable = [16]byte{
25 | '0', '1', '2', '3',
26 | '4', '5', '6', '7',
27 | '8', '9', 'a', 'b',
28 | 'c', 'd', 'e', 'f',
29 | }
30 |
31 | type DNSBLResponse uint8
32 |
33 | func (r DNSBLResponse) Bad() bool {
34 | return r != ResponseGood && r != ResponseUnknown
35 | }
36 |
37 | const (
38 | ResponseGood = DNSBLResponse(0)
39 | ResponseUnknown = DNSBLResponse(255)
40 | )
41 |
42 | func (bl DNSBL) Lookup(ctx context.Context, ip net.IP) (DNSBLResponse, error) {
43 | var target []byte
44 | if ip4 := ip.To4(); ip4 != nil {
45 | // max length preallocate
46 | target = make([]byte, 0, len(bl.target)+1+len(ip4)*4)
47 |
48 | for i := len(ip4) - 1; i >= 0; i-- {
49 | target = strconv.AppendUint(target, uint64(ip4[i]), 10)
50 | target = append(target, '.')
51 | }
52 | } else {
53 | // IPv6
54 | // max length preallocate
55 | target = make([]byte, 0, len(bl.target)+1+len(ip)*4)
56 |
57 | for i := len(ip) - 1; i >= 0; i-- {
58 | target = append(target, nibbleTable[ip[i]&0xf], '.', ip[i]>>4, '.')
59 | }
60 | }
61 |
62 | target = append(target, bl.target...)
63 |
64 | ips, err := bl.resolver.LookupIP(ctx, "ip4", string(target))
65 | if err != nil {
66 | return ResponseUnknown, err
67 | }
68 |
69 | for _, ip := range ips {
70 | ip4 := ip.To4()
71 | return DNSBLResponse(ip4[len(ip4)-1]), nil
72 | }
73 |
74 | return ResponseUnknown, nil
75 | }
76 |
--------------------------------------------------------------------------------
/utils/http.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import (
4 | "context"
5 | "crypto/rand"
6 | "crypto/tls"
7 | "encoding/base64"
8 | "errors"
9 | "fmt"
10 | "net"
11 | "net/http"
12 | "net/http/httputil"
13 | "net/netip"
14 | "net/url"
15 | "strings"
16 | "time"
17 | )
18 |
19 | func NewServer(handler http.Handler, tlsConfig *tls.Config) *http.Server {
20 | if tlsConfig == nil {
21 | proto := new(http.Protocols)
22 | proto.SetHTTP1(true)
23 | proto.SetUnencryptedHTTP2(true)
24 | h1s := &http.Server{
25 | Handler: handler,
26 | Protocols: proto,
27 | }
28 |
29 | return h1s
30 | } else {
31 | server := &http.Server{
32 | TLSConfig: tlsConfig,
33 | Handler: handler,
34 | }
35 | applyTLSFingerprinter(server)
36 | return server
37 | }
38 | }
39 |
40 | func SelectHTTPHandler(backends map[string]http.Handler, host string) http.Handler {
41 | backend, ok := backends[host]
42 | if !ok {
43 | // do wildcard match
44 | wildcard := "*." + strings.Join(strings.Split(host, ".")[1:], ".")
45 | backend, ok = backends[wildcard]
46 |
47 | if !ok {
48 | // return fallback
49 | backend = backends["*"]
50 | }
51 | }
52 | return backend
53 | }
54 |
55 | func EnsureNoOpenRedirect(redirect string) (string, error) {
56 | uri, err := url.Parse(redirect)
57 | if err != nil {
58 | return "", err
59 | }
60 | uri.Scheme = ""
61 | uri.Host = ""
62 | uri.User = nil
63 | uri.Opaque = ""
64 | uri.OmitHost = true
65 |
66 | if uri.Path != "" && !strings.HasPrefix(uri.Path, "/") {
67 | return "", errors.New("invalid redirect path")
68 | }
69 |
70 | return uri.String(), nil
71 | }
72 |
73 | func MakeReverseProxy(target string, goDns bool, dialTimeout time.Duration) (*httputil.ReverseProxy, error) {
74 | u, err := url.Parse(target)
75 | if err != nil {
76 | return nil, fmt.Errorf("failed to parse target URL: %w", err)
77 | }
78 |
79 | transport := http.DefaultTransport.(*http.Transport).Clone()
80 | transport.TLSClientConfig = &tls.Config{}
81 |
82 | // https://github.com/oauth2-proxy/oauth2-proxy/blob/4e2100a2879ef06aea1411790327019c1a09217c/pkg/upstream/http.go#L124
83 | if u.Scheme == "unix" {
84 | // clean path up so we don't use the socket path in proxied requests
85 | addr := u.Path
86 | u.Path = ""
87 | // tell transport how to dial unix sockets
88 | transport.DialContext = func(ctx context.Context, _, _ string) (net.Conn, error) {
89 | dialer := net.Dialer{
90 | Timeout: dialTimeout,
91 | }
92 | return dialer.DialContext(ctx, "unix", addr)
93 | }
94 | // tell transport how to handle the unix url scheme
95 | transport.RegisterProtocol("unix", UnixRoundTripper{Transport: transport})
96 | } else if goDns {
97 | dialer := &net.Dialer{
98 | Resolver: &net.Resolver{
99 | PreferGo: true,
100 | },
101 | Timeout: dialTimeout,
102 | }
103 | transport.DialContext = dialer.DialContext
104 | } else {
105 | dialer := &net.Dialer{
106 | Timeout: dialTimeout,
107 | }
108 | transport.DialContext = dialer.DialContext
109 | }
110 |
111 | rp := httputil.NewSingleHostReverseProxy(u)
112 |
113 | rp.Transport = transport
114 |
115 | return rp, nil
116 | }
117 |
118 | func GetRequestScheme(r *http.Request) string {
119 | if proto := r.Header.Get("X-Forwarded-Proto"); proto == "http" || proto == "https" {
120 | return proto
121 | }
122 |
123 | if r.TLS != nil {
124 | return "https"
125 | }
126 |
127 | return "http"
128 | }
129 |
130 | func GetRequestAddress(r *http.Request, clientHeader string) netip.AddrPort {
131 | strVal := r.RemoteAddr
132 |
133 | if clientHeader != "" {
134 | strVal = r.Header.Get(clientHeader)
135 | }
136 | if strVal != "" {
137 | // handle X-Forwarded-For
138 | strVal = strings.Split(strVal, ",")[0]
139 | }
140 |
141 | // fallback
142 | if strVal == "" {
143 | strVal = r.RemoteAddr
144 | }
145 |
146 | addrPort, err := netip.ParseAddrPort(strVal)
147 | if err != nil {
148 | addr, err2 := netip.ParseAddr(strVal)
149 | if err2 != nil {
150 | return netip.AddrPort{}
151 | }
152 | addrPort = netip.AddrPortFrom(addr, 0)
153 | }
154 | return addrPort
155 | }
156 |
157 | type remoteAddress struct{}
158 |
159 | func SetRemoteAddress(r *http.Request, addrPort netip.AddrPort) *http.Request {
160 | return r.WithContext(context.WithValue(r.Context(), remoteAddress{}, addrPort))
161 | }
162 | func GetRemoteAddress(ctx context.Context) *netip.AddrPort {
163 | ip, ok := ctx.Value(remoteAddress{}).(netip.AddrPort)
164 | if !ok {
165 | return nil
166 | }
167 | return &ip
168 | }
169 |
170 | func RandomCacheBust(n int) string {
171 | buf := make([]byte, n)
172 | _, _ = rand.Read(buf)
173 | return base64.RawURLEncoding.EncodeToString(buf)
174 | }
175 |
176 | var staticCacheBust = RandomCacheBust(16)
177 |
178 | func StaticCacheBust() string {
179 | return staticCacheBust
180 | }
181 |
--------------------------------------------------------------------------------
/utils/inline/hex.go:
--------------------------------------------------------------------------------
1 | package inline
2 |
3 | import (
4 | "errors"
5 | )
6 |
7 | const (
8 | hextable = "0123456789abcdef"
9 | reverseHexTable = "" +
10 | "\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff" +
11 | "\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff" +
12 | "\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff" +
13 | "\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\xff\xff\xff\xff\xff\xff" +
14 | "\xff\x0a\x0b\x0c\x0d\x0e\x0f\xff\xff\xff\xff\xff\xff\xff\xff\xff" +
15 | "\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff" +
16 | "\xff\x0a\x0b\x0c\x0d\x0e\x0f\xff\xff\xff\xff\xff\xff\xff\xff\xff" +
17 | "\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff" +
18 | "\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff" +
19 | "\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff" +
20 | "\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff" +
21 | "\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff" +
22 | "\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff" +
23 | "\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff" +
24 | "\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff" +
25 | "\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff"
26 | )
27 |
28 | // EncodedLen returns the length of an encoding of n source bytes.
29 | // Specifically, it returns n * 2.
30 | func EncodedLen(n int) int { return n * 2 }
31 |
32 | // Encode encodes src into [EncodedLen](len(src))
33 | // bytes of dst. As a convenience, it returns the number
34 | // of bytes written to dst, but this value is always [EncodedLen](len(src)).
35 | // Encode implements hexadecimal encoding.
36 | func Encode(dst, src []byte) int {
37 | j := 0
38 | for _, v := range src {
39 | dst[j] = hextable[v>>4]
40 | dst[j+1] = hextable[v&0x0f]
41 | j += 2
42 | }
43 | return len(src) * 2
44 | }
45 |
46 | // ErrLength reports an attempt to decode an odd-length input
47 | // using [Decode] or [DecodeString].
48 | // The stream-based Decoder returns [io.ErrUnexpectedEOF] instead of ErrLength.
49 | var ErrLength = errors.New("encoding/hex: odd length hex string")
50 |
51 | // InvalidByteError values describe errors resulting from an invalid byte in a hex string.
52 | type InvalidByteError byte
53 |
54 | func (e InvalidByteError) Error() string {
55 | return "encoding/hex: invalid byte"
56 | }
57 |
58 | // DecodedLen returns the length of a decoding of x source bytes.
59 | // Specifically, it returns x / 2.
60 | func DecodedLen(x int) int { return x / 2 }
61 |
62 | // Decode decodes src into [DecodedLen](len(src)) bytes,
63 | // returning the actual number of bytes written to dst.
64 | //
65 | // Decode expects that src contains only hexadecimal
66 | // characters and that src has even length.
67 | // If the input is malformed, Decode returns the number
68 | // of bytes decoded before the error.
69 | func Decode(dst, src []byte) (int, error) {
70 | i, j := 0, 1
71 | for ; j < len(src); j += 2 {
72 | p := src[j-1]
73 | q := src[j]
74 |
75 | a := reverseHexTable[p]
76 | b := reverseHexTable[q]
77 | if a > 0x0f {
78 | return i, InvalidByteError(p)
79 | }
80 | if b > 0x0f {
81 | return i, InvalidByteError(q)
82 | }
83 | dst[i] = (a << 4) | b
84 | i++
85 | }
86 | if len(src)%2 == 1 {
87 | // Check for invalid char before reporting bad length,
88 | // since the invalid char (if present) is an earlier problem.
89 | if reverseHexTable[src[j-1]] > 0x0f {
90 | return i, InvalidByteError(src[j-1])
91 | }
92 | return i, ErrLength
93 | }
94 | return i, nil
95 | }
96 |
--------------------------------------------------------------------------------
/utils/inline/mime.go:
--------------------------------------------------------------------------------
1 | package inline
2 |
3 | // from textproto
4 |
5 | // A MIMEHeader represents a MIME-style header mapping
6 | // keys to sets of values.
7 | type MIMEHeader map[string][]string
8 |
9 | // Add adds the key, value pair to the header.
10 | // It appends to any existing values associated with key.
11 | func (h MIMEHeader) Add(key, value string) {
12 | key = CanonicalMIMEHeaderKey(key)
13 | h[key] = append(h[key], value)
14 | }
15 |
16 | // Set sets the header entries associated with key to
17 | // the single element value. It replaces any existing
18 | // values associated with key.
19 | func (h MIMEHeader) Set(key, value string) {
20 | h[CanonicalMIMEHeaderKey(key)] = []string{value}
21 | }
22 |
23 | // Get gets the first value associated with the given key.
24 | // It is case insensitive; [CanonicalMIMEHeaderKey] is used
25 | // to canonicalize the provided key.
26 | // If there are no values associated with the key, Get returns "".
27 | // To use non-canonical keys, access the map directly.
28 | func (h MIMEHeader) Get(key string) string {
29 | if h == nil {
30 | return ""
31 | }
32 | v := h[CanonicalMIMEHeaderKey(key)]
33 | if len(v) == 0 {
34 | return ""
35 | }
36 | return v[0]
37 | }
38 |
39 | // Values returns all values associated with the given key.
40 | // It is case insensitive; [CanonicalMIMEHeaderKey] is
41 | // used to canonicalize the provided key. To use non-canonical
42 | // keys, access the map directly.
43 | // The returned slice is not a copy.
44 | func (h MIMEHeader) Values(key string) []string {
45 | if h == nil {
46 | return nil
47 | }
48 | return h[CanonicalMIMEHeaderKey(key)]
49 | }
50 |
51 | // Del deletes the values associated with key.
52 | func (h MIMEHeader) Del(key string) {
53 | delete(h, CanonicalMIMEHeaderKey(key))
54 | }
55 |
56 | // CanonicalMIMEHeaderKey returns the canonical format of the
57 | // MIME header key s. The canonicalization converts the first
58 | // letter and any letter following a hyphen to upper case;
59 | // the rest are converted to lowercase. For example, the
60 | // canonical key for "accept-encoding" is "Accept-Encoding".
61 | // MIME header keys are assumed to be ASCII only.
62 | // If s contains a space or invalid header field bytes, it is
63 | // returned without modifications.
64 | func CanonicalMIMEHeaderKey(s string) string {
65 | // Quick check for canonical encoding.
66 | upper := true
67 | for i := 0; i < len(s); i++ {
68 | c := s[i]
69 | if !validHeaderFieldByte(c) {
70 | return s
71 | }
72 | if upper && 'a' <= c && c <= 'z' {
73 | s, _ = canonicalMIMEHeaderKey([]byte(s))
74 | return s
75 | }
76 | if !upper && 'A' <= c && c <= 'Z' {
77 | s, _ = canonicalMIMEHeaderKey([]byte(s))
78 | return s
79 | }
80 | upper = c == '-'
81 | }
82 | return s
83 | }
84 |
85 | const toLower = 'a' - 'A'
86 |
87 | // validHeaderFieldByte reports whether c is a valid byte in a header
88 | // field name. RFC 7230 says:
89 | //
90 | // header-field = field-name ":" OWS field-value OWS
91 | // field-name = token
92 | // tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" / "+" / "-" / "." /
93 | // "^" / "_" / "`" / "|" / "~" / DIGIT / ALPHA
94 | // token = 1*tchar
95 | func validHeaderFieldByte(c byte) bool {
96 | // mask is a 128-bit bitmap with 1s for allowed bytes,
97 | // so that the byte c can be tested with a shift and an and.
98 | // If c >= 128, then 1<>64)) != 0
121 | }
122 |
123 | // canonicalMIMEHeaderKey is like CanonicalMIMEHeaderKey but is
124 | // allowed to mutate the provided byte slice before returning the
125 | // string.
126 | //
127 | // For invalid inputs (if a contains spaces or non-token bytes), a
128 | // is unchanged and a string copy is returned.
129 | //
130 | // ok is true if the header key contains only valid characters and spaces.
131 | // ReadMIMEHeader accepts header keys containing spaces, but does not
132 | // canonicalize them.
133 | func canonicalMIMEHeaderKey(a []byte) (_ string, ok bool) {
134 | if len(a) == 0 {
135 | return "", false
136 | }
137 |
138 | // See if a looks like a header key. If not, return it unchanged.
139 | noCanon := false
140 | for _, c := range a {
141 | if validHeaderFieldByte(c) {
142 | continue
143 | }
144 | // Don't canonicalize.
145 | if c == ' ' {
146 | // We accept invalid headers with a space before the
147 | // colon, but must not canonicalize them.
148 | // See https://go.dev/issue/34540.
149 | noCanon = true
150 | continue
151 | }
152 | return string(a), false
153 | }
154 | if noCanon {
155 | return string(a), true
156 | }
157 |
158 | upper := true
159 | for i, c := range a {
160 | // Canonicalize: first letter upper case
161 | // and upper case after each dash.
162 | // (Host, User-Agent, If-Modified-Since).
163 | // MIME headers are ASCII only, so no Unicode issues.
164 | if upper && 'a' <= c && c <= 'z' {
165 | c -= toLower
166 | } else if !upper && 'A' <= c && c <= 'Z' {
167 | c += toLower
168 | }
169 | a[i] = c
170 | upper = c == '-' // for next time
171 | }
172 |
173 | // The compiler recognizes m[string(byteSlice)] as a special
174 | // case, so a copy of a's bytes into a new string does not
175 | // happen in this map lookup:
176 | if v := commonHeader[string(a)]; v != "" {
177 | return v, true
178 | }
179 | return string(a), true
180 | }
181 |
182 | func init() {
183 | initCommonHeader()
184 | }
185 |
186 | // commonHeader interns common header strings.
187 | var commonHeader map[string]string
188 |
189 | func initCommonHeader() {
190 | commonHeader = make(map[string]string)
191 | for _, v := range []string{
192 | "Accept",
193 | "Accept-Charset",
194 | "Accept-Encoding",
195 | "Accept-Language",
196 | "Accept-Ranges",
197 | "Cache-Control",
198 | "Cc",
199 | "Connection",
200 | "Content-Id",
201 | "Content-Language",
202 | "Content-Length",
203 | "Content-Transfer-Encoding",
204 | "Content-Type",
205 | "Cookie",
206 | "Date",
207 | "Dkim-Signature",
208 | "Etag",
209 | "Expires",
210 | "From",
211 | "Host",
212 | "If-Modified-Since",
213 | "If-None-Match",
214 | "In-Reply-To",
215 | "Last-Modified",
216 | "Location",
217 | "Message-Id",
218 | "Mime-Version",
219 | "Pragma",
220 | "Received",
221 | "Return-Path",
222 | "Server",
223 | "Set-Cookie",
224 | "Subject",
225 | "To",
226 | "User-Agent",
227 | "Via",
228 | "X-Forwarded-For",
229 | "X-Imforwards",
230 | "X-Powered-By",
231 | } {
232 | commonHeader[v] = v
233 | }
234 | }
235 |
--------------------------------------------------------------------------------
/utils/radb.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import (
4 | "bufio"
5 | "bytes"
6 | "fmt"
7 | "net"
8 | "regexp"
9 | "strings"
10 | "time"
11 | )
12 |
13 | type RADb struct {
14 | target string
15 | dialer net.Dialer
16 | }
17 |
18 | const RADBServer = "whois.radb.net:43"
19 |
20 | func NewRADb() (*RADb, error) {
21 |
22 | host, port, err := net.SplitHostPort(RADBServer)
23 | if err != nil {
24 | return nil, err
25 | }
26 |
27 | return &RADb{
28 | target: fmt.Sprintf("%s:%s", host, port),
29 | dialer: net.Dialer{
30 | Timeout: 5 * time.Second,
31 | },
32 | }, nil
33 | }
34 |
35 | var whoisRouteRegex = regexp.MustCompile("(?P(([0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+)|([0-9a-f:]+::))/[0-9]+)")
36 |
37 | func (db *RADb) query(fn func(n int, record []byte) error, queries ...string) error {
38 |
39 | conn, err := db.dialer.Dial("tcp", db.target)
40 | if err != nil {
41 | return err
42 | }
43 | defer conn.Close()
44 |
45 | if len(queries) > 1 {
46 | // enable persistent conn
47 | _ = conn.SetDeadline(time.Now().Add(time.Second * 5))
48 | _, err = conn.Write([]byte("!!\n"))
49 | if err != nil {
50 | return err
51 | }
52 | }
53 |
54 | scanner := bufio.NewScanner(conn)
55 | scanner.Split(bufio.ScanLines)
56 | // 16 MiB lines
57 | const bufferSize = 1024 * 1024 * 16
58 | scanner.Buffer(make([]byte, 0, bufferSize), bufferSize)
59 |
60 | for _, q := range queries {
61 |
62 | _ = conn.SetDeadline(time.Now().Add(time.Second * 5))
63 | _, err = conn.Write([]byte(strings.TrimSpace(q) + "\n"))
64 | if err != nil {
65 | return err
66 | }
67 |
68 | n := 0
69 |
70 | for scanner.Scan() {
71 | buf := bytes.Trim(scanner.Bytes(), "\r\n")
72 | if bytes.HasPrefix(buf, []byte("%")) || bytes.Equal(buf, []byte("C")) {
73 | // end of record
74 | break
75 | }
76 | err = fn(n, buf)
77 | if err != nil {
78 | return err
79 | }
80 | n++
81 | }
82 |
83 | if scanner.Err() != nil {
84 | return scanner.Err()
85 | }
86 | }
87 |
88 | if len(queries) > 1 {
89 | // exit
90 | _ = conn.SetDeadline(time.Now().Add(time.Second * 5))
91 | _, err = conn.Write([]byte("q\n"))
92 | if err != nil {
93 | return err
94 | }
95 | }
96 |
97 | return nil
98 | }
99 |
100 | func (db *RADb) FetchIPInfo(ip net.IP) (result []string, err error) {
101 | var ipNet net.IPNet
102 | if ip4 := ip.To4(); ip4 != nil {
103 | ipNet = net.IPNet{
104 | IP: ip4,
105 | // single ip
106 | Mask: net.CIDRMask(len(ip4)*8, len(ip4)*8),
107 | }
108 | } else {
109 | ipNet = net.IPNet{
110 | IP: ip,
111 | // single ip
112 | Mask: net.CIDRMask(len(ip)*8, len(ip)*8),
113 | }
114 | }
115 |
116 | err = db.query(func(n int, record []byte) error {
117 | result = append(result, string(record))
118 | return nil
119 | }, fmt.Sprintf("!r%s,l", ipNet.String()))
120 |
121 | if err != nil {
122 | return nil, err
123 | }
124 |
125 | return result, nil
126 | }
127 |
128 | func (db *RADb) FetchASNets(asn int) (result []net.IPNet, err error) {
129 |
130 | ix := whoisRouteRegex.SubexpIndex("prefix")
131 | if ix == -1 {
132 | panic("invalid regex prefix")
133 | }
134 |
135 | var data []byte
136 |
137 | err = db.query(func(n int, record []byte) error {
138 | if n == 0 {
139 | // do not append ASN number reply
140 | return nil
141 | }
142 | // pad data
143 | if n == 1 {
144 | data = append(data, ' ')
145 | }
146 | data = append(data, record...)
147 | return nil
148 | },
149 | // See https://www.radb.net/query/help
150 | // fetch IPv4 routes
151 | fmt.Sprintf("!gas%d", asn),
152 | // fetch IPv6 routes
153 | fmt.Sprintf("!6as%d", asn),
154 | )
155 | if err != nil {
156 | return nil, err
157 | }
158 |
159 | matches := whoisRouteRegex.FindAllSubmatch(data, -1)
160 | for _, match := range matches {
161 | _, ipNet, err := net.ParseCIDR(string(match[ix]))
162 | if err != nil {
163 | return nil, fmt.Errorf("invalid CIDR %s: %w", string(match[ix]), err)
164 | }
165 | result = append(result, *ipNet)
166 | }
167 |
168 | return result, nil
169 | }
170 |
--------------------------------------------------------------------------------
/utils/strings.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import (
4 | "html/template"
5 | "maps"
6 | )
7 |
8 | type Strings map[string]string
9 |
10 | func (s Strings) set(v map[string]string) Strings {
11 | maps.Copy(s, v)
12 | return s
13 | }
14 |
15 | func (s Strings) Get(value string) template.HTML {
16 | v, ok := (s)[value]
17 | if !ok {
18 | // fallback
19 | return template.HTML("string:" + value)
20 | }
21 | return template.HTML(v)
22 | }
23 |
24 | func NewStrings[T ~map[string]string](v T) Strings {
25 | return make(Strings).set(v)
26 | }
27 |
--------------------------------------------------------------------------------
/utils/tagfetcher.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import (
4 | "golang.org/x/net/html"
5 | "mime"
6 | "net/http"
7 | "net/http/httptest"
8 | "net/url"
9 | "slices"
10 | )
11 |
12 | func FetchTags(backend http.Handler, uri *url.URL, kinds ...string) (result []html.Node) {
13 | writer := httptest.NewRecorder()
14 | backend.ServeHTTP(writer, &http.Request{
15 | Method: http.MethodGet,
16 | URL: uri,
17 | Header: http.Header{
18 | "User-Agent": []string{"Mozilla 5.0 (compatible; go-away/1.0 fetch-tags) TwitterBot/1.0"},
19 | "Accept": []string{"text/html,application/xhtml+xml"},
20 | },
21 | Close: true,
22 | })
23 | response := writer.Result()
24 | if response == nil {
25 | return nil
26 | }
27 | defer response.Body.Close()
28 | if response.StatusCode != http.StatusOK {
29 | return nil
30 | }
31 |
32 | if contentType, _, _ := mime.ParseMediaType(response.Header.Get("Content-Type")); contentType != "text/html" && contentType != "application/xhtml+xml" {
33 | return nil
34 | }
35 |
36 | //TODO: handle non UTF-8 documents
37 | node, err := html.ParseWithOptions(response.Body, html.ParseOptionEnableScripting(false))
38 | if err != nil {
39 | return nil
40 | }
41 |
42 | for n := range node.Descendants() {
43 | if n.Type == html.ElementNode && slices.Contains(kinds, n.Data) {
44 | result = append(result, html.Node{
45 | Type: n.Type,
46 | DataAtom: n.DataAtom,
47 | Data: n.Data,
48 | Namespace: n.Namespace,
49 | Attr: n.Attr,
50 | })
51 | }
52 | }
53 |
54 | return result
55 | }
56 |
--------------------------------------------------------------------------------
/utils/unix.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import "net/http"
4 |
5 | // UnixRoundTripper https://github.com/oauth2-proxy/oauth2-proxy/blob/master/pkg/upstream/http.go#L124
6 | type UnixRoundTripper struct {
7 | Transport *http.Transport
8 | }
9 |
10 | // RoundTrip set bare minimum stuff
11 | func (t UnixRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
12 | req = req.Clone(req.Context())
13 | if req.Host == "" {
14 | req.Host = "localhost"
15 | }
16 | req.URL.Host = req.Host // proxy error: no Host in request URL
17 | req.URL.Scheme = "http" // make http.Transport happy and avoid an infinite recursion
18 | return t.Transport.RoundTrip(req)
19 | }
20 |
--------------------------------------------------------------------------------