Initial commit

This commit is contained in:
WeebDataHoarder
2025-03-31 16:24:08 +02:00
commit 06bc5107d6
27 changed files with 2500 additions and 0 deletions

0
.bin/.gitkeep Normal file
View File

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
/tinygo
/.bin/*
*.gz
*.br
*.zst

BIN
assets/static/geist.woff2 Normal file

Binary file not shown.

Binary file not shown.

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

Binary file not shown.

105
assets/static/style.css Normal file
View 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;
}
}

1
away.go Normal file
View File

@@ -0,0 +1 @@
package go_away

41
build.sh Executable file
View 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
View 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: &notBefore,
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
View 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
View 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
)

View File

@@ -0,0 +1 @@
*.wasm

View 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)
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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";
}
})();