Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f80d6ebd15 | ||
|
|
10dc2a0177 | ||
|
|
baf9df9f0a | ||
|
|
b0ab78ef65 | ||
|
|
f272a5ae72 | ||
|
|
2ce9709667 | ||
|
|
d2513d2bab | ||
|
|
04e102cb74 | ||
|
|
7be88ca6af | ||
|
|
b3f471bb30 | ||
|
|
1144424eff | ||
|
|
903b4e26bd |
@@ -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,15 +78,14 @@ local Publish(go, alpine, os, arch, platforms) = {
|
||||
from_builder: "golang:" + go +"-alpine" + alpine,
|
||||
from: "alpine:" + alpine,
|
||||
},
|
||||
auto_tag: true,
|
||||
auto_tag_suffix: "-alpine" + alpine,
|
||||
auto_tag_suffix: "alpine" + alpine,
|
||||
username: {
|
||||
from_secret: "git_username",
|
||||
},
|
||||
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,}),
|
||||
]
|
||||
58
.drone.yml
58
.drone.yml
@@ -160,17 +160,55 @@ steps:
|
||||
type: docker
|
||||
---
|
||||
kind: pipeline
|
||||
name: publish-1.24-alpine3.21
|
||||
name: publish-latest
|
||||
platform:
|
||||
arch: amd64
|
||||
os: linux
|
||||
steps:
|
||||
- image: plugins/buildx
|
||||
- environment:
|
||||
DOCKER_BUILDKIT: "1"
|
||||
image: plugins/buildx
|
||||
name: docker
|
||||
privileged: true
|
||||
settings:
|
||||
auto_tag: true
|
||||
auto_tag_suffix: -alpine3.21
|
||||
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:
|
||||
- environment:
|
||||
DOCKER_BUILDKIT: "1"
|
||||
image: plugins/buildx
|
||||
name: docker
|
||||
privileged: true
|
||||
settings:
|
||||
auto_tag: true
|
||||
auto_tag_suffix: alpine3.21
|
||||
build_args:
|
||||
from: alpine:3.21
|
||||
from_builder: golang:1.24-alpine3.21
|
||||
@@ -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,12 +237,14 @@ platform:
|
||||
arch: amd64
|
||||
os: linux
|
||||
steps:
|
||||
- image: plugins/buildx
|
||||
- environment:
|
||||
DOCKER_BUILDKIT: "1"
|
||||
image: plugins/buildx
|
||||
name: docker
|
||||
privileged: true
|
||||
settings:
|
||||
auto_tag: true
|
||||
auto_tag_suffix: -alpine3.20
|
||||
auto_tag_suffix: alpine3.20
|
||||
build_args:
|
||||
from: alpine:3.20
|
||||
from_builder: golang:1.22-alpine3.20
|
||||
@@ -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: df5f717113694708251e53b4a30070d44ce0fc1dbc0975d5b90fab130f5d1f2a
|
||||
hmac: 3cbd114d368c7bd348105921d85c703db1c1bc46de79f00daabbca23ffac6050
|
||||
|
||||
...
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
"git.gammaspectra.live/git/go-away/lib"
|
||||
"git.gammaspectra.live/git/go-away/lib/policy"
|
||||
"git.gammaspectra.live/git/go-away/utils"
|
||||
"golang.org/x/net/http2"
|
||||
"golang.org/x/net/http2/h2c"
|
||||
"gopkg.in/yaml.v3"
|
||||
"log"
|
||||
"log/slog"
|
||||
@@ -79,6 +81,18 @@ func (v *MultiVar) Set(value string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func newServer(handler http.Handler) *http.Server {
|
||||
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
|
||||
}
|
||||
|
||||
func main() {
|
||||
bind := flag.String("bind", ":8080", "network address to bind HTTP to")
|
||||
bindNetwork := flag.String("bind-network", "tcp", "network family to bind HTTP to, e.g. unix, tcp")
|
||||
@@ -86,7 +100,7 @@ func main() {
|
||||
|
||||
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")
|
||||
|
||||
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.)")
|
||||
|
||||
@@ -196,17 +210,15 @@ 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)
|
||||
}))
|
||||
|
||||
listener, listenUrl := setupListener(*bindNetwork, *bind, *socketMode)
|
||||
slog.Warn(
|
||||
@@ -228,10 +240,10 @@ 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()
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -259,9 +271,7 @@ func main() {
|
||||
"url", listenUrl,
|
||||
)
|
||||
|
||||
server := http.Server{
|
||||
Handler: state,
|
||||
}
|
||||
server := newServer(state)
|
||||
|
||||
if err := server.Serve(listener); !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Fatal(err)
|
||||
|
||||
@@ -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)")'
|
||||
|
||||
@@ -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]
|
||||
@@ -396,7 +417,7 @@ rules:
|
||||
# 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
|
||||
@@ -430,6 +451,6 @@ rules:
|
||||
|
||||
- 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)'
|
||||
|
||||
6
go.mod
6
go.mod
@@ -13,6 +13,7 @@ require (
|
||||
github.com/klauspost/compress v1.18.0
|
||||
github.com/tetratelabs/wazero v1.9.0
|
||||
github.com/yl2chen/cidranger v1.0.2
|
||||
golang.org/x/net v0.26.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
@@ -24,6 +25,7 @@ require (
|
||||
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 +39,7 @@ 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 (
|
||||
golang.org/x/crypto => golang.org/x/crypto v0.33.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
|
||||
)
|
||||
)
|
||||
|
||||
2
go.sum
2
go.sum
@@ -46,6 +46,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.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ=
|
||||
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
|
||||
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=
|
||||
|
||||
@@ -20,6 +20,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,6 +48,7 @@ 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)
|
||||
}
|
||||
|
||||
@@ -52,8 +65,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})
|
||||
|
||||
@@ -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:
|
||||
|
||||
69
lib/conditions.go
Normal file
69
lib/conditions.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package lib
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/google/cel-go/cel"
|
||||
"github.com/google/cel-go/common/types"
|
||||
"github.com/google/cel-go/common/types/ref"
|
||||
"net"
|
||||
)
|
||||
|
||||
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("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
|
||||
}
|
||||
78
lib/http.go
78
lib/http.go
@@ -159,29 +159,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 +188,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 +207,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 +225,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 +245,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)
|
||||
@@ -425,6 +409,27 @@ func (state *State) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = rand.Read(data.Id[:])
|
||||
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": 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
|
||||
}(),
|
||||
}
|
||||
|
||||
for _, c := range state.Challenges {
|
||||
key := state.GetChallengeKeyForRequest(c.Name, data.Expires, r)
|
||||
@@ -433,10 +438,30 @@ 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[:]))
|
||||
w.Header().Set("X-Away-Id", hex.EncodeToString(data.Id[:]))
|
||||
|
||||
// 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")
|
||||
|
||||
r = r.WithContext(context.WithValue(r.Context(), "_goaway_data", &data))
|
||||
|
||||
@@ -449,6 +474,7 @@ func RequestDataFromContext(ctx context.Context) *RequestData {
|
||||
|
||||
type RequestData struct {
|
||||
Id [16]byte
|
||||
ProgramEnv map[string]any
|
||||
Expires time.Time
|
||||
Challenges map[challenge.Id]challenge.VerifyResult
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
216
lib/state.go
216
lib/state.go
@@ -20,15 +20,12 @@ 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"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
@@ -36,6 +33,8 @@ import (
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -59,8 +58,36 @@ type State struct {
|
||||
privateKey ed25519.PrivateKey
|
||||
|
||||
Poison map[string][]byte
|
||||
|
||||
ChallengeSolve sync.Map
|
||||
}
|
||||
|
||||
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
|
||||
@@ -167,13 +194,56 @@ 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)
|
||||
|
||||
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 +310,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 +343,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 +355,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 +362,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
|
||||
}
|
||||
@@ -341,6 +422,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)
|
||||
@@ -458,6 +587,9 @@ func NewState(p policy.Policy, settings StateSettings) (state *State, err error)
|
||||
} else if !ok {
|
||||
state.logger(r).Warn("challenge failed", "challenge", challengeName, "redirect", redirect)
|
||||
utils.ClearCookie(utils.CookiePrefix+challengeName, w)
|
||||
|
||||
state.SolveChallenge(key, challenge.VerifyResultFAIL)
|
||||
|
||||
_ = state.errorPage(w, r.Header.Get("X-Away-Id"), http.StatusForbidden, fmt.Errorf("access denied: failed challenge %s", challengeName), redirect)
|
||||
return nil
|
||||
}
|
||||
@@ -472,6 +604,8 @@ func NewState(p policy.Policy, settings StateSettings) (state *State, err error)
|
||||
}
|
||||
data.Challenges[c.Id] = challenge.VerifyResultPASS
|
||||
|
||||
state.SolveChallenge(key, challenge.VerifyResultPASS)
|
||||
|
||||
switch httpCode {
|
||||
case http.StatusMovedPermanently, http.StatusFound, http.StatusSeeOther, http.StatusTemporaryRedirect, http.StatusPermanentRedirect:
|
||||
http.Redirect(w, r, redirect, httpCode)
|
||||
@@ -566,80 +700,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))
|
||||
|
||||
Reference in New Issue
Block a user