├── .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 | 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 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 |
40 | 47 | 48 |
49 |
50 |
51 |
52 | 53 |
54 |
55 |

56 | {{ .Title }} 57 |

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 | 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 | --------------------------------------------------------------------------------