Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87c2845952 | ||
|
|
7829eece77 | ||
|
|
0da12cfdab | ||
|
|
3060188f44 | ||
|
|
031a8c5482 | ||
|
|
2eee5b20c2 | ||
|
|
4744048a38 | ||
|
|
ca4101df7c | ||
|
|
527f1342e8 | ||
|
|
15472b00b8 | ||
|
|
ce111f6ae9 | ||
|
|
285090c9c1 | ||
|
|
153870acc0 | ||
|
|
574cf71156 | ||
|
|
8c815ed808 | ||
|
|
6948027b57 | ||
|
|
713db376b5 | ||
|
|
186904e020 | ||
|
|
f80d6ebd15 | ||
|
|
10dc2a0177 | ||
|
|
baf9df9f0a | ||
|
|
b0ab78ef65 | ||
|
|
f272a5ae72 | ||
|
|
2ce9709667 | ||
|
|
d2513d2bab | ||
|
|
04e102cb74 | ||
|
|
7be88ca6af | ||
|
|
b3f471bb30 | ||
|
|
1144424eff |
@@ -51,7 +51,7 @@ local Build(go, alpine, os, arch) = {
|
||||
]
|
||||
};
|
||||
|
||||
local Publish(go, alpine, os, arch, platforms) = {
|
||||
local Publish(go, alpine, os, arch, trigger, platforms, extra) = {
|
||||
kind: "pipeline",
|
||||
type: "docker",
|
||||
name: "publish-" + go + "-alpine" + alpine,
|
||||
@@ -59,19 +59,18 @@ local Publish(go, alpine, os, arch, platforms) = {
|
||||
os: os,
|
||||
arch: arch,
|
||||
},
|
||||
trigger: {
|
||||
event: ["promote"],
|
||||
target: ["production"],
|
||||
},
|
||||
trigger: trigger,
|
||||
steps: [
|
||||
{
|
||||
name: "docker",
|
||||
image: "plugins/buildx",
|
||||
privileged: true,
|
||||
environment: {
|
||||
DOCKER_BUILDKIT: "1"
|
||||
},
|
||||
settings: {
|
||||
registry: "git.gammaspectra.live",
|
||||
repo: "git.gammaspectra.live/git/go-away",
|
||||
squash: true,
|
||||
compress: true,
|
||||
platform: platforms,
|
||||
builder_driver: "docker-container",
|
||||
@@ -79,7 +78,6 @@ local Publish(go, alpine, os, arch, platforms) = {
|
||||
from_builder: "golang:" + go +"-alpine" + alpine,
|
||||
from: "alpine:" + alpine,
|
||||
},
|
||||
auto_tag: true,
|
||||
auto_tag_suffix: "alpine" + alpine,
|
||||
username: {
|
||||
from_secret: "git_username",
|
||||
@@ -87,7 +85,7 @@ local Publish(go, alpine, os, arch, platforms) = {
|
||||
password: {
|
||||
from_secret: "git_password",
|
||||
},
|
||||
}
|
||||
} + extra,
|
||||
},
|
||||
]
|
||||
};
|
||||
@@ -100,6 +98,9 @@ local Publish(go, alpine, os, arch, platforms) = {
|
||||
Build("1.24", "3.21", "linux", "amd64"),
|
||||
Build("1.24", "3.21", "linux", "arm64"),
|
||||
|
||||
Publish("1.24", "3.21", "linux", "amd64", ["linux/amd64", "linux/arm64"]),
|
||||
Publish("1.22", "3.20", "linux", "amd64", ["linux/amd64", "linux/arm64"]),
|
||||
# latest
|
||||
Publish("1.24", "3.21", "linux", "amd64", {event: ["push"], branch: ["master"], }, ["linux/amd64", "linux/arm64"], {tags: ["latest"],}) + {name: "publish-latest"},
|
||||
Publish("1.24", "3.21", "linux", "amd64", {event: ["promote", "tag"], target: ["production"], }, ["linux/amd64", "linux/arm64"], {auto_tag: true,}),
|
||||
# legacy
|
||||
Publish("1.22", "3.20", "linux", "amd64", {event: ["promote", "tag"], target: ["production"], }, ["linux/amd64", "linux/arm64"], {auto_tag: true,}),
|
||||
]
|
||||
50
.drone.yml
50
.drone.yml
@@ -160,12 +160,50 @@ steps:
|
||||
type: docker
|
||||
---
|
||||
kind: pipeline
|
||||
name: publish-latest
|
||||
platform:
|
||||
arch: amd64
|
||||
os: linux
|
||||
steps:
|
||||
- environment:
|
||||
DOCKER_BUILDKIT: "1"
|
||||
image: plugins/buildx
|
||||
name: docker
|
||||
privileged: true
|
||||
settings:
|
||||
auto_tag_suffix: alpine3.21
|
||||
build_args:
|
||||
from: alpine:3.21
|
||||
from_builder: golang:1.24-alpine3.21
|
||||
builder_driver: docker-container
|
||||
compress: true
|
||||
password:
|
||||
from_secret: git_password
|
||||
platform:
|
||||
- linux/amd64
|
||||
- linux/arm64
|
||||
registry: git.gammaspectra.live
|
||||
repo: git.gammaspectra.live/git/go-away
|
||||
tags:
|
||||
- latest
|
||||
username:
|
||||
from_secret: git_username
|
||||
trigger:
|
||||
branch:
|
||||
- master
|
||||
event:
|
||||
- push
|
||||
type: docker
|
||||
---
|
||||
kind: pipeline
|
||||
name: publish-1.24-alpine3.21
|
||||
platform:
|
||||
arch: amd64
|
||||
os: linux
|
||||
steps:
|
||||
- image: plugins/buildx
|
||||
- environment:
|
||||
DOCKER_BUILDKIT: "1"
|
||||
image: plugins/buildx
|
||||
name: docker
|
||||
privileged: true
|
||||
settings:
|
||||
@@ -183,12 +221,12 @@ steps:
|
||||
- linux/arm64
|
||||
registry: git.gammaspectra.live
|
||||
repo: git.gammaspectra.live/git/go-away
|
||||
squash: true
|
||||
username:
|
||||
from_secret: git_username
|
||||
trigger:
|
||||
event:
|
||||
- promote
|
||||
- tag
|
||||
target:
|
||||
- production
|
||||
type: docker
|
||||
@@ -199,7 +237,9 @@ platform:
|
||||
arch: amd64
|
||||
os: linux
|
||||
steps:
|
||||
- image: plugins/buildx
|
||||
- environment:
|
||||
DOCKER_BUILDKIT: "1"
|
||||
image: plugins/buildx
|
||||
name: docker
|
||||
privileged: true
|
||||
settings:
|
||||
@@ -217,17 +257,17 @@ steps:
|
||||
- linux/arm64
|
||||
registry: git.gammaspectra.live
|
||||
repo: git.gammaspectra.live/git/go-away
|
||||
squash: true
|
||||
username:
|
||||
from_secret: git_username
|
||||
trigger:
|
||||
event:
|
||||
- promote
|
||||
- tag
|
||||
target:
|
||||
- production
|
||||
type: docker
|
||||
---
|
||||
kind: signature
|
||||
hmac: b24b30bb7ac591a385173daeb0edbcd9119918ee9e38be90d232f83b8b767115
|
||||
hmac: 3cbd114d368c7bd348105921d85c703db1c1bc46de79f00daabbca23ffac6050
|
||||
|
||||
...
|
||||
|
||||
23
Dockerfile
23
Dockerfile
@@ -1,13 +1,13 @@
|
||||
ARG from_builder=golang:1.24-alpine3.21
|
||||
ARG from=alpine:3.21
|
||||
|
||||
ARG TARGETPLATFORM
|
||||
ARG TARGETARCH
|
||||
ARG TARGETOS
|
||||
ARG BUILDPLATFORM
|
||||
|
||||
FROM --platform=$BUILDPLATFORM ${from_builder} AS build
|
||||
|
||||
ARG TARGETARCH
|
||||
ARG TARGETOS
|
||||
|
||||
RUN apk update && apk add --no-cache \
|
||||
bash \
|
||||
git \
|
||||
@@ -41,16 +41,23 @@ ENV GOAWAY_CHALLENGE_TEMPLATE="anubis"
|
||||
ENV GOAWAY_CHALLENGE_TEMPLATE_THEME=""
|
||||
ENV GOAWAY_SLOG_LEVEL="WARN"
|
||||
ENV GOAWAY_CLIENT_IP_HEADER=""
|
||||
ENV GOAWAY_BACKEND_IP_HEADER=""
|
||||
ENV GOAWAY_JWT_PRIVATE_KEY_SEED=""
|
||||
ENV GOAWAY_BACKEND=""
|
||||
ENV GOAWAY_DNSBL="dnsbl.dronebl.org"
|
||||
ENV GOAWAY_ACME_AUTOCERT=""
|
||||
ENV GOAWAY_CACHE="/cache"
|
||||
|
||||
EXPOSE 8080/tcp
|
||||
EXPOSE 8080/udp
|
||||
|
||||
ENV JWT_PRIVATE_KEY_SEED="${GOAWAY_JWT_PRIVATE_KEY_SEED}"
|
||||
|
||||
ENTRYPOINT /bin/go-away --bind ${GOAWAY_BIND} --bind-network ${GOAWAY_BIND_NETWORK} --socket-mode ${GOAWAY_SOCKET_MODE} \
|
||||
--policy ${GOAWAY_POLICY} --client-ip-header ${GOAWAY_CLIENT_IP_HEADER} \
|
||||
--challenge-template ${GOAWAY_CHALLENGE_TEMPLATE} --challenge-template-theme ${GOAWAY_CHALLENGE_TEMPLATE_THEME} \
|
||||
--slog-level ${GOAWAY_SLOG_LEVEL} \
|
||||
--backend ${GOAWAY_BACKEND}
|
||||
ENTRYPOINT /bin/go-away --bind "${GOAWAY_BIND}" --bind-network "${GOAWAY_BIND_NETWORK}" --socket-mode "${GOAWAY_SOCKET_MODE}" \
|
||||
--policy ${GOAWAY_POLICY} --client-ip-header "${GOAWAY_CLIENT_IP_HEADER}" --backend-ip-header "${GOAWAY_BACKEND_IP_HEADER}" \
|
||||
--cache "${GOAWAY_CACHE}" \
|
||||
--dnsbl "${GOAWAY_DNSBL}" \
|
||||
--challenge-template "${GOAWAY_CHALLENGE_TEMPLATE}" --challenge-template-theme "${GOAWAY_CHALLENGE_TEMPLATE_THEME}" \
|
||||
--slog-level "${GOAWAY_SLOG_LEVEL}" \
|
||||
--acme-autocert "${GOAWAY_ACME_AUTOCERT}" \
|
||||
--backend "${GOAWAY_BACKEND}"
|
||||
135
README.md
Normal file
135
README.md
Normal file
@@ -0,0 +1,135 @@
|
||||
# go-away
|
||||
|
||||
Self-hosted abuse detection and rule enforcement against low-effort mass AI scraping and bots.
|
||||
|
||||
[](https://ci.gammaspectra.live/git/go-away)
|
||||
[](https://pkg.go.dev/git.gammaspectra.live/git/go-away)
|
||||
|
||||
This documentation is a work in progress. For now, see policy examples under [examples/](examples/).
|
||||
|
||||
## Setup
|
||||
|
||||
It is recommended to have another reverse proxy above (for example [Caddy](https://caddyserver.com/), nginx, HAProxy) to handle HTTPs or similar.
|
||||
|
||||
go-away for now only accepts plaintext connections, although it can take _HTTP/2_ / _h2c_ connections if desired over the same port.
|
||||
|
||||
### Binary / Go
|
||||
|
||||
Requires Go 1.22+. Builds statically without CGo
|
||||
|
||||
```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
|
||||
|
||||
```yaml
|
||||
services:
|
||||
go-away:
|
||||
image: git.gammaspectra.live/git/go-away:latest
|
||||
restart: always
|
||||
ports:
|
||||
- "3000:8080"
|
||||
networks:
|
||||
- forgejo
|
||||
depends_on:
|
||||
- forgejo
|
||||
volumes:
|
||||
- "./examples/forgejo.yml:/policy.yml:ro"
|
||||
environment:
|
||||
#GOAWAY_BIND: ":8080"
|
||||
#GOAWAY_BIND_NETWORK: "tcp"
|
||||
#GOAWAY_SOCKET_MODE: "0770"
|
||||
|
||||
# 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
|
||||
GOAWAY_CLIENT_IP_HEADER: "X-Real-Ip"
|
||||
|
||||
GOAWAY_POLICY: "/policy.yml"
|
||||
|
||||
# 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
|
||||
|
||||
# specify a DNSBL for usage in conditions. Defaults to DroneBL
|
||||
# GOAWAY_DNSBL: "dnsbl.dronebl.org"
|
||||
|
||||
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"]
|
||||
|
||||
```
|
||||
|
||||
|
||||
## Example policies
|
||||
|
||||
### Forgejo
|
||||
|
||||
The policy file at [examples/forgejo.yml](examples/forgejo.yml) provides a ready template to be used on your own Forgejo instance.
|
||||
|
||||
Important notes:
|
||||
* Edit the `homesite` rule, as it's targeted to common users or orgs on the instance. A better regex might be possible in the future.
|
||||
* Edit the `http-cookie-check` challenge, as this will fetch the listed backend with the given session cookie to check for user login.
|
||||
* Adjust the desired blocked networks or others. A template list of network ranges is provided, feel free to remove these if not needed.
|
||||
* Check the conditions and base rules to change your challenges offered and other ordering.
|
||||
|
||||
|
||||
## Development
|
||||
|
||||
### 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/interface.go for a definition
|
||||
func MakeChallenge(in Allocation[MakeChallengeInput]) Allocation[MakeChallengeOutput]
|
||||
|
||||
// VerifyChallenge VerifyChallengeInput is valid JSON.
|
||||
// See lib/challenge/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
|
||||
@@ -11,6 +11,11 @@ import (
|
||||
"git.gammaspectra.live/git/go-away/lib"
|
||||
"git.gammaspectra.live/git/go-away/lib/policy"
|
||||
"git.gammaspectra.live/git/go-away/utils"
|
||||
"github.com/pires/go-proxyproto"
|
||||
"golang.org/x/crypto/acme"
|
||||
"golang.org/x/crypto/acme/autocert"
|
||||
"golang.org/x/net/http2"
|
||||
"golang.org/x/net/http2/h2c"
|
||||
"gopkg.in/yaml.v3"
|
||||
"log"
|
||||
"log/slog"
|
||||
@@ -18,6 +23,7 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"runtime/debug"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -25,7 +31,12 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
func setupListener(network, address, socketMode string) (net.Listener, string) {
|
||||
func setupListener(network, address, socketMode string, proxy bool) (net.Listener, string) {
|
||||
if network == "proxy" {
|
||||
network = "tcp"
|
||||
proxy = true
|
||||
}
|
||||
|
||||
formattedAddress := ""
|
||||
switch network {
|
||||
case "unix":
|
||||
@@ -56,6 +67,14 @@ func setupListener(network, address, socketMode string) (net.Listener, string) {
|
||||
}
|
||||
}
|
||||
|
||||
if proxy {
|
||||
slog.Warn("listener PROXY enabled")
|
||||
formattedAddress += " +PROXY"
|
||||
listener = &proxyproto.Listener{
|
||||
Listener: listener,
|
||||
}
|
||||
}
|
||||
|
||||
return listener, formattedAddress
|
||||
}
|
||||
|
||||
@@ -79,16 +98,66 @@ func (v *MultiVar) Set(value string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func newServer(handler http.Handler, manager *autocert.Manager) *http.Server {
|
||||
|
||||
if manager == nil {
|
||||
h2s := &http2.Server{}
|
||||
|
||||
// TODO: use Go 1.24 Server.Protocols to add H2C
|
||||
// https://pkg.go.dev/net/http#Server.Protocols
|
||||
h1s := &http.Server{
|
||||
Handler: h2c.NewHandler(handler, h2s),
|
||||
}
|
||||
|
||||
return h1s
|
||||
} else {
|
||||
return &http.Server{
|
||||
TLSConfig: manager.TLSConfig(),
|
||||
Handler: handler,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func newACMEManager(clientDirectory string, backends map[string]http.Handler) *autocert.Manager {
|
||||
|
||||
var domains []string
|
||||
for d := range backends {
|
||||
parts := strings.Split(d, ":")
|
||||
d = parts[0]
|
||||
if net.ParseIP(d) != nil {
|
||||
continue
|
||||
}
|
||||
domains = append(domains, d)
|
||||
}
|
||||
|
||||
manager := &autocert.Manager{
|
||||
Prompt: autocert.AcceptTOS,
|
||||
HostPolicy: autocert.HostWhitelist(domains...),
|
||||
Client: &acme.Client{
|
||||
HTTPClient: http.DefaultClient,
|
||||
DirectoryURL: clientDirectory,
|
||||
},
|
||||
}
|
||||
return manager
|
||||
}
|
||||
|
||||
func main() {
|
||||
bind := flag.String("bind", ":8080", "network address to bind HTTP to")
|
||||
bind := flag.String("bind", ":8080", "network address to bind HTTP/HTTP(s) to")
|
||||
bindNetwork := flag.String("bind-network", "tcp", "network family to bind HTTP to, e.g. unix, tcp")
|
||||
bindProxy := flag.Bool("bind-proxy", false, "use PROXY protocol in front of the listener")
|
||||
socketMode := flag.String("socket-mode", "0770", "socket mode (permissions) for unix domain sockets.")
|
||||
|
||||
slogLevel := flag.String("slog-level", "WARN", "logging level (see https://pkg.go.dev/log/slog#hdr-Levels)")
|
||||
debugMode := flag.Bool("debug", false, "debug mode with logs and server timings")
|
||||
passThrough := flag.Bool("passthrough", true, "passthrough mode sends all requests to matching backends until state is loaded")
|
||||
passThrough := flag.Bool("passthrough", false, "passthrough mode sends all requests to matching backends until state is loaded")
|
||||
acmeAutocert := flag.String("acme-autocert", "", "enables HTTP(s) mode and uses the provided ACME server URL or available service (available: letsencrypt)")
|
||||
|
||||
clientIpHeader := flag.String("client-ip-header", "", "Client HTTP header to fetch their IP address from (X-Real-Ip, X-Client-Ip, X-Forwarded-For, Cf-Connecting-Ip, etc.)")
|
||||
backendIpHeader := flag.String("backend-ip-header", "", "Backend HTTP header to set the client IP address from, if empty defaults to leaving Client header alone (X-Real-Ip, X-Client-Ip, X-Forwarded-For, Cf-Connecting-Ip, etc.)")
|
||||
|
||||
dnsbl := flag.String("dnsbl", "dnsbl.dronebl.org", "blocklist for DNSBL (default DroneBL)")
|
||||
|
||||
cachePath := flag.String("cache", path.Join(os.TempDir(), "go_away_cache"), "path to temporary cache directory")
|
||||
|
||||
policyFile := flag.String("policy", "", "path to policy YAML file")
|
||||
challengeTemplate := flag.String("challenge-template", "anubis", "name or path of the challenge template to use (anubis, forgejo)")
|
||||
@@ -186,6 +255,35 @@ func main() {
|
||||
createdBackends[k] = backend
|
||||
}
|
||||
|
||||
if *cachePath != "" {
|
||||
err = os.MkdirAll(*cachePath, 0755)
|
||||
if err != nil {
|
||||
log.Fatal(fmt.Errorf("failed to create cache directory: %w", err))
|
||||
}
|
||||
}
|
||||
|
||||
var acmeManager *autocert.Manager
|
||||
|
||||
if *acmeAutocert != "" {
|
||||
switch *acmeAutocert {
|
||||
case "letsencrypt":
|
||||
*acmeAutocert = "https://acme-v02.api.letsencrypt.org/directory"
|
||||
}
|
||||
|
||||
acmeManager = newACMEManager(*acmeAutocert, createdBackends)
|
||||
if *cachePath != "" {
|
||||
err = os.MkdirAll(path.Join(*cachePath, "acme"), 0755)
|
||||
if err != nil {
|
||||
log.Fatal(fmt.Errorf("failed to create acme cache directory: %w", err))
|
||||
}
|
||||
acmeManager.Cache = autocert.DirCache(path.Join(*cachePath, "acme"))
|
||||
}
|
||||
slog.Warn(
|
||||
"acme-autocert enabled",
|
||||
"directory", *acmeAutocert,
|
||||
)
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
passThroughCtx, cancelFunc := context.WithCancel(context.Background())
|
||||
@@ -196,19 +294,17 @@ func main() {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
server := http.Server{
|
||||
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
backend, ok := createdBackends[r.Host]
|
||||
if !ok {
|
||||
http.Error(w, http.StatusText(http.StatusServiceUnavailable), http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
server := newServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
backend, ok := createdBackends[r.Host]
|
||||
if !ok {
|
||||
http.Error(w, http.StatusText(http.StatusServiceUnavailable), http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
backend.ServeHTTP(w, r)
|
||||
}),
|
||||
}
|
||||
backend.ServeHTTP(w, r)
|
||||
}), acmeManager)
|
||||
|
||||
listener, listenUrl := setupListener(*bindNetwork, *bind, *socketMode)
|
||||
listener, listenUrl := setupListener(*bindNetwork, *bind, *socketMode, *bindProxy)
|
||||
slog.Warn(
|
||||
"listening passthrough",
|
||||
"url", listenUrl,
|
||||
@@ -218,8 +314,15 @@ func main() {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if err := server.Serve(listener); !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Fatal(err)
|
||||
|
||||
if acmeManager != nil {
|
||||
if err := server.ServeTLS(listener, "", ""); !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Fatal(err)
|
||||
}
|
||||
} else {
|
||||
if err := server.Serve(listener); !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -228,14 +331,14 @@ func main() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
server.Close()
|
||||
if err := server.Shutdown(ctx); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
_ = server.Close()
|
||||
}()
|
||||
}
|
||||
|
||||
state, err := lib.NewState(p, lib.StateSettings{
|
||||
settings := lib.StateSettings{
|
||||
Backends: createdBackends,
|
||||
Debug: *debugMode,
|
||||
PackageName: *packageName,
|
||||
@@ -243,7 +346,14 @@ func main() {
|
||||
ChallengeTemplateTheme: *challengeTemplateTheme,
|
||||
PrivateKeySeed: seed,
|
||||
ClientIpHeader: *clientIpHeader,
|
||||
})
|
||||
BackendIpHeader: *backendIpHeader,
|
||||
}
|
||||
|
||||
if *dnsbl != "" {
|
||||
settings.DNSBL = utils.NewDNSBL(*dnsbl, net.DefaultResolver)
|
||||
}
|
||||
|
||||
state, err := lib.NewState(p, settings)
|
||||
|
||||
if err != nil {
|
||||
log.Fatal(fmt.Errorf("failed to create state: %w", err))
|
||||
@@ -253,17 +363,23 @@ func main() {
|
||||
cancelFunc()
|
||||
wg.Wait()
|
||||
|
||||
listener, listenUrl := setupListener(*bindNetwork, *bind, *socketMode)
|
||||
listener, listenUrl := setupListener(*bindNetwork, *bind, *socketMode, *bindProxy)
|
||||
slog.Warn(
|
||||
"listening",
|
||||
"url", listenUrl,
|
||||
)
|
||||
|
||||
server := http.Server{
|
||||
Handler: state,
|
||||
server := newServer(state, acmeManager)
|
||||
|
||||
if acmeManager != nil {
|
||||
if err := server.ServeTLS(listener, "", ""); !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Fatal(err)
|
||||
}
|
||||
} else {
|
||||
|
||||
if err := server.Serve(listener); !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := server.Serve(listener); !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Example cmdline (forward requests from upstream to port :8080)
|
||||
# $ go-away --bind :8080 --backend git.example.com:http://forgejo:3000 --policy examples/forgejo.yml --challenge-template forgejo --challenge-template-theme forgejo-dark
|
||||
# $ go-away --bind :8080 --backend git.example.com=http://forgejo:3000 --policy examples/forgejo.yml --challenge-template forgejo --challenge-template-theme forgejo-dark
|
||||
|
||||
|
||||
|
||||
@@ -112,7 +112,23 @@ challenges:
|
||||
self-cookie:
|
||||
mode: "cookie"
|
||||
|
||||
# Challenges with a redirect via header (non-JS, requires HTTP parsing and logic)
|
||||
|
||||
# Challenges with a redirect via Link header with rel=preload and early hints (non-JS, requires HTTP parsing, fetching and logic)
|
||||
# Works on HTTP/2 and above!
|
||||
self-preload-link:
|
||||
condition: '"Sec-Fetch-Mode" in headers && headers["Sec-Fetch-Mode"] == "navigate"'
|
||||
mode: "preload-link"
|
||||
runtime:
|
||||
# verifies that result = key
|
||||
mode: "key"
|
||||
probability: 0.1
|
||||
parameters:
|
||||
preload-early-hint-deadline: 3s
|
||||
key-code: 200
|
||||
key-mime: text/css
|
||||
key-content: ""
|
||||
|
||||
# Challenges with a redirect via Refresh header (non-JS, requires HTTP parsing and logic)
|
||||
self-header-refresh:
|
||||
mode: "header-refresh"
|
||||
runtime:
|
||||
@@ -120,7 +136,7 @@ challenges:
|
||||
mode: "key"
|
||||
probability: 0.1
|
||||
|
||||
# Challenges with a redirect via meta (non-JS, requires HTML parsing and logic)
|
||||
# Challenges with a redirect via Refresh meta (non-JS, requires HTML parsing and logic)
|
||||
self-meta-refresh:
|
||||
mode: "meta-refresh"
|
||||
runtime:
|
||||
@@ -186,6 +202,7 @@ conditions:
|
||||
# Golang proxy and initial fetch
|
||||
- 'userAgent.startsWith("GoModuleMirror/")'
|
||||
- 'userAgent.startsWith("Go-http-client/") && "go-get" in query && query["go-get"] == "1"'
|
||||
- '"Git-Protocol" in headers && headers["Git-Protocol"] == "version=2"'
|
||||
is-git-path:
|
||||
- 'path.matches("^/[^/]+/[^/]+/(git-upload-pack|git-receive-pack|HEAD|info/refs|info/lfs|objects)")'
|
||||
|
||||
@@ -215,7 +232,7 @@ conditions:
|
||||
# Old IE browsers
|
||||
- 'userAgent.matches("MSIE ([2-9]|10|11)\\.")'
|
||||
# Old Linux browsers
|
||||
- 'userAgent.contains("Linux i686")'
|
||||
- 'userAgent.contains("Linux i[63]86") || userAgent.contains("FreeBSD i[63]86")'
|
||||
# Old Windows browsers
|
||||
- 'userAgent.matches("Windows (3|95|98|CE)") || userAgent.matches("Windows NT [1-5]\\.")'
|
||||
# Old mobile browsers
|
||||
@@ -299,8 +316,12 @@ rules:
|
||||
- name: suspicious-crawlers/1
|
||||
conditions: ['($is-suspicious-crawler)']
|
||||
action: check
|
||||
challenges: [self-header-refresh]
|
||||
challenges: [self-preload-link]
|
||||
- name: suspicious-crawlers/2
|
||||
conditions: ['($is-suspicious-crawler)']
|
||||
action: check
|
||||
challenges: [self-header-refresh]
|
||||
- name: suspicious-crawlers/3
|
||||
conditions: ['($is-suspicious-crawler)']
|
||||
action: check
|
||||
challenges: [self-resource-load]
|
||||
@@ -312,8 +333,10 @@ rules:
|
||||
|
||||
- name: always-pow-challenge
|
||||
conditions:
|
||||
# login paths
|
||||
- 'path.startsWith("/user/sign_up") || path.startsWith("/user/login") || path.startsWith("/user/oauth2/")'
|
||||
# login and sign up paths
|
||||
- 'path.startsWith("/user/sign_up")'
|
||||
- 'path.startsWith("/user/login") || path.startsWith("/user/oauth2/")'
|
||||
- 'path.startsWith("/user/activate")'
|
||||
# repo / org / mirror creation paths
|
||||
- 'path == "/repo/create" || path == "/repo/migrate" || path == "/org/create"'
|
||||
# user profile info edit paths
|
||||
@@ -371,7 +394,7 @@ rules:
|
||||
- name: desired-crawlers
|
||||
conditions:
|
||||
- 'userAgent.contains("+https://kagi.com/bot") && inNetwork("kagibot", remoteAddress)'
|
||||
- '(userAgent.contains("+http://www.google.com/bot.html") || userAgent.contains("Google-InspectionTool")) && inNetwork("googlebot", remoteAddress)'
|
||||
- '(userAgent.contains("+http://www.google.com/bot.html") || userAgent.contains("Google-InspectionTool") || userAgent.contains("Googlebot")) && inNetwork("googlebot", remoteAddress)'
|
||||
- 'userAgent.contains("+http://www.bing.com/bingbot.htm") && inNetwork("bingbot", remoteAddress)'
|
||||
- 'userAgent.contains("+http://duckduckgo.com/duckduckbot.html") && inNetwork("duckduckbot", remoteAddress)'
|
||||
- 'userAgent.contains("+https://help.qwant.com/bot/") && inNetwork("qwantbot", remoteAddress)'
|
||||
@@ -387,16 +410,10 @@ rules:
|
||||
- 'path.matches("(?i)^/(WeebDataHoarder|P2Pool|mirror|git|S\\.O\\.N\\.G|FM10K|Sillycom|pwgen2155|kaitou|metonym)/[^/]+$")'
|
||||
action: pass
|
||||
|
||||
- name: suspicious-fetchers
|
||||
action: challenge
|
||||
challenges: [js-pow-sha256, http-cookie-check]
|
||||
conditions:
|
||||
- 'userAgent.contains("facebookexternalhit/") || userAgent.contains("facebookcatalog/")'
|
||||
|
||||
# check a sequence of challenges
|
||||
- name: heavy-operations/0
|
||||
action: check
|
||||
challenges: [self-header-refresh, js-pow-sha256, http-cookie-check]
|
||||
challenges: [self-preload-link, self-header-refresh, js-pow-sha256, http-cookie-check]
|
||||
conditions: ['($is-heavy-resource)']
|
||||
- name: heavy-operations/1
|
||||
action: check
|
||||
@@ -409,10 +426,23 @@ rules:
|
||||
conditions:
|
||||
- 'path.matches("^/[^/]+/[^/]+/raw/branch/")'
|
||||
- 'path.matches("^/[^/]+/[^/]+/archive/")'
|
||||
- 'path.matches("^/[^/]+/[^/]+/media/")'
|
||||
- 'path.matches("^/[^/]+/[^/]+/releases/download/")'
|
||||
- 'path.matches("^/[^/]+/[^/]+/media/") && ($is-generic-browser)'
|
||||
action: pass
|
||||
|
||||
# check DNSBL and serve harder challenges
|
||||
- name: undesired-dnsbl
|
||||
conditions:
|
||||
- 'inDNSBL(remoteAddress)'
|
||||
action: check
|
||||
challenges: [js-pow-sha256, http-cookie-check]
|
||||
|
||||
- name: suspicious-fetchers
|
||||
action: check
|
||||
challenges: [js-pow-sha256]
|
||||
conditions:
|
||||
- 'userAgent.contains("facebookexternalhit/") || userAgent.contains("facebookcatalog/")'
|
||||
|
||||
# Allow PUT/DELETE/PATCH/POST requests in general
|
||||
- name: non-get-request
|
||||
action: pass
|
||||
@@ -420,16 +450,16 @@ rules:
|
||||
- '!(method == "HEAD" || method == "GET")'
|
||||
|
||||
|
||||
|
||||
- name: standard-tools
|
||||
action: challenge
|
||||
challenges: [self-meta-refresh]
|
||||
challenges: [self-cookie]
|
||||
conditions:
|
||||
- '($is-generic-robot-ua)'
|
||||
- '($is-tool-ua)'
|
||||
- '!($is-generic-browser)'
|
||||
|
||||
- name: standard-browser
|
||||
action: challenge
|
||||
challenges: [http-cookie-check, self-meta-refresh, self-resource-load, js-pow-sha256]
|
||||
challenges: [http-cookie-check, self-preload-link, self-meta-refresh, self-resource-load, js-pow-sha256]
|
||||
conditions:
|
||||
- '($is-generic-browser)'
|
||||
|
||||
12
go.mod
12
go.mod
@@ -11,8 +11,11 @@ require (
|
||||
github.com/google/cel-go v0.24.1
|
||||
github.com/itchyny/gojq v0.12.17
|
||||
github.com/klauspost/compress v1.18.0
|
||||
github.com/pires/go-proxyproto v0.8.0
|
||||
github.com/tetratelabs/wazero v1.9.0
|
||||
github.com/yl2chen/cidranger v1.0.2
|
||||
golang.org/x/crypto v0.33.0
|
||||
golang.org/x/net v0.35.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
@@ -22,8 +25,8 @@ require (
|
||||
github.com/itchyny/timefmt-go v0.1.6 // indirect
|
||||
github.com/kevinpollet/nego v0.0.0-20211010160919-a65cd48cee43 // indirect
|
||||
github.com/stoewer/go-strcase v1.3.0 // indirect
|
||||
golang.org/x/crypto v0.33.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac // indirect
|
||||
golang.org/x/text v0.22.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20240826202546-f6391c0de4c7 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240826202546-f6391c0de4c7 // indirect
|
||||
google.golang.org/protobuf v1.36.6 // indirect
|
||||
@@ -37,7 +40,10 @@ replace golang.org/x/exp v0.0.0 => ./utils/exp
|
||||
// Pin latest versions to support Go 1.22 to prevent a package update from changing them
|
||||
// TODO: remove this when Go 1.22+ is supported by other higher users
|
||||
replace (
|
||||
github.com/go-jose/go-jose/v4 => github.com/go-jose/go-jose/v4 v4.0.5
|
||||
golang.org/x/crypto => golang.org/x/crypto v0.33.0
|
||||
golang.org/x/net => golang.org/x/net v0.35.0
|
||||
golang.org/x/text => golang.org/x/text v0.22.0
|
||||
google.golang.org/genproto/googleapis/api => google.golang.org/genproto/googleapis/api v0.0.0-20240826202546-f6391c0de4c7
|
||||
google.golang.org/genproto/googleapis/rpc => google.golang.org/genproto/googleapis/rpc v0.0.0-20240826202546-f6391c0de4c7
|
||||
golang.org/x/crypto => golang.org/x/crypto v0.33.0
|
||||
)
|
||||
)
|
||||
|
||||
4
go.sum
4
go.sum
@@ -23,6 +23,8 @@ github.com/kevinpollet/nego v0.0.0-20211010160919-a65cd48cee43 h1:Pdirg1gwhEcGjM
|
||||
github.com/kevinpollet/nego v0.0.0-20211010160919-a65cd48cee43/go.mod h1:ahLMuLCUyDdXqtqGyuwGev7/PGtO7r7ocvdwDuEN/3E=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/pires/go-proxyproto v0.8.0 h1:5unRmEAPbHXHuLjDg01CxJWf91cw3lKHc/0xzKpXEe0=
|
||||
github.com/pires/go-proxyproto v0.8.0/go.mod h1:iknsfgnH8EkjrMeMyvfKByp9TiBZCKZM0jx2xmKqnVY=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stoewer/go-strcase v1.3.0 h1:g0eASXYtp+yvN9fK8sH94oCIk0fau9uV1/ZdJ0AVEzs=
|
||||
@@ -46,6 +48,8 @@ golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
|
||||
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
|
||||
golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac h1:l5+whBCLH3iH2ZNHYLbAe58bo7yrN4mVcnkHDYz5vvs=
|
||||
golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac/go.mod h1:hH+7mtFmImwwcMvScyxUhjuVHR3HGaDPMn9rMSUUbxo=
|
||||
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
||||
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
||||
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20240826202546-f6391c0de4c7 h1:YcyjlL1PRr2Q17/I0dPk2JmYS5CDXfcdb2Z3YRioEbw=
|
||||
|
||||
@@ -9,8 +9,8 @@ github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUc
|
||||
github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
|
||||
golang.org/x/mod v0.23.0 h1:Zb7khfcRGKk+kqfxFaP5tZqCnDZMjC5VtUBs87Hr6QM=
|
||||
golang.org/x/mod v0.23.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
|
||||
golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ=
|
||||
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
|
||||
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
||||
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
||||
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
|
||||
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||
|
||||
@@ -3,6 +3,8 @@ package lib
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"github.com/go-jose/go-jose/v4/jwt"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -20,6 +22,18 @@ type ChallengeInformation struct {
|
||||
IssuedAt *jwt.NumericDate `json:"iat,omitempty"`
|
||||
}
|
||||
|
||||
func getRequestScheme(r *http.Request) string {
|
||||
if proto := r.Header.Get("X-Forwarded-Proto"); proto == "http" || proto == "https" {
|
||||
return proto
|
||||
}
|
||||
|
||||
if r.TLS != nil {
|
||||
return "https"
|
||||
}
|
||||
|
||||
return "http"
|
||||
}
|
||||
|
||||
func getRequestAddress(r *http.Request, clientHeader string) net.IP {
|
||||
var ipStr string
|
||||
if clientHeader != "" {
|
||||
@@ -36,15 +50,49 @@ func getRequestAddress(r *http.Request, clientHeader string) net.IP {
|
||||
// drop port
|
||||
ipStr = strings.Join(parts[:len(parts)-1], ":")
|
||||
}
|
||||
ipStr = strings.Trim(ipStr, "[]")
|
||||
return net.ParseIP(ipStr)
|
||||
}
|
||||
|
||||
func (state *State) GetChallengeKeyForRequest(challengeName string, until time.Time, r *http.Request) []byte {
|
||||
type ChallengeKey []byte
|
||||
|
||||
const ChallengeKeySize = sha256.Size
|
||||
|
||||
func (k *ChallengeKey) Set(flags ChallengeKeyFlags) {
|
||||
(*k)[0] |= uint8(flags)
|
||||
}
|
||||
func (k *ChallengeKey) Get(flags ChallengeKeyFlags) ChallengeKeyFlags {
|
||||
return ChallengeKeyFlags((*k)[0] & uint8(flags))
|
||||
}
|
||||
func (k *ChallengeKey) Unset(flags ChallengeKeyFlags) {
|
||||
(*k)[0] = (*k)[0] & ^(uint8(flags))
|
||||
}
|
||||
|
||||
type ChallengeKeyFlags uint8
|
||||
|
||||
const (
|
||||
ChallengeKeyFlagIsIPv4 = ChallengeKeyFlags(1 << iota)
|
||||
)
|
||||
|
||||
func ChallengeKeyFromString(s string) (ChallengeKey, error) {
|
||||
b, err := hex.DecodeString(s)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(b) != ChallengeKeySize {
|
||||
return nil, errors.New("invalid challenge key")
|
||||
}
|
||||
return ChallengeKey(b), nil
|
||||
}
|
||||
|
||||
func (state *State) GetChallengeKeyForRequest(challengeName string, until time.Time, r *http.Request) ChallengeKey {
|
||||
data := RequestDataFromContext(r.Context())
|
||||
address := data.RemoteAddress
|
||||
hasher := sha256.New()
|
||||
hasher.Write([]byte("challenge\x00"))
|
||||
hasher.Write([]byte(challengeName))
|
||||
hasher.Write([]byte{0})
|
||||
hasher.Write(getRequestAddress(r, state.Settings.ClientIpHeader).To16())
|
||||
hasher.Write(address.To16())
|
||||
hasher.Write([]byte{0})
|
||||
|
||||
// specific headers
|
||||
@@ -52,8 +100,9 @@ func (state *State) GetChallengeKeyForRequest(challengeName string, until time.T
|
||||
"Accept-Language",
|
||||
// General browser information
|
||||
"User-Agent",
|
||||
"Sec-Ch-Ua",
|
||||
"Sec-Ch-Ua-Platform",
|
||||
// TODO: not sent in preload
|
||||
//"Sec-Ch-Ua",
|
||||
//"Sec-Ch-Ua-Platform",
|
||||
} {
|
||||
hasher.Write([]byte(r.Header.Get(k)))
|
||||
hasher.Write([]byte{0})
|
||||
@@ -64,5 +113,13 @@ func (state *State) GetChallengeKeyForRequest(challengeName string, until time.T
|
||||
hasher.Write(state.publicKey)
|
||||
hasher.Write([]byte{0})
|
||||
|
||||
return hasher.Sum(nil)
|
||||
sum := ChallengeKey(hasher.Sum(nil))
|
||||
|
||||
sum[0] = 0
|
||||
|
||||
if address.To4() != nil {
|
||||
// Is IPv4, mark
|
||||
sum.Set(ChallengeKeyFlagIsIPv4)
|
||||
}
|
||||
return ChallengeKey(sum)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"git.gammaspectra.live/git/go-away/utils"
|
||||
"github.com/go-jose/go-jose/v4"
|
||||
"github.com/go-jose/go-jose/v4/jwt"
|
||||
"github.com/google/cel-go/cel"
|
||||
"math/rand/v2"
|
||||
"net/http"
|
||||
"time"
|
||||
@@ -26,9 +27,10 @@ const (
|
||||
type Id int
|
||||
|
||||
type Challenge struct {
|
||||
Id Id
|
||||
Name string
|
||||
Path string
|
||||
Id Id
|
||||
Program cel.Program
|
||||
Name string
|
||||
Path string
|
||||
|
||||
Verify func(key []byte, result string, r *http.Request) (bool, error)
|
||||
VerifyProbability float64
|
||||
@@ -86,6 +88,7 @@ type VerifyResult int
|
||||
const (
|
||||
VerifyResultNONE = VerifyResult(iota)
|
||||
VerifyResultFAIL
|
||||
VerifyResultSKIP
|
||||
|
||||
// VerifyResultPASS Client just passed this challenge
|
||||
VerifyResultPASS
|
||||
@@ -95,7 +98,7 @@ const (
|
||||
)
|
||||
|
||||
func (r VerifyResult) Ok() bool {
|
||||
return r > VerifyResultFAIL
|
||||
return r >= VerifyResultPASS
|
||||
}
|
||||
|
||||
func (r VerifyResult) String() string {
|
||||
@@ -104,6 +107,8 @@ func (r VerifyResult) String() string {
|
||||
return "NONE"
|
||||
case VerifyResultFAIL:
|
||||
return "FAIL"
|
||||
case VerifyResultSKIP:
|
||||
return "SKIP"
|
||||
case VerifyResultPASS:
|
||||
return "PASS"
|
||||
case VerifyResultOK:
|
||||
|
||||
118
lib/conditions.go
Normal file
118
lib/conditions.go
Normal file
@@ -0,0 +1,118 @@
|
||||
package lib
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/google/cel-go/cel"
|
||||
"github.com/google/cel-go/common/types"
|
||||
"github.com/google/cel-go/common/types/ref"
|
||||
"log/slog"
|
||||
"net"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (state *State) initConditions() (err error) {
|
||||
state.RulesEnv, err = cel.NewEnv(
|
||||
cel.DefaultUTCTimeZone(true),
|
||||
cel.Variable("remoteAddress", cel.BytesType),
|
||||
cel.Variable("host", cel.StringType),
|
||||
cel.Variable("method", cel.StringType),
|
||||
cel.Variable("userAgent", cel.StringType),
|
||||
cel.Variable("path", cel.StringType),
|
||||
cel.Variable("query", cel.MapType(cel.StringType, cel.StringType)),
|
||||
// http.Header
|
||||
cel.Variable("headers", cel.MapType(cel.StringType, cel.StringType)),
|
||||
//TODO: dynamic type?
|
||||
cel.Function("inDNSBL",
|
||||
cel.Overload("inDNSBL_ip",
|
||||
[]*cel.Type{cel.AnyType},
|
||||
cel.BoolType,
|
||||
cel.UnaryBinding(func(val ref.Val) ref.Val {
|
||||
if state.Settings.DNSBL == nil {
|
||||
return types.Bool(false)
|
||||
}
|
||||
|
||||
var ip net.IP
|
||||
switch v := val.Value().(type) {
|
||||
case []byte:
|
||||
ip = v
|
||||
case net.IP:
|
||||
ip = v
|
||||
case string:
|
||||
ip = net.ParseIP(v)
|
||||
}
|
||||
|
||||
if ip == nil {
|
||||
panic(fmt.Errorf("invalid ip %v", val.Value()))
|
||||
}
|
||||
|
||||
var key [net.IPv6len]byte
|
||||
copy(key[:], ip.To16())
|
||||
|
||||
result, ok := state.DecayMap.Get(key)
|
||||
if ok {
|
||||
return types.Bool(result.Bad())
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
result, err := state.Settings.DNSBL.Lookup(ctx, ip)
|
||||
if err != nil {
|
||||
slog.Debug("dnsbl lookup failed", "address", ip.String(), "result", result, "err", err)
|
||||
} else {
|
||||
slog.Debug("dnsbl lookup", "address", ip.String(), "result", result)
|
||||
}
|
||||
//TODO: configure decay
|
||||
state.DecayMap.Set(key, result, time.Hour)
|
||||
|
||||
return types.Bool(result.Bad())
|
||||
}),
|
||||
),
|
||||
),
|
||||
cel.Function("inNetwork",
|
||||
cel.Overload("inNetwork_string_ip",
|
||||
[]*cel.Type{cel.StringType, cel.AnyType},
|
||||
cel.BoolType,
|
||||
cel.BinaryBinding(func(lhs ref.Val, rhs ref.Val) ref.Val {
|
||||
var ip net.IP
|
||||
switch v := rhs.Value().(type) {
|
||||
case []byte:
|
||||
ip = v
|
||||
case net.IP:
|
||||
ip = v
|
||||
case string:
|
||||
ip = net.ParseIP(v)
|
||||
}
|
||||
|
||||
if ip == nil {
|
||||
panic(fmt.Errorf("invalid ip %v", rhs.Value()))
|
||||
}
|
||||
|
||||
val, ok := lhs.Value().(string)
|
||||
if !ok {
|
||||
panic(fmt.Errorf("invalid value %v", lhs.Value()))
|
||||
}
|
||||
|
||||
network, ok := state.Networks[val]
|
||||
if !ok {
|
||||
_, ipNet, err := net.ParseCIDR(val)
|
||||
if err != nil {
|
||||
panic("network not found")
|
||||
}
|
||||
return types.Bool(ipNet.Contains(ip))
|
||||
} else {
|
||||
ok, err := network.Contains(ip)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return types.Bool(ok)
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
111
lib/http.go
111
lib/http.go
@@ -18,7 +18,9 @@ import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/pprof"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
@@ -129,10 +131,11 @@ func (state *State) addTiming(w http.ResponseWriter, name, desc string, duration
|
||||
}
|
||||
}
|
||||
|
||||
func GetLoggerForRequest(r *http.Request, clientHeader string) *slog.Logger {
|
||||
func GetLoggerForRequest(r *http.Request) *slog.Logger {
|
||||
data := RequestDataFromContext(r.Context())
|
||||
return slog.With(
|
||||
"request_id", r.Header.Get("X-Away-Id"),
|
||||
"remote_address", getRequestAddress(r, clientHeader),
|
||||
"request_id", hex.EncodeToString(data.Id[:]),
|
||||
"remote_address", data.RemoteAddress.String(),
|
||||
"user_agent", r.UserAgent(),
|
||||
"host", r.Host,
|
||||
"path", r.URL.Path,
|
||||
@@ -141,7 +144,7 @@ func GetLoggerForRequest(r *http.Request, clientHeader string) *slog.Logger {
|
||||
}
|
||||
|
||||
func (state *State) logger(r *http.Request) *slog.Logger {
|
||||
return GetLoggerForRequest(r, state.Settings.ClientIpHeader)
|
||||
return GetLoggerForRequest(r)
|
||||
}
|
||||
|
||||
func (state *State) handleRequest(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -159,29 +162,6 @@ func (state *State) handleRequest(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
start := time.Now()
|
||||
|
||||
//TODO better matcher! combo ast?
|
||||
env := map[string]any{
|
||||
"host": host,
|
||||
"method": r.Method,
|
||||
"remoteAddress": getRequestAddress(r, state.Settings.ClientIpHeader),
|
||||
"userAgent": r.UserAgent(),
|
||||
"path": r.URL.Path,
|
||||
"query": func() map[string]string {
|
||||
result := make(map[string]string)
|
||||
for k, v := range r.URL.Query() {
|
||||
result[k] = strings.Join(v, ",")
|
||||
}
|
||||
return result
|
||||
}(),
|
||||
"headers": func() map[string]string {
|
||||
result := make(map[string]string)
|
||||
for k, v := range r.Header {
|
||||
result[k] = strings.Join(v, ",")
|
||||
}
|
||||
return result
|
||||
}(),
|
||||
}
|
||||
|
||||
state.addTiming(w, "rule-env", "Setup the rule environment", time.Since(start))
|
||||
|
||||
var (
|
||||
@@ -211,7 +191,7 @@ func (state *State) handleRequest(w http.ResponseWriter, r *http.Request) {
|
||||
continue
|
||||
}
|
||||
start = time.Now()
|
||||
out, _, err := rule.Program.Eval(env)
|
||||
out, _, err := rule.Program.Eval(data.ProgramEnv)
|
||||
ruleEvalDuration += time.Since(start)
|
||||
|
||||
if err != nil {
|
||||
@@ -230,7 +210,6 @@ func (state *State) handleRequest(w http.ResponseWriter, r *http.Request) {
|
||||
serve()
|
||||
return
|
||||
case policy.RuleActionCHALLENGE, policy.RuleActionCHECK:
|
||||
|
||||
for _, challengeId := range rule.Challenges {
|
||||
if result := data.Challenges[challengeId]; !result.Ok() {
|
||||
continue
|
||||
@@ -249,6 +228,11 @@ func (state *State) handleRequest(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// none matched, issue first challenge in priority
|
||||
for _, challengeId := range rule.Challenges {
|
||||
result := data.Challenges[challengeId]
|
||||
if result.Ok() || result == challenge.VerifyResultSKIP {
|
||||
// skip already ok'd challenges for some reason, and also skip skipped challenges
|
||||
continue
|
||||
}
|
||||
c := state.Challenges[challengeId]
|
||||
if c.ServeChallenge != nil {
|
||||
result := c.ServeChallenge(w, r, state.GetChallengeKeyForRequest(c.Name, data.Expires, r), data.Expires)
|
||||
@@ -264,7 +248,10 @@ func (state *State) handleRequest(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
state.logger(r).Warn("challenge passed", "rule", rule.Name, "rule_hash", rule.Hash, "challenge", c.Name)
|
||||
|
||||
data.Challenges[c.Id] = challenge.VerifyResultOK
|
||||
// set pass if caller didn't set one
|
||||
if !data.Challenges[c.Id].Ok() {
|
||||
data.Challenges[c.Id] = challenge.VerifyResultPASS
|
||||
}
|
||||
|
||||
// we pass the challenge early!
|
||||
lg.Debug("request passed", "rule", rule.Name, "rule_hash", rule.Hash, "challenge", c.Name)
|
||||
@@ -343,6 +330,13 @@ func (state *State) setupRoutes() error {
|
||||
|
||||
state.Mux.HandleFunc("/", state.handleRequest)
|
||||
|
||||
if state.Settings.Debug {
|
||||
http.HandleFunc(state.UrlPath+"/debug/pprof/", pprof.Index)
|
||||
http.HandleFunc(state.UrlPath+"/debug/pprof/profile", pprof.Profile)
|
||||
http.HandleFunc(state.UrlPath+"/debug/pprof/symbol", pprof.Symbol)
|
||||
http.HandleFunc(state.UrlPath+"/debug/pprof/trace", pprof.Trace)
|
||||
}
|
||||
|
||||
state.Mux.Handle("GET "+state.UrlPath+"/assets/", http.StripPrefix(state.UrlPath, gzipped.FileServer(gzipped.FS(embed.AssetsFs))))
|
||||
|
||||
for _, c := range state.Challenges {
|
||||
@@ -420,11 +414,36 @@ func (state *State) setupRoutes() error {
|
||||
}
|
||||
|
||||
func (state *State) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var data RequestData
|
||||
// generate random id, todo: is this fast?
|
||||
_, _ = rand.Read(data.Id[:])
|
||||
data.RemoteAddress = getRequestAddress(r, state.Settings.ClientIpHeader)
|
||||
data.Challenges = make(map[challenge.Id]challenge.VerifyResult, len(state.Challenges))
|
||||
data.Expires = time.Now().UTC().Add(DefaultValidity).Round(DefaultValidity)
|
||||
data.ProgramEnv = map[string]any{
|
||||
"host": r.Host,
|
||||
"method": r.Method,
|
||||
"remoteAddress": data.RemoteAddress,
|
||||
"userAgent": r.UserAgent(),
|
||||
"path": r.URL.Path,
|
||||
"query": func() map[string]string {
|
||||
result := make(map[string]string)
|
||||
for k, v := range r.URL.Query() {
|
||||
result[k] = strings.Join(v, ",")
|
||||
}
|
||||
return result
|
||||
}(),
|
||||
"headers": func() map[string]string {
|
||||
result := make(map[string]string)
|
||||
for k, v := range r.Header {
|
||||
result[k] = strings.Join(v, ",")
|
||||
}
|
||||
return result
|
||||
}(),
|
||||
}
|
||||
|
||||
r = r.WithContext(context.WithValue(r.Context(), "_goaway_data", &data))
|
||||
|
||||
for _, c := range state.Challenges {
|
||||
key := state.GetChallengeKeyForRequest(c.Name, data.Expires, r)
|
||||
@@ -433,12 +452,34 @@ func (state *State) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
// clear invalid cookie
|
||||
utils.ClearCookie(utils.CookiePrefix+c.Name, w)
|
||||
}
|
||||
|
||||
// prevent the challenge if not solved
|
||||
if !result.Ok() && c.Program != nil {
|
||||
out, _, err := c.Program.Eval(data.ProgramEnv)
|
||||
// verify eligibility
|
||||
if err != nil {
|
||||
state.logger(r).Error(err.Error(), "challenge", c.Name)
|
||||
} else if out != nil && out.Type() == types.BoolType {
|
||||
if out.Equal(types.True) != types.True {
|
||||
// skip challenge match!
|
||||
result = challenge.VerifyResultSKIP
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
data.Challenges[c.Id] = result
|
||||
}
|
||||
|
||||
r.Header.Set("X-Away-Id", hex.EncodeToString(data.Id[:]))
|
||||
if state.Settings.BackendIpHeader != "" {
|
||||
r.Header.Del(state.Settings.ClientIpHeader)
|
||||
r.Header.Set(state.Settings.BackendIpHeader, data.RemoteAddress.String())
|
||||
}
|
||||
w.Header().Add("Via", fmt.Sprintf("%s %s", r.Proto, "go-away"))
|
||||
|
||||
r = r.WithContext(context.WithValue(r.Context(), "_goaway_data", &data))
|
||||
// send these to client so we consistently get the headers
|
||||
//w.Header().Set("Accept-CH", "Sec-CH-UA, Sec-CH-UA-Platform")
|
||||
//w.Header().Set("Critical-CH", "Sec-CH-UA, Sec-CH-UA-Platform")
|
||||
|
||||
state.Mux.ServeHTTP(w, r)
|
||||
}
|
||||
@@ -448,9 +489,11 @@ func RequestDataFromContext(ctx context.Context) *RequestData {
|
||||
}
|
||||
|
||||
type RequestData struct {
|
||||
Id [16]byte
|
||||
Expires time.Time
|
||||
Challenges map[challenge.Id]challenge.VerifyResult
|
||||
Id [16]byte
|
||||
ProgramEnv map[string]any
|
||||
Expires time.Time
|
||||
Challenges map[challenge.Id]challenge.VerifyResult
|
||||
RemoteAddress net.IP
|
||||
}
|
||||
|
||||
func (d *RequestData) HasValidChallenge(id challenge.Id) bool {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
package policy
|
||||
|
||||
type Challenge struct {
|
||||
Mode string `yaml:"mode"`
|
||||
Asset *string `yaml:"asset,omitempty"`
|
||||
Url *string `yaml:"url,omitempty"`
|
||||
Conditions []string `yaml:"conditions"`
|
||||
Mode string `yaml:"mode"`
|
||||
Asset *string `yaml:"asset,omitempty"`
|
||||
Url *string `yaml:"url,omitempty"`
|
||||
|
||||
Parameters map[string]string `json:"parameters,omitempty"`
|
||||
Runtime struct {
|
||||
|
||||
290
lib/state.go
290
lib/state.go
@@ -20,8 +20,6 @@ import (
|
||||
"git.gammaspectra.live/git/go-away/utils"
|
||||
"git.gammaspectra.live/git/go-away/utils/inline"
|
||||
"github.com/google/cel-go/cel"
|
||||
"github.com/google/cel-go/common/types"
|
||||
"github.com/google/cel-go/common/types/ref"
|
||||
"github.com/tetratelabs/wazero/api"
|
||||
"github.com/yl2chen/cidranger"
|
||||
"html/template"
|
||||
@@ -36,6 +34,8 @@ import (
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -59,8 +59,40 @@ type State struct {
|
||||
privateKey ed25519.PrivateKey
|
||||
|
||||
Poison map[string][]byte
|
||||
|
||||
ChallengeSolve sync.Map
|
||||
|
||||
DecayMap *utils.DecayMap[[net.IPv6len]byte, utils.DNSBLResponse]
|
||||
|
||||
close chan struct{}
|
||||
}
|
||||
|
||||
func (state *State) AwaitChallenge(key []byte, ctx context.Context) challenge.VerifyResult {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
var result atomic.Int64
|
||||
|
||||
state.ChallengeSolve.Store(string(key), ChallengeCallback(func(receivedResult challenge.VerifyResult) {
|
||||
result.Store(int64(receivedResult))
|
||||
cancel()
|
||||
}))
|
||||
|
||||
<-ctx.Done()
|
||||
|
||||
return challenge.VerifyResult(result.Load())
|
||||
}
|
||||
|
||||
func (state *State) SolveChallenge(key []byte, result challenge.VerifyResult) {
|
||||
if f, ok := state.ChallengeSolve.LoadAndDelete(string(key)); ok && f != nil {
|
||||
if cb, ok := f.(ChallengeCallback); ok {
|
||||
cb(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type ChallengeCallback func(result challenge.VerifyResult)
|
||||
|
||||
type RuleState struct {
|
||||
Name string
|
||||
Hash string
|
||||
@@ -80,10 +112,13 @@ type StateSettings struct {
|
||||
ChallengeTemplate string
|
||||
ChallengeTemplateTheme string
|
||||
ClientIpHeader string
|
||||
BackendIpHeader string
|
||||
DNSBL *utils.DNSBL
|
||||
}
|
||||
|
||||
func NewState(p policy.Policy, settings StateSettings) (state *State, err error) {
|
||||
state = new(State)
|
||||
func NewState(p policy.Policy, settings StateSettings) (handler http.Handler, err error) {
|
||||
state := new(State)
|
||||
state.close = make(chan struct{})
|
||||
state.Settings = settings
|
||||
state.Client = &http.Client{
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
@@ -92,6 +127,10 @@ func NewState(p policy.Policy, settings StateSettings) (state *State, err error)
|
||||
}
|
||||
state.UrlPath = "/.well-known/." + state.Settings.PackageName
|
||||
|
||||
if state.Settings.DNSBL != nil {
|
||||
state.DecayMap = utils.NewDecayMap[[net.IPv6len]byte, utils.DNSBLResponse]()
|
||||
}
|
||||
|
||||
// set a reasonable configuration for default http proxy if there is none
|
||||
for _, backend := range state.Settings.Backends {
|
||||
if proxy, ok := backend.(*httputil.ReverseProxy); ok {
|
||||
@@ -167,13 +206,57 @@ func NewState(p policy.Policy, settings StateSettings) (state *State, err error)
|
||||
|
||||
state.Wasm = wasm.NewRunner(true)
|
||||
|
||||
err = state.initConditions()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var replacements []string
|
||||
for k, entries := range p.Conditions {
|
||||
ast, err := condition.FromStrings(state.RulesEnv, condition.OperatorOr, entries...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("conditions %s: error compiling conditions: %v", k, err)
|
||||
}
|
||||
|
||||
cond, err := cel.AstToString(ast)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("conditions %s: error printing condition: %v", k, err)
|
||||
}
|
||||
|
||||
replacements = append(replacements, fmt.Sprintf("($%s)", k))
|
||||
replacements = append(replacements, "("+cond+")")
|
||||
}
|
||||
conditionReplacer := strings.NewReplacer(replacements...)
|
||||
|
||||
state.Challenges = make(map[challenge.Id]challenge.Challenge)
|
||||
|
||||
idCounter := challenge.Id(1)
|
||||
|
||||
//TODO: move this to self-contained challenge files
|
||||
for challengeName, p := range p.Challenges {
|
||||
|
||||
// allow nesting
|
||||
var conditions []string
|
||||
for _, cond := range p.Conditions {
|
||||
cond = conditionReplacer.Replace(cond)
|
||||
conditions = append(conditions, cond)
|
||||
}
|
||||
|
||||
var program cel.Program
|
||||
if len(conditions) > 0 {
|
||||
ast, err := condition.FromStrings(state.RulesEnv, condition.OperatorOr, conditions...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("challenge %s: error compiling conditions: %v", challengeName, err)
|
||||
}
|
||||
program, err = state.RulesEnv.Program(ast)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("challenge %s: error compiling program: %v", challengeName, err)
|
||||
}
|
||||
}
|
||||
|
||||
c := challenge.Challenge{
|
||||
Id: idCounter,
|
||||
Program: program,
|
||||
Name: challengeName,
|
||||
Path: fmt.Sprintf("%s/challenge/%s", state.UrlPath, challengeName),
|
||||
VerifyProbability: p.Runtime.Probability,
|
||||
@@ -240,6 +323,13 @@ func NewState(p policy.Policy, settings StateSettings) (state *State, err error)
|
||||
}
|
||||
|
||||
c.ServeChallenge = func(w http.ResponseWriter, r *http.Request, key []byte, expiry time.Time) challenge.Result {
|
||||
|
||||
data := RequestDataFromContext(r.Context())
|
||||
|
||||
if result := data.Challenges[c.Id]; result.Ok() {
|
||||
return challenge.ResultPass
|
||||
}
|
||||
|
||||
var cookieValue string
|
||||
if expectedCookie != "" {
|
||||
if cookie, err := r.Cookie(expectedCookie); err != nil || cookie == nil {
|
||||
@@ -266,6 +356,9 @@ func NewState(p policy.Policy, settings StateSettings) (state *State, err error)
|
||||
if response.StatusCode != httpCode {
|
||||
utils.ClearCookie(utils.CookiePrefix+c.Name, w)
|
||||
// continue other challenges!
|
||||
|
||||
//TODO: negatively cache failure
|
||||
|
||||
return challenge.ResultContinue
|
||||
} else {
|
||||
// bind hash of cookie contents
|
||||
@@ -275,7 +368,6 @@ func NewState(p policy.Policy, settings StateSettings) (state *State, err error)
|
||||
sum.Write(key)
|
||||
sum.Write([]byte{0})
|
||||
sum.Write(state.publicKey)
|
||||
|
||||
token, err := c.IssueChallengeToken(state.privateKey, key, sum.Sum(nil), expiry)
|
||||
if err != nil {
|
||||
utils.ClearCookie(utils.CookiePrefix+c.Name, w)
|
||||
@@ -283,6 +375,8 @@ func NewState(p policy.Policy, settings StateSettings) (state *State, err error)
|
||||
utils.SetCookie(utils.CookiePrefix+challengeName, token, expiry, w)
|
||||
}
|
||||
|
||||
data.Challenges[c.Id] = challenge.VerifyResultPASS
|
||||
|
||||
// we passed it!
|
||||
return challenge.ResultPass
|
||||
}
|
||||
@@ -290,6 +384,12 @@ func NewState(p policy.Policy, settings StateSettings) (state *State, err error)
|
||||
|
||||
case "cookie":
|
||||
c.ServeChallenge = func(w http.ResponseWriter, r *http.Request, key []byte, expiry time.Time) challenge.Result {
|
||||
if chall := r.URL.Query().Get("__goaway_challenge"); chall == challengeName {
|
||||
state.logger(r).Warn("challenge failed", "challenge", c.Name)
|
||||
utils.ClearCookie(utils.CookiePrefix+c.Name, w)
|
||||
_ = state.errorPage(w, r.Header.Get("X-Away-Id"), http.StatusForbidden, fmt.Errorf("access denied: failed challenge %s", c.Name), "")
|
||||
return challenge.ResultStop
|
||||
}
|
||||
|
||||
token, err := c.IssueChallengeToken(state.privateKey, key, nil, expiry)
|
||||
if err != nil {
|
||||
@@ -297,9 +397,14 @@ func NewState(p policy.Policy, settings StateSettings) (state *State, err error)
|
||||
} else {
|
||||
utils.SetCookie(utils.CookiePrefix+challengeName, token, expiry, w)
|
||||
}
|
||||
|
||||
// self redirect!
|
||||
//TODO: add redirect loop detect parameter
|
||||
http.Redirect(w, r, r.URL.String(), http.StatusTemporaryRedirect)
|
||||
uri, err := url.ParseRequestURI(r.URL.String())
|
||||
values := uri.Query()
|
||||
values.Set("__goaway_challenge", challengeName)
|
||||
uri.RawQuery = values.Encode()
|
||||
|
||||
http.Redirect(w, r, uri.String(), http.StatusTemporaryRedirect)
|
||||
return challenge.ResultStop
|
||||
}
|
||||
case "meta-refresh":
|
||||
@@ -341,6 +446,54 @@ func NewState(p policy.Policy, settings StateSettings) (state *State, err error)
|
||||
|
||||
return challenge.ResultStop
|
||||
}
|
||||
case "preload-link":
|
||||
deadline, _ := time.ParseDuration(p.Parameters["preload-early-hint-deadline"])
|
||||
if deadline == 0 {
|
||||
deadline = time.Second * 3
|
||||
}
|
||||
|
||||
c.ServeChallenge = func(w http.ResponseWriter, r *http.Request, key []byte, expiry time.Time) challenge.Result {
|
||||
// this only works on HTTP/2 and HTTP/3
|
||||
|
||||
if r.ProtoMajor < 2 {
|
||||
// this can happen if we are an upgraded request from HTTP/1.1 to HTTP/2 in H2C
|
||||
if _, ok := w.(http.Pusher); !ok {
|
||||
return challenge.ResultContinue
|
||||
}
|
||||
}
|
||||
|
||||
data := RequestDataFromContext(r.Context())
|
||||
redirectUri := new(url.URL)
|
||||
redirectUri.Scheme = getRequestScheme(r)
|
||||
redirectUri.Host = r.Host
|
||||
redirectUri.Path = c.Path + "/verify-challenge"
|
||||
|
||||
values := make(url.Values)
|
||||
values.Set("result", hex.EncodeToString(key))
|
||||
values.Set("requestId", r.Header.Get("X-Away-Id"))
|
||||
|
||||
redirectUri.RawQuery = values.Encode()
|
||||
|
||||
w.Header().Set("Link", fmt.Sprintf("<%s>; rel=\"preload\"; as=\"style\"; fetchpriority=high", redirectUri.String()))
|
||||
defer func() {
|
||||
// remove old header so it won't show on response!
|
||||
w.Header().Del("Link")
|
||||
}()
|
||||
w.WriteHeader(http.StatusEarlyHints)
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), deadline)
|
||||
defer cancel()
|
||||
if result := state.AwaitChallenge(key, ctx); result.Ok() {
|
||||
data.Challenges[c.Id] = challenge.VerifyResultPASS
|
||||
|
||||
// this should serve!
|
||||
return challenge.ResultPass
|
||||
}
|
||||
|
||||
data.Challenges[c.Id] = challenge.VerifyResultFAIL
|
||||
// we failed, continue
|
||||
return challenge.ResultContinue
|
||||
}
|
||||
case "resource-load":
|
||||
c.ServeChallenge = func(w http.ResponseWriter, r *http.Request, key []byte, expiry time.Time) challenge.Result {
|
||||
redirectUri := new(url.URL)
|
||||
@@ -437,7 +590,7 @@ func NewState(p policy.Policy, settings StateSettings) (state *State, err error)
|
||||
|
||||
redirect, err := utils.EnsureNoOpenRedirect(r.FormValue("redirect"))
|
||||
if err != nil {
|
||||
_ = state.errorPage(w, r.Header.Get("X-Away-Id"), http.StatusInternalServerError, err, "")
|
||||
_ = state.errorPage(w, r.Header.Get("X-Away-Id"), http.StatusBadRequest, err, "")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -456,24 +609,37 @@ func NewState(p policy.Policy, settings StateSettings) (state *State, err error)
|
||||
if ok, err := c.Verify(key, result, r); err != nil {
|
||||
return err
|
||||
} else if !ok {
|
||||
utils.ClearCookie(utils.CookiePrefix+challengeName, w)
|
||||
data.Challenges[c.Id] = challenge.VerifyResultFAIL
|
||||
state.SolveChallenge(key, challenge.VerifyResultFAIL)
|
||||
state.logger(r).Warn("challenge failed", "challenge", challengeName, "redirect", redirect)
|
||||
utils.ClearCookie(utils.CookiePrefix+challengeName, w)
|
||||
_ = state.errorPage(w, r.Header.Get("X-Away-Id"), http.StatusForbidden, fmt.Errorf("access denied: failed challenge %s", challengeName), redirect)
|
||||
return nil
|
||||
}
|
||||
|
||||
state.logger(r).Warn("challenge passed", "challenge", challengeName, "redirect", redirect)
|
||||
// catch happy eyeballs IPv4 -> IPv6 migration, re-direct to try again
|
||||
if resultKey, err := ChallengeKeyFromString(result); err == nil && resultKey.Get(ChallengeKeyFlagIsIPv4) > 0 && key.Get(ChallengeKeyFlagIsIPv4) == 0 {
|
||||
|
||||
token, err := c.IssueChallengeToken(state.privateKey, key, []byte(result), data.Expires)
|
||||
if err != nil {
|
||||
utils.ClearCookie(utils.CookiePrefix+challengeName, w)
|
||||
} else {
|
||||
_ = state.errorPage(w, r.Header.Get("X-Away-Id"), http.StatusForbidden, fmt.Errorf("access denied: failed challenge %s", challengeName), redirect)
|
||||
return nil
|
||||
}
|
||||
} else {
|
||||
utils.SetCookie(utils.CookiePrefix+challengeName, token, data.Expires, w)
|
||||
state.logger(r).Warn("challenge passed", "challenge", challengeName, "redirect", redirect)
|
||||
|
||||
token, err := c.IssueChallengeToken(state.privateKey, key, []byte(result), data.Expires)
|
||||
if err != nil {
|
||||
utils.ClearCookie(utils.CookiePrefix+challengeName, w)
|
||||
} else {
|
||||
utils.SetCookie(utils.CookiePrefix+challengeName, token, data.Expires, w)
|
||||
}
|
||||
data.Challenges[c.Id] = challenge.VerifyResultPASS
|
||||
state.SolveChallenge(key, challenge.VerifyResultPASS)
|
||||
}
|
||||
data.Challenges[c.Id] = challenge.VerifyResultPASS
|
||||
|
||||
switch httpCode {
|
||||
case http.StatusMovedPermanently, http.StatusFound, http.StatusSeeOther, http.StatusTemporaryRedirect, http.StatusPermanentRedirect:
|
||||
if redirect == "" {
|
||||
_ = state.errorPage(w, r.Header.Get("X-Away-Id"), http.StatusBadRequest, errors.New("no redirect found"), "")
|
||||
return nil
|
||||
}
|
||||
http.Redirect(w, r, redirect, httpCode)
|
||||
default:
|
||||
w.Header().Set("Content-Type", mimeType)
|
||||
@@ -566,80 +732,6 @@ func NewState(p policy.Policy, settings StateSettings) (state *State, err error)
|
||||
state.Challenges[c.Id] = c
|
||||
}
|
||||
|
||||
state.RulesEnv, err = cel.NewEnv(
|
||||
cel.DefaultUTCTimeZone(true),
|
||||
cel.Variable("remoteAddress", cel.BytesType),
|
||||
cel.Variable("host", cel.StringType),
|
||||
cel.Variable("method", cel.StringType),
|
||||
cel.Variable("userAgent", cel.StringType),
|
||||
cel.Variable("path", cel.StringType),
|
||||
cel.Variable("query", cel.MapType(cel.StringType, cel.StringType)),
|
||||
// http.Header
|
||||
cel.Variable("headers", cel.MapType(cel.StringType, cel.StringType)),
|
||||
//TODO: dynamic type?
|
||||
cel.Function("inNetwork",
|
||||
cel.Overload("inNetwork_string_ip",
|
||||
[]*cel.Type{cel.StringType, cel.AnyType},
|
||||
cel.BoolType,
|
||||
cel.BinaryBinding(func(lhs ref.Val, rhs ref.Val) ref.Val {
|
||||
var ip net.IP
|
||||
switch v := rhs.Value().(type) {
|
||||
case []byte:
|
||||
ip = v
|
||||
case net.IP:
|
||||
ip = v
|
||||
case string:
|
||||
ip = net.ParseIP(v)
|
||||
}
|
||||
|
||||
if ip == nil {
|
||||
panic(fmt.Errorf("invalid ip %v", rhs.Value()))
|
||||
}
|
||||
|
||||
val, ok := lhs.Value().(string)
|
||||
if !ok {
|
||||
panic(fmt.Errorf("invalid value %v", lhs.Value()))
|
||||
}
|
||||
|
||||
network, ok := state.Networks[val]
|
||||
if !ok {
|
||||
_, ipNet, err := net.ParseCIDR(val)
|
||||
if err != nil {
|
||||
panic("network not found")
|
||||
}
|
||||
return types.Bool(ipNet.Contains(ip))
|
||||
} else {
|
||||
ok, err := network.Contains(ip)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return types.Bool(ok)
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var replacements []string
|
||||
for k, entries := range p.Conditions {
|
||||
ast, err := condition.FromStrings(state.RulesEnv, condition.OperatorOr, entries...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("conditions %s: error compiling conditions: %v", k, err)
|
||||
}
|
||||
|
||||
cond, err := cel.AstToString(ast)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("conditions %s: error printing condition: %v", k, err)
|
||||
}
|
||||
|
||||
replacements = append(replacements, fmt.Sprintf("($%s)", k))
|
||||
replacements = append(replacements, "("+cond+")")
|
||||
}
|
||||
conditionReplacer := strings.NewReplacer(replacements...)
|
||||
|
||||
for _, rule := range p.Rules {
|
||||
hasher := sha256.New()
|
||||
hasher.Write([]byte(rule.Name))
|
||||
@@ -701,6 +793,20 @@ func NewState(p policy.Policy, settings StateSettings) (state *State, err error)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if state.DecayMap != nil {
|
||||
go func() {
|
||||
ticker := time.NewTicker(17 * time.Minute)
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
state.DecayMap.Decay()
|
||||
case <-state.close:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
return state, nil
|
||||
}
|
||||
|
||||
|
||||
73
utils/decaymap.go
Normal file
73
utils/decaymap.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
func zilch[T any]() T {
|
||||
var zero T
|
||||
return zero
|
||||
}
|
||||
|
||||
type DecayMap[K, V comparable] struct {
|
||||
data map[K]DecayMapEntry[V]
|
||||
lock sync.RWMutex
|
||||
}
|
||||
|
||||
type DecayMapEntry[V comparable] struct {
|
||||
Value V
|
||||
expiry time.Time
|
||||
}
|
||||
|
||||
func NewDecayMap[K, V comparable]() *DecayMap[K, V] {
|
||||
return &DecayMap[K, V]{
|
||||
data: make(map[K]DecayMapEntry[V]),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *DecayMap[K, V]) Get(key K) (V, bool) {
|
||||
m.lock.RLock()
|
||||
value, ok := m.data[key]
|
||||
m.lock.RUnlock()
|
||||
|
||||
if !ok {
|
||||
return zilch[V](), false
|
||||
}
|
||||
|
||||
if time.Now().After(value.expiry) {
|
||||
m.lock.Lock()
|
||||
// Since previously reading m.data[key], the value may have been updated.
|
||||
// Delete the entry only if the expiry time is still the same.
|
||||
if m.data[key].expiry == value.expiry {
|
||||
delete(m.data, key)
|
||||
}
|
||||
m.lock.Unlock()
|
||||
|
||||
return zilch[V](), false
|
||||
}
|
||||
|
||||
return value.Value, true
|
||||
}
|
||||
|
||||
func (m *DecayMap[K, V]) Set(key K, value V, ttl time.Duration) {
|
||||
m.lock.Lock()
|
||||
defer m.lock.Unlock()
|
||||
|
||||
m.data[key] = DecayMapEntry[V]{
|
||||
Value: value,
|
||||
expiry: time.Now().Add(ttl),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *DecayMap[K, V]) Decay() {
|
||||
m.lock.Lock()
|
||||
defer m.lock.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
for key, entry := range m.data {
|
||||
if now.After(entry.expiry) {
|
||||
delete(m.data, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
75
utils/dnsbl.go
Normal file
75
utils/dnsbl.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
type DNSBL struct {
|
||||
target string
|
||||
resolver *net.Resolver
|
||||
}
|
||||
|
||||
func NewDNSBL(target string, resolver *net.Resolver) *DNSBL {
|
||||
if resolver == nil {
|
||||
resolver = net.DefaultResolver
|
||||
}
|
||||
return &DNSBL{
|
||||
target: target,
|
||||
resolver: resolver,
|
||||
}
|
||||
}
|
||||
|
||||
var nibbleTable = [16]byte{
|
||||
'0', '1', '2', '3',
|
||||
'4', '5', '6', '7',
|
||||
'8', '9', 'a', 'b',
|
||||
'c', 'd', 'e', 'f',
|
||||
}
|
||||
|
||||
type DNSBLResponse uint8
|
||||
|
||||
func (r DNSBLResponse) Bad() bool {
|
||||
return r != ResponseGood && r != ResponseUnknown
|
||||
}
|
||||
|
||||
const (
|
||||
ResponseGood = DNSBLResponse(0)
|
||||
ResponseUnknown = DNSBLResponse(255)
|
||||
)
|
||||
|
||||
func (bl DNSBL) Lookup(ctx context.Context, ip net.IP) (DNSBLResponse, error) {
|
||||
var target []byte
|
||||
if ip4 := ip.To4(); ip4 != nil {
|
||||
// max length preallocate
|
||||
target = make([]byte, 0, len(bl.target)+1+len(ip4)*4)
|
||||
|
||||
for i := len(ip4) - 1; i >= 0; i-- {
|
||||
target = strconv.AppendUint(target, uint64(ip4[i]), 10)
|
||||
target = append(target, '.')
|
||||
}
|
||||
} else {
|
||||
// IPv6
|
||||
// max length preallocate
|
||||
target = make([]byte, 0, len(bl.target)+1+len(ip)*4)
|
||||
|
||||
for i := len(ip) - 1; i >= 0; i-- {
|
||||
target = append(target, nibbleTable[ip[i]&0xf], '.', ip[i]>>4, '.')
|
||||
}
|
||||
}
|
||||
|
||||
target = append(target, bl.target...)
|
||||
|
||||
ips, err := bl.resolver.LookupIP(ctx, "ip4", string(target))
|
||||
if err != nil {
|
||||
return ResponseUnknown, err
|
||||
}
|
||||
|
||||
for _, ip := range ips {
|
||||
ip4 := ip.To4()
|
||||
return DNSBLResponse(ip4[len(ip4)-1]), nil
|
||||
}
|
||||
|
||||
return ResponseUnknown, nil
|
||||
}
|
||||
Reference in New Issue
Block a user