Initial commit
This commit is contained in:
0
.bin/.gitkeep
Normal file
0
.bin/.gitkeep
Normal file
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
/tinygo
|
||||
/.bin/*
|
||||
*.gz
|
||||
*.br
|
||||
*.zst
|
BIN
assets/static/geist.woff2
Normal file
BIN
assets/static/geist.woff2
Normal file
Binary file not shown.
BIN
assets/static/iosevka-curly.woff2
Normal file
BIN
assets/static/iosevka-curly.woff2
Normal file
Binary file not shown.
BIN
assets/static/logo.png
Normal file
BIN
assets/static/logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 3.0 KiB |
BIN
assets/static/podkova.woff2
Normal file
BIN
assets/static/podkova.woff2
Normal file
Binary file not shown.
105
assets/static/style.css
Normal file
105
assets/static/style.css
Normal file
@@ -0,0 +1,105 @@
|
||||
@font-face {
|
||||
font-family: "Geist";
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url("./geist.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Podkova";
|
||||
font-style: normal;
|
||||
font-weight: 400 800;
|
||||
font-display: swap;
|
||||
src: url("./podkova.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Iosevka Curly";
|
||||
font-style: monospace;
|
||||
font-display: swap;
|
||||
src: url("./iosevka-curly.woff2") format("woff2");
|
||||
}
|
||||
|
||||
main {
|
||||
font-family: Geist, sans-serif;
|
||||
max-width: 50rem;
|
||||
padding: 2rem;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: #d3869b;
|
||||
}
|
||||
|
||||
body {
|
||||
background: #1d2021;
|
||||
color: #f9f5d7;
|
||||
}
|
||||
|
||||
pre {
|
||||
background-color: #3c3836;
|
||||
padding: 1em;
|
||||
border: 0;
|
||||
font-family: Iosevka Curly Iaso, monospace;
|
||||
}
|
||||
|
||||
a,
|
||||
a:active,
|
||||
a:visited {
|
||||
color: #b16286;
|
||||
background-color: #282828;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5 {
|
||||
margin-bottom: 0.1rem;
|
||||
font-family: Podkova, serif;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
border-left: 1px solid #bdae93;
|
||||
margin: 0.5em 10px;
|
||||
padding: 0.5em 10px;
|
||||
}
|
||||
|
||||
footer {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
body {
|
||||
background: #f9f5d7;
|
||||
color: #1d2021;
|
||||
}
|
||||
|
||||
pre {
|
||||
background-color: #ebdbb2;
|
||||
padding: 1em;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
a,
|
||||
a:active,
|
||||
a:visited {
|
||||
color: #b16286;
|
||||
background-color: #fbf1c7;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5 {
|
||||
margin-bottom: 0.1rem;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
border-left: 1px solid #655c54;
|
||||
margin: 0.5em 10px;
|
||||
padding: 0.5em 10px;
|
||||
}
|
||||
}
|
41
build.sh
Executable file
41
build.sh
Executable file
@@ -0,0 +1,41 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
set -o pipefail
|
||||
|
||||
cd "$(cd -P -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
|
||||
|
||||
# Setup tinygo first
|
||||
if [[ ! -d .bin/tinygo ]]; then
|
||||
git clone --depth=1 --branch v0.37.0 https://github.com/tinygo-org/tinygo.git .bin/tinygo
|
||||
pushd .bin/tinygo
|
||||
git submodule update --init --recursive
|
||||
|
||||
go mod download -x && go mod verify
|
||||
|
||||
make binaryen STATIC=1
|
||||
make wasi-libc
|
||||
|
||||
make llvm-source
|
||||
make llvm-build
|
||||
|
||||
make build/release
|
||||
else
|
||||
pushd .bin/tinygo
|
||||
fi
|
||||
|
||||
export TINYGOROOT="$(realpath ./build/release/tinygo/)"
|
||||
export PATH="$PATH:$(realpath ./build/release/tinygo/bin/)"
|
||||
|
||||
popd
|
||||
|
||||
go generate ./...
|
||||
|
||||
do_compress () {
|
||||
find "$1" \( -type f -name "*.wasm" -o -name "*.css" -o -name "*.js" -o -name "*.mjs" \) -exec zopfli {} \;
|
||||
find "$1" \( -type f -name "*.wasm" -o -name "*.css" -o -name "*.js" -o -name "*.mjs" \) -exec brotli -v -f -9 -o {}.br {} \;
|
||||
#find "$1" \( -type f -name "*.wasm" -o -name "*.css" -o -name "*.js" -o -name "*.mjs" \) -exec zstd -v -f -19 -o {}.zst {} \;
|
||||
}
|
||||
|
||||
do_compress challenge/
|
||||
do_compress assets/
|
171
challenge.go
Normal file
171
challenge.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package go_away
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"github.com/go-jose/go-jose/v4"
|
||||
"github.com/go-jose/go-jose/v4/jwt"
|
||||
"github.com/tetratelabs/wazero"
|
||||
"github.com/tetratelabs/wazero/api"
|
||||
"math/rand/v2"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ChallengeInformation struct {
|
||||
Name string `json:"name"`
|
||||
Key []byte `json:"key"`
|
||||
Result []byte `json:"result"`
|
||||
|
||||
Expiry *jwt.NumericDate `json:"exp,omitempty"`
|
||||
NotBefore *jwt.NumericDate `json:"nbf,omitempty"`
|
||||
IssuedAt *jwt.NumericDate `json:"iat,omitempty"`
|
||||
}
|
||||
|
||||
func (state *State) GetRequestAddress(r *http.Request) net.IP {
|
||||
//TODO: verified upstream
|
||||
ipStr := r.Header.Get("X-Real-Ip")
|
||||
if ipStr == "" {
|
||||
ipStr = strings.Split(r.Header.Get("X-Forwarded-For"), ",")[0]
|
||||
}
|
||||
if ipStr == "" {
|
||||
parts := strings.Split(r.RemoteAddr, ":")
|
||||
// drop port
|
||||
ipStr = strings.Join(parts[:len(parts)-1], ":")
|
||||
}
|
||||
return net.ParseIP(ipStr)
|
||||
}
|
||||
|
||||
func (state *State) GetChallengeKeyForRequest(name string, until time.Time, r *http.Request) []byte {
|
||||
hasher := sha256.New()
|
||||
hasher.Write([]byte("challenge\x00"))
|
||||
hasher.Write([]byte(name))
|
||||
hasher.Write([]byte{0})
|
||||
hasher.Write(state.GetRequestAddress(r).To16())
|
||||
hasher.Write([]byte{0})
|
||||
|
||||
// specific headers
|
||||
for _, k := range []string{
|
||||
"Accept-Language",
|
||||
// General browser information
|
||||
"User-Agent",
|
||||
"Sec-Ch-Ua",
|
||||
"Sec-Ch-Ua-Platform",
|
||||
} {
|
||||
hasher.Write([]byte(r.Header.Get(k)))
|
||||
hasher.Write([]byte{0})
|
||||
}
|
||||
hasher.Write([]byte{0})
|
||||
_ = binary.Write(hasher, binary.LittleEndian, until.UTC().Unix())
|
||||
hasher.Write([]byte{0})
|
||||
hasher.Write(state.PublicKey)
|
||||
hasher.Write([]byte{0})
|
||||
|
||||
return hasher.Sum(nil)
|
||||
}
|
||||
|
||||
func (state *State) IssueChallengeToken(name string, key, result []byte, until time.Time) (token string, err error) {
|
||||
signer, err := jose.NewSigner(jose.SigningKey{
|
||||
Algorithm: jose.EdDSA,
|
||||
Key: state.PrivateKey,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
expiry := jwt.NumericDate(until.Unix())
|
||||
notBefore := jwt.NumericDate(time.Now().UTC().AddDate(0, 0, -1).Unix())
|
||||
issuedAt := jwt.NumericDate(time.Now().UTC().Unix())
|
||||
|
||||
token, err = jwt.Signed(signer).Claims(ChallengeInformation{
|
||||
Name: name,
|
||||
Key: key,
|
||||
Result: result,
|
||||
Expiry: &expiry,
|
||||
NotBefore: ¬Before,
|
||||
IssuedAt: &issuedAt,
|
||||
}).Serialize()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (state *State) VerifyChallengeToken(name string, expectedKey []byte, r *http.Request) (ok bool, err error) {
|
||||
c, ok := state.Challenges[name]
|
||||
if !ok {
|
||||
return false, errors.New("challenge not found")
|
||||
}
|
||||
|
||||
cookie, err := r.Cookie(CookiePrefix + name)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
token, err := jwt.ParseSigned(cookie.Value, []jose.SignatureAlgorithm{jose.EdDSA})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
var i ChallengeInformation
|
||||
err = token.Claims(state.PublicKey, &i)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if i.Name != name {
|
||||
return false, errors.New("token invalid name")
|
||||
}
|
||||
if i.Expiry == nil && i.Expiry.Time().Compare(time.Now()) < 0 {
|
||||
return false, errors.New("token expired")
|
||||
}
|
||||
if i.NotBefore == nil && i.NotBefore.Time().Compare(time.Now()) > 0 {
|
||||
return false, errors.New("token not valid yet")
|
||||
}
|
||||
|
||||
if bytes.Compare(expectedKey, i.Key) != 0 {
|
||||
return false, errors.New("key mismatch")
|
||||
}
|
||||
|
||||
if c.Verify != nil && rand.Float64() < c.VerifyProbability {
|
||||
// random spot check
|
||||
if ok, err := c.Verify(expectedKey, string(i.Result)); err != nil {
|
||||
return false, err
|
||||
} else if !ok {
|
||||
return false, errors.New("failed challenge verification")
|
||||
}
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (state *State) ChallengeMod(name string, cb func(ctx context.Context, mod api.Module) error) error {
|
||||
c, ok := state.Challenges[name]
|
||||
if !ok {
|
||||
return errors.New("challenge not found")
|
||||
}
|
||||
if c.RuntimeModule == nil {
|
||||
return errors.New("challenge module is nil")
|
||||
}
|
||||
|
||||
ctx := state.WasmContext
|
||||
mod, err := state.WasmRuntime.InstantiateModule(
|
||||
ctx,
|
||||
c.RuntimeModule,
|
||||
wazero.NewModuleConfig().WithName(name).WithStartFunctions("_initialize"),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer mod.Close(ctx)
|
||||
err = cb(ctx, mod)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
74
challenge/generic.go
Normal file
74
challenge/generic.go
Normal file
@@ -0,0 +1,74 @@
|
||||
//go:build !tinygo
|
||||
|
||||
package challenge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"github.com/tetratelabs/wazero/api"
|
||||
)
|
||||
|
||||
func MakeChallengeCall(ctx context.Context, mod api.Module, in MakeChallengeInput) (*MakeChallengeOutput, error) {
|
||||
makeChallengeFunc := mod.ExportedFunction("MakeChallenge")
|
||||
malloc := mod.ExportedFunction("malloc")
|
||||
free := mod.ExportedFunction("free")
|
||||
|
||||
inData, err := json.Marshal(in)
|
||||
|
||||
mallocResult, err := malloc.Call(ctx, uint64(len(inData)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer free.Call(ctx, mallocResult[0])
|
||||
if !mod.Memory().Write(uint32(mallocResult[0]), inData) {
|
||||
return nil, errors.New("could not write memory")
|
||||
}
|
||||
result, err := makeChallengeFunc.Call(ctx, uint64(NewAllocation(uint32(mallocResult[0]), uint32(len(inData)))))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resultPtr := Allocation(result[0])
|
||||
outData, ok := mod.Memory().Read(resultPtr.Pointer(), resultPtr.Size())
|
||||
if !ok {
|
||||
return nil, errors.New("could not read result")
|
||||
}
|
||||
defer free.Call(ctx, uint64(resultPtr.Pointer()))
|
||||
|
||||
var out MakeChallengeOutput
|
||||
err = json.Unmarshal(outData, &out)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func VerifyChallengeCall(ctx context.Context, mod api.Module, in VerifyChallengeInput) (VerifyChallengeOutput, error) {
|
||||
verifyChallengeFunc := mod.ExportedFunction("VerifyChallenge")
|
||||
malloc := mod.ExportedFunction("malloc")
|
||||
free := mod.ExportedFunction("free")
|
||||
|
||||
inData, err := json.Marshal(in)
|
||||
|
||||
mallocResult, err := malloc.Call(ctx, uint64(len(inData)))
|
||||
if err != nil {
|
||||
return VerifyChallengeOutputError, err
|
||||
}
|
||||
defer free.Call(ctx, mallocResult[0])
|
||||
if !mod.Memory().Write(uint32(mallocResult[0]), inData) {
|
||||
return VerifyChallengeOutputError, errors.New("could not write memory")
|
||||
}
|
||||
result, err := verifyChallengeFunc.Call(ctx, uint64(NewAllocation(uint32(mallocResult[0]), uint32(len(inData)))))
|
||||
if err != nil {
|
||||
return VerifyChallengeOutputError, err
|
||||
}
|
||||
|
||||
return VerifyChallengeOutput(result[0]), nil
|
||||
}
|
||||
|
||||
func PtrToBytes(ptr uint32, size uint32) []byte { panic("not implemented") }
|
||||
func BytesToPtr(s []byte) (uint32, uint32) { panic("not implemented") }
|
||||
func BytesToLeakedPtr(s []byte) (uint32, uint32) { panic("not implemented") }
|
||||
func PtrToString(ptr uint32, size uint32) string { panic("not implemented") }
|
||||
func StringToPtr(s string) (uint32, uint32) { panic("not implemented") }
|
||||
func StringToLeakedPtr(s string) (uint32, uint32) { panic("not implemented") }
|
123
challenge/interface.go
Normal file
123
challenge/interface.go
Normal file
@@ -0,0 +1,123 @@
|
||||
package challenge
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
const ChallengeKeySize = sha256.Size
|
||||
|
||||
type MakeChallenge func(in Allocation) (out Allocation)
|
||||
|
||||
type Allocation uint64
|
||||
|
||||
func NewAllocation(ptr, size uint32) Allocation {
|
||||
return Allocation((uint64(ptr) << uint64(32)) | uint64(size))
|
||||
}
|
||||
|
||||
func (p Allocation) Pointer() uint32 {
|
||||
return uint32(p >> 32)
|
||||
}
|
||||
func (p Allocation) Size() uint32 {
|
||||
return uint32(p)
|
||||
}
|
||||
|
||||
func MakeChallengeDecode(callback func(in MakeChallengeInput, out *MakeChallengeOutput), in Allocation) (out Allocation) {
|
||||
outStruct := &MakeChallengeOutput{}
|
||||
var inStruct MakeChallengeInput
|
||||
|
||||
inData := PtrToBytes(in.Pointer(), in.Size())
|
||||
|
||||
err := json.Unmarshal(inData, &inStruct)
|
||||
if err != nil {
|
||||
outStruct.Code = 500
|
||||
outStruct.Error = err.Error()
|
||||
} else {
|
||||
outStruct.Code = 200
|
||||
outStruct.Headers = make(http.Header)
|
||||
|
||||
func() {
|
||||
// encapsulate err
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
if outStruct.Code == 200 {
|
||||
outStruct.Code = 500
|
||||
}
|
||||
if err, ok := recovered.(error); ok {
|
||||
outStruct.Error = err.Error()
|
||||
} else {
|
||||
outStruct.Error = fmt.Sprintf("%v", recovered)
|
||||
}
|
||||
}
|
||||
}()
|
||||
callback(inStruct, outStruct)
|
||||
}()
|
||||
}
|
||||
|
||||
if len(outStruct.Headers) == 0 {
|
||||
outStruct.Headers = nil
|
||||
}
|
||||
|
||||
outData, err := json.Marshal(outStruct)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return NewAllocation(BytesToLeakedPtr(outData))
|
||||
}
|
||||
|
||||
func VerifyChallengeDecode(callback func(in VerifyChallengeInput) VerifyChallengeOutput, in Allocation) (out VerifyChallengeOutput) {
|
||||
var inStruct VerifyChallengeInput
|
||||
|
||||
inData := PtrToBytes(in.Pointer(), in.Size())
|
||||
|
||||
err := json.Unmarshal(inData, &inStruct)
|
||||
if err != nil {
|
||||
return VerifyChallengeOutputError
|
||||
} else {
|
||||
func() {
|
||||
// encapsulate err
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
out = VerifyChallengeOutputError
|
||||
}
|
||||
}()
|
||||
out = callback(inStruct)
|
||||
}()
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
type MakeChallengeInput struct {
|
||||
Key []byte `json:"key"`
|
||||
|
||||
Parameters map[string]string `json:"parameters,omitempty"`
|
||||
|
||||
Headers http.Header `json:"headers,omitempty"`
|
||||
Data []byte `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
type MakeChallengeOutput struct {
|
||||
Data []byte `json:"data"`
|
||||
Code int `json:"code"`
|
||||
Headers http.Header `json:"headers,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type VerifyChallengeInput struct {
|
||||
Key []byte `json:"key"`
|
||||
Parameters map[string]string `json:"parameters,omitempty"`
|
||||
|
||||
Result []byte `json:"result,omitempty"`
|
||||
}
|
||||
|
||||
type VerifyChallengeOutput uint64
|
||||
|
||||
const (
|
||||
VerifyChallengeOutputOK = VerifyChallengeOutput(iota)
|
||||
VerifyChallengeOutputFailed
|
||||
VerifyChallengeOutputError
|
||||
)
|
1
challenge/js-pow-sha256/runtime/.gitignore
vendored
Normal file
1
challenge/js-pow-sha256/runtime/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
*.wasm
|
92
challenge/js-pow-sha256/runtime/runtime.go
Normal file
92
challenge/js-pow-sha256/runtime/runtime.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"git.gammaspectra.live/git/go-away/challenge"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:generate tinygo build -target wasip1 -buildmode=c-shared -scheduler=none -gc=leaking -o runtime.wasm runtime.go
|
||||
func main() {
|
||||
|
||||
}
|
||||
|
||||
func getChallenge(key []byte, params map[string]string) ([]byte, uint64) {
|
||||
difficulty := uint64(5)
|
||||
var err error
|
||||
if diffStr, ok := params["difficulty"]; ok {
|
||||
difficulty, err = strconv.ParseUint(diffStr, 10, 64)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
hasher := sha256.New()
|
||||
hasher.Write(binary.LittleEndian.AppendUint64(nil, difficulty))
|
||||
hasher.Write(key)
|
||||
return hasher.Sum(nil), difficulty
|
||||
}
|
||||
|
||||
//go:wasmexport MakeChallenge
|
||||
func MakeChallenge(in challenge.Allocation) (out challenge.Allocation) {
|
||||
return challenge.MakeChallengeDecode(func(in challenge.MakeChallengeInput, out *challenge.MakeChallengeOutput) {
|
||||
type Result struct {
|
||||
Challenge string `json:"challenge"`
|
||||
Difficulty uint64 `json:"difficulty"`
|
||||
}
|
||||
|
||||
challenge, difficulty := getChallenge(in.Key, in.Parameters)
|
||||
|
||||
data, err := json.Marshal(Result{
|
||||
Challenge: hex.EncodeToString(challenge),
|
||||
Difficulty: difficulty,
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
out.Data = data
|
||||
out.Headers.Set("Content-Type", "text/javascript; charset=utf-8")
|
||||
}, in)
|
||||
}
|
||||
|
||||
//go:wasmexport VerifyChallenge
|
||||
func VerifyChallenge(in challenge.Allocation) (out challenge.VerifyChallengeOutput) {
|
||||
return challenge.VerifyChallengeDecode(func(in challenge.VerifyChallengeInput) challenge.VerifyChallengeOutput {
|
||||
c, difficulty := getChallenge(in.Key, in.Parameters)
|
||||
|
||||
type Result struct {
|
||||
Hash string `json:"hash"`
|
||||
Nonce uint64 `json:"nonce"`
|
||||
}
|
||||
var result Result
|
||||
err := json.Unmarshal(in.Result, &result)
|
||||
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(result.Hash, strings.Repeat("0", int(difficulty))) {
|
||||
return challenge.VerifyChallengeOutputFailed
|
||||
}
|
||||
|
||||
resultBinary, err := hex.DecodeString(result.Hash)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
buf := make([]byte, 0, len(c)+8)
|
||||
buf = append(buf, c[:]...)
|
||||
buf = binary.LittleEndian.AppendUint64(buf, result.Nonce)
|
||||
calculated := sha256.Sum256(buf)
|
||||
|
||||
if subtle.ConstantTimeCompare(resultBinary, calculated[:]) != 1 {
|
||||
return challenge.VerifyChallengeOutputFailed
|
||||
}
|
||||
|
||||
return challenge.VerifyChallengeOutputOK
|
||||
}, in)
|
||||
}
|
125
challenge/js-pow-sha256/static/load.mjs
Normal file
125
challenge/js-pow-sha256/static/load.mjs
Normal file
@@ -0,0 +1,125 @@
|
||||
let _worker;
|
||||
let _webWorkerURL;
|
||||
let _challenge;
|
||||
let _difficulty;
|
||||
|
||||
async function setup(config) {
|
||||
|
||||
const status = document.getElementById('status');
|
||||
const image = document.getElementById('image');
|
||||
const title = document.getElementById('title');
|
||||
const spinner = document.getElementById('spinner');
|
||||
|
||||
const { challenge, difficulty } = await fetch(config.Path + "/make-challenge", { method: "POST" })
|
||||
.then(r => {
|
||||
if (!r.ok) {
|
||||
throw new Error("Failed to fetch config");
|
||||
}
|
||||
return r.json();
|
||||
})
|
||||
.catch(err => {
|
||||
throw err;
|
||||
});
|
||||
|
||||
_challenge = challenge;
|
||||
_difficulty = difficulty;
|
||||
|
||||
_webWorkerURL = URL.createObjectURL(new Blob([
|
||||
'(', processTask(challenge, difficulty), ')()'
|
||||
], { type: 'application/javascript' }));
|
||||
_worker = new Worker(_webWorkerURL);
|
||||
|
||||
return `Difficulty ${difficulty}`
|
||||
}
|
||||
|
||||
function challenge() {
|
||||
return new Promise((resolve, reject) => {
|
||||
_worker.onmessage = (event) => {
|
||||
_worker.terminate();
|
||||
resolve(event.data);
|
||||
};
|
||||
|
||||
_worker.onerror = (event) => {
|
||||
_worker.terminate();
|
||||
reject();
|
||||
};
|
||||
|
||||
_worker.postMessage({
|
||||
challenge: _challenge,
|
||||
difficulty: _difficulty,
|
||||
});
|
||||
|
||||
URL.revokeObjectURL(_webWorkerURL);
|
||||
});
|
||||
}
|
||||
|
||||
function processTask() {
|
||||
return function () {
|
||||
|
||||
const decodeHex = (str) => {
|
||||
let result = new Uint8Array(str.length>>1)
|
||||
for (let i = 0; i < str.length; i += 2){
|
||||
result[i>>1] = parseInt(str.substring(i, i + 2), 16)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
const encodeHex = (buf) => {
|
||||
return buf.reduce((a, b) => a + b.toString(16).padStart(2, '0'), '')
|
||||
}
|
||||
|
||||
const lessThan = (buf, target) => {
|
||||
for(let i = 0; i < buf.length; ++i){
|
||||
if (buf[i] < target[i]){
|
||||
return true;
|
||||
} else if (buf[i] > target[i]){
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
const increment = (number) => {
|
||||
for ( let i = 0; i < number.length; i++ ) {
|
||||
if(number[i]===255){
|
||||
number[i] = 0;
|
||||
} else {
|
||||
number[i]++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
addEventListener('message', async (event) => {
|
||||
let data = decodeHex(event.data.challenge);
|
||||
let target = decodeHex("0".repeat(event.data.difficulty) + "f".repeat(64 - event.data.difficulty));
|
||||
|
||||
let nonce = new Uint8Array(8);
|
||||
let buf = new Uint8Array(data.length + nonce.length);
|
||||
buf.set(data, 0);
|
||||
|
||||
while(true) {
|
||||
buf.set(nonce, data.length);
|
||||
let result = new Uint8Array(await crypto.subtle.digest("SHA-256", buf))
|
||||
|
||||
if (lessThan(result, target)){
|
||||
const nonceNumber = Number(new BigUint64Array(nonce.buffer).at(0))
|
||||
postMessage({
|
||||
result: {
|
||||
hash: encodeHex(result),
|
||||
nonce: nonceNumber,
|
||||
},
|
||||
info: `iterations ${nonceNumber}`,
|
||||
});
|
||||
return
|
||||
}
|
||||
increment(nonce)
|
||||
}
|
||||
|
||||
});
|
||||
}.toString();
|
||||
}
|
||||
|
||||
export { setup, challenge }
|
59
challenge/tinygo.go
Normal file
59
challenge/tinygo.go
Normal file
@@ -0,0 +1,59 @@
|
||||
//go:build tinygo
|
||||
|
||||
package challenge
|
||||
|
||||
// #include <stdlib.h>
|
||||
import "C"
|
||||
import (
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
// PtrToBytes returns a byte slice from WebAssembly compatible numeric types
|
||||
// representing its pointer and length.
|
||||
func PtrToBytes(ptr uint32, size uint32) []byte {
|
||||
return unsafe.Slice((*byte)(unsafe.Pointer(uintptr(ptr))), size)
|
||||
}
|
||||
|
||||
// BytesToPtr returns a pointer and size pair for the given byte slice in a way
|
||||
// compatible with WebAssembly numeric types.
|
||||
// The returned pointer aliases the slice hence the slice must be kept alive
|
||||
// until ptr is no longer needed.
|
||||
func BytesToPtr(s []byte) (uint32, uint32) {
|
||||
ptr := unsafe.Pointer(unsafe.SliceData(s))
|
||||
return uint32(uintptr(ptr)), uint32(len(s))
|
||||
}
|
||||
|
||||
// BytesToLeakedPtr returns a pointer and size pair for the given byte slice in a way
|
||||
// compatible with WebAssembly numeric types.
|
||||
// The pointer is not automatically managed by TinyGo hence it must be freed by the host.
|
||||
func BytesToLeakedPtr(s []byte) (uint32, uint32) {
|
||||
size := C.ulong(len(s))
|
||||
ptr := unsafe.Pointer(C.malloc(size))
|
||||
copy(unsafe.Slice((*byte)(ptr), size), s)
|
||||
return uint32(uintptr(ptr)), uint32(size)
|
||||
}
|
||||
|
||||
// PtrToString returns a string from WebAssembly compatible numeric types
|
||||
// representing its pointer and length.
|
||||
func PtrToString(ptr uint32, size uint32) string {
|
||||
return unsafe.String((*byte)(unsafe.Pointer(uintptr(ptr))), size)
|
||||
}
|
||||
|
||||
// StringToPtr returns a pointer and size pair for the given string in a way
|
||||
// compatible with WebAssembly numeric types.
|
||||
// The returned pointer aliases the string hence the string must be kept alive
|
||||
// until ptr is no longer needed.
|
||||
func StringToPtr(s string) (uint32, uint32) {
|
||||
ptr := unsafe.Pointer(unsafe.StringData(s))
|
||||
return uint32(uintptr(ptr)), uint32(len(s))
|
||||
}
|
||||
|
||||
// StringToLeakedPtr returns a pointer and size pair for the given string in a way
|
||||
// compatible with WebAssembly numeric types.
|
||||
// The pointer is not automatically managed by TinyGo hence it must be freed by the host.
|
||||
func StringToLeakedPtr(s string) (uint32, uint32) {
|
||||
size := C.ulong(len(s))
|
||||
ptr := unsafe.Pointer(C.malloc(size))
|
||||
copy(unsafe.Slice((*byte)(ptr), size), s)
|
||||
return uint32(uintptr(ptr)), uint32(size)
|
||||
}
|
150
cmd/away.go
Normal file
150
cmd/away.go
Normal file
@@ -0,0 +1,150 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
go_away "git.gammaspectra.live/git/go-away"
|
||||
"gopkg.in/yaml.v3"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
func makeReverseProxy(target string) (http.Handler, error) {
|
||||
u, err := url.Parse(target)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse target URL: %w", err)
|
||||
}
|
||||
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
|
||||
// https://github.com/oauth2-proxy/oauth2-proxy/blob/4e2100a2879ef06aea1411790327019c1a09217c/pkg/upstream/http.go#L124
|
||||
if u.Scheme == "unix" {
|
||||
// clean path up so we don't use the socket path in proxied requests
|
||||
addr := u.Path
|
||||
u.Path = ""
|
||||
// tell transport how to dial unix sockets
|
||||
transport.DialContext = func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||
dialer := net.Dialer{}
|
||||
return dialer.DialContext(ctx, "unix", addr)
|
||||
}
|
||||
// tell transport how to handle the unix url scheme
|
||||
transport.RegisterProtocol("unix", go_away.UnixRoundTripper{Transport: transport})
|
||||
}
|
||||
|
||||
rp := httputil.NewSingleHostReverseProxy(u)
|
||||
rp.Transport = transport
|
||||
|
||||
return rp, nil
|
||||
}
|
||||
|
||||
func setupListener(network, address, socketMode string) (net.Listener, string) {
|
||||
formattedAddress := ""
|
||||
switch network {
|
||||
case "unix":
|
||||
formattedAddress = "unix:" + address
|
||||
case "tcp":
|
||||
formattedAddress = "http://localhost" + address
|
||||
default:
|
||||
formattedAddress = fmt.Sprintf(`(%s) %s`, network, address)
|
||||
}
|
||||
|
||||
listener, err := net.Listen(network, address)
|
||||
if err != nil {
|
||||
log.Fatal(fmt.Errorf("failed to bind to %s: %w", formattedAddress, err))
|
||||
}
|
||||
|
||||
// additional permission handling for unix sockets
|
||||
if network == "unix" {
|
||||
mode, err := strconv.ParseUint(socketMode, 8, 0)
|
||||
if err != nil {
|
||||
listener.Close()
|
||||
log.Fatal(fmt.Errorf("could not parse socket mode %s: %w", socketMode, err))
|
||||
}
|
||||
|
||||
err = os.Chmod(address, os.FileMode(mode))
|
||||
if err != nil {
|
||||
listener.Close()
|
||||
log.Fatal(fmt.Errorf("could not change socket mode: %w", err))
|
||||
}
|
||||
}
|
||||
|
||||
return listener, formattedAddress
|
||||
}
|
||||
|
||||
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")
|
||||
socketMode := flag.String("socket-mode", "0770", "socket mode (permissions) for unix domain sockets.")
|
||||
|
||||
slogLevel := flag.String("slog-level", "INFO", "logging level (see https://pkg.go.dev/log/slog#hdr-Levels)")
|
||||
|
||||
target := flag.String("target", "http://localhost:80", "target to reverse proxy to")
|
||||
|
||||
policyFile := flag.String("policy", "", "path to policy YAML file")
|
||||
|
||||
flag.Parse()
|
||||
|
||||
_, _, _, _ = bind, bindNetwork, socketMode, target
|
||||
|
||||
{
|
||||
var programLevel slog.Level
|
||||
if err := (&programLevel).UnmarshalText([]byte(*slogLevel)); err != nil {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "invalid log level %s: %v, using info\n", *slogLevel, err)
|
||||
programLevel = slog.LevelInfo
|
||||
}
|
||||
|
||||
leveler := &slog.LevelVar{}
|
||||
leveler.Set(programLevel)
|
||||
|
||||
h := slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{
|
||||
AddSource: true,
|
||||
Level: leveler,
|
||||
})
|
||||
slog.SetDefault(slog.New(h))
|
||||
}
|
||||
|
||||
policyData, err := os.ReadFile(*policyFile)
|
||||
if err != nil {
|
||||
log.Fatal(fmt.Errorf("failed to read policy file: %w", err))
|
||||
}
|
||||
|
||||
var policy go_away.Policy
|
||||
|
||||
if err = yaml.Unmarshal(policyData, &policy); err != nil {
|
||||
log.Fatal(fmt.Errorf("failed to parse policy file: %w", err))
|
||||
}
|
||||
|
||||
backend, err := makeReverseProxy(*target)
|
||||
if err != nil {
|
||||
log.Fatal(fmt.Errorf("failed to create reverse proxy for %s: %w", *target, err))
|
||||
}
|
||||
|
||||
state, err := go_away.NewState(policy, "git.gammaspectra.live/git/go-away/cmd", backend)
|
||||
|
||||
if err != nil {
|
||||
log.Fatal(fmt.Errorf("failed to create state: %w", err))
|
||||
}
|
||||
|
||||
listener, listenUrl := setupListener(*bindNetwork, *bind, *socketMode)
|
||||
slog.Info(
|
||||
"listening",
|
||||
"url", listenUrl,
|
||||
"target", *target,
|
||||
)
|
||||
|
||||
server := http.Server{
|
||||
Handler: state,
|
||||
}
|
||||
|
||||
if err := server.Serve(listener); !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
53
condition.go
Normal file
53
condition.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package go_away
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/google/cel-go/cel"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Condition struct {
|
||||
Expression *cel.Ast
|
||||
}
|
||||
|
||||
const (
|
||||
OperatorOr = "||"
|
||||
OperatorAnd = "&&"
|
||||
)
|
||||
|
||||
func ConditionFromStrings(env *cel.Env, operator string, conditions ...string) (*cel.Ast, error) {
|
||||
var asts []*cel.Ast
|
||||
for _, c := range conditions {
|
||||
ast, issues := env.Compile(c)
|
||||
if issues != nil && issues.Err() != nil {
|
||||
return nil, fmt.Errorf("condition %s: %s", issues.Err(), c)
|
||||
}
|
||||
asts = append(asts, ast)
|
||||
}
|
||||
|
||||
return MergeConditions(env, operator, asts...)
|
||||
}
|
||||
|
||||
func MergeConditions(env *cel.Env, operator string, conditions ...*cel.Ast) (*cel.Ast, error) {
|
||||
if len(conditions) == 0 {
|
||||
return nil, nil
|
||||
} else if len(conditions) == 1 {
|
||||
return conditions[0], nil
|
||||
}
|
||||
var asts []string
|
||||
for _, c := range conditions {
|
||||
ast, err := cel.AstToString(c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
asts = append(asts, "("+ast+")")
|
||||
}
|
||||
|
||||
condition := strings.Join(asts, " "+operator+" ")
|
||||
ast, issues := env.Compile(condition)
|
||||
if issues != nil && issues.Err() != nil {
|
||||
return nil, issues.Err()
|
||||
}
|
||||
|
||||
return ast, nil
|
||||
}
|
27
cookie.go
Normal file
27
cookie.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package go_away
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
var CookiePrefix = ".go-away-"
|
||||
|
||||
func SetCookie(name, value string, expiry time.Time, w http.ResponseWriter) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: name,
|
||||
Value: value,
|
||||
Expires: expiry,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Path: "/",
|
||||
})
|
||||
}
|
||||
func ClearCookie(name string, w http.ResponseWriter) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: name,
|
||||
Value: "",
|
||||
Expires: time.Now().Add(-1 * time.Hour),
|
||||
MaxAge: -1,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
44
go.mod
Normal file
44
go.mod
Normal file
@@ -0,0 +1,44 @@
|
||||
module git.gammaspectra.live/git/go-away
|
||||
|
||||
go 1.24
|
||||
|
||||
require (
|
||||
codeberg.org/meta/gzipped/v2 v2.0.0-20231111234332-aa70c3194756
|
||||
github.com/go-jose/go-jose/v4 v4.0.5
|
||||
github.com/google/cel-go v0.24.1
|
||||
github.com/itchyny/gojq v0.12.17
|
||||
github.com/tetratelabs/wazero v1.9.0
|
||||
github.com/yl2chen/cidranger v1.0.2
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
cel.dev/expr v0.19.1 // indirect
|
||||
github.com/antlr4-go/antlr/v4 v4.13.0 // indirect
|
||||
github.com/aykevl/go-wasm v0.0.2-0.20240825160117-b76c3f9f0982 // indirect
|
||||
github.com/blakesmith/ar v0.0.0-20150311145944-8bd4349a67f2 // indirect
|
||||
github.com/creack/goselect v0.1.2 // indirect
|
||||
github.com/gofrs/flock v0.8.1 // indirect
|
||||
github.com/google/shlex v0.0.0-20181106134648-c34317bd91bf // indirect
|
||||
github.com/inhies/go-bytesize v0.0.0-20220417184213-4913239db9cf // indirect
|
||||
github.com/itchyny/timefmt-go v0.1.6 // indirect
|
||||
github.com/kevinpollet/nego v0.0.0-20211010160919-a65cd48cee43 // indirect
|
||||
github.com/marcinbor85/gohex v0.0.0-20200531091804-343a4b548892 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-tty v0.0.4 // indirect
|
||||
github.com/sigurn/crc16 v0.0.0-20211026045750-20ab5afb07e3 // indirect
|
||||
github.com/stoewer/go-strcase v1.2.0 // indirect
|
||||
github.com/tinygo-org/tinygo v0.37.0 // indirect
|
||||
go.bug.st/serial v1.6.0 // indirect
|
||||
golang.org/x/crypto v0.32.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc // indirect
|
||||
golang.org/x/sys v0.29.0 // indirect
|
||||
golang.org/x/tools v0.22.1-0.20240621165957-db513b091504 // 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.34.2 // indirect
|
||||
tinygo.org/x/go-llvm v0.0.0-20250119132755-9dca92dfb4f9 // indirect
|
||||
)
|
||||
|
||||
tool github.com/tinygo-org/tinygo
|
99
go.sum
Normal file
99
go.sum
Normal file
@@ -0,0 +1,99 @@
|
||||
cel.dev/expr v0.19.1 h1:NciYrtDRIR0lNCnH1LFJegdjspNx9fI59O7TWcua/W4=
|
||||
cel.dev/expr v0.19.1/go.mod h1:MrpN08Q+lEBs+bGYdLxxHkZoUSsCp0nSKTs0nTymJgw=
|
||||
codeberg.org/meta/gzipped/v2 v2.0.0-20231111234332-aa70c3194756 h1:bDqEUEYt4UJy8mfLCZeJuXx+xNJvdqTbkE4Ci11NQYU=
|
||||
codeberg.org/meta/gzipped/v2 v2.0.0-20231111234332-aa70c3194756/go.mod h1:aJ/ghJW7viYfwZ6OizDst+uJgbb6r/Hvoqhmi1OPTTw=
|
||||
github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI=
|
||||
github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g=
|
||||
github.com/aykevl/go-wasm v0.0.2-0.20240825160117-b76c3f9f0982 h1:cD7QfvrJdYmBw2tFP/VyKPT8ZESlcrwSwo7SvH9Y4dc=
|
||||
github.com/aykevl/go-wasm v0.0.2-0.20240825160117-b76c3f9f0982/go.mod h1:7sXyiaA0WtSogCu67R2252fQpVmJMh9JWJ9ddtGkpWw=
|
||||
github.com/blakesmith/ar v0.0.0-20150311145944-8bd4349a67f2 h1:oMCHnXa6CCCafdPDbMh/lWRhRByN0VFLvv+g+ayx1SI=
|
||||
github.com/blakesmith/ar v0.0.0-20150311145944-8bd4349a67f2/go.mod h1:PkYb9DJNAwrSvRx5DYA+gUcOIgTGVMNkfSCbZM8cWpI=
|
||||
github.com/creack/goselect v0.1.2 h1:2DNy14+JPjRBgPzAd1thbQp4BSIihxcBf0IXhQXDRa0=
|
||||
github.com/creack/goselect v0.1.2/go.mod h1:a/NhLweNvqIYMuxcMOuWY516Cimucms3DglDzQP3hKY=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
|
||||
github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA=
|
||||
github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw=
|
||||
github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU=
|
||||
github.com/google/cel-go v0.24.1 h1:jsBCtxG8mM5wiUJDSGUqU0K7Mtr3w7Eyv00rw4DiZxI=
|
||||
github.com/google/cel-go v0.24.1/go.mod h1:Hdf9TqOaTNSFQA1ybQaRqATVoK7m/zcf7IMhGXP5zI8=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/shlex v0.0.0-20181106134648-c34317bd91bf h1:7+FW5aGwISbqUtkfmIpZJGRgNFg2ioYPvFaUxdqpDsg=
|
||||
github.com/google/shlex v0.0.0-20181106134648-c34317bd91bf/go.mod h1:RpwtwJQFrIEPstU94h88MWPXP2ektJZ8cZ0YntAmXiE=
|
||||
github.com/inhies/go-bytesize v0.0.0-20220417184213-4913239db9cf h1:FtEj8sfIcaaBfAKrE1Cwb61YDtYq9JxChK1c7AKce7s=
|
||||
github.com/inhies/go-bytesize v0.0.0-20220417184213-4913239db9cf/go.mod h1:yrqSXGoD/4EKfF26AOGzscPOgTTJcyAwM2rpixWT+t4=
|
||||
github.com/itchyny/gojq v0.12.17 h1:8av8eGduDb5+rvEdaOO+zQUjA04MS0m3Ps8HiD+fceg=
|
||||
github.com/itchyny/gojq v0.12.17/go.mod h1:WBrEMkgAfAGO1LUcGOckBl5O726KPp+OlkKug0I/FEY=
|
||||
github.com/itchyny/timefmt-go v0.1.6 h1:ia3s54iciXDdzWzwaVKXZPbiXzxxnv1SPGFfM/myJ5Q=
|
||||
github.com/itchyny/timefmt-go v0.1.6/go.mod h1:RRDZYC5s9ErkjQvTvvU7keJjxUYzIISJGxm9/mAERQg=
|
||||
github.com/kevinpollet/nego v0.0.0-20211010160919-a65cd48cee43 h1:Pdirg1gwhEcGjMLyuSxGn9664p+P8J9SrfMgpFwrDyg=
|
||||
github.com/kevinpollet/nego v0.0.0-20211010160919-a65cd48cee43/go.mod h1:ahLMuLCUyDdXqtqGyuwGev7/PGtO7r7ocvdwDuEN/3E=
|
||||
github.com/marcinbor85/gohex v0.0.0-20200531091804-343a4b548892 h1:6J+qramlHVLmiBOgRiBOnQkno8uprqG6YFFQTt6uYIw=
|
||||
github.com/marcinbor85/gohex v0.0.0-20200531091804-343a4b548892/go.mod h1:Pb6XcsXyropB9LNHhnqaknG/vEwYztLkQzVCHv8sQ3M=
|
||||
github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
|
||||
github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.7/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
|
||||
github.com/mattn/go-tty v0.0.4 h1:NVikla9X8MN0SQAqCYzpGyXv0jY7MNl3HOWD2dkle7E=
|
||||
github.com/mattn/go-tty v0.0.4/go.mod h1:u5GGXBtZU6RQoKV8gY5W6UhMudbR5vXnUe7j3pxse28=
|
||||
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/sigurn/crc16 v0.0.0-20211026045750-20ab5afb07e3 h1:aQKxg3+2p+IFXXg97McgDGT5zcMrQoi0EICZs8Pgchs=
|
||||
github.com/sigurn/crc16 v0.0.0-20211026045750-20ab5afb07e3/go.mod h1:9/etS5gpQq9BJsJMWg1wpLbfuSnkm8dPF6FdW2JXVhA=
|
||||
github.com/stoewer/go-strcase v1.2.0 h1:Z2iHWqGXH00XYgqDmNgQbIBxf3wrNq0F3feEy0ainaU=
|
||||
github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag1KpM8ahLw8=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I=
|
||||
github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM=
|
||||
github.com/tinygo-org/tinygo v0.37.0 h1:N6ThUAOfgqcsZmcbkFBIJQSTBe4d/JFyTSbEjIy9yeU=
|
||||
github.com/tinygo-org/tinygo v0.37.0/go.mod h1:k3d5kfRLHcJ+bvZLe/VOlF00NVd/cvjgIEIyManFmF0=
|
||||
github.com/yl2chen/cidranger v1.0.2 h1:lbOWZVCG1tCRX4u24kuM1Tb4nHqWkDxwLdoS+SevawU=
|
||||
github.com/yl2chen/cidranger v1.0.2/go.mod h1:9U1yz7WPYDwf0vpNWFaeRh0bjwz5RVgRy/9UEQfHl0g=
|
||||
go.bug.st/serial v1.6.0 h1:mAbRGN4cKE2J5gMwsMHC2KQisdLRQssO9WSM+rbZJ8A=
|
||||
go.bug.st/serial v1.6.0/go.mod h1:UABfsluHAiaNI+La2iESysd9Vetq7VRdpxvjx7CmmOE=
|
||||
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
|
||||
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
|
||||
golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc h1:mCRnTeVUjcrhlRmO0VK8a6k6Rrf6TF9htwo2pJVSjIU=
|
||||
golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w=
|
||||
golang.org/x/mod v0.18.0 h1:5+9lSbEzPSdWkH32vYPBwEpX8KwDbM52Ud9xBUvNlb0=
|
||||
golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
|
||||
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/tools v0.22.1-0.20240621165957-db513b091504 h1:MMsD8mMfluf/578+3wrTn22pjI/Xkzm+gPW47SYfspY=
|
||||
golang.org/x/tools v0.22.1-0.20240621165957-db513b091504/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20240826202546-f6391c0de4c7 h1:YcyjlL1PRr2Q17/I0dPk2JmYS5CDXfcdb2Z3YRioEbw=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20240826202546-f6391c0de4c7/go.mod h1:OCdP9MfskevB/rbYvHTsXTtKC+3bHWajPdoKgjcYkfo=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240826202546-f6391c0de4c7 h1:2035KHhUv+EpyB+hWgJnaWKJOdX1E95w2S8Rr4uWKTs=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240826202546-f6391c0de4c7/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU=
|
||||
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
|
||||
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
tinygo.org/x/go-llvm v0.0.0-20250119132755-9dca92dfb4f9 h1:rMvEzuCYjyiR+pmdiCVWTQw3L6VqiSIXoL19I3lYufE=
|
||||
tinygo.org/x/go-llvm v0.0.0-20250119132755-9dca92dfb4f9/go.mod h1:GFbusT2VTA4I+l4j80b17KFK+6whv69Wtny5U+T8RR0=
|
221
http.go
Normal file
221
http.go
Normal file
@@ -0,0 +1,221 @@
|
||||
package go_away
|
||||
|
||||
import (
|
||||
"codeberg.org/meta/gzipped/v2"
|
||||
"crypto/rand"
|
||||
"embed"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/google/cel-go/common/types"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
//go:embed assets
|
||||
var assetsFs embed.FS
|
||||
|
||||
//go:embed challenge
|
||||
var challengesFs embed.FS
|
||||
|
||||
//go:embed templates
|
||||
var templatesFs embed.FS
|
||||
|
||||
var templates map[string]*template.Template
|
||||
|
||||
var cacheBust string
|
||||
|
||||
// DefaultValidity TODO: adjust
|
||||
const DefaultValidity = time.Hour * 24 * 7
|
||||
|
||||
func init() {
|
||||
|
||||
buf := make([]byte, 16)
|
||||
_, _ = rand.Read(buf)
|
||||
cacheBust = base64.RawURLEncoding.EncodeToString(buf)
|
||||
|
||||
templates = make(map[string]*template.Template)
|
||||
|
||||
dir, err := templatesFs.ReadDir("templates")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
for _, e := range dir {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
data, err := templatesFs.ReadFile(filepath.Join("templates", e.Name()))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
tpl := template.New(e.Name())
|
||||
_, err = tpl.Parse(string(data))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
templates[e.Name()] = tpl
|
||||
}
|
||||
}
|
||||
|
||||
func (state *State) handleRequest(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
//TODO better matcher! combo ast?
|
||||
env := map[string]any{
|
||||
"remoteAddress": state.GetRequestAddress(r),
|
||||
"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 _, rule := range state.Rules {
|
||||
if out, _, err := rule.Program.Eval(env); err != nil {
|
||||
//TODO error
|
||||
panic(err)
|
||||
} else if out != nil && out.Type() == types.BoolType {
|
||||
if out.Equal(types.True) == types.True {
|
||||
switch rule.Action {
|
||||
default:
|
||||
panic(fmt.Errorf("unknown action %s", rule.Action))
|
||||
case PolicyRuleActionPASS:
|
||||
//fallback, proxy!
|
||||
state.Backend.ServeHTTP(w, r)
|
||||
return
|
||||
case PolicyRuleActionCHALLENGE:
|
||||
expiry := time.Now().UTC().Add(DefaultValidity).Round(DefaultValidity)
|
||||
|
||||
for _, challengeName := range rule.Challenges {
|
||||
key := state.GetChallengeKeyForRequest(challengeName, expiry, r)
|
||||
ok, err := state.VerifyChallengeToken(challengeName, key, r)
|
||||
if !ok || err != nil {
|
||||
if !errors.Is(err, http.ErrNoCookie) {
|
||||
ClearCookie(CookiePrefix+challengeName, w)
|
||||
}
|
||||
} else {
|
||||
// we passed the challenge!
|
||||
//TODO log?
|
||||
state.Backend.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// none matched, issue first challenge in priority
|
||||
for _, challengeName := range rule.Challenges {
|
||||
c := state.Challenges[challengeName]
|
||||
if c.Challenge != nil {
|
||||
result := c.Challenge(w, r, state.GetChallengeKeyForRequest(challengeName, expiry, r), expiry)
|
||||
switch result {
|
||||
case ChallengeResultStop:
|
||||
return
|
||||
case ChallengeResultContinue:
|
||||
continue
|
||||
case ChallengeResultPass:
|
||||
// we pass the challenge early!
|
||||
state.Backend.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
panic("challenge not found")
|
||||
}
|
||||
}
|
||||
case PolicyRuleActionDENY:
|
||||
//TODO: config error code
|
||||
http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
|
||||
return
|
||||
case PolicyRuleActionBLOCK:
|
||||
//TODO: config error code
|
||||
http.Error(w, http.StatusText(http.StatusTooManyRequests), http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (state *State) setupRoutes() error {
|
||||
|
||||
state.Mux.HandleFunc("/", state.handleRequest)
|
||||
|
||||
state.Mux.Handle("GET "+state.UrlPath+"/assets/", http.StripPrefix(state.UrlPath, gzipped.FileServer(gzipped.FS(assetsFs))))
|
||||
|
||||
for challengeName, c := range state.Challenges {
|
||||
if c.Static != nil {
|
||||
state.Mux.Handle("GET "+c.Path+"/static/", c.Static)
|
||||
}
|
||||
|
||||
if c.ChallengeScript != nil {
|
||||
state.Mux.Handle("GET "+c.ChallengeScriptPath, c.ChallengeScript)
|
||||
}
|
||||
|
||||
if c.MakeChallenge != nil {
|
||||
state.Mux.Handle(fmt.Sprintf("POST %s/make-challenge", c.Path), c.MakeChallenge)
|
||||
}
|
||||
|
||||
if c.Verify != nil {
|
||||
state.Mux.HandleFunc(fmt.Sprintf("GET %s/verify-challenge", c.Path), func(w http.ResponseWriter, r *http.Request) {
|
||||
err := func() (err error) {
|
||||
expiry := time.Now().UTC().Add(DefaultValidity).Round(DefaultValidity)
|
||||
key := state.GetChallengeKeyForRequest(challengeName, expiry, r)
|
||||
result := []byte(r.FormValue("result"))
|
||||
|
||||
if ok, err := c.Verify(key, string(result)); err != nil {
|
||||
return err
|
||||
} else if !ok {
|
||||
ClearCookie(CookiePrefix+challengeName, w)
|
||||
http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
|
||||
return nil
|
||||
}
|
||||
|
||||
token, err := state.IssueChallengeToken(challengeName, key, result, expiry)
|
||||
if err != nil {
|
||||
ClearCookie(CookiePrefix+challengeName, w)
|
||||
} else {
|
||||
SetCookie(CookiePrefix+challengeName, token, expiry, w)
|
||||
}
|
||||
|
||||
http.Redirect(w, r, r.FormValue("redirect"), http.StatusTemporaryRedirect)
|
||||
return nil
|
||||
}()
|
||||
if err != nil {
|
||||
ClearCookie(CookiePrefix+challengeName, w)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UnixRoundTripper https://github.com/oauth2-proxy/oauth2-proxy/blob/master/pkg/upstream/http.go#L124
|
||||
type UnixRoundTripper struct {
|
||||
Transport *http.Transport
|
||||
}
|
||||
|
||||
// RoundTrip set bare minimum stuff
|
||||
func (t UnixRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
req = req.Clone(req.Context())
|
||||
if req.Host == "" {
|
||||
req.Host = "localhost"
|
||||
}
|
||||
req.URL.Host = req.Host // proxy error: no Host in request URL
|
||||
req.URL.Scheme = "http" // make http.Transport happy and avoid an infinite recursion
|
||||
return t.Transport.RoundTrip(req)
|
||||
}
|
188
policy.go
Normal file
188
policy.go
Normal file
@@ -0,0 +1,188 @@
|
||||
package go_away
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/itchyny/gojq"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
func parseCIDROrIP(value string) (net.IPNet, error) {
|
||||
_, ipNet, err := net.ParseCIDR(value)
|
||||
if err != nil {
|
||||
ip := net.ParseIP(value)
|
||||
if ip == nil {
|
||||
return net.IPNet{}, fmt.Errorf("failed to parse CIDR: %s", err)
|
||||
}
|
||||
|
||||
if ip4 := ip.To4(); ip4 != nil {
|
||||
return net.IPNet{
|
||||
IP: ip4,
|
||||
// single ip
|
||||
Mask: net.CIDRMask(len(ip4)*8, len(ip4)*8),
|
||||
}, nil
|
||||
}
|
||||
return net.IPNet{
|
||||
IP: ip,
|
||||
// single ip
|
||||
Mask: net.CIDRMask(len(ip)*8, len(ip)*8),
|
||||
}, nil
|
||||
} else if ipNet != nil {
|
||||
return *ipNet, nil
|
||||
} else {
|
||||
return net.IPNet{}, errors.New("invalid CIDR")
|
||||
}
|
||||
}
|
||||
|
||||
type Policy struct {
|
||||
// UserAgents map of a list of user-agent regex
|
||||
UserAgents map[string][]string `yaml:"user-agents"`
|
||||
// Networks map of networks and prefixes to be loaded
|
||||
Networks map[string][]PolicyNetwork `yaml:"networks"`
|
||||
|
||||
Conditions map[string][]string `yaml:"conditions"`
|
||||
|
||||
Challenges map[string]PolicyChallenge `yaml:"challenges"`
|
||||
|
||||
Rules []PolicyRule `yaml:"rules"`
|
||||
}
|
||||
|
||||
type PolicyRuleAction string
|
||||
|
||||
const (
|
||||
PolicyRuleActionPASS PolicyRuleAction = "PASS"
|
||||
PolicyRuleActionDENY PolicyRuleAction = "DENY"
|
||||
PolicyRuleActionBLOCK PolicyRuleAction = "BLOCK"
|
||||
PolicyRuleActionCHALLENGE PolicyRuleAction = "CHALLENGE"
|
||||
)
|
||||
|
||||
type PolicyRule struct {
|
||||
Name string `yaml:"name"`
|
||||
Conditions []string `yaml:"conditions"`
|
||||
|
||||
Action string `yaml:"action"`
|
||||
|
||||
Challenges []string `yaml:"challenges"`
|
||||
}
|
||||
|
||||
type PolicyChallenge struct {
|
||||
Mode string `yaml:"mode"`
|
||||
Asset *string `yaml:"asset,omitempty"`
|
||||
Url *string `yaml:"url,omitempty"`
|
||||
|
||||
Parameters map[string]string `json:"parameters,omitempty"`
|
||||
Runtime struct {
|
||||
Mode string `yaml:"mode,omitempty"`
|
||||
Asset string `yaml:"asset,omitempty"`
|
||||
Probability float64 `yaml:"probability,omitempty"`
|
||||
} `yaml:"runtime"`
|
||||
}
|
||||
|
||||
type PolicyNetwork struct {
|
||||
Url *string `yaml:"url,omitempty"`
|
||||
File *string `yaml:"file,omitempty"`
|
||||
|
||||
JqPath *string `yaml:"jq-path,omitempty"`
|
||||
Regex *string `yaml:"regex,omitempty"`
|
||||
|
||||
Prefixes []string `yaml:"prefixes,omitempty"`
|
||||
}
|
||||
|
||||
func (n PolicyNetwork) FetchPrefixes() (output []net.IPNet, err error) {
|
||||
if len(n.Prefixes) > 0 {
|
||||
for _, prefix := range n.Prefixes {
|
||||
ipNet, err := parseCIDROrIP(prefix)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
output = append(output, ipNet)
|
||||
}
|
||||
}
|
||||
|
||||
var reader io.Reader
|
||||
if n.Url != nil {
|
||||
response, err := http.DefaultClient.Get(*n.Url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("unexpected status code: %d", response.StatusCode)
|
||||
}
|
||||
reader = response.Body
|
||||
} else if n.File != nil {
|
||||
file, err := os.Open(*n.File)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
reader = file
|
||||
} else {
|
||||
if len(output) > 0 {
|
||||
return output, nil
|
||||
}
|
||||
return nil, errors.New("no url, file or prefixes specified")
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if n.JqPath != nil {
|
||||
var jsonData any
|
||||
err = json.Unmarshal(data, &jsonData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
query, err := gojq.Parse(*n.JqPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
iter := query.Run(jsonData)
|
||||
for {
|
||||
value, more := iter.Next()
|
||||
if !more {
|
||||
break
|
||||
}
|
||||
|
||||
if strValue, ok := value.(string); ok {
|
||||
ipNet, err := parseCIDROrIP(strValue)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
output = append(output, ipNet)
|
||||
} else {
|
||||
return nil, fmt.Errorf("invalid value from jq-query: %v", value)
|
||||
}
|
||||
}
|
||||
return output, nil
|
||||
} else if n.Regex != nil {
|
||||
expr, err := regexp.Compile(*n.Regex)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
prefixName := expr.SubexpIndex("prefix")
|
||||
if prefixName == -1 {
|
||||
return nil, fmt.Errorf("invalid regex %q: could not find prefix named match", *n.Regex)
|
||||
}
|
||||
matches := expr.FindAllSubmatch(data, -1)
|
||||
for _, match := range matches {
|
||||
matchName := string(match[prefixName])
|
||||
ipNet, err := parseCIDROrIP(matchName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
output = append(output, ipNet)
|
||||
}
|
||||
} else {
|
||||
return nil, errors.New("no jq-path or regex specified")
|
||||
}
|
||||
return output, nil
|
||||
}
|
150
policy.yml
Normal file
150
policy.yml
Normal file
@@ -0,0 +1,150 @@
|
||||
# Define groups of useragents to use later below for matching
|
||||
user-agents:
|
||||
default-browser:
|
||||
- "^Mozilla/"
|
||||
- "^Opera/"
|
||||
bad-crawlers:
|
||||
- "Amazonbot"
|
||||
headless-browser:
|
||||
- "HeadlessChrome"
|
||||
- "HeadlessChromium"
|
||||
- "^Lightpanda/"
|
||||
- "^$"
|
||||
rss:
|
||||
- "FeedFetcher-Google"
|
||||
git:
|
||||
- "^git/"
|
||||
- "^go-git/"
|
||||
- "^JGit[/-]"
|
||||
- "^GoModuleMirror/"
|
||||
|
||||
# Define networks to be used later below
|
||||
networks:
|
||||
# todo: support ASN lookups
|
||||
huawei-cloud:
|
||||
# AS136907
|
||||
- url: https://raw.githubusercontent.com/ipverse/asn-ip/refs/heads/master/as/136907/aggregated.json
|
||||
jq-path: '.subnets.ipv4[], .subnets.ipv6[]'
|
||||
alibaba-cloud:
|
||||
# AS45102
|
||||
- url: https://raw.githubusercontent.com/ipverse/asn-ip/refs/heads/master/as/45102/aggregated.json
|
||||
jq-path: '.subnets.ipv4[], .subnets.ipv6[]'
|
||||
googlebot:
|
||||
- url: https://developers.google.com/static/search/apis/ipranges/googlebot.json
|
||||
jq-path: '(.prefixes[] | select(has("ipv4Prefix")) | .ipv4Prefix), (.prefixes[] | select(has("ipv6Prefix")) | .ipv6Prefix)'
|
||||
bingbot:
|
||||
- url: https://www.bing.com/toolbox/bingbot.json
|
||||
jq-path: '(.prefixes[] | select(has("ipv4Prefix")) | .ipv4Prefix), (.prefixes[] | select(has("ipv6Prefix")) | .ipv6Prefix)'
|
||||
qwantbot:
|
||||
- url: https://help.qwant.com/wp-content/uploads/sites/2/2025/01/qwantbot.json
|
||||
jq-path: '(.prefixes[] | select(has("ipv4Prefix")) | .ipv4Prefix), (.prefixes[] | select(has("ipv6Prefix")) | .ipv6Prefix)'
|
||||
duckduckbot:
|
||||
- url: https://duckduckgo.com/duckduckgo-help-pages/results/duckduckbot/
|
||||
regex: "<li>(?P<prefix>[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+)</li>"
|
||||
yandexbot:
|
||||
# todo: detected as bot
|
||||
# - url: https://yandex.com/ips
|
||||
# regex: "<span>(?P<prefix>(([0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+)|([0-9a-f:]+::))/[0-9]+)[ \\\\t]*</span><br/>"
|
||||
- prefixes:
|
||||
- "5.45.192.0/18"
|
||||
- "5.255.192.0/18"
|
||||
- "37.9.64.0/18"
|
||||
- "37.140.128.0/18"
|
||||
- "77.88.0.0/18"
|
||||
- "84.252.160.0/19"
|
||||
- "87.250.224.0/19"
|
||||
- "90.156.176.0/22"
|
||||
- "93.158.128.0/18"
|
||||
- "95.108.128.0/17"
|
||||
- "141.8.128.0/18"
|
||||
- "178.154.128.0/18"
|
||||
- "185.32.187.0/24"
|
||||
- "2a02:6b8::/29"
|
||||
kagibot:
|
||||
- url: https://kagi.com/bot
|
||||
regex: "\\n(?P<prefix>[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+) "
|
||||
cloudflare:
|
||||
- url: https://www.cloudflare.com/ips-v4
|
||||
regex: "(?P<prefix>[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+/[0-9]+)"
|
||||
- url: https://www.cloudflare.com/ips-v6
|
||||
regex: "(?P<prefix>[0-9a-f:]+::/[0-9]+)"
|
||||
|
||||
|
||||
conditions:
|
||||
# Checks to detect a headless chromium via headers only
|
||||
is-headless-chromium:
|
||||
- 'userAgent.contains("HeadlessChrome") || userAgent.contains("HeadlessChromium")'
|
||||
- 'headers["Sec-Ch-Ua"].contains("HeadlessChrome") || headers["Sec-Ch-Ua"].contains("HeadlessChromium")'
|
||||
- '(userAgent.contains("Chrome/") || userAgent.contains("Chromium/")) && (headers["Accept-Language"] == "" || headers["Accept-Encoding"] == "")'
|
||||
is-static-asset:
|
||||
- 'path == "/robots.txt"'
|
||||
- 'path == "/favicon.ico"'
|
||||
- 'path == "/apple-touch-icon.png"'
|
||||
- 'path == "/apple-touch-icon-precomposed.png"'
|
||||
- 'path.startsWith("/assets/")'
|
||||
- 'path.startsWith("/repo-avatars/")'
|
||||
- 'path.startsWith("/avatars/")'
|
||||
- 'path.startsWith("/avatar/")'
|
||||
|
||||
|
||||
# todo: define interface
|
||||
challenges:
|
||||
js-pow-sha256:
|
||||
# Asset must be under challenges/{name}/static/{asset}
|
||||
# Other files here will be available under that path
|
||||
mode: js
|
||||
asset: load.mjs
|
||||
parameters:
|
||||
difficulty: 4
|
||||
runtime:
|
||||
mode: wasm
|
||||
# Verify must be under challenges/{name}/runtime/{asset}
|
||||
asset: runtime.wasm
|
||||
probability: 0.02
|
||||
|
||||
# Challenges with a cookie, self redirect
|
||||
self-cookie:
|
||||
mode: "cookie"
|
||||
|
||||
# Challenges with a redirect via header
|
||||
self-header-refresh:
|
||||
mode: "header-refresh"
|
||||
runtime:
|
||||
# verifies that result = key
|
||||
mode: "key"
|
||||
probability: 0.1
|
||||
|
||||
# Challenges with a redirect via meta
|
||||
self-meta-refresh:
|
||||
mode: "meta-refresh"
|
||||
runtime:
|
||||
# verifies that result = key
|
||||
mode: "key"
|
||||
probability: 0.1
|
||||
|
||||
http-cookie-check:
|
||||
mode: http
|
||||
url: http://172.20.5.5:3002/user/stopwatches
|
||||
# url: http://gitea:3000/repo/search
|
||||
# url: http://gitea:3000/notifications/new
|
||||
parameters:
|
||||
http-method: GET
|
||||
http-code: 200
|
||||
|
||||
rules:
|
||||
- name: blocked-networks
|
||||
conditions:
|
||||
- 'inNetwork("huawei-cloud", remoteAddress) || inNetwork("alibaba-cloud", remoteAddress)'
|
||||
action: deny
|
||||
|
||||
- name: golang-proxy
|
||||
conditions:
|
||||
- 'userAgent.startsWith("GoModuleMirror/") || (userAgent.startsWith("Go-http-client/") && query["go-get"] == "1")'
|
||||
action: pass
|
||||
|
||||
- name: standard-browser
|
||||
action: challenge
|
||||
challenges: [http-cookie-check, self-meta-refresh, js-pow-sha256]
|
||||
conditions:
|
||||
- 'userAgent.startsWith("Mozilla/") || userAgent.startsWith("Opera/")'
|
||||
|
512
state.go
Normal file
512
state.go
Normal file
@@ -0,0 +1,512 @@
|
||||
package go_away
|
||||
|
||||
import (
|
||||
"codeberg.org/meta/gzipped/v2"
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"git.gammaspectra.live/git/go-away/challenge"
|
||||
"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"
|
||||
"github.com/tetratelabs/wazero/api"
|
||||
"github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1"
|
||||
"github.com/yl2chen/cidranger"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type State struct {
|
||||
Client *http.Client
|
||||
PackagePath string
|
||||
UrlPath string
|
||||
Mux *http.ServeMux
|
||||
Backend http.Handler
|
||||
|
||||
Networks map[string]cidranger.Ranger
|
||||
|
||||
UserAgents map[string][]*regexp.Regexp
|
||||
|
||||
WasmRuntime wazero.Runtime
|
||||
WasmContext context.Context
|
||||
|
||||
Challenges map[string]ChallengeState
|
||||
|
||||
RulesEnv *cel.Env
|
||||
Conditions map[string]*cel.Ast
|
||||
|
||||
Rules []RuleState
|
||||
|
||||
PublicKey ed25519.PublicKey
|
||||
PrivateKey ed25519.PrivateKey
|
||||
}
|
||||
|
||||
type RuleState struct {
|
||||
Name string
|
||||
|
||||
Program cel.Program
|
||||
Action PolicyRuleAction
|
||||
Challenges []string
|
||||
}
|
||||
|
||||
type ChallengeResult int
|
||||
|
||||
const (
|
||||
// ChallengeResultStop Stop testing challenges and return
|
||||
ChallengeResultStop = ChallengeResult(iota)
|
||||
// ChallengeResultContinue Test next challenge
|
||||
ChallengeResultContinue
|
||||
// ChallengeResultPass Challenge passed, return and proxy
|
||||
ChallengeResultPass
|
||||
)
|
||||
|
||||
type ChallengeState struct {
|
||||
RuntimeModule wazero.CompiledModule
|
||||
|
||||
Path string
|
||||
|
||||
Static http.Handler
|
||||
Challenge func(w http.ResponseWriter, r *http.Request, key []byte, expiry time.Time) ChallengeResult
|
||||
ChallengeScriptPath string
|
||||
ChallengeScript http.Handler
|
||||
MakeChallenge http.Handler
|
||||
VerifyChallenge http.Handler
|
||||
|
||||
VerifyProbability float64
|
||||
Verify func(key []byte, result string) (bool, error)
|
||||
}
|
||||
|
||||
func NewState(policy Policy, packagePath string, backend http.Handler) (state *State, err error) {
|
||||
state = new(State)
|
||||
state.Client = &http.Client{
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
state.PackagePath = packagePath
|
||||
state.UrlPath = "/.well-known/." + state.PackagePath
|
||||
state.Backend = backend
|
||||
|
||||
state.UserAgents = make(map[string][]*regexp.Regexp)
|
||||
for k, v := range policy.UserAgents {
|
||||
for _, str := range v {
|
||||
expr, err := regexp.Compile(str)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("user-agent %s: invalid regex expression %s: %v", k, str, err)
|
||||
}
|
||||
state.UserAgents[k] = append(state.UserAgents[k], expr)
|
||||
}
|
||||
}
|
||||
state.Networks = make(map[string]cidranger.Ranger)
|
||||
for k, network := range policy.Networks {
|
||||
ranger := cidranger.NewPCTrieRanger()
|
||||
for _, e := range network {
|
||||
prefixes, err := e.FetchPrefixes()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("networks %s: error fetching prefixes: %v", k, err)
|
||||
}
|
||||
for _, prefix := range prefixes {
|
||||
err = ranger.Insert(cidranger.NewBasicRangerEntry(prefix))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("networks %s: error inserting prefix %s: %v", k, prefix.String(), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
state.Networks[k] = ranger
|
||||
}
|
||||
|
||||
state.WasmContext = context.Background()
|
||||
state.WasmRuntime = wazero.NewRuntimeWithConfig(state.WasmContext, wazero.NewRuntimeConfigCompiler())
|
||||
wasi_snapshot_preview1.MustInstantiate(state.WasmContext, state.WasmRuntime)
|
||||
|
||||
state.Challenges = make(map[string]ChallengeState)
|
||||
|
||||
for challengeName, p := range policy.Challenges {
|
||||
c := ChallengeState{
|
||||
Path: fmt.Sprintf("%s/challenge/%s", state.UrlPath, challengeName),
|
||||
VerifyProbability: p.Runtime.Probability,
|
||||
}
|
||||
|
||||
if c.VerifyProbability <= 0 {
|
||||
//10% default
|
||||
c.VerifyProbability = 0.1
|
||||
} else if c.VerifyProbability > 1.0 {
|
||||
c.VerifyProbability = 1.0
|
||||
}
|
||||
|
||||
assetPath := c.Path + "/static/"
|
||||
subFs, err := fs.Sub(challengesFs, fmt.Sprintf("challenge/%s/static", challengeName))
|
||||
if err == nil {
|
||||
c.Static = http.StripPrefix(
|
||||
assetPath,
|
||||
gzipped.FileServer(gzipped.FS(subFs)),
|
||||
)
|
||||
}
|
||||
|
||||
switch p.Mode {
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown challenge mode: %s", p.Mode)
|
||||
case "http":
|
||||
if p.Url == nil {
|
||||
return nil, fmt.Errorf("challenge %s: missing url", challengeName)
|
||||
}
|
||||
method := p.Parameters["http-method"]
|
||||
if method == "" {
|
||||
method = "GET"
|
||||
}
|
||||
|
||||
httpCode, _ := strconv.Atoi(p.Parameters["http-code"])
|
||||
if httpCode == 0 {
|
||||
httpCode = http.StatusOK
|
||||
}
|
||||
|
||||
//todo
|
||||
c.Challenge = func(w http.ResponseWriter, r *http.Request, key []byte, expiry time.Time) ChallengeResult {
|
||||
request, err := http.NewRequest(method, *p.Url, nil)
|
||||
if err != nil {
|
||||
return ChallengeResultContinue
|
||||
}
|
||||
|
||||
request.Header = r.Header
|
||||
response, err := state.Client.Do(request)
|
||||
if err != nil {
|
||||
return ChallengeResultContinue
|
||||
}
|
||||
defer response.Body.Close()
|
||||
defer io.Copy(io.Discard, response.Body)
|
||||
|
||||
if response.StatusCode != httpCode {
|
||||
ClearCookie(CookiePrefix+challengeName, w)
|
||||
// continue other challenges!
|
||||
return ChallengeResultContinue
|
||||
} else {
|
||||
token, err := state.IssueChallengeToken(challengeName, key, nil, expiry)
|
||||
if err != nil {
|
||||
ClearCookie(CookiePrefix+challengeName, w)
|
||||
} else {
|
||||
SetCookie(CookiePrefix+challengeName, token, expiry, w)
|
||||
}
|
||||
|
||||
// we passed it!
|
||||
return ChallengeResultPass
|
||||
}
|
||||
}
|
||||
|
||||
case "cookie":
|
||||
c.Challenge = func(w http.ResponseWriter, r *http.Request, key []byte, expiry time.Time) ChallengeResult {
|
||||
token, err := state.IssueChallengeToken(challengeName, key, nil, expiry)
|
||||
if err != nil {
|
||||
ClearCookie(CookiePrefix+challengeName, w)
|
||||
} else {
|
||||
SetCookie(CookiePrefix+challengeName, token, expiry, w)
|
||||
}
|
||||
// self redirect!
|
||||
//TODO: add redirect loop detect parameter
|
||||
http.Redirect(w, r, r.URL.String(), http.StatusTemporaryRedirect)
|
||||
return ChallengeResultStop
|
||||
}
|
||||
case "meta-refresh":
|
||||
c.Challenge = func(w http.ResponseWriter, r *http.Request, key []byte, expiry time.Time) ChallengeResult {
|
||||
redirectUri := new(url.URL)
|
||||
redirectUri.Path = c.Path + "/verify-challenge"
|
||||
|
||||
values := make(url.Values)
|
||||
values.Set("result", hex.EncodeToString(key))
|
||||
values.Set("redirect", r.URL.String())
|
||||
|
||||
redirectUri.RawQuery = values.Encode()
|
||||
|
||||
// self redirect!
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusTeapot)
|
||||
|
||||
_ = templates["challenge.gohtml"].Execute(w, map[string]any{
|
||||
"Title": "Bot",
|
||||
"Path": state.UrlPath,
|
||||
"Random": cacheBust,
|
||||
"Challenge": "",
|
||||
"Meta": map[string]string{
|
||||
"refresh": "0; url=" + redirectUri.String(),
|
||||
},
|
||||
})
|
||||
return ChallengeResultStop
|
||||
}
|
||||
case "header-refresh":
|
||||
c.Challenge = func(w http.ResponseWriter, r *http.Request, key []byte, expiry time.Time) ChallengeResult {
|
||||
redirectUri := new(url.URL)
|
||||
redirectUri.Path = c.Path + "/verify-challenge"
|
||||
|
||||
values := make(url.Values)
|
||||
values.Set("result", hex.EncodeToString(key))
|
||||
values.Set("redirect", r.URL.String())
|
||||
|
||||
redirectUri.RawQuery = values.Encode()
|
||||
|
||||
// self redirect!
|
||||
w.Header().Set("Refresh", "0; url="+redirectUri.String())
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusTeapot)
|
||||
|
||||
_ = templates["challenge.gohtml"].Execute(w, map[string]any{
|
||||
"Title": "Bot",
|
||||
"Path": state.UrlPath,
|
||||
"Random": cacheBust,
|
||||
"Challenge": "",
|
||||
})
|
||||
return ChallengeResultStop
|
||||
}
|
||||
case "js":
|
||||
c.Challenge = func(w http.ResponseWriter, r *http.Request, key []byte, expiry time.Time) ChallengeResult {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusTeapot)
|
||||
|
||||
err := templates["challenge.gohtml"].Execute(w, map[string]any{
|
||||
"Title": "Bot",
|
||||
"Path": state.UrlPath,
|
||||
"Random": cacheBust,
|
||||
"Challenge": challengeName,
|
||||
})
|
||||
if err != nil {
|
||||
//TODO: log
|
||||
}
|
||||
return ChallengeResultStop
|
||||
}
|
||||
c.ChallengeScriptPath = c.Path + "/challenge.mjs"
|
||||
c.ChallengeScript = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/javascript; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
params, _ := json.Marshal(p.Parameters)
|
||||
|
||||
err := templates["challenge.mjs"].Execute(w, map[string]any{
|
||||
"Path": c.Path,
|
||||
"Parameters": string(params),
|
||||
"Random": cacheBust,
|
||||
"Challenge": challengeName,
|
||||
"ChallengeScript": func() string {
|
||||
if p.Asset != nil {
|
||||
return assetPath + *p.Asset
|
||||
} else if p.Url != nil {
|
||||
return *p.Url
|
||||
} else {
|
||||
panic("not implemented")
|
||||
}
|
||||
}(),
|
||||
})
|
||||
if err != nil {
|
||||
//TODO: log
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// how to runtime
|
||||
switch p.Runtime.Mode {
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown challenge runtime mode: %s", p.Runtime.Mode)
|
||||
case "":
|
||||
case "http":
|
||||
case "key":
|
||||
c.Verify = func(key []byte, result string) (bool, error) {
|
||||
resultBytes, err := hex.DecodeString(result)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if subtle.ConstantTimeCompare(resultBytes, key) != 1 {
|
||||
return false, nil
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
case "wasm":
|
||||
wasmData, err := challengesFs.ReadFile(fmt.Sprintf("challenge/%s/runtime/%s", challengeName, p.Runtime.Asset))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("c %s: could not load runtime: %w", challengeName, err)
|
||||
}
|
||||
c.RuntimeModule, err = state.WasmRuntime.CompileModule(state.WasmContext, wasmData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("c %s: compiling runtime: %w", challengeName, err)
|
||||
}
|
||||
|
||||
c.MakeChallenge = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
err := state.ChallengeMod(challengeName, func(ctx context.Context, mod api.Module) (err error) {
|
||||
|
||||
in := challenge.MakeChallengeInput{
|
||||
Key: state.GetChallengeKeyForRequest(challengeName, time.Now().UTC().Add(DefaultValidity).Round(DefaultValidity), r),
|
||||
Parameters: p.Parameters,
|
||||
Headers: r.Header,
|
||||
}
|
||||
in.Data, err = io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out, err := challenge.MakeChallengeCall(state.WasmContext, mod, in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// set output headers
|
||||
for k, v := range out.Headers {
|
||||
w.Header()[k] = v
|
||||
}
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(out.Data)))
|
||||
w.WriteHeader(out.Code)
|
||||
_, _ = w.Write(out.Data)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
c.Verify = func(key []byte, result string) (ok bool, err error) {
|
||||
err = state.ChallengeMod(challengeName, func(ctx context.Context, mod api.Module) (err error) {
|
||||
in := challenge.VerifyChallengeInput{
|
||||
Key: key,
|
||||
Parameters: p.Parameters,
|
||||
Result: []byte(result),
|
||||
}
|
||||
|
||||
out, err := challenge.VerifyChallengeCall(state.WasmContext, mod, in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if out == challenge.VerifyChallengeOutputError {
|
||||
return errors.New("error checking challenge")
|
||||
}
|
||||
ok = out == challenge.VerifyChallengeOutputOK
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return ok, nil
|
||||
}
|
||||
}
|
||||
|
||||
state.Challenges[challengeName] = c
|
||||
}
|
||||
|
||||
state.RulesEnv, err = cel.NewEnv(
|
||||
cel.DefaultUTCTimeZone(true),
|
||||
cel.Variable("remoteAddress", cel.BytesType),
|
||||
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
|
||||
}
|
||||
|
||||
state.Conditions = make(map[string]*cel.Ast)
|
||||
for k, entries := range policy.Conditions {
|
||||
ast, err := ConditionFromStrings(state.RulesEnv, OperatorOr, entries...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("conditions %s: error compiling conditions: %v", k, err)
|
||||
}
|
||||
state.Conditions[k] = ast
|
||||
}
|
||||
|
||||
for _, rule := range policy.Rules {
|
||||
r := RuleState{
|
||||
Name: rule.Name,
|
||||
Action: PolicyRuleAction(strings.ToUpper(rule.Action)),
|
||||
Challenges: rule.Challenges,
|
||||
}
|
||||
|
||||
if r.Action == PolicyRuleActionCHALLENGE && len(r.Challenges) == 0 {
|
||||
return nil, fmt.Errorf("no challenges found in rule %s", rule.Name)
|
||||
}
|
||||
|
||||
//TODO: nesting conditions via decorator!
|
||||
ast, err := ConditionFromStrings(state.RulesEnv, OperatorOr, rule.Conditions...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("rules %s: error compiling conditions: %v", rule.Name, err)
|
||||
}
|
||||
program, err := state.RulesEnv.Program(ast)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("rules %s: error compiling program: %v", rule.Name, err)
|
||||
}
|
||||
r.Program = program
|
||||
|
||||
state.Rules = append(state.Rules, r)
|
||||
}
|
||||
|
||||
state.Mux = http.NewServeMux()
|
||||
|
||||
state.PublicKey, state.PrivateKey, err = ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err = state.setupRoutes(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func (state *State) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
state.Mux.ServeHTTP(w, r)
|
||||
}
|
191
templates/challenge.gohtml
Normal file
191
templates/challenge.gohtml
Normal file
@@ -0,0 +1,191 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>{{ .Title }}</title>
|
||||
<link rel="stylesheet" href="{{ .Path }}/assets/static/style.css?cacheBust={{ .Random }}"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
{{ range $key, $value := .Meta }}
|
||||
{{ if eq $key "refresh"}}
|
||||
<meta http-equiv="{{ $key }}" content="{{ $value }}"/>
|
||||
{{else}}
|
||||
<meta name="{{ $key }}" content="{{ $value }}"/>
|
||||
{{end}}
|
||||
{{ end }}
|
||||
<style>
|
||||
body,
|
||||
html {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.centered-div {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.lds-roller,
|
||||
.lds-roller div,
|
||||
.lds-roller div:after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.lds-roller {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
}
|
||||
|
||||
.lds-roller div {
|
||||
animation: lds-roller 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite;
|
||||
transform-origin: 40px 40px;
|
||||
}
|
||||
|
||||
.lds-roller div:after {
|
||||
content: " ";
|
||||
display: block;
|
||||
position: absolute;
|
||||
width: 7.2px;
|
||||
height: 7.2px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
margin: -3.6px 0 0 -3.6px;
|
||||
}
|
||||
|
||||
.lds-roller div:nth-child(1) {
|
||||
animation-delay: -0.036s;
|
||||
}
|
||||
|
||||
.lds-roller div:nth-child(1):after {
|
||||
top: 62.62742px;
|
||||
left: 62.62742px;
|
||||
}
|
||||
|
||||
.lds-roller div:nth-child(2) {
|
||||
animation-delay: -0.072s;
|
||||
}
|
||||
|
||||
.lds-roller div:nth-child(2):after {
|
||||
top: 67.71281px;
|
||||
left: 56px;
|
||||
}
|
||||
|
||||
.lds-roller div:nth-child(3) {
|
||||
animation-delay: -0.108s;
|
||||
}
|
||||
|
||||
.lds-roller div:nth-child(3):after {
|
||||
top: 70.90963px;
|
||||
left: 48.28221px;
|
||||
}
|
||||
|
||||
.lds-roller div:nth-child(4) {
|
||||
animation-delay: -0.144s;
|
||||
}
|
||||
|
||||
.lds-roller div:nth-child(4):after {
|
||||
top: 72px;
|
||||
left: 40px;
|
||||
}
|
||||
|
||||
.lds-roller div:nth-child(5) {
|
||||
animation-delay: -0.18s;
|
||||
}
|
||||
|
||||
.lds-roller div:nth-child(5):after {
|
||||
top: 70.90963px;
|
||||
left: 31.71779px;
|
||||
}
|
||||
|
||||
.lds-roller div:nth-child(6) {
|
||||
animation-delay: -0.216s;
|
||||
}
|
||||
|
||||
.lds-roller div:nth-child(6):after {
|
||||
top: 67.71281px;
|
||||
left: 24px;
|
||||
}
|
||||
|
||||
.lds-roller div:nth-child(7) {
|
||||
animation-delay: -0.252s;
|
||||
}
|
||||
|
||||
.lds-roller div:nth-child(7):after {
|
||||
top: 62.62742px;
|
||||
left: 17.37258px;
|
||||
}
|
||||
|
||||
.lds-roller div:nth-child(8) {
|
||||
animation-delay: -0.288s;
|
||||
}
|
||||
|
||||
.lds-roller div:nth-child(8):after {
|
||||
top: 56px;
|
||||
left: 12.28719px;
|
||||
}
|
||||
|
||||
@keyframes lds-roller {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body id="top">
|
||||
<main>
|
||||
<center>
|
||||
<h1 id="title" class=".centered-div">{{ .Title }}</h1>
|
||||
</center>
|
||||
|
||||
<div class="centered-div">
|
||||
<img
|
||||
id="image"
|
||||
style="width:100%;max-width:256px;"
|
||||
src="{{ .Path }}/assets/static/logo.png?cacheBust={{ .Random }}"
|
||||
/>
|
||||
<p id="status">Loading...</p>
|
||||
{{if .Challenge }}
|
||||
<script async type="module" src="{{ .Path }}/challenge/{{ .Challenge }}/challenge.mjs?cacheBust={{ .Random }}"></script>
|
||||
{{end}}
|
||||
<div id="spinner" class="lds-roller">
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
</div>
|
||||
<details>
|
||||
<summary>Why am I seeing this?</summary>
|
||||
<p>You are seeing this because the administrator of this website has set up <a href="https://git.gammaspectra.live/git/go-away">go-away</a> to protect the server against the scourge of <a href="https://thelibre.news/foss-infrastructure-is-under-attack-by-ai-companies/">AI companies aggressively scraping websites</a>. This can and does cause downtime for the websites, which makes their resources inaccessible for everyone.</p>
|
||||
<p>Please note that this challenge requires the use of modern JavaScript features that plugins like <a href="https://jshelter.org/">JShelter</a> will disable. Please disable JShelter or other such plugins for this domain.</p>
|
||||
</details>
|
||||
<noscript>
|
||||
<p>
|
||||
Sadly, you must enable JavaScript to get past this challenge. This is required because AI companies have changed
|
||||
the social contract around how website hosting works.
|
||||
</p>
|
||||
</noscript>
|
||||
<div id="testarea"></div>
|
||||
</div>
|
||||
|
||||
|
||||
<footer>
|
||||
<center>
|
||||
<p>
|
||||
Protected by <a href="https://git.gammaspectra.live/git/go-away">go-away</a>
|
||||
</p>
|
||||
</center>
|
||||
</footer>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
68
templates/challenge.mjs
Normal file
68
templates/challenge.mjs
Normal file
@@ -0,0 +1,68 @@
|
||||
import {setup, challenge} from "{{ .ChallengeScript }}";
|
||||
|
||||
|
||||
// from Xeact
|
||||
const u = (url = "", params = {}) => {
|
||||
let result = new URL(url, window.location.href);
|
||||
Object.entries(params).forEach((kv) => {
|
||||
let [k, v] = kv;
|
||||
result.searchParams.set(k, v);
|
||||
});
|
||||
return result.toString();
|
||||
};
|
||||
|
||||
(async () => {
|
||||
const status = document.getElementById('status');
|
||||
const title = document.getElementById('title');
|
||||
const spinner = document.getElementById('spinner');
|
||||
|
||||
status.innerText = 'Starting...';
|
||||
|
||||
try {
|
||||
const info = await setup({
|
||||
Path: "{{ .Path }}",
|
||||
Parameters: "{{ .Parameters }}"
|
||||
});
|
||||
|
||||
if (info != "") {
|
||||
status.innerText = 'Calculating... ' + info
|
||||
} else {
|
||||
status.innerText = 'Calculating...';
|
||||
}
|
||||
} catch (err) {
|
||||
title.innerHTML = "Oh no!";
|
||||
status.innerHTML = `Failed to initialize: ${err.message}`;
|
||||
spinner.innerHTML = "";
|
||||
spinner.style.display = "none";
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
const t0 = Date.now();
|
||||
const { result, info } = await challenge();
|
||||
const t1 = Date.now();
|
||||
console.log({ result, info });
|
||||
|
||||
title.innerHTML = "Success!";
|
||||
if (info != "") {
|
||||
status.innerHTML = `Done! Took ${t1 - t0}ms, ${info}`;
|
||||
} else {
|
||||
status.innerHTML = `Done! Took ${t1 - t0}ms`;
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
const redir = window.location.href;
|
||||
window.location.href = u("{{ .Path }}/verify-challenge", {
|
||||
result: JSON.stringify(result),
|
||||
redirect: redir,
|
||||
elapsedTime: t1 - t0,
|
||||
});
|
||||
}, 500);
|
||||
} catch (err) {
|
||||
title.innerHTML = "Oh no!";
|
||||
status.innerHTML = `Failed to challenge: ${err.message}`;
|
||||
spinner.innerHTML = "";
|
||||
spinner.style.display = "none";
|
||||
}
|
||||
})();
|
Reference in New Issue
Block a user