commit 06bc5107d6011422c74b45b82053d299de700f8b Author: WeebDataHoarder <57538841+WeebDataHoarder@users.noreply.github.com> Date: Mon Mar 31 16:24:08 2025 +0200 Initial commit diff --git a/.bin/.gitkeep b/.bin/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d4d458d --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/tinygo +/.bin/* +*.gz +*.br +*.zst \ No newline at end of file diff --git a/assets/static/geist.woff2 b/assets/static/geist.woff2 new file mode 100644 index 0000000..6fd61c4 Binary files /dev/null and b/assets/static/geist.woff2 differ diff --git a/assets/static/iosevka-curly.woff2 b/assets/static/iosevka-curly.woff2 new file mode 100644 index 0000000..df9df40 Binary files /dev/null and b/assets/static/iosevka-curly.woff2 differ diff --git a/assets/static/logo.png b/assets/static/logo.png new file mode 100644 index 0000000..301503a Binary files /dev/null and b/assets/static/logo.png differ diff --git a/assets/static/podkova.woff2 b/assets/static/podkova.woff2 new file mode 100644 index 0000000..508f97d Binary files /dev/null and b/assets/static/podkova.woff2 differ diff --git a/assets/static/style.css b/assets/static/style.css new file mode 100644 index 0000000..60c97c3 --- /dev/null +++ b/assets/static/style.css @@ -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; + } +} diff --git a/away.go b/away.go new file mode 100644 index 0000000..5d4ffd5 --- /dev/null +++ b/away.go @@ -0,0 +1 @@ +package go_away diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..b342086 --- /dev/null +++ b/build.sh @@ -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/ \ No newline at end of file diff --git a/challenge.go b/challenge.go new file mode 100644 index 0000000..f4fe93d --- /dev/null +++ b/challenge.go @@ -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 +} diff --git a/challenge/generic.go b/challenge/generic.go new file mode 100644 index 0000000..9442418 --- /dev/null +++ b/challenge/generic.go @@ -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") } diff --git a/challenge/interface.go b/challenge/interface.go new file mode 100644 index 0000000..3828191 --- /dev/null +++ b/challenge/interface.go @@ -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 +) diff --git a/challenge/js-pow-sha256/runtime/.gitignore b/challenge/js-pow-sha256/runtime/.gitignore new file mode 100644 index 0000000..917660a --- /dev/null +++ b/challenge/js-pow-sha256/runtime/.gitignore @@ -0,0 +1 @@ +*.wasm \ No newline at end of file diff --git a/challenge/js-pow-sha256/runtime/runtime.go b/challenge/js-pow-sha256/runtime/runtime.go new file mode 100644 index 0000000..07cc988 --- /dev/null +++ b/challenge/js-pow-sha256/runtime/runtime.go @@ -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) +} diff --git a/challenge/js-pow-sha256/static/load.mjs b/challenge/js-pow-sha256/static/load.mjs new file mode 100644 index 0000000..d098de4 --- /dev/null +++ b/challenge/js-pow-sha256/static/load.mjs @@ -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 } \ No newline at end of file diff --git a/challenge/tinygo.go b/challenge/tinygo.go new file mode 100644 index 0000000..f75ffa5 --- /dev/null +++ b/challenge/tinygo.go @@ -0,0 +1,59 @@ +//go:build tinygo + +package challenge + +// #include +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) +} diff --git a/cmd/away.go b/cmd/away.go new file mode 100644 index 0000000..f36f4a6 --- /dev/null +++ b/cmd/away.go @@ -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) + } +} diff --git a/condition.go b/condition.go new file mode 100644 index 0000000..7a02f92 --- /dev/null +++ b/condition.go @@ -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 +} diff --git a/cookie.go b/cookie.go new file mode 100644 index 0000000..dea995c --- /dev/null +++ b/cookie.go @@ -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, + }) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..9aa6c9a --- /dev/null +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a0a82ee --- /dev/null +++ b/go.sum @@ -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= diff --git a/http.go b/http.go new file mode 100644 index 0000000..2c83670 --- /dev/null +++ b/http.go @@ -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) +} diff --git a/policy.go b/policy.go new file mode 100644 index 0000000..7b639cf --- /dev/null +++ b/policy.go @@ -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 +} diff --git a/policy.yml b/policy.yml new file mode 100644 index 0000000..f5df8ee --- /dev/null +++ b/policy.yml @@ -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: "
  • (?P[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+)
  • " + yandexbot: + # todo: detected as bot + # - url: https://yandex.com/ips + # regex: "(?P(([0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+)|([0-9a-f:]+::))/[0-9]+)[ \\\\t]*
    " + - 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[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+) " + cloudflare: + - url: https://www.cloudflare.com/ips-v4 + regex: "(?P[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+/[0-9]+)" + - url: https://www.cloudflare.com/ips-v6 + regex: "(?P[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/")' + diff --git a/state.go b/state.go new file mode 100644 index 0000000..3b8a212 --- /dev/null +++ b/state.go @@ -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) +} diff --git a/templates/challenge.gohtml b/templates/challenge.gohtml new file mode 100644 index 0000000..24efdba --- /dev/null +++ b/templates/challenge.gohtml @@ -0,0 +1,191 @@ + + + + {{ .Title }} + + + {{ range $key, $value := .Meta }} + {{ if eq $key "refresh"}} + + {{else}} + + {{end}} + {{ end }} + + + +
    +
    +

    {{ .Title }}

    +
    + +
    + +

    Loading...

    + {{if .Challenge }} + + {{end}} +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + Why am I seeing this? +

    You are seeing this because the administrator of this website has set up go-away to protect the server against the scourge of AI companies aggressively scraping websites. This can and does cause downtime for the websites, which makes their resources inaccessible for everyone.

    +

    Please note that this challenge requires the use of modern JavaScript features that plugins like JShelter will disable. Please disable JShelter or other such plugins for this domain.

    +
    + +
    +
    + + + +
    + + \ No newline at end of file diff --git a/templates/challenge.mjs b/templates/challenge.mjs new file mode 100644 index 0000000..8b0272f --- /dev/null +++ b/templates/challenge.mjs @@ -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"; + } +})(); \ No newline at end of file