mirror of
https://github.com/TecharoHQ/anubis.git
synced 2026-06-09 22:08:15 +00:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f19a5f7eb8 | |||
| 3dc962b301 | |||
| 926f3d1d0e | |||
| 04b3a835cd | |||
| 63d517c34d | |||
| b57508afcd | |||
| 276b537776 | |||
| 9f479f578a | |||
| c184028d42 | |||
| 0491f1fac2 | |||
| d3a00da448 | |||
| 7e037b65e8 | |||
| ebf9a30878 | |||
| f8605bcd3c | |||
| 1d700a0370 | |||
| 681c2cc2ed |
@@ -39,3 +39,8 @@ wenet
|
||||
qwertiko
|
||||
setuplistener
|
||||
mba
|
||||
xfu
|
||||
xou
|
||||
AWOO
|
||||
firewalls
|
||||
bindhosts
|
||||
|
||||
@@ -120,6 +120,7 @@ fahedouch
|
||||
fastcgi
|
||||
FCr
|
||||
fcrdns
|
||||
fcvg
|
||||
fediverse
|
||||
ffprobe
|
||||
fhdr
|
||||
@@ -238,6 +239,7 @@ mnt
|
||||
Mojeek
|
||||
mojeekbot
|
||||
mozilla
|
||||
mqvh
|
||||
myclient
|
||||
mymaster
|
||||
mypass
|
||||
@@ -387,6 +389,7 @@ vnd
|
||||
VPS
|
||||
Vultr
|
||||
WAIFU
|
||||
wcg
|
||||
weblate
|
||||
webmaster
|
||||
webpage
|
||||
|
||||
@@ -27,6 +27,7 @@ jobs:
|
||||
- palemoon/amd64
|
||||
#- palemoon/i386
|
||||
- robots_txt
|
||||
- traefik
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
|
||||
+1
-1
@@ -259,7 +259,7 @@ func main() {
|
||||
}
|
||||
|
||||
lg.Info("loading policy file", "fname", *policyFname)
|
||||
policy, err := libanubis.LoadPoliciesOrDefault(ctx, *policyFname, *challengeDifficulty, *slogLevel)
|
||||
policy, err := libanubis.LoadPoliciesOrDefault(ctx, *policyFname, *challengeDifficulty, *slogLevel, strings.TrimSpace(*target) == "")
|
||||
if err != nil {
|
||||
log.Fatalf("can't parse policy file: %v", err)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
<!-- This changes the project to: -->
|
||||
|
||||
- Patch [GHSA-6wcg-mqvh-fcvg](https://github.com/TecharoHQ/anubis/security/advisories/GHSA-6wcg-mqvh-fcvg) by containing subrequest logic to Anubis instances in subrequest mode.
|
||||
- Implement robot9001 style delays on the honeypot feature so that the first hit takes 1 millisecond, the second takes 2, etc.
|
||||
- Move metrics server configuration to [the policy file](./admin/policies.mdx#metrics-server).
|
||||
- Expose [pprof endpoints](https://pkg.go.dev/net/http/pprof) on the metrics listener to enable profiling Anubis in production.
|
||||
- fix: prevent nil pointer panic in challenge validation when threshold rules match during PassChallenge (#1463)
|
||||
@@ -20,9 +22,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Fixed mixed tab/space indentation in Caddy documentation code block
|
||||
- Improve error messages and fix broken REDIRECT_DOMAINS link in docs ([#1193](https://github.com/TecharoHQ/anubis/issues/1193))
|
||||
- Add Bulgarian locale ([#1394](https://github.com/TecharoHQ/anubis/pull/1394))
|
||||
- Fixed case-sensitivity mismatch in geoipchecker.go
|
||||
- Fix CEL internal errors when iterating `headers`/`query` map wrappers by implementing map iterators for `HTTPHeaders` and `URLValues` ([#1465](https://github.com/TecharoHQ/anubis/pull/1465)).
|
||||
- Enable [metrics serving via TLS](./admin/policies.mdx#tls), including [mutual TLS (mTLS)](./admin/policies.mdx#mtls).
|
||||
- Enable [HTTP basic auth](./admin/policies.mdx#http-basic-authentication) for the metrics server.
|
||||
- Fix a bug in the dataset poisoning maze that could allow denial of service [#1580](https://github.com/TecharoHQ/anubis/issues/1580).
|
||||
- Add config option to add ASN to logs/metrics.
|
||||
- Log weight when issuing challenge.
|
||||
- Gate pprof endpoints behind `metrics.debug` in the policy file.
|
||||
- Limit naive honeypot r9k delay to one second.
|
||||
- Fix an obscure case where adding query values to a subrequest match could cause an invalid rule match when using path based matching for protected resources.
|
||||
- Fix an edge case where load average expression values could nil pointer dereference when Anubis just started up.
|
||||
- Fix an obscure case where Anubis in subrequest mode could allow redirects to invalid domains with strange instructions.
|
||||
- Fix `path_regex` and CEL `path` rules not matching when using Traefik `forwardAuth` middleware. Anubis now checks `X-Forwarded-Uri` (Traefik) in addition to `X-Original-URI` (nginx) when resolving the request path in subrequest mode ([#1628](https://github.com/TecharoHQ/anubis/issues/1628)).
|
||||
- Validate bounds in the CEL `randInt` helper so non-positive or platform-overflowing arguments surface a typed CEL error instead of an evaluator panic.
|
||||
- Fix a race in the bbolt store where the asynchronous cleanup scheduled by an expired read could delete a value that had just been refreshed; the delete now only fires when the key still carries the same expired generation it observed.
|
||||
- Marginally increase the performances of requests processing
|
||||
- Marginally improve the performances of PoW validation
|
||||
- Significantly improve the performances of the gzip middleware
|
||||
|
||||
## v1.25.0: Necron
|
||||
|
||||
|
||||
@@ -131,11 +131,27 @@ Then point your Ingress to the Anubis port:
|
||||
name: anubis
|
||||
```
|
||||
|
||||
## Storage
|
||||
|
||||
By default, Anubis stores all of its data in memory. This memory is not shared between pods. If you have multiple instances of Anubis without the data being [stored outside of memory](../policies.mdx#storage-backends) and a [shared cookie key](../installation.mdx#key-generation), you will run into [unexpected behaviour](https://github.com/TecharoHQ/anubis/issues/1602) when user traffic traverses between pods.
|
||||
|
||||
Based on the deployment of your Kubernetes cluster, here are the preferable storage backends to pick from:
|
||||
|
||||
| Backend | Pro | Con |
|
||||
| :------- | :-------------------------------------------------------------- | :------------------------------------------------------------------------------------------- |
|
||||
| `bbolt` | Only requires a ReadWriteOnce PVC. | Does not support more than one Anubis pod. |
|
||||
| `memory` | Requires no configuration. | Process memory is not shared between pods. |
|
||||
| `s3api` | Great if your cluster includes Rook/Ceph to use RADOS directly. | Potentially higher latency unless you use a store like [Tigris](https://www.tigrisdata.com). |
|
||||
| `valkey` | Trivial to configure in your cluster. | If your Redis/Valkey server is down, Anubis is going to have issues. |
|
||||
|
||||
Pick your poison accordingly. Many production deployments use the `s3api` and `valkey` backends without issue. Single node deployments can get away with either `memory` or `bbolt` depending on the facts and circumstances of the deployment.
|
||||
|
||||
## Envoy Gateway
|
||||
|
||||
If you are using envoy-gateway, the `X-Real-Ip` header is not set by default, but Anubis does require it. You can resolve this by adding the header, either on the specific `HTTPRoute` where Anubis is listening, or on the `ClientTrafficPolicy` to apply it to any number of Gateways:
|
||||
|
||||
HTTPRoute:
|
||||
|
||||
```yaml
|
||||
apiVersion: gateway.networking.k8s.io/v1
|
||||
kind: HTTPRoute
|
||||
@@ -160,6 +176,7 @@ spec:
|
||||
```
|
||||
|
||||
Applying to any number of Gateways:
|
||||
|
||||
```yaml
|
||||
apiVersion: gateway.envoyproxy.io/v1alpha1
|
||||
kind: ClientTrafficPolicy
|
||||
|
||||
@@ -138,6 +138,24 @@ metrics:
|
||||
socketMode: "0700" # must be a string
|
||||
```
|
||||
|
||||
### Debug routes
|
||||
|
||||
Anubis' metrics server supports [pprof](https://pkg.go.dev/runtime/pprof), the Go standard library tool for profiling Go applications. This is very useful for debugging how Anubis works in the wild with regards to CPU, multicore, and RAM usage. pprof is very powerful and can expose command line arguments as part of the debugging setup (inside Google, everything is done with command line flags).
|
||||
|
||||
Prior versions of Anubis exposed pprof endpoints on all TCP bindhosts by default. This means that machines with incorrectly configured firewalls can expose command line arguments to the public internet in the right conditions.
|
||||
|
||||
In order to enable pprof profiling endpoints on the Metrics server, set the `debug` flag under the `metrics` block:
|
||||
|
||||
```yaml
|
||||
metrics:
|
||||
bind: ":9090"
|
||||
network: "tcp"
|
||||
|
||||
debug: true
|
||||
```
|
||||
|
||||
To err on the side of caution, this defaults to disabled. If this defaults migration breaks your configuration, please let us know in a ticket.
|
||||
|
||||
### TLS
|
||||
|
||||
If you want to serve the metrics server over TLS, use the `tls` block:
|
||||
@@ -201,8 +219,11 @@ Anubis offers the following storage backends:
|
||||
|
||||
- [`memory`](#memory) -- A simple in-memory hashmap
|
||||
- [`bbolt`](#bbolt) -- An on-disk key/value store backed by [bbolt](https://github.com/etcd-io/bbolt), an embedded key/value database for Go programs
|
||||
- [`s3api`](#s3api) -- Amazon S3 based storage or another compatible object store
|
||||
- [`valkey`](#valkey) -- A remote in-memory key/value database backed by [Valkey](https://valkey.io/) (or another database compatible with the [RESP](https://redis.io/docs/latest/develop/reference/protocol-spec/) protocol)
|
||||
|
||||
:::warning
|
||||
|
||||
If no storage backend is set in the policy file, Anubis will use the [`memory`](#memory) backend by default. This is equivalent to the following in the policy file:
|
||||
|
||||
```yaml
|
||||
@@ -211,6 +232,10 @@ store:
|
||||
parameters: {}
|
||||
```
|
||||
|
||||
This means that all session data that is required for the challenge mechanism to work is stored **IN PROCESS MEMORY** that is **NOT** shared between instances of Anubis. If you set up Anubis with multiple instances using the `memory` storage backend, your users will sometimes get "Administrator has misconfigured Anubis" error messages when it cannot look up the aforementioned session data.
|
||||
|
||||
:::
|
||||
|
||||
### `memory`
|
||||
|
||||
The memory backend is an in-memory cache. This backend works best if you don't use multiple instances of Anubis or don't have mutable storage in the environment you're running Anubis in.
|
||||
@@ -411,6 +436,7 @@ Anubis exposes the following logging settings in the policy file:
|
||||
| `level` | [log level](#log-levels) | `info` | The logging level threshold. Any logs that are at or above this threshold will be drained to the sink. Any other logs will be discarded. |
|
||||
| `sink` | string | `stdio`, `file` | The sink where the logs drain to as they are being recorded in Anubis. |
|
||||
| `parameters` | object | | Parameters for the given logging sink. This will vary based on the logging sink of choice. See below for more information. |
|
||||
| `asn` | bool | `true`, `false` | Add ASN information to logs/metrics. (Requires a Thoth client configured) |
|
||||
|
||||
Anubis supports the following logging sinks:
|
||||
|
||||
|
||||
@@ -106,7 +106,7 @@ require (
|
||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
|
||||
github.com/go-git/go-billy/v5 v5.6.2 // indirect
|
||||
github.com/go-git/go-git/v5 v5.16.2 // indirect
|
||||
github.com/go-jose/go-jose/v3 v3.0.4 // indirect
|
||||
github.com/go-jose/go-jose/v3 v3.0.5 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
|
||||
@@ -189,8 +189,8 @@ github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMj
|
||||
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
|
||||
github.com/go-git/go-git/v5 v5.16.2 h1:fT6ZIOjE5iEnkzKyxTHK1W4HGAsPhqEqiSAssSO77hM=
|
||||
github.com/go-git/go-git/v5 v5.16.2/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8=
|
||||
github.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY=
|
||||
github.com/go-jose/go-jose/v3 v3.0.4/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ=
|
||||
github.com/go-jose/go-jose/v3 v3.0.5 h1:BLLJWbC4nMZOfuPVxoZIxeYsn6Nl2r1fITaJ78UQlVQ=
|
||||
github.com/go-jose/go-jose/v3 v3.0.5/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
|
||||
+24
-5
@@ -2,11 +2,28 @@ package internal
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
func GzipMiddleware(level int, next http.Handler) http.Handler {
|
||||
// Validate the level once at setup; gzip.NewWriterLevel only fails for
|
||||
// invalid levels and we'd rather panic now than mid-request.
|
||||
if _, err := gzip.NewWriterLevel(io.Discard, level); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Per-middleware pool of *gzip.Writer. Each entry carries ~40 KiB of
|
||||
// deflate buffers; reusing them avoids that allocation on every request.
|
||||
pool := sync.Pool{
|
||||
New: func() any {
|
||||
gz, _ := gzip.NewWriterLevel(io.Discard, level)
|
||||
return gz
|
||||
},
|
||||
}
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
|
||||
next.ServeHTTP(w, r)
|
||||
@@ -14,11 +31,13 @@ func GzipMiddleware(level int, next http.Handler) http.Handler {
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Encoding", "gzip")
|
||||
gz, err := gzip.NewWriterLevel(w, level)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer gz.Close()
|
||||
gz := pool.Get().(*gzip.Writer)
|
||||
gz.Reset(w)
|
||||
defer func() {
|
||||
gz.Close()
|
||||
gz.Reset(io.Discard)
|
||||
pool.Put(gz)
|
||||
}()
|
||||
|
||||
grw := gzipResponseWriter{ResponseWriter: w, sink: gz}
|
||||
next.ServeHTTP(grw, r)
|
||||
|
||||
+2
-3
@@ -11,9 +11,8 @@ import (
|
||||
// SHA256sum computes a cryptographic hash. Still used for proof-of-work challenges
|
||||
// where we need the security properties of a cryptographic hash function.
|
||||
func SHA256sum(text string) string {
|
||||
hash := sha256.New()
|
||||
hash.Write([]byte(text))
|
||||
return hex.EncodeToString(hash.Sum(nil))
|
||||
sum := sha256.Sum256([]byte(text))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// FastHash is a high-performance non-cryptographic hash function suitable for
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"math/rand/v2"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
@@ -76,13 +77,6 @@ type Impl struct {
|
||||
affirmation, body, title spintax.Spintax
|
||||
}
|
||||
|
||||
func (i *Impl) incrementUA(ctx context.Context, userAgent string) int {
|
||||
result, _ := i.uaWeight.Get(ctx, internal.SHA256sum(userAgent))
|
||||
result++
|
||||
i.uaWeight.Set(ctx, internal.SHA256sum(userAgent), result, time.Hour)
|
||||
return result
|
||||
}
|
||||
|
||||
func (i *Impl) incrementNetwork(ctx context.Context, network string) int {
|
||||
result, _ := i.networkWeight.Get(ctx, internal.SHA256sum(network))
|
||||
result++
|
||||
@@ -90,20 +84,19 @@ func (i *Impl) incrementNetwork(ctx context.Context, network string) int {
|
||||
return result
|
||||
}
|
||||
|
||||
func (i *Impl) CheckUA() checker.Impl {
|
||||
return checker.Func(func(r *http.Request) (bool, error) {
|
||||
result, _ := i.uaWeight.Get(r.Context(), internal.SHA256sum(r.UserAgent()))
|
||||
if result >= 25 {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
return false, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (i *Impl) CheckNetwork() checker.Impl {
|
||||
return checker.Func(func(r *http.Request) (bool, error) {
|
||||
result, _ := i.uaWeight.Get(r.Context(), internal.SHA256sum(r.UserAgent()))
|
||||
realIP, _ := internal.RealIP(r)
|
||||
if !realIP.IsValid() {
|
||||
realIP = netip.MustParseAddr(r.Header.Get("X-Real-Ip"))
|
||||
}
|
||||
|
||||
network, ok := internal.ClampIP(realIP)
|
||||
if !ok {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
result, _ := i.networkWeight.Get(r.Context(), internal.SHA256sum(network.String()))
|
||||
if result >= 25 {
|
||||
return true, nil
|
||||
}
|
||||
@@ -164,7 +157,6 @@ func (i *Impl) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
networkCount := i.incrementNetwork(r.Context(), network.String())
|
||||
uaCount := i.incrementUA(r.Context(), r.UserAgent())
|
||||
|
||||
stage := r.PathValue("stage")
|
||||
|
||||
@@ -172,11 +164,14 @@ func (i *Impl) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
lg.Debug("found new entrance point", "id", id, "stage", stage, "userAgent", r.UserAgent(), "clampedIP", network)
|
||||
} else {
|
||||
switch {
|
||||
case networkCount%256 == 0, uaCount%256 == 0:
|
||||
lg.Warn("found possible crawler", "id", id, "network", network)
|
||||
case networkCount%256 == 0:
|
||||
lg.Warn("found possible crawler", "id", id, "network", network, "userAgent", r.UserAgent())
|
||||
}
|
||||
}
|
||||
|
||||
millisecondAmount := min(math.Pow(float64(networkCount), 2), 1000)
|
||||
time.Sleep(time.Duration(millisecondAmount) * time.Millisecond)
|
||||
|
||||
spins := i.makeSpins()
|
||||
affirmations := i.makeAffirmations()
|
||||
title := i.makeTitle()
|
||||
|
||||
@@ -595,7 +595,7 @@ func spawnAnubisWithOptions(t *testing.T, basePrefix string) string {
|
||||
fmt.Fprintf(w, "<html><body><span id=anubis-test>%d</span></body></html>", time.Now().Unix())
|
||||
})
|
||||
|
||||
policy, err := libanubis.LoadPoliciesOrDefault(t.Context(), "", anubis.DefaultDifficulty, "info")
|
||||
policy, err := libanubis.LoadPoliciesOrDefault(t.Context(), "", anubis.DefaultDifficulty, "info", false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
+72
-15
@@ -11,6 +11,7 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -32,6 +33,7 @@ import (
|
||||
"github.com/TecharoHQ/anubis/lib/policy"
|
||||
"github.com/TecharoHQ/anubis/lib/policy/checker"
|
||||
"github.com/TecharoHQ/anubis/lib/store"
|
||||
iptoasnv1 "github.com/TecharoHQ/thoth-proto/gen/techaro/thoth/iptoasn/v1"
|
||||
|
||||
// challenge implementations
|
||||
_ "github.com/TecharoHQ/anubis/lib/challenge/metarefresh"
|
||||
@@ -39,31 +41,52 @@ import (
|
||||
_ "github.com/TecharoHQ/anubis/lib/challenge/proofofwork"
|
||||
)
|
||||
|
||||
type contextKey int
|
||||
|
||||
const asnContextKey contextKey = iota
|
||||
|
||||
type asnInfo struct {
|
||||
ASN string
|
||||
Description string
|
||||
}
|
||||
|
||||
func asnFromContext(ctx context.Context) (string, string) {
|
||||
if v, ok := ctx.Value(asnContextKey).(asnInfo); ok {
|
||||
return v.ASN, v.Description
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
var (
|
||||
challengesIssued = promauto.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "anubis_challenges_issued",
|
||||
Help: "The total number of challenges issued",
|
||||
}, []string{"method"})
|
||||
}, []string{"method", "asn", "asn_description"})
|
||||
|
||||
challengesValidated = promauto.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "anubis_challenges_validated",
|
||||
Help: "The total number of challenges validated",
|
||||
}, []string{"method"})
|
||||
}, []string{"method", "asn", "asn_description"})
|
||||
|
||||
droneBLHits = promauto.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "anubis_dronebl_hits",
|
||||
Help: "The total number of hits from DroneBL",
|
||||
}, []string{"status"})
|
||||
}, []string{"status", "asn", "asn_description"})
|
||||
|
||||
failedValidations = promauto.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "anubis_failed_validations",
|
||||
Help: "The total number of failed validations",
|
||||
}, []string{"method"})
|
||||
}, []string{"method", "asn", "asn_description"})
|
||||
|
||||
requestsProxied = promauto.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "anubis_proxied_requests_total",
|
||||
Help: "Number of requests proxied through Anubis to upstream targets",
|
||||
}, []string{"host"})
|
||||
}, []string{"host", "asn", "asn_description"})
|
||||
|
||||
requestsByASN = promauto.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "anubis_requests_by_asn_total",
|
||||
Help: "Number of requests by ASN",
|
||||
}, []string{"asn", "asn_description"})
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
@@ -78,6 +101,28 @@ type Server struct {
|
||||
hs512Secret []byte
|
||||
}
|
||||
|
||||
func (s *Server) getRequestLogger(r *http.Request) (*slog.Logger, *http.Request) {
|
||||
lg := internal.GetRequestLogger(s.logger, r)
|
||||
|
||||
if s.policy.LogASN && s.policy.ThothClient != nil {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 500*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
ip := r.Header.Get("X-Real-Ip")
|
||||
if info, err := s.policy.ThothClient.IPToASN.Lookup(ctx, &iptoasnv1.LookupRequest{IpAddress: ip}); err == nil && info.GetAnnounced() {
|
||||
asn := strconv.FormatUint(uint64(info.GetAsNumber()), 10)
|
||||
lg = lg.With("asn", info.GetAsNumber(), "asn_description", info.GetDescription())
|
||||
requestsByASN.WithLabelValues(asn, info.GetDescription()).Inc()
|
||||
r = r.WithContext(context.WithValue(r.Context(), asnContextKey, asnInfo{
|
||||
ASN: asn,
|
||||
Description: info.GetDescription(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
return lg, r
|
||||
}
|
||||
|
||||
func (s *Server) getTokenKeyfunc() jwt.Keyfunc {
|
||||
// return ED25519 key if HS512 is not set
|
||||
if len(s.hs512Secret) == 0 {
|
||||
@@ -141,7 +186,7 @@ func (s *Server) issueChallenge(ctx context.Context, r *http.Request, lg *slog.L
|
||||
return nil, err
|
||||
}
|
||||
|
||||
lg.Info("new challenge issued", "challenge", id.String())
|
||||
lg.Info("new challenge issued", "challenge", id.String(), "weight", cr.Weight)
|
||||
|
||||
return &chall, err
|
||||
}
|
||||
@@ -193,7 +238,7 @@ func (s *Server) maybeReverseProxyOrPage(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
func (s *Server) maybeReverseProxy(w http.ResponseWriter, r *http.Request, httpStatusOnly bool) {
|
||||
lg := internal.GetRequestLogger(s.logger, r)
|
||||
lg, r := s.getRequestLogger(r)
|
||||
|
||||
if val, _ := s.store.Get(r.Context(), fmt.Sprintf("ogtags:allow:%s%s", r.Host, r.URL.String())); val != nil {
|
||||
lg.Debug("serving opengraph tag asset")
|
||||
@@ -218,7 +263,10 @@ func (s *Server) maybeReverseProxy(w http.ResponseWriter, r *http.Request, httpS
|
||||
r.Header.Add("X-Anubis-Rule", cr.Name)
|
||||
r.Header.Add("X-Anubis-Action", string(cr.Rule))
|
||||
lg = lg.With("check_result", cr)
|
||||
policy.Applications.WithLabelValues(cr.Name, string(cr.Rule)).Add(1)
|
||||
{
|
||||
asn, asnDesc := asnFromContext(r.Context())
|
||||
policy.Applications.WithLabelValues(cr.Name, string(cr.Rule), asn, asnDesc).Add(1)
|
||||
}
|
||||
|
||||
ip := r.Header.Get("X-Real-Ip")
|
||||
|
||||
@@ -348,7 +396,8 @@ func (s *Server) handleDNSBL(w http.ResponseWriter, r *http.Request, ip string,
|
||||
lg.Error("can't look up ip in dnsbl", "err", err)
|
||||
}
|
||||
db.Set(r.Context(), ip, resp, 24*time.Hour)
|
||||
droneBLHits.WithLabelValues(resp.String()).Inc()
|
||||
asn, asnDesc := asnFromContext(r.Context())
|
||||
droneBLHits.WithLabelValues(resp.String(), asn, asnDesc).Inc()
|
||||
}
|
||||
|
||||
if resp != dnsbl.AllGood {
|
||||
@@ -366,7 +415,7 @@ func (s *Server) handleDNSBL(w http.ResponseWriter, r *http.Request, ip string,
|
||||
}
|
||||
|
||||
func (s *Server) MakeChallenge(w http.ResponseWriter, r *http.Request) {
|
||||
lg := internal.GetRequestLogger(s.logger, r)
|
||||
lg, r := s.getRequestLogger(r)
|
||||
localizer := localization.GetLocalizer(r)
|
||||
|
||||
redir := r.FormValue("redir")
|
||||
@@ -435,11 +484,14 @@ func (s *Server) MakeChallenge(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
lg.Debug("made challenge", "challenge", chall, "rules", rule.Challenge, "cr", cr)
|
||||
challengesIssued.WithLabelValues("api").Inc()
|
||||
{
|
||||
asn, asnDesc := asnFromContext(r.Context())
|
||||
challengesIssued.WithLabelValues("api", asn, asnDesc).Inc()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) {
|
||||
lg := internal.GetRequestLogger(s.logger, r)
|
||||
lg, r := s.getRequestLogger(r)
|
||||
localizer := localization.GetLocalizer(r)
|
||||
|
||||
redir := r.FormValue("redir")
|
||||
@@ -530,7 +582,8 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if err := impl.Validate(r, lg, in); err != nil {
|
||||
failedValidations.WithLabelValues(rule.Challenge.Algorithm).Inc()
|
||||
asn, asnDesc := asnFromContext(r.Context())
|
||||
failedValidations.WithLabelValues(rule.Challenge.Algorithm, asn, asnDesc).Inc()
|
||||
var cerr *challenge.Error
|
||||
s.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host})
|
||||
lg.Debug("challenge validate call failed", "err", err)
|
||||
@@ -590,7 +643,10 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) {
|
||||
lg.Debug("can't update information about challenge", "err", err)
|
||||
}
|
||||
|
||||
challengesValidated.WithLabelValues(rule.Challenge.Algorithm).Inc()
|
||||
{
|
||||
asn, asnDesc := asnFromContext(r.Context())
|
||||
challengesValidated.WithLabelValues(rule.Challenge.Algorithm, asn, asnDesc).Inc()
|
||||
}
|
||||
lg.Debug("challenge passed, redirecting to app")
|
||||
http.Redirect(w, r, redir, http.StatusFound)
|
||||
}
|
||||
@@ -629,7 +685,8 @@ func (s *Server) check(r *http.Request, lg *slog.Logger) (policy.CheckResult, *p
|
||||
return cr("bot/"+b.Name, b.Action, weight), &b, nil
|
||||
case config.RuleWeigh:
|
||||
lg.Debug("adjusting weight", "name", b.Name, "delta", b.Weight.Adjust)
|
||||
policy.Applications.WithLabelValues("bot/"+b.Name, "WEIGH").Add(1)
|
||||
asn, asnDesc := asnFromContext(r.Context())
|
||||
policy.Applications.WithLabelValues("bot/"+b.Name, "WEIGH", asn, asnDesc).Add(1)
|
||||
weight += b.Weight.Adjust
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -58,7 +58,7 @@ func loadPolicies(t *testing.T, fname string, difficulty int) *policy.ParsedConf
|
||||
|
||||
t.Logf("loading policy file: %s", fname)
|
||||
|
||||
anubisPolicy, err := LoadPoliciesOrDefault(ctx, fname, difficulty, "info")
|
||||
anubisPolicy, err := LoadPoliciesOrDefault(ctx, fname, difficulty, "info", false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -250,7 +250,7 @@ func TestLoadPolicies(t *testing.T) {
|
||||
}
|
||||
defer fin.Close()
|
||||
|
||||
if _, err := policy.ParseConfig(t.Context(), fin, fname, 4, "info"); err != nil {
|
||||
if _, err := policy.ParseConfig(t.Context(), fin, fname, 4, "info", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -45,7 +45,7 @@ func (i *Impl) Validate(r *http.Request, lg *slog.Logger, in *chall.ValidateInpu
|
||||
return chall.NewError("validate", "invalid response", fmt.Errorf("%w nonce", chall.ErrMissingField))
|
||||
}
|
||||
|
||||
nonce, err := strconv.Atoi(nonceStr)
|
||||
_, err := strconv.Atoi(nonceStr)
|
||||
if err != nil {
|
||||
return chall.NewError("validate", "invalid response", fmt.Errorf("%w: nonce: %w", chall.ErrInvalidFormat, err))
|
||||
|
||||
@@ -66,7 +66,7 @@ func (i *Impl) Validate(r *http.Request, lg *slog.Logger, in *chall.ValidateInpu
|
||||
return chall.NewError("validate", "invalid response", fmt.Errorf("%w response", chall.ErrMissingField))
|
||||
}
|
||||
|
||||
calcString := fmt.Sprintf("%s%d", challenge, nonce)
|
||||
calcString := challenge + nonceStr
|
||||
calculated := internal.SHA256sum(calcString)
|
||||
|
||||
if subtle.ConstantTimeCompare([]byte(response), []byte(calculated)) != 1 {
|
||||
|
||||
+2
-10
@@ -55,7 +55,7 @@ type Options struct {
|
||||
DifficultyInJWT bool
|
||||
}
|
||||
|
||||
func LoadPoliciesOrDefault(ctx context.Context, fname string, defaultDifficulty int, logLevel string) (*policy.ParsedConfig, error) {
|
||||
func LoadPoliciesOrDefault(ctx context.Context, fname string, defaultDifficulty int, logLevel string, subrequestMode bool) (*policy.ParsedConfig, error) {
|
||||
var fin io.ReadCloser
|
||||
var err error
|
||||
|
||||
@@ -79,7 +79,7 @@ func LoadPoliciesOrDefault(ctx context.Context, fname string, defaultDifficulty
|
||||
}
|
||||
}(fin)
|
||||
|
||||
anubisPolicy, err := policy.ParseConfig(ctx, fin, fname, defaultDifficulty, logLevel)
|
||||
anubisPolicy, err := policy.ParseConfig(ctx, fin, fname, defaultDifficulty, logLevel, subrequestMode)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("can't parse policy file %s: %w", fname, err)
|
||||
}
|
||||
@@ -190,14 +190,6 @@ func New(opts Options) (*Server, error) {
|
||||
},
|
||||
Name: "honeypot/network",
|
||||
},
|
||||
policy.Bot{
|
||||
Rules: mazeGen.CheckUA(),
|
||||
Action: config.RuleWeigh,
|
||||
Weight: &config.Weight{
|
||||
Adjust: 30,
|
||||
},
|
||||
Name: "honeypot/user-agent",
|
||||
},
|
||||
)
|
||||
} else {
|
||||
result.logger.Error("can't init honeypot subsystem", "err", err)
|
||||
|
||||
@@ -17,6 +17,7 @@ type Logging struct {
|
||||
Sink string `json:"sink"` // Logging sink, either "stdio" or "file"
|
||||
Level *slog.Level `json:"level"` // Log level, if set supersedes the level in flags
|
||||
Parameters *LoggingFileConfig `json:"parameters"` // Logging parameters, to be dynamic in the future
|
||||
LogASN bool `json:"asn" yaml:"asn"`
|
||||
}
|
||||
|
||||
const (
|
||||
|
||||
@@ -32,6 +32,7 @@ type Metrics struct {
|
||||
Network string `json:"network" yaml:"network"`
|
||||
SocketMode string `json:"socketMode" yaml:"socketMode"`
|
||||
TLS *MetricsTLS `json:"tls" yaml:"tls"`
|
||||
Debug bool `json:"debug" yaml:"debug"`
|
||||
BasicAuth *MetricsBasicAuth `json:"basicAuth" yaml:"basicAuth"`
|
||||
}
|
||||
|
||||
|
||||
+4
-4
@@ -12,7 +12,7 @@ import (
|
||||
)
|
||||
|
||||
func TestInvalidChallengeMethod(t *testing.T) {
|
||||
if _, err := LoadPoliciesOrDefault(t.Context(), "testdata/invalid-challenge-method.yaml", 4, "info"); !errors.Is(err, policy.ErrChallengeRuleHasWrongAlgorithm) {
|
||||
if _, err := LoadPoliciesOrDefault(t.Context(), "testdata/invalid-challenge-method.yaml", 4, "info", false); !errors.Is(err, policy.ErrChallengeRuleHasWrongAlgorithm) {
|
||||
t.Fatalf("wanted error %v but got %v", policy.ErrChallengeRuleHasWrongAlgorithm, err)
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,7 @@ func TestBadConfigs(t *testing.T) {
|
||||
|
||||
for _, st := range finfos {
|
||||
t.Run(st.Name(), func(t *testing.T) {
|
||||
if _, err := LoadPoliciesOrDefault(t.Context(), filepath.Join("config", "testdata", "bad", st.Name()), anubis.DefaultDifficulty, "info"); err == nil {
|
||||
if _, err := LoadPoliciesOrDefault(t.Context(), filepath.Join("config", "testdata", "bad", st.Name()), anubis.DefaultDifficulty, "info", false); err == nil {
|
||||
t.Fatal(err)
|
||||
} else {
|
||||
t.Log(err)
|
||||
@@ -44,13 +44,13 @@ func TestGoodConfigs(t *testing.T) {
|
||||
t.Run(st.Name(), func(t *testing.T) {
|
||||
t.Run("with-thoth", func(t *testing.T) {
|
||||
ctx := thothmock.WithMockThoth(t)
|
||||
if _, err := LoadPoliciesOrDefault(ctx, filepath.Join("config", "testdata", "good", st.Name()), anubis.DefaultDifficulty, "info"); err != nil {
|
||||
if _, err := LoadPoliciesOrDefault(ctx, filepath.Join("config", "testdata", "good", st.Name()), anubis.DefaultDifficulty, "info", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("without-thoth", func(t *testing.T) {
|
||||
if _, err := LoadPoliciesOrDefault(t.Context(), filepath.Join("config", "testdata", "good", st.Name()), anubis.DefaultDifficulty, "info"); err != nil {
|
||||
if _, err := LoadPoliciesOrDefault(t.Context(), filepath.Join("config", "testdata", "good", st.Name()), anubis.DefaultDifficulty, "info", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
|
||||
+19
-14
@@ -207,7 +207,7 @@ func (s *Server) RenderIndex(w http.ResponseWriter, r *http.Request, cr policy.C
|
||||
return
|
||||
}
|
||||
|
||||
lg := internal.GetRequestLogger(s.logger, r)
|
||||
lg, r := s.getRequestLogger(r)
|
||||
|
||||
if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") && randomChance(64) {
|
||||
lg.Error("client was given a challenge but does not in fact support gzip compression")
|
||||
@@ -215,7 +215,10 @@ func (s *Server) RenderIndex(w http.ResponseWriter, r *http.Request, cr policy.C
|
||||
return
|
||||
}
|
||||
|
||||
challengesIssued.WithLabelValues("embedded").Add(1)
|
||||
{
|
||||
asn, asnDesc := asnFromContext(r.Context())
|
||||
challengesIssued.WithLabelValues("embedded", asn, asnDesc).Add(1)
|
||||
}
|
||||
chall, err := s.issueChallenge(r.Context(), r, lg, cr, rule)
|
||||
if err != nil {
|
||||
lg.Error("can't get challenge", "err", err)
|
||||
@@ -306,14 +309,14 @@ func (s *Server) constructRedirectURL(r *http.Request) (string, error) {
|
||||
case "http", "https":
|
||||
// allowed
|
||||
default:
|
||||
lg := internal.GetRequestLogger(s.logger, r)
|
||||
lg, _ := s.getRequestLogger(r)
|
||||
lg.Warn("invalid protocol in X-Forwarded-Proto", "proto", proto)
|
||||
return "", errors.New(localizer.T("invalid_redirect"))
|
||||
}
|
||||
|
||||
// Check if host is allowed in RedirectDomains (supports '*' via glob)
|
||||
if len(s.opts.RedirectDomains) > 0 && !matchRedirectDomain(s.opts.RedirectDomains, host) {
|
||||
lg := internal.GetRequestLogger(s.logger, r)
|
||||
lg, _ := s.getRequestLogger(r)
|
||||
lg.Debug("domain not allowed", "domain", host)
|
||||
return "", errors.New(localizer.T("redirect_domain_not_allowed"))
|
||||
}
|
||||
@@ -400,14 +403,15 @@ func (s *Server) ServeHTTPNext(w http.ResponseWriter, r *http.Request) {
|
||||
localizer := localization.GetLocalizer(r)
|
||||
|
||||
redir := r.FormValue("redir")
|
||||
urlParsed, err := url.ParseRequestURI(redir)
|
||||
urlParsed, err := url.Parse(redir)
|
||||
if err != nil {
|
||||
// if ParseRequestURI fails, try as relative URL
|
||||
urlParsed, err = r.URL.Parse(redir)
|
||||
if err != nil {
|
||||
s.respondWithStatus(w, r, localizer.T("redirect_not_parseable"), makeCode(err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
s.respondWithStatus(w, r, localizer.T("redirect_not_parseable"), makeCode(err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if urlParsed.Opaque != "" || (urlParsed.Scheme == "" && strings.HasPrefix(redir, "//")) {
|
||||
s.respondWithStatus(w, r, localizer.T("invalid_redirect"), "", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// validate URL scheme to prevent javascript:, data:, file:, tel:, etc.
|
||||
@@ -415,7 +419,7 @@ func (s *Server) ServeHTTPNext(w http.ResponseWriter, r *http.Request) {
|
||||
case "", "http", "https":
|
||||
// allowed: empty scheme means relative URL
|
||||
default:
|
||||
lg := internal.GetRequestLogger(s.logger, r)
|
||||
lg, _ := s.getRequestLogger(r)
|
||||
lg.Warn("XSS attempt blocked, invalid redirect scheme", "scheme", urlParsed.Scheme, "redir", redir)
|
||||
s.respondWithStatus(w, r, localizer.T("invalid_redirect"), "", http.StatusBadRequest)
|
||||
return
|
||||
@@ -427,7 +431,7 @@ func (s *Server) ServeHTTPNext(w http.ResponseWriter, r *http.Request) {
|
||||
hostMismatch := r.URL.Host != "" && urlParsed.Host != "" && urlParsed.Host != r.URL.Host
|
||||
|
||||
if hostNotAllowed || hostMismatch {
|
||||
lg := internal.GetRequestLogger(s.logger, r)
|
||||
lg, _ := s.getRequestLogger(r)
|
||||
lg.Debug("domain not allowed", "domain", urlParsed.Host)
|
||||
s.respondWithStatus(w, r, localizer.T("redirect_domain_not_allowed"), makeCode(err), http.StatusBadRequest)
|
||||
return
|
||||
@@ -442,7 +446,8 @@ func (s *Server) ServeHTTPNext(w http.ResponseWriter, r *http.Request) {
|
||||
web.Base(localizer.T("you_are_not_a_bot"), web.StaticHappy(localizer), s.policy.Impressum, localizer),
|
||||
).ServeHTTP(w, r)
|
||||
} else {
|
||||
requestsProxied.WithLabelValues(r.Host).Inc()
|
||||
asn, asnDesc := asnFromContext(r.Context())
|
||||
requestsProxied.WithLabelValues(r.Host, asn, asnDesc).Inc()
|
||||
r = s.stripBasePrefixFromRequest(r)
|
||||
s.next.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
@@ -223,3 +223,17 @@ func TestNoCacheOnError(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectsHostlessRedirect(t *testing.T) {
|
||||
pol := loadPolicies(t, "testdata/useragent.yaml", 0)
|
||||
srv := spawnAnubis(t, Options{Policy: pol, RedirectDomains: []string{"allowed.example"}})
|
||||
req := httptest.NewRequest(http.MethodGet, "https://anubis.example/.within.website/?redir=%2f%2fevil.example%2fphish", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
srv.ServeHTTPNext(rr, req)
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected hostless redirect to be rejected, got HTTP %d body %q", rr.Code, rr.Body.String())
|
||||
}
|
||||
if got := rr.Header().Get("Location"); got != "" {
|
||||
t.Fatalf("expected no Location header on rejected redirect, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
+10
-6
@@ -34,11 +34,15 @@ func (s *Server) Run(ctx context.Context, done func()) {
|
||||
|
||||
func (s *Server) run(ctx context.Context, lg *slog.Logger) error {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /debug/pprof/", pprof.Index)
|
||||
mux.HandleFunc("GET /debug/pprof/cmdline", pprof.Cmdline)
|
||||
mux.HandleFunc("GET /debug/pprof/profile", pprof.Profile)
|
||||
mux.HandleFunc("GET /debug/pprof/symbol", pprof.Symbol)
|
||||
mux.HandleFunc("GET /debug/pprof/trace", pprof.Trace)
|
||||
|
||||
if s.Config.Debug {
|
||||
mux.HandleFunc("GET /debug/pprof/", pprof.Index)
|
||||
mux.HandleFunc("GET /debug/pprof/cmdline", pprof.Cmdline)
|
||||
mux.HandleFunc("GET /debug/pprof/profile", pprof.Profile)
|
||||
mux.HandleFunc("GET /debug/pprof/symbol", pprof.Symbol)
|
||||
mux.HandleFunc("GET /debug/pprof/trace", pprof.Trace)
|
||||
}
|
||||
|
||||
mux.Handle("/metrics", promhttp.Handler())
|
||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
st, ok := internal.GetHealth("anubis")
|
||||
@@ -64,7 +68,7 @@ func (s *Server) run(ctx context.Context, lg *slog.Logger) error {
|
||||
ErrorLog: internal.GetFilteredHTTPLogger(),
|
||||
}
|
||||
|
||||
ln, metricsURL, err := internal.SetupListener(s.Config.Bind, s.Config.Network, s.Config.SocketMode)
|
||||
ln, metricsURL, err := internal.SetupListener(s.Config.Network, s.Config.Bind, s.Config.SocketMode)
|
||||
if err != nil {
|
||||
return fmt.Errorf("can't setup listener: %w", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/TecharoHQ/anubis/lib/config"
|
||||
)
|
||||
|
||||
func TestMetricsPprofCmdlineExposedWithoutAuthentication(t *testing.T) {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
addr := ln.Addr().String()
|
||||
_ = ln.Close()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
done := make(chan struct{})
|
||||
srv := &Server{
|
||||
Config: &config.Metrics{Network: "tcp", Bind: addr},
|
||||
Log: slog.Default(),
|
||||
}
|
||||
go srv.Run(ctx, func() { close(done) })
|
||||
|
||||
url := "http://" + addr + "/debug/pprof/cmdline"
|
||||
var body []byte
|
||||
resp, err := http.Get(url)
|
||||
if err == nil {
|
||||
body, err = io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("can't read body: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if strings.Contains(string(body), "metrics.test") {
|
||||
t.Fatalf("pprof is enabled by default, cmdline process arguments: %q", string(body))
|
||||
}
|
||||
cancel()
|
||||
<-done
|
||||
}
|
||||
+15
-4
@@ -1,8 +1,6 @@
|
||||
package policy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/TecharoHQ/anubis/internal"
|
||||
"github.com/TecharoHQ/anubis/lib/config"
|
||||
"github.com/TecharoHQ/anubis/lib/policy/checker"
|
||||
@@ -13,9 +11,22 @@ type Bot struct {
|
||||
Challenge *config.ChallengeRules
|
||||
Weight *config.Weight
|
||||
Name string
|
||||
Action config.Rule
|
||||
// hash caches the result of Hash() when populated at parse time, see ParseConfig
|
||||
hash string
|
||||
Action config.Rule
|
||||
}
|
||||
|
||||
// Hash returns a stable identifier for this Bot derived from its Name
|
||||
// and Rules. When the cached value is present (populated by
|
||||
// ParseConfig) it is returned directly; otherwise the hash is
|
||||
// recomputed on demand so callers do not have to know about the cache.
|
||||
func (b Bot) Hash() string {
|
||||
return internal.FastHash(fmt.Sprintf("%s::%s", b.Name, b.Rules.Hash()))
|
||||
if b.hash != "" {
|
||||
return b.hash
|
||||
}
|
||||
var rulesHash string
|
||||
if b.Rules != nil { // defensive, should never happen
|
||||
rulesHash = b.Rules.Hash()
|
||||
}
|
||||
return internal.FastHash(b.Name + "::" + rulesHash)
|
||||
}
|
||||
|
||||
@@ -13,11 +13,12 @@ import (
|
||||
)
|
||||
|
||||
type CELChecker struct {
|
||||
program cel.Program
|
||||
src string
|
||||
program cel.Program
|
||||
src string
|
||||
subRequestMode bool
|
||||
}
|
||||
|
||||
func NewCELChecker(cfg *config.ExpressionOrList, dnsObj *dns.Dns) (*CELChecker, error) {
|
||||
func NewCELChecker(cfg *config.ExpressionOrList, dnsObj *dns.Dns, subRequestMode bool) (*CELChecker, error) {
|
||||
env, err := expressions.BotEnvironment(dnsObj)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -29,8 +30,9 @@ func NewCELChecker(cfg *config.ExpressionOrList, dnsObj *dns.Dns) (*CELChecker,
|
||||
}
|
||||
|
||||
return &CELChecker{
|
||||
src: cfg.String(),
|
||||
program: program,
|
||||
src: cfg.String(),
|
||||
program: program,
|
||||
subRequestMode: subRequestMode,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -39,7 +41,7 @@ func (cc *CELChecker) Hash() string {
|
||||
}
|
||||
|
||||
func (cc *CELChecker) Check(r *http.Request) (bool, error) {
|
||||
result, _, err := cc.program.ContextEval(r.Context(), &CELRequest{r})
|
||||
result, _, err := cc.program.ContextEval(r.Context(), &CELRequest{r, cc.subRequestMode})
|
||||
|
||||
if err != nil {
|
||||
return false, err
|
||||
@@ -54,6 +56,7 @@ func (cc *CELChecker) Check(r *http.Request) (bool, error) {
|
||||
|
||||
type CELRequest struct {
|
||||
*http.Request
|
||||
subRequestMode bool
|
||||
}
|
||||
|
||||
func (cr *CELRequest) Parent() cel.Activation { return nil }
|
||||
@@ -71,6 +74,14 @@ func (cr *CELRequest) ResolveName(name string) (any, bool) {
|
||||
case "userAgent":
|
||||
return cr.UserAgent(), true
|
||||
case "path":
|
||||
if cr.subRequestMode {
|
||||
if xou := cr.Header.Get("X-Original-URI"); xou != "" {
|
||||
return xou, true
|
||||
}
|
||||
if xfu := cr.Header.Get("X-Forwarded-Uri"); xfu != "" {
|
||||
return xfu, true
|
||||
}
|
||||
}
|
||||
return cr.URL.Path, true
|
||||
case "query":
|
||||
return expressions.URLValues{Values: cr.URL.Query()}, true
|
||||
|
||||
@@ -23,7 +23,7 @@ func TestCELChecker_MapIterationWrappers(t *testing.T) {
|
||||
Expression: `headers.exists(k, k == "Accept") && query.exists(k, k == "format")`,
|
||||
}
|
||||
|
||||
checker, err := NewCELChecker(cfg, newTestDNS(t))
|
||||
checker, err := NewCELChecker(cfg, newTestDNS(t), false)
|
||||
if err != nil {
|
||||
t.Fatalf("creating CEL checker failed: %v", err)
|
||||
}
|
||||
@@ -42,3 +42,77 @@ func TestCELChecker_MapIterationWrappers(t *testing.T) {
|
||||
t.Fatal("expected expression to evaluate true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCELChecker_PathWithForwardedUri(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
expression string
|
||||
xForwardedUri string
|
||||
urlPath string
|
||||
subRequestMode bool
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "path matches X-Forwarded-Uri in subrequest mode",
|
||||
expression: `path.startsWith("/admin")`,
|
||||
xForwardedUri: "/admin/secret",
|
||||
urlPath: "/.within.website/x/cmd/anubis/api/check",
|
||||
subRequestMode: true,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "path with query string",
|
||||
expression: `path.startsWith("/api/secret")`,
|
||||
xForwardedUri: "/api/secret?token=abc",
|
||||
urlPath: "/.within.website/x/cmd/anubis/api/check",
|
||||
subRequestMode: true,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "path falls back to url path when no header",
|
||||
expression: `path == "/public/page"`,
|
||||
urlPath: "/public/page",
|
||||
subRequestMode: true,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "non-subrequest mode ignores X-Forwarded-Uri",
|
||||
expression: `path.startsWith("/admin")`,
|
||||
xForwardedUri: "/admin/secret",
|
||||
urlPath: "/public/page",
|
||||
subRequestMode: false,
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cfg := &config.ExpressionOrList{
|
||||
Expression: tt.expression,
|
||||
}
|
||||
checker, err := NewCELChecker(cfg, newTestDNS(t), tt.subRequestMode)
|
||||
if err != nil {
|
||||
t.Fatalf("NewCELChecker() error: %v", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "http://example.com"+tt.urlPath, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("http.NewRequest: %v", err)
|
||||
}
|
||||
|
||||
if tt.xForwardedUri != "" {
|
||||
req.Header.Set("X-Forwarded-Uri", tt.xForwardedUri)
|
||||
}
|
||||
|
||||
got, err := checker.Check(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Check() error: %v", err)
|
||||
}
|
||||
|
||||
if got != tt.want {
|
||||
t.Errorf("Check() = %v, want %v (subRequestMode=%v, urlPath=%q, X-Forwarded-Uri=%q)",
|
||||
got, tt.want, tt.subRequestMode, tt.urlPath, tt.xForwardedUri)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+18
-8
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
@@ -94,23 +95,32 @@ func (hmc *HeaderMatchesChecker) Hash() string {
|
||||
}
|
||||
|
||||
type PathChecker struct {
|
||||
regexp *regexp.Regexp
|
||||
hash string
|
||||
regexp *regexp.Regexp
|
||||
hash string
|
||||
subRequestMode bool
|
||||
}
|
||||
|
||||
func NewPathChecker(rexStr string) (checker.Impl, error) {
|
||||
func NewPathChecker(rexStr string, subrequestMode bool) (checker.Impl, error) {
|
||||
rex, err := regexp.Compile(strings.TrimSpace(rexStr))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: regex %s failed parse: %w", ErrMisconfiguration, rexStr, err)
|
||||
}
|
||||
return &PathChecker{rex, internal.FastHash(rexStr)}, nil
|
||||
return &PathChecker{rex, internal.FastHash(rexStr), subrequestMode}, nil
|
||||
}
|
||||
|
||||
func (pc *PathChecker) Check(r *http.Request) (bool, error) {
|
||||
originalUrl := r.Header.Get("X-Original-URI")
|
||||
if originalUrl != "" {
|
||||
if pc.regexp.MatchString(originalUrl) {
|
||||
return true, nil
|
||||
if pc.subRequestMode {
|
||||
originalUrl := r.Header.Get("X-Original-URI")
|
||||
if originalUrl == "" {
|
||||
originalUrl = r.Header.Get("X-Forwarded-Uri")
|
||||
}
|
||||
if originalUrl != "" {
|
||||
if parsed, err := url.ParseRequestURI(originalUrl); err == nil {
|
||||
originalUrl = parsed.Path
|
||||
}
|
||||
if pc.regexp.MatchString(originalUrl) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+223
-2
@@ -272,8 +272,8 @@ func TestPathChecker_XOriginalURI(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Create the PathChecker
|
||||
pc, err := NewPathChecker(tt.regex)
|
||||
// Create the PathChecker in subrequest mode so X-Original-URI is honored.
|
||||
pc, err := NewPathChecker(tt.regex, true)
|
||||
if err != nil {
|
||||
if !tt.expectError {
|
||||
t.Fatalf("NewPathChecker() unexpected error: %v", err)
|
||||
@@ -305,3 +305,224 @@ func TestPathChecker_XOriginalURI(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPathChecker_GHSA_6wcg_mqvh_fcvg is a regression test for
|
||||
// https://github.com/TecharoHQ/anubis/security/advisories/GHSA-6wcg-mqvh-fcvg.
|
||||
//
|
||||
// PR https://github.com/TecharoHQ/anubis/pull/1015 added the ability for
|
||||
// reverse proxies using Anubis in subrequest auth mode to look at the path
|
||||
// of a request as there are many rules in the wild that rely on checking
|
||||
// the path. This is how access to things like robots.txt or anything in the
|
||||
// .well-known directory is unaffected by Anubis.
|
||||
//
|
||||
// However this logic was also enabled for non-subrequest deployments of Anubis,
|
||||
// meaning that a specially crafted request could include a /.well-known/
|
||||
// path in it and then get around Anubis with little effort.
|
||||
//
|
||||
// This fix gates the logic behind a new plumbed variable named subrequestMode
|
||||
// that only fires when Anubis is running in subrequest auth mode. This
|
||||
// properly contains that workaround so that the logic does not fire in
|
||||
// most deployments.
|
||||
func TestPathChecker_GHSA_6wcg_mqvh_fcvg(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
regex string
|
||||
urlPath string
|
||||
xOriginalURI string
|
||||
subRequestMode bool
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "default mode ignores spoofed X-Original-URI when real path matches",
|
||||
regex: "^/admin/.*",
|
||||
urlPath: "/admin/secret",
|
||||
xOriginalURI: "/public/index",
|
||||
subRequestMode: false,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "default mode ignores spoofed X-Original-URI when real path does not match",
|
||||
regex: "^/admin/.*",
|
||||
urlPath: "/public/index",
|
||||
xOriginalURI: "/admin/secret",
|
||||
subRequestMode: false,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "default mode without X-Original-URI matches real path",
|
||||
regex: "^/admin/.*",
|
||||
urlPath: "/admin/dashboard",
|
||||
xOriginalURI: "",
|
||||
subRequestMode: false,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "subrequest mode honors X-Original-URI",
|
||||
regex: "^/admin/.*",
|
||||
urlPath: "/auth",
|
||||
xOriginalURI: "/admin/secret",
|
||||
subRequestMode: true,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "subrequest mode falls back to URL.Path when X-Original-URI does not match",
|
||||
regex: "^/admin/.*",
|
||||
urlPath: "/admin/dashboard",
|
||||
xOriginalURI: "/public/index",
|
||||
subRequestMode: true,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "subrequest mode with empty X-Original-URI uses URL.Path",
|
||||
regex: "^/admin/.*",
|
||||
urlPath: "/admin/dashboard",
|
||||
xOriginalURI: "",
|
||||
subRequestMode: true,
|
||||
want: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
pc, err := NewPathChecker(tt.regex, tt.subRequestMode)
|
||||
if err != nil {
|
||||
t.Fatalf("NewPathChecker(%q, %v) returned error: %v", tt.regex, tt.subRequestMode, err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "http://example.com"+tt.urlPath, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("http.NewRequest: %v", err)
|
||||
}
|
||||
|
||||
if tt.xOriginalURI != "" {
|
||||
req.Header.Set("X-Original-URI", tt.xOriginalURI)
|
||||
}
|
||||
|
||||
got, err := pc.Check(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Check() unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if got != tt.want {
|
||||
t.Errorf("Check() = %v, want %v (subRequestMode=%v, urlPath=%q, X-Original-URI=%q)",
|
||||
got, tt.want, tt.subRequestMode, tt.urlPath, tt.xOriginalURI)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathChecker_XForwardedUri(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
regex string
|
||||
xForwardedUri string
|
||||
xOriginalURI string
|
||||
urlPath string
|
||||
subRequestMode bool
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "X-Forwarded-Uri matches regex in subrequest mode",
|
||||
regex: "^/admin/.*",
|
||||
xForwardedUri: "/admin/users",
|
||||
urlPath: "/.within.website/x/cmd/anubis/api/check",
|
||||
subRequestMode: true,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "X-Forwarded-Uri with query string",
|
||||
regex: "^/admin/.*",
|
||||
xForwardedUri: "/admin/users?page=1",
|
||||
urlPath: "/.within.website/x/cmd/anubis/api/check",
|
||||
subRequestMode: true,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "X-Original-URI takes priority over X-Forwarded-Uri",
|
||||
regex: "^/admin/.*",
|
||||
xForwardedUri: "/public/page",
|
||||
xOriginalURI: "/admin/users",
|
||||
urlPath: "/.within.website/x/cmd/anubis/api/check",
|
||||
subRequestMode: true,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "falls back to X-Forwarded-Uri when no X-Original-URI",
|
||||
regex: "^/admin/.*",
|
||||
xForwardedUri: "/admin/dashboard",
|
||||
urlPath: "/.within.website/x/cmd/anubis/api/check",
|
||||
subRequestMode: true,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "neither header matches, url path matches",
|
||||
regex: "^/public/.*",
|
||||
xForwardedUri: "/admin/users",
|
||||
urlPath: "/public/page",
|
||||
subRequestMode: true,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "nothing matches",
|
||||
regex: "^/admin/.*",
|
||||
xForwardedUri: "/public/page",
|
||||
urlPath: "/.within.website/x/cmd/anubis/api/check",
|
||||
subRequestMode: true,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "non-subrequest mode ignores X-Forwarded-Uri",
|
||||
regex: "^/admin/.*",
|
||||
xForwardedUri: "/admin/users",
|
||||
urlPath: "/public/page",
|
||||
subRequestMode: false,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "non-subrequest mode uses url path",
|
||||
regex: "^/admin/.*",
|
||||
xForwardedUri: "/public/page",
|
||||
urlPath: "/admin/secret",
|
||||
subRequestMode: false,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "empty X-Forwarded-Uri falls back to url path",
|
||||
regex: "^/check$",
|
||||
urlPath: "/check",
|
||||
subRequestMode: true,
|
||||
want: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
pc, err := NewPathChecker(tt.regex, tt.subRequestMode)
|
||||
if err != nil {
|
||||
t.Fatalf("NewPathChecker(%q, %v) returned error: %v", tt.regex, tt.subRequestMode, err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "http://example.com"+tt.urlPath, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("http.NewRequest: %v", err)
|
||||
}
|
||||
|
||||
if tt.xForwardedUri != "" {
|
||||
req.Header.Set("X-Forwarded-Uri", tt.xForwardedUri)
|
||||
}
|
||||
if tt.xOriginalURI != "" {
|
||||
req.Header.Set("X-Original-URI", tt.xOriginalURI)
|
||||
}
|
||||
|
||||
got, err := pc.Check(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Check() unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if got != tt.want {
|
||||
t.Errorf("Check() = %v, want %v (subRequestMode=%v, urlPath=%q, X-Forwarded-Uri=%q, X-Original-URI=%q)",
|
||||
got, tt.want, tt.subRequestMode, tt.urlPath, tt.xForwardedUri, tt.xOriginalURI)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,7 +222,16 @@ func New(opts ...cel.EnvOption) (*cel.Env, error) {
|
||||
return types.ValOrErr(val, "value is not an integer, but is %T", val)
|
||||
}
|
||||
|
||||
return types.Int(rand.IntN(int(n)))
|
||||
if n <= 0 {
|
||||
return types.NewErr("randInt bound must be positive, got %d", int64(n))
|
||||
}
|
||||
|
||||
bound := int(n)
|
||||
if types.Int(bound) != n {
|
||||
return types.NewErr("randInt bound %d overflows platform int", int64(n))
|
||||
}
|
||||
|
||||
return types.Int(rand.IntN(bound))
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/TecharoHQ/anubis/internal/dns"
|
||||
"github.com/TecharoHQ/anubis/lib/store/memory"
|
||||
"github.com/google/cel-go/cel"
|
||||
"github.com/google/cel-go/common/types"
|
||||
"github.com/google/cel-go/common/types/ref"
|
||||
)
|
||||
@@ -688,6 +689,14 @@ func TestNewEnvironment(t *testing.T) {
|
||||
description: "should return values in correct range",
|
||||
shouldCompile: true,
|
||||
},
|
||||
{
|
||||
name: "randInt-large-bound",
|
||||
expression: `randInt(2147483647) >= 0`,
|
||||
variables: map[string]any{},
|
||||
expectBool: boolPtr(true),
|
||||
description: "should accept int32-max bounds without overflow",
|
||||
shouldCompile: true,
|
||||
},
|
||||
{
|
||||
name: "strings-extension-size",
|
||||
expression: `"hello".size() == 5`,
|
||||
@@ -750,3 +759,65 @@ func TestNewEnvironment(t *testing.T) {
|
||||
func boolPtr(b bool) *bool {
|
||||
return &b
|
||||
}
|
||||
|
||||
func TestRandIntInvalidBounds(t *testing.T) {
|
||||
env, err := New(cel.Variable("contentLength", cel.IntType))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create environment: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
expression string
|
||||
variables map[string]any
|
||||
wantErrText string
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "zero-bound-literal",
|
||||
expression: `randInt(0)`,
|
||||
variables: map[string]any{},
|
||||
wantErrText: "randInt bound must be positive",
|
||||
description: "randInt(0) should return a CEL error, not panic",
|
||||
},
|
||||
{
|
||||
name: "negative-bound-literal",
|
||||
expression: `randInt(-5)`,
|
||||
variables: map[string]any{},
|
||||
wantErrText: "randInt bound must be positive",
|
||||
description: "randInt(-5) should return a CEL error, not panic",
|
||||
},
|
||||
{
|
||||
name: "zero-bound-from-variable",
|
||||
expression: `randInt(contentLength)`,
|
||||
variables: map[string]any{"contentLength": 0},
|
||||
wantErrText: "randInt bound must be positive",
|
||||
description: "attacker-controlled zero contentLength should error gracefully",
|
||||
},
|
||||
{
|
||||
name: "negative-bound-from-variable",
|
||||
expression: `randInt(contentLength)`,
|
||||
variables: map[string]any{"contentLength": -1},
|
||||
wantErrText: "randInt bound must be positive",
|
||||
description: "attacker-controlled negative contentLength should error gracefully",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
prog, err := Compile(env, tt.expression)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to compile expression %q: %v", tt.expression, err)
|
||||
}
|
||||
|
||||
result, _, err := prog.Eval(tt.variables)
|
||||
if err == nil {
|
||||
t.Fatalf("%s: expected an evaluation error, got result %v", tt.description, result)
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), tt.wantErrText) {
|
||||
t.Errorf("%s: expected error containing %q, got %q", tt.description, tt.wantErrText, err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ var (
|
||||
)
|
||||
|
||||
func init() {
|
||||
globalLoadAvg = &loadAvg{}
|
||||
globalLoadAvg = &loadAvg{data: &load.AvgStat{}}
|
||||
go globalLoadAvg.updateThread(context.Background())
|
||||
}
|
||||
|
||||
|
||||
+15
-4
@@ -27,7 +27,7 @@ var (
|
||||
Applications = promauto.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "anubis_policy_results",
|
||||
Help: "The results of each policy rule",
|
||||
}, []string{"rule", "action"})
|
||||
}, []string{"rule", "action", "asn", "asn_description"})
|
||||
|
||||
ErrChallengeRuleHasWrongAlgorithm = errors.New("config.Bot.ChallengeRules: algorithm is invalid")
|
||||
warnedAboutThresholds = &atomic.Bool{}
|
||||
@@ -47,6 +47,8 @@ type ParsedConfig struct {
|
||||
Dns *dns.Dns
|
||||
Logger *slog.Logger
|
||||
Metrics *config.Metrics
|
||||
ThothClient *thoth.Client
|
||||
LogASN bool
|
||||
}
|
||||
|
||||
func newParsedConfig(orig *config.Config) *ParsedConfig {
|
||||
@@ -58,7 +60,7 @@ func newParsedConfig(orig *config.Config) *ParsedConfig {
|
||||
}
|
||||
}
|
||||
|
||||
func ParseConfig(ctx context.Context, fin io.Reader, fname string, defaultDifficulty int, logLevel string) (*ParsedConfig, error) {
|
||||
func ParseConfig(ctx context.Context, fin io.Reader, fname string, defaultDifficulty int, logLevel string, subrequestMode bool) (*ParsedConfig, error) {
|
||||
c, err := config.Load(fin, fname)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -70,6 +72,10 @@ func ParseConfig(ctx context.Context, fin io.Reader, fname string, defaultDiffic
|
||||
|
||||
result := newParsedConfig(c)
|
||||
result.DefaultDifficulty = defaultDifficulty
|
||||
result.LogASN = c.Logging.LogASN
|
||||
if hasThothClient {
|
||||
result.ThothClient = tc
|
||||
}
|
||||
|
||||
if c.Logging.Level != nil {
|
||||
logLevel = c.Logging.Level.String()
|
||||
@@ -94,6 +100,10 @@ func ParseConfig(ctx context.Context, fin io.Reader, fname string, defaultDiffic
|
||||
|
||||
lg := result.Logger.With("at", "config-validate")
|
||||
|
||||
if result.LogASN && !hasThothClient {
|
||||
lg.Warn("logging.asn is enabled but no Thoth client is configured; ASN logging and metrics will be skipped. Please read https://anubis.techaro.lol/docs/admin/thoth for more information")
|
||||
}
|
||||
|
||||
stFac, ok := store.Get(c.Store.Backend)
|
||||
switch ok {
|
||||
case true:
|
||||
@@ -142,7 +152,7 @@ func ParseConfig(ctx context.Context, fin io.Reader, fname string, defaultDiffic
|
||||
}
|
||||
|
||||
if b.PathRegex != nil {
|
||||
c, err := NewPathChecker(*b.PathRegex)
|
||||
c, err := NewPathChecker(*b.PathRegex, subrequestMode)
|
||||
if err != nil {
|
||||
validationErrs = append(validationErrs, fmt.Errorf("while processing rule %s path regex: %w", b.Name, err))
|
||||
} else {
|
||||
@@ -160,7 +170,7 @@ func ParseConfig(ctx context.Context, fin io.Reader, fname string, defaultDiffic
|
||||
}
|
||||
|
||||
if b.Expression != nil {
|
||||
c, err := NewCELChecker(b.Expression, result.Dns)
|
||||
c, err := NewCELChecker(b.Expression, result.Dns, subrequestMode)
|
||||
if err != nil {
|
||||
validationErrs = append(validationErrs, fmt.Errorf("while processing rule %s expressions: %w", b.Name, err))
|
||||
} else {
|
||||
@@ -209,6 +219,7 @@ func ParseConfig(ctx context.Context, fin io.Reader, fname string, defaultDiffic
|
||||
result.Impressum = c.Impressum
|
||||
|
||||
parsedBot.Rules = cl
|
||||
parsedBot.hash = parsedBot.Hash()
|
||||
|
||||
result.Bots = append(result.Bots, parsedBot)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package policy
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
@@ -19,7 +21,7 @@ func TestDefaultPolicyMustParse(t *testing.T) {
|
||||
}
|
||||
defer fin.Close()
|
||||
|
||||
if _, err := ParseConfig(ctx, fin, "botPolicies.yaml", anubis.DefaultDifficulty, "info"); err != nil {
|
||||
if _, err := ParseConfig(ctx, fin, "botPolicies.yaml", anubis.DefaultDifficulty, "info", false); err != nil {
|
||||
t.Fatalf("can't parse config: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -41,7 +43,7 @@ func TestGoodConfigs(t *testing.T) {
|
||||
defer fin.Close()
|
||||
|
||||
ctx := thothmock.WithMockThoth(t)
|
||||
if _, err := ParseConfig(ctx, fin, fin.Name(), anubis.DefaultDifficulty, "info"); err != nil {
|
||||
if _, err := ParseConfig(ctx, fin, fin.Name(), anubis.DefaultDifficulty, "info", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
@@ -53,7 +55,7 @@ func TestGoodConfigs(t *testing.T) {
|
||||
}
|
||||
defer fin.Close()
|
||||
|
||||
if _, err := ParseConfig(t.Context(), fin, fin.Name(), anubis.DefaultDifficulty, "info"); err != nil {
|
||||
if _, err := ParseConfig(t.Context(), fin, fin.Name(), anubis.DefaultDifficulty, "info", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
@@ -77,7 +79,7 @@ func TestBadConfigs(t *testing.T) {
|
||||
}
|
||||
defer fin.Close()
|
||||
|
||||
if _, err := ParseConfig(ctx, fin, fin.Name(), anubis.DefaultDifficulty, "info"); err == nil {
|
||||
if _, err := ParseConfig(ctx, fin, fin.Name(), anubis.DefaultDifficulty, "info", false); err == nil {
|
||||
t.Fatal(err)
|
||||
} else {
|
||||
t.Log(err)
|
||||
@@ -85,3 +87,27 @@ func TestBadConfigs(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathCheckerStripsForwardedURIQuery(t *testing.T) {
|
||||
checker, err := NewPathChecker("^/admin$", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "https://anubis.local/.within.website/x/cmd/anubis/api/check", nil)
|
||||
req.Header.Set("X-Forwarded-Uri", "/admin?x=1")
|
||||
matched, err := checker.Check(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !matched {
|
||||
t.Fatalf("expected exact path checker to match forwarded URI when query string is appended")
|
||||
}
|
||||
req.Header.Set("X-Forwarded-Uri", "/admin")
|
||||
matched, err = checker.Check(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !matched {
|
||||
t.Fatalf("expected exact path checker to match forwarded URI without query string")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,33 @@ func (s *Store) Delete(ctx context.Context, key string) error {
|
||||
})
|
||||
}
|
||||
|
||||
// deleteIfExpired removes key only if it still carries the exact expiry that an
|
||||
// expired Get observed and that expiry is still in the past.
|
||||
//
|
||||
// Get runs in a read-only transaction, so it can only schedule cleanup
|
||||
// asynchronously. Between observing the expiry and this delete running, another
|
||||
// request may Set a fresh value for the same key. Re-reading and matching the
|
||||
// observed expiry inside the write transaction makes the timestamp act as a
|
||||
// generation token: a refreshed value carries a different, future expiry and is
|
||||
// therefore left untouched (see AWOO-015).
|
||||
func (s *Store) deleteIfExpired(ctx context.Context, key string, observed time.Time) error {
|
||||
return s.bdb.Update(func(tx *bbolt.Tx) error {
|
||||
valueBkt := tx.Bucket([]byte(key))
|
||||
if valueBkt == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
expiry, err := time.Parse(time.RFC3339Nano, string(valueBkt.Get([]byte("expiry"))))
|
||||
if err != nil || !expiry.Equal(observed) || !time.Now().After(expiry) {
|
||||
// Unparseable, refreshed to a different generation, or no longer
|
||||
// expired: leave it for cleanup or a later Get to handle.
|
||||
return nil
|
||||
}
|
||||
|
||||
return tx.DeleteBucket([]byte(key))
|
||||
})
|
||||
}
|
||||
|
||||
// Get a value from the datastore.
|
||||
//
|
||||
// Because each value is stored in its own bucket with data and expiry keys,
|
||||
@@ -77,7 +104,7 @@ func (s *Store) Get(ctx context.Context, key string) ([]byte, error) {
|
||||
}
|
||||
|
||||
if time.Now().After(expiry) {
|
||||
go s.Delete(context.Background(), key)
|
||||
go s.deleteIfExpired(context.Background(), key, expiry)
|
||||
return fmt.Errorf("%w: %q", store.ErrNotFound, key)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,8 +4,10 @@ import (
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/TecharoHQ/anubis/lib/store/storetest"
|
||||
"go.etcd.io/bbolt"
|
||||
)
|
||||
|
||||
func TestImpl(t *testing.T) {
|
||||
@@ -20,3 +22,154 @@ func TestImpl(t *testing.T) {
|
||||
|
||||
storetest.Common(t, Factory{}, json.RawMessage(data))
|
||||
}
|
||||
|
||||
// newTestStore returns a Store backed by a throwaway bbolt database that is
|
||||
// closed when the test finishes.
|
||||
func newTestStore(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
|
||||
db, err := bbolt.Open(filepath.Join(t.TempDir(), "db"), 0600, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("can't open bbolt database: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
|
||||
return &Store{bdb: db}
|
||||
}
|
||||
|
||||
// mustSet writes a value with the given relative expiry, failing the test on error.
|
||||
func mustSet(t *testing.T, s *Store, key, value string, expiry time.Duration) {
|
||||
t.Helper()
|
||||
|
||||
if err := s.Set(t.Context(), key, []byte(value), expiry); err != nil {
|
||||
t.Fatalf("Set(%q): %v", key, err)
|
||||
}
|
||||
}
|
||||
|
||||
// readExpiry returns the expiry timestamp currently stored for key, as a Get
|
||||
// would parse it. It fails the test if the bucket or expiry is missing.
|
||||
func readExpiry(t *testing.T, s *Store, key string) time.Time {
|
||||
t.Helper()
|
||||
|
||||
var out time.Time
|
||||
if err := s.bdb.View(func(tx *bbolt.Tx) error {
|
||||
b := tx.Bucket([]byte(key))
|
||||
if b == nil {
|
||||
t.Fatalf("bucket %q missing", key)
|
||||
}
|
||||
|
||||
expiry, err := time.Parse(time.RFC3339Nano, string(b.Get([]byte("expiry"))))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out = expiry
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatalf("reading expiry for %q: %v", key, err)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// rawData reads the raw data value for key directly, bypassing the expiry check
|
||||
// in Get so tests can observe whether a bucket physically exists. It returns nil
|
||||
// when the bucket is absent.
|
||||
func rawData(t *testing.T, s *Store, key string) []byte {
|
||||
t.Helper()
|
||||
|
||||
var out []byte
|
||||
if err := s.bdb.View(func(tx *bbolt.Tx) error {
|
||||
b := tx.Bucket([]byte(key))
|
||||
if b == nil {
|
||||
return nil
|
||||
}
|
||||
data := b.Get([]byte("data"))
|
||||
out = make([]byte, len(data))
|
||||
copy(out, data)
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatalf("reading data for %q: %v", key, err)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// TestDeleteIfExpired guards against AWOO-015: a stale async delete scheduled by
|
||||
// an expired Get must not erase a value that was refreshed (or otherwise differs
|
||||
// from) the generation it observed.
|
||||
func TestDeleteIfExpired(t *testing.T) {
|
||||
const key = "challenge"
|
||||
|
||||
for _, tt := range []struct {
|
||||
setup func(t *testing.T, s *Store) time.Time
|
||||
name string
|
||||
wantValue string
|
||||
wantPresent bool
|
||||
}{
|
||||
{
|
||||
name: "deletes the observed expired generation",
|
||||
setup: func(t *testing.T, s *Store) time.Time {
|
||||
mustSet(t, s, key, "old", -time.Minute)
|
||||
return readExpiry(t, s, key)
|
||||
},
|
||||
wantPresent: false,
|
||||
},
|
||||
{
|
||||
name: "preserves a refreshed generation",
|
||||
setup: func(t *testing.T, s *Store) time.Time {
|
||||
mustSet(t, s, key, "old", -time.Minute)
|
||||
observed := readExpiry(t, s, key)
|
||||
mustSet(t, s, key, "fresh", time.Hour)
|
||||
return observed
|
||||
},
|
||||
wantPresent: true,
|
||||
wantValue: "fresh",
|
||||
},
|
||||
{
|
||||
name: "skips on generation mismatch",
|
||||
setup: func(t *testing.T, s *Store) time.Time {
|
||||
mustSet(t, s, key, "old", -time.Minute)
|
||||
// An expiry we never wrote: even though the stored value is
|
||||
// expired, it is a different generation and must be left alone.
|
||||
return time.Now().Add(-2 * time.Hour)
|
||||
},
|
||||
wantPresent: true,
|
||||
wantValue: "old",
|
||||
},
|
||||
{
|
||||
name: "skips a non-expired observation",
|
||||
setup: func(t *testing.T, s *Store) time.Time {
|
||||
mustSet(t, s, key, "live", time.Hour)
|
||||
return readExpiry(t, s, key)
|
||||
},
|
||||
wantPresent: true,
|
||||
wantValue: "live",
|
||||
},
|
||||
{
|
||||
name: "no-op when bucket is absent",
|
||||
setup: func(t *testing.T, s *Store) time.Time {
|
||||
return time.Now().Add(-time.Hour)
|
||||
},
|
||||
wantPresent: false,
|
||||
},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
observed := tt.setup(t, s)
|
||||
|
||||
if err := s.deleteIfExpired(t.Context(), key, observed); err != nil {
|
||||
t.Fatalf("deleteIfExpired(%q): %v", key, err)
|
||||
}
|
||||
|
||||
got := rawData(t, s, key)
|
||||
switch {
|
||||
case tt.wantPresent && got == nil:
|
||||
t.Fatalf("key %q: want present with value %q, got deleted", key, tt.wantValue)
|
||||
case tt.wantPresent && string(got) != tt.wantValue:
|
||||
t.Errorf("key %q: want value %q, got %q", key, tt.wantValue, string(got))
|
||||
case !tt.wantPresent && got != nil:
|
||||
t.Errorf("key %q: want deleted, got value %q", key, string(got))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ func (c *Client) GeoIPCheckerFor(countries []string) checker.Impl {
|
||||
var sb strings.Builder
|
||||
fmt.Fprintln(&sb, "GeoIPChecker")
|
||||
for _, cc := range countries {
|
||||
countryMap[cc] = struct{}{}
|
||||
countryMap[strings.ToLower(cc)] = struct{}{}
|
||||
fmt.Fprintln(&sb, cc)
|
||||
}
|
||||
|
||||
|
||||
Generated
+622
-537
File diff suppressed because it is too large
Load Diff
+7
-7
@@ -20,11 +20,11 @@
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^20.5.0",
|
||||
"@commitlint/config-conventional": "^20.5.0",
|
||||
"baseline-browser-mapping": "^2.10.15",
|
||||
"cssnano": "^7.1.4",
|
||||
"cssnano-preset-advanced": "^7.0.12",
|
||||
"@commitlint/cli": "^21.0.1",
|
||||
"@commitlint/config-conventional": "^21.0.1",
|
||||
"baseline-browser-mapping": "^2.10.30",
|
||||
"cssnano": "^8.0.1",
|
||||
"cssnano-preset-advanced": "^8.0.1",
|
||||
"esbuild": "^0.28.0",
|
||||
"husky": "^9.1.7",
|
||||
"playwright": "^1.52.0",
|
||||
@@ -32,11 +32,11 @@
|
||||
"postcss-import": "^16.1.1",
|
||||
"postcss-import-url": "^7.2.0",
|
||||
"postcss-url": "^10.1.3",
|
||||
"prettier": "^3.8.1"
|
||||
"prettier": "^3.8.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-crypto/sha256-js": "^5.2.0",
|
||||
"preact": "^10.29.1"
|
||||
"preact": "^10.29.2"
|
||||
},
|
||||
"commitlint": {
|
||||
"extends": [
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
bots:
|
||||
- name: block-admin-via-regex
|
||||
path_regex: ^/admin(/.*)?$
|
||||
action: DENY
|
||||
|
||||
- name: block-secret-via-cel
|
||||
expression:
|
||||
all:
|
||||
- 'path.startsWith("/api/secret")'
|
||||
action: DENY
|
||||
|
||||
- import: (data)/meta/default-config.yaml
|
||||
|
||||
status_codes:
|
||||
CHALLENGE: 200
|
||||
DENY: 403
|
||||
@@ -0,0 +1,27 @@
|
||||
services:
|
||||
traefik:
|
||||
image: traefik:v3.3
|
||||
restart: always
|
||||
ports:
|
||||
- 8080:80
|
||||
volumes:
|
||||
- ./traefik.yml:/etc/traefik/traefik.yml:ro
|
||||
- ./http.yaml:/config/http.yaml:ro
|
||||
|
||||
anubis:
|
||||
image: ko.local/anubis
|
||||
restart: always
|
||||
environment:
|
||||
BIND: ":8080"
|
||||
TARGET: " "
|
||||
POLICY_FNAME: /etc/techaro/anubis.yaml
|
||||
PUBLIC_URL: http://localhost:8080/.within.website/x/cmd/anubis
|
||||
COOKIE_DOMAIN: localhost
|
||||
USE_REMOTE_ADDRESS: "true"
|
||||
volumes:
|
||||
- ./anubis.yaml:/etc/techaro/anubis.yaml
|
||||
|
||||
backend:
|
||||
image: ghcr.io/xe/x/httpdebug
|
||||
pull_policy: always
|
||||
restart: always
|
||||
@@ -0,0 +1,30 @@
|
||||
http:
|
||||
middlewares:
|
||||
anubis:
|
||||
forwardAuth:
|
||||
address: http://anubis:8080/.within.website/x/cmd/anubis/api/check
|
||||
trustForwardHeader: true
|
||||
|
||||
routers:
|
||||
anubis-assets:
|
||||
rule: Host(`localhost`) && PathPrefix(`/.within.website/x/cmd/anubis`)
|
||||
entryPoints:
|
||||
- web
|
||||
service: anubis
|
||||
backend:
|
||||
rule: Host(`localhost`)
|
||||
entryPoints:
|
||||
- web
|
||||
service: backend
|
||||
middlewares:
|
||||
- anubis
|
||||
|
||||
services:
|
||||
anubis:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: http://anubis:8080
|
||||
backend:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: http://backend:3000
|
||||
@@ -0,0 +1,33 @@
|
||||
// Smoke test for https://github.com/TecharoHQ/anubis/issues/1628
|
||||
//
|
||||
// Traefik's forwardAuth middleware calls Anubis at the literal path
|
||||
// /.within.website/x/cmd/anubis/api/check and conveys the original URL in the
|
||||
// X-Forwarded-Uri header. Path-targeting policy rules must match that header
|
||||
// (not r.URL.Path), otherwise every request looks like a request to /check.
|
||||
|
||||
const BASE = "http://localhost:8080";
|
||||
const UA = "Mozilla/5.0 (compatible; AnubisTraefikSmoke/1.0)";
|
||||
|
||||
const cases = [
|
||||
{ path: "/", expected: 307, why: "control: no DENY rule, default challenge redirect" },
|
||||
{ path: "/free", expected: 307, why: "control: no DENY rule, default challenge redirect" },
|
||||
{ path: "/admin", expected: 403, why: "path_regex must match X-Forwarded-Uri, not 307 or 200" },
|
||||
{ path: "/admin/users", expected: 403, why: "path_regex must match X-Forwarded-Uri, not 307 or 200" },
|
||||
{ path: "/api/secret", expected: 403, why: "CEL path must match X-Forwarded-Uri, not 307 or 200" },
|
||||
];
|
||||
|
||||
let failed = false;
|
||||
|
||||
for (const c of cases) {
|
||||
const resp = await fetch(`${BASE}${c.path}`, {
|
||||
headers: { "User-Agent": UA },
|
||||
redirect: "manual",
|
||||
});
|
||||
const ok = resp.status === c.expected;
|
||||
console.log(
|
||||
`${ok ? "PASS" : "FAIL"}: GET ${c.path} → ${resp.status} (want ${c.expected}: ${c.why})`,
|
||||
);
|
||||
if (!ok) failed = true;
|
||||
}
|
||||
|
||||
process.exit(failed ? 1 : 0);
|
||||
Executable
+22
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -eo pipefail
|
||||
|
||||
export VERSION=${GITHUB_SHA:-devel}-test
|
||||
export KO_DOCKER_REPO=ko.local
|
||||
|
||||
set -u
|
||||
|
||||
source ../lib/lib.sh
|
||||
|
||||
build_anubis_ko
|
||||
|
||||
function cleanup() {
|
||||
docker compose down -t 1 || :
|
||||
}
|
||||
|
||||
trap cleanup EXIT SIGINT
|
||||
|
||||
docker compose up -d
|
||||
|
||||
backoff-retry --try-count 20 node ./test.mjs
|
||||
@@ -0,0 +1,8 @@
|
||||
entryPoints:
|
||||
web:
|
||||
address: ":80"
|
||||
|
||||
providers:
|
||||
file:
|
||||
directory: /config
|
||||
watch: false
|
||||
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
Reference in New Issue
Block a user