From 9923878c5c8b68df7f132efd28f76ce5478a1f1a Mon Sep 17 00:00:00 2001 From: Xe Iaso Date: Mon, 17 Mar 2025 19:33:07 -0400 Subject: initial import from /x/ monorepo Signed-off-by: Xe Iaso --- cmd/anubis/.gitignore | 2 + cmd/anubis/CHANGELOG.md | 5 + cmd/anubis/botPolicies.json | 70 +++ cmd/anubis/decaymap.go | 87 ++++ cmd/anubis/decaymap_test.go | 31 ++ cmd/anubis/index.templ | 159 ++++++ cmd/anubis/index_templ.go | 215 ++++++++ cmd/anubis/internal/config/config.go | 99 ++++ cmd/anubis/internal/config/config_test.go | 168 ++++++ .../internal/config/testdata/bad/badregexes.json | 14 + .../internal/config/testdata/bad/invalid.json | 5 + .../internal/config/testdata/bad/nobots.json | 1 + .../config/testdata/good/challengemozilla.json | 9 + .../config/testdata/good/everything_blocked.json | 10 + cmd/anubis/internal/dnsbl/dnsbl.go | 95 ++++ cmd/anubis/internal/dnsbl/dnsbl_test.go | 55 ++ .../internal/dnsbl/droneblresponse_string.go | 54 ++ cmd/anubis/js/main.mjs | 71 +++ cmd/anubis/js/proof-of-work.mjs | 62 +++ cmd/anubis/js/video.mjs | 16 + cmd/anubis/main.go | 574 +++++++++++++++++++++ cmd/anubis/policy.go | 146 ++++++ cmd/anubis/policy_test.go | 65 +++ cmd/anubis/static/img/happy.webp | Bin 0 -> 60572 bytes cmd/anubis/static/img/pensive.webp | Bin 0 -> 49148 bytes cmd/anubis/static/img/sad.webp | Bin 0 -> 50802 bytes cmd/anubis/static/js/main.mjs | 2 + cmd/anubis/static/js/main.mjs.br | Bin 0 -> 802 bytes cmd/anubis/static/js/main.mjs.gz | Bin 0 -> 985 bytes cmd/anubis/static/js/main.mjs.map | 7 + cmd/anubis/static/js/main.mjs.zst | Bin 0 -> 982 bytes cmd/anubis/static/robots.txt | 47 ++ cmd/anubis/static/testdata/black.mp4 | Bin 0 -> 1667 bytes 33 files changed, 2069 insertions(+) create mode 100644 cmd/anubis/.gitignore create mode 100644 cmd/anubis/CHANGELOG.md create mode 100644 cmd/anubis/botPolicies.json create mode 100644 cmd/anubis/decaymap.go create mode 100644 cmd/anubis/decaymap_test.go create mode 100644 cmd/anubis/index.templ create mode 100644 cmd/anubis/index_templ.go create mode 100644 cmd/anubis/internal/config/config.go create mode 100644 cmd/anubis/internal/config/config_test.go create mode 100644 cmd/anubis/internal/config/testdata/bad/badregexes.json create mode 100644 cmd/anubis/internal/config/testdata/bad/invalid.json create mode 100644 cmd/anubis/internal/config/testdata/bad/nobots.json create mode 100644 cmd/anubis/internal/config/testdata/good/challengemozilla.json create mode 100644 cmd/anubis/internal/config/testdata/good/everything_blocked.json create mode 100644 cmd/anubis/internal/dnsbl/dnsbl.go create mode 100644 cmd/anubis/internal/dnsbl/dnsbl_test.go create mode 100644 cmd/anubis/internal/dnsbl/droneblresponse_string.go create mode 100644 cmd/anubis/js/main.mjs create mode 100644 cmd/anubis/js/proof-of-work.mjs create mode 100644 cmd/anubis/js/video.mjs create mode 100644 cmd/anubis/main.go create mode 100644 cmd/anubis/policy.go create mode 100644 cmd/anubis/policy_test.go create mode 100644 cmd/anubis/static/img/happy.webp create mode 100644 cmd/anubis/static/img/pensive.webp create mode 100644 cmd/anubis/static/img/sad.webp create mode 100644 cmd/anubis/static/js/main.mjs create mode 100644 cmd/anubis/static/js/main.mjs.br create mode 100644 cmd/anubis/static/js/main.mjs.gz create mode 100644 cmd/anubis/static/js/main.mjs.map create mode 100644 cmd/anubis/static/js/main.mjs.zst create mode 100644 cmd/anubis/static/robots.txt create mode 100644 cmd/anubis/static/testdata/black.mp4 (limited to 'cmd') diff --git a/cmd/anubis/.gitignore b/cmd/anubis/.gitignore new file mode 100644 index 0000000..061bf12 --- /dev/null +++ b/cmd/anubis/.gitignore @@ -0,0 +1,2 @@ +*.rpm +anubis diff --git a/cmd/anubis/CHANGELOG.md b/cmd/anubis/CHANGELOG.md new file mode 100644 index 0000000..612bec1 --- /dev/null +++ b/cmd/anubis/CHANGELOG.md @@ -0,0 +1,5 @@ +# CHANGELOG + +## 2025-01-24 + +- Added support for custom bot policy documentation, allowing administrators to change how Anubis works to meet their needs. diff --git a/cmd/anubis/botPolicies.json b/cmd/anubis/botPolicies.json new file mode 100644 index 0000000..6e04a11 --- /dev/null +++ b/cmd/anubis/botPolicies.json @@ -0,0 +1,70 @@ +{ + "bots": [ + { + "name": "amazonbot", + "user_agent_regex": "Amazonbot", + "action": "DENY" + }, + { + "name": "googlebot", + "user_agent_regex": "\\+http\\:\\/\\/www\\.google\\.com/bot\\.html", + "action": "ALLOW" + }, + { + "name": "bingbot", + "user_agent_regex": "\\+http\\:\\/\\/www\\.bing\\.com/bingbot\\.htm", + "action": "ALLOW" + }, + { + "name": "qwantbot", + "user_agent_regex": "\\+https\\:\\/\\/help\\.qwant\\.com/bot/", + "action": "ALLOW" + }, + { + "name": "us-artificial-intelligence-scraper", + "user_agent_regex": "\\+https\\:\\/\\/github\\.com\\/US-Artificial-Intelligence\\/scraper", + "action": "DENY" + }, + { + "name": "well-known", + "path_regex": "^/.well-known/.*$", + "action": "ALLOW" + }, + { + "name": "favicon", + "path_regex": "^/favicon.ico$", + "action": "ALLOW" + }, + { + "name": "robots-txt", + "path_regex": "^/robots.txt$", + "action": "ALLOW" + }, + { + "name": "rss-readers", + "path_regex": ".*\\.(rss|xml|atom|json)$", + "action": "ALLOW" + }, + { + "name": "lightpanda", + "user_agent_regex": "^Lightpanda/.*$", + "action": "DENY" + }, + { + "name": "headless-chrome", + "user_agent_regex": "HeadlessChrome", + "action": "DENY" + }, + { + "name": "headless-chromium", + "user_agent_regex": "HeadlessChromium", + "action": "DENY" + }, + { + "name": "generic-browser", + "user_agent_regex": "Mozilla", + "action": "CHALLENGE" + } + ], + "dnsbl": true +} diff --git a/cmd/anubis/decaymap.go b/cmd/anubis/decaymap.go new file mode 100644 index 0000000..dcd2952 --- /dev/null +++ b/cmd/anubis/decaymap.go @@ -0,0 +1,87 @@ +package main + +import ( + "sync" + "time" +) + +func zilch[T any]() T { + var zero T + return zero +} + +// DecayMap is a lazy key->value map. It's a wrapper around a map and a mutex. If values exceed their time-to-live, they are pruned at Get time. +type DecayMap[K comparable, V any] struct { + data map[K]decayMapEntry[V] + lock sync.RWMutex +} + +type decayMapEntry[V any] struct { + Value V + expiry time.Time +} + +// NewDecayMap creates a new DecayMap of key type K and value type V. +// +// Key types must be comparable to work with maps. +func NewDecayMap[K comparable, V any]() *DecayMap[K, V] { + return &DecayMap[K, V]{ + data: make(map[K]decayMapEntry[V]), + } +} + +// expire forcibly expires a key by setting its time-to-live one second in the past. +func (m *DecayMap[K, V]) expire(key K) bool { + m.lock.RLock() + val, ok := m.data[key] + m.lock.RUnlock() + + if !ok { + return false + } + + m.lock.Lock() + val.expiry = time.Now().Add(-1 * time.Second) + m.data[key] = val + m.lock.Unlock() + + return true +} + +// Get gets a value from the DecayMap by key. +// +// If a value has expired, forcibly delete it if it was not updated. +func (m *DecayMap[K, V]) Get(key K) (V, bool) { + m.lock.RLock() + value, ok := m.data[key] + m.lock.RUnlock() + + if !ok { + return zilch[V](), false + } + + if time.Now().After(value.expiry) { + m.lock.Lock() + // Since previously reading m.data[key], the value may have been updated. + // Delete the entry only if the expiry time is still the same. + if m.data[key].expiry == value.expiry { + delete(m.data, key) + } + m.lock.Unlock() + + return zilch[V](), false + } + + return value.Value, true +} + +// Set sets a key value pair in the map. +func (m *DecayMap[K, V]) Set(key K, value V, ttl time.Duration) { + m.lock.Lock() + defer m.lock.Unlock() + + m.data[key] = decayMapEntry[V]{ + Value: value, + expiry: time.Now().Add(ttl), + } +} diff --git a/cmd/anubis/decaymap_test.go b/cmd/anubis/decaymap_test.go new file mode 100644 index 0000000..73e0626 --- /dev/null +++ b/cmd/anubis/decaymap_test.go @@ -0,0 +1,31 @@ +package main + +import ( + "testing" + "time" +) + +func TestDecayMap(t *testing.T) { + dm := NewDecayMap[string, string]() + + dm.Set("test", "hi", 5*time.Minute) + + val, ok := dm.Get("test") + if !ok { + t.Error("somehow the test key was not set") + } + + if val != "hi" { + t.Errorf("wanted value %q, got: %q", "hi", val) + } + + ok = dm.expire("test") + if !ok { + t.Error("somehow could not force-expire the test key") + } + + _, ok = dm.Get("test") + if ok { + t.Error("got value even though it was supposed to be expired") + } +} diff --git a/cmd/anubis/index.templ b/cmd/anubis/index.templ new file mode 100644 index 0000000..e2426d5 --- /dev/null +++ b/cmd/anubis/index.templ @@ -0,0 +1,159 @@ +package main + +import ( + "github.com/TecharoHQ/anubis" + "github.com/TecharoHQ/anubis/xess" +) + +templ base(title string, body templ.Component) { + + + + { title } + + + + + +
+
+

{ title }

+
+ @body + +
+ + +} + +templ index() { +
+ Loading...

+
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func errorPage(message string) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var9 := templ.GetChildren(ctx) + if templ_7745c5c3_Var9 == nil { + templ_7745c5c3_Var9 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var11 string + templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(message) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 155, Col: 14} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, ".

Go home

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/cmd/anubis/internal/config/config.go b/cmd/anubis/internal/config/config.go new file mode 100644 index 0000000..ad338ef --- /dev/null +++ b/cmd/anubis/internal/config/config.go @@ -0,0 +1,99 @@ +package config + +import ( + "errors" + "fmt" + "regexp" +) + +type Rule string + +const ( + RuleUnknown = "" + RuleAllow = "ALLOW" + RuleDeny = "DENY" + RuleChallenge = "CHALLENGE" +) + +type Bot struct { + Name string `json:"name"` + UserAgentRegex *string `json:"user_agent_regex"` + PathRegex *string `json:"path_regex"` + Action Rule `json:"action"` +} + +var ( + ErrNoBotRulesDefined = errors.New("config: must define at least one (1) bot rule") + ErrBotMustHaveName = errors.New("config.Bot: must set name") + ErrBotMustHaveUserAgentOrPath = errors.New("config.Bot: must set either user_agent_regex, path_regex") + ErrBotMustHaveUserAgentOrPathNotBoth = errors.New("config.Bot: must set either user_agent_regex, path_regex, and not both") + ErrUnknownAction = errors.New("config.Bot: unknown action") + ErrInvalidUserAgentRegex = errors.New("config.Bot: invalid user agent regex") + ErrInvalidPathRegex = errors.New("config.Bot: invalid path regex") +) + +func (b Bot) Valid() error { + var errs []error + + if b.Name == "" { + errs = append(errs, ErrBotMustHaveName) + } + + if b.UserAgentRegex == nil && b.PathRegex == nil { + errs = append(errs, ErrBotMustHaveUserAgentOrPath) + } + + if b.UserAgentRegex != nil && b.PathRegex != nil { + errs = append(errs, ErrBotMustHaveUserAgentOrPathNotBoth) + } + + if b.UserAgentRegex != nil { + if _, err := regexp.Compile(*b.UserAgentRegex); err != nil { + errs = append(errs, ErrInvalidUserAgentRegex, err) + } + } + + if b.PathRegex != nil { + if _, err := regexp.Compile(*b.PathRegex); err != nil { + errs = append(errs, ErrInvalidPathRegex, err) + } + } + + switch b.Action { + case RuleAllow, RuleChallenge, RuleDeny: + // okay + default: + errs = append(errs, fmt.Errorf("%w: %q", ErrUnknownAction, b.Action)) + } + + if len(errs) != 0 { + return fmt.Errorf("config: bot entry for %q is not valid:\n%w", b.Name, errors.Join(errs...)) + } + + return nil +} + +type Config struct { + Bots []Bot `json:"bots"` + DNSBL bool `json:"dnsbl"` +} + +func (c Config) Valid() error { + var errs []error + + if len(c.Bots) == 0 { + errs = append(errs, ErrNoBotRulesDefined) + } + + for _, b := range c.Bots { + if err := b.Valid(); err != nil { + errs = append(errs, err) + } + } + + if len(errs) != 0 { + return fmt.Errorf("config is not valid:\n%w", errors.Join(errs...)) + } + + return nil +} diff --git a/cmd/anubis/internal/config/config_test.go b/cmd/anubis/internal/config/config_test.go new file mode 100644 index 0000000..f362a76 --- /dev/null +++ b/cmd/anubis/internal/config/config_test.go @@ -0,0 +1,168 @@ +package config + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "testing" +) + +func p[V any](v V) *V { return &v } + +func TestBotValid(t *testing.T) { + var tests = []struct { + name string + bot Bot + err error + }{ + { + name: "simple user agent", + bot: Bot{ + Name: "mozilla-ua", + Action: RuleChallenge, + UserAgentRegex: p("Mozilla"), + }, + err: nil, + }, + { + name: "simple path", + bot: Bot{ + Name: "well-known-path", + Action: RuleAllow, + PathRegex: p("^/.well-known/.*$"), + }, + err: nil, + }, + { + name: "no rule name", + bot: Bot{ + Action: RuleChallenge, + UserAgentRegex: p("Mozilla"), + }, + err: ErrBotMustHaveName, + }, + { + name: "no rule matcher", + bot: Bot{ + Name: "broken-rule", + Action: RuleAllow, + }, + err: ErrBotMustHaveUserAgentOrPath, + }, + { + name: "both user-agent and path", + bot: Bot{ + Name: "path-and-user-agent", + Action: RuleDeny, + UserAgentRegex: p("Mozilla"), + PathRegex: p("^/.secret-place/.*$"), + }, + err: ErrBotMustHaveUserAgentOrPathNotBoth, + }, + { + name: "unknown action", + bot: Bot{ + Name: "Unknown action", + Action: RuleUnknown, + UserAgentRegex: p("Mozilla"), + }, + err: ErrUnknownAction, + }, + { + name: "invalid user agent regex", + bot: Bot{ + Name: "mozilla-ua", + Action: RuleChallenge, + UserAgentRegex: p("a(b"), + }, + err: ErrInvalidUserAgentRegex, + }, + { + name: "invalid path regex", + bot: Bot{ + Name: "mozilla-ua", + Action: RuleChallenge, + PathRegex: p("a(b"), + }, + err: ErrInvalidPathRegex, + }, + } + + for _, cs := range tests { + cs := cs + t.Run(cs.name, func(t *testing.T) { + err := cs.bot.Valid() + if err == nil && cs.err == nil { + return + } + + if err == nil && cs.err != nil { + t.Errorf("didn't get an error, but wanted: %v", cs.err) + } + + if !errors.Is(err, cs.err) { + t.Logf("got wrong error from Valid()") + t.Logf("wanted: %v", cs.err) + t.Logf("got: %v", err) + t.Errorf("got invalid error from check") + } + }) + } +} + +func TestConfigValidKnownGood(t *testing.T) { + finfos, err := os.ReadDir("testdata/good") + if err != nil { + t.Fatal(err) + } + + for _, st := range finfos { + st := st + t.Run(st.Name(), func(t *testing.T) { + fin, err := os.Open(filepath.Join("testdata", "good", st.Name())) + if err != nil { + t.Fatal(err) + } + defer fin.Close() + + var c Config + if err := json.NewDecoder(fin).Decode(&c); err != nil { + t.Fatalf("can't decode file: %v", err) + } + + if err := c.Valid(); err != nil { + t.Fatal(err) + } + }) + } +} + +func TestConfigValidBad(t *testing.T) { + finfos, err := os.ReadDir("testdata/bad") + if err != nil { + t.Fatal(err) + } + + for _, st := range finfos { + st := st + t.Run(st.Name(), func(t *testing.T) { + fin, err := os.Open(filepath.Join("testdata", "bad", st.Name())) + if err != nil { + t.Fatal(err) + } + defer fin.Close() + + var c Config + if err := json.NewDecoder(fin).Decode(&c); err != nil { + t.Fatalf("can't decode file: %v", err) + } + + if err := c.Valid(); err == nil { + t.Fatal("validation should have failed but didn't somehow") + } else { + t.Log(err) + } + }) + } +} diff --git a/cmd/anubis/internal/config/testdata/bad/badregexes.json b/cmd/anubis/internal/config/testdata/bad/badregexes.json new file mode 100644 index 0000000..e85b85b --- /dev/null +++ b/cmd/anubis/internal/config/testdata/bad/badregexes.json @@ -0,0 +1,14 @@ +{ + "bots": [ + { + "name": "path-bad", + "path_regex": "a(b", + "action": "DENY" + }, + { + "name": "user-agent-bad", + "user_agent_regex": "a(b", + "action": "DENY" + } + ] +} \ No newline at end of file diff --git a/cmd/anubis/internal/config/testdata/bad/invalid.json b/cmd/anubis/internal/config/testdata/bad/invalid.json new file mode 100644 index 0000000..c5d1ff6 --- /dev/null +++ b/cmd/anubis/internal/config/testdata/bad/invalid.json @@ -0,0 +1,5 @@ +{ + "bots": [ + {} + ] +} \ No newline at end of file diff --git a/cmd/anubis/internal/config/testdata/bad/nobots.json b/cmd/anubis/internal/config/testdata/bad/nobots.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/cmd/anubis/internal/config/testdata/bad/nobots.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/cmd/anubis/internal/config/testdata/good/challengemozilla.json b/cmd/anubis/internal/config/testdata/good/challengemozilla.json new file mode 100644 index 0000000..e9d34ee --- /dev/null +++ b/cmd/anubis/internal/config/testdata/good/challengemozilla.json @@ -0,0 +1,9 @@ +{ + "bots": [ + { + "name": "generic-browser", + "user_agent_regex": "Mozilla", + "action": "CHALLENGE" + } + ] +} \ No newline at end of file diff --git a/cmd/anubis/internal/config/testdata/good/everything_blocked.json b/cmd/anubis/internal/config/testdata/good/everything_blocked.json new file mode 100644 index 0000000..e1763e4 --- /dev/null +++ b/cmd/anubis/internal/config/testdata/good/everything_blocked.json @@ -0,0 +1,10 @@ +{ + "bots": [ + { + "name": "everything", + "user_agent_regex": ".*", + "action": "DENY" + } + ], + "dnsbl": false +} \ No newline at end of file diff --git a/cmd/anubis/internal/dnsbl/dnsbl.go b/cmd/anubis/internal/dnsbl/dnsbl.go new file mode 100644 index 0000000..60edd5c --- /dev/null +++ b/cmd/anubis/internal/dnsbl/dnsbl.go @@ -0,0 +1,95 @@ +package dnsbl + +import ( + "errors" + "fmt" + "net" + "strings" +) + +//go:generate go tool golang.org/x/tools/cmd/stringer -type=DroneBLResponse + +type DroneBLResponse byte + +const ( + AllGood DroneBLResponse = 0 + IRCDrone DroneBLResponse = 3 + Bottler DroneBLResponse = 5 + UnknownSpambotOrDrone DroneBLResponse = 6 + DDOSDrone DroneBLResponse = 7 + SOCKSProxy DroneBLResponse = 8 + HTTPProxy DroneBLResponse = 9 + ProxyChain DroneBLResponse = 10 + OpenProxy DroneBLResponse = 11 + OpenDNSResolver DroneBLResponse = 12 + BruteForceAttackers DroneBLResponse = 13 + OpenWingateProxy DroneBLResponse = 14 + CompromisedRouter DroneBLResponse = 15 + AutoRootingWorms DroneBLResponse = 16 + AutoDetectedBotIP DroneBLResponse = 17 + Unknown DroneBLResponse = 255 +) + +func Reverse(ip net.IP) string { + if ip.To4() != nil { + return reverse4(ip) + } + + return reverse6(ip) +} + +func reverse4(ip net.IP) string { + splitAddress := strings.Split(ip.String(), ".") + + // swap first and last octet + splitAddress[0], splitAddress[3] = splitAddress[3], splitAddress[0] + // swap middle octets + splitAddress[1], splitAddress[2] = splitAddress[2], splitAddress[1] + + return strings.Join(splitAddress, ".") +} + +func reverse6(ip net.IP) string { + ipBytes := []byte(ip) + var sb strings.Builder + + for i := len(ipBytes) - 1; i >= 0; i-- { + // Split the byte into two nibbles + highNibble := ipBytes[i] >> 4 + lowNibble := ipBytes[i] & 0x0F + + // Append the nibbles in reversed order + sb.WriteString(fmt.Sprintf("%x.%x.", lowNibble, highNibble)) + } + + return sb.String()[:len(sb.String())-1] +} + +func Lookup(ipStr string) (DroneBLResponse, error) { + ip := net.ParseIP(ipStr) + if ip == nil { + return Unknown, errors.New("dnsbl: input is not an IP address") + } + + revIP := Reverse(ip) + ".dnsbl.dronebl.org" + + ips, err := net.LookupIP(revIP) + if err != nil { + var dnserr *net.DNSError + if errors.As(err, &dnserr) { + if dnserr.IsNotFound { + return AllGood, nil + } + } + + return Unknown, err + } + + if len(ips) != 0 { + for _, ip := range ips { + return DroneBLResponse(ip.To4()[3]), nil + } + } + + return UnknownSpambotOrDrone, nil +} diff --git a/cmd/anubis/internal/dnsbl/dnsbl_test.go b/cmd/anubis/internal/dnsbl/dnsbl_test.go new file mode 100644 index 0000000..0ead488 --- /dev/null +++ b/cmd/anubis/internal/dnsbl/dnsbl_test.go @@ -0,0 +1,55 @@ +package dnsbl + +import ( + "fmt" + "net" + "testing" +) + +func TestReverse4(t *testing.T) { + cases := []struct { + inp, out string + }{ + {"1.2.3.4", "4.3.2.1"}, + } + + for _, cs := range cases { + t.Run(fmt.Sprintf("%s->%s", cs.inp, cs.out), func(t *testing.T) { + out := reverse4(net.ParseIP(cs.inp)) + + if out != cs.out { + t.Errorf("wanted %s\ngot: %s", cs.out, out) + } + }) + } +} + +func TestReverse6(t *testing.T) { + cases := []struct { + inp, out string + }{ + { + inp: "1234:5678:9ABC:DEF0:1234:5678:9ABC:DEF0", + out: "0.f.e.d.c.b.a.9.8.7.6.5.4.3.2.1.0.f.e.d.c.b.a.9.8.7.6.5.4.3.2.1", + }, + } + + for _, cs := range cases { + t.Run(fmt.Sprintf("%s->%s", cs.inp, cs.out), func(t *testing.T) { + out := reverse6(net.ParseIP(cs.inp)) + + if out != cs.out { + t.Errorf("wanted %s, got: %s", cs.out, out) + } + }) + } +} + +func TestLookup(t *testing.T) { + resp, err := Lookup("27.65.243.194") + if err != nil { + t.Fatalf("it broked: %v", err) + } + + t.Logf("response: %d", resp) +} diff --git a/cmd/anubis/internal/dnsbl/droneblresponse_string.go b/cmd/anubis/internal/dnsbl/droneblresponse_string.go new file mode 100644 index 0000000..5104dda --- /dev/null +++ b/cmd/anubis/internal/dnsbl/droneblresponse_string.go @@ -0,0 +1,54 @@ +// Code generated by "stringer -type=DroneBLResponse"; DO NOT EDIT. + +package dnsbl + +import "strconv" + +func _() { + // An "invalid array index" compiler error signifies that the constant values have changed. + // Re-run the stringer command to generate them again. + var x [1]struct{} + _ = x[AllGood-0] + _ = x[IRCDrone-3] + _ = x[Bottler-5] + _ = x[UnknownSpambotOrDrone-6] + _ = x[DDOSDrone-7] + _ = x[SOCKSProxy-8] + _ = x[HTTPProxy-9] + _ = x[ProxyChain-10] + _ = x[OpenProxy-11] + _ = x[OpenDNSResolver-12] + _ = x[BruteForceAttackers-13] + _ = x[OpenWingateProxy-14] + _ = x[CompromisedRouter-15] + _ = x[AutoRootingWorms-16] + _ = x[AutoDetectedBotIP-17] + _ = x[Unknown-255] +} + +const ( + _DroneBLResponse_name_0 = "AllGood" + _DroneBLResponse_name_1 = "IRCDrone" + _DroneBLResponse_name_2 = "BottlerUnknownSpambotOrDroneDDOSDroneSOCKSProxyHTTPProxyProxyChainOpenProxyOpenDNSResolverBruteForceAttackersOpenWingateProxyCompromisedRouterAutoRootingWormsAutoDetectedBotIP" + _DroneBLResponse_name_3 = "Unknown" +) + +var ( + _DroneBLResponse_index_2 = [...]uint8{0, 7, 28, 37, 47, 56, 66, 75, 90, 109, 125, 142, 158, 175} +) + +func (i DroneBLResponse) String() string { + switch { + case i == 0: + return _DroneBLResponse_name_0 + case i == 3: + return _DroneBLResponse_name_1 + case 5 <= i && i <= 17: + i -= 5 + return _DroneBLResponse_name_2[_DroneBLResponse_index_2[i]:_DroneBLResponse_index_2[i+1]] + case i == 255: + return _DroneBLResponse_name_3 + default: + return "DroneBLResponse(" + strconv.FormatInt(int64(i), 10) + ")" + } +} diff --git a/cmd/anubis/js/main.mjs b/cmd/anubis/js/main.mjs new file mode 100644 index 0000000..3f2c652 --- /dev/null +++ b/cmd/anubis/js/main.mjs @@ -0,0 +1,71 @@ +import { process } from './proof-of-work.mjs'; +import { testVideo } from './video.mjs'; + +// 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(); +}; + +const imageURL = (mood) => { + return `/.within.website/x/cmd/anubis/static/img/${mood}.webp`; +}; + +(async () => { + const status = document.getElementById('status'); + const image = document.getElementById('image'); + const title = document.getElementById('title'); + const spinner = document.getElementById('spinner'); + // const testarea = document.getElementById('testarea'); + + // const videoWorks = await testVideo(testarea); + // console.log(`videoWorks: ${videoWorks}`); + + // if (!videoWorks) { + // title.innerHTML = "Oh no!"; + // status.innerHTML = "Checks failed. Please check your browser's settings and try again."; + // image.src = imageURL("sad"); + // spinner.innerHTML = ""; + // spinner.style.display = "none"; + // return; + // } + + status.innerHTML = 'Calculating...'; + + const { challenge, difficulty } = await fetch("/.within.website/x/cmd/anubis/api/make-challenge", { method: "POST" }) + .then(r => { + if (!r.ok) { + throw new Error("Failed to fetch config"); + } + return r.json(); + }) + .catch(err => { + title.innerHTML = "Oh no!"; + status.innerHTML = `Failed to fetch config: ${err.message}`; + image.src = imageURL("sad"); + spinner.innerHTML = ""; + spinner.style.display = "none"; + throw err; + }); + + status.innerHTML = `Calculating...
Difficulty: ${difficulty}`; + + const t0 = Date.now(); + const { hash, nonce } = await process(challenge, difficulty); + const t1 = Date.now(); + + title.innerHTML = "Success!"; + status.innerHTML = `Done! Took ${t1 - t0}ms, ${nonce} iterations`; + image.src = imageURL("happy"); + spinner.innerHTML = ""; + spinner.style.display = "none"; + + setTimeout(() => { + const redir = window.location.href; + window.location.href = u("/.within.website/x/cmd/anubis/api/pass-challenge", { response: hash, nonce, redir, elapsedTime: t1 - t0 }); + }, 2000); +})(); \ No newline at end of file diff --git a/cmd/anubis/js/proof-of-work.mjs b/cmd/anubis/js/proof-of-work.mjs new file mode 100644 index 0000000..d71d2db --- /dev/null +++ b/cmd/anubis/js/proof-of-work.mjs @@ -0,0 +1,62 @@ +// https://dev.to/ratmd/simple-proof-of-work-in-javascript-3kgm + +export function process(data, difficulty = 5) { + return new Promise((resolve, reject) => { + let webWorkerURL = URL.createObjectURL(new Blob([ + '(', processTask(), ')()' + ], { type: 'application/javascript' })); + + let worker = new Worker(webWorkerURL); + + worker.onmessage = (event) => { + worker.terminate(); + resolve(event.data); + }; + + worker.onerror = (event) => { + worker.terminate(); + reject(); + }; + + worker.postMessage({ + data, + difficulty + }); + + URL.revokeObjectURL(webWorkerURL); + }); +} + +function processTask() { + return function () { + const sha256 = (text) => { + const encoded = new TextEncoder().encode(text); + return crypto.subtle.digest("SHA-256", encoded.buffer).then((result) => + Array.from(new Uint8Array(result)) + .map((c) => c.toString(16).padStart(2, "0")) + .join(""), + ); + }; + + addEventListener('message', async (event) => { + let data = event.data.data; + let difficulty = event.data.difficulty; + + let hash; + let nonce = 0; + do { + hash = await sha256(data + nonce++); + } while (hash.substring(0, difficulty) !== Array(difficulty + 1).join('0')); + + nonce -= 1; // last nonce was post-incremented + + postMessage({ + hash, + data, + difficulty, + nonce, + }); + }); + }.toString(); +} + diff --git a/cmd/anubis/js/video.mjs b/cmd/anubis/js/video.mjs new file mode 100644 index 0000000..59cde1e --- /dev/null +++ b/cmd/anubis/js/video.mjs @@ -0,0 +1,16 @@ +const videoElement = ``; + +export const testVideo = async (testarea) => { + testarea.innerHTML = videoElement; + return (await new Promise((resolve) => { + const video = document.getElementById('videotest'); + video.oncanplay = () => { + testarea.style.display = "none"; + resolve(true); + }; + video.onerror = (ev) => { + testarea.style.display = "none"; + resolve(false); + }; + })); +}; \ No newline at end of file diff --git a/cmd/anubis/main.go b/cmd/anubis/main.go new file mode 100644 index 0000000..45afeb2 --- /dev/null +++ b/cmd/anubis/main.go @@ -0,0 +1,574 @@ +package main + +import ( + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "embed" + "encoding/hex" + "encoding/json" + "flag" + "fmt" + "io" + "log" + "log/slog" + "math" + mrand "math/rand" + "net/http" + "net/http/httputil" + "net/url" + "os" + "strconv" + "strings" + "time" + + "github.com/TecharoHQ/anubis" + "github.com/TecharoHQ/anubis/cmd/anubis/internal/config" + "github.com/TecharoHQ/anubis/cmd/anubis/internal/dnsbl" + "github.com/TecharoHQ/anubis/internal" + "github.com/TecharoHQ/anubis/xess" + "github.com/a-h/templ" + "github.com/facebookgo/flagenv" + "github.com/golang-jwt/jwt/v5" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +var ( + bind = flag.String("bind", ":8923", "TCP port to bind HTTP to") + challengeDifficulty = flag.Int("difficulty", 5, "difficulty of the challenge") + metricsBind = flag.String("metrics-bind", ":9090", "TCP port to bind metrics to") + robotsTxt = flag.Bool("serve-robots-txt", false, "serve a robots.txt file that disallows all robots") + policyFname = flag.String("policy-fname", "", "full path to anubis policy document (defaults to a sensible built-in policy)") + slogLevel = flag.String("slog-level", "INFO", "logging level (see https://pkg.go.dev/log/slog#hdr-Levels)") + target = flag.String("target", "http://localhost:3923", "target to reverse proxy to") + healthcheck = flag.Bool("healthcheck", false, "run a health check against Anubis") + + //go:embed static botPolicies.json + static embed.FS + + challengesIssued = promauto.NewCounter(prometheus.CounterOpts{ + Name: "anubis_challenges_issued", + Help: "The total number of challenges issued", + }) + + challengesValidated = promauto.NewCounter(prometheus.CounterOpts{ + Name: "anubis_challenges_validated", + Help: "The total number of challenges validated", + }) + + droneBLHits = promauto.NewCounterVec(prometheus.CounterOpts{ + Name: "anubis_dronebl_hits", + Help: "The total number of hits from DroneBL", + }, []string{"status"}) + + failedValidations = promauto.NewCounter(prometheus.CounterOpts{ + Name: "anubis_failed_validations", + Help: "The total number of failed validations", + }) + + timeTaken = promauto.NewHistogram(prometheus.HistogramOpts{ + Name: "anubis_time_taken", + Help: "The time taken for a browser to generate a response (milliseconds)", + Buckets: prometheus.ExponentialBucketsRange(1, math.Pow(2, 18), 19), + }) +) + +const ( + cookieName = "within.website-x-cmd-anubis-auth" + staticPath = "/.within.website/x/cmd/anubis/" +) + +//go:generate go tool github.com/a-h/templ/cmd/templ generate +//go:generate esbuild js/main.mjs --sourcemap --minify --bundle --outfile=static/js/main.mjs +//go:generate gzip -f -k static/js/main.mjs +//go:generate zstd -f -k --ultra -22 static/js/main.mjs +//go:generate brotli -fZk static/js/main.mjs + +func doHealthCheck() error { + resp, err := http.Get("http://localhost" + *metricsBind + "/metrics") + if err != nil { + return fmt.Errorf("failed to fetch metrics: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + return nil +} + +func main() { + flagenv.Parse() + flag.Parse() + + internal.InitSlog(*slogLevel) + + if *healthcheck { + if err := doHealthCheck(); err != nil { + log.Fatal(err) + } + return + } + + s, err := New(*target, *policyFname) + if err != nil { + log.Fatal(err) + } + + fmt.Println("Rule error IDs:") + for _, rule := range s.policy.Bots { + if rule.Action != config.RuleDeny { + continue + } + + hash, err := rule.Hash() + if err != nil { + log.Fatalf("can't calculate checksum of rule %s: %v", rule.Name, err) + } + + fmt.Printf("* %s: %s\n", rule.Name, hash) + } + fmt.Println() + + mux := http.NewServeMux() + xess.Mount(mux) + + mux.Handle(staticPath, internal.UnchangingCache(http.StripPrefix(staticPath, http.FileServerFS(static)))) + + // mux.HandleFunc("GET /.within.website/x/cmd/anubis/static/js/main.mjs", serveMainJSWithBestEncoding) + + mux.HandleFunc("POST /.within.website/x/cmd/anubis/api/make-challenge", s.makeChallenge) + mux.HandleFunc("GET /.within.website/x/cmd/anubis/api/pass-challenge", s.passChallenge) + mux.HandleFunc("GET /.within.website/x/cmd/anubis/api/test-error", s.testError) + + if *robotsTxt { + mux.HandleFunc("/robots.txt", func(w http.ResponseWriter, r *http.Request) { + http.ServeFileFS(w, r, static, "static/robots.txt") + }) + + mux.HandleFunc("/.well-known/robots.txt", func(w http.ResponseWriter, r *http.Request) { + http.ServeFileFS(w, r, static, "static/robots.txt") + }) + } + + if *metricsBind != "" { + go metricsServer() + } + + mux.HandleFunc("/", s.maybeReverseProxy) + + slog.Info("listening", "url", "http://localhost"+*bind, "difficulty", *challengeDifficulty, "serveRobotsTXT", *robotsTxt, "target", *target, "version", anubis.Version) + log.Fatal(http.ListenAndServe(*bind, mux)) +} + +func metricsServer() { + http.DefaultServeMux.Handle("/metrics", promhttp.Handler()) + slog.Debug("listening for metrics", "url", "http://localhost"+*metricsBind) + log.Fatal(http.ListenAndServe(*metricsBind, nil)) +} + +func sha256sum(text string) (string, error) { + hash := sha256.New() + _, err := hash.Write([]byte(text)) + if err != nil { + return "", err + } + return hex.EncodeToString(hash.Sum(nil)), nil +} + +func (s *Server) challengeFor(r *http.Request) string { + fp := sha256.Sum256(s.priv.Seed()) + + data := fmt.Sprintf( + "Accept-Language=%s,X-Real-IP=%s,User-Agent=%s,WeekTime=%s,Fingerprint=%x,Difficulty=%d", + r.Header.Get("Accept-Language"), + r.Header.Get("X-Real-Ip"), + r.UserAgent(), + time.Now().UTC().Round(24*7*time.Hour).Format(time.RFC3339), + fp, + *challengeDifficulty, + ) + result, _ := sha256sum(data) + return result +} + +func New(target, policyFname string) (*Server, error) { + u, err := url.Parse(target) + if err != nil { + return nil, fmt.Errorf("failed to parse target URL: %w", err) + } + + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return nil, fmt.Errorf("failed to generate ed25519 key: %w", err) + } + + rp := httputil.NewSingleHostReverseProxy(u) + + var fin io.ReadCloser + + if policyFname != "" { + fin, err = os.Open(policyFname) + if err != nil { + return nil, fmt.Errorf("can't parse policy file %s: %w", policyFname, err) + } + } else { + policyFname = "(static)/botPolicies.json" + fin, err = static.Open("botPolicies.json") + if err != nil { + return nil, fmt.Errorf("[unexpected] can't parse builtin policy file %s: %w", policyFname, err) + } + } + + defer fin.Close() + + policy, err := parseConfig(fin, policyFname) + if err != nil { + return nil, err // parseConfig sets a fancy error for us + } + + return &Server{ + rp: rp, + priv: priv, + pub: pub, + policy: policy, + dnsblCache: NewDecayMap[string, dnsbl.DroneBLResponse](), + }, nil +} + +type Server struct { + rp *httputil.ReverseProxy + priv ed25519.PrivateKey + pub ed25519.PublicKey + policy *ParsedConfig + dnsblCache *DecayMap[string, dnsbl.DroneBLResponse] +} + +func (s *Server) maybeReverseProxy(w http.ResponseWriter, r *http.Request) { + cr, rule := s.check(r) + r.Header.Add("X-Anubis-Rule", cr.Name) + r.Header.Add("X-Anubis-Action", string(cr.Rule)) + lg := slog.With( + "check_result", cr, + "user_agent", r.UserAgent(), + "accept_language", r.Header.Get("Accept-Language"), + "priority", r.Header.Get("Priority"), + "x-forwarded-for", + r.Header.Get("X-Forwarded-For"), + "x-real-ip", r.Header.Get("X-Real-Ip"), + ) + policyApplications.WithLabelValues(cr.Name, string(cr.Rule)).Add(1) + + ip := r.Header.Get("X-Real-Ip") + + if s.policy.DNSBL && ip != "" { + resp, ok := s.dnsblCache.Get(ip) + if !ok { + lg.Debug("looking up ip in dnsbl") + resp, err := dnsbl.Lookup(ip) + if err != nil { + lg.Error("can't look up ip in dnsbl", "err", err) + } + s.dnsblCache.Set(ip, resp, 24*time.Hour) + droneBLHits.WithLabelValues(resp.String()).Inc() + } + + if resp != dnsbl.AllGood { + lg.Info("DNSBL hit", "status", resp.String()) + templ.Handler(base("Oh noes!", errorPage(fmt.Sprintf("DroneBL reported an entry: %s, see https://dronebl.org/lookup?ip=%s", resp.String(), ip))), templ.WithStatus(http.StatusOK)).ServeHTTP(w, r) + return + } + } + + switch cr.Rule { + case config.RuleAllow: + lg.Debug("allowing traffic to origin (explicit)") + s.rp.ServeHTTP(w, r) + return + case config.RuleDeny: + clearCookie(w) + lg.Info("explicit deny") + if rule == nil { + lg.Error("rule is nil, cannot calculate checksum") + templ.Handler(base("Oh noes!", errorPage("Other internal server error (contact the admin)")), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r) + return + } + hash, err := rule.Hash() + if err != nil { + lg.Error("can't calculate checksum of rule", "err", err) + templ.Handler(base("Oh noes!", errorPage("Other internal server error (contact the admin)")), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r) + return + } + lg.Debug("rule hash", "hash", hash) + templ.Handler(base("Oh noes!", errorPage(fmt.Sprintf("Access Denied: error code %s", hash))), templ.WithStatus(http.StatusOK)).ServeHTTP(w, r) + return + case config.RuleChallenge: + lg.Debug("challenge requested") + default: + clearCookie(w) + templ.Handler(base("Oh noes!", errorPage("Other internal server error (contact the admin)")), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r) + return + } + + ckie, err := r.Cookie(cookieName) + if err != nil { + lg.Debug("cookie not found", "path", r.URL.Path) + clearCookie(w) + s.renderIndex(w, r) + return + } + + if err := ckie.Valid(); err != nil { + lg.Debug("cookie is invalid", "err", err) + clearCookie(w) + s.renderIndex(w, r) + return + } + + if time.Now().After(ckie.Expires) && !ckie.Expires.IsZero() { + lg.Debug("cookie expired", "path", r.URL.Path) + clearCookie(w) + s.renderIndex(w, r) + return + } + + token, err := jwt.ParseWithClaims(ckie.Value, jwt.MapClaims{}, func(token *jwt.Token) (interface{}, error) { + return s.pub, nil + }) + + if !token.Valid { + lg.Debug("invalid token", "path", r.URL.Path) + clearCookie(w) + s.renderIndex(w, r) + return + } + + claims := token.Claims.(jwt.MapClaims) + + exp, ok := claims["exp"].(float64) + if !ok { + lg.Debug("exp is not int64", "ok", ok, "typeof(exp)", fmt.Sprintf("%T", exp)) + clearCookie(w) + s.renderIndex(w, r) + return + } + + if exp := time.Unix(int64(exp), 0); time.Now().After(exp) { + lg.Debug("token has expired", "exp", exp.Format(time.RFC3339)) + clearCookie(w) + s.renderIndex(w, r) + return + } + + if token.Valid && randomJitter() { + r.Header.Add("X-Anubis-Status", "PASS-BRIEF") + lg.Debug("cookie is not enrolled into secondary screening") + s.rp.ServeHTTP(w, r) + return + } + + if claims["challenge"] != s.challengeFor(r) { + lg.Debug("invalid challenge", "path", r.URL.Path) + clearCookie(w) + s.renderIndex(w, r) + return + } + + var nonce int + + if v, ok := claims["nonce"].(float64); ok { + nonce = int(v) + } + + calcString := fmt.Sprintf("%s%d", s.challengeFor(r), nonce) + calculated, err := sha256sum(calcString) + if err != nil { + lg.Error("failed to calculate sha256sum", "path", r.URL.Path, "err", err) + clearCookie(w) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + if subtle.ConstantTimeCompare([]byte(claims["response"].(string)), []byte(calculated)) != 1 { + lg.Debug("invalid response", "path", r.URL.Path) + failedValidations.Inc() + clearCookie(w) + s.renderIndex(w, r) + return + } + + slog.Debug("all checks passed") + r.Header.Add("X-Anubis-Status", "PASS-FULL") + s.rp.ServeHTTP(w, r) +} + +func (s *Server) renderIndex(w http.ResponseWriter, r *http.Request) { + templ.Handler( + base("Making sure you're not a bot!", index()), + ).ServeHTTP(w, r) +} + +func (s *Server) makeChallenge(w http.ResponseWriter, r *http.Request) { + challenge := s.challengeFor(r) + difficulty := *challengeDifficulty + + lg := slog.With("user_agent", r.UserAgent(), "accept_language", r.Header.Get("Accept-Language"), "priority", r.Header.Get("Priority"), "x-forwarded-for", r.Header.Get("X-Forwarded-For"), "x-real-ip", r.Header.Get("X-Real-Ip")) + + json.NewEncoder(w).Encode(struct { + Challenge string `json:"challenge"` + Difficulty int `json:"difficulty"` + }{ + Challenge: challenge, + Difficulty: difficulty, + }) + lg.Debug("made challenge", "challenge", challenge, "difficulty", difficulty) + challengesIssued.Inc() +} + +func (s *Server) passChallenge(w http.ResponseWriter, r *http.Request) { + lg := slog.With("user_agent", r.UserAgent(), "accept_language", r.Header.Get("Accept-Language"), "priority", r.Header.Get("Priority"), "x-forwarded-for", r.Header.Get("X-Forwarded-For"), "x-real-ip", r.Header.Get("X-Real-Ip")) + + nonceStr := r.FormValue("nonce") + if nonceStr == "" { + clearCookie(w) + lg.Debug("no nonce") + templ.Handler(base("Oh noes!", errorPage("missing nonce")), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r) + return + } + + elapsedTimeStr := r.FormValue("elapsedTime") + if elapsedTimeStr == "" { + clearCookie(w) + lg.Debug("no elapsedTime") + templ.Handler(base("Oh noes!", errorPage("missing elapsedTime")), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r) + return + } + + elapsedTime, err := strconv.ParseFloat(elapsedTimeStr, 64) + if err != nil { + clearCookie(w) + lg.Debug("elapsedTime doesn't parse", "err", err) + templ.Handler(base("Oh noes!", errorPage("invalid elapsedTime")), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r) + return + } + + lg.Info("challenge took", "elapsedTime", elapsedTime) + timeTaken.Observe(elapsedTime) + + response := r.FormValue("response") + redir := r.FormValue("redir") + + challenge := s.challengeFor(r) + + nonce, err := strconv.Atoi(nonceStr) + if err != nil { + clearCookie(w) + lg.Debug("nonce doesn't parse", "err", err) + templ.Handler(base("Oh noes!", errorPage("invalid nonce")), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r) + return + } + + calcString := fmt.Sprintf("%s%d", challenge, nonce) + calculated, err := sha256sum(calcString) + if err != nil { + clearCookie(w) + lg.Debug("can't parse shasum", "err", err) + templ.Handler(base("Oh noes!", errorPage("failed to calculate sha256sum")), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r) + return + } + + if subtle.ConstantTimeCompare([]byte(response), []byte(calculated)) != 1 { + clearCookie(w) + lg.Debug("hash does not match", "got", response, "want", calculated) + templ.Handler(base("Oh noes!", errorPage("invalid response")), templ.WithStatus(http.StatusForbidden)).ServeHTTP(w, r) + failedValidations.Inc() + return + } + + // compare the leading zeroes + if !strings.HasPrefix(response, strings.Repeat("0", *challengeDifficulty)) { + clearCookie(w) + lg.Debug("difficulty check failed", "response", response, "difficulty", *challengeDifficulty) + templ.Handler(base("Oh noes!", errorPage("invalid response")), templ.WithStatus(http.StatusForbidden)).ServeHTTP(w, r) + failedValidations.Inc() + return + } + + // generate JWT cookie + token := jwt.NewWithClaims(jwt.SigningMethodEdDSA, jwt.MapClaims{ + "challenge": challenge, + "nonce": nonce, + "response": response, + "iat": time.Now().Unix(), + "nbf": time.Now().Add(-1 * time.Minute).Unix(), + "exp": time.Now().Add(24 * 7 * time.Hour).Unix(), + }) + tokenString, err := token.SignedString(s.priv) + if err != nil { + lg.Error("failed to sign JWT", "err", err) + clearCookie(w) + templ.Handler(base("Oh noes!", errorPage("failed to sign JWT")), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r) + return + } + + http.SetCookie(w, &http.Cookie{ + Name: cookieName, + Value: tokenString, + Expires: time.Now().Add(24 * 7 * time.Hour), + SameSite: http.SameSiteLaxMode, + Path: "/", + }) + + challengesValidated.Inc() + lg.Debug("challenge passed, redirecting to app") + http.Redirect(w, r, redir, http.StatusFound) +} + +func (s *Server) testError(w http.ResponseWriter, r *http.Request) { + err := r.FormValue("err") + templ.Handler(base("Oh noes!", errorPage(err)), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r) +} + +func ohNoes(w http.ResponseWriter, r *http.Request, err error) { + slog.Error("super fatal error", "err", err) + templ.Handler(base("Oh noes!", errorPage("An internal server error happened")), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r) +} + +func clearCookie(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{ + Name: cookieName, + Value: "", + Expires: time.Now().Add(-1 * time.Hour), + MaxAge: -1, + SameSite: http.SameSiteLaxMode, + }) +} + +func randomJitter() bool { + return mrand.Intn(100) > 10 +} + +func serveMainJSWithBestEncoding(w http.ResponseWriter, r *http.Request) { + priorityList := []string{"zstd", "br", "gzip"} + enc2ext := map[string]string{ + "zstd": "zst", + "br": "br", + "gzip": "gz", + } + + for _, enc := range priorityList { + if strings.Contains(r.Header.Get("Accept-Encoding"), enc) { + w.Header().Set("Content-Type", "text/javascript") + w.Header().Set("Content-Encoding", enc) + http.ServeFileFS(w, r, static, "static/js/main.mjs."+enc2ext[enc]) + return + } + } + + w.Header().Set("Content-Type", "text/javascript") + http.ServeFileFS(w, r, static, "static/js/main.mjs") +} diff --git a/cmd/anubis/policy.go b/cmd/anubis/policy.go new file mode 100644 index 0000000..f636349 --- /dev/null +++ b/cmd/anubis/policy.go @@ -0,0 +1,146 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "regexp" + + "github.com/TecharoHQ/anubis/cmd/anubis/internal/config" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" +) + +var ( + policyApplications = promauto.NewCounterVec(prometheus.CounterOpts{ + Name: "anubis_policy_results", + Help: "The results of each policy rule", + }, []string{"rule", "action"}) +) + +type ParsedConfig struct { + orig config.Config + + Bots []Bot + DNSBL bool +} + +type Bot struct { + Name string + UserAgent *regexp.Regexp + Path *regexp.Regexp + Action config.Rule `json:"action"` +} + +func (b Bot) Hash() (string, error) { + var pathRex string + if b.Path != nil { + pathRex = b.Path.String() + } + var userAgentRex string + if b.UserAgent != nil { + userAgentRex = b.UserAgent.String() + } + + return sha256sum(fmt.Sprintf("%s::%s::%s", b.Name, pathRex, userAgentRex)) +} + +func parseConfig(fin io.Reader, fname string) (*ParsedConfig, error) { + var c config.Config + if err := json.NewDecoder(fin).Decode(&c); err != nil { + return nil, fmt.Errorf("can't parse policy config JSON %s: %w", fname, err) + } + + if err := c.Valid(); err != nil { + return nil, err + } + + var err error + + result := &ParsedConfig{ + orig: c, + } + + for _, b := range c.Bots { + if berr := b.Valid(); berr != nil { + err = errors.Join(err, berr) + continue + } + + var botParseErr error + parsedBot := Bot{ +