36 Commits

Author SHA1 Message Date
WeebDataHoarder
816d0fef90 ci: trigger on tags 2025-05-03 22:14:15 +02:00
WeebDataHoarder
06aca367a1 ci: change push trigger 2025-05-03 22:12:13 +02:00
WeebDataHoarder
44c9114ae5 challenges: add refresh via JavaScript window.location 2025-05-03 21:35:12 +02:00
WeebDataHoarder
4b1878f1ac examples/forgejo: exclude fetchers from suspicious crawler 2025-05-03 21:21:13 +02:00
WeebDataHoarder
925a1d59a2 challenges: return ErrNoCookie when no cookies of given name are present 2025-05-03 17:41:50 +02:00
WeebDataHoarder
76417b4308 challenges: parse all existing cookies with given name and extract valid one always 2025-05-03 17:37:52 +02:00
WeebDataHoarder
0e62f80f9b challenges: prevent unbounded growth of stored cookies by bundling all state onto a single JWT token 2025-05-03 17:30:39 +02:00
WeebDataHoarder
2cb5972371 challenges/context: allow setting request headers towards the backend 2025-05-03 15:55:13 +02:00
WeebDataHoarder
3d73ee76c4 state: add more meta tags onto cached tags, add missing txt and xml resources to well-known snippet 2025-05-03 05:59:32 +02:00
WeebDataHoarder
5bc1ab428b docker: add GOAWAY_CHALLENGE_TEMPLATE_LOGO parameter to Dockerfile 2025-05-03 04:17:02 +02:00
WeebDataHoarder
606f8ec3a0 templates: explicitly allow overriding logo via cmdline/override in config, have bundled templates support it 2025-05-03 04:14:11 +02:00
WeebDataHoarder
1ea19c5a6c state context: Added proxy-safe-link-tags to proxy <link> tags, use specific LinkTags ranger on templates instead of raw elements 2025-05-03 04:12:58 +02:00
WeebDataHoarder
736c2708e9 examples/forgejo: exclude fetchers from TLS Fingerprint rule 2025-05-02 22:21:40 +02:00
WeebDataHoarder
74cc614564 readme: cleanup, redirect to wiki as necessary 2025-05-02 20:55:44 +02:00
WeebDataHoarder
e8e072286e challenge: lower preload-early-hint-deadline to 2 seconds by default 2025-05-02 20:42:25 +02:00
WeebDataHoarder
0d28d1680c readme: add ngx_http_js_challenge_module and haproxy-protection 2025-05-02 13:39:25 +02:00
pwgen2155
2ab45983e9 feat: all betterstack ip ranges and useragent (#16)
ref: https://betterstack.com/docs/uptime/frequently-asked-questions/#what-ips-does-uptime-use

I believe this is how you do it. Will test later on. Unfortunately their playwrite contains a generic user agent...

Co-authored-by: WeebDataHoarder <weebdatahoarder@noreply.gammaspectra.live>
Reviewed-on: https://git.gammaspectra.live/git/go-away/pulls/16
Co-authored-by: pwgen2155 <pwgen2155@noreply.gammaspectra.live>
Co-committed-by: pwgen2155 <pwgen2155@noreply.gammaspectra.live>
2025-05-02 11:00:39 +00:00
WeebDataHoarder
a2225fe749 context: allow nil request context in fetch cases 2025-05-02 02:23:48 +02:00
nakoo
61d0964eb0 docker: fix docker entrypoint to optionally accept the command option 2025-05-01 21:08:38 +00:00
WeebDataHoarder
b9ca196c63 settings/bind: allow specifying bind/client timeouts 2025-05-01 22:26:51 +02:00
WeebDataHoarder
f6a8f50a53 settings/backend: allow configuring dial and transport timeouts 2025-05-01 22:23:23 +02:00
WeebDataHoarder
3047dcfd4b examples/forgejo: Restrict meta tag fetching for likely bots 2025-05-01 16:15:28 +02:00
WeebDataHoarder
868c76eeb9 examples/forgejo: add commit graph endpoint to heavy resources 2025-05-01 14:20:03 +02:00
WeebDataHoarder
d412672ed4 state: explicitly free resources on Close() 2025-05-01 14:16:19 +02:00
WeebDataHoarder
d80e282781 readme: note existence of the wiki 2025-05-01 03:23:14 +02:00
WeebDataHoarder
2ecbd1db21 condition: ast: deprecated inNetwork is not a member function, fix logic 2025-05-01 02:44:12 +02:00
WeebDataHoarder
d6c29846df condition: generalize AST compilation, hot load network prefix blocks as needed, walk the AST and detect and preload networks 2025-05-01 02:40:43 +02:00
WeebDataHoarder
6e47cec540 examples/forgejo: allow releases summary-card fetch 2025-05-01 02:34:14 +02:00
WeebDataHoarder
fccaa64fad conditions: verify that AST condition result is bool 2025-05-01 01:58:08 +02:00
WeebDataHoarder
a9f03267b6 settings: allow transparent backends that don't set all values 2025-04-30 20:54:50 +02:00
WeebDataHoarder
4ce6d9efa3 cmd: add go runtime version and arch logs 2025-04-30 10:45:14 +02:00
WeebDataHoarder
cb46d4c7b6 ci: trigger builds on PRs 2025-04-30 10:44:47 +02:00
WeebDataHoarder
e46a5c75f8 debug: output mismatched backend host 2025-04-30 03:11:29 +02:00
WeebDataHoarder
b3cd741bee readme: note that port is necessary in case of non-standard port usage 2025-04-30 03:08:18 +02:00
WeebDataHoarder
3606590b48 Revert "docker: fix docker entrypoint to allow the command option"
This reverts commit 3c73c2de1c.

Fixes #14
2025-04-30 02:41:25 +02:00
WeebDataHoarder
a87023861a state: fix errors when loading network lists 2025-04-29 13:45:30 +02:00
46 changed files with 1164 additions and 835 deletions

View File

@@ -144,8 +144,11 @@ local goVersion = "1.24";
local mirror = "https://mirror.gcr.io";
[
Build(mirror, goVersion, alpineVersion, "linux", "amd64"),
Build(mirror, goVersion, alpineVersion, "linux", "arm64"),
Build(mirror, goVersion, alpineVersion, "linux", "amd64") + {"trigger": {event: ["push", "tag"], }},
Build(mirror, goVersion, alpineVersion, "linux", "arm64") + {"trigger": {event: ["push", "tag"], }},
# Test PRs
Build(mirror, goVersion, alpineVersion, "linux", "amd64") + {"name": "test-pr", "trigger": {event: ["pull_request"], }},
# latest
Publish(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"},

View File

@@ -65,6 +65,10 @@ steps:
image: alpine:3.21
mirror: https://mirror.gcr.io
name: test-wasm-fail
trigger:
event:
- push
- tag
type: docker
---
environment:
@@ -133,6 +137,81 @@ 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: 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
image: golang:1.24-alpine3.21
mirror: https://mirror.gcr.io
name: build
- commands:
- ./.bin/go-away --check --slog-level DEBUG --backend example.com=http://127.0.0.1:80
--policy examples/forgejo.yml --policy-snippets examples/snippets/
depends_on:
- build
image: alpine:3.21
mirror: https://mirror.gcr.io
name: check-policy-forgejo
- commands:
- ./.bin/go-away --check --slog-level DEBUG --backend example.com=http://127.0.0.1:80
--policy examples/generic.yml --policy-snippets examples/snippets/
depends_on:
- build
image: alpine:3.21
mirror: https://mirror.gcr.io
name: check-policy-generic
- commands:
- ./.bin/go-away --check --slog-level DEBUG --backend example.com=http://127.0.0.1:80
--policy examples/spa.yml --policy-snippets examples/snippets/
depends_on:
- build
image: alpine:3.21
mirror: https://mirror.gcr.io
name: check-policy-spa
- commands:
- ./.bin/test-wasm-runtime -wasm ./embed/challenge/js-pow-sha256/runtime/runtime.wasm
-make-challenge ./embed/challenge/js-pow-sha256/test/make-challenge.json -make-challenge-out
./embed/challenge/js-pow-sha256/test/make-challenge-out.json -verify-challenge
./embed/challenge/js-pow-sha256/test/verify-challenge.json -verify-challenge-out
0
depends_on:
- build
image: alpine:3.21
mirror: https://mirror.gcr.io
name: test-wasm-success
- commands:
- ./.bin/test-wasm-runtime -wasm ./embed/challenge/js-pow-sha256/runtime/runtime.wasm
-make-challenge ./embed/challenge/js-pow-sha256/test/make-challenge.json -make-challenge-out
./embed/challenge/js-pow-sha256/test/make-challenge-out.json -verify-challenge
./embed/challenge/js-pow-sha256/test/verify-challenge-fail.json -verify-challenge-out
1
depends_on:
- build
image: alpine:3.21
mirror: https://mirror.gcr.io
name: test-wasm-fail
trigger:
event:
- pull_request
type: docker
---
kind: pipeline
@@ -424,6 +503,6 @@ trigger:
type: docker
---
kind: signature
hmac: 6eab8ae9773b048e780db2bf9d440095eb5615d0baf8da71878069ad7124e167
hmac: df53e4ea6f1c47df4d2a3f89b931b8513e83daa9c6c15baba2662d8112a721c8
...

View File

@@ -1,132 +0,0 @@
# 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
```

View File

@@ -48,6 +48,7 @@ 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=""

253
README.md
View File

@@ -8,15 +8,17 @@ Self-hosted abuse detection and rule enforcement against low-effort mass AI scra
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](#extended-rule-actions) or [challenged](CHALLENGES.md#challenges) to filter suspicious requests.
Incoming requests can be selected by [rules](#rich-rule-matching) to be [actioned](https://git.gammaspectra.live/git/go-away/wiki/Rule-Actions) or [challenged](https://git.gammaspectra.live/git/go-away/wiki/Challenges) to filter suspicious requests.
The tool is designed highly flexible so the operator can minimize impact to legit users, while surgically targeting heavy endpoints or scrapers.
[Challenges](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)
[Challenges](https://git.gammaspectra.live/git/go-away/wiki/Challenges) can be transparent (not shown to user, depends on backend or other logic), [non-JavaScript](#non-javascript-challenges) (challenges common browser properties), or [custom JavaScript](#custom-javascript-wasm-challenges) (from Proof of Work to fingerprinting or Captcha is supported)
See _[Why do this?](#why-do-this)_ section for the challenges and reasoning behind this tool.
This documentation and go-away are in active development. See [What's left?](#what-s-left) section for a breakdown.
**This documentation and go-away are in active development.** See [What's left?](#what-s-left) section for a breakdown.
Check this README for a general introduction. An [in-depth Wiki](https://git.gammaspectra.live/git/go-away/wiki/) is available and being improved.
## Support
@@ -40,6 +42,13 @@ 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
@@ -69,7 +78,17 @@ Only available when TLS is enabled
fp.ja4 (string) JA4 TLS Fingerprint
```
### Template support
### Package path
You can modify the path where challenges are served and package name, if you don't want its presence to be easily discoverable.
No source code editing or forking necessary!
Simply pass a new absolute path via the cmdline _path_ argument, like so: `--path "/.goaway_example"`
### Page template and customization support
Internal or external templates can be loaded to customize the look of the challenge or error page. Additionally, themes can be configured to change the look of these quickly.
@@ -80,29 +99,19 @@ 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](#config) file if specified.
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.
### Extended rule actions
Some templates support themes. Specify that either via the [config.yml](examples/config.yml) file, or via `challenge-template-theme` cmdline argument.
In addition to the common PASS / CHALLENGE / DENY rules, go-away offers more actions that can be extended via code.
Most templates support overriding the logo. Specify that either via the [config.yml](examples/config.yml) file, or via `challenge-template-logo` cmdline argument.
| 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 |
**Feel free to make any changes to existing templates or bring your own, alter any logos or styling, it's yours to adapt!**
### Advanced actions
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.
In addition to the common PASS / CHALLENGE / DENY rules, go-away offers more actions, plus any more extensible via code.
PROXY allows the operator to send matching requests to a different backend, for example, a poison generator or a scraping maze.
See the [Rule Actions page](https://git.gammaspectra.live/git/go-away/wiki/Rule-Actions) on the Wiki.
### Multiple challenge matching
@@ -130,15 +139,15 @@ Several challenges that do not require JavaScript are offered, some targeting th
These can be used for light checking of requests that eliminate most of the low effort scraping.
See [Challenges](CHALLENGES.md#challenges) for a list of them.
See [Transparent challenges](https://git.gammaspectra.live/git/go-away/wiki/Challenges#transparent) and [Non-JavaScript challenges](https://git.gammaspectra.live/git/go-away/wiki/Challenges#non-javascript) on the Wiki for more information.
### Custom JavaScript / WASM challenges
A WASM interface for server-side proof generation and checking is offered. We provide `js-pow-sha256` as an example of one.
An internal test has shown you can implement Captchas or other browser fingerprinting tests within this interface.
You can implement Captchas or other browser fingerprinting tests within this interface.
If you are interested in creating your own, see the [Development](#development) section below.
See [Custom JavaScript challenges](https://git.gammaspectra.live/git/go-away/wiki/Challenges#custom-javascript) on the Wiki for more information.
### Upstream PROXY support
@@ -154,7 +163,6 @@ You can enable automatic certificate generation and TLS for the site via any ACM
Without TLS, HTTP/2 cleartext is supported, but you will need to configure the upstream proxy to send this protocol (`h2c://` on Caddy for example).
### TLS Fingerprinting
When running with TLS via autocert, TLS Fingerprinting of the incoming client is done.
@@ -188,14 +196,6 @@ 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.
@@ -204,12 +204,6 @@ 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.
@@ -312,187 +306,24 @@ 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/) | [![GitHub](https://img.shields.io/badge/GitHub-TecharoHQ/anubis-blue?style=flat&logo=github&labelColor=fff&logoColor=24292f)](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/) | [![lindenii.runxiyu.org](https://img.shields.io/badge/lindenii-powxy-blue?style=flat&logo=git&labelColor=fff&logoColor=000)](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) | [![SequentialRead](https://img.shields.io/badge/SequentialRead-forest/pow--bot--deterrent-blue?style=flat&logo=gitea&labelColor=fff&logoColor=000)](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) | [![GitHub](https://img.shields.io/badge/GitHub-yzqzss/csswaf-blue?style=flat&logo=github&labelColor=fff&logoColor=24292f)](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) | [![humungus.tedunangst.com](https://img.shields.io/badge/tedunangst-anticrawl-blue?style=flat&logo=mercurial&labelColor=fff&logoColor=000)](https://humungus.tedunangst.com/r/anticrawl)<br/>Go / None | Go http handler / proxy for regex based rules | Non-JS manual Challenge/Response |
| Project | Source Code | Description | Method |
|:----------------------------------------------------------------------------------------:|:---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------:|:---------------------------------------------------------------------------------------------------------------------------|:---------------------------------------------|
| [Anubis](https://anubis.techaro.lol/) | [![GitHub](https://img.shields.io/badge/GitHub-TecharoHQ/anubis-blue?style=flat&logo=github&labelColor=fff&logoColor=24292f)](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/) | [![lindenii.runxiyu.org](https://img.shields.io/badge/lindenii-powxy-blue?style=flat&logo=git&labelColor=fff&logoColor=000)](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) | [![SequentialRead](https://img.shields.io/badge/SequentialRead-forest/pow--bot--deterrent-blue?style=flat&logo=gitea&labelColor=fff&logoColor=000)](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) | [![GitHub](https://img.shields.io/badge/GitHub-yzqzss/csswaf-blue?style=flat&logo=github&labelColor=fff&logoColor=24292f)](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) | [![humungus.tedunangst.com](https://img.shields.io/badge/tedunangst-anticrawl-blue?style=flat&logo=mercurial&labelColor=fff&logoColor=000)](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) | [![GitHub](https://img.shields.io/badge/GitHub-simon987/ngx_http_js_challenge_module-blue?style=flat&logo=github&labelColor=fff&logoColor=24292f)](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/) | [![GitGud](https://img.shields.io/badge/GitGud-fatchan/haproxy--protection-blue?style=flat&logo=gitlab&labelColor=fff&logoColor=000)](https://gitgud.io/fatchan/haproxy-protection/)<br/> Lua / [GPL v3.0](https://gitgud.io/fatchan/haproxy-protection/-/blob/master/LICENSE.txt) | HAProxy configuration and lua scripts allowing a challenge-response page where users solve a captcha and/or proof-of-work. | JavaScript Challenge / Captcha |
## Development
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.

View File

@@ -20,6 +20,7 @@ import (
"os"
"os/signal"
"path"
"runtime"
"runtime/debug"
"strings"
"syscall"
@@ -85,7 +86,8 @@ 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"], "name of the challenge template theme to use (forgejo => [forgejo-auto, forgejo-dark, forgejo-light, gitea...])")
templateTheme := flag.String("challenge-template-theme", opt.ChallengeTemplateOverrides["Theme"], "override template theme to use (forgejo => [forgejo-auto, forgejo-dark, forgejo-light, gitea...])")
templateLogo := flag.String("challenge-template-logo", opt.ChallengeTemplateOverrides["Logo"], "override template logo to use")
basePath := flag.String("path", "/.well-known/."+internalCmdName, "base path where to expose go-away package onto, challenges will be served from here")
@@ -131,10 +133,11 @@ func main() {
slog.SetLogLoggerLevel(programLevel)
}
slog.Info("go-away", "package", internalMainName, "version", internalMainVersion, "cmd", internalCmdName)
slog.Info("go-away", "package", internalMainName, "version", internalMainVersion, "cmd", internalCmdName, "go", runtime.Version(), "os", runtime.GOOS, "arch", runtime.GOARCH)
// preload missing settings
opt.ChallengeTemplateOverrides["Theme"] = *templateTheme
opt.ChallengeTemplateOverrides["Logo"] = *templateLogo
// load overrides
if *settingsFile != "" {
@@ -237,7 +240,7 @@ func main() {
acmeCache = path.Join(*cachePath, "acme")
}
loadPolicyState := func() (http.Handler, error) {
loadPolicyState := func() (*lib.State, error) {
policyData, err := os.ReadFile(*policyFile)
if err != nil {
return nil, fmt.Errorf("failed to read policy file: %w", err)
@@ -308,6 +311,7 @@ func main() {
if sig != syscall.SIGHUP {
continue
}
oldHandler := handler
handler, err = loadPolicyState()
if err != nil {
slog.Error("handler configuration reload error", "err", err)
@@ -316,6 +320,9 @@ func main() {
swap(handler)
slog.Warn("handler configuration reloaded")
if oldHandler != nil {
_ = oldHandler.Close()
}
}
}()

View File

@@ -1,7 +1,7 @@
#!/bin/sh
set -e
if [ "${1#-}" != "$1" ]; then
if [ $# -eq 0 ] || [ "${1#-}" != "$1" ]; then
set -- /bin/go-away \
--bind "${GOAWAY_BIND}" --bind-network "${GOAWAY_BIND_NETWORK}" --socket-mode "${GOAWAY_SOCKET_MODE}" \
--metrics-bind "${GOAWAY_METRICS_BIND}" --debug-bind "${GOAWAY_DEBUG_BIND}" \
@@ -9,7 +9,9 @@ if [ "${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-theme "${GOAWAY_CHALLENGE_TEMPLATE_THEME}" \
--challenge-template "${GOAWAY_CHALLENGE_TEMPLATE}" \
--challenge-template-logo "${GOAWAY_CHALLENGE_TEMPLATE_LOGO}" \
--challenge-template-theme "${GOAWAY_CHALLENGE_TEMPLATE_THEME}" \
--slog-level "${GOAWAY_SLOG_LEVEL}" \
--acme-autocert "${GOAWAY_ACME_AUTOCERT}" \
--backend "${GOAWAY_BACKEND}" \

View File

@@ -1,13 +1,17 @@
<!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 .Meta }}
{{ range .MetaTags }}
<meta {{ range $key, $value := . }}{{ $key | attr }}="{{ $value }}" {{end}}/>
{{ end }}
{{ range .LinkTags }}
<link {{ range $key, $value := . }}{{ $key | attr }}="{{ $value }}" {{end}}/>
{{ end }}
{{ range .HeaderTags }}
{{ . }}
{{ end }}
@@ -22,7 +26,7 @@
<img
id="image"
style="width:100%;max-width:256px;"
src="{{ .Path }}/assets/static/logo.png?cacheBust={{ .Random }}"
src="{{ $logo }}"
/>
{{if .Challenge }}
<p id="status">{{ .Strings.Get "status_loading_challenge" }} <em>{{ .Challenge }}</em>...</p>

View File

@@ -1,14 +1,18 @@
<!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 .Meta }}
{{ range .MetaTags }}
<meta {{ range $key, $value := . }}{{ $key | attr }}="{{ $value }}" {{end}}/>
{{ end }}
{{ range .LinkTags }}
<link {{ range $key, $value := . }}{{ $key | attr }}="{{ $value }}" {{end}}/>
{{ end }}
{{ range .HeaderTags }}
{{ . }}
{{ end }}
@@ -45,7 +49,7 @@
<div class="ui stackable middle very relaxed page grid">
<div class="sixteen wide center aligned centered column">
<div>
<img class="logo" id="image" src="/assets/img/logo.png" />
<img class="logo" id="image" src="{{ $logo }}" />
</div>
<div class="hero">
<h2 class="ui icon header title" id="title">

View File

@@ -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
# forgejo: Looks like native Forgejo. Includes logos and resources from your instance. Supports Theme.
# anubis: An Anubis-like template with no configuration parameters. Supports Logo.
# forgejo: Looks like native Forgejo. Includes logos and resources from your instance. Supports Theme, Logo.
#
#challenge-template: "anubis"
@@ -49,6 +49,8 @@ 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
@@ -58,6 +60,12 @@ 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":
@@ -66,6 +74,14 @@ 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.

View File

@@ -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.startsWith("Mozilla/") || userAgent.startsWith("Opera/")) && ("ja4" in fp && fp.ja4.matches("^t[0-9a-z]+00_")) && !(userAgent.contains("compatible;") || userAgent.contains("+http") || userAgent.contains("facebookexternalhit/") || userAgent.contains("Twitterbot/"))'
# Old engines
- 'userAgent.contains("Presto/") || userAgent.contains("Trident/")'
# Old IE browsers
@@ -75,6 +75,7 @@ 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
@@ -146,7 +147,7 @@ rules:
- name: 0
action: check
settings:
challenges: [js-pow-sha256, http-cookie-check]
challenges: [js-refresh, http-cookie-check]
- name: 1
action: check
settings:
@@ -172,11 +173,12 @@ rules:
- 'path.matches("^/[^/]+/[^/]+/archive/.*\\.(bundle|zip|tar\\.gz)") && ($is-generic-browser)'
action: challenge
settings:
challenges: [ js-pow-sha256 ]
challenges: [ js-refresh ]
- name: allow-git-operations
conditions:
- '($is-git-path)'
# Includes repository and wiki git endpoints
- 'path.matches("^/[^/]+/[^/]+\\.git")'
- 'path.matches("^/[^/]+/[^/]+/") && ($is-git-ua)'
action: pass
@@ -213,7 +215,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.endsWith("/-/summary-card") || path.matches("^/[^/]+/[^/]+/releases/summary-card/[^/]+$")'
#- 'userAgent.contains("facebookexternalhit/")'
#- 'userAgent.contains("Twitterbot/")'
action: pass
@@ -240,11 +242,11 @@ rules:
- name: 0
action: check
settings:
challenges: [preload-link, header-refresh, js-pow-sha256, http-cookie-check]
challenges: [preload-link, header-refresh, js-refresh, http-cookie-check]
- name: 1
action: check
settings:
challenges: [ resource-load, js-pow-sha256, http-cookie-check ]
challenges: [ resource-load, js-refresh, http-cookie-check ]
- name: standard-bots
action: check
@@ -272,14 +274,7 @@ rules:
# if DNSBL fails, check additional challenges
fail: check
fail-settings:
challenges: [js-pow-sha256, http-cookie-check]
- name: suspicious-fetchers
action: check
settings:
challenges: [js-pow-sha256]
conditions:
- 'userAgent.contains("facebookexternalhit/") || userAgent.contains("facebookcatalog/")'
challenges: [js-refresh, http-cookie-check]
# Allow PUT/DELETE/PATCH/POST requests in general
- name: non-get-request
@@ -290,10 +285,15 @@ 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:
@@ -321,7 +321,7 @@ rules:
- name: standard-browser
action: challenge
settings:
challenges: [http-cookie-check, preload-link, meta-refresh, resource-load, js-pow-sha256]
challenges: [http-cookie-check, preload-link, meta-refresh, resource-load, js-refresh, js-pow-sha256]
conditions:
- '($is-generic-browser)'

View File

@@ -98,7 +98,7 @@ rules:
- name: 0
action: check
settings:
challenges: [js-pow-sha256]
challenges: [js-refresh]
- name: 1
action: check
settings:
@@ -122,12 +122,12 @@ rules:
# if DNSBL fails, check additional challenges
fail: check
fail-settings:
challenges: [js-pow-sha256]
challenges: [js-refresh]
- name: suspicious-fetchers
action: check
settings:
challenges: [js-pow-sha256]
challenges: [js-refresh]
conditions:
- 'userAgent.contains("facebookexternalhit/") || userAgent.contains("facebookcatalog/")'
@@ -170,7 +170,7 @@ rules:
- name: standard-browser
action: challenge
settings:
challenges: [preload-link, meta-refresh, resource-load, js-pow-sha256]
challenges: [preload-link, meta-refresh, resource-load, js-refresh]
conditions:
- '($is-generic-browser)'

View File

@@ -0,0 +1,8 @@
networks:
betterstack:
- url: https://uptime.betterstack.com/ips-by-cluster.json
jq-path: '.[] | .[]'
conditions:
is-bot-betterstack:
- &is-bot-betterstack '((userAgent.startsWith("Better Stack Better Uptime Bot") || userAgent.startsWith("Better Uptime Bot") || userAgent == "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.17 Safari/537.36")) && remoteAddress.network("betterstack")'

View File

@@ -0,0 +1,6 @@
challenges:
js-refresh:
# Challenges with a redirect via window.location (requires HTML parsing and JavaScript logic)
runtime: "refresh"
parameters:
refresh-via: "javascript"

View File

@@ -9,7 +9,7 @@ challenges:
condition: '"Sec-Fetch-Mode" in headers && headers["Sec-Fetch-Mode"] == "navigate"'
runtime: "preload-link"
parameters:
preload-early-hint-deadline: 3s
preload-early-hint-deadline: 2s
# Challenges with a redirect via Refresh header (non-JS, requires HTTP parsing and logic)
header-refresh:

View File

@@ -1,8 +1,19 @@
conditions:
is-well-known-asset:
- 'path == "/robots.txt"'
# general txt files or scraper
- 'path == "/robots.txt" || path == "/security.txt"'
# ads txt files
- 'path == "/app-ads.txt" || path == "/ads.txt"'
# generally requested by browsers
- 'path == "/favicon.ico"'
- 'path.startsWith("/.well-known")'
# used by some applications
- 'path == "/crossdomain.xml"'
# well-known paths
- 'path.startsWith("/.well-known/")'
is-git-ua:
- 'userAgent.startsWith("git/") || userAgent.contains("libgit")'

View File

@@ -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]+),"

View File

@@ -29,6 +29,7 @@ func (a Block) Handle(logger *slog.Logger, w http.ResponseWriter, r *http.Reques
w.Header().Set("Content-Type", "text/plain")
w.Header().Set("Connection", "close")
data.ResponseHeaders(w)
w.WriteHeader(a.Code)
_, _ = w.Write([]byte(fmt.Errorf("access blocked: blocked by administrative rule %s/%s", data.Id.String(), a.RuleHash).Error()))

View File

@@ -42,6 +42,8 @@ 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) {
challenge.RequestDataFromContext(r.Context()).ResponseHeaders(w)
w.WriteHeader(int(a))
return false, nil
}

View File

@@ -7,6 +7,7 @@ import (
"github.com/goccy/go-yaml/ast"
"log/slog"
"net/http"
"net/textproto"
)
func init() {
@@ -33,8 +34,9 @@ func init() {
var ContextDefaultSettings = ContextSettings{}
type ContextSettings struct {
ContextSet map[string]string `yaml:"context-set"`
ResponseHeaders map[string]string `yaml:"response-headers"`
ContextSet map[string]string `yaml:"context-set"`
ResponseHeaders map[string][]string `yaml:"response-headers"`
RequestHeaders map[string][]string `yaml:"request-headers"`
}
type Context struct {
@@ -48,7 +50,19 @@ func (a Context) Handle(logger *slog.Logger, w http.ResponseWriter, r *http.Requ
}
for k, v := range a.opts.ResponseHeaders {
w.Header().Set(k, v)
// do this to allow unsetting values that are sent automatically
w.Header()[textproto.CanonicalMIMEHeaderKey(k)] = nil
for _, val := range v {
w.Header().Add(k, val)
}
}
for k, v := range a.opts.RequestHeaders {
// do this to allow unsetting values that are sent automatically
r.Header[textproto.CanonicalMIMEHeaderKey(k)] = nil
for _, val := range v {
r.Header.Add(k, val)
}
}
return true, nil

View File

@@ -2,7 +2,6 @@ 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"
@@ -18,18 +17,15 @@ 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 {
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)
data := challenge.RequestDataFromContext(r.Context())
data.IssueChallengeToken(reg, key, nil, expiry, true)
uri, err := challenge.RedirectUrl(r, reg)
if err != nil {
return challenge.VerifyResultFail
}
data.ResponseHeaders(w)
http.Redirect(w, r, uri.String(), http.StatusTemporaryRedirect)
return challenge.VerifyResultNone
}

View File

@@ -1,6 +1,7 @@
package challenge
import (
"bytes"
http_cel "codeberg.org/gone/http-cel"
"context"
"crypto/rand"
@@ -9,9 +10,13 @@ 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"
@@ -23,7 +28,11 @@ type requestDataContextKey struct {
}
func RequestDataFromContext(ctx context.Context) *RequestData {
return ctx.Value(requestDataContextKey{}).(*RequestData)
val := ctx.Value(requestDataContextKey{})
if val == nil {
return nil
}
return val.(*RequestData)
}
type RequestId [16]byte
@@ -33,13 +42,19 @@ func (id RequestId) String() string {
}
type RequestData struct {
Id RequestId
Time time.Time
ChallengeVerify map[Id]VerifyResult
ChallengeState map[Id]VerifyState
Id RequestId
Time time.Time
ChallengeVerify map[Id]VerifyResult
ChallengeState map[Id]VerifyState
ChallengeMap TokenChallengeMap
challengeMapModified bool
RemoteAddress netip.AddrPort
State StateInterface
CookiePrefix string
cookieName string
issuedChallenge string
ExtraHeaders http.Header
r *http.Request
@@ -61,22 +76,27 @@ 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) {
@@ -88,19 +108,12 @@ 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
}
@@ -145,8 +158,9 @@ func (d *RequestData) NetworkPrefix() netip.Addr {
}
const (
RequestOptBackendHost = "backend-host"
RequestOptCacheMetaTags = "proxy-meta-tags"
RequestOptBackendHost = "backend-host"
RequestOptProxyMetaTags = "proxy-meta-tags"
RequestOptProxySafeLinkTags = "proxy-safe-link-tags"
)
func (d *RequestData) SetOpt(n, v string) {
@@ -186,18 +200,70 @@ func (d *RequestData) BackendHost() (http.Handler, string) {
return d.State.GetBackend(host), host
}
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)
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()),
}
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)
d.challengeMapModified = true
}
var ErrVerifyKeyMismatch = errors.New("verify: key mismatch")
var ErrVerifyVerifyMismatch = errors.New("verify: verification mismatch")
var ErrTokenExpired = errors.New("token: expired")
func (d *RequestData) VerifyChallengeToken(reg *Registration, token TokenChallenge, expectedKey Key) (VerifyResult, VerifyState, error) {
if token.Expiry.Time().Compare(time.Now()) < 0 {
return VerifyResultFail, VerifyStateNone, ErrTokenExpired
}
if token.NotBefore.Time().Compare(time.Now()) > 0 {
return VerifyResultFail, VerifyStateNone, errors.New("token not valid yet")
}
if bytes.Compare(expectedKey[:], token.Key) != 0 {
return VerifyResultFail, VerifyStateNone, ErrVerifyKeyMismatch
}
if reg.Verify != nil {
if unsaferand.Float64() < reg.VerifyProbability {
// random spot check
if ok, err := reg.Verify(expectedKey, token.Result, d.r); err != nil {
return VerifyResultFail, VerifyStateFull, err
} else if ok == VerifyResultNotOK {
return VerifyResultNotOK, VerifyStateFull, nil
} else if !ok.Ok() {
return ok, VerifyStateFull, ErrVerifyVerifyMismatch
} else {
return ok, VerifyStateFull, nil
}
}
}
if !token.Ok {
return VerifyResultNotOK, VerifyStateBrief, nil
}
return VerifyResultOK, VerifyStateBrief, nil
}
func (d *RequestData) verifyChallenge(reg *Registration, key Key) (verifyResult VerifyResult, verifyState VerifyState, err error) {
token, ok := d.ChallengeMap[reg.Name]
if !ok {
verifyResult = VerifyResultFail
verifyState = VerifyStateNone
} else {
verifyResult, verifyState, err = d.VerifyChallengeToken(reg, token, key)
if err != nil && !errors.Is(err, http.ErrNoCookie) {
// clear invalid cookie
utils.ClearCookie(d.CookiePrefix+reg.Name, w, r)
// clear invalid state
d.ClearChallengeToken(reg)
}
// prevent evaluating the challenge if not solved
@@ -205,34 +271,49 @@ func (d *RequestData) EvaluateChallenges(w http.ResponseWriter, r *http.Request)
out, _, err := reg.Condition.Eval(d)
// verify eligibility
if err != nil {
d.State.Logger(r).Error(err.Error(), "challenge", reg.Name)
d.State.Logger(d.r).Error(err.Error(), "challenge", reg.Name)
} else if out != nil && out.Type() == types.BoolType {
if out.Equal(types.True) != types.True {
// skip challenge match due to precondition!
verifyResult = VerifyResultSkip
continue
return verifyResult, verifyState, err
}
}
}
}
if !verifyResult.Ok() && issuedChallenge == reg.Name {
// we issued the challenge, must skip to prevent loops
verifyResult = VerifyResultSkip
if !verifyResult.Ok() && d.issuedChallenge == reg.Name {
// we issued the challenge, must skip to prevent loops
verifyResult = VerifyResultSkip
}
return verifyResult, verifyState, err
}
func (d *RequestData) EvaluateChallenges(w http.ResponseWriter, r *http.Request) {
challengeMap, err := d.verifyChallengeState()
if err != nil {
if !errors.Is(err, http.ErrNoCookie) {
//clear invalid cookie and continue
utils.ClearCookie(d.cookieName, w, r)
}
challengeMap = make(TokenChallengeMap)
}
d.ChallengeMap = challengeMap
for _, reg := range d.State.GetChallenges() {
key := GetChallengeKeyForRequest(d.State, reg, d.Expiration(reg.Duration), r)
verifyResult, verifyState, err := d.verifyChallenge(reg, key)
if err != nil {
// clear invalid state
d.ClearChallengeToken(reg)
}
d.ChallengeVerify[reg.Id()] = verifyResult
d.ChallengeState[reg.Id()] = verifyState
}
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 {
@@ -243,9 +324,35 @@ func (d *RequestData) HasValidChallenge(id Id) bool {
return d.ChallengeVerify[id].Ok()
}
func (d *RequestData) ResponseHeaders(w http.ResponseWriter) {
// send these to client so we consistently get the headers
//w.Header().Set("Accept-CH", "Sec-CH-UA, Sec-CH-UA-Platform")
//w.Header().Set("Critical-CH", "Sec-CH-UA, Sec-CH-UA-Platform")
if d.State.Settings().MainName != "" {
w.Header().Add("Via", fmt.Sprintf("%s %s@%s", d.r.Proto, d.State.Settings().MainName, d.State.Settings().MainVersion))
}
if d.challengeMapModified {
expiration := d.Expiration(DefaultDuration)
if token, err := d.issueChallengeState(expiration); err == nil {
utils.SetCookie(d.cookieName, token, expiration, w, d.r)
} else {
d.State.Logger(d.r).Error("error while issuing cookie", "error", err)
}
}
}
func (d *RequestData) RequestHeaders(headers http.Header) {
headers.Set("X-Away-Id", d.Id.String())
if d.State.Settings().BackendIpHeader != "" {
if d.State.Settings().ClientIpHeader != "" {
headers.Del(d.State.Settings().ClientIpHeader)
}
headers.Set(d.State.Settings().BackendIpHeader, d.RemoteAddress.String())
}
for id, result := range d.ChallengeVerify {
if result.Ok() {
c, ok := d.State.GetChallenge(id)
@@ -257,4 +364,136 @@ 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]
}

View File

@@ -125,18 +125,10 @@ func FillRegistration(state challenge.StateInterface, reg *challenge.Registratio
}
if result.Bad() {
token, err := reg.IssueChallengeToken(state.PrivateKey(), key, nil, expiry, false)
if err != nil {
return challenge.VerifyResultFail
}
utils.SetCookie(data.CookiePrefix+reg.Name, token, expiry, w, r)
data.IssueChallengeToken(reg, key, nil, expiry, false)
return challenge.VerifyResultNotOK
} else {
token, err := reg.IssueChallengeToken(state.PrivateKey(), key, nil, expiry, true)
if err != nil {
return challenge.VerifyResultFail
}
utils.SetCookie(data.CookiePrefix+reg.Name, token, expiry, w, r)
data.IssueChallengeToken(reg, key, nil, expiry, true)
return challenge.VerifyResultOK
}
}

View File

@@ -138,6 +138,8 @@ func VerifyHandlerChallengeResponseFunc(state StateInterface, data *RequestData,
}
reqUri.RawQuery = q.Encode()
data.ResponseHeaders(w)
http.Redirect(w, r, reqUri.String(), http.StatusTemporaryRedirect)
return
}
@@ -147,6 +149,7 @@ 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)
}
@@ -175,18 +178,12 @@ 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
}
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.IssueChallengeToken(reg, key, []byte(token), expiration, true)
data.ChallengeVerify[reg.id] = verifyResult
state.ChallengePassed(r, reg, redirect, nil)
@@ -194,7 +191,6 @@ 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

View File

@@ -5,7 +5,6 @@ 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"
@@ -140,18 +139,10 @@ func FillRegistration(state challenge.StateInterface, reg *challenge.Registratio
data := challenge.RequestDataFromContext(r.Context())
if response.StatusCode != params.HttpCode {
token, err := reg.IssueChallengeToken(state.PrivateKey(), key, sum, expiry, false)
if err != nil {
return challenge.VerifyResultFail
}
utils.SetCookie(data.CookiePrefix+reg.Name, token, expiry, w, r)
data.IssueChallengeToken(reg, key, sum, expiry, false)
return challenge.VerifyResultNotOK
} else {
token, err := reg.IssueChallengeToken(state.PrivateKey(), key, sum, expiry, true)
if err != nil {
return challenge.VerifyResultFail
}
utils.SetCookie(data.CookiePrefix+reg.Name, token, expiry, w, r)
data.IssueChallengeToken(reg, key, sum, expiry, true)
return challenge.VerifyResultOK
}
}

View File

@@ -66,7 +66,7 @@ func GetChallengeKeyForRequest(state StateInterface, reg *Registration, until ti
hasher.Write([]byte{0})
_ = binary.Write(hasher, binary.LittleEndian, until.UTC().Unix())
hasher.Write([]byte{0})
hasher.Write(state.PublicKey())
hasher.Write(state.PrivateKeyFingerprint())
hasher.Write([]byte{0})
sum := Key(hasher.Sum(nil))

View File

@@ -22,7 +22,7 @@ type Parameters struct {
}
var DefaultParameters = Parameters{
Deadline: time.Second * 3,
Deadline: time.Second * 2,
}
func FillRegistration(state challenge.StateInterface, reg *challenge.Registration, parameters ast.Node) error {
@@ -110,6 +110,9 @@ 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 {

View File

@@ -1,9 +1,12 @@
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"
)
@@ -45,9 +48,19 @@ func FillRegistration(state challenge.StateInterface, reg *challenge.Registratio
return challenge.VerifyResultFail
}
if params.Mode == "meta" {
if params.Mode == "javascript" {
data, err := json.Marshal(uri.String())
if err != nil {
return challenge.VerifyResultFail
}
state.ChallengePage(w, r, state.Settings().ChallengeResponseCode, reg, map[string]any{
"Meta": []map[string]string{
"EndTags": []template.HTML{
template.HTML(fmt.Sprintf("<script type=\"text/javascript\">window.location = %s;</script>", string(data))),
},
})
} else if params.Mode == "meta" {
state.ChallengePage(w, r, state.Settings().ChallengeResponseCode, reg, map[string]any{
"MetaTags": []map[string]string{
{
"http-equiv": "refresh",
"content": "0; url=" + uri.String(),

View File

@@ -1,18 +1,12 @@
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"
@@ -67,13 +61,10 @@ func (r Register) Create(state StateInterface, name string, pol policy.Challenge
}
if len(conditions) > 0 {
ast, err := http_cel.NewAst(state.ProgramEnv(), http_cel.OperatorOr, conditions...)
var err error
reg.Condition, err = state.RegisterCondition(http_cel.OperatorOr, conditions...)
if err != nil {
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)
return nil, 0, fmt.Errorf("error compiling condition: %w", err)
}
}
@@ -148,104 +139,10 @@ 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)

View File

@@ -1,10 +1,8 @@
package resource_load
import (
"fmt"
"git.gammaspectra.live/git/go-away/lib/challenge"
"github.com/goccy/go-yaml/ast"
"html/template"
"net/http"
"time"
)
@@ -30,8 +28,12 @@ func FillRegistrationHeader(state challenge.StateInterface, reg *challenge.Regis
w.Header().Set("Refresh", "2; url="+r.URL.String())
state.ChallengePage(w, r, state.Settings().ChallengeResponseCode, reg, map[string]any{
"HeaderTags": []template.HTML{
template.HTML(fmt.Sprintf("<link href=\"%s\" rel=\"stylesheet\" crossorigin=\"use-credentials\">", uri.String())),
"LinkTags": []map[string]string{
{
"href": uri.String(),
"rel": "stylesheet",
"crossorigin": "use-credentials",
},
},
})
return challenge.VerifyResultNone
@@ -43,6 +45,9 @@ func FillRegistrationHeader(state challenge.StateInterface, reg *challenge.Regis
//TODO: add other types inside css that need to be loaded!
w.Header().Set("Content-Type", "text/css; charset=utf-8")
w.Header().Set("Content-Length", "0")
data.ResponseHeaders(w)
if !verifyResult.Ok() {
w.WriteHeader(http.StatusForbidden)
} else {

View File

@@ -23,7 +23,7 @@ func ServeChallengeScript(w http.ResponseWriter, r *http.Request, reg *Registrat
//TODO: log
panic(err)
}
data.ResponseHeaders(w)
w.WriteHeader(http.StatusOK)
err = scriptTemplate.Execute(w, map[string]any{
@@ -33,7 +33,7 @@ func ServeChallengeScript(w http.ResponseWriter, r *http.Request, reg *Registrat
"Random": utils.CacheBust(),
"Challenge": reg.Name,
"ChallengeScript": script,
"Strings": data.State.Options().Strings,
"Strings": data.State.Strings(),
})
if err != nil {
//TODO: log

View File

@@ -3,7 +3,7 @@ package challenge
import (
"crypto/ed25519"
"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"
"net/http"
@@ -86,9 +86,10 @@ func (r VerifyResult) String() string {
}
type StateInterface interface {
ProgramEnv() *cel.Env
RegisterCondition(operator string, conditions ...string) (cel.Program, error)
Client() *http.Client
PrivateKeyFingerprint() []byte
PrivateKey() ed25519.PrivateKey
PublicKey() ed25519.PublicKey
@@ -114,7 +115,7 @@ type StateInterface interface {
Settings() policy.StateSettings
Options() settings.Settings
Strings() utils.Strings
GetBackend(host string) http.Handler
}

View File

@@ -4,6 +4,7 @@ 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"
@@ -55,7 +56,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)
}
@@ -96,7 +97,7 @@ func (state *State) initConditions() (err error) {
}
return types.Bool(ipNet.Contains(ip))
} else {
ok, err := network.Contains(ip)
ok, err := network().Contains(ip)
if err != nil {
panic(err)
}
@@ -111,3 +112,113 @@ func (state *State) initConditions() (err error) {
}
return nil
}
func (state *State) RegisterCondition(operator string, conditions ...string) (cel.Program, error) {
compiledAst, err := http_cel.NewAst(state.ProgramEnv(), operator, conditions...)
if err != nil {
return nil, err
}
if out := compiledAst.OutputType(); out == nil {
return nil, fmt.Errorf("no output")
} else if out != types.BoolType {
return nil, fmt.Errorf("output type is not bool")
}
walkExpr(compiledAst.NativeRep().Expr(), func(e ast.Expr) {
if e.Kind() == ast.CallKind {
call := e.AsCall()
switch call.FunctionName() {
// deprecated
case "inNetwork":
args := call.Args()
if !call.IsMemberFunction() && len(args) == 2 {
// we have a network select function
switch args[1].Kind() {
case ast.LiteralKind:
lit := args[1].AsLiteral()
if lit.Type() == types.StringType {
if fn, ok := state.networks[lit.Value().(string)]; ok {
// preload
fn()
}
}
}
}
case "network":
args := call.Args()
if call.IsMemberFunction() && len(args) == 1 {
// we have a network select function
switch args[0].Kind() {
case ast.LiteralKind:
lit := args[0].AsLiteral()
if lit.Type() == types.StringType {
if fn, ok := state.networks[lit.Value().(string)]; ok {
// preload
fn()
}
}
}
}
}
}
})
return http_cel.ProgramAst(state.ProgramEnv(), compiledAst)
}
func walkExpr(e ast.Expr, fn func(ast.Expr)) {
fn(e)
switch e.Kind() {
case ast.CallKind:
ee := e.AsCall()
walkExpr(ee.Target(), fn)
for _, arg := range ee.Args() {
walkExpr(arg, fn)
}
case ast.ComprehensionKind:
ee := e.AsComprehension()
walkExpr(ee.Result(), fn)
walkExpr(ee.IterRange(), fn)
walkExpr(ee.AccuInit(), fn)
walkExpr(ee.LoopCondition(), fn)
walkExpr(ee.LoopStep(), fn)
case ast.ListKind:
ee := e.AsList()
for _, element := range ee.Elements() {
walkExpr(element, fn)
}
case ast.MapKind:
ee := e.AsMap()
for _, entry := range ee.Entries() {
switch entry.Kind() {
case ast.MapEntryKind:
eee := entry.AsMapEntry()
walkExpr(eee.Key(), fn)
walkExpr(eee.Value(), fn)
case ast.StructFieldKind:
eee := entry.AsStructField()
walkExpr(eee.Value(), fn)
}
}
case ast.SelectKind:
ee := e.AsSelect()
walkExpr(ee.Operand(), fn)
case ast.StructKind:
ee := e.AsStruct()
for _, field := range ee.Fields() {
switch field.Kind() {
case ast.MapEntryKind:
eee := field.AsMapEntry()
walkExpr(eee.Key(), fn)
walkExpr(eee.Value(), fn)
case ast.StructFieldKind:
eee := field.AsStructField()
walkExpr(eee.Value(), fn)
}
}
}
}

View File

@@ -38,7 +38,7 @@ func GetLoggerForRequest(r *http.Request) *slog.Logger {
return slog.With(args...)
}
func (state *State) fetchMetaTags(host string, backend http.Handler, r *http.Request) []html.Node {
func (state *State) fetchTags(host string, backend http.Handler, r *http.Request, meta, link bool) []html.Node {
uri := *r.URL
q := uri.Query()
for k := range q {
@@ -54,76 +54,161 @@ func (state *State) fetchMetaTags(host string, backend http.Handler, r *http.Req
return v
}
result := utils.FetchTags(backend, &uri, "meta")
result := utils.FetchTags(backend, &uri, func() (r []string) {
if meta {
r = append(r, "meta")
} else if link {
r = append(r, "link")
}
return r
}()...)
if result == nil {
return nil
}
entries := make([]html.Node, 0, len(result))
safeAttributes := []string{"name", "property", "content"}
for _, n := range result {
if n.Namespace != "" {
continue
}
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
}
}
switch n.Data {
case "link":
safeAttributes := []string{"rel", "href", "hreflang", "media", "title", "type"}
// 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,
}
var name string
for _, attr := range n.Attr {
if attr.Namespace != "" {
continue
}
if slices.Contains(safeAttributes, attr.Key) {
newNode.Attr = append(newNode.Attr, attr)
if attr.Key == "rel" {
name = attr.Val
break
}
}
if len(newNode.Attr) == 0 {
if name == "" {
continue
}
entries = append(entries, newNode)
var keep bool
if name == "icon" || name == "alternate icon" {
keep = true
} else if name == "alternate" || name == "canonical" || name == "search" {
// urls to versions of document
keep = true
} else if name == "author" || name == "privacy-policy" || name == "license" || name == "copyright" || name == "terms-of-service" {
keep = true
} else if name == "manifest" {
// web app manifest
keep = true
}
// prevent other arbitrary arguments
if keep {
newNode := html.Node{
Type: html.ElementNode,
Data: n.Data,
}
for _, attr := range n.Attr {
if attr.Namespace != "" {
continue
}
if slices.Contains(safeAttributes, attr.Key) {
newNode.Attr = append(newNode.Attr, attr)
}
}
if len(newNode.Attr) == 0 {
continue
}
entries = append(entries, newNode)
}
case "meta":
safeAttributes := []string{"name", "property", "content"}
var name string
for _, attr := range n.Attr {
if attr.Namespace != "" {
continue
}
if attr.Key == "name" {
name = attr.Val
break
}
if attr.Key == "property" && name == "" {
name = attr.Val
}
}
if name == "" {
continue
}
// prevent unwanted keys like CSRF and other internal entries to pass through as much as possible
var keep bool
if strings.HasPrefix("og:", name) || strings.HasPrefix("fb:", name) || strings.HasPrefix("twitter:", name) || strings.HasPrefix("profile:", name) {
// social / OpenGraph tags
keep = true
} else if name == "vcs" || strings.HasPrefix("vcs:", name) {
// source tags
keep = true
} else if name == "forge" || strings.HasPrefix("forge:", name) {
// forge tags
keep = true
} else if strings.HasPrefix("citation_", name) {
// citations for Google Scholar
keep = true
} else {
switch name {
case "theme-color", "color-scheme", "origin-trials":
// modifies page presentation
keep = true
case "application-name", "origin", "author", "creator", "contact", "title", "description", "thumbnail", "rating":
// standard content tags
keep = true
case "license", "license:uri", "rights", "rights-standard":
// licensing standards
keep = true
case "go-import", "go-source":
// golang tags
keep = true
case "apple-itunes-app", "appstore:bundle_id", "appstore:developer_url", "appstore:store_id", "google-play-app":
// application linking
keep = true
case "verify-v1", "google-site-verification", "p:domain_verify", "yandex-verification", "alexaverifyid":
// site verification
keep = true
case "keywords", "robots", "google", "googlebot", "bingbot", "pinterest", "Slurp":
// scraper and search content directives
keep = true
}
}
// prevent other arbitrary arguments
if keep {
newNode := html.Node{
Type: html.ElementNode,
Data: n.Data,
}
for _, attr := range n.Attr {
if attr.Namespace != "" {
continue
}
if slices.Contains(safeAttributes, attr.Key) {
newNode.Attr = append(newNode.Attr, attr)
}
}
if len(newNode.Attr) == 0 {
continue
}
entries = append(entries, newNode)
}
}
}
state.tagCache.Set(key, entries, time.Hour*6)
@@ -135,8 +220,11 @@ 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
}
@@ -154,9 +242,7 @@ func (state *State) handleRequest(w http.ResponseWriter, r *http.Request) {
return backend
}
lg := state.Logger(r)
cleanupRequest := func(r *http.Request, fromChallenge bool) {
cleanupRequest := func(r *http.Request, fromChallenge bool, ruleName string, ruleAction policy.RuleAction) {
if fromChallenge {
r.Header.Del("Referer")
}
@@ -174,21 +260,25 @@ func (state *State) handleRequest(w http.ResponseWriter, r *http.Request) {
}
r.URL.RawQuery = q.Encode()
data.RequestHeaders(r.Header)
data.ExtraHeaders.Set("X-Away-Rule", ruleName)
data.ExtraHeaders.Set("X-Away-Action", string(ruleAction))
// delete cookies set by go-away to prevent user tracking that way
cookies := r.Cookies()
r.Header.Del("Cookie")
for _, c := range cookies {
if !strings.HasPrefix(c.Name, utils.CookiePrefix) {
if !strings.HasPrefix(c.Name, utils.DefaultCookiePrefix) {
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)
cleanupRequest(r, true, rule.Name, rule.Action)
return getBackend()
})
if err != nil {
@@ -207,10 +297,7 @@ func (state *State) handleRequest(w http.ResponseWriter, r *http.Request) {
// default pass
_, _ = action.Pass{}.Handle(lg, w, r, func() http.Handler {
r.Header.Set("X-Away-Rule", "DEFAULT")
r.Header.Set("X-Away-Action", "PASS")
cleanupRequest(r, false)
cleanupRequest(r, false, "DEFAULT", policy.RuleActionPASS)
return getBackend()
})
}
@@ -239,9 +326,5 @@ 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)
}

View File

@@ -4,7 +4,6 @@ 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"
@@ -27,6 +26,10 @@ 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
}
@@ -99,8 +102,8 @@ func (state *State) Settings() policy.StateSettings {
return state.settings
}
func (state *State) Options() settings.Settings {
return state.opt
func (state *State) Strings() utils.Strings {
return state.opt.Strings
}
func (state *State) GetBackend(host string) http.Handler {

View File

@@ -29,7 +29,6 @@ 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))
@@ -38,7 +37,7 @@ func NewRuleState(state challenge.StateInterface, r policy.Rule, replacer *strin
}
hasher.Write([]byte(r.Name))
hasher.Write([]byte{0})
hasher.Write(fp[:])
hasher.Write(state.PrivateKeyFingerprint())
sum := hasher.Sum(nil)
rule := RuleState{
@@ -66,14 +65,9 @@ func NewRuleState(state challenge.StateInterface, r policy.Rule, replacer *strin
conditions = append(conditions, cond)
}
ast, err := http_cel.NewAst(state.ProgramEnv(), http_cel.OperatorOr, conditions...)
program, err := state.RegisterCondition(http_cel.OperatorOr, conditions...)
if err != nil {
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)
return RuleState{}, fmt.Errorf("error compiling condition: %w", err)
}
rule.Condition = program
}

View File

@@ -1,9 +1,11 @@
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 {
@@ -27,6 +29,48 @@ 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) {
@@ -34,13 +78,19 @@ func (b Backend) Create() (*httputil.ReverseProxy, error) {
b.IpHeader = ""
}
proxy, err := utils.MakeReverseProxy(b.URL, b.GoDNS)
proxy, err := utils.MakeReverseProxy(b.URL, b.GoDNS, b.DialTimeout)
if err != nil {
return nil, err
}
transport := proxy.Transport.(*http.Transport)
// set transport timeouts
transport.TLSHandshakeTimeout = b.TLSHandshakeTimeout
transport.IdleConnTimeout = b.IdleConnTimeout
transport.ResponseHeaderTimeout = b.ResponseHeaderTimeout
transport.ExpectContinueTimeout = b.ExpectContinueTimeout
if b.HTTP2Enabled {
transport.ForceAttemptHTTP2 = true
}
@@ -53,10 +103,10 @@ func (b Backend) Create() (*httputil.ReverseProxy, error) {
transport.TLSClientConfig.ServerName = b.Host
}
if b.IpHeader != "" || b.Host != "" {
if b.IpHeader != "" || b.Host != "" || !b.Transparent {
director := proxy.Director
proxy.Director = func(req *http.Request) {
if b.IpHeader != "" {
if b.IpHeader != "" && !b.Transparent {
if ip := utils.GetRemoteAddress(req.Context()); ip != nil {
req.Header.Set(b.IpHeader, ip.Addr().Unmap().String())
}
@@ -64,6 +114,12 @@ func (b Backend) Create() (*httputil.ReverseProxy, error) {
if b.Host != "" {
req.Host = b.Host
}
if !b.Transparent {
if data := challenge.RequestDataFromContext(req.Context()); data != nil {
data.RequestHeaders(req.Header)
}
}
director(req)
}
}

View File

@@ -14,6 +14,7 @@ import (
"os"
"strconv"
"sync/atomic"
"time"
)
type Bind struct {
@@ -31,6 +32,37 @@ type Bind struct {
TLSCertificate string `yaml:"tls-certificate"`
// TLSPrivateKey Alternate to TLSAcmeAutoCert
TLSPrivateKey string `yaml:"tls-key"`
// ReadTimeout is the maximum duration for reading the entire
// request, including the body. A zero or negative value means
// there will be no timeout.
//
// Because ReadTimeout does not let Handlers make per-request
// decisions on each request body's acceptable deadline or
// upload rate, most users will prefer to use
// ReadHeaderTimeout. It is valid to use them both.
ReadTimeout time.Duration `yaml:"read-timeout"`
// ReadHeaderTimeout is the amount of time allowed to read
// request headers. The connection's read deadline is reset
// after reading the headers and the Handler can decide what
// is considered too slow for the body. If zero, the value of
// ReadTimeout is used. If negative, or if zero and ReadTimeout
// is zero or negative, there is no timeout.
ReadHeaderTimeout time.Duration `yaml:"read-header-timeout"`
// WriteTimeout is the maximum duration before timing out
// writes of the response. It is reset whenever a new
// request's header is read. Like ReadTimeout, it does not
// let Handlers make decisions on a per-request basis.
// A zero or negative value means there will be no timeout.
WriteTimeout time.Duration `yaml:"write-timeout"`
// IdleTimeout is the maximum amount of time to wait for the
// next request when keep-alives are enabled. If zero, the value
// of ReadTimeout is used. If negative, or if zero and ReadTimeout
// is zero or negative, there is no timeout.
IdleTimeout time.Duration `yaml:"idle-timeout"`
}
func (b *Bind) Listener() (net.Listener, string) {
@@ -83,6 +115,11 @@ 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)
}
@@ -92,6 +129,7 @@ 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)

View File

@@ -1,6 +1,9 @@
package settings
import "maps"
import (
"git.gammaspectra.live/git/go-away/utils"
"maps"
)
type Settings struct {
Bind Bind `yaml:"bind"`
@@ -10,7 +13,7 @@ type Settings struct {
BindDebug string `yaml:"bind-debug"`
BindMetrics string `yaml:"bind-metrics"`
Strings Strings `yaml:"strings"`
Strings utils.Strings `yaml:"strings"`
// Links to add to challenge/error pages like privacy/impressum.
Links []Link `yaml:"links"`

View File

@@ -1,13 +1,10 @@
package settings
import (
"html/template"
"maps"
"git.gammaspectra.live/git/go-away/utils"
)
type Strings map[string]string
var DefaultStrings = make(Strings).set(map[string]string{
var DefaultStrings = utils.NewStrings(map[string]string{
"title_challenge": "Checking you are not a bot",
"title_error": "Oh no!",
@@ -39,17 +36,3 @@ var DefaultStrings = make(Strings).set(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)
}

View File

@@ -13,6 +13,7 @@ import (
"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"
@@ -23,6 +24,7 @@ import (
"path"
"strconv"
"strings"
"sync"
"time"
)
@@ -33,13 +35,14 @@ type State struct {
programEnv *cel.Env
publicKey ed25519.PublicKey
privateKey ed25519.PrivateKey
publicKey ed25519.PublicKey
privateKey ed25519.PrivateKey
privateKeyFingerprint []byte
opt settings.Settings
settings policy.StateSettings
networks map[string]cidranger.Ranger
networks map[string]func() cidranger.Ranger
challenges challenge.Register
@@ -52,8 +55,8 @@ type State struct {
Mux *http.ServeMux
}
func NewState(p policy.Policy, opt settings.Settings, settings policy.StateSettings) (handler http.Handler, err error) {
state := new(State)
func NewState(p policy.Policy, opt settings.Settings, settings policy.StateSettings) (state *State, err error) {
state = new(State)
state.close = make(chan struct{})
state.settings = settings
state.opt = opt
@@ -99,97 +102,106 @@ func NewState(p policy.Policy, opt settings.Settings, settings policy.StateSetti
}
}
if templates["challenge-"+state.Options().ChallengeTemplate+".gohtml"] == nil {
fp := sha256.Sum256(state.privateKey)
state.privateKeyFingerprint = fp[:]
if data, err := os.ReadFile(state.Options().ChallengeTemplate); err == nil && len(data) > 0 {
name := path.Base(state.Options().ChallengeTemplate)
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)
err := initTemplate(name, string(data))
if err != nil {
return nil, fmt.Errorf("error loading template %s: %w", state.Options().ChallengeTemplate, err)
return nil, fmt.Errorf("error loading template %s: %w", state.opt.ChallengeTemplate, err)
}
state.opt.ChallengeTemplate = name
}
return nil, fmt.Errorf("no template defined for %s", state.Options().ChallengeTemplate)
return nil, fmt.Errorf("no template defined for %s", state.opt.ChallengeTemplate)
}
state.networks = make(map[string]cidranger.Ranger)
state.networks = make(map[string]func() cidranger.Ranger)
networkCache := utils.CachePrefix(state.Settings().Cache, "networks/")
for k, network := range p.Networks {
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
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)
}
cacheKey := fmt.Sprintf("%s-%d-", k, i)
if e.Url != nil {
slog.Debug("loading network url list", "network", k, "url", *e.Url)
useCache = true
sum := sha256.Sum256([]byte(*e.Url))
cacheKey += hex.EncodeToString(sum[:4])
} else if e.ASN != nil {
slog.Debug("loading ASN", "network", k, "asn", *e.ASN)
useCache = true
cacheKey += strconv.FormatInt(int64(*e.ASN), 10)
}
var cached []net.IPNet
if useCache && networkCache != nil {
//TODO: add randomness
cachedData, err := networkCache.Get(cacheKey, time.Hour*24)
var l []string
_ = json.Unmarshal(cachedData, &l)
for _, n := range l {
_, ipNet, err := net.ParseCIDR(n)
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 {
cached = append(cached, *ipNet)
// use
return cached, nil
}
}
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
}
}
prefixes, err := e.FetchPrefixes(state.client, state.radb)
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 len(cached) > 0 {
// use cached meanwhile
return cached, err
if e.Url != nil {
slog.Error("error loading network list", "network", k, "url", *e.Url, "error", err)
} else if e.ASN != nil {
slog.Error("error loading ASN", "network", k, "asn", *e.ASN, "error", err)
} else {
slog.Error("error loading list", "network", k, "error", err)
}
return nil, err
continue
}
if useCache && networkCache != nil {
var l []string
for _, n := range prefixes {
l = append(l, n.String())
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)
}
cachedData, err := json.Marshal(l)
if err == nil {
_ = networkCache.Set(cacheKey, cachedData)
}
}
return prefixes, nil
}()
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)
}
}
if err != nil {
slog.Error("error loading network list", "network", k, "url", *e.Url, "error", err)
continue
}
}
slog.Warn("loaded network prefixes", "network", k, "count", ranger.Len())
state.networks[k] = ranger
slog.Warn("loaded network prefixes", "network", k, "count", ranger.Len())
return ranger
})
}
err = state.initConditions()
@@ -204,6 +216,12 @@ 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)
@@ -258,3 +276,21 @@ func NewState(p policy.Policy, opt settings.Settings, settings policy.StateSetti
return state, nil
}
func (state *State) Close() error {
select {
case <-state.close:
default:
close(state.close)
for _, c := range state.challenges {
if c.Object != nil {
err := c.Object.Close()
if err != nil {
return err
}
}
}
}
return nil
}

View File

@@ -52,6 +52,28 @@ 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)
@@ -59,9 +81,9 @@ func (state *State) ChallengePage(w http.ResponseWriter, r *http.Request, status
input["Random"] = utils.CacheBust()
input["Path"] = state.UrlPath()
input["Links"] = state.Options().Links
input["Strings"] = state.Options().Strings
for k, v := range state.Options().ChallengeTemplateOverrides {
input["Links"] = state.opt.Links
input["Strings"] = state.opt.Strings
for k, v := range state.opt.ChallengeTemplateOverrides {
input[k] = v
}
@@ -72,33 +94,20 @@ func (state *State) ChallengePage(w http.ResponseWriter, r *http.Request, status
maps.Copy(input, params)
if _, ok := input["Title"]; !ok {
input["Title"] = state.Options().Strings.Get("title_challenge")
input["Title"] = state.opt.Strings.Get("title_challenge")
}
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
}
}
state.addCachedTags(data, r, input)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
buf := bytes.NewBuffer(make([]byte, 0, 8192))
err := templates["challenge-"+state.Options().ChallengeTemplate+".gohtml"].Execute(buf, input)
err := templates["challenge-"+state.opt.ChallengeTemplate+".gohtml"].Execute(buf, input)
if err != nil {
state.ErrorPage(w, r, http.StatusInternalServerError, err, "")
} else {
data.ResponseHeaders(w)
w.WriteHeader(status)
_, _ = w.Write(buf.Bytes())
}
@@ -116,37 +125,24 @@ func (state *State) ErrorPage(w http.ResponseWriter, r *http.Request, status int
"Error": err.Error(),
"Path": state.UrlPath(),
"Theme": "",
"Title": template.HTML(string(state.Options().Strings.Get("title_error")) + " " + http.StatusText(status)),
"Title": template.HTML(string(state.opt.Strings.Get("title_error")) + " " + http.StatusText(status)),
"Challenge": "",
"Redirect": redirect,
"Links": state.Options().Links,
"Strings": state.Options().Strings,
"Links": state.opt.Links,
"Strings": state.opt.Strings,
}
for k, v := range state.Options().ChallengeTemplateOverrides {
for k, v := range state.opt.ChallengeTemplateOverrides {
input[k] = v
}
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)
state.addCachedTags(data, r, 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)
err2 := templates["challenge-"+state.opt.ChallengeTemplate+".gohtml"].Execute(buf, input)
if err2 != nil {
// nested errors!
panic(err2)
} else {
data.ResponseHeaders(w)
w.WriteHeader(status)
_, _ = w.Write(buf.Bytes())
}

View File

@@ -6,7 +6,7 @@ import (
"time"
)
var CookiePrefix = ".go-away-"
var DefaultCookiePrefix = ".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

View File

@@ -13,6 +13,7 @@ import (
"net/netip"
"net/url"
"strings"
"time"
)
func NewServer(handler http.Handler, tlsConfig *tls.Config) *http.Server {
@@ -69,7 +70,7 @@ func EnsureNoOpenRedirect(redirect string) (string, error) {
return uri.String(), nil
}
func MakeReverseProxy(target string, goDns bool) (*httputil.ReverseProxy, error) {
func MakeReverseProxy(target string, goDns bool, dialTimeout time.Duration) (*httputil.ReverseProxy, error) {
u, err := url.Parse(target)
if err != nil {
return nil, fmt.Errorf("failed to parse target URL: %w", err)
@@ -85,7 +86,9 @@ func MakeReverseProxy(target string, goDns bool) (*httputil.ReverseProxy, error)
u.Path = ""
// tell transport how to dial unix sockets
transport.DialContext = func(ctx context.Context, _, _ string) (net.Conn, error) {
dialer := net.Dialer{}
dialer := net.Dialer{
Timeout: dialTimeout,
}
return dialer.DialContext(ctx, "unix", addr)
}
// tell transport how to handle the unix url scheme
@@ -95,6 +98,12 @@ func MakeReverseProxy(target string, goDns bool) (*httputil.ReverseProxy, error)
Resolver: &net.Resolver{
PreferGo: true,
},
Timeout: dialTimeout,
}
transport.DialContext = dialer.DialContext
} else {
dialer := &net.Dialer{
Timeout: dialTimeout,
}
transport.DialContext = dialer.DialContext
}

26
utils/strings.go Normal file
View File

@@ -0,0 +1,26 @@
package utils
import (
"html/template"
"maps"
)
type Strings map[string]string
func (s Strings) set(v map[string]string) Strings {
maps.Copy(s, v)
return s
}
func (s Strings) Get(value string) template.HTML {
v, ok := (s)[value]
if !ok {
// fallback
return template.HTML("string:" + value)
}
return template.HTML(v)
}
func NewStrings[T ~map[string]string](v T) Strings {
return make(Strings).set(v)
}

View File

@@ -6,9 +6,10 @@ import (
"net/http"
"net/http/httptest"
"net/url"
"slices"
)
func FetchTags(backend http.Handler, uri *url.URL, kind string) (result []html.Node) {
func FetchTags(backend http.Handler, uri *url.URL, kinds ...string) (result []html.Node) {
writer := httptest.NewRecorder()
backend.ServeHTTP(writer, &http.Request{
Method: http.MethodGet,
@@ -39,7 +40,7 @@ func FetchTags(backend http.Handler, uri *url.URL, kind string) (result []html.N
}
for n := range node.Descendants() {
if n.Type == html.ElementNode && n.Data == kind {
if n.Type == html.ElementNode && slices.Contains(kinds, n.Data) {
result = append(result, html.Node{
Type: n.Type,
DataAtom: n.DataAtom,