Compare commits
2 Commits
tls-entrie
...
tests
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a6c3fef07 | ||
|
|
467ad9c5a9 |
@@ -12,6 +12,7 @@ local Build(mirror, go, alpine, os, arch) = {
|
||||
CGO_ENABLED: "0",
|
||||
GOOS: os,
|
||||
GOARCH: arch,
|
||||
GORACE: "halt_on_error=1"
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
@@ -26,6 +27,16 @@ local Build(mirror, go, alpine, os, arch) = {
|
||||
"go build -v -o ./.bin/test-wasm-runtime ./cmd/test-wasm-runtime",
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "test",
|
||||
image: "golang:" + go +"-alpine" + alpine,
|
||||
mirror: mirror,
|
||||
commands: [
|
||||
"apk update",
|
||||
"apk add --no-cache git",
|
||||
"go test -p 1 -timeout 20m -v ./tests/"
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "check-policy-forgejo",
|
||||
image: "alpine:" + alpine,
|
||||
@@ -92,6 +103,16 @@ local Publish(mirror, registry, repo, secret, go, alpine, os, arch, trigger, pla
|
||||
},
|
||||
trigger: trigger,
|
||||
steps: [
|
||||
{
|
||||
name: "test",
|
||||
image: "golang:" + go +"-alpine" + alpine,
|
||||
mirror: mirror,
|
||||
commands: [
|
||||
"apk update",
|
||||
"apk add --no-cache git",
|
||||
"go test -p 1 -timeout 20m -v ./tests/"
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "setup-buildkitd",
|
||||
image: "alpine:" + alpine,
|
||||
@@ -144,11 +165,8 @@ local goVersion = "1.24";
|
||||
local mirror = "https://mirror.gcr.io";
|
||||
|
||||
[
|
||||
Build(mirror, goVersion, alpineVersion, "linux", "amd64") + {"trigger": {event: ["push", "tag"], }},
|
||||
Build(mirror, goVersion, alpineVersion, "linux", "arm64") + {"trigger": {event: ["push", "tag"], }},
|
||||
|
||||
# Test PRs
|
||||
Build(mirror, goVersion, alpineVersion, "linux", "amd64") + {"name": "test-pr", "trigger": {event: ["pull_request"], }},
|
||||
Build(mirror, goVersion, alpineVersion, "linux", "amd64"),
|
||||
Build(mirror, goVersion, alpineVersion, "linux", "arm64"),
|
||||
|
||||
# latest
|
||||
Publish(mirror, "git.gammaspectra.live", "git.gammaspectra.live/git/go-away", "git", goVersion, alpineVersion, "linux", "amd64", {event: ["push"], branch: ["master"], }, containerArchitectures, {tags: ["latest"],}) + {name: "publish-latest-git"},
|
||||
|
||||
129
.drone.yml
129
.drone.yml
@@ -3,6 +3,7 @@ environment:
|
||||
CGO_ENABLED: "0"
|
||||
GOARCH: amd64
|
||||
GOOS: linux
|
||||
GORACE: halt_on_error=1
|
||||
GOTOOLCHAIN: local
|
||||
kind: pipeline
|
||||
name: build-1.24-alpine3.21-amd64
|
||||
@@ -19,6 +20,13 @@ steps:
|
||||
image: golang:1.24-alpine3.21
|
||||
mirror: https://mirror.gcr.io
|
||||
name: build
|
||||
- commands:
|
||||
- apk update
|
||||
- apk add --no-cache git
|
||||
- go test -p 1 -timeout 20m -v ./tests/
|
||||
image: golang:1.24-alpine3.21
|
||||
mirror: https://mirror.gcr.io
|
||||
name: test
|
||||
- commands:
|
||||
- ./.bin/go-away --check --slog-level DEBUG --backend example.com=http://127.0.0.1:80
|
||||
--policy examples/forgejo.yml --policy-snippets examples/snippets/
|
||||
@@ -65,16 +73,13 @@ steps:
|
||||
image: alpine:3.21
|
||||
mirror: https://mirror.gcr.io
|
||||
name: test-wasm-fail
|
||||
trigger:
|
||||
event:
|
||||
- push
|
||||
- tag
|
||||
type: docker
|
||||
---
|
||||
environment:
|
||||
CGO_ENABLED: "0"
|
||||
GOARCH: arm64
|
||||
GOOS: linux
|
||||
GORACE: halt_on_error=1
|
||||
GOTOOLCHAIN: local
|
||||
kind: pipeline
|
||||
name: build-1.24-alpine3.21-arm64
|
||||
@@ -91,78 +96,13 @@ steps:
|
||||
image: golang:1.24-alpine3.21
|
||||
mirror: https://mirror.gcr.io
|
||||
name: build
|
||||
- commands:
|
||||
- ./.bin/go-away --check --slog-level DEBUG --backend example.com=http://127.0.0.1:80
|
||||
--policy examples/forgejo.yml --policy-snippets examples/snippets/
|
||||
depends_on:
|
||||
- build
|
||||
image: alpine:3.21
|
||||
mirror: https://mirror.gcr.io
|
||||
name: check-policy-forgejo
|
||||
- commands:
|
||||
- ./.bin/go-away --check --slog-level DEBUG --backend example.com=http://127.0.0.1:80
|
||||
--policy examples/generic.yml --policy-snippets examples/snippets/
|
||||
depends_on:
|
||||
- build
|
||||
image: alpine:3.21
|
||||
mirror: https://mirror.gcr.io
|
||||
name: check-policy-generic
|
||||
- commands:
|
||||
- ./.bin/go-away --check --slog-level DEBUG --backend example.com=http://127.0.0.1:80
|
||||
--policy examples/spa.yml --policy-snippets examples/snippets/
|
||||
depends_on:
|
||||
- build
|
||||
image: alpine:3.21
|
||||
mirror: https://mirror.gcr.io
|
||||
name: check-policy-spa
|
||||
- commands:
|
||||
- ./.bin/test-wasm-runtime -wasm ./embed/challenge/js-pow-sha256/runtime/runtime.wasm
|
||||
-make-challenge ./embed/challenge/js-pow-sha256/test/make-challenge.json -make-challenge-out
|
||||
./embed/challenge/js-pow-sha256/test/make-challenge-out.json -verify-challenge
|
||||
./embed/challenge/js-pow-sha256/test/verify-challenge.json -verify-challenge-out
|
||||
0
|
||||
depends_on:
|
||||
- build
|
||||
image: alpine:3.21
|
||||
mirror: https://mirror.gcr.io
|
||||
name: test-wasm-success
|
||||
- commands:
|
||||
- ./.bin/test-wasm-runtime -wasm ./embed/challenge/js-pow-sha256/runtime/runtime.wasm
|
||||
-make-challenge ./embed/challenge/js-pow-sha256/test/make-challenge.json -make-challenge-out
|
||||
./embed/challenge/js-pow-sha256/test/make-challenge-out.json -verify-challenge
|
||||
./embed/challenge/js-pow-sha256/test/verify-challenge-fail.json -verify-challenge-out
|
||||
1
|
||||
depends_on:
|
||||
- build
|
||||
image: alpine:3.21
|
||||
mirror: https://mirror.gcr.io
|
||||
name: test-wasm-fail
|
||||
trigger:
|
||||
event:
|
||||
- push
|
||||
- tag
|
||||
type: docker
|
||||
---
|
||||
environment:
|
||||
CGO_ENABLED: "0"
|
||||
GOARCH: amd64
|
||||
GOOS: linux
|
||||
GOTOOLCHAIN: local
|
||||
kind: pipeline
|
||||
name: test-pr
|
||||
platform:
|
||||
arch: amd64
|
||||
os: linux
|
||||
steps:
|
||||
- commands:
|
||||
- apk update
|
||||
- apk add --no-cache git
|
||||
- mkdir .bin
|
||||
- go build -v -pgo=auto -v -trimpath -ldflags=-buildid= -o ./.bin/go-away ./cmd/go-away
|
||||
- go build -v -o ./.bin/test-wasm-runtime ./cmd/test-wasm-runtime
|
||||
- go test -p 1 -timeout 20m -v ./tests/
|
||||
image: golang:1.24-alpine3.21
|
||||
mirror: https://mirror.gcr.io
|
||||
name: build
|
||||
name: test
|
||||
- commands:
|
||||
- ./.bin/go-away --check --slog-level DEBUG --backend example.com=http://127.0.0.1:80
|
||||
--policy examples/forgejo.yml --policy-snippets examples/snippets/
|
||||
@@ -209,9 +149,6 @@ steps:
|
||||
image: alpine:3.21
|
||||
mirror: https://mirror.gcr.io
|
||||
name: test-wasm-fail
|
||||
trigger:
|
||||
event:
|
||||
- pull_request
|
||||
type: docker
|
||||
---
|
||||
kind: pipeline
|
||||
@@ -220,6 +157,13 @@ platform:
|
||||
arch: amd64
|
||||
os: linux
|
||||
steps:
|
||||
- commands:
|
||||
- apk update
|
||||
- apk add --no-cache git
|
||||
- go test -p 1 -timeout 20m -v ./tests/
|
||||
image: golang:1.24-alpine3.21
|
||||
mirror: https://mirror.gcr.io
|
||||
name: test
|
||||
- commands:
|
||||
- echo '[registry."docker.io"]' > buildkitd.toml
|
||||
- echo ' mirrors = ["mirror.gcr.io"]' >> buildkitd.toml
|
||||
@@ -268,6 +212,13 @@ platform:
|
||||
arch: amd64
|
||||
os: linux
|
||||
steps:
|
||||
- commands:
|
||||
- apk update
|
||||
- apk add --no-cache git
|
||||
- go test -p 1 -timeout 20m -v ./tests/
|
||||
image: golang:1.24-alpine3.21
|
||||
mirror: https://mirror.gcr.io
|
||||
name: test
|
||||
- commands:
|
||||
- echo '[registry."docker.io"]' > buildkitd.toml
|
||||
- echo ' mirrors = ["mirror.gcr.io"]' >> buildkitd.toml
|
||||
@@ -316,6 +267,13 @@ platform:
|
||||
arch: amd64
|
||||
os: linux
|
||||
steps:
|
||||
- commands:
|
||||
- apk update
|
||||
- apk add --no-cache git
|
||||
- go test -p 1 -timeout 20m -v ./tests/
|
||||
image: golang:1.24-alpine3.21
|
||||
mirror: https://mirror.gcr.io
|
||||
name: test
|
||||
- commands:
|
||||
- echo '[registry."docker.io"]' > buildkitd.toml
|
||||
- echo ' mirrors = ["mirror.gcr.io"]' >> buildkitd.toml
|
||||
@@ -364,6 +322,13 @@ platform:
|
||||
arch: amd64
|
||||
os: linux
|
||||
steps:
|
||||
- commands:
|
||||
- apk update
|
||||
- apk add --no-cache git
|
||||
- go test -p 1 -timeout 20m -v ./tests/
|
||||
image: golang:1.24-alpine3.21
|
||||
mirror: https://mirror.gcr.io
|
||||
name: test
|
||||
- commands:
|
||||
- echo '[registry."docker.io"]' > buildkitd.toml
|
||||
- echo ' mirrors = ["mirror.gcr.io"]' >> buildkitd.toml
|
||||
@@ -412,6 +377,13 @@ platform:
|
||||
arch: amd64
|
||||
os: linux
|
||||
steps:
|
||||
- commands:
|
||||
- apk update
|
||||
- apk add --no-cache git
|
||||
- go test -p 1 -timeout 20m -v ./tests/
|
||||
image: golang:1.24-alpine3.21
|
||||
mirror: https://mirror.gcr.io
|
||||
name: test
|
||||
- commands:
|
||||
- echo '[registry."docker.io"]' > buildkitd.toml
|
||||
- echo ' mirrors = ["mirror.gcr.io"]' >> buildkitd.toml
|
||||
@@ -460,6 +432,13 @@ platform:
|
||||
arch: amd64
|
||||
os: linux
|
||||
steps:
|
||||
- commands:
|
||||
- apk update
|
||||
- apk add --no-cache git
|
||||
- go test -p 1 -timeout 20m -v ./tests/
|
||||
image: golang:1.24-alpine3.21
|
||||
mirror: https://mirror.gcr.io
|
||||
name: test
|
||||
- commands:
|
||||
- echo '[registry."docker.io"]' > buildkitd.toml
|
||||
- echo ' mirrors = ["mirror.gcr.io"]' >> buildkitd.toml
|
||||
@@ -503,6 +482,6 @@ trigger:
|
||||
type: docker
|
||||
---
|
||||
kind: signature
|
||||
hmac: df53e4ea6f1c47df4d2a3f89b931b8513e83daa9c6c15baba2662d8112a721c8
|
||||
hmac: 07ac33f9298a9910aacb29ef18931cb999841f76be8a95ca210f9f3704c347f9
|
||||
|
||||
...
|
||||
|
||||
132
CHALLENGES.md
Normal file
132
CHALLENGES.md
Normal file
@@ -0,0 +1,132 @@
|
||||
# Challenges
|
||||
|
||||
Challenges can be [transparent](#transparent) (not shown to user, depends on backend or other logic), [non-JavaScript](#non-javascript) (challenges common browser properties), or [custom JavaScript](README.md#custom-javascript) (from Proof of Work to fingerprinting or Captcha is supported)
|
||||
|
||||
## Transparent
|
||||
|
||||
### http
|
||||
|
||||
Verify incoming requests against a specified backend to allow the user through. Cookies and some other headers are passed.
|
||||
|
||||
For example, this allows verifying the user cookies against the backend to have the user skip all other challenges.
|
||||
|
||||
Example on Forgejo, checks that current user is authenticated:
|
||||
```yaml
|
||||
http-cookie-check:
|
||||
runtime: http
|
||||
parameters:
|
||||
http-url: http://forgejo:3000/user/stopwatches
|
||||
# http-url: http://forgejo:3000/repo/search
|
||||
# http-url: http://forgejo:3000/notifications/new
|
||||
http-method: GET
|
||||
http-cookie: i_like_gitea
|
||||
http-code: 200
|
||||
verify-probability: 0.1
|
||||
```
|
||||
|
||||
### preload-link
|
||||
|
||||
Requires HTTP/2+ response parsing and logic, silent challenge (does not display a challenge page).
|
||||
|
||||
Browsers that support [103 Early Hints](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status/103) are indicated to fetch a CSS resource via [Link](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Link) preload that solves the challenge.
|
||||
|
||||
The server waits until solved or defined timeout, then continues on other challenges if failed.
|
||||
|
||||
Example:
|
||||
```yaml
|
||||
preload-link:
|
||||
condition: '"Sec-Fetch-Mode" in headers && headers["Sec-Fetch-Mode"] == "navigate"'
|
||||
runtime: "preload-link"
|
||||
parameters:
|
||||
preload-early-hint-deadline: 3s
|
||||
```
|
||||
|
||||
### dnsbl
|
||||
|
||||
You can configure a [DNSBL (Domain Name System blocklist)](https://en.wikipedia.org/wiki/Domain_Name_System_blocklist) to be queried.
|
||||
|
||||
This allows you to serve harder or different challenges to higher risk clients, or block them from specific sections.
|
||||
|
||||
Only rules that match a DNSBL challenge will cause a query to be sent, meaning the bulk of requests will not be sent to this service upstream.
|
||||
|
||||
Results will be temporarily cached.
|
||||
|
||||
By default, [DroneBL](https://dronebl.org/) is used.
|
||||
|
||||
Example challenge definition and rule:
|
||||
```yaml
|
||||
challenges:
|
||||
dnsbl:
|
||||
runtime: dnsbl
|
||||
parameters:
|
||||
# dnsbl-host: "dnsbl.dronebl.org"
|
||||
dnsbl-decay: 1h
|
||||
dnsbl-timeout: 1s
|
||||
|
||||
rules:
|
||||
# check DNSBL and serve harder challenges
|
||||
- name: undesired-dnsbl
|
||||
action: check
|
||||
settings:
|
||||
challenges: [dnsbl]
|
||||
# if DNSBL fails, check additional challenges
|
||||
fail: check
|
||||
fail-settings:
|
||||
challenges: [js-pow-sha256]
|
||||
```
|
||||
|
||||
## Non-JavaScript
|
||||
|
||||
### cookie
|
||||
|
||||
Requires HTTP parsing and a Cookie Jar, silent challenge (does not display a challenge page unless failed).
|
||||
|
||||
Serves the client with a Set-Cookie that solves the challenge, and redirects it back to the same page. Browser must present the cookie to load.
|
||||
|
||||
Several tools implement this, but usually not mass scrapers.
|
||||
|
||||
### header-refresh
|
||||
|
||||
Requires HTTP response parsing and logic, displays challenge site instantly.
|
||||
|
||||
Have the browser solve the challenge by following the URL listed on HTTP [Refresh](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Refresh) instantly.
|
||||
|
||||
|
||||
### meta-refresh
|
||||
|
||||
Requires HTTP and HTML response parsing and logic, displays challenge site instantly.
|
||||
|
||||
Have the browser solve the challenge by following the URL listed on HTML `<meta http-equiv=refresh>` tag instantly. Equivalent to above.
|
||||
|
||||
### resource-load
|
||||
|
||||
Requires HTTP and HTML response parsing and logic, displays challenge site.
|
||||
|
||||
Servers a challenge page with a linked resource that is loaded by the browser, which solves the challenge. Page refreshes a few seconds later via [Refresh](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Refresh).
|
||||
|
||||
## Custom JavaScript
|
||||
|
||||
### js-pow-sha256
|
||||
|
||||
Requires JavaScript and workers, displays challenge site.
|
||||
|
||||
Has the user solve a Proof of Work using SHA256 hashes, with configurable difficulty.
|
||||
|
||||
Example:
|
||||
```yaml
|
||||
js-pow-sha256:
|
||||
runtime: js
|
||||
parameters:
|
||||
# specifies the folder path that assets are under
|
||||
# can be either embedded or external path
|
||||
# defaults to name of challenge
|
||||
path: "js-pow-sha256"
|
||||
# needs to be under static folder
|
||||
js-loader: load.mjs
|
||||
# needs to be under runtime folder
|
||||
wasm-runtime: runtime.wasm
|
||||
wasm-runtime-settings:
|
||||
difficulty: 20
|
||||
verify-probability: 0.02
|
||||
```
|
||||
|
||||
@@ -48,7 +48,6 @@ ENV GOAWAY_POLICY="/policy.yml"
|
||||
ENV GOAWAY_POLICY_SNIPPETS=""
|
||||
ENV GOAWAY_CHALLENGE_TEMPLATE="anubis"
|
||||
ENV GOAWAY_CHALLENGE_TEMPLATE_THEME=""
|
||||
ENV GOAWAY_CHALLENGE_TEMPLATE_LOGO=""
|
||||
ENV GOAWAY_SLOG_LEVEL="WARN"
|
||||
ENV GOAWAY_CLIENT_IP_HEADER=""
|
||||
ENV GOAWAY_BACKEND_IP_HEADER=""
|
||||
|
||||
254
README.md
254
README.md
@@ -3,23 +3,20 @@
|
||||
|
||||
Self-hosted abuse detection and rule enforcement against low-effort mass AI scraping and bots. Uses conventional non-nuclear options.
|
||||
|
||||
[](https://git.gammaspectra.live/git/go-away/releases)
|
||||
[](https://ci.gammaspectra.live/git/go-away)
|
||||
[](https://pkg.go.dev/git.gammaspectra.live/git/go-away)
|
||||
|
||||
go-away sits in between your site and the Internet / upstream proxy.
|
||||
|
||||
Incoming requests can be selected by [rules](#rich-rule-matching) to be [actioned](https://git.gammaspectra.live/git/go-away/wiki/Rule-Actions) or [challenged](https://git.gammaspectra.live/git/go-away/wiki/Challenges) to filter suspicious requests.
|
||||
Incoming requests can be selected by [rules](#rich-rule-matching) to be [actioned](#extended-rule-actions) or [challenged](CHALLENGES.md#challenges) to filter suspicious requests.
|
||||
|
||||
The tool is designed highly flexible so the operator can minimize impact to legit users, while surgically targeting heavy endpoints or scrapers.
|
||||
|
||||
[Challenges](https://git.gammaspectra.live/git/go-away/wiki/Challenges) can be transparent (not shown to user, depends on backend or other logic), [non-JavaScript](#non-javascript-challenges) (challenges common browser properties), or [custom JavaScript](#custom-javascript-wasm-challenges) (from Proof of Work to fingerprinting or Captcha is supported)
|
||||
[Challenges](CHALLENGES.md#challenges) can be transparent (not shown to user, depends on backend or other logic), [non-JavaScript](#non-javascript-challenges) (challenges common browser properties), or [custom JavaScript](#custom-javascript-wasm-challenges) (from Proof of Work to fingerprinting or Captcha is supported)
|
||||
|
||||
See _[Why do this?](#why-do-this)_ section for the challenges and reasoning behind this tool.
|
||||
|
||||
**This documentation and go-away are in active development.** See [What's left?](#what-s-left) section for a breakdown.
|
||||
|
||||
Check this README for a general introduction. An [in-depth Wiki](https://git.gammaspectra.live/git/go-away/wiki/) is available and being improved.
|
||||
This documentation and go-away are in active development. See [What's left?](#what-s-left) section for a breakdown.
|
||||
|
||||
## Support
|
||||
|
||||
@@ -43,13 +40,6 @@ Source code is automatically pushed to the following mirrors. Packages are also
|
||||
|
||||
Note that issues or pull requests should be issued on the [main Forge](https://git.gammaspectra.live/git/go-away).
|
||||
|
||||
## Installation and Setup
|
||||
|
||||
See the [Installation page](https://git.gammaspectra.live/git/go-away/wiki/Installation) on the Wiki for all the details.
|
||||
|
||||
go-away can be directly run from command line, via pre-built containers, or your own built containers.
|
||||
|
||||
|
||||
## Features
|
||||
|
||||
### Rich rule matching
|
||||
@@ -79,17 +69,7 @@ Only available when TLS is enabled
|
||||
fp.ja4 (string) JA4 TLS Fingerprint
|
||||
```
|
||||
|
||||
|
||||
|
||||
### Package path
|
||||
|
||||
You can modify the path where challenges are served and package name, if you don't want its presence to be easily discoverable.
|
||||
|
||||
No source code editing or forking necessary!
|
||||
|
||||
Simply pass a new absolute path via the cmdline _path_ argument, like so: `--path "/.goaway_example"`
|
||||
|
||||
### Page template and customization support
|
||||
### Template support
|
||||
|
||||
Internal or external templates can be loaded to customize the look of the challenge or error page. Additionally, themes can be configured to change the look of these quickly.
|
||||
|
||||
@@ -100,19 +80,29 @@ These templates are included by default:
|
||||
|
||||
External templates for your site can be loaded specifying a full path to the `.gohtml` file. See [embed/templates/](embed/templates/) for examples to follow.
|
||||
|
||||
You can alter the language and strings in the templates directly from the [config.yml](examples/config.yml) file if specified, or add footer links directly.
|
||||
You can alter the language and strings in the templates directly from the [config.yml](#config) file if specified.
|
||||
|
||||
Some templates support themes. Specify that either via the [config.yml](examples/config.yml) file, or via `challenge-template-theme` cmdline argument.
|
||||
### Extended rule actions
|
||||
|
||||
Most templates support overriding the logo. Specify that either via the [config.yml](examples/config.yml) file, or via `challenge-template-logo` cmdline argument.
|
||||
In addition to the common PASS / CHALLENGE / DENY rules, go-away offers more actions that can be extended via code.
|
||||
|
||||
**Feel free to make any changes to existing templates or bring your own, alter any logos or styling, it's yours to adapt!**
|
||||
| Action | Behavior | Terminating |
|
||||
|:---------:|:------------------------------------------------------------------------|:-----------:|
|
||||
| NONE | Do nothing, continue. Useful for specifying on checks or challenges. | No |
|
||||
| PASS | Passes the request to the backend immediately | Yes |
|
||||
| DENY | Denies the request with a descriptive page | Yes |
|
||||
| BLOCK | Denies the request with a response code | Yes |
|
||||
| DROP | Drops the connection without sending a reply | Yes |
|
||||
| CHALLENGE | Issues a challenge that when passed, acts like PASS | Yes |
|
||||
| CHECK | Issues a challenge that when passed, continues executing rules | No |
|
||||
| PROXY | Proxies request to a different backend, with optional path replacements | Yes |
|
||||
| CONTEXT | Modify the request context and apply different options | No |
|
||||
|
||||
### Advanced actions
|
||||
|
||||
In addition to the common PASS / CHALLENGE / DENY rules, go-away offers more actions, plus any more extensible via code.
|
||||
CHECK allows the client to be challenged but continue matching rules after these, for example, chaining a list of challenges that must be passed.
|
||||
For example, you could use this to implement browser in checks without explicitly allowing all requests, and later deferring to a secondary check/challenge.
|
||||
|
||||
See the [Rule Actions page](https://git.gammaspectra.live/git/go-away/wiki/Rule-Actions) on the Wiki.
|
||||
PROXY allows the operator to send matching requests to a different backend, for example, a poison generator or a scraping maze.
|
||||
|
||||
### Multiple challenge matching
|
||||
|
||||
@@ -140,15 +130,15 @@ Several challenges that do not require JavaScript are offered, some targeting th
|
||||
|
||||
These can be used for light checking of requests that eliminate most of the low effort scraping.
|
||||
|
||||
See [Transparent challenges](https://git.gammaspectra.live/git/go-away/wiki/Challenges#transparent) and [Non-JavaScript challenges](https://git.gammaspectra.live/git/go-away/wiki/Challenges#non-javascript) on the Wiki for more information.
|
||||
See [Challenges](CHALLENGES.md#challenges) for a list of them.
|
||||
|
||||
### Custom JavaScript / WASM challenges
|
||||
|
||||
A WASM interface for server-side proof generation and checking is offered. We provide `js-pow-sha256` as an example of one.
|
||||
|
||||
You can implement Captchas or other browser fingerprinting tests within this interface.
|
||||
An internal test has shown you can implement Captchas or other browser fingerprinting tests within this interface.
|
||||
|
||||
See [Custom JavaScript challenges](https://git.gammaspectra.live/git/go-away/wiki/Challenges#custom-javascript) on the Wiki for more information.
|
||||
If you are interested in creating your own, see the [Development](#development) section below.
|
||||
|
||||
### Upstream PROXY support
|
||||
|
||||
@@ -164,6 +154,7 @@ You can enable automatic certificate generation and TLS for the site via any ACM
|
||||
|
||||
Without TLS, HTTP/2 cleartext is supported, but you will need to configure the upstream proxy to send this protocol (`h2c://` on Caddy for example).
|
||||
|
||||
|
||||
### TLS Fingerprinting
|
||||
|
||||
When running with TLS via autocert, TLS Fingerprinting of the incoming client is done.
|
||||
@@ -197,6 +188,14 @@ Example for _regex_:
|
||||
```
|
||||
|
||||
|
||||
### Sharing of signing seed across instances
|
||||
|
||||
You can share the signing secret across multiple of your instances if you'd like to deploy multiple across the world.
|
||||
|
||||
That way signed secrets will be verifiable across all the instances.
|
||||
|
||||
By default, a random temporary key is generated every run.
|
||||
|
||||
### Multiple backend support
|
||||
|
||||
Multiple backends are supported, and rules specific on backend can be defined, and conditions and rules can match this as well.
|
||||
@@ -205,6 +204,12 @@ Subdomain wildcards like `*.example.com`, or full fallback wildcard `*` are supp
|
||||
|
||||
This allows one instance to run multiple domains or subdomains.
|
||||
|
||||
### Package path
|
||||
|
||||
You can modify the path where challenges are served and package name, if you don't want its presence to be easily discoverable.
|
||||
|
||||
No source code editing or forking necessary!
|
||||
|
||||
### IPv6 Happy Eyeballs challenge retry
|
||||
|
||||
In case a client connects over IPv4 first then IPv6 due to [Fast Fallback / Happy Eyeballs](https://en.wikipedia.org/wiki/Happy_Eyeballs), the challenge will automatically be retried.
|
||||
@@ -307,24 +312,187 @@ However, a few points are left before go-away can be called v1.0.0:
|
||||
* [x] Expose metrics for challenge solve rates and acting on them.
|
||||
* [ ] Metrics for common network ranges / AS / useragent
|
||||
|
||||
## Setup
|
||||
|
||||
go-away can take plaintext HTTP/1 and _HTTP/2_ / _h2c_ connections if desired over the same port. When doing this, it is recommended to have another reverse proxy above (for example [Caddy](https://caddyserver.com/), nginx, HAProxy) to handle HTTPs or similar.
|
||||
|
||||
We also support the `autocert` parameter to configure HTTP(s). This will also allow TLS Fingerprinting to be done on incoming clients. This doesn't require any upstream proxies, and we recommend it's exposed directly or via SNI / Layer 4 proxying.
|
||||
|
||||
### Config
|
||||
|
||||
While most basic configuration can be passed via the command line, we support passing a [config.yml](examples/config.yml) with more advanced setup, including string replacement or custom backends configuration.
|
||||
|
||||
### Binary / Go
|
||||
|
||||
Requires Go 1.24+. Builds statically without CGo usage.
|
||||
|
||||
We have Go 1.22+ support on the [go1.22 branch](https://git.gammaspectra.live/git/go-away/src/branch/go1.22).
|
||||
It will be regularly rebased to keep current with recent releases, at least until v1.0.0.
|
||||
Some features, such as TLS Fingerprinting, are not available on Go 1.22.
|
||||
|
||||
```shell
|
||||
git clone https://git.gammaspectra.live/git/go-away.git && cd go-away
|
||||
|
||||
CGO_ENABLED=0 go build -pgo=auto -v -trimpath -o ./go-away ./cmd/go-away
|
||||
|
||||
# Run on port 8080, forwarding matching requests on git.example.com to http://forgejo:3000
|
||||
./go-away --bind :8080 \
|
||||
--backend git.example.com=http://forgejo:3000 \
|
||||
--policy examples/forgejo.yml \
|
||||
--challenge-template forgejo --challenge-template-theme forgejo-dark
|
||||
|
||||
```
|
||||
|
||||
### Dockerfile
|
||||
|
||||
Available under [Dockerfile](Dockerfile). See the _docker compose_ below for the environment variables.
|
||||
|
||||
### docker compose
|
||||
|
||||
Example follows a hypothetical Forgejo server running on `http://forgejo:3000` serving `git.example.com`
|
||||
|
||||
Container images are published under `git.gammaspectra.live/git/go-away`, `codeberg.org/gone/go-away` and `ghcr.io/weebdatahoarder/go-away`
|
||||
|
||||
```yaml
|
||||
networks:
|
||||
forgejo:
|
||||
external: false
|
||||
|
||||
volumes:
|
||||
goaway_cache:
|
||||
|
||||
services:
|
||||
go-away:
|
||||
# image: codeberg.org/gone/go-away:latest
|
||||
# image: ghcr.io/weebdatahoarder/go-away:latest
|
||||
image: git.gammaspectra.live/git/go-away:latest
|
||||
restart: always
|
||||
ports:
|
||||
- "3000:8080"
|
||||
networks:
|
||||
- forgejo
|
||||
depends_on:
|
||||
- forgejo
|
||||
volumes:
|
||||
- "goaway_cache:/cache"
|
||||
- "./examples/forgejo.yml:/policy.yml:ro"
|
||||
#- "./your/snippets/:/policy/snippets/:ro"
|
||||
environment:
|
||||
#GOAWAY_BIND: ":8080"
|
||||
# Supported tcp, unix, and proxy (for enabling PROXY module for request unwrapping)
|
||||
#GOAWAY_BIND_NETWORK: "tcp"
|
||||
#GOAWAY_SOCKET_MODE: "0770"
|
||||
|
||||
# Enable Prometheus metrics under /metrics on this bind
|
||||
#GOAWAY_METRICS_BIND: ":9090"
|
||||
# Enable Go debug profiles under this bind
|
||||
#GOAWAY_DEBUG_BIND: ":6060"
|
||||
|
||||
# set to letsencrypt or other directory URL to enable HTTPS. Above ports will be TLS only.
|
||||
# enables request JA3N / JA4 client TLS fingerprinting
|
||||
# TLS fingerprints are served on X-TLS-Fingerprint-JA3N and X-TLS-Fingerprint-JA4 headers
|
||||
# TLS fingerprints can be matched against on CEL conditions
|
||||
#GOAWAY_ACME_AUTOCERT: ""
|
||||
|
||||
# Cache path for several services like certificates and caching network ranges
|
||||
# Can be semi-ephemeral, recommended to be mapped to a permanent volume
|
||||
#GOAWAY_CACHE="/cache"
|
||||
|
||||
# default is WARN, set to INFO to also see challenge successes and others
|
||||
#GOAWAY_SLOG_LEVEL: "INFO"
|
||||
|
||||
# this value is used to sign cookies and challenges. by default a new one is generated each time
|
||||
# set to generate to create one, then set the same value across all your instances
|
||||
#GOAWAY_JWT_PRIVATE_KEY_SEED: ""
|
||||
|
||||
# HTTP header that the client ip will be fetched from
|
||||
# Defaults to the connection ip itself, if set here make sure your upstream proxy sets this properly
|
||||
# Usually X-Forwarded-For is a good pick
|
||||
# Not necessary with GOAWAY_BIND_NETWORK: proxy
|
||||
GOAWAY_CLIENT_IP_HEADER: "X-Real-Ip"
|
||||
|
||||
# HTTP header that go-away will set the obtained ip will be set to
|
||||
# If left empty, the header on GOAWAY_CLIENT_IP_HEADER will be left as-is
|
||||
#GOAWAY_BACKEND_IP_HEADER: ""
|
||||
|
||||
# Alternate way of specifying parameters or more advanced settings
|
||||
# Pass path to YAML file
|
||||
#GOAWAY_CONFIG: ""
|
||||
|
||||
GOAWAY_POLICY: "/policy.yml"
|
||||
|
||||
# Include extra snippets to load from this path.
|
||||
# Note that the default snippets from example/snippets/ are included by default
|
||||
#GOAWAY_POLICY_SNIPPETS: "/policy/snippets"
|
||||
|
||||
# Template, and theme for the template to pick. defaults to an anubis-like one
|
||||
# An file path can be specified. See embed/templates for a few examples
|
||||
GOAWAY_CHALLENGE_TEMPLATE: forgejo
|
||||
GOAWAY_CHALLENGE_TEMPLATE_THEME: forgejo-dark
|
||||
|
||||
# Backend to match. Can be subdomain or full wildcards, "*.example.com" or "*"
|
||||
GOAWAY_BACKEND: "git.example.com=http://forgejo:3000"
|
||||
|
||||
# additional backends can be specified via more command arguments
|
||||
# command: ["--backend", "ci.example.com=http://ci:3000"]
|
||||
|
||||
forgejo:
|
||||
# etc.
|
||||
|
||||
```
|
||||
|
||||
|
||||
|
||||
## Other Similar Projects
|
||||
|
||||
| Project | Source Code | Description | Method |
|
||||
|:----------------------------------------------------------------------------------------:|:---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------:|:---------------------------------------------------------------------------------------------------------------------------|:---------------------------------------------|
|
||||
| [Anubis](https://anubis.techaro.lol/) | [](https://github.com/TecharoHQ/anubis)<br/>Go / [MIT](https://github.com/TecharoHQ/anubis/blob/main/LICENSE) | Proxy that uses JavaScript proof of work to weight request based on simple match rules | JavaScript PoW (SHA-256) |
|
||||
| [powxy](https://forge.lindenii.runxiyu.org/powxy/-/repos/powxy/) | [](https://forge.lindenii.runxiyu.org/powxy/-/repos/powxy/)<br/> Go / [BSD 2-Clause](https://forge.lindenii.runxiyu.org/powxy/-/repos/powxy/tree/LICENSE) | Powxy is a reverse proxy that protects your upstream service by challenging clients with proof-of-work. | JavaScript PoW (SHA-256) with manual program |
|
||||
| [PoW! Bot Deterrent](https://git.sequentialread.com/forest/pow-bot-deterrent) | [](https://git.sequentialread.com/forest/pow-bot-deterrent)<br/> Go / [GPL v3.0](https://git.sequentialread.com/forest/pow-bot-deterrent/src/branch/main/LICENSE.md) | A proof-of-work based bot deterrent. Lightweight, self-hosted and copyleft licensed. | JavaScript PoW (WASM scrypt) |
|
||||
| [CSSWAF](https://github.com/yzqzss/csswaf) | [](https://github.com/yzqzss/csswaf)<br/>Go / [MIT](https://github.com/yzqzss/csswaf/blob/main/LICENSE) | A CSS-based NoJS Anti-BOT WAF (Proof of Concept) | Non-JS CSS Subresource loading order |
|
||||
| [anticrawl](https://flak.tedunangst.com/post/anticrawl) | [](https://humungus.tedunangst.com/r/anticrawl)<br/>Go / None | Go http handler / proxy for regex based rules | Non-JS manual Challenge/Response |
|
||||
| [ngx_http_js_challenge_module](https://github.com/simon987/ngx_http_js_challenge_module) | [](https://github.com/simon987/ngx_http_js_challenge_module)<br/>C / [GPL v3.0](https://github.com/simon987/ngx_http_js_challenge_module/blob/master/LICENSE) | Simple javascript proof-of-work based access for Nginx with virtually no overhead. | JavaScript Challenge |
|
||||
| [haproxy-protection](https://gitgud.io/fatchan/haproxy-protection/) | [](https://gitgud.io/fatchan/haproxy-protection/)<br/> Lua / [GPL v3.0](https://gitgud.io/fatchan/haproxy-protection/-/blob/master/LICENSE.txt) | HAProxy configuration and lua scripts allowing a challenge-response page where users solve a captcha and/or proof-of-work. | JavaScript Challenge / Captcha |
|
||||
| Project | Source Code | Description | Method |
|
||||
|:-----------------------------------------------------------------------------:|:---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------:|:--------------------------------------------------------------------------------------------------------|:---------------------------------------------|
|
||||
| [Anubis](https://anubis.techaro.lol/) | [](https://github.com/TecharoHQ/anubis)<br/>Go / [MIT](https://github.com/TecharoHQ/anubis/blob/main/LICENSE) | Proxy that uses JavaScript proof of work to weight request based on simple match rules | JavaScript PoW (SHA-256) |
|
||||
| [powxy](https://forge.lindenii.runxiyu.org/powxy/-/repos/powxy/) | [](https://forge.lindenii.runxiyu.org/powxy/-/repos/powxy/)<br/> Go / [BSD 2-Clause](https://forge.lindenii.runxiyu.org/powxy/-/repos/powxy/tree/LICENSE) | Powxy is a reverse proxy that protects your upstream service by challenging clients with proof-of-work. | JavaScript PoW (SHA-256) with manual program |
|
||||
| [PoW! Bot Deterrent](https://git.sequentialread.com/forest/pow-bot-deterrent) | [](https://git.sequentialread.com/forest/pow-bot-deterrent)<br/> Go / [GPL v3.0](https://git.sequentialread.com/forest/pow-bot-deterrent/src/branch/main/LICENSE.md) | A proof-of-work based bot deterrent. Lightweight, self-hosted and copyleft licensed. | JavaScript PoW (WASM scrypt) |
|
||||
| [CSSWAF](https://github.com/yzqzss/csswaf) | [](https://github.com/yzqzss/csswaf)<br/>Go / [MIT](https://github.com/yzqzss/csswaf/blob/main/LICENSE) | A CSS-based NoJS Anti-BOT WAF (Proof of Concept) | Non-JS CSS Subresource loading order |
|
||||
| [anticrawl](https://flak.tedunangst.com/post/anticrawl) | [](https://humungus.tedunangst.com/r/anticrawl)<br/>Go / None | Go http handler / proxy for regex based rules | Non-JS manual Challenge/Response |
|
||||
|
||||
|
||||
## Development
|
||||
|
||||
This Go package can be used as a command on `git.gammaspectra.live/git/go-away/cmd/go-away` or a library under `git.gammaspectra.live/git/go-away/lib`
|
||||
|
||||
### Compiling WASM runtime challenge modules
|
||||
|
||||
Custom WASM runtime modules follow the WASI `wasip1` preview syscall API.
|
||||
|
||||
It is recommended using TinyGo to compile / refresh modules, and some function helpers are provided.
|
||||
|
||||
If you want to use a different language or compiler, enable `wasip1` and the following interface must be exported:
|
||||
|
||||
```
|
||||
// Allocation is a combination of pointer location in WASM memory and size of it
|
||||
type Allocation uint64
|
||||
|
||||
func (p Allocation) Pointer() uint32 {
|
||||
return uint32(p >> 32)
|
||||
}
|
||||
func (p Allocation) Size() uint32 {
|
||||
return uint32(p)
|
||||
}
|
||||
|
||||
|
||||
// MakeChallenge MakeChallengeInput / MakeChallengeOutput are valid JSON.
|
||||
// See lib/challenge/wasm/interface/interface.go for a definition
|
||||
func MakeChallenge(in Allocation[MakeChallengeInput]) Allocation[MakeChallengeOutput]
|
||||
|
||||
// VerifyChallenge VerifyChallengeInput is valid JSON.
|
||||
// See lib/challenge/wasm/interface/interface.go for a definition
|
||||
func VerifyChallenge(in Allocation[VerifyChallengeInput]) VerifyChallengeOutput
|
||||
|
||||
func malloc(size uint32) uintptr
|
||||
func free(size uintptr)
|
||||
|
||||
```
|
||||
|
||||
Modules will be recreated for each call, so there is no state leftover.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@ import (
|
||||
"os"
|
||||
"os/signal"
|
||||
"path"
|
||||
"runtime"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
"syscall"
|
||||
@@ -86,8 +85,7 @@ func main() {
|
||||
|
||||
flag.StringVar(&opt.ChallengeTemplate, "challenge-template", opt.ChallengeTemplate, "name or path of the challenge template to use (anubis, forgejo)")
|
||||
|
||||
templateTheme := flag.String("challenge-template-theme", opt.ChallengeTemplateOverrides["Theme"], "override template theme to use (forgejo => [forgejo-auto, forgejo-dark, forgejo-light, gitea...])")
|
||||
templateLogo := flag.String("challenge-template-logo", opt.ChallengeTemplateOverrides["Logo"], "override template logo to use")
|
||||
templateTheme := flag.String("challenge-template-theme", opt.ChallengeTemplateOverrides["Theme"], "name of the challenge template theme to use (forgejo => [forgejo-auto, forgejo-dark, forgejo-light, gitea...])")
|
||||
|
||||
basePath := flag.String("path", "/.well-known/."+internalCmdName, "base path where to expose go-away package onto, challenges will be served from here")
|
||||
|
||||
@@ -133,11 +131,10 @@ func main() {
|
||||
slog.SetLogLoggerLevel(programLevel)
|
||||
}
|
||||
|
||||
slog.Info("go-away", "package", internalMainName, "version", internalMainVersion, "cmd", internalCmdName, "go", runtime.Version(), "os", runtime.GOOS, "arch", runtime.GOARCH)
|
||||
slog.Info("go-away", "package", internalMainName, "version", internalMainVersion, "cmd", internalCmdName)
|
||||
|
||||
// preload missing settings
|
||||
opt.ChallengeTemplateOverrides["Theme"] = *templateTheme
|
||||
opt.ChallengeTemplateOverrides["Logo"] = *templateLogo
|
||||
|
||||
// load overrides
|
||||
if *settingsFile != "" {
|
||||
@@ -210,7 +207,7 @@ func main() {
|
||||
fatal(fmt.Errorf("backend %s: failed to make reverse proxy: %w", k, err))
|
||||
}
|
||||
|
||||
backend.ErrorLog = slog.NewLogLogger(slog.With("backend", k).Handler(), slog.LevelDebug)
|
||||
backend.ErrorLog = slog.NewLogLogger(slog.With("backend", k).Handler(), slog.LevelError)
|
||||
createdBackends[k] = backend
|
||||
}
|
||||
|
||||
@@ -240,7 +237,7 @@ func main() {
|
||||
acmeCache = path.Join(*cachePath, "acme")
|
||||
}
|
||||
|
||||
loadPolicyState := func() (*lib.State, error) {
|
||||
loadPolicyState := func() (http.Handler, error) {
|
||||
policyData, err := os.ReadFile(*policyFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read policy file: %w", err)
|
||||
@@ -291,7 +288,7 @@ func main() {
|
||||
fatal(fmt.Errorf("failed to create server: %w", err))
|
||||
}
|
||||
|
||||
server.ErrorLog = slog.NewLogLogger(slog.With("server", "http").Handler(), slog.LevelDebug)
|
||||
server.ErrorLog = slog.NewLogLogger(slog.With("server", "http").Handler(), slog.LevelError)
|
||||
|
||||
go func() {
|
||||
handler, err := loadPolicyState()
|
||||
@@ -302,7 +299,6 @@ func main() {
|
||||
swap(handler)
|
||||
slog.Warn(
|
||||
"handler configuration loaded",
|
||||
"key_fingerprint", hex.EncodeToString(handler.PrivateKeyFingerprint()),
|
||||
)
|
||||
|
||||
// allow reloading from now on
|
||||
@@ -312,7 +308,6 @@ func main() {
|
||||
if sig != syscall.SIGHUP {
|
||||
continue
|
||||
}
|
||||
oldHandler := handler
|
||||
handler, err = loadPolicyState()
|
||||
if err != nil {
|
||||
slog.Error("handler configuration reload error", "err", err)
|
||||
@@ -321,9 +316,6 @@ func main() {
|
||||
|
||||
swap(handler)
|
||||
slog.Warn("handler configuration reloaded")
|
||||
if oldHandler != nil {
|
||||
_ = oldHandler.Close()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -337,7 +329,7 @@ func main() {
|
||||
debugServer := http.Server{
|
||||
Addr: opt.BindDebug,
|
||||
Handler: mux,
|
||||
ErrorLog: slog.NewLogLogger(slog.With("server", "debug").Handler(), slog.LevelDebug),
|
||||
ErrorLog: slog.NewLogLogger(slog.With("server", "debug").Handler(), slog.LevelError),
|
||||
}
|
||||
|
||||
slog.Warn(
|
||||
@@ -357,7 +349,7 @@ func main() {
|
||||
metricsServer := http.Server{
|
||||
Addr: opt.BindMetrics,
|
||||
Handler: mux,
|
||||
ErrorLog: slog.NewLogLogger(slog.With("server", "metrics").Handler(), slog.LevelDebug),
|
||||
ErrorLog: slog.NewLogLogger(slog.With("server", "metrics").Handler(), slog.LevelError),
|
||||
}
|
||||
|
||||
slog.Warn(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
if [ $# -eq 0 ] || [ "${1#-}" != "$1" ]; then
|
||||
if [ "${1#-}" != "$1" ]; then
|
||||
set -- /bin/go-away \
|
||||
--bind "${GOAWAY_BIND}" --bind-network "${GOAWAY_BIND_NETWORK}" --socket-mode "${GOAWAY_SOCKET_MODE}" \
|
||||
--metrics-bind "${GOAWAY_METRICS_BIND}" --debug-bind "${GOAWAY_DEBUG_BIND}" \
|
||||
@@ -9,9 +9,7 @@ if [ $# -eq 0 ] || [ "${1#-}" != "$1" ]; then
|
||||
--policy "${GOAWAY_POLICY}" --policy-snippets "/snippets" --policy-snippets "${GOAWAY_POLICY_SNIPPETS}" \
|
||||
--client-ip-header "${GOAWAY_CLIENT_IP_HEADER}" --backend-ip-header "${GOAWAY_BACKEND_IP_HEADER}" \
|
||||
--cache "${GOAWAY_CACHE}" \
|
||||
--challenge-template "${GOAWAY_CHALLENGE_TEMPLATE}" \
|
||||
--challenge-template-logo "${GOAWAY_CHALLENGE_TEMPLATE_LOGO}" \
|
||||
--challenge-template-theme "${GOAWAY_CHALLENGE_TEMPLATE_THEME}" \
|
||||
--challenge-template "${GOAWAY_CHALLENGE_TEMPLATE}" --challenge-template-theme "${GOAWAY_CHALLENGE_TEMPLATE_THEME}" \
|
||||
--slog-level "${GOAWAY_SLOG_LEVEL}" \
|
||||
--acme-autocert "${GOAWAY_ACME_AUTOCERT}" \
|
||||
--backend "${GOAWAY_BACKEND}" \
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
{{$logo := print .Path "/assets/static/logo.png?cacheBust=" .Random }}{{ if .Logo }}{{$logo = .Logo}}{{ end }}
|
||||
<html>
|
||||
<head>
|
||||
<title>{{ .Title }}</title>
|
||||
<link rel="stylesheet" href="{{ .Path }}/assets/static/anubis/style.css?cacheBust={{ .Random }}"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<meta name="referrer" content="origin"/>
|
||||
{{ range .MetaTags }}
|
||||
{{ range .Meta }}
|
||||
<meta {{ range $key, $value := . }}{{ $key | attr }}="{{ $value }}" {{end}}/>
|
||||
{{ end }}
|
||||
{{ range .LinkTags }}
|
||||
<link {{ range $key, $value := . }}{{ $key | attr }}="{{ $value }}" {{end}}/>
|
||||
{{ end }}
|
||||
{{ range .HeaderTags }}
|
||||
{{ . }}
|
||||
{{ end }}
|
||||
@@ -26,7 +22,7 @@
|
||||
<img
|
||||
id="image"
|
||||
style="width:100%;max-width:256px;"
|
||||
src="{{ $logo }}"
|
||||
src="{{ .Path }}/assets/static/logo.png?cacheBust={{ .Random }}"
|
||||
/>
|
||||
{{if .Challenge }}
|
||||
<p id="status">{{ .Strings.Get "status_loading_challenge" }} <em>{{ .Challenge }}</em>...</p>
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
{{$theme := "forgejo-auto"}}{{ if .Theme }}{{$theme = .Theme}}{{ end }}
|
||||
{{$logo := "/assets/img/logo.png"}}{{ if .Logo }}{{$logo = .Logo}}{{ end }}
|
||||
<html lang="en-US" data-theme="{{ $theme }}">
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
|
||||
<title>{{ .Title }}</title>
|
||||
<meta name="referrer" content="origin">
|
||||
{{ range .MetaTags }}
|
||||
{{ range .Meta }}
|
||||
<meta {{ range $key, $value := . }}{{ $key | attr }}="{{ $value }}" {{end}}/>
|
||||
{{ end }}
|
||||
{{ range .LinkTags }}
|
||||
<link {{ range $key, $value := . }}{{ $key | attr }}="{{ $value }}" {{end}}/>
|
||||
{{ end }}
|
||||
{{ range .HeaderTags }}
|
||||
{{ . }}
|
||||
{{ end }}
|
||||
@@ -49,7 +45,7 @@
|
||||
<div class="ui stackable middle very relaxed page grid">
|
||||
<div class="sixteen wide center aligned centered column">
|
||||
<div>
|
||||
<img class="logo" id="image" src="{{ $logo }}" />
|
||||
<img class="logo" id="image" src="/assets/img/logo.png" />
|
||||
</div>
|
||||
<div class="hero">
|
||||
<h2 class="ui icon header title" id="title">
|
||||
|
||||
@@ -24,7 +24,7 @@ bind:
|
||||
#bind-debug: ":6060"
|
||||
|
||||
# Bind the Prometheus metrics onto /metrics path on this port
|
||||
#bind-metrics: ":9090"
|
||||
#bind-metrics ":9090"
|
||||
|
||||
# These links will be shown on the presented challenge or error pages
|
||||
links:
|
||||
@@ -40,8 +40,8 @@ links:
|
||||
# HTML Template to use for challenge or error pages
|
||||
# External templates can be included by providing a disk path
|
||||
# Bundled templates:
|
||||
# anubis: An Anubis-like template with no configuration parameters. Supports Logo.
|
||||
# forgejo: Looks like native Forgejo. Includes logos and resources from your instance. Supports Theme, Logo.
|
||||
# anubis: An Anubis-like template with no configuration parameters
|
||||
# forgejo: Looks like native Forgejo. Includes logos and resources from your instance. Supports Theme.
|
||||
#
|
||||
#challenge-template: "anubis"
|
||||
|
||||
@@ -49,8 +49,6 @@ links:
|
||||
challenge-template-overrides:
|
||||
# Set template theme if supported
|
||||
#Theme: "forgejo-auto"
|
||||
# Set logo on template if supported
|
||||
#Logo: "/my/custom/logo/path.png"
|
||||
|
||||
# Advanced backend configuration
|
||||
# Backends setup via cmdline will be added here
|
||||
@@ -60,12 +58,6 @@ backends:
|
||||
# url: "http://forgejo:3000"
|
||||
# ip-header: "X-Client-Ip"
|
||||
|
||||
# Example HTTP backend matching a non-standard port in Host
|
||||
# Standard ports are 80 and 443. Others will be sent in Host by browsers
|
||||
#"git.example.com:8080":
|
||||
# url: "http://forgejo:3000"
|
||||
# ip-header: "X-Client-Ip"
|
||||
|
||||
|
||||
# Example HTTPS backend with host/SNI override, HTTP/2 and no certificate verification
|
||||
#"ssl.example.com":
|
||||
@@ -74,14 +66,6 @@ backends:
|
||||
# http2-enabled: true
|
||||
# tls-skip-verify: true
|
||||
|
||||
# Example HTTPS transparent backend with host/SNI override, HTTP/2, and subdirectory
|
||||
#"ssl.example.com":
|
||||
# url: "https://ssl.example.com/subdirectory/"
|
||||
# host: ssl.example.com
|
||||
# http2-enabled: true
|
||||
# ip-header: "-"
|
||||
# transparent: true
|
||||
|
||||
# List of strings you can replace to alter the presentation on challenge/error templates
|
||||
# Can use other languages.
|
||||
# Note raw HTML is allowed, be careful with it.
|
||||
|
||||
@@ -50,7 +50,7 @@ conditions:
|
||||
|
||||
is-suspicious-crawler:
|
||||
# TLS Fingerprint for specific agent without ALPN
|
||||
- '(userAgent.startsWith("Mozilla/") || userAgent.startsWith("Opera/")) && ("ja4" in fp && fp.ja4.matches("^t[0-9a-z]+00_")) && !(userAgent.contains("compatible;") || userAgent.contains("+http") || userAgent.contains("facebookexternalhit/") || userAgent.contains("Twitterbot/"))'
|
||||
- '(userAgent.startsWith("Mozilla/") || userAgent.startsWith("Opera/")) && ("ja4" in fp && fp.ja4.matches("^t[0-9a-z]+00_"))'
|
||||
# Old engines
|
||||
- 'userAgent.contains("Presto/") || userAgent.contains("Trident/")'
|
||||
# Old IE browsers
|
||||
@@ -75,7 +75,6 @@ conditions:
|
||||
- 'path.matches("^/[^/]+/[^/]+/search/")'
|
||||
- 'path.matches("^/[^/]+/[^/]+/find/")'
|
||||
- 'path.matches("^/[^/]+/[^/]+/activity")'
|
||||
- 'path.matches("^/[^/]+/[^/]+/graph$")'
|
||||
# any search with a custom query
|
||||
- '"q" in query && query.q != ""'
|
||||
# user activity tab
|
||||
@@ -104,15 +103,6 @@ rules:
|
||||
- *is-bot-yandexbot
|
||||
action: pass
|
||||
|
||||
# Matches private networks and localhost.
|
||||
# Uncomment this if you want to let your own tools this way
|
||||
# - name: allow-private-networks
|
||||
# conditions:
|
||||
# # Allows localhost and private networks CIDR
|
||||
# - *is-network-localhost
|
||||
# - *is-network-private
|
||||
# action: pass
|
||||
|
||||
- name: undesired-networks
|
||||
conditions:
|
||||
- 'remoteAddress.network("huawei-cloud") || remoteAddress.network("alibaba-cloud") || remoteAddress.network("zenlayer-inc")'
|
||||
@@ -156,7 +146,7 @@ rules:
|
||||
- name: 0
|
||||
action: check
|
||||
settings:
|
||||
challenges: [js-refresh, http-cookie-check]
|
||||
challenges: [js-pow-sha256, http-cookie-check]
|
||||
- name: 1
|
||||
action: check
|
||||
settings:
|
||||
@@ -182,12 +172,11 @@ rules:
|
||||
- 'path.matches("^/[^/]+/[^/]+/archive/.*\\.(bundle|zip|tar\\.gz)") && ($is-generic-browser)'
|
||||
action: challenge
|
||||
settings:
|
||||
challenges: [ js-refresh ]
|
||||
challenges: [ js-pow-sha256 ]
|
||||
|
||||
- name: allow-git-operations
|
||||
conditions:
|
||||
- '($is-git-path)'
|
||||
# Includes repository and wiki git endpoints
|
||||
- 'path.matches("^/[^/]+/[^/]+\\.git")'
|
||||
- 'path.matches("^/[^/]+/[^/]+/") && ($is-git-ua)'
|
||||
action: pass
|
||||
@@ -224,7 +213,7 @@ rules:
|
||||
- name: preview-fetchers
|
||||
conditions:
|
||||
# These summary cards are included in most previews at the end of the url
|
||||
- 'path.endsWith("/-/summary-card") || path.matches("^/[^/]+/[^/]+/releases/summary-card/[^/]+$")'
|
||||
- 'path.endsWith("/-/summary-card")'
|
||||
#- 'userAgent.contains("facebookexternalhit/")'
|
||||
#- 'userAgent.contains("Twitterbot/")'
|
||||
action: pass
|
||||
@@ -251,11 +240,18 @@ rules:
|
||||
- name: 0
|
||||
action: check
|
||||
settings:
|
||||
challenges: [preload-link, header-refresh, js-refresh, http-cookie-check]
|
||||
challenges: [preload-link, header-refresh, js-pow-sha256, http-cookie-check]
|
||||
- name: 1
|
||||
action: check
|
||||
settings:
|
||||
challenges: [ resource-load, js-refresh, http-cookie-check ]
|
||||
challenges: [ resource-load, js-pow-sha256, http-cookie-check ]
|
||||
|
||||
- name: standard-bots
|
||||
action: check
|
||||
settings:
|
||||
challenges: [meta-refresh, resource-load]
|
||||
conditions:
|
||||
- '($is-generic-robot-ua)'
|
||||
|
||||
# Allow all source downloads not caught in browser above
|
||||
# todo: limit this as needed?
|
||||
@@ -276,7 +272,14 @@ rules:
|
||||
# if DNSBL fails, check additional challenges
|
||||
fail: check
|
||||
fail-settings:
|
||||
challenges: [js-refresh, http-cookie-check]
|
||||
challenges: [js-pow-sha256, http-cookie-check]
|
||||
|
||||
- name: suspicious-fetchers
|
||||
action: check
|
||||
settings:
|
||||
challenges: [js-pow-sha256]
|
||||
conditions:
|
||||
- 'userAgent.contains("facebookexternalhit/") || userAgent.contains("facebookcatalog/")'
|
||||
|
||||
# Allow PUT/DELETE/PATCH/POST requests in general
|
||||
- name: non-get-request
|
||||
@@ -287,16 +290,11 @@ rules:
|
||||
# Enable fetching OpenGraph and other tags from backend on these paths
|
||||
- name: enable-meta-tags
|
||||
action: context
|
||||
conditions:
|
||||
- 'userAgent.contains("facebookexternalhit/") || userAgent.contains("Facebot/") || userAgent.contains("Twitterbot/")'
|
||||
- '($is-generic-robot-ua)'
|
||||
- '!($is-generic-browser)'
|
||||
settings:
|
||||
context-set:
|
||||
# Map OpenGraph or similar <meta> tags back to the reply, even if denied/challenged
|
||||
proxy-meta-tags: "true"
|
||||
# proxy-safe-link-tags: "true"
|
||||
|
||||
|
||||
# Set additional response headers
|
||||
#response-headers:
|
||||
# X-Clacks-Overhead:
|
||||
@@ -323,7 +321,7 @@ rules:
|
||||
- name: standard-browser
|
||||
action: challenge
|
||||
settings:
|
||||
challenges: [http-cookie-check, preload-link, meta-refresh, resource-load, js-refresh, js-pow-sha256]
|
||||
challenges: [http-cookie-check, preload-link, meta-refresh, resource-load, js-pow-sha256]
|
||||
conditions:
|
||||
- '($is-generic-browser)'
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ networks:
|
||||
|
||||
challenges:
|
||||
# Challenges will get included from snippets
|
||||
|
||||
|
||||
conditions:
|
||||
# Conditions will get replaced on rules AST when found as ($condition-name)
|
||||
|
||||
@@ -27,7 +27,7 @@ conditions:
|
||||
# Old IE browsers
|
||||
- 'userAgent.matches("MSIE ([2-9]|10|11)\\.")'
|
||||
# Old Linux browsers
|
||||
- 'userAgent.matches("Linux i[63]86") || userAgent.matches("FreeBSD i[63]86")'
|
||||
- 'userAgent.contains("Linux i[63]86") || userAgent.contains("FreeBSD i[63]86")'
|
||||
# Old Windows browsers
|
||||
- 'userAgent.matches("Windows (3|95|98|CE)") || userAgent.matches("Windows NT [1-5]\\.")'
|
||||
# Old mobile browsers
|
||||
@@ -60,15 +60,6 @@ rules:
|
||||
- *is-bot-yandexbot
|
||||
action: pass
|
||||
|
||||
# Matches private networks and localhost.
|
||||
# Uncomment this if you want to let your own tools this way
|
||||
# - name: allow-private-networks
|
||||
# conditions:
|
||||
# # Allows localhost and private networks CIDR
|
||||
# - *is-network-localhost
|
||||
# - *is-network-private
|
||||
# action: pass
|
||||
|
||||
- name: undesired-crawlers
|
||||
conditions:
|
||||
- '($is-headless-chromium)'
|
||||
@@ -107,7 +98,7 @@ rules:
|
||||
- name: 0
|
||||
action: check
|
||||
settings:
|
||||
challenges: [js-refresh]
|
||||
challenges: [js-pow-sha256]
|
||||
- name: 1
|
||||
action: check
|
||||
settings:
|
||||
@@ -131,12 +122,12 @@ rules:
|
||||
# if DNSBL fails, check additional challenges
|
||||
fail: check
|
||||
fail-settings:
|
||||
challenges: [js-refresh]
|
||||
challenges: [js-pow-sha256]
|
||||
|
||||
- name: suspicious-fetchers
|
||||
action: check
|
||||
settings:
|
||||
challenges: [js-refresh]
|
||||
challenges: [js-pow-sha256]
|
||||
conditions:
|
||||
- 'userAgent.contains("facebookexternalhit/") || userAgent.contains("facebookcatalog/")'
|
||||
|
||||
@@ -179,7 +170,7 @@ rules:
|
||||
- name: standard-browser
|
||||
action: challenge
|
||||
settings:
|
||||
challenges: [preload-link, meta-refresh, resource-load, js-refresh]
|
||||
challenges: [preload-link, meta-refresh, resource-load, js-pow-sha256]
|
||||
conditions:
|
||||
- '($is-generic-browser)'
|
||||
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
networks:
|
||||
betterstack:
|
||||
- url: https://uptime.betterstack.com/ips-by-cluster.json
|
||||
jq-path: '.[] | .[]'
|
||||
|
||||
conditions:
|
||||
is-bot-betterstack:
|
||||
- &is-bot-betterstack '((userAgent.startsWith("Better Stack Better Uptime Bot") || userAgent.startsWith("Better Uptime Bot") || userAgent == "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.17 Safari/537.36")) && remoteAddress.network("betterstack")'
|
||||
@@ -1,6 +0,0 @@
|
||||
challenges:
|
||||
js-refresh:
|
||||
# Challenges with a redirect via window.location (requires HTML parsing and JavaScript logic)
|
||||
runtime: "refresh"
|
||||
parameters:
|
||||
refresh-via: "javascript"
|
||||
@@ -9,7 +9,7 @@ challenges:
|
||||
condition: '"Sec-Fetch-Mode" in headers && headers["Sec-Fetch-Mode"] == "navigate"'
|
||||
runtime: "preload-link"
|
||||
parameters:
|
||||
preload-early-hint-deadline: 2s
|
||||
preload-early-hint-deadline: 3s
|
||||
|
||||
# Challenges with a redirect via Refresh header (non-JS, requires HTTP parsing and logic)
|
||||
header-refresh:
|
||||
|
||||
@@ -1,19 +1,8 @@
|
||||
conditions:
|
||||
is-well-known-asset:
|
||||
# general txt files or scraper
|
||||
- 'path == "/robots.txt" || path == "/security.txt"'
|
||||
|
||||
# ads txt files
|
||||
- 'path == "/app-ads.txt" || path == "/ads.txt"'
|
||||
|
||||
# generally requested by browsers
|
||||
- 'path == "/robots.txt"'
|
||||
- 'path == "/favicon.ico"'
|
||||
|
||||
# used by some applications
|
||||
- 'path == "/crossdomain.xml"'
|
||||
|
||||
# well-known paths
|
||||
- 'path.startsWith("/.well-known/")'
|
||||
- 'path.startsWith("/.well-known")'
|
||||
|
||||
is-git-ua:
|
||||
- 'userAgent.startsWith("git/") || userAgent.contains("libgit")'
|
||||
|
||||
@@ -1,37 +1,37 @@
|
||||
networks:
|
||||
aws-cloud:
|
||||
- url: https://ip-ranges.amazonaws.com/ip-ranges.json
|
||||
jq-path: '(.prefixes[] | select(has("ip_prefix")) | .ip_prefix), (.prefixes[] | select(has("ipv6_prefix")) | .ipv6_prefix)'
|
||||
google-cloud:
|
||||
- url: https://www.gstatic.com/ipranges/cloud.json
|
||||
jq-path: '(.prefixes[] | select(has("ipv4Prefix")) | .ipv4Prefix), (.prefixes[] | select(has("ipv6Prefix")) | .ipv6Prefix)'
|
||||
oracle-cloud:
|
||||
- url: https://docs.oracle.com/en-us/iaas/tools/public_ip_ranges.json
|
||||
jq-path: '.regions[] | .cidrs[] | .cidr'
|
||||
azure-cloud:
|
||||
# todo: https://www.microsoft.com/en-us/download/details.aspx?id=56519 does not provide direct JSON
|
||||
- url: https://raw.githubusercontent.com/femueller/cloud-ip-ranges/refs/heads/master/microsoft-azure-ip-ranges.json
|
||||
jq-path: '.values[] | .properties.addressPrefixes[]'
|
||||
|
||||
digitalocean:
|
||||
- url: https://www.digitalocean.com/geo/google.csv
|
||||
regex: "(?P<prefix>(([0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+)|([0-9a-f:]+::))/[0-9]+),"
|
||||
linode:
|
||||
- url: https://geoip.linode.com/
|
||||
regex: "(?P<prefix>(([0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+)|([0-9a-f:]+::))/[0-9]+),"
|
||||
vultr:
|
||||
- url: "https://geofeed.constant.com/?json"
|
||||
jq-path: '.subnets[] | .ip_prefix'
|
||||
cloudflare:
|
||||
- url: https://www.cloudflare.com/ips-v4
|
||||
regex: "(?P<prefix>[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+/[0-9]+)"
|
||||
- url: https://www.cloudflare.com/ips-v6
|
||||
regex: "(?P<prefix>[0-9a-f:]+::/[0-9]+)"
|
||||
|
||||
icloud-private-relay:
|
||||
- url: https://mask-api.icloud.com/egress-ip-ranges.csv
|
||||
regex: "(?P<prefix>(([0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+)|([0-9a-f:]+::))/[0-9]+),"
|
||||
tunnelbroker-relay:
|
||||
# HE Tunnelbroker
|
||||
- url: https://tunnelbroker.net/export/google
|
||||
regex: "(?P<prefix>([0-9a-f:]+::)/[0-9]+),"
|
||||
# aws-cloud:
|
||||
# - url: https://ip-ranges.amazonaws.com/ip-ranges.json
|
||||
# jq-path: '(.prefixes[] | select(has("ip_prefix")) | .ip_prefix), (.prefixes[] | select(has("ipv6_prefix")) | .ipv6_prefix)'
|
||||
# google-cloud:
|
||||
# - url: https://www.gstatic.com/ipranges/cloud.json
|
||||
# jq-path: '(.prefixes[] | select(has("ipv4Prefix")) | .ipv4Prefix), (.prefixes[] | select(has("ipv6Prefix")) | .ipv6Prefix)'
|
||||
# oracle-cloud:
|
||||
# - url: https://docs.oracle.com/en-us/iaas/tools/public_ip_ranges.json
|
||||
# jq-path: '.regions[] | .cidrs[] | .cidr'
|
||||
# azure-cloud:
|
||||
# # todo: https://www.microsoft.com/en-us/download/details.aspx?id=56519 does not provide direct JSON
|
||||
# - url: https://raw.githubusercontent.com/femueller/cloud-ip-ranges/refs/heads/master/microsoft-azure-ip-ranges.json
|
||||
# jq-path: '.values[] | .properties.addressPrefixes[]'
|
||||
#
|
||||
# digitalocean:
|
||||
# - url: https://www.digitalocean.com/geo/google.csv
|
||||
# regex: "(?P<prefix>(([0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+)|([0-9a-f:]+::))/[0-9]+),"
|
||||
# linode:
|
||||
# - url: https://geoip.linode.com/
|
||||
# regex: "(?P<prefix>(([0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+)|([0-9a-f:]+::))/[0-9]+),"
|
||||
# vultr:
|
||||
# - url: "https://geofeed.constant.com/?json"
|
||||
# jq-path: '.subnets[] | .ip_prefix'
|
||||
# cloudflare:
|
||||
# - url: https://www.cloudflare.com/ips-v4
|
||||
# regex: "(?P<prefix>[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+/[0-9]+)"
|
||||
# - url: https://www.cloudflare.com/ips-v6
|
||||
# regex: "(?P<prefix>[0-9a-f:]+::/[0-9]+)"
|
||||
#
|
||||
# icloud-private-relay:
|
||||
# - url: https://mask-api.icloud.com/egress-ip-ranges.csv
|
||||
# regex: "(?P<prefix>(([0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+)|([0-9a-f:]+::))/[0-9]+),"
|
||||
# tunnelbroker-relay:
|
||||
# # HE Tunnelbroker
|
||||
# - url: https://tunnelbroker.net/export/google
|
||||
# regex: "(?P<prefix>([0-9a-f:]+::)/[0-9]+),"
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
networks:
|
||||
localhost:
|
||||
# localhost and loopback addresses
|
||||
- prefixes:
|
||||
- "127.0.0.0/8"
|
||||
- "::1/128"
|
||||
private:
|
||||
# Private network CIDR blocks
|
||||
- prefixes:
|
||||
# private networks
|
||||
- "10.0.0.0/8"
|
||||
- "172.16.0.0/12"
|
||||
- "192.168.0.0/16"
|
||||
- "fc00::/7"
|
||||
# CGNAT
|
||||
- "100.64.0.0/10"
|
||||
|
||||
conditions:
|
||||
is-network-localhost:
|
||||
- &is-network-localhost 'remoteAddress.network("localhost")'
|
||||
is-network-private:
|
||||
- &is-network-private 'remoteAddress.network("private")'
|
||||
@@ -28,10 +28,7 @@ func (a Block) Handle(logger *slog.Logger, w http.ResponseWriter, r *http.Reques
|
||||
data := challenge.RequestDataFromContext(r.Context())
|
||||
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
w.Header().Set("Connection", "close")
|
||||
|
||||
data.ResponseHeaders(w)
|
||||
w.WriteHeader(a.Code)
|
||||
_, _ = w.Write([]byte(fmt.Errorf("access blocked: blocked by administrative rule %s/%s", data.Id.String(), a.RuleHash).Error()))
|
||||
|
||||
|
||||
@@ -42,12 +42,6 @@ type CodeSettings struct {
|
||||
type Code int
|
||||
|
||||
func (a Code) Handle(logger *slog.Logger, w http.ResponseWriter, r *http.Request, done func() (backend http.Handler)) (next bool, err error) {
|
||||
data := challenge.RequestDataFromContext(r.Context())
|
||||
|
||||
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
|
||||
data.ResponseHeaders(w)
|
||||
|
||||
w.WriteHeader(int(a))
|
||||
return false, nil
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"github.com/goccy/go-yaml/ast"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/textproto"
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -34,9 +33,8 @@ func init() {
|
||||
var ContextDefaultSettings = ContextSettings{}
|
||||
|
||||
type ContextSettings struct {
|
||||
ContextSet map[string]string `yaml:"context-set"`
|
||||
ResponseHeaders map[string][]string `yaml:"response-headers"`
|
||||
RequestHeaders map[string][]string `yaml:"request-headers"`
|
||||
ContextSet map[string]string `yaml:"context-set"`
|
||||
ResponseHeaders map[string]string `yaml:"response-headers"`
|
||||
}
|
||||
|
||||
type Context struct {
|
||||
@@ -50,19 +48,7 @@ func (a Context) Handle(logger *slog.Logger, w http.ResponseWriter, r *http.Requ
|
||||
}
|
||||
|
||||
for k, v := range a.opts.ResponseHeaders {
|
||||
// do this to allow unsetting values that are sent automatically
|
||||
w.Header()[textproto.CanonicalMIMEHeaderKey(k)] = nil
|
||||
for _, val := range v {
|
||||
w.Header().Add(k, val)
|
||||
}
|
||||
}
|
||||
|
||||
for k, v := range a.opts.RequestHeaders {
|
||||
// do this to allow unsetting values that are sent automatically
|
||||
r.Header[textproto.CanonicalMIMEHeaderKey(k)] = nil
|
||||
for _, val := range v {
|
||||
r.Header.Add(k, val)
|
||||
}
|
||||
w.Header().Set(k, v)
|
||||
}
|
||||
|
||||
return true, nil
|
||||
|
||||
@@ -33,8 +33,6 @@ func (a Drop) Handle(logger *slog.Logger, w http.ResponseWriter, r *http.Request
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
w.Header().Set("Content-Length", "0")
|
||||
w.Header().Set("Connection", "close")
|
||||
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
|
||||
return false, nil
|
||||
|
||||
@@ -2,6 +2,7 @@ package cookie
|
||||
|
||||
import (
|
||||
"git.gammaspectra.live/git/go-away/lib/challenge"
|
||||
"git.gammaspectra.live/git/go-away/utils"
|
||||
"github.com/goccy/go-yaml/ast"
|
||||
"net/http"
|
||||
"time"
|
||||
@@ -17,15 +18,18 @@ func FillRegistration(state challenge.StateInterface, reg *challenge.Registratio
|
||||
reg.Class = challenge.ClassBlocking
|
||||
|
||||
reg.IssueChallenge = func(w http.ResponseWriter, r *http.Request, key challenge.Key, expiry time.Time) challenge.VerifyResult {
|
||||
data := challenge.RequestDataFromContext(r.Context())
|
||||
data.IssueChallengeToken(reg, key, nil, expiry, true)
|
||||
token, err := reg.IssueChallengeToken(state.PrivateKey(), key, nil, expiry, true)
|
||||
if err != nil {
|
||||
return challenge.VerifyResultFail
|
||||
}
|
||||
|
||||
utils.SetCookie(challenge.RequestDataFromContext(r.Context()).CookiePrefix+reg.Name, token, expiry, w, r)
|
||||
|
||||
uri, err := challenge.RedirectUrl(r, reg)
|
||||
if err != nil {
|
||||
return challenge.VerifyResultFail
|
||||
}
|
||||
|
||||
data.ResponseHeaders(w)
|
||||
http.Redirect(w, r, uri.String(), http.StatusTemporaryRedirect)
|
||||
return challenge.VerifyResultNone
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package challenge
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
http_cel "codeberg.org/gone/http-cel"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
@@ -10,13 +9,9 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"git.gammaspectra.live/git/go-away/utils"
|
||||
"github.com/go-jose/go-jose/v4"
|
||||
"github.com/go-jose/go-jose/v4/jwt"
|
||||
"github.com/google/cel-go/cel"
|
||||
"github.com/google/cel-go/common/types"
|
||||
"github.com/google/cel-go/common/types/traits"
|
||||
"maps"
|
||||
unsaferand "math/rand/v2"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/textproto"
|
||||
@@ -28,11 +23,7 @@ type requestDataContextKey struct {
|
||||
}
|
||||
|
||||
func RequestDataFromContext(ctx context.Context) *RequestData {
|
||||
val := ctx.Value(requestDataContextKey{})
|
||||
if val == nil {
|
||||
return nil
|
||||
}
|
||||
return val.(*RequestData)
|
||||
return ctx.Value(requestDataContextKey{}).(*RequestData)
|
||||
}
|
||||
|
||||
type RequestId [16]byte
|
||||
@@ -42,19 +33,13 @@ func (id RequestId) String() string {
|
||||
}
|
||||
|
||||
type RequestData struct {
|
||||
Id RequestId
|
||||
Time time.Time
|
||||
ChallengeVerify map[Id]VerifyResult
|
||||
ChallengeState map[Id]VerifyState
|
||||
ChallengeMap TokenChallengeMap
|
||||
challengeMapModified bool
|
||||
|
||||
Id RequestId
|
||||
Time time.Time
|
||||
ChallengeVerify map[Id]VerifyResult
|
||||
ChallengeState map[Id]VerifyState
|
||||
RemoteAddress netip.AddrPort
|
||||
State StateInterface
|
||||
cookieName string
|
||||
issuedChallenge string
|
||||
|
||||
ExtraHeaders http.Header
|
||||
CookiePrefix string
|
||||
|
||||
r *http.Request
|
||||
|
||||
@@ -76,27 +61,22 @@ func CreateRequestData(r *http.Request, state StateInterface) (*http.Request, *R
|
||||
data.Time = time.Now().UTC()
|
||||
data.State = state
|
||||
|
||||
data.ExtraHeaders = make(http.Header)
|
||||
|
||||
data.fp = make(map[string]string, 2)
|
||||
|
||||
if fp := utils.GetTLSFingerprint(r); fp != nil {
|
||||
if ja3nPtr := fp.JA3N(); ja3nPtr != nil {
|
||||
ja3n := ja3nPtr.String()
|
||||
data.fp["ja3n"] = ja3n
|
||||
r.Header.Set("X-TLS-Fingerprint-JA3N", ja3n)
|
||||
}
|
||||
if ja4Ptr := fp.JA4(); ja4Ptr != nil {
|
||||
ja4 := ja4Ptr.String()
|
||||
data.fp["ja4"] = ja4
|
||||
r.Header.Set("X-TLS-Fingerprint-JA4", ja4)
|
||||
}
|
||||
}
|
||||
|
||||
q := r.URL.Query()
|
||||
|
||||
if q.Has(QueryArgChallenge) {
|
||||
data.issuedChallenge = q.Get(QueryArgChallenge)
|
||||
}
|
||||
|
||||
// delete query parameters that were set by go-away
|
||||
for k := range q {
|
||||
if strings.HasPrefix(k, QueryArgPrefix) {
|
||||
@@ -108,12 +88,19 @@ func CreateRequestData(r *http.Request, state StateInterface) (*http.Request, *R
|
||||
data.header = http_cel.NewMIMEMap(textproto.MIMEHeader(r.Header))
|
||||
data.opts = make(map[string]string)
|
||||
|
||||
sum := sha256.New()
|
||||
sum.Write([]byte(r.Host))
|
||||
sum.Write([]byte{0})
|
||||
sum.Write(data.NetworkPrefix().AsSlice())
|
||||
sum.Write([]byte{0})
|
||||
sum.Write(state.PublicKey())
|
||||
sum.Write([]byte{0})
|
||||
data.CookiePrefix = utils.CookiePrefix + hex.EncodeToString(sum.Sum(nil)[:6]) + "-"
|
||||
|
||||
r = r.WithContext(context.WithValue(r.Context(), requestDataContextKey{}, &data))
|
||||
r = utils.SetRemoteAddress(r, data.RemoteAddress)
|
||||
data.r = r
|
||||
|
||||
data.cookieName = utils.DefaultCookiePrefix + hex.EncodeToString(data.cookieHostKey()) + "-state"
|
||||
|
||||
return r, &data
|
||||
}
|
||||
|
||||
@@ -158,9 +145,8 @@ func (d *RequestData) NetworkPrefix() netip.Addr {
|
||||
}
|
||||
|
||||
const (
|
||||
RequestOptBackendHost = "backend-host"
|
||||
RequestOptProxyMetaTags = "proxy-meta-tags"
|
||||
RequestOptProxySafeLinkTags = "proxy-safe-link-tags"
|
||||
RequestOptBackendHost = "backend-host"
|
||||
RequestOptCacheMetaTags = "proxy-meta-tags"
|
||||
)
|
||||
|
||||
func (d *RequestData) SetOpt(n, v string) {
|
||||
@@ -200,70 +186,18 @@ func (d *RequestData) BackendHost() (http.Handler, string) {
|
||||
return d.State.GetBackend(host), host
|
||||
}
|
||||
|
||||
func (d *RequestData) ClearChallengeToken(reg *Registration) {
|
||||
delete(d.ChallengeMap, reg.Name)
|
||||
d.challengeMapModified = true
|
||||
}
|
||||
|
||||
func (d *RequestData) IssueChallengeToken(reg *Registration, key Key, result []byte, until time.Time, ok bool) {
|
||||
d.ChallengeMap[reg.Name] = TokenChallenge{
|
||||
Key: key[:],
|
||||
Result: result,
|
||||
Ok: ok,
|
||||
Expiry: jwt.NumericDate(until.Unix()),
|
||||
IssuedAt: jwt.NumericDate(time.Now().UTC().Unix()),
|
||||
func (d *RequestData) EvaluateChallenges(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
var issuedChallenge string
|
||||
if q.Has(QueryArgChallenge) {
|
||||
issuedChallenge = q.Get(QueryArgChallenge)
|
||||
}
|
||||
d.challengeMapModified = true
|
||||
}
|
||||
|
||||
var ErrVerifyKeyMismatch = errors.New("verify: key mismatch")
|
||||
var ErrVerifyVerifyMismatch = errors.New("verify: verification mismatch")
|
||||
var ErrTokenExpired = errors.New("token: expired")
|
||||
|
||||
func (d *RequestData) VerifyChallengeToken(reg *Registration, token TokenChallenge, expectedKey Key) (VerifyResult, VerifyState, error) {
|
||||
if token.Expiry.Time().Compare(time.Now()) < 0 {
|
||||
return VerifyResultFail, VerifyStateNone, ErrTokenExpired
|
||||
}
|
||||
if token.NotBefore.Time().Compare(time.Now()) > 0 {
|
||||
return VerifyResultFail, VerifyStateNone, errors.New("token not valid yet")
|
||||
}
|
||||
|
||||
if bytes.Compare(expectedKey[:], token.Key) != 0 {
|
||||
return VerifyResultFail, VerifyStateNone, ErrVerifyKeyMismatch
|
||||
}
|
||||
|
||||
if reg.Verify != nil {
|
||||
if unsaferand.Float64() < reg.VerifyProbability {
|
||||
// random spot check
|
||||
if ok, err := reg.Verify(expectedKey, token.Result, d.r); err != nil {
|
||||
return VerifyResultFail, VerifyStateFull, err
|
||||
} else if ok == VerifyResultNotOK {
|
||||
return VerifyResultNotOK, VerifyStateFull, nil
|
||||
} else if !ok.Ok() {
|
||||
return ok, VerifyStateFull, ErrVerifyVerifyMismatch
|
||||
} else {
|
||||
return ok, VerifyStateFull, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !token.Ok {
|
||||
return VerifyResultNotOK, VerifyStateBrief, nil
|
||||
}
|
||||
return VerifyResultOK, VerifyStateBrief, nil
|
||||
}
|
||||
|
||||
func (d *RequestData) verifyChallenge(reg *Registration, key Key) (verifyResult VerifyResult, verifyState VerifyState, err error) {
|
||||
|
||||
token, ok := d.ChallengeMap[reg.Name]
|
||||
if !ok {
|
||||
verifyResult = VerifyResultFail
|
||||
verifyState = VerifyStateNone
|
||||
} else {
|
||||
verifyResult, verifyState, err = d.VerifyChallengeToken(reg, token, key)
|
||||
for _, reg := range d.State.GetChallenges() {
|
||||
key := GetChallengeKeyForRequest(d.State, reg, d.Expiration(reg.Duration), r)
|
||||
verifyResult, verifyState, err := reg.VerifyChallengeToken(d.State.PublicKey(), key, r)
|
||||
if err != nil && !errors.Is(err, http.ErrNoCookie) {
|
||||
// clear invalid state
|
||||
d.ClearChallengeToken(reg)
|
||||
// clear invalid cookie
|
||||
utils.ClearCookie(d.CookiePrefix+reg.Name, w, r)
|
||||
}
|
||||
|
||||
// prevent evaluating the challenge if not solved
|
||||
@@ -271,49 +205,34 @@ func (d *RequestData) verifyChallenge(reg *Registration, key Key) (verifyResult
|
||||
out, _, err := reg.Condition.Eval(d)
|
||||
// verify eligibility
|
||||
if err != nil {
|
||||
d.State.Logger(d.r).Error(err.Error(), "challenge", reg.Name)
|
||||
d.State.Logger(r).Error(err.Error(), "challenge", reg.Name)
|
||||
} else if out != nil && out.Type() == types.BoolType {
|
||||
if out.Equal(types.True) != types.True {
|
||||
// skip challenge match due to precondition!
|
||||
verifyResult = VerifyResultSkip
|
||||
return verifyResult, verifyState, err
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !verifyResult.Ok() && d.issuedChallenge == reg.Name {
|
||||
// we issued the challenge, must skip to prevent loops
|
||||
verifyResult = VerifyResultSkip
|
||||
}
|
||||
|
||||
return verifyResult, verifyState, err
|
||||
}
|
||||
|
||||
func (d *RequestData) EvaluateChallenges(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
challengeMap, err := d.verifyChallengeState()
|
||||
if err != nil {
|
||||
if !errors.Is(err, http.ErrNoCookie) {
|
||||
//queue resend invalid cookie and continue
|
||||
d.challengeMapModified = true
|
||||
if !verifyResult.Ok() && issuedChallenge == reg.Name {
|
||||
// we issued the challenge, must skip to prevent loops
|
||||
verifyResult = VerifyResultSkip
|
||||
}
|
||||
challengeMap = make(TokenChallengeMap)
|
||||
}
|
||||
d.ChallengeMap = challengeMap
|
||||
|
||||
for _, reg := range d.State.GetChallenges() {
|
||||
|
||||
key := GetChallengeKeyForRequest(d.State, reg, d.Expiration(reg.Duration), r)
|
||||
verifyResult, verifyState, err := d.verifyChallenge(reg, key)
|
||||
if err != nil {
|
||||
// clear invalid state
|
||||
d.ClearChallengeToken(reg)
|
||||
}
|
||||
|
||||
d.ChallengeVerify[reg.Id()] = verifyResult
|
||||
d.ChallengeState[reg.Id()] = verifyState
|
||||
}
|
||||
|
||||
if d.State.Settings().BackendIpHeader != "" {
|
||||
if d.State.Settings().ClientIpHeader != "" {
|
||||
r.Header.Del(d.State.Settings().ClientIpHeader)
|
||||
}
|
||||
r.Header.Set(d.State.Settings().BackendIpHeader, d.RemoteAddress.String())
|
||||
}
|
||||
|
||||
// send these to client so we consistently get the headers
|
||||
//w.Header().Set("Accept-CH", "Sec-CH-UA, Sec-CH-UA-Platform")
|
||||
//w.Header().Set("Critical-CH", "Sec-CH-UA, Sec-CH-UA-Platform")
|
||||
}
|
||||
|
||||
func (d *RequestData) Expiration(duration time.Duration) time.Time {
|
||||
@@ -324,38 +243,9 @@ func (d *RequestData) HasValidChallenge(id Id) bool {
|
||||
return d.ChallengeVerify[id].Ok()
|
||||
}
|
||||
|
||||
func (d *RequestData) ResponseHeaders(w http.ResponseWriter) {
|
||||
// send these to client so we consistently get the headers
|
||||
//w.Header().Set("Accept-CH", "Sec-CH-UA, Sec-CH-UA-Platform")
|
||||
//w.Header().Set("Critical-CH", "Sec-CH-UA, Sec-CH-UA-Platform")
|
||||
|
||||
// send Vary header to mark that response may vary based on Cookie values and other client headers
|
||||
w.Header().Set("Vary", "Cookie, Accept, Accept-Encoding, Accept-Language, User-Agent")
|
||||
|
||||
if d.State.Settings().MainName != "" {
|
||||
w.Header().Add("Via", fmt.Sprintf("%s %s@%s", d.r.Proto, d.State.Settings().MainName, d.State.Settings().MainVersion))
|
||||
}
|
||||
|
||||
if d.challengeMapModified {
|
||||
expiration := d.Expiration(DefaultDuration)
|
||||
if token, err := d.issueChallengeState(expiration); err == nil {
|
||||
utils.SetCookie(d.cookieName, token, expiration, w, d.r)
|
||||
} else {
|
||||
d.State.Logger(d.r).Error("error while issuing cookie", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *RequestData) RequestHeaders(headers http.Header) {
|
||||
headers.Set("X-Away-Id", d.Id.String())
|
||||
|
||||
if d.State.Settings().BackendIpHeader != "" {
|
||||
if d.State.Settings().ClientIpHeader != "" {
|
||||
headers.Del(d.State.Settings().ClientIpHeader)
|
||||
}
|
||||
headers.Set(d.State.Settings().BackendIpHeader, d.RemoteAddress.Addr().Unmap().String())
|
||||
}
|
||||
|
||||
for id, result := range d.ChallengeVerify {
|
||||
if result.Ok() {
|
||||
c, ok := d.State.GetChallenge(id)
|
||||
@@ -367,136 +257,4 @@ func (d *RequestData) RequestHeaders(headers http.Header) {
|
||||
headers.Set(fmt.Sprintf("X-Away-Challenge-%s-State", c.Name), d.ChallengeState[id].String())
|
||||
}
|
||||
}
|
||||
|
||||
if ja4, ok := d.fp["fp4"]; ok {
|
||||
headers.Set("X-TLS-Fingerprint-JA4", ja4)
|
||||
}
|
||||
|
||||
if ja3n, ok := d.fp["ja3n"]; ok {
|
||||
headers.Set("X-TLS-Fingerprint-JA3N", ja3n)
|
||||
}
|
||||
|
||||
maps.Copy(headers, d.ExtraHeaders)
|
||||
}
|
||||
|
||||
type Token struct {
|
||||
State TokenChallengeMap `json:"state"`
|
||||
|
||||
Expiry jwt.NumericDate `json:"exp,omitempty"`
|
||||
NotBefore jwt.NumericDate `json:"nbf,omitempty"`
|
||||
IssuedAt jwt.NumericDate `json:"iat,omitempty"`
|
||||
}
|
||||
|
||||
type TokenChallengeMap map[string]TokenChallenge
|
||||
|
||||
type TokenChallenge struct {
|
||||
Key []byte `json:"key"`
|
||||
Result []byte `json:"result,omitempty"`
|
||||
Ok bool `json:"ok"`
|
||||
|
||||
Expiry jwt.NumericDate `json:"exp,omitempty"`
|
||||
NotBefore jwt.NumericDate `json:"nbf,omitempty"`
|
||||
IssuedAt jwt.NumericDate `json:"iat,omitempty"`
|
||||
}
|
||||
|
||||
func (d *RequestData) verifyChallengeStateCookie(cookie *http.Cookie) (TokenChallengeMap, error) {
|
||||
cookie, err := d.r.Cookie(d.cookieName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cookie == nil {
|
||||
return nil, http.ErrNoCookie
|
||||
}
|
||||
encryptedToken, err := jwt.ParseSignedAndEncrypted(cookie.Value,
|
||||
[]jose.KeyAlgorithm{jose.DIRECT},
|
||||
[]jose.ContentEncryption{jose.A256GCM},
|
||||
[]jose.SignatureAlgorithm{jose.EdDSA},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
signedToken, err := encryptedToken.Decrypt(d.cookieKey())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var i Token
|
||||
err = signedToken.Claims(d.State.PublicKey(), &i)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if i.Expiry.Time().Compare(time.Now()) < 0 {
|
||||
return nil, ErrTokenExpired
|
||||
}
|
||||
if i.NotBefore.Time().Compare(time.Now()) > 0 {
|
||||
return nil, errors.New("token not valid yet")
|
||||
}
|
||||
|
||||
return i.State, nil
|
||||
}
|
||||
|
||||
func (d *RequestData) verifyChallengeState() (state TokenChallengeMap, err error) {
|
||||
cookies := d.r.CookiesNamed(d.cookieName)
|
||||
if len(cookies) == 0 {
|
||||
return nil, http.ErrNoCookie
|
||||
}
|
||||
for _, cookie := range cookies {
|
||||
state, err = d.verifyChallengeStateCookie(cookie)
|
||||
if err == nil {
|
||||
return state, nil
|
||||
}
|
||||
}
|
||||
return state, err
|
||||
}
|
||||
|
||||
func (d *RequestData) issueChallengeState(until time.Time) (string, error) {
|
||||
signer, err := jose.NewSigner(jose.SigningKey{
|
||||
Algorithm: jose.EdDSA,
|
||||
Key: d.State.PrivateKey(),
|
||||
}, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
encrypter, err := jose.NewEncrypter(jose.A256GCM, jose.Recipient{
|
||||
Algorithm: jose.DIRECT,
|
||||
Key: d.cookieKey(),
|
||||
}, (&jose.EncrypterOptions{
|
||||
Compression: jose.DEFLATE,
|
||||
}).WithContentType("JWT"))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return jwt.SignedAndEncrypted(signer, encrypter).Claims(Token{
|
||||
State: d.ChallengeMap,
|
||||
Expiry: jwt.NumericDate(until.Unix()),
|
||||
NotBefore: jwt.NumericDate(time.Now().UTC().AddDate(0, 0, -1).Unix()),
|
||||
IssuedAt: jwt.NumericDate(time.Now().UTC().Unix()),
|
||||
}).Serialize()
|
||||
}
|
||||
|
||||
func (d *RequestData) cookieKey() []byte {
|
||||
sum := sha256.New()
|
||||
sum.Write([]byte(d.r.Host))
|
||||
sum.Write([]byte{0})
|
||||
sum.Write(d.NetworkPrefix().AsSlice())
|
||||
sum.Write([]byte{0})
|
||||
sum.Write(d.State.PrivateKey())
|
||||
sum.Write([]byte{0})
|
||||
// version/compressor
|
||||
sum.Write([]byte("1.0/DEFLATE"))
|
||||
sum.Write([]byte{0})
|
||||
|
||||
return sum.Sum(nil)
|
||||
}
|
||||
|
||||
func (d *RequestData) cookieHostKey() []byte {
|
||||
sum := sha256.New()
|
||||
sum.Write([]byte(d.r.Host))
|
||||
sum.Write([]byte{0})
|
||||
sum.Write(d.NetworkPrefix().AsSlice())
|
||||
sum.Write([]byte{0})
|
||||
|
||||
return sum.Sum(nil)[:6]
|
||||
}
|
||||
|
||||
@@ -125,10 +125,18 @@ func FillRegistration(state challenge.StateInterface, reg *challenge.Registratio
|
||||
}
|
||||
|
||||
if result.Bad() {
|
||||
data.IssueChallengeToken(reg, key, nil, expiry, false)
|
||||
token, err := reg.IssueChallengeToken(state.PrivateKey(), key, nil, expiry, false)
|
||||
if err != nil {
|
||||
return challenge.VerifyResultFail
|
||||
}
|
||||
utils.SetCookie(data.CookiePrefix+reg.Name, token, expiry, w, r)
|
||||
return challenge.VerifyResultNotOK
|
||||
} else {
|
||||
data.IssueChallengeToken(reg, key, nil, expiry, true)
|
||||
token, err := reg.IssueChallengeToken(state.PrivateKey(), key, nil, expiry, true)
|
||||
if err != nil {
|
||||
return challenge.VerifyResultFail
|
||||
}
|
||||
utils.SetCookie(data.CookiePrefix+reg.Name, token, expiry, w, r)
|
||||
return challenge.VerifyResultOK
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,9 +8,7 @@ import (
|
||||
"git.gammaspectra.live/git/go-away/utils"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ErrInvalidToken = errors.New("invalid token")
|
||||
@@ -49,7 +47,6 @@ const (
|
||||
QueryArgRequestId = QueryArgPrefix + "_id"
|
||||
QueryArgChallenge = QueryArgPrefix + "_challenge"
|
||||
QueryArgToken = QueryArgPrefix + "_token"
|
||||
QueryArgBust = QueryArgPrefix + "_bust"
|
||||
)
|
||||
|
||||
const MakeChallengeUrlSuffix = "/make-challenge"
|
||||
@@ -94,13 +91,12 @@ func VerifyUrl(r *http.Request, reg *Registration, token string) (*url.URL, erro
|
||||
uri.Path = reg.Path + VerifyChallengeUrlSuffix
|
||||
|
||||
data := RequestDataFromContext(r.Context())
|
||||
values, _ := utils.ParseRawQuery(r.URL.RawQuery)
|
||||
values.Set(QueryArgRequestId, url.QueryEscape(data.Id.String()))
|
||||
values.Set(QueryArgRedirect, url.QueryEscape(redirectUrl.String()))
|
||||
values.Set(QueryArgToken, url.QueryEscape(token))
|
||||
values.Set(QueryArgChallenge, url.QueryEscape(reg.Name))
|
||||
values.Set(QueryArgBust, url.QueryEscape(strconv.FormatInt(time.Now().UTC().UnixMilli(), 10)))
|
||||
uri.RawQuery = utils.EncodeRawQuery(values)
|
||||
values := uri.Query()
|
||||
values.Set(QueryArgRequestId, data.Id.String())
|
||||
values.Set(QueryArgRedirect, redirectUrl.String())
|
||||
values.Set(QueryArgToken, token)
|
||||
values.Set(QueryArgChallenge, reg.Name)
|
||||
uri.RawQuery = values.Encode()
|
||||
|
||||
return uri, nil
|
||||
}
|
||||
@@ -112,13 +108,13 @@ func RedirectUrl(r *http.Request, reg *Registration) (*url.URL, error) {
|
||||
}
|
||||
|
||||
data := RequestDataFromContext(r.Context())
|
||||
values, _ := utils.ParseRawQuery(r.URL.RawQuery)
|
||||
values.Set(QueryArgRequestId, url.QueryEscape(data.Id.String()))
|
||||
values := uri.Query()
|
||||
values.Set(QueryArgRequestId, data.Id.String())
|
||||
if ref := r.Referer(); ref != "" {
|
||||
values.Set(QueryArgReferer, url.QueryEscape(r.Referer()))
|
||||
values.Set(QueryArgReferer, r.Referer())
|
||||
}
|
||||
values.Set(QueryArgChallenge, url.QueryEscape(reg.Name))
|
||||
uri.RawQuery = utils.EncodeRawQuery(values)
|
||||
values.Set(QueryArgChallenge, reg.Name)
|
||||
uri.RawQuery = values.Encode()
|
||||
|
||||
return uri, nil
|
||||
}
|
||||
@@ -142,8 +138,6 @@ func VerifyHandlerChallengeResponseFunc(state StateInterface, data *RequestData,
|
||||
}
|
||||
reqUri.RawQuery = q.Encode()
|
||||
|
||||
data.ResponseHeaders(w)
|
||||
|
||||
http.Redirect(w, r, reqUri.String(), http.StatusTemporaryRedirect)
|
||||
return
|
||||
}
|
||||
@@ -153,7 +147,6 @@ func VerifyHandlerChallengeResponseFunc(state StateInterface, data *RequestData,
|
||||
state.ErrorPage(w, r, http.StatusForbidden, fmt.Errorf("access denied: failed challenge"), redirect)
|
||||
return
|
||||
}
|
||||
data.ResponseHeaders(w)
|
||||
http.Redirect(w, r, redirect, http.StatusTemporaryRedirect)
|
||||
}
|
||||
|
||||
@@ -182,12 +175,18 @@ func VerifyHandlerFunc(state StateInterface, reg *Registration, verify VerifyFun
|
||||
if err != nil {
|
||||
return err
|
||||
} else if !verifyResult.Ok() {
|
||||
utils.ClearCookie(data.CookiePrefix+reg.Name, w, r)
|
||||
state.ChallengeFailed(r, reg, nil, redirect, nil)
|
||||
responseFunc(state, data, w, r, verifyResult, nil, redirect)
|
||||
return nil
|
||||
}
|
||||
|
||||
data.IssueChallengeToken(reg, key, []byte(token), expiration, true)
|
||||
challengeToken, err := reg.IssueChallengeToken(state.PrivateKey(), key, []byte(token), expiration, true)
|
||||
if err != nil {
|
||||
utils.ClearCookie(data.CookiePrefix+reg.Name, w, r)
|
||||
} else {
|
||||
utils.SetCookie(data.CookiePrefix+reg.Name, challengeToken, expiration, w, r)
|
||||
}
|
||||
data.ChallengeVerify[reg.id] = verifyResult
|
||||
state.ChallengePassed(r, reg, redirect, nil)
|
||||
|
||||
@@ -195,6 +194,7 @@ func VerifyHandlerFunc(state StateInterface, reg *Registration, verify VerifyFun
|
||||
return nil
|
||||
}()
|
||||
if err != nil {
|
||||
utils.ClearCookie(data.CookiePrefix+reg.Name, w, r)
|
||||
state.ChallengeFailed(r, reg, err, redirect, nil)
|
||||
responseFunc(state, data, w, r, VerifyResultFail, fmt.Errorf("access denied: error in challenge %s: %w", reg.Name, err), redirect)
|
||||
return
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
"git.gammaspectra.live/git/go-away/lib/challenge"
|
||||
"git.gammaspectra.live/git/go-away/utils"
|
||||
"github.com/goccy/go-yaml"
|
||||
"github.com/goccy/go-yaml/ast"
|
||||
"io"
|
||||
@@ -108,14 +109,12 @@ func FillRegistration(state challenge.StateInterface, reg *challenge.Registratio
|
||||
}
|
||||
}
|
||||
|
||||
data := challenge.RequestDataFromContext(r.Context())
|
||||
|
||||
request, err := http.NewRequest(params.HttpMethod, params.Url, nil)
|
||||
if err != nil {
|
||||
return challenge.VerifyResultFail
|
||||
}
|
||||
|
||||
var excludeHeaders = []string{"Host", "Content-Length", "Upgrade", "Accept-Encoding", "Range"}
|
||||
var excludeHeaders = []string{"Host", "Content-Length"}
|
||||
for k, v := range r.Header {
|
||||
if slices.Contains(excludeHeaders, k) {
|
||||
// skip these parameters
|
||||
@@ -123,12 +122,10 @@ func FillRegistration(state challenge.StateInterface, reg *challenge.Registratio
|
||||
}
|
||||
request.Header[k] = v
|
||||
}
|
||||
|
||||
// set id, ip, and other headers
|
||||
data.RequestHeaders(request.Header)
|
||||
// set id
|
||||
request.Header.Set("X-Away-Id", challenge.RequestDataFromContext(r.Context()).Id.String())
|
||||
|
||||
// set request info in X headers
|
||||
request.Header.Set("X-Away-Method", r.Method)
|
||||
request.Header.Set("X-Away-Host", r.Host)
|
||||
request.Header.Set("X-Away-Path", r.URL.Path)
|
||||
request.Header.Set("X-Away-Query", r.URL.RawQuery)
|
||||
@@ -140,11 +137,21 @@ func FillRegistration(state challenge.StateInterface, reg *challenge.Registratio
|
||||
defer response.Body.Close()
|
||||
defer io.Copy(io.Discard, response.Body)
|
||||
|
||||
data := challenge.RequestDataFromContext(r.Context())
|
||||
|
||||
if response.StatusCode != params.HttpCode {
|
||||
data.IssueChallengeToken(reg, key, sum, expiry, false)
|
||||
token, err := reg.IssueChallengeToken(state.PrivateKey(), key, sum, expiry, false)
|
||||
if err != nil {
|
||||
return challenge.VerifyResultFail
|
||||
}
|
||||
utils.SetCookie(data.CookiePrefix+reg.Name, token, expiry, w, r)
|
||||
return challenge.VerifyResultNotOK
|
||||
} else {
|
||||
data.IssueChallengeToken(reg, key, sum, expiry, true)
|
||||
token, err := reg.IssueChallengeToken(state.PrivateKey(), key, sum, expiry, true)
|
||||
if err != nil {
|
||||
return challenge.VerifyResultFail
|
||||
}
|
||||
utils.SetCookie(data.CookiePrefix+reg.Name, token, expiry, w, r)
|
||||
return challenge.VerifyResultOK
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,19 +52,21 @@ func GetChallengeKeyForRequest(state StateInterface, reg *Registration, until ti
|
||||
hasher.Write([]byte{0})
|
||||
|
||||
// specific headers
|
||||
for _, k := range reg.KeyHeaders {
|
||||
hasher.Write([]byte(k))
|
||||
hasher.Write([]byte{0})
|
||||
for _, v := range r.Header.Values(k) {
|
||||
hasher.Write([]byte(v))
|
||||
hasher.Write([]byte{1})
|
||||
}
|
||||
for _, k := range []string{
|
||||
"Accept-Language",
|
||||
// General browser information
|
||||
"User-Agent",
|
||||
// TODO: not sent in preload
|
||||
//"Sec-Ch-Ua",
|
||||
//"Sec-Ch-Ua-Platform",
|
||||
} {
|
||||
hasher.Write([]byte(r.Header.Get(k)))
|
||||
hasher.Write([]byte{0})
|
||||
}
|
||||
hasher.Write([]byte{0})
|
||||
_ = binary.Write(hasher, binary.LittleEndian, until.UTC().Unix())
|
||||
hasher.Write([]byte{0})
|
||||
hasher.Write(state.PrivateKeyFingerprint())
|
||||
hasher.Write(state.PublicKey())
|
||||
hasher.Write([]byte{0})
|
||||
|
||||
sum := Key(hasher.Sum(nil))
|
||||
|
||||
@@ -22,7 +22,7 @@ type Parameters struct {
|
||||
}
|
||||
|
||||
var DefaultParameters = Parameters{
|
||||
Deadline: time.Second * 2,
|
||||
Deadline: time.Second * 3,
|
||||
}
|
||||
|
||||
func FillRegistration(state challenge.StateInterface, reg *challenge.Registration, parameters ast.Node) error {
|
||||
@@ -44,9 +44,6 @@ func FillRegistration(state challenge.StateInterface, reg *challenge.Registratio
|
||||
|
||||
reg.Class = challenge.ClassTransparent
|
||||
|
||||
// some of regular headers are not sent in default headers
|
||||
reg.KeyHeaders = challenge.MinimalKeyHeaders
|
||||
|
||||
ob := challenge.NewAwaiter[string]()
|
||||
|
||||
reg.Object = ob
|
||||
@@ -69,9 +66,9 @@ func FillRegistration(state challenge.StateInterface, reg *challenge.Registratio
|
||||
}
|
||||
|
||||
// remove redirect args
|
||||
values, _ := utils.ParseRawQuery(uri.RawQuery)
|
||||
values := uri.Query()
|
||||
values.Del(challenge.QueryArgRedirect)
|
||||
uri.RawQuery = utils.EncodeRawQuery(values)
|
||||
uri.RawQuery = values.Encode()
|
||||
|
||||
// Redirect URI must be absolute to work
|
||||
uri.Scheme = utils.GetRequestScheme(r)
|
||||
@@ -101,7 +98,6 @@ func FillRegistration(state challenge.StateInterface, reg *challenge.Registratio
|
||||
|
||||
mux.HandleFunc("GET "+reg.Path+challenge.VerifyChallengeUrlSuffix, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/css; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
w.Header().Set("Content-Length", "0")
|
||||
|
||||
data := challenge.RequestDataFromContext(r.Context())
|
||||
@@ -114,9 +110,6 @@ func FillRegistration(state challenge.StateInterface, reg *challenge.Registratio
|
||||
}
|
||||
|
||||
verifyResult, _ := verifier(key, []byte(token), r)
|
||||
|
||||
data.ResponseHeaders(w)
|
||||
|
||||
if !verifyResult.Ok() {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
} else {
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
package refresh
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"git.gammaspectra.live/git/go-away/lib/challenge"
|
||||
"github.com/goccy/go-yaml"
|
||||
"github.com/goccy/go-yaml/ast"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
@@ -48,19 +45,9 @@ func FillRegistration(state challenge.StateInterface, reg *challenge.Registratio
|
||||
return challenge.VerifyResultFail
|
||||
}
|
||||
|
||||
if params.Mode == "javascript" {
|
||||
data, err := json.Marshal(uri.String())
|
||||
if err != nil {
|
||||
return challenge.VerifyResultFail
|
||||
}
|
||||
if params.Mode == "meta" {
|
||||
state.ChallengePage(w, r, state.Settings().ChallengeResponseCode, reg, map[string]any{
|
||||
"EndTags": []template.HTML{
|
||||
template.HTML(fmt.Sprintf("<script type=\"text/javascript\">window.location = %s;</script>", string(data))),
|
||||
},
|
||||
})
|
||||
} else if params.Mode == "meta" {
|
||||
state.ChallengePage(w, r, state.Settings().ChallengeResponseCode, reg, map[string]any{
|
||||
"MetaTags": []map[string]string{
|
||||
"Meta": []map[string]string{
|
||||
{
|
||||
"http-equiv": "refresh",
|
||||
"content": "0; url=" + uri.String(),
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
package challenge
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
http_cel "codeberg.org/gone/http-cel"
|
||||
"crypto/ed25519"
|
||||
"errors"
|
||||
"fmt"
|
||||
"git.gammaspectra.live/git/go-away/lib/policy"
|
||||
"github.com/go-jose/go-jose/v4"
|
||||
"github.com/go-jose/go-jose/v4/jwt"
|
||||
"github.com/goccy/go-yaml/ast"
|
||||
"github.com/google/cel-go/cel"
|
||||
"io"
|
||||
"math/rand/v2"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
@@ -35,24 +41,6 @@ var idCounter Id
|
||||
// DefaultDuration TODO: adjust
|
||||
const DefaultDuration = time.Hour * 24 * 7
|
||||
|
||||
var DefaultKeyHeaders = []string{
|
||||
// General browser information
|
||||
"User-Agent",
|
||||
// Accept headers
|
||||
"Accept-Language",
|
||||
"Accept-Encoding",
|
||||
|
||||
// NOTE: not sent in preload
|
||||
"Sec-Ch-Ua",
|
||||
"Sec-Ch-Ua-Platform",
|
||||
}
|
||||
|
||||
var MinimalKeyHeaders = []string{
|
||||
"Accept-Language",
|
||||
// General browser information
|
||||
"User-Agent",
|
||||
}
|
||||
|
||||
func (r Register) Create(state StateInterface, name string, pol policy.Challenge, replacer *strings.Replacer) (*Registration, Id, error) {
|
||||
runtime, ok := Runtimes[pol.Runtime]
|
||||
if !ok {
|
||||
@@ -60,10 +48,9 @@ func (r Register) Create(state StateInterface, name string, pol policy.Challenge
|
||||
}
|
||||
|
||||
reg := &Registration{
|
||||
Name: name,
|
||||
Path: path.Join(state.UrlPath(), "challenge", name),
|
||||
Duration: pol.Duration,
|
||||
KeyHeaders: DefaultKeyHeaders,
|
||||
Name: name,
|
||||
Path: path.Join(state.UrlPath(), "challenge", name),
|
||||
Duration: pol.Duration,
|
||||
}
|
||||
|
||||
if reg.Duration == 0 {
|
||||
@@ -80,10 +67,13 @@ func (r Register) Create(state StateInterface, name string, pol policy.Challenge
|
||||
}
|
||||
|
||||
if len(conditions) > 0 {
|
||||
var err error
|
||||
reg.Condition, err = state.RegisterCondition(http_cel.OperatorOr, conditions...)
|
||||
ast, err := http_cel.NewAst(state.ProgramEnv(), http_cel.OperatorOr, conditions...)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("error compiling condition: %w", err)
|
||||
return nil, 0, fmt.Errorf("error compiling conditions: %v", err)
|
||||
}
|
||||
reg.Condition, err = http_cel.ProgramAst(state.ProgramEnv(), ast)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("error compiling program: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,9 +135,6 @@ type Registration struct {
|
||||
Verify VerifyFunc
|
||||
VerifyProbability float64
|
||||
|
||||
// KeyHeaders The client headers used in key generation, in this order
|
||||
KeyHeaders []string
|
||||
|
||||
// IssueChallenge Issues a challenge to a request.
|
||||
// If Class is ClassTransparent and VerifyResult is !VerifyResult.Ok(), continue with other challenges
|
||||
// TODO: have this return error as well
|
||||
@@ -161,10 +148,104 @@ type Registration struct {
|
||||
|
||||
type VerifyFunc func(key Key, token []byte, r *http.Request) (VerifyResult, error)
|
||||
|
||||
type Token struct {
|
||||
Name string `json:"name"`
|
||||
Key []byte `json:"key"`
|
||||
Result []byte `json:"result,omitempty"`
|
||||
Ok bool `json:"ok"`
|
||||
|
||||
Expiry jwt.NumericDate `json:"exp,omitempty"`
|
||||
NotBefore jwt.NumericDate `json:"nbf,omitempty"`
|
||||
IssuedAt jwt.NumericDate `json:"iat,omitempty"`
|
||||
}
|
||||
|
||||
func (reg Registration) Id() Id {
|
||||
return reg.id
|
||||
}
|
||||
|
||||
func (reg Registration) IssueChallengeToken(privateKey ed25519.PrivateKey, key Key, result []byte, until time.Time, ok bool) (token string, err error) {
|
||||
signer, err := jose.NewSigner(jose.SigningKey{
|
||||
Algorithm: jose.EdDSA,
|
||||
Key: privateKey,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
token, err = jwt.Signed(signer).Claims(Token{
|
||||
Name: reg.Name,
|
||||
Key: key[:],
|
||||
Result: result,
|
||||
Ok: ok,
|
||||
Expiry: jwt.NumericDate(until.Unix()),
|
||||
NotBefore: jwt.NumericDate(time.Now().UTC().AddDate(0, 0, -1).Unix()),
|
||||
IssuedAt: jwt.NumericDate(time.Now().UTC().Unix()),
|
||||
}).Serialize()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
var ErrVerifyKeyMismatch = errors.New("verify: key mismatch")
|
||||
var ErrVerifyVerifyMismatch = errors.New("verify: verification mismatch")
|
||||
var ErrTokenExpired = errors.New("token: expired")
|
||||
|
||||
func (reg Registration) VerifyChallengeToken(publicKey ed25519.PublicKey, expectedKey Key, r *http.Request) (VerifyResult, VerifyState, error) {
|
||||
cookie, err := r.Cookie(RequestDataFromContext(r.Context()).CookiePrefix + reg.Name)
|
||||
if err != nil {
|
||||
return VerifyResultNone, VerifyStateNone, err
|
||||
}
|
||||
if cookie == nil {
|
||||
return VerifyResultNone, VerifyStateNone, http.ErrNoCookie
|
||||
}
|
||||
|
||||
token, err := jwt.ParseSigned(cookie.Value, []jose.SignatureAlgorithm{jose.EdDSA})
|
||||
if err != nil {
|
||||
return VerifyResultFail, VerifyStateNone, err
|
||||
}
|
||||
|
||||
var i Token
|
||||
err = token.Claims(publicKey, &i)
|
||||
if err != nil {
|
||||
return VerifyResultFail, VerifyStateNone, err
|
||||
}
|
||||
|
||||
if i.Name != reg.Name {
|
||||
return VerifyResultFail, VerifyStateNone, errors.New("token invalid name")
|
||||
}
|
||||
if i.Expiry.Time().Compare(time.Now()) < 0 {
|
||||
return VerifyResultFail, VerifyStateNone, ErrTokenExpired
|
||||
}
|
||||
if i.NotBefore.Time().Compare(time.Now()) > 0 {
|
||||
return VerifyResultFail, VerifyStateNone, errors.New("token not valid yet")
|
||||
}
|
||||
|
||||
if bytes.Compare(expectedKey[:], i.Key) != 0 {
|
||||
return VerifyResultFail, VerifyStateNone, ErrVerifyKeyMismatch
|
||||
}
|
||||
|
||||
if reg.Verify != nil {
|
||||
if rand.Float64() < reg.VerifyProbability {
|
||||
// random spot check
|
||||
if ok, err := reg.Verify(expectedKey, i.Result, r); err != nil {
|
||||
return VerifyResultFail, VerifyStateFull, err
|
||||
} else if ok == VerifyResultNotOK {
|
||||
return VerifyResultNotOK, VerifyStateFull, nil
|
||||
} else if !ok.Ok() {
|
||||
return ok, VerifyStateFull, ErrVerifyVerifyMismatch
|
||||
} else {
|
||||
return ok, VerifyStateFull, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !i.Ok {
|
||||
return VerifyResultNotOK, VerifyStateBrief, nil
|
||||
}
|
||||
return VerifyResultOK, VerifyStateBrief, nil
|
||||
}
|
||||
|
||||
type FillRegistration func(state StateInterface, reg *Registration, parameters ast.Node) error
|
||||
|
||||
var Runtimes = make(map[string]FillRegistration)
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package resource_load
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"git.gammaspectra.live/git/go-away/lib/challenge"
|
||||
"github.com/goccy/go-yaml/ast"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
@@ -23,21 +25,13 @@ func FillRegistrationHeader(state challenge.StateInterface, reg *challenge.Regis
|
||||
return challenge.VerifyResultFail
|
||||
}
|
||||
|
||||
redirectUri, err := challenge.RedirectUrl(r, reg)
|
||||
if err != nil {
|
||||
return challenge.VerifyResultFail
|
||||
}
|
||||
// self redirect!
|
||||
//TODO: adjust deadline
|
||||
w.Header().Set("Refresh", "2; url="+redirectUri.String())
|
||||
w.Header().Set("Refresh", "2; url="+r.URL.String())
|
||||
|
||||
state.ChallengePage(w, r, state.Settings().ChallengeResponseCode, reg, map[string]any{
|
||||
"LinkTags": []map[string]string{
|
||||
{
|
||||
"href": uri.String(),
|
||||
"rel": "stylesheet",
|
||||
"crossorigin": "use-credentials",
|
||||
},
|
||||
"HeaderTags": []template.HTML{
|
||||
template.HTML(fmt.Sprintf("<link href=\"%s\" rel=\"stylesheet\" crossorigin=\"use-credentials\">", uri.String())),
|
||||
},
|
||||
})
|
||||
return challenge.VerifyResultNone
|
||||
@@ -48,11 +42,7 @@ func FillRegistrationHeader(state challenge.StateInterface, reg *challenge.Regis
|
||||
mux.HandleFunc("GET "+reg.Path+challenge.VerifyChallengeUrlSuffix, challenge.VerifyHandlerFunc(state, reg, nil, func(state challenge.StateInterface, data *challenge.RequestData, w http.ResponseWriter, r *http.Request, verifyResult challenge.VerifyResult, err error, redirect string) {
|
||||
//TODO: add other types inside css that need to be loaded!
|
||||
w.Header().Set("Content-Type", "text/css; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
w.Header().Set("Content-Length", "0")
|
||||
|
||||
data.ResponseHeaders(w)
|
||||
|
||||
if !verifyResult.Ok() {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
} else {
|
||||
|
||||
@@ -24,17 +24,16 @@ func ServeChallengeScript(w http.ResponseWriter, r *http.Request, reg *Registrat
|
||||
panic(err)
|
||||
}
|
||||
|
||||
data.ResponseHeaders(w)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
err = scriptTemplate.Execute(w, map[string]any{
|
||||
"Id": data.Id.String(),
|
||||
"Path": reg.Path,
|
||||
"Parameters": paramData,
|
||||
"Random": utils.StaticCacheBust(),
|
||||
"Random": utils.CacheBust(),
|
||||
"Challenge": reg.Name,
|
||||
"ChallengeScript": script,
|
||||
"Strings": data.State.Strings(),
|
||||
"Strings": data.State.Options().Strings,
|
||||
})
|
||||
if err != nil {
|
||||
//TODO: log
|
||||
|
||||
@@ -3,7 +3,7 @@ package challenge
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"git.gammaspectra.live/git/go-away/lib/policy"
|
||||
"git.gammaspectra.live/git/go-away/utils"
|
||||
"git.gammaspectra.live/git/go-away/lib/settings"
|
||||
"github.com/google/cel-go/cel"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
@@ -86,10 +86,9 @@ func (r VerifyResult) String() string {
|
||||
}
|
||||
|
||||
type StateInterface interface {
|
||||
RegisterCondition(operator string, conditions ...string) (cel.Program, error)
|
||||
ProgramEnv() *cel.Env
|
||||
|
||||
Client() *http.Client
|
||||
PrivateKeyFingerprint() []byte
|
||||
PrivateKey() ed25519.PrivateKey
|
||||
PublicKey() ed25519.PublicKey
|
||||
|
||||
@@ -115,7 +114,7 @@ type StateInterface interface {
|
||||
|
||||
Settings() policy.StateSettings
|
||||
|
||||
Strings() utils.Strings
|
||||
Options() settings.Settings
|
||||
|
||||
GetBackend(host string) http.Handler
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ func FillJavaScriptRegistration(state challenge.StateInterface, reg *challenge.R
|
||||
reg.IssueChallenge = func(w http.ResponseWriter, r *http.Request, key challenge.Key, expiry time.Time) challenge.VerifyResult {
|
||||
state.ChallengePage(w, r, state.Settings().ChallengeResponseCode, reg, map[string]any{
|
||||
"EndTags": []template.HTML{
|
||||
template.HTML(fmt.Sprintf("<script async type=\"module\" src=\"%s?cacheBust=%s\"></script>", reg.Path+"/script.mjs", utils.StaticCacheBust())),
|
||||
template.HTML(fmt.Sprintf("<script async type=\"module\" src=\"%s?cacheBust=%s\"></script>", reg.Path+"/script.mjs", utils.CacheBust())),
|
||||
},
|
||||
})
|
||||
return challenge.VerifyResultNone
|
||||
@@ -164,9 +164,6 @@ func FillJavaScriptRegistration(state challenge.StateInterface, reg *challenge.R
|
||||
w.Header()[k] = v
|
||||
}
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(out.Data)))
|
||||
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
|
||||
data.ResponseHeaders(w)
|
||||
w.WriteHeader(out.Code)
|
||||
_, _ = w.Write(out.Data)
|
||||
return nil
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
http_cel "codeberg.org/gone/http-cel"
|
||||
"fmt"
|
||||
"github.com/google/cel-go/cel"
|
||||
"github.com/google/cel-go/common/ast"
|
||||
"github.com/google/cel-go/common/types"
|
||||
"github.com/google/cel-go/common/types/ref"
|
||||
"log/slog"
|
||||
@@ -56,7 +55,7 @@ func (state *State) initConditions() (err error) {
|
||||
}
|
||||
return types.Bool(ipNet.Contains(ip))
|
||||
} else {
|
||||
ok, err := network().Contains(ip)
|
||||
ok, err := network.Contains(ip)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
@@ -97,7 +96,7 @@ func (state *State) initConditions() (err error) {
|
||||
}
|
||||
return types.Bool(ipNet.Contains(ip))
|
||||
} else {
|
||||
ok, err := network().Contains(ip)
|
||||
ok, err := network.Contains(ip)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
@@ -112,113 +111,3 @@ func (state *State) initConditions() (err error) {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (state *State) RegisterCondition(operator string, conditions ...string) (cel.Program, error) {
|
||||
compiledAst, err := http_cel.NewAst(state.ProgramEnv(), operator, conditions...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if out := compiledAst.OutputType(); out == nil {
|
||||
return nil, fmt.Errorf("no output")
|
||||
} else if out != types.BoolType {
|
||||
return nil, fmt.Errorf("output type is not bool")
|
||||
}
|
||||
|
||||
walkExpr(compiledAst.NativeRep().Expr(), func(e ast.Expr) {
|
||||
if e.Kind() == ast.CallKind {
|
||||
call := e.AsCall()
|
||||
switch call.FunctionName() {
|
||||
// deprecated
|
||||
case "inNetwork":
|
||||
args := call.Args()
|
||||
if !call.IsMemberFunction() && len(args) == 2 {
|
||||
// we have a network select function
|
||||
switch args[1].Kind() {
|
||||
case ast.LiteralKind:
|
||||
lit := args[1].AsLiteral()
|
||||
if lit.Type() == types.StringType {
|
||||
if fn, ok := state.networks[lit.Value().(string)]; ok {
|
||||
// preload
|
||||
fn()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
case "network":
|
||||
args := call.Args()
|
||||
if call.IsMemberFunction() && len(args) == 1 {
|
||||
// we have a network select function
|
||||
switch args[0].Kind() {
|
||||
case ast.LiteralKind:
|
||||
lit := args[0].AsLiteral()
|
||||
if lit.Type() == types.StringType {
|
||||
if fn, ok := state.networks[lit.Value().(string)]; ok {
|
||||
// preload
|
||||
fn()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return http_cel.ProgramAst(state.ProgramEnv(), compiledAst)
|
||||
}
|
||||
|
||||
func walkExpr(e ast.Expr, fn func(ast.Expr)) {
|
||||
fn(e)
|
||||
|
||||
switch e.Kind() {
|
||||
case ast.CallKind:
|
||||
ee := e.AsCall()
|
||||
walkExpr(ee.Target(), fn)
|
||||
for _, arg := range ee.Args() {
|
||||
walkExpr(arg, fn)
|
||||
}
|
||||
case ast.ComprehensionKind:
|
||||
ee := e.AsComprehension()
|
||||
walkExpr(ee.Result(), fn)
|
||||
walkExpr(ee.IterRange(), fn)
|
||||
walkExpr(ee.AccuInit(), fn)
|
||||
walkExpr(ee.LoopCondition(), fn)
|
||||
walkExpr(ee.LoopStep(), fn)
|
||||
case ast.ListKind:
|
||||
ee := e.AsList()
|
||||
for _, element := range ee.Elements() {
|
||||
walkExpr(element, fn)
|
||||
}
|
||||
case ast.MapKind:
|
||||
ee := e.AsMap()
|
||||
for _, entry := range ee.Entries() {
|
||||
switch entry.Kind() {
|
||||
case ast.MapEntryKind:
|
||||
eee := entry.AsMapEntry()
|
||||
walkExpr(eee.Key(), fn)
|
||||
walkExpr(eee.Value(), fn)
|
||||
case ast.StructFieldKind:
|
||||
eee := entry.AsStructField()
|
||||
walkExpr(eee.Value(), fn)
|
||||
}
|
||||
}
|
||||
case ast.SelectKind:
|
||||
ee := e.AsSelect()
|
||||
walkExpr(ee.Operand(), fn)
|
||||
case ast.StructKind:
|
||||
ee := e.AsStruct()
|
||||
for _, field := range ee.Fields() {
|
||||
switch field.Kind() {
|
||||
case ast.MapEntryKind:
|
||||
eee := field.AsMapEntry()
|
||||
walkExpr(eee.Key(), fn)
|
||||
walkExpr(eee.Value(), fn)
|
||||
case ast.StructFieldKind:
|
||||
eee := field.AsStructField()
|
||||
walkExpr(eee.Value(), fn)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
230
lib/http.go
230
lib/http.go
@@ -38,7 +38,7 @@ func GetLoggerForRequest(r *http.Request) *slog.Logger {
|
||||
return slog.With(args...)
|
||||
}
|
||||
|
||||
func (state *State) fetchTags(host string, backend http.Handler, r *http.Request, meta, link bool) []html.Node {
|
||||
func (state *State) fetchMetaTags(host string, backend http.Handler, r *http.Request) []html.Node {
|
||||
uri := *r.URL
|
||||
q := uri.Query()
|
||||
for k := range q {
|
||||
@@ -54,161 +54,76 @@ func (state *State) fetchTags(host string, backend http.Handler, r *http.Request
|
||||
return v
|
||||
}
|
||||
|
||||
result := utils.FetchTags(backend, &uri, func() (r []string) {
|
||||
if meta {
|
||||
r = append(r, "meta")
|
||||
} else if link {
|
||||
r = append(r, "link")
|
||||
}
|
||||
return r
|
||||
}()...)
|
||||
result := utils.FetchTags(backend, &uri, "meta")
|
||||
if result == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
entries := make([]html.Node, 0, len(result))
|
||||
|
||||
safeAttributes := []string{"name", "property", "content"}
|
||||
for _, n := range result {
|
||||
if n.Namespace != "" {
|
||||
continue
|
||||
}
|
||||
|
||||
switch n.Data {
|
||||
case "link":
|
||||
safeAttributes := []string{"rel", "href", "hreflang", "media", "title", "type"}
|
||||
|
||||
var name string
|
||||
for _, attr := range n.Attr {
|
||||
if attr.Namespace != "" {
|
||||
continue
|
||||
}
|
||||
if attr.Key == "rel" {
|
||||
name = attr.Val
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if name == "" {
|
||||
var name string
|
||||
for _, attr := range n.Attr {
|
||||
if attr.Namespace != "" {
|
||||
continue
|
||||
}
|
||||
|
||||
var keep bool
|
||||
if name == "icon" || name == "alternate icon" {
|
||||
keep = true
|
||||
} else if name == "alternate" || name == "canonical" || name == "search" {
|
||||
// urls to versions of document
|
||||
keep = true
|
||||
} else if name == "author" || name == "privacy-policy" || name == "license" || name == "copyright" || name == "terms-of-service" {
|
||||
keep = true
|
||||
} else if name == "manifest" {
|
||||
// web app manifest
|
||||
keep = true
|
||||
if attr.Key == "name" {
|
||||
name = attr.Val
|
||||
break
|
||||
}
|
||||
|
||||
// prevent other arbitrary arguments
|
||||
if keep {
|
||||
newNode := html.Node{
|
||||
Type: html.ElementNode,
|
||||
Data: n.Data,
|
||||
}
|
||||
for _, attr := range n.Attr {
|
||||
if attr.Namespace != "" {
|
||||
continue
|
||||
}
|
||||
if slices.Contains(safeAttributes, attr.Key) {
|
||||
newNode.Attr = append(newNode.Attr, attr)
|
||||
}
|
||||
}
|
||||
if len(newNode.Attr) == 0 {
|
||||
continue
|
||||
}
|
||||
entries = append(entries, newNode)
|
||||
}
|
||||
|
||||
case "meta":
|
||||
|
||||
safeAttributes := []string{"name", "property", "content"}
|
||||
var name string
|
||||
for _, attr := range n.Attr {
|
||||
if attr.Namespace != "" {
|
||||
continue
|
||||
}
|
||||
if attr.Key == "name" {
|
||||
name = attr.Val
|
||||
break
|
||||
}
|
||||
if attr.Key == "property" && name == "" {
|
||||
name = attr.Val
|
||||
}
|
||||
}
|
||||
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// prevent unwanted keys like CSRF and other internal entries to pass through as much as possible
|
||||
|
||||
var keep bool
|
||||
if strings.HasPrefix("og:", name) || strings.HasPrefix("fb:", name) || strings.HasPrefix("twitter:", name) || strings.HasPrefix("profile:", name) {
|
||||
// social / OpenGraph tags
|
||||
keep = true
|
||||
} else if name == "vcs" || strings.HasPrefix("vcs:", name) {
|
||||
// source tags
|
||||
keep = true
|
||||
} else if name == "forge" || strings.HasPrefix("forge:", name) {
|
||||
// forge tags
|
||||
keep = true
|
||||
} else if strings.HasPrefix("citation_", name) {
|
||||
// citations for Google Scholar
|
||||
keep = true
|
||||
} else {
|
||||
switch name {
|
||||
case "theme-color", "color-scheme", "origin-trials":
|
||||
// modifies page presentation
|
||||
keep = true
|
||||
case "application-name", "origin", "author", "creator", "contact", "title", "description", "thumbnail", "rating":
|
||||
// standard content tags
|
||||
keep = true
|
||||
case "license", "license:uri", "rights", "rights-standard":
|
||||
// licensing standards
|
||||
keep = true
|
||||
case "go-import", "go-source":
|
||||
// golang tags
|
||||
keep = true
|
||||
case "apple-itunes-app", "appstore:bundle_id", "appstore:developer_url", "appstore:store_id", "google-play-app":
|
||||
// application linking
|
||||
keep = true
|
||||
|
||||
case "verify-v1", "google-site-verification", "p:domain_verify", "yandex-verification", "alexaverifyid":
|
||||
// site verification
|
||||
keep = true
|
||||
|
||||
case "keywords", "robots", "google", "googlebot", "bingbot", "pinterest", "Slurp":
|
||||
// scraper and search content directives
|
||||
keep = true
|
||||
}
|
||||
}
|
||||
|
||||
// prevent other arbitrary arguments
|
||||
if keep {
|
||||
newNode := html.Node{
|
||||
Type: html.ElementNode,
|
||||
Data: n.Data,
|
||||
}
|
||||
for _, attr := range n.Attr {
|
||||
if attr.Namespace != "" {
|
||||
continue
|
||||
}
|
||||
if slices.Contains(safeAttributes, attr.Key) {
|
||||
newNode.Attr = append(newNode.Attr, attr)
|
||||
}
|
||||
}
|
||||
if len(newNode.Attr) == 0 {
|
||||
continue
|
||||
}
|
||||
entries = append(entries, newNode)
|
||||
if attr.Key == "property" && name == "" {
|
||||
name = attr.Val
|
||||
}
|
||||
}
|
||||
|
||||
// prevent unwanted keys like CSRF and other internal entries to pass through as much as possible
|
||||
|
||||
var keep bool
|
||||
if strings.HasPrefix("og:", name) || strings.HasPrefix("fb:", name) || strings.HasPrefix("twitter:", name) || strings.HasPrefix("profile:", name) {
|
||||
// social / OpenGraph tags
|
||||
keep = true
|
||||
} else if name == "vcs" || strings.HasPrefix("vcs:", name) {
|
||||
// source tags
|
||||
keep = true
|
||||
} else if name == "forge" || strings.HasPrefix("forge:", name) {
|
||||
// forge tags
|
||||
keep = true
|
||||
} else {
|
||||
switch name {
|
||||
// standard content tags
|
||||
case "application-name", "author", "description", "keywords", "robots", "thumbnail":
|
||||
keep = true
|
||||
case "go-import", "go-source":
|
||||
// golang tags
|
||||
keep = true
|
||||
case "apple-itunes-app":
|
||||
}
|
||||
}
|
||||
|
||||
// prevent other arbitrary arguments
|
||||
if keep {
|
||||
newNode := html.Node{
|
||||
Type: html.ElementNode,
|
||||
Data: n.Data,
|
||||
}
|
||||
for _, attr := range n.Attr {
|
||||
if attr.Namespace != "" {
|
||||
continue
|
||||
}
|
||||
if slices.Contains(safeAttributes, attr.Key) {
|
||||
newNode.Attr = append(newNode.Attr, attr)
|
||||
}
|
||||
}
|
||||
if len(newNode.Attr) == 0 {
|
||||
continue
|
||||
}
|
||||
entries = append(entries, newNode)
|
||||
}
|
||||
}
|
||||
|
||||
state.tagCache.Set(key, entries, time.Hour*6)
|
||||
@@ -220,11 +135,8 @@ func (state *State) handleRequest(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
data := challenge.RequestDataFromContext(r.Context())
|
||||
|
||||
lg := state.Logger(r)
|
||||
|
||||
backend := state.GetBackend(host)
|
||||
if backend == nil {
|
||||
lg.Debug("no backend for host", "host", host)
|
||||
http.Error(w, http.StatusText(http.StatusServiceUnavailable), http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
@@ -242,44 +154,41 @@ func (state *State) handleRequest(w http.ResponseWriter, r *http.Request) {
|
||||
return backend
|
||||
}
|
||||
|
||||
cleanupRequest := func(r *http.Request, fromChallenge bool, ruleName string, ruleAction policy.RuleAction) {
|
||||
lg := state.Logger(r)
|
||||
|
||||
cleanupRequest := func(r *http.Request, fromChallenge bool) {
|
||||
if fromChallenge {
|
||||
r.Header.Del("Referer")
|
||||
}
|
||||
|
||||
q := r.URL.Query()
|
||||
|
||||
if ref := q.Get(challenge.QueryArgReferer); ref != "" {
|
||||
r.Header.Set("Referer", ref)
|
||||
}
|
||||
|
||||
rawQ, _ := utils.ParseRawQuery(r.URL.RawQuery)
|
||||
// delete query parameters that were set by go-away
|
||||
for k := range rawQ {
|
||||
for k := range q {
|
||||
if strings.HasPrefix(k, challenge.QueryArgPrefix) {
|
||||
rawQ.Del(k)
|
||||
q.Del(k)
|
||||
}
|
||||
}
|
||||
r.URL.RawQuery = utils.EncodeRawQuery(rawQ)
|
||||
r.URL.RawQuery = q.Encode()
|
||||
|
||||
data.ExtraHeaders.Set("X-Away-Rule", ruleName)
|
||||
data.ExtraHeaders.Set("X-Away-Action", string(ruleAction))
|
||||
data.RequestHeaders(r.Header)
|
||||
|
||||
// delete cookies set by go-away to prevent user tracking that way
|
||||
cookies := r.Cookies()
|
||||
r.Header.Del("Cookie")
|
||||
for _, c := range cookies {
|
||||
if !strings.HasPrefix(c.Name, utils.DefaultCookiePrefix) {
|
||||
if !strings.HasPrefix(c.Name, utils.CookiePrefix) {
|
||||
r.AddCookie(c)
|
||||
}
|
||||
}
|
||||
|
||||
// set response headers
|
||||
data.ResponseHeaders(w)
|
||||
}
|
||||
|
||||
for _, rule := range state.rules {
|
||||
next, err := rule.Evaluate(lg, w, r, func() http.Handler {
|
||||
cleanupRequest(r, true, rule.Name, rule.Action)
|
||||
cleanupRequest(r, true)
|
||||
return getBackend()
|
||||
})
|
||||
if err != nil {
|
||||
@@ -298,7 +207,10 @@ func (state *State) handleRequest(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// default pass
|
||||
_, _ = action.Pass{}.Handle(lg, w, r, func() http.Handler {
|
||||
cleanupRequest(r, false, "DEFAULT", policy.RuleActionPASS)
|
||||
r.Header.Set("X-Away-Rule", "DEFAULT")
|
||||
r.Header.Set("X-Away-Action", "PASS")
|
||||
|
||||
cleanupRequest(r, false)
|
||||
return getBackend()
|
||||
})
|
||||
}
|
||||
@@ -327,5 +239,9 @@ func (state *State) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
data.EvaluateChallenges(w, r)
|
||||
|
||||
if state.Settings().MainName != "" {
|
||||
w.Header().Add("Via", fmt.Sprintf("%s %s@%s", r.Proto, state.Settings().MainName, state.Settings().MainVersion))
|
||||
}
|
||||
|
||||
state.Mux.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"crypto/ed25519"
|
||||
"git.gammaspectra.live/git/go-away/lib/challenge"
|
||||
"git.gammaspectra.live/git/go-away/lib/policy"
|
||||
"git.gammaspectra.live/git/go-away/lib/settings"
|
||||
"git.gammaspectra.live/git/go-away/utils"
|
||||
"github.com/google/cel-go/cel"
|
||||
"log/slog"
|
||||
@@ -26,10 +27,6 @@ func (state *State) PrivateKey() ed25519.PrivateKey {
|
||||
return state.privateKey
|
||||
}
|
||||
|
||||
func (state *State) PrivateKeyFingerprint() []byte {
|
||||
return state.privateKeyFingerprint
|
||||
}
|
||||
|
||||
func (state *State) PublicKey() ed25519.PublicKey {
|
||||
return state.publicKey
|
||||
}
|
||||
@@ -102,8 +99,8 @@ func (state *State) Settings() policy.StateSettings {
|
||||
return state.settings
|
||||
}
|
||||
|
||||
func (state *State) Strings() utils.Strings {
|
||||
return state.opt.Strings
|
||||
func (state *State) Options() settings.Settings {
|
||||
return state.opt
|
||||
}
|
||||
|
||||
func (state *State) GetBackend(host string) http.Handler {
|
||||
|
||||
12
lib/rule.go
12
lib/rule.go
@@ -29,6 +29,7 @@ type RuleState struct {
|
||||
}
|
||||
|
||||
func NewRuleState(state challenge.StateInterface, r policy.Rule, replacer *strings.Replacer, parent *RuleState) (RuleState, error) {
|
||||
fp := sha256.Sum256(state.PrivateKey())
|
||||
hasher := sha256.New()
|
||||
if parent != nil {
|
||||
hasher.Write([]byte(parent.Name))
|
||||
@@ -37,7 +38,7 @@ func NewRuleState(state challenge.StateInterface, r policy.Rule, replacer *strin
|
||||
}
|
||||
hasher.Write([]byte(r.Name))
|
||||
hasher.Write([]byte{0})
|
||||
hasher.Write(state.PrivateKeyFingerprint())
|
||||
hasher.Write(fp[:])
|
||||
sum := hasher.Sum(nil)
|
||||
|
||||
rule := RuleState{
|
||||
@@ -65,9 +66,14 @@ func NewRuleState(state challenge.StateInterface, r policy.Rule, replacer *strin
|
||||
conditions = append(conditions, cond)
|
||||
}
|
||||
|
||||
program, err := state.RegisterCondition(http_cel.OperatorOr, conditions...)
|
||||
ast, err := http_cel.NewAst(state.ProgramEnv(), http_cel.OperatorOr, conditions...)
|
||||
if err != nil {
|
||||
return RuleState{}, fmt.Errorf("error compiling condition: %w", err)
|
||||
return RuleState{}, fmt.Errorf("error compiling conditions: %w", err)
|
||||
}
|
||||
|
||||
program, err := http_cel.ProgramAst(state.ProgramEnv(), ast)
|
||||
if err != nil {
|
||||
return RuleState{}, fmt.Errorf("error compiling program: %w", err)
|
||||
}
|
||||
rule.Condition = program
|
||||
}
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"git.gammaspectra.live/git/go-away/lib/challenge"
|
||||
"git.gammaspectra.live/git/go-away/utils"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Backend struct {
|
||||
@@ -29,48 +27,6 @@ type Backend struct {
|
||||
// GoDNS Resolve URL using the Go DNS server
|
||||
// Only relevant when running with CGO enabled
|
||||
GoDNS bool `yaml:"go-dns"`
|
||||
|
||||
// Transparent Do not add extra headers onto this backend
|
||||
// This prevents GoAway headers from being set, or other state
|
||||
Transparent bool `yaml:"transparent"`
|
||||
|
||||
// DialTimeout is the maximum amount of time a dial will wait for
|
||||
// a connect to complete.
|
||||
//
|
||||
// The default is no timeout.
|
||||
//
|
||||
// When using TCP and dialing a host name with multiple IP
|
||||
// addresses, the timeout may be divided between them.
|
||||
//
|
||||
// With or without a timeout, the operating system may impose
|
||||
// its own earlier timeout. For instance, TCP timeouts are
|
||||
// often around 3 minutes.
|
||||
DialTimeout time.Duration `yaml:"dial-timeout"`
|
||||
|
||||
// TLSHandshakeTimeout specifies the maximum amount of time to
|
||||
// wait for a TLS handshake. Zero means no timeout.
|
||||
TLSHandshakeTimeout time.Duration `yaml:"tls-handshake-timeout"`
|
||||
|
||||
// IdleConnTimeout is the maximum amount of time an idle
|
||||
// (keep-alive) connection will remain idle before closing
|
||||
// itself.
|
||||
// Zero means no limit.
|
||||
IdleConnTimeout time.Duration `yaml:"idle-conn-timeout"`
|
||||
|
||||
// ResponseHeaderTimeout, if non-zero, specifies the amount of
|
||||
// time to wait for a server's response headers after fully
|
||||
// writing the request (including its body, if any). This
|
||||
// time does not include the time to read the response body.
|
||||
ResponseHeaderTimeout time.Duration `yaml:"response-header-timeout"`
|
||||
|
||||
// ExpectContinueTimeout, if non-zero, specifies the amount of
|
||||
// time to wait for a server's first response headers after fully
|
||||
// writing the request headers if the request has an
|
||||
// "Expect: 100-continue" header. Zero means no timeout and
|
||||
// causes the body to be sent immediately, without
|
||||
// waiting for the server to approve.
|
||||
// This time does not include the time to send the request header.
|
||||
ExpectContinueTimeout time.Duration `yaml:"expect-continue-timeout"`
|
||||
}
|
||||
|
||||
func (b Backend) Create() (*httputil.ReverseProxy, error) {
|
||||
@@ -78,19 +34,13 @@ func (b Backend) Create() (*httputil.ReverseProxy, error) {
|
||||
b.IpHeader = ""
|
||||
}
|
||||
|
||||
proxy, err := utils.MakeReverseProxy(b.URL, b.GoDNS, b.DialTimeout)
|
||||
proxy, err := utils.MakeReverseProxy(b.URL, b.GoDNS)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
transport := proxy.Transport.(*http.Transport)
|
||||
|
||||
// set transport timeouts
|
||||
transport.TLSHandshakeTimeout = b.TLSHandshakeTimeout
|
||||
transport.IdleConnTimeout = b.IdleConnTimeout
|
||||
transport.ResponseHeaderTimeout = b.ResponseHeaderTimeout
|
||||
transport.ExpectContinueTimeout = b.ExpectContinueTimeout
|
||||
|
||||
if b.HTTP2Enabled {
|
||||
transport.ForceAttemptHTTP2 = true
|
||||
}
|
||||
@@ -103,16 +53,10 @@ func (b Backend) Create() (*httputil.ReverseProxy, error) {
|
||||
transport.TLSClientConfig.ServerName = b.Host
|
||||
}
|
||||
|
||||
if b.IpHeader != "" || b.Host != "" || !b.Transparent {
|
||||
if b.IpHeader != "" || b.Host != "" {
|
||||
director := proxy.Director
|
||||
proxy.Director = func(req *http.Request) {
|
||||
if !b.Transparent {
|
||||
if data := challenge.RequestDataFromContext(req.Context()); data != nil {
|
||||
data.RequestHeaders(req.Header)
|
||||
}
|
||||
}
|
||||
|
||||
if b.IpHeader != "" && !b.Transparent {
|
||||
if b.IpHeader != "" {
|
||||
if ip := utils.GetRemoteAddress(req.Context()); ip != nil {
|
||||
req.Header.Set(b.IpHeader, ip.Addr().Unmap().String())
|
||||
}
|
||||
|
||||
@@ -13,18 +13,9 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
type TLSEntry struct {
|
||||
// Certificate Path to the certificate file
|
||||
Certificate string `yaml:"certificate"`
|
||||
// Key Path to the corresponding key file
|
||||
Key string `yaml:"key"`
|
||||
}
|
||||
|
||||
type Bind struct {
|
||||
Address string `yaml:"address"`
|
||||
Network string `yaml:"network"`
|
||||
@@ -36,65 +27,10 @@ type Bind struct {
|
||||
// TLSAcmeAutoCert URL to ACME directory, or letsencrypt
|
||||
TLSAcmeAutoCert string `yaml:"tls-acme-autocert"`
|
||||
|
||||
// TLSEntries Alternate to TLSAcmeAutoCert. Allows multiple entries with matching.
|
||||
// Entries on this list can be live-reloaded if application implements SIGHUP handling
|
||||
TLSEntries []TLSEntry `yaml:"tls-entries"`
|
||||
|
||||
// TLSCertificate Alternate to TLSAcmeAutoCert. Preferred over TLSEntries if specified.
|
||||
// TLSCertificate Alternate to TLSAcmeAutoCert
|
||||
TLSCertificate string `yaml:"tls-certificate"`
|
||||
// TLSPrivateKey Alternate to TLSAcmeAutoCert. Preferred over TLSEntries if specified.
|
||||
// TLSPrivateKey Alternate to TLSAcmeAutoCert
|
||||
TLSPrivateKey string `yaml:"tls-key"`
|
||||
|
||||
// General TLS config
|
||||
// TLSMinVersion TLS Minimum supported version.
|
||||
// Default is Golang's default, at writing time it's TLS 1.2. Lowest supported is TLS 1.0
|
||||
TLSMinVersion string `yaml:"tls-min-version"`
|
||||
|
||||
// TLSMaxVersion TLS Maximum supported version.
|
||||
// Default is Golang's default, at writing time it's TLS 1.3, and is automatically increased.
|
||||
// Lowest supported is TLS 1.2
|
||||
TLSMaxVersion string `yaml:"tls-max-version"`
|
||||
|
||||
// TLSCurves List of supported TLS curve ids from Golang internals
|
||||
// See this list https://github.com/golang/go/blob/go1.24.0/src/crypto/tls/common.go#L138-L153 for supported values
|
||||
// Default values are chosen by Golang. It's recommended to leave the default
|
||||
TLSCurves []tls.CurveID `yaml:"tls-curves"`
|
||||
|
||||
// TLSCiphers List of supported TLS ciphers from Golang internals, case sensitive. TLS 1.3 suites are not configurable.
|
||||
// See this list https://github.com/golang/go/blob/go1.24.0/src/crypto/tls/cipher_suites.go#L56-L73 for supported values
|
||||
// Default values are chosen by Golang. It's recommended to leave the default
|
||||
TLSCiphers []string `yaml:"tls-ciphers"`
|
||||
|
||||
// ReadTimeout is the maximum duration for reading the entire
|
||||
// request, including the body. A zero or negative value means
|
||||
// there will be no timeout.
|
||||
//
|
||||
// Because ReadTimeout does not let Handlers make per-request
|
||||
// decisions on each request body's acceptable deadline or
|
||||
// upload rate, most users will prefer to use
|
||||
// ReadHeaderTimeout. It is valid to use them both.
|
||||
ReadTimeout time.Duration `yaml:"read-timeout"`
|
||||
|
||||
// ReadHeaderTimeout is the amount of time allowed to read
|
||||
// request headers. The connection's read deadline is reset
|
||||
// after reading the headers and the Handler can decide what
|
||||
// is considered too slow for the body. If zero, the value of
|
||||
// ReadTimeout is used. If negative, or if zero and ReadTimeout
|
||||
// is zero or negative, there is no timeout.
|
||||
ReadHeaderTimeout time.Duration `yaml:"read-header-timeout"`
|
||||
|
||||
// WriteTimeout is the maximum duration before timing out
|
||||
// writes of the response. It is reset whenever a new
|
||||
// request's header is read. Like ReadTimeout, it does not
|
||||
// let Handlers make decisions on a per-request basis.
|
||||
// A zero or negative value means there will be no timeout.
|
||||
WriteTimeout time.Duration `yaml:"write-timeout"`
|
||||
|
||||
// IdleTimeout is the maximum amount of time to wait for the
|
||||
// next request when keep-alives are enabled. If zero, the value
|
||||
// of ReadTimeout is used. If negative, or if zero and ReadTimeout
|
||||
// is zero or negative, there is no timeout.
|
||||
IdleTimeout time.Duration `yaml:"idle-timeout"`
|
||||
}
|
||||
|
||||
func (b *Bind) Listener() (net.Listener, string) {
|
||||
@@ -136,105 +72,6 @@ func (b *Bind) Server(backends map[string]http.Handler, acmeCachePath string) (*
|
||||
"TLS enabled",
|
||||
"certificate", b.TLSCertificate,
|
||||
)
|
||||
} else if len(b.TLSEntries) > 0 {
|
||||
tlsConfig = &tls.Config{}
|
||||
var err error
|
||||
|
||||
var certificatesPtr atomic.Pointer[[]tls.Certificate]
|
||||
|
||||
swapTls := func() error {
|
||||
certs := make([]tls.Certificate, 0, len(b.TLSEntries))
|
||||
for _, entry := range b.TLSEntries {
|
||||
cert, err := tls.LoadX509KeyPair(entry.Certificate, entry.Key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load TLS certificate %s: %w", entry.Certificate, err)
|
||||
}
|
||||
certs = append(certs, cert)
|
||||
}
|
||||
certificatesPtr.Swap(&certs)
|
||||
return nil
|
||||
}
|
||||
|
||||
tlsConfig.GetCertificate = func(clientHello *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||
certs := certificatesPtr.Load()
|
||||
|
||||
if certs == nil || len(*certs) == 0 {
|
||||
panic("no certificates found")
|
||||
}
|
||||
|
||||
for _, cert := range *certs {
|
||||
if err := clientHello.SupportsCertificate(&cert); err == nil {
|
||||
return &cert, nil
|
||||
}
|
||||
}
|
||||
|
||||
// if none match, return first
|
||||
return &(*certs)[0], nil
|
||||
}
|
||||
|
||||
err = swapTls()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
slog.Warn(
|
||||
"TLS enabled with multiple certificates",
|
||||
"certificates", len(b.TLSEntries),
|
||||
)
|
||||
}
|
||||
|
||||
if tlsConfig != nil {
|
||||
if b.TLSMinVersion != "" {
|
||||
switch strings.NewReplacer("-", "", "_", "", " ", "", ".", "").Replace(strings.ToLower(b.TLSMinVersion)) {
|
||||
case "13", "tls13":
|
||||
tlsConfig.MinVersion = tls.VersionTLS13
|
||||
case "12", "tls12":
|
||||
tlsConfig.MinVersion = tls.VersionTLS12
|
||||
case "11", "tls11":
|
||||
tlsConfig.MinVersion = tls.VersionTLS11
|
||||
case "10", "tls10":
|
||||
tlsConfig.MinVersion = tls.VersionTLS10
|
||||
default:
|
||||
return nil, nil, fmt.Errorf("unsupported minimum TLS version: %s", b.TLSMinVersion)
|
||||
}
|
||||
}
|
||||
|
||||
if b.TLSMaxVersion != "" {
|
||||
switch strings.NewReplacer("-", "", "_", "", " ", "", ".", "").Replace(strings.ToLower(b.TLSMaxVersion)) {
|
||||
case "13", "tls13":
|
||||
tlsConfig.MaxVersion = tls.VersionTLS13
|
||||
case "12", "tls12":
|
||||
tlsConfig.MaxVersion = tls.VersionTLS12
|
||||
default:
|
||||
return nil, nil, fmt.Errorf("unsupported maximum TLS version: %s", b.TLSMinVersion)
|
||||
}
|
||||
}
|
||||
|
||||
if len(b.TLSCiphers) > 0 {
|
||||
for _, cipher := range b.TLSCiphers {
|
||||
if c := func() *tls.CipherSuite {
|
||||
for _, c := range tls.CipherSuites() {
|
||||
if c.Name == cipher {
|
||||
return c
|
||||
}
|
||||
}
|
||||
for _, c := range tls.InsecureCipherSuites() {
|
||||
if c.Name == cipher {
|
||||
return c
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}(); c != nil {
|
||||
tlsConfig.CipherSuites = append(tlsConfig.CipherSuites, c.ID)
|
||||
} else {
|
||||
return nil, nil, fmt.Errorf("unsupported TLS cipher suite: %s", cipher)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(b.TLSCurves) > 0 {
|
||||
tlsConfig.CurvePreferences = append(tlsConfig.CurvePreferences, b.TLSCurves...)
|
||||
}
|
||||
}
|
||||
|
||||
var serverHandler atomic.Pointer[http.Handler]
|
||||
@@ -246,11 +83,6 @@ func (b *Bind) Server(backends map[string]http.Handler, acmeCachePath string) (*
|
||||
}
|
||||
}), tlsConfig)
|
||||
|
||||
server.ReadTimeout = b.ReadTimeout
|
||||
server.ReadHeaderTimeout = b.ReadHeaderTimeout
|
||||
server.WriteTimeout = b.WriteTimeout
|
||||
server.IdleTimeout = b.IdleTimeout
|
||||
|
||||
swap := func(handler http.Handler) {
|
||||
serverHandler.Store(&handler)
|
||||
}
|
||||
@@ -260,7 +92,6 @@ func (b *Bind) Server(backends map[string]http.Handler, acmeCachePath string) (*
|
||||
swap(http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
backend := utils.SelectHTTPHandler(backends, r.Host)
|
||||
if backend == nil {
|
||||
slog.Debug("no backend for host", "host", r.Host)
|
||||
http.Error(w, http.StatusText(http.StatusBadGateway), http.StatusBadGateway)
|
||||
} else {
|
||||
backend.ServeHTTP(w, r)
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"git.gammaspectra.live/git/go-away/utils"
|
||||
"maps"
|
||||
)
|
||||
import "maps"
|
||||
|
||||
type Settings struct {
|
||||
Bind Bind `yaml:"bind"`
|
||||
@@ -13,7 +10,7 @@ type Settings struct {
|
||||
BindDebug string `yaml:"bind-debug"`
|
||||
BindMetrics string `yaml:"bind-metrics"`
|
||||
|
||||
Strings utils.Strings `yaml:"strings"`
|
||||
Strings Strings `yaml:"strings"`
|
||||
|
||||
// Links to add to challenge/error pages like privacy/impressum.
|
||||
Links []Link `yaml:"links"`
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"git.gammaspectra.live/git/go-away/utils"
|
||||
"html/template"
|
||||
"maps"
|
||||
)
|
||||
|
||||
var DefaultStrings = utils.NewStrings(map[string]string{
|
||||
type Strings map[string]string
|
||||
|
||||
var DefaultStrings = make(Strings).set(map[string]string{
|
||||
"title_challenge": "Checking you are not a bot",
|
||||
"title_error": "Oh no!",
|
||||
|
||||
@@ -36,3 +39,17 @@ var DefaultStrings = utils.NewStrings(map[string]string{
|
||||
"status_challenge_done_took": "Done! Took",
|
||||
"status_error": "Error:",
|
||||
})
|
||||
|
||||
func (s Strings) set(v map[string]string) Strings {
|
||||
maps.Copy(s, v)
|
||||
return s
|
||||
}
|
||||
|
||||
func (s Strings) Get(value string) template.HTML {
|
||||
v, ok := (s)[value]
|
||||
if !ok {
|
||||
// fallback
|
||||
return template.HTML("string:" + value)
|
||||
}
|
||||
return template.HTML(v)
|
||||
}
|
||||
|
||||
185
lib/state.go
185
lib/state.go
@@ -7,13 +7,13 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"git.gammaspectra.live/git/go-away/lib/challenge"
|
||||
"git.gammaspectra.live/git/go-away/lib/policy"
|
||||
"git.gammaspectra.live/git/go-away/lib/settings"
|
||||
"git.gammaspectra.live/git/go-away/utils"
|
||||
"github.com/google/cel-go/cel"
|
||||
"github.com/google/cel-go/common/types"
|
||||
"github.com/yl2chen/cidranger"
|
||||
"golang.org/x/net/html"
|
||||
"log/slog"
|
||||
@@ -24,7 +24,6 @@ import (
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -35,14 +34,13 @@ type State struct {
|
||||
|
||||
programEnv *cel.Env
|
||||
|
||||
publicKey ed25519.PublicKey
|
||||
privateKey ed25519.PrivateKey
|
||||
privateKeyFingerprint []byte
|
||||
publicKey ed25519.PublicKey
|
||||
privateKey ed25519.PrivateKey
|
||||
|
||||
opt settings.Settings
|
||||
settings policy.StateSettings
|
||||
|
||||
networks map[string]func() cidranger.Ranger
|
||||
networks map[string]cidranger.Ranger
|
||||
|
||||
challenges challenge.Register
|
||||
|
||||
@@ -102,106 +100,103 @@ func NewState(p policy.Policy, opt settings.Settings, settings policy.StateSetti
|
||||
}
|
||||
}
|
||||
|
||||
fp := sha256.Sum256(state.privateKey)
|
||||
state.privateKeyFingerprint = fp[:]
|
||||
if templates["challenge-"+state.Options().ChallengeTemplate+".gohtml"] == nil {
|
||||
|
||||
if templates["challenge-"+state.opt.ChallengeTemplate+".gohtml"] == nil {
|
||||
|
||||
if data, err := os.ReadFile(state.opt.ChallengeTemplate); err == nil && len(data) > 0 {
|
||||
name := path.Base(state.opt.ChallengeTemplate)
|
||||
if data, err := os.ReadFile(state.Options().ChallengeTemplate); err == nil && len(data) > 0 {
|
||||
name := path.Base(state.Options().ChallengeTemplate)
|
||||
err := initTemplate(name, string(data))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error loading template %s: %w", state.opt.ChallengeTemplate, err)
|
||||
return nil, fmt.Errorf("error loading template %s: %w", state.Options().ChallengeTemplate, err)
|
||||
}
|
||||
state.opt.ChallengeTemplate = name
|
||||
} else {
|
||||
return nil, fmt.Errorf("no template defined for %s", state.opt.ChallengeTemplate)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no template defined for %s", state.Options().ChallengeTemplate)
|
||||
}
|
||||
|
||||
state.networks = make(map[string]func() cidranger.Ranger)
|
||||
state.networks = make(map[string]cidranger.Ranger)
|
||||
|
||||
networkCache := utils.CachePrefix(state.Settings().Cache, "networks/")
|
||||
|
||||
for k, network := range p.Networks {
|
||||
state.networks[k] = sync.OnceValue[cidranger.Ranger](func() cidranger.Ranger {
|
||||
ranger := cidranger.NewPCTrieRanger()
|
||||
for i, e := range network {
|
||||
prefixes, err := func() ([]net.IPNet, error) {
|
||||
var useCache bool
|
||||
|
||||
cacheKey := fmt.Sprintf("%s-%d-", k, i)
|
||||
if e.Url != nil {
|
||||
slog.Debug("loading network url list", "network", k, "url", *e.Url)
|
||||
useCache = true
|
||||
sum := sha256.Sum256([]byte(*e.Url))
|
||||
cacheKey += hex.EncodeToString(sum[:4])
|
||||
} else if e.ASN != nil {
|
||||
slog.Debug("loading ASN", "network", k, "asn", *e.ASN)
|
||||
useCache = true
|
||||
cacheKey += strconv.FormatInt(int64(*e.ASN), 10)
|
||||
}
|
||||
ranger := cidranger.NewPCTrieRanger()
|
||||
for i, e := range network {
|
||||
prefixes, err := func() ([]net.IPNet, error) {
|
||||
var useCache bool
|
||||
|
||||
var cached []net.IPNet
|
||||
if useCache && networkCache != nil {
|
||||
//TODO: add randomness
|
||||
cachedData, err := networkCache.Get(cacheKey, time.Hour*24)
|
||||
var l []string
|
||||
_ = json.Unmarshal(cachedData, &l)
|
||||
for _, n := range l {
|
||||
_, ipNet, err := net.ParseCIDR(n)
|
||||
if err == nil {
|
||||
cached = append(cached, *ipNet)
|
||||
}
|
||||
}
|
||||
if err == nil {
|
||||
// use
|
||||
return cached, nil
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
prefixes, err := e.FetchPrefixes(state.client, state.radb)
|
||||
if err != nil {
|
||||
if len(cached) > 0 {
|
||||
// use cached meanwhile
|
||||
return cached, err
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if useCache && networkCache != nil {
|
||||
var l []string
|
||||
for _, n := range prefixes {
|
||||
l = append(l, n.String())
|
||||
}
|
||||
cachedData, err := json.Marshal(l)
|
||||
if err == nil {
|
||||
_ = networkCache.Set(cacheKey, cachedData)
|
||||
}
|
||||
}
|
||||
return prefixes, nil
|
||||
}()
|
||||
if err != nil {
|
||||
if e.Url != nil {
|
||||
slog.Error("error loading network list", "network", k, "url", *e.Url, "error", err)
|
||||
} else if e.ASN != nil {
|
||||
slog.Error("error loading ASN", "network", k, "asn", *e.ASN, "error", err)
|
||||
} else {
|
||||
slog.Error("error loading list", "network", k, "error", err)
|
||||
}
|
||||
continue
|
||||
cacheKey := fmt.Sprintf("%s-%d-", k, i)
|
||||
if e.Url != nil {
|
||||
slog.Debug("loading network url list", "network", k, "url", *e.Url)
|
||||
useCache = true
|
||||
sum := sha256.Sum256([]byte(*e.Url))
|
||||
cacheKey += hex.EncodeToString(sum[:4])
|
||||
} else if e.ASN != nil {
|
||||
slog.Debug("loading ASN", "network", k, "asn", *e.ASN)
|
||||
useCache = true
|
||||
cacheKey += strconv.FormatInt(int64(*e.ASN), 10)
|
||||
}
|
||||
for _, prefix := range prefixes {
|
||||
err = ranger.Insert(cidranger.NewBasicRangerEntry(prefix))
|
||||
if err != nil {
|
||||
slog.Error("error inserting prefix", "network", k, "prefix", prefix.String(), "error", err)
|
||||
|
||||
var cached []net.IPNet
|
||||
if useCache && networkCache != nil {
|
||||
//TODO: add randomness
|
||||
cachedData, err := networkCache.Get(cacheKey, time.Hour*24)
|
||||
var l []string
|
||||
_ = json.Unmarshal(cachedData, &l)
|
||||
for _, n := range l {
|
||||
_, ipNet, err := net.ParseCIDR(n)
|
||||
if err == nil {
|
||||
cached = append(cached, *ipNet)
|
||||
}
|
||||
}
|
||||
if err == nil {
|
||||
// use
|
||||
return cached, nil
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
prefixes, err := e.FetchPrefixes(state.client, state.radb)
|
||||
if err != nil {
|
||||
if len(cached) > 0 {
|
||||
// use cached meanwhile
|
||||
return cached, err
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if useCache && networkCache != nil {
|
||||
var l []string
|
||||
for _, n := range prefixes {
|
||||
l = append(l, n.String())
|
||||
}
|
||||
cachedData, err := json.Marshal(l)
|
||||
if err == nil {
|
||||
_ = networkCache.Set(cacheKey, cachedData)
|
||||
}
|
||||
}
|
||||
return prefixes, nil
|
||||
}()
|
||||
if err != nil {
|
||||
if e.Url != nil {
|
||||
slog.Error("error loading network list", "network", k, "url", *e.Url, "error", err)
|
||||
} else if e.ASN != nil {
|
||||
slog.Error("error loading ASN", "network", k, "asn", *e.ASN, "error", err)
|
||||
} else {
|
||||
slog.Error("error loading list", "network", k, "error", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
for _, prefix := range prefixes {
|
||||
err = ranger.Insert(cidranger.NewBasicRangerEntry(prefix))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("networks %s: error inserting prefix %s: %v", k, prefix.String(), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
slog.Warn("loaded network prefixes", "network", k, "count", ranger.Len())
|
||||
return ranger
|
||||
})
|
||||
slog.Warn("loaded network prefixes", "network", k, "count", ranger.Len())
|
||||
|
||||
state.networks[k] = ranger
|
||||
}
|
||||
|
||||
err = state.initConditions()
|
||||
@@ -216,12 +211,6 @@ func NewState(p policy.Policy, opt settings.Settings, settings policy.StateSetti
|
||||
return nil, fmt.Errorf("conditions %s: error compiling conditions: %v", k, err)
|
||||
}
|
||||
|
||||
if out := ast.OutputType(); out == nil {
|
||||
return nil, fmt.Errorf("conditions %s: error compiling conditions: no output", k)
|
||||
} else if out != types.BoolType {
|
||||
return nil, fmt.Errorf("conditions %s: error compiling conditions: output type is not bool", k)
|
||||
}
|
||||
|
||||
cond, err := cel.AstToString(ast)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("conditions %s: error printing condition: %v", k, err)
|
||||
@@ -280,17 +269,9 @@ func NewState(p policy.Policy, opt settings.Settings, settings policy.StateSetti
|
||||
func (state *State) Close() error {
|
||||
select {
|
||||
case <-state.close:
|
||||
return errors.New("already closed")
|
||||
default:
|
||||
close(state.close)
|
||||
for _, c := range state.challenges {
|
||||
if c.Object != nil {
|
||||
err := c.Object.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -52,38 +52,16 @@ func initTemplate(name, data string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (state *State) addCachedTags(data *challenge.RequestData, r *http.Request, input map[string]any) {
|
||||
proxyMetaTags := data.GetOptBool(challenge.RequestOptProxyMetaTags, false)
|
||||
proxySafeLinkTags := data.GetOptBool(challenge.RequestOptProxySafeLinkTags, false)
|
||||
if proxyMetaTags || proxySafeLinkTags {
|
||||
backend, host := data.BackendHost()
|
||||
if tags := state.fetchTags(host, backend, r, proxyMetaTags, proxySafeLinkTags); len(tags) > 0 {
|
||||
metaTagMap, _ := input["MetaTags"].([]map[string]string)
|
||||
linkTagMap, _ := input["LinkTags"].([]map[string]string)
|
||||
|
||||
for _, tag := range tags {
|
||||
tagAttrs := make(map[string]string, len(tag.Attr))
|
||||
for _, v := range tag.Attr {
|
||||
tagAttrs[v.Key] = v.Val
|
||||
}
|
||||
metaTagMap = append(metaTagMap, tagAttrs)
|
||||
}
|
||||
input["MetaTags"] = metaTagMap
|
||||
input["LinkTags"] = linkTagMap
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (state *State) ChallengePage(w http.ResponseWriter, r *http.Request, status int, reg *challenge.Registration, params map[string]any) {
|
||||
data := challenge.RequestDataFromContext(r.Context())
|
||||
input := make(map[string]any)
|
||||
input["Id"] = data.Id.String()
|
||||
input["Random"] = utils.StaticCacheBust()
|
||||
input["Random"] = utils.CacheBust()
|
||||
|
||||
input["Path"] = state.UrlPath()
|
||||
input["Links"] = state.opt.Links
|
||||
input["Strings"] = state.opt.Strings
|
||||
for k, v := range state.opt.ChallengeTemplateOverrides {
|
||||
input["Links"] = state.Options().Links
|
||||
input["Strings"] = state.Options().Strings
|
||||
for k, v := range state.Options().ChallengeTemplateOverrides {
|
||||
input[k] = v
|
||||
}
|
||||
|
||||
@@ -94,21 +72,33 @@ func (state *State) ChallengePage(w http.ResponseWriter, r *http.Request, status
|
||||
maps.Copy(input, params)
|
||||
|
||||
if _, ok := input["Title"]; !ok {
|
||||
input["Title"] = state.opt.Strings.Get("title_challenge")
|
||||
input["Title"] = state.Options().Strings.Get("title_challenge")
|
||||
}
|
||||
|
||||
state.addCachedTags(data, r, input)
|
||||
if data.GetOptBool(challenge.RequestOptCacheMetaTags, false) {
|
||||
backend, host := data.BackendHost()
|
||||
if tags := state.fetchMetaTags(host, backend, r); len(tags) > 0 {
|
||||
tagMap, _ := input["Meta"].([]map[string]string)
|
||||
|
||||
for _, tag := range tags {
|
||||
tagAttrs := make(map[string]string, len(tag.Attr))
|
||||
for _, v := range tag.Attr {
|
||||
tagAttrs[v.Key] = v.Val
|
||||
}
|
||||
tagMap = append(tagMap, tagAttrs)
|
||||
}
|
||||
input["Meta"] = tagMap
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
|
||||
buf := bytes.NewBuffer(make([]byte, 0, 8192))
|
||||
|
||||
err := templates["challenge-"+state.opt.ChallengeTemplate+".gohtml"].Execute(buf, input)
|
||||
err := templates["challenge-"+state.Options().ChallengeTemplate+".gohtml"].Execute(buf, input)
|
||||
if err != nil {
|
||||
state.ErrorPage(w, r, http.StatusInternalServerError, err, "")
|
||||
} else {
|
||||
data.ResponseHeaders(w)
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write(buf.Bytes())
|
||||
}
|
||||
@@ -117,34 +107,46 @@ func (state *State) ChallengePage(w http.ResponseWriter, r *http.Request, status
|
||||
func (state *State) ErrorPage(w http.ResponseWriter, r *http.Request, status int, err error, redirect string) {
|
||||
data := challenge.RequestDataFromContext(r.Context())
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
|
||||
buf := bytes.NewBuffer(make([]byte, 0, 8192))
|
||||
|
||||
input := map[string]any{
|
||||
"Id": data.Id.String(),
|
||||
"Random": utils.StaticCacheBust(),
|
||||
"Random": utils.CacheBust(),
|
||||
"Error": err.Error(),
|
||||
"Path": state.UrlPath(),
|
||||
"Theme": "",
|
||||
"Title": template.HTML(string(state.opt.Strings.Get("title_error")) + " " + http.StatusText(status)),
|
||||
"Title": template.HTML(string(state.Options().Strings.Get("title_error")) + " " + http.StatusText(status)),
|
||||
"Challenge": "",
|
||||
"Redirect": redirect,
|
||||
"Links": state.opt.Links,
|
||||
"Strings": state.opt.Strings,
|
||||
"Links": state.Options().Links,
|
||||
"Strings": state.Options().Strings,
|
||||
}
|
||||
for k, v := range state.opt.ChallengeTemplateOverrides {
|
||||
for k, v := range state.Options().ChallengeTemplateOverrides {
|
||||
input[k] = v
|
||||
}
|
||||
|
||||
state.addCachedTags(data, r, input)
|
||||
if data.GetOptBool(challenge.RequestOptCacheMetaTags, false) {
|
||||
backend, host := data.BackendHost()
|
||||
if tags := state.fetchMetaTags(host, backend, r); len(tags) > 0 {
|
||||
tagMap, _ := input["Meta"].([]map[string]string)
|
||||
|
||||
err2 := templates["challenge-"+state.opt.ChallengeTemplate+".gohtml"].Execute(buf, input)
|
||||
for _, tag := range tags {
|
||||
tagAttrs := make(map[string]string, len(tag.Attr))
|
||||
for _, v := range tag.Attr {
|
||||
tagAttrs[v.Key] = v.Val
|
||||
}
|
||||
tagMap = append(tagMap, tagAttrs)
|
||||
}
|
||||
input["Meta"] = tagMap
|
||||
}
|
||||
}
|
||||
|
||||
err2 := templates["challenge-"+state.Options().ChallengeTemplate+".gohtml"].Execute(buf, input)
|
||||
if err2 != nil {
|
||||
// nested errors!
|
||||
panic(err2)
|
||||
} else {
|
||||
data.ResponseHeaders(w)
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write(buf.Bytes())
|
||||
}
|
||||
|
||||
280
tests/action_test.go
Normal file
280
tests/action_test.go
Normal file
@@ -0,0 +1,280 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"git.gammaspectra.live/git/go-away/lib/policy"
|
||||
"git.gammaspectra.live/git/go-away/utils"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func testAction(t *testing.T, pol policy.Policy, expected int, url string) (*http.Response, error) {
|
||||
settings := setupDefaultSettings(t)
|
||||
var r *http.Response
|
||||
err := MakeGoAwayState(pol, settings, func(do func(r *http.Request, errs ...error) (*http.Response, error)) error {
|
||||
request, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Header.Set(settings.ClientIpHeader, "127.0.0.1")
|
||||
response, err := do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != expected {
|
||||
return fmt.Errorf("expected status code %d, got %d", expected, response.StatusCode)
|
||||
}
|
||||
r = response
|
||||
|
||||
return nil
|
||||
})
|
||||
return r, err
|
||||
}
|
||||
|
||||
func TestActionPass(t *testing.T) {
|
||||
pol, err := policy.NewPolicy(strings.NewReader(
|
||||
`
|
||||
rules:
|
||||
- name: test
|
||||
conditions: ["true"]
|
||||
action: pass
|
||||
settings:
|
||||
|
||||
`,
|
||||
))
|
||||
if err != nil {
|
||||
t.Fatal(fmt.Errorf("failed to create policy: %w", err))
|
||||
}
|
||||
|
||||
_, err = testAction(t, *pol, http.StatusOK, "/test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionNone(t *testing.T) {
|
||||
pol, err := policy.NewPolicy(strings.NewReader(
|
||||
`
|
||||
rules:
|
||||
- name: test
|
||||
conditions: ["true"]
|
||||
action: none
|
||||
settings:
|
||||
|
||||
`,
|
||||
))
|
||||
if err != nil {
|
||||
t.Fatal(fmt.Errorf("failed to create policy: %w", err))
|
||||
}
|
||||
|
||||
_, err = testAction(t, *pol, http.StatusOK, "/test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionDrop(t *testing.T) {
|
||||
pol, err := policy.NewPolicy(strings.NewReader(
|
||||
`
|
||||
rules:
|
||||
- name: test
|
||||
conditions: ["true"]
|
||||
action: drop
|
||||
settings:
|
||||
|
||||
`,
|
||||
))
|
||||
if err != nil {
|
||||
t.Fatal(fmt.Errorf("failed to create policy: %w", err))
|
||||
}
|
||||
|
||||
response, err := testAction(t, *pol, http.StatusForbidden, "/test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(data) != 0 {
|
||||
t.Fatal(fmt.Errorf("expected empty response, got %s", string(data)))
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionDeny(t *testing.T) {
|
||||
pol, err := policy.NewPolicy(strings.NewReader(
|
||||
`
|
||||
rules:
|
||||
- name: test
|
||||
conditions: ["true"]
|
||||
action: deny
|
||||
settings:
|
||||
|
||||
`,
|
||||
))
|
||||
if err != nil {
|
||||
t.Fatal(fmt.Errorf("failed to create policy: %w", err))
|
||||
}
|
||||
|
||||
response, err := testAction(t, *pol, http.StatusForbidden, "/test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
t.Fatal(errors.New("expected non-empty response, got none"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionBlock(t *testing.T) {
|
||||
pol, err := policy.NewPolicy(strings.NewReader(
|
||||
`
|
||||
rules:
|
||||
- name: test
|
||||
conditions: ["true"]
|
||||
action: block
|
||||
settings:
|
||||
|
||||
`,
|
||||
))
|
||||
if err != nil {
|
||||
t.Fatal(fmt.Errorf("failed to create policy: %w", err))
|
||||
}
|
||||
|
||||
response, err := testAction(t, *pol, http.StatusForbidden, "/test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
t.Fatal(errors.New("expected non-empty response, got none"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionCode(t *testing.T) {
|
||||
pol, err := policy.NewPolicy(strings.NewReader(
|
||||
`
|
||||
rules:
|
||||
- name: test
|
||||
conditions: ["true"]
|
||||
action: code
|
||||
settings:
|
||||
http-code: 418
|
||||
|
||||
`,
|
||||
))
|
||||
if err != nil {
|
||||
t.Fatal(fmt.Errorf("failed to create policy: %w", err))
|
||||
}
|
||||
|
||||
_, err = testAction(t, *pol, http.StatusTeapot, "/test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionContextResponseHeaders(t *testing.T) {
|
||||
pol, err := policy.NewPolicy(strings.NewReader(
|
||||
`
|
||||
rules:
|
||||
- name: test
|
||||
conditions: ["true"]
|
||||
action: context
|
||||
settings:
|
||||
response-headers:
|
||||
X-World-Domination: yes
|
||||
|
||||
`,
|
||||
))
|
||||
if err != nil {
|
||||
t.Fatal(fmt.Errorf("failed to create policy: %w", err))
|
||||
}
|
||||
|
||||
response, err := testAction(t, *pol, http.StatusOK, "/test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if response.Header.Get("X-World-Domination") != "yes" {
|
||||
t.Fatal(fmt.Errorf("expected header set, got %s", response.Header.Get("X-World-Domination")))
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionContextSetMetaTags(t *testing.T) {
|
||||
pol, err := policy.NewPolicy(strings.NewReader(
|
||||
`
|
||||
rules:
|
||||
- name: test-context
|
||||
conditions: ["true"]
|
||||
action: context
|
||||
settings:
|
||||
context-set:
|
||||
proxy-meta-tags: yes
|
||||
|
||||
- name: test
|
||||
conditions: ["true"]
|
||||
action: deny
|
||||
|
||||
`,
|
||||
))
|
||||
if err != nil {
|
||||
t.Fatal(fmt.Errorf("failed to create policy: %w", err))
|
||||
}
|
||||
|
||||
uri, err := url.Parse("/test")
|
||||
if err != nil {
|
||||
t.Fatal(fmt.Errorf("failed to create policy: %w", err))
|
||||
}
|
||||
q := uri.Query()
|
||||
q.Set("mime-type", "text/html")
|
||||
q.Set("content", base64.RawURLEncoding.EncodeToString([]byte(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name="description" content="test">
|
||||
</head>
|
||||
</html>
|
||||
`)))
|
||||
|
||||
uri.RawQuery = q.Encode()
|
||||
|
||||
response, err := testAction(t, *pol, http.StatusForbidden, uri.String())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tags := utils.FetchTagsFromReader(response.Body, "meta")
|
||||
|
||||
if str := func() string {
|
||||
for _, t := range tags {
|
||||
var is bool
|
||||
var val string
|
||||
for _, a := range t.Attr {
|
||||
if a.Key == "name" && a.Val == "description" {
|
||||
is = true
|
||||
}
|
||||
if a.Key == "content" {
|
||||
val = a.Val
|
||||
}
|
||||
}
|
||||
if is {
|
||||
return val
|
||||
}
|
||||
}
|
||||
return "NONE"
|
||||
}(); str != "test" {
|
||||
t.Fatal(fmt.Errorf("expected meta tag with 'test', got %s", str))
|
||||
}
|
||||
}
|
||||
34
tests/away.go
Normal file
34
tests/away.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"git.gammaspectra.live/git/go-away/lib"
|
||||
"git.gammaspectra.live/git/go-away/lib/policy"
|
||||
"git.gammaspectra.live/git/go-away/lib/settings"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
)
|
||||
|
||||
var DefaultSettings = policy.StateSettings{
|
||||
Cache: nil,
|
||||
Backends: map[string]http.Handler{
|
||||
"*": MakeTestBackend(),
|
||||
},
|
||||
MainName: "go-away/tests",
|
||||
MainVersion: "testing",
|
||||
BasePath: "/.go-away",
|
||||
ChallengeResponseCode: http.StatusTeapot,
|
||||
ClientIpHeader: "X-Forwarded-For",
|
||||
}
|
||||
|
||||
func MakeGoAwayState(pol policy.Policy, stateSettings policy.StateSettings, f func(do func(r *http.Request, errs ...error) (*http.Response, error)) error) error {
|
||||
state, err := lib.NewState(pol, settings.DefaultSettings, stateSettings)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return f(func(r *http.Request, errs ...error) (*http.Response, error) {
|
||||
recorder := httptest.NewRecorder()
|
||||
state.ServeHTTP(recorder, r)
|
||||
return recorder.Result(), nil
|
||||
})
|
||||
}
|
||||
57
tests/backend.go
Normal file
57
tests/backend.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
func MakeTestBackend() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
responseCode := http.StatusOK
|
||||
var err error
|
||||
if opt := q.Get("http-code"); opt != "" {
|
||||
rc, err := strconv.ParseInt(opt, 10, 64)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
responseCode = int(rc)
|
||||
}
|
||||
type ResponseJson struct {
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"`
|
||||
Query string `json:"query"`
|
||||
}
|
||||
|
||||
if opt := q.Get("mime-type"); opt != "" {
|
||||
w.Header().Set("Content-Type", opt)
|
||||
} else {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
var data []byte
|
||||
if opt := q.Get("content"); opt != "" {
|
||||
data, err = base64.RawURLEncoding.DecodeString(opt)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
data, err = json.Marshal(ResponseJson{
|
||||
Method: r.Method,
|
||||
Path: r.URL.Path,
|
||||
Query: r.URL.RawQuery,
|
||||
})
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
w.WriteHeader(responseCode)
|
||||
_, _ = w.Write(data)
|
||||
})
|
||||
}
|
||||
362
tests/challenge_test.go
Normal file
362
tests/challenge_test.go
Normal file
@@ -0,0 +1,362 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
challenge2 "git.gammaspectra.live/git/go-away/lib/challenge"
|
||||
"git.gammaspectra.live/git/go-away/lib/policy"
|
||||
"golang.org/x/net/html"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func setupDefaultSettings(t *testing.T) policy.StateSettings {
|
||||
settings := DefaultSettings
|
||||
slog.SetDefault(slog.New(initLogger(t)))
|
||||
|
||||
return settings
|
||||
}
|
||||
|
||||
func TestChallengeCookie(t *testing.T) {
|
||||
settings := setupDefaultSettings(t)
|
||||
|
||||
pol, err := policy.NewPolicy(strings.NewReader(
|
||||
`
|
||||
challenges:
|
||||
"challenge-cookie":
|
||||
runtime: "cookie"
|
||||
|
||||
rules:
|
||||
- name: catch-all
|
||||
conditions: ["true"]
|
||||
action: challenge
|
||||
settings:
|
||||
challenges: ["challenge-cookie"]
|
||||
|
||||
`,
|
||||
))
|
||||
if err != nil {
|
||||
t.Fatal(fmt.Errorf("failed to create policy: %w", err))
|
||||
}
|
||||
|
||||
var expectedCode = http.StatusTemporaryRedirect
|
||||
|
||||
err = MakeGoAwayState(*pol, settings, func(do func(r *http.Request, errs ...error) (*http.Response, error)) error {
|
||||
challenge, err := http.NewRequest(http.MethodGet, "/test", nil)
|
||||
challenge.Header.Set(settings.ClientIpHeader, "127.0.0.1")
|
||||
challengeResponse, err := do(challenge)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer challengeResponse.Body.Close()
|
||||
if challengeResponse.StatusCode != expectedCode {
|
||||
return fmt.Errorf("expected challenge status code %d, got %d", expectedCode, challengeResponse.StatusCode)
|
||||
} else if cookies := challengeResponse.Cookies(); len(cookies) == 0 {
|
||||
return fmt.Errorf("expected set cookies to be non-empty, got none")
|
||||
} else if challengeResponse.Header.Get("Location") == "" {
|
||||
return fmt.Errorf("expected header 'Location' to be non-empty, got none")
|
||||
}
|
||||
|
||||
solveLocation := challengeResponse.Header.Get("Location")
|
||||
|
||||
if !strings.HasPrefix(solveLocation, "/test") {
|
||||
return fmt.Errorf("expected next location to start with '/test', got %s", solveLocation)
|
||||
}
|
||||
|
||||
// test pass
|
||||
pass, err := http.NewRequest(http.MethodGet, solveLocation, nil)
|
||||
pass.Header.Set(settings.ClientIpHeader, "127.0.0.1")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, c := range challengeResponse.Cookies() {
|
||||
pass.AddCookie(c)
|
||||
}
|
||||
|
||||
response, err := do(pass)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("expected pass status code %d, got %d", http.StatusOK, response.StatusCode)
|
||||
}
|
||||
|
||||
// test failure
|
||||
fail, err := http.NewRequest(http.MethodGet, solveLocation, nil)
|
||||
fail.Header.Set(settings.ClientIpHeader, "127.0.0.1")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
response, err = do(fail)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusForbidden {
|
||||
return fmt.Errorf("expected fail status code %d, got %d", http.StatusForbidden, response.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChallengeHeaderRefresh(t *testing.T) {
|
||||
settings := setupDefaultSettings(t)
|
||||
|
||||
pol, err := policy.NewPolicy(strings.NewReader(
|
||||
`
|
||||
challenges:
|
||||
"challenge-header-refresh":
|
||||
runtime: "refresh"
|
||||
parameters:
|
||||
refresh-via: "header"
|
||||
|
||||
rules:
|
||||
- name: catch-all
|
||||
conditions: ["true"]
|
||||
action: challenge
|
||||
settings:
|
||||
challenges: ["challenge-header-refresh"]
|
||||
|
||||
`,
|
||||
))
|
||||
if err != nil {
|
||||
t.Fatal(fmt.Errorf("failed to create policy: %w", err))
|
||||
}
|
||||
|
||||
var expectedCode = settings.ChallengeResponseCode
|
||||
|
||||
err = MakeGoAwayState(*pol, settings, func(do func(r *http.Request, errs ...error) (*http.Response, error)) error {
|
||||
challenge, err := http.NewRequest(http.MethodGet, "/test", nil)
|
||||
challenge.Header.Set(settings.ClientIpHeader, "127.0.0.1")
|
||||
challengeResponse, err := do(challenge)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer challengeResponse.Body.Close()
|
||||
if challengeResponse.StatusCode != expectedCode {
|
||||
return fmt.Errorf("expected challenge status code %d, got %d", expectedCode, challengeResponse.StatusCode)
|
||||
} else if challengeResponse.Header.Get("Refresh") == "" {
|
||||
return fmt.Errorf("expected header 'Refresh' to be non-empty, got none")
|
||||
}
|
||||
|
||||
solveLocation, err := url.QueryUnescape(strings.Split(challengeResponse.Header.Get("Refresh"), "; url=")[1])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// test solve
|
||||
solve, err := http.NewRequest(http.MethodGet, solveLocation, nil)
|
||||
solve.Header.Set(settings.ClientIpHeader, "127.0.0.1")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
response, err := do(solve)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusTemporaryRedirect {
|
||||
return fmt.Errorf("expected solve status code %d, got %d", http.StatusTemporaryRedirect, response.StatusCode)
|
||||
} else if cookies := response.Cookies(); len(cookies) == 0 {
|
||||
return fmt.Errorf("expected set cookies to be non-empty, got none")
|
||||
} else if response.Header.Get("Location") == "" {
|
||||
return fmt.Errorf("expected header 'Location' to be non-empty, got none")
|
||||
} else if !strings.HasPrefix(response.Header.Get("Location"), "/test") {
|
||||
return fmt.Errorf("expected next location to start with '/test', got %s", response.Header.Get("Location"))
|
||||
}
|
||||
|
||||
// test pass
|
||||
pass, err := http.NewRequest(http.MethodGet, response.Header.Get("Location"), nil)
|
||||
pass.Header.Set(settings.ClientIpHeader, "127.0.0.1")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, c := range response.Cookies() {
|
||||
pass.AddCookie(c)
|
||||
}
|
||||
|
||||
response, err = do(pass)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("expected pass status code %d, got %d", http.StatusOK, response.StatusCode)
|
||||
}
|
||||
|
||||
// test failure
|
||||
uri, err := url.Parse(solveLocation)
|
||||
q := uri.Query()
|
||||
q.Set(challenge2.QueryArgToken, hex.EncodeToString(make([]byte, challenge2.KeySize)))
|
||||
uri.RawQuery = q.Encode()
|
||||
|
||||
fail, err := http.NewRequest(http.MethodGet, uri.String(), nil)
|
||||
fail.Header.Set(settings.ClientIpHeader, "127.0.0.1")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
response, err = do(fail)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusBadRequest {
|
||||
return fmt.Errorf("expected fail status code %d, got %d", http.StatusBadRequest, response.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChallengeMetaRefresh(t *testing.T) {
|
||||
settings := setupDefaultSettings(t)
|
||||
|
||||
pol, err := policy.NewPolicy(strings.NewReader(
|
||||
`
|
||||
challenges:
|
||||
"challenge-meta-refresh":
|
||||
runtime: "refresh"
|
||||
parameters:
|
||||
refresh-via: "meta"
|
||||
|
||||
rules:
|
||||
- name: catch-all
|
||||
conditions: ["true"]
|
||||
action: challenge
|
||||
settings:
|
||||
challenges: ["challenge-meta-refresh"]
|
||||
|
||||
`,
|
||||
))
|
||||
if err != nil {
|
||||
t.Fatal(fmt.Errorf("failed to create policy: %w", err))
|
||||
}
|
||||
|
||||
var expectedCode = settings.ChallengeResponseCode
|
||||
|
||||
err = MakeGoAwayState(*pol, settings, func(do func(r *http.Request, errs ...error) (*http.Response, error)) error {
|
||||
challenge, err := http.NewRequest(http.MethodGet, "/test", nil)
|
||||
challenge.Header.Set(settings.ClientIpHeader, "127.0.0.1")
|
||||
challengeResponse, err := do(challenge)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer challengeResponse.Body.Close()
|
||||
if challengeResponse.StatusCode != expectedCode {
|
||||
return fmt.Errorf("expected challenge status code %d, got %d", expectedCode, challengeResponse.StatusCode)
|
||||
} else if challengeResponse.Header.Get("Refresh") != "" {
|
||||
return fmt.Errorf("expected header 'Refresh' to be empty, got \"%s\"", challengeResponse.Header.Get("Refresh"))
|
||||
}
|
||||
|
||||
node, err := html.ParseWithOptions(challengeResponse.Body, html.ParseOptionEnableScripting(false))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var refresh string
|
||||
for n := range node.Descendants() {
|
||||
if n.Type == html.ElementNode && n.Data == "meta" {
|
||||
var is bool
|
||||
var val string
|
||||
for _, a := range n.Attr {
|
||||
if a.Key == "http-equiv" && a.Val == "refresh" {
|
||||
is = true
|
||||
}
|
||||
if a.Key == "content" {
|
||||
val = a.Val
|
||||
}
|
||||
}
|
||||
if is {
|
||||
refresh = val
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
solveLocation, err := url.QueryUnescape(strings.Split(refresh, "; url=")[1])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// test solve
|
||||
solve, err := http.NewRequest(http.MethodGet, solveLocation, nil)
|
||||
solve.Header.Set(settings.ClientIpHeader, "127.0.0.1")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
response, err := do(solve)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusTemporaryRedirect {
|
||||
return fmt.Errorf("expected solve status code %d, got %d", http.StatusTemporaryRedirect, response.StatusCode)
|
||||
} else if cookies := response.Cookies(); len(cookies) == 0 {
|
||||
return fmt.Errorf("expected set cookies to be non-empty, got none")
|
||||
} else if response.Header.Get("Location") == "" {
|
||||
return fmt.Errorf("expected header 'Location' to be non-empty, got none")
|
||||
} else if !strings.HasPrefix(response.Header.Get("Location"), "/test") {
|
||||
return fmt.Errorf("expected next location to start with '/test', got %s", response.Header.Get("Location"))
|
||||
}
|
||||
|
||||
// test pass
|
||||
pass, err := http.NewRequest(http.MethodGet, response.Header.Get("Location"), nil)
|
||||
pass.Header.Set(settings.ClientIpHeader, "127.0.0.1")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, c := range response.Cookies() {
|
||||
pass.AddCookie(c)
|
||||
}
|
||||
|
||||
response, err = do(pass)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("expected pass status code %d, got %d", http.StatusOK, response.StatusCode)
|
||||
}
|
||||
|
||||
// test failure
|
||||
uri, err := url.Parse(solveLocation)
|
||||
q := uri.Query()
|
||||
q.Set(challenge2.QueryArgToken, hex.EncodeToString(make([]byte, challenge2.KeySize)))
|
||||
uri.RawQuery = q.Encode()
|
||||
|
||||
fail, err := http.NewRequest(http.MethodGet, uri.String(), nil)
|
||||
fail.Header.Set(settings.ClientIpHeader, "127.0.0.1")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
response, err = do(fail)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusBadRequest {
|
||||
return fmt.Errorf("expected fail status code %d, got %d", http.StatusBadRequest, response.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
57
tests/logger_test.go
Normal file
57
tests/logger_test.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type logger struct {
|
||||
t *testing.T
|
||||
attrs []slog.Attr
|
||||
}
|
||||
|
||||
func (l logger) Enabled(ctx context.Context, level slog.Level) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (l logger) Handle(ctx context.Context, record slog.Record) error {
|
||||
str := fmt.Sprintf("[%s] %s", record.Level, record.Message)
|
||||
|
||||
if record.NumAttrs() > 0 || len(l.attrs) > 0 {
|
||||
str += ": "
|
||||
}
|
||||
for _, attr := range l.attrs {
|
||||
str += fmt.Sprintf("%s=%s ", attr.Key, attr.Value.String())
|
||||
}
|
||||
record.Attrs(func(attr slog.Attr) bool {
|
||||
str += fmt.Sprintf("%s=%s ", attr.Key, attr.Value.String())
|
||||
return true
|
||||
})
|
||||
|
||||
if record.Level == slog.LevelError {
|
||||
l.t.Error(str)
|
||||
} else {
|
||||
l.t.Log(str)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l logger) WithAttrs(attrs []slog.Attr) slog.Handler {
|
||||
newAttrs := make([]slog.Attr, 0, len(attrs)+len(l.attrs))
|
||||
newAttrs = append(newAttrs, l.attrs...)
|
||||
newAttrs = append(newAttrs, attrs...)
|
||||
return logger{
|
||||
t: l.t,
|
||||
attrs: newAttrs,
|
||||
}
|
||||
}
|
||||
|
||||
func (l logger) WithGroup(name string) slog.Handler {
|
||||
return l
|
||||
}
|
||||
|
||||
func initLogger(t *testing.T) slog.Handler {
|
||||
return logger{t: t}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
var DefaultCookiePrefix = ".go-away-"
|
||||
var CookiePrefix = ".go-away-"
|
||||
|
||||
// getValidHost Gets a valid host for an http.Cookie Domain field
|
||||
// TODO: bug: does not work with IPv6, see https://github.com/golang/go/issues/65521
|
||||
|
||||
@@ -7,15 +7,12 @@ import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func NewServer(handler http.Handler, tlsConfig *tls.Config) *http.Server {
|
||||
@@ -72,7 +69,7 @@ func EnsureNoOpenRedirect(redirect string) (string, error) {
|
||||
return uri.String(), nil
|
||||
}
|
||||
|
||||
func MakeReverseProxy(target string, goDns bool, dialTimeout time.Duration) (*httputil.ReverseProxy, error) {
|
||||
func MakeReverseProxy(target string, goDns bool) (*httputil.ReverseProxy, error) {
|
||||
u, err := url.Parse(target)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse target URL: %w", err)
|
||||
@@ -88,9 +85,7 @@ func MakeReverseProxy(target string, goDns bool, dialTimeout time.Duration) (*ht
|
||||
u.Path = ""
|
||||
// tell transport how to dial unix sockets
|
||||
transport.DialContext = func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||
dialer := net.Dialer{
|
||||
Timeout: dialTimeout,
|
||||
}
|
||||
dialer := net.Dialer{}
|
||||
return dialer.DialContext(ctx, "unix", addr)
|
||||
}
|
||||
// tell transport how to handle the unix url scheme
|
||||
@@ -100,12 +95,6 @@ func MakeReverseProxy(target string, goDns bool, dialTimeout time.Duration) (*ht
|
||||
Resolver: &net.Resolver{
|
||||
PreferGo: true,
|
||||
},
|
||||
Timeout: dialTimeout,
|
||||
}
|
||||
transport.DialContext = dialer.DialContext
|
||||
} else {
|
||||
dialer := &net.Dialer{
|
||||
Timeout: dialTimeout,
|
||||
}
|
||||
transport.DialContext = dialer.DialContext
|
||||
}
|
||||
@@ -169,51 +158,15 @@ func GetRemoteAddress(ctx context.Context) *netip.AddrPort {
|
||||
return &ip
|
||||
}
|
||||
|
||||
func RandomCacheBust(n int) string {
|
||||
buf := make([]byte, n)
|
||||
func CacheBust() string {
|
||||
return cacheBust
|
||||
}
|
||||
|
||||
var cacheBust string
|
||||
|
||||
func init() {
|
||||
|
||||
buf := make([]byte, 16)
|
||||
_, _ = rand.Read(buf)
|
||||
return base64.RawURLEncoding.EncodeToString(buf)
|
||||
}
|
||||
|
||||
var staticCacheBust = RandomCacheBust(16)
|
||||
|
||||
func StaticCacheBust() string {
|
||||
return staticCacheBust
|
||||
}
|
||||
|
||||
func ParseRawQuery(rawQuery string) (m url.Values, err error) {
|
||||
m = make(url.Values)
|
||||
for rawQuery != "" {
|
||||
var key string
|
||||
key, rawQuery, _ = strings.Cut(rawQuery, "&")
|
||||
if strings.Contains(key, ";") {
|
||||
err = fmt.Errorf("invalid semicolon separator in query")
|
||||
continue
|
||||
}
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
key, value, _ := strings.Cut(key, "=")
|
||||
m[key] = append(m[key], value)
|
||||
}
|
||||
return m, err
|
||||
}
|
||||
|
||||
func EncodeRawQuery(v url.Values) string {
|
||||
if len(v) == 0 {
|
||||
return ""
|
||||
}
|
||||
var buf strings.Builder
|
||||
for _, k := range slices.Sorted(maps.Keys(v)) {
|
||||
vs := v[k]
|
||||
for _, v := range vs {
|
||||
if buf.Len() > 0 {
|
||||
buf.WriteByte('&')
|
||||
}
|
||||
buf.WriteString(k)
|
||||
buf.WriteByte('=')
|
||||
buf.WriteString(v)
|
||||
}
|
||||
}
|
||||
return buf.String()
|
||||
cacheBust = base64.RawURLEncoding.EncodeToString(buf)
|
||||
}
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"maps"
|
||||
)
|
||||
|
||||
type Strings map[string]string
|
||||
|
||||
func (s Strings) set(v map[string]string) Strings {
|
||||
maps.Copy(s, v)
|
||||
return s
|
||||
}
|
||||
|
||||
func (s Strings) Get(value string) template.HTML {
|
||||
v, ok := (s)[value]
|
||||
if !ok {
|
||||
// fallback
|
||||
return template.HTML("string:" + value)
|
||||
}
|
||||
return template.HTML(v)
|
||||
}
|
||||
|
||||
func NewStrings[T ~map[string]string](v T) Strings {
|
||||
return make(Strings).set(v)
|
||||
}
|
||||
@@ -2,14 +2,14 @@ package utils
|
||||
|
||||
import (
|
||||
"golang.org/x/net/html"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"slices"
|
||||
)
|
||||
|
||||
func FetchTags(backend http.Handler, uri *url.URL, kinds ...string) (result []html.Node) {
|
||||
func FetchTags(backend http.Handler, uri *url.URL, kind string) (result []html.Node) {
|
||||
writer := httptest.NewRecorder()
|
||||
backend.ServeHTTP(writer, &http.Request{
|
||||
Method: http.MethodGet,
|
||||
@@ -33,14 +33,18 @@ func FetchTags(backend http.Handler, uri *url.URL, kinds ...string) (result []ht
|
||||
return nil
|
||||
}
|
||||
|
||||
return FetchTagsFromReader(response.Body, kind)
|
||||
}
|
||||
|
||||
func FetchTagsFromReader(r io.Reader, kind string) (result []html.Node) {
|
||||
//TODO: handle non UTF-8 documents
|
||||
node, err := html.ParseWithOptions(response.Body, html.ParseOptionEnableScripting(false))
|
||||
node, err := html.ParseWithOptions(r, html.ParseOptionEnableScripting(false))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
for n := range node.Descendants() {
|
||||
if n.Type == html.ElementNode && slices.Contains(kinds, n.Data) {
|
||||
if n.Type == html.ElementNode && n.Data == kind {
|
||||
result = append(result, html.Node{
|
||||
Type: n.Type,
|
||||
DataAtom: n.DataAtom,
|
||||
|
||||
Reference in New Issue
Block a user