Compare commits

...

11 Commits

Author SHA1 Message Date
Xe Iaso
0510aad9ae docs: clarify that log filters remove lines if the filter matches
Signed-off-by: Xe Iaso <me@xeiaso.net>
2025-09-12 16:55:35 +00:00
Xe Iaso
a7a5e0d5c7 feat(lib): add log filtering rules
Closes #942

Signed-off-by: Xe Iaso <me@xeiaso.net>
2025-09-12 16:51:36 +00:00
Xe Iaso
53516738c1 feat(lib/logging): add slog filter handler, move slog init logic here
Signed-off-by: Xe Iaso <me@xeiaso.net>
2025-09-12 16:50:22 +00:00
Xe Iaso
e0d2136ee6 feat(config): add support for log filters
Signed-off-by: Xe Iaso <me@xeiaso.net>
2025-09-12 16:50:22 +00:00
Xe Iaso
10f05938ac feat(expressions): add CEL log filtering calls
Signed-off-by: Xe Iaso <me@xeiaso.net>
2025-09-12 16:50:22 +00:00
Xe Iaso
63591866aa fix(decaymap): fix lock convoy (#1106)
* fix(decaymap): fix lock convoy

Ref #1103

This uses the actor pattern to delay deletion instead of making things
fight over a lock. It also properly fixes locking logic to prevent the
convoy problem.

Signed-off-by: Xe Iaso <me@xeiaso.net>

* docs: update CHANGELOG

Signed-off-by: Xe Iaso <me@xeiaso.net>

---------

Signed-off-by: Xe Iaso <me@xeiaso.net>
2025-09-12 16:43:08 +00:00
Xe Iaso
f79d36d21e docs: update CHANGELOG properly
It helps if you save your editor buffer!

Signed-off-by: Xe Iaso <me@xeiaso.net>
2025-09-11 14:07:52 +00:00
Xe Iaso
f5b5243b5e docs: update CHANGELOG
Signed-off-by: Xe Iaso <me@xeiaso.net>
2025-09-11 14:04:32 +00:00
Xe Iaso
2011b83a44 chore: port client-side JS to TypeScript (#1100)
* chore(challenge/preact): port to typescript

Signed-off-by: Xe Iaso <me@xeiaso.net>

* chore(js/algorithms): port to typescript

Signed-off-by: Xe Iaso <me@xeiaso.net>

* chore(js/worker): port to typescript

Signed-off-by: Xe Iaso <me@xeiaso.net>

* chore(web): fix TypeScript build logic

Signed-off-by: Xe Iaso <me@xeiaso.net>

* chore(web): port bench.mjs to typescript

Signed-off-by: Xe Iaso <me@xeiaso.net>

* chore(web): port main.mjs to typescript

Signed-off-by: Xe Iaso <me@xeiaso.net>

* Update metadata

check-spelling run (pull_request) for Xe/use-typescript

Signed-off-by: check-spelling-bot <check-spelling-bot@users.noreply.github.com>
on-behalf-of: @check-spelling <check-spelling-bot@check-spelling.dev>

* fix(js/algorithms/fast): handle old browsers

Closes #1082

Signed-off-by: Xe Iaso <me@xeiaso.net>

---------

Signed-off-by: Xe Iaso <me@xeiaso.net>
Signed-off-by: check-spelling-bot <check-spelling-bot@users.noreply.github.com>
2025-09-11 10:03:10 -04:00
Martin
8ed89a6c6e feat(lib): Add option for adding difficulty field to JWT claims (#1063)
* Add option for difficulty JWT field

* Add DIFFICULTY_IN_JWT option to docs

* Add missing_required_forwarded_headers to lt translation via Google Translate

* docs(CHANGELOG): move CHANGELOG entry to the top

Signed-off-by: Xe Iaso <me@xeiaso.net>

---------

Signed-off-by: Xe Iaso <me@xeiaso.net>
Co-authored-by: Xe Iaso <me@xeiaso.net>
2025-09-11 13:50:33 +00:00
Xe Iaso
9430d0e6a5 fix(cmd/containerbuild): support commas in --docker-tags (#1099)
Signed-off-by: Xe Iaso <me@xeiaso.net>
2025-09-08 22:19:42 +00:00
34 changed files with 805 additions and 312 deletions

View File

@@ -214,6 +214,7 @@ nicksnyder
nobots
NONINFRINGEMENT
nosleep
nullglob
OCOB
ogtag
oklch
@@ -278,6 +279,7 @@ Seo
setsebool
shellcheck
shirou
shopt
Sidetrade
simprint
sitemap

View File

@@ -31,6 +31,7 @@ import (
"github.com/TecharoHQ/anubis/data"
"github.com/TecharoHQ/anubis/internal"
libanubis "github.com/TecharoHQ/anubis/lib"
"github.com/TecharoHQ/anubis/lib/logging"
botPolicy "github.com/TecharoHQ/anubis/lib/policy"
"github.com/TecharoHQ/anubis/lib/policy/config"
"github.com/TecharoHQ/anubis/lib/thoth"
@@ -51,6 +52,7 @@ var (
cookieExpiration = flag.Duration("cookie-expiration-time", anubis.CookieDefaultExpirationTime, "The amount of time the authorization cookie is valid for")
cookiePrefix = flag.String("cookie-prefix", anubis.CookieName, "prefix for browser cookies created by Anubis")
cookiePartitioned = flag.Bool("cookie-partitioned", false, "if true, sets the partitioned flag on Anubis cookies, enabling CHIPS support")
difficultyInJWT = flag.Bool("difficulty-in-jwt", false, "if true, adds a difficulty field in the JWT claims")
useSimplifiedExplanation = flag.Bool("use-simplified-explanation", false, "if true, replaces the text when clicking \"Why am I seeing this?\" with a more simplified text for a non-tech-savvy audience.")
forcedLanguage = flag.String("forced-language", "", "if set, this language is being used instead of the one from the request's Accept-Language header")
hs512Secret = flag.String("hs512-secret", "", "secret used to sign JWTs, uses ed25519 if not set")
@@ -249,7 +251,7 @@ func main() {
return
}
internal.InitSlog(*slogLevel)
slog.SetDefault(slog.New(logging.Init(*slogLevel)))
internal.SetHealth("anubis", healthv1.HealthCheckResponse_NOT_SERVING)
if *healthcheck {
@@ -433,6 +435,7 @@ func main() {
CookieSecure: *cookieSecure,
PublicUrl: *publicUrl,
JWTRestrictionHeader: *jwtRestrictionHeader,
DifficultyInJWT: *difficultyInJWT,
})
if err != nil {
log.Fatalf("can't construct libanubis.Server: %v", err)
@@ -445,7 +448,10 @@ func main() {
h = internal.XForwardedForUpdate(*xffStripPrivate, h)
h = internal.JA4H(h)
srv := http.Server{Handler: h, ErrorLog: internal.GetFilteredHTTPLogger()}
srv := http.Server{
Handler: h,
ErrorLog: logging.StdlibLogger(s.GetLogger("http-server").Handler(), slog.LevelDebug),
}
listener, listenerUrl := setupListener(*bindNetwork, *bind)
slog.Info(
"listening",
@@ -505,7 +511,10 @@ func metricsServer(ctx context.Context, done func()) {
}
})
srv := http.Server{Handler: mux, ErrorLog: internal.GetFilteredHTTPLogger()}
srv := http.Server{
Handler: mux,
ErrorLog: logging.StdlibLogger(slog.With("subsystem", "metrics-server").Handler(), slog.LevelDebug),
}
listener, metricsUrl := setupListener(*metricsBindNetwork, *metricsBind)
slog.Debug("listening for metrics", "url", metricsUrl)

View File

@@ -10,7 +10,7 @@ import (
"path/filepath"
"strings"
"github.com/TecharoHQ/anubis/internal"
"github.com/TecharoHQ/anubis/lib/logging"
"github.com/facebookgo/flagenv"
)
@@ -28,7 +28,7 @@ func main() {
flagenv.Parse()
flag.Parse()
internal.InitSlog(*slogLevel)
slog.SetDefault(slog.New(logging.Init(*slogLevel)))
koDockerRepo := strings.TrimSuffix(*dockerRepo, "/"+filepath.Base(*dockerRepo))
@@ -46,6 +46,11 @@ func main() {
)
}
if strings.Contains(*dockerTags, ",") {
newTags := strings.Join(strings.Split(*dockerTags, ","), "\n")
dockerTags = &newTags
}
setOutput("docker_image", strings.SplitN(*dockerTags, "\n", 2)[0])
version, err := run("git describe --tags --always --dirty")

View File

@@ -132,6 +132,28 @@ dnsbl: false
# <!-- ... -->
# Logging settings for Anubis
logging:
# CEL log filters. Note that this is a very powerful feature and it is very easy to get
# yourself into trouble with this. Avoid using log filters unless you are running into
# circumstances like https://github.com/TecharoHQ/anubis/issues/942. This has a nonzero
# impact on logging, which spirals out into a more than zero impact on Anubis'
# performance and memory usage.
filters:
# Every filter must have a name and an expression. You can use the same expression
# syntax as you can with bots or thresholds.
#
# If the expression returns `true`, then the log line is filtered _out_.
- name: "http-stdlib"
# Log lines where the message starts with "http:" are filtered out.
expression: msg.startsWith("http:")
- name: "context-canceled"
# Log lines relating to context cancellation are filtered out.
expression: msg.contains("context canceled")
- name: "http-pipelining"
# Log lines relating to HTTP/1.1 pipelining being improperly handled are filtered out.
expression: msg.contains("Unsolicited response received on idle HTTP channel")
# Open Graph passthrough configuration, see here for more information:
# https://anubis.techaro.lol/docs/admin/configuration/open-graph/
openGraph:

View File

@@ -14,6 +14,12 @@ func Zilch[T any]() T {
type Impl[K comparable, V any] struct {
data map[K]decayMapEntry[V]
lock sync.RWMutex
// deleteCh receives decay-deletion requests from readers.
deleteCh chan deleteReq[K]
// stopCh stops the background cleanup worker.
stopCh chan struct{}
wg sync.WaitGroup
}
type decayMapEntry[V any] struct {
@@ -21,30 +27,38 @@ type decayMapEntry[V any] struct {
expiry time.Time
}
// deleteReq is a request to remove a key if its expiry timestamp still matches
// the observed one. This prevents racing with concurrent Set updates.
type deleteReq[K comparable] struct {
key K
expiry time.Time
}
// New creates a new DecayMap of key type K and value type V.
//
// Key types must be comparable to work with maps.
func New[K comparable, V any]() *Impl[K, V] {
return &Impl[K, V]{
data: make(map[K]decayMapEntry[V]),
m := &Impl[K, V]{
data: make(map[K]decayMapEntry[V]),
deleteCh: make(chan deleteReq[K], 1024),
stopCh: make(chan struct{}),
}
m.wg.Add(1)
go m.cleanupWorker()
return m
}
// expire forcibly expires a key by setting its time-to-live one second in the past.
func (m *Impl[K, V]) expire(key K) bool {
m.lock.RLock()
// Use a single write lock to avoid RUnlock->Lock convoy.
m.lock.Lock()
defer m.lock.Unlock()
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
}
@@ -53,19 +67,14 @@ func (m *Impl[K, V]) expire(key K) bool {
// If the value does not exist, return false. Return true after
// deletion.
func (m *Impl[K, V]) Delete(key K) bool {
m.lock.RLock()
_, ok := m.data[key]
m.lock.RUnlock()
if !ok {
return false
}
// Use a single write lock to avoid RUnlock->Lock convoy.
m.lock.Lock()
delete(m.data, key)
m.lock.Unlock()
return true
defer m.lock.Unlock()
_, ok := m.data[key]
if ok {
delete(m.data, key)
}
return ok
}
// Get gets a value from the DecayMap by key.
@@ -81,13 +90,12 @@ func (m *Impl[K, V]) Get(key K) (V, bool) {
}
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.Equal(value.expiry) {
delete(m.data, key)
// Defer decay deletion to the background worker to avoid convoy.
select {
case m.deleteCh <- deleteReq[K]{key: key, expiry: value.expiry}:
default:
// Channel full: drop request; a future Cleanup() or Get will retry.
}
m.lock.Unlock()
return Zilch[V](), false
}
@@ -125,3 +133,64 @@ func (m *Impl[K, V]) Len() int {
defer m.lock.RUnlock()
return len(m.data)
}
// Close stops the background cleanup worker. It's optional to call; maps live
// for the process lifetime in many cases. Call in tests or when you know you no
// longer need the map to avoid goroutine leaks.
func (m *Impl[K, V]) Close() {
close(m.stopCh)
m.wg.Wait()
}
// cleanupWorker batches decay deletions to minimize lock contention.
func (m *Impl[K, V]) cleanupWorker() {
defer m.wg.Done()
batch := make([]deleteReq[K], 0, 64)
ticker := time.NewTicker(10 * time.Millisecond)
defer ticker.Stop()
flush := func() {
if len(batch) == 0 {
return
}
m.applyDeletes(batch)
// reset batch without reallocating
batch = batch[:0]
}
for {
select {
case req := <-m.deleteCh:
batch = append(batch, req)
case <-ticker.C:
flush()
case <-m.stopCh:
// Drain any remaining requests then exit
for {
select {
case req := <-m.deleteCh:
batch = append(batch, req)
default:
flush()
return
}
}
}
}
}
func (m *Impl[K, V]) applyDeletes(batch []deleteReq[K]) {
now := time.Now()
m.lock.Lock()
for _, req := range batch {
entry, ok := m.data[req.key]
if !ok {
continue
}
// Only delete if the expiry is unchanged and already past.
if entry.expiry.Equal(req.expiry) && now.After(entry.expiry) {
delete(m.data, req.key)
}
}
m.lock.Unlock()
}

View File

@@ -7,6 +7,7 @@ import (
func TestImpl(t *testing.T) {
dm := New[string, string]()
t.Cleanup(dm.Close)
dm.Set("test", "hi", 5*time.Minute)
@@ -28,10 +29,24 @@ func TestImpl(t *testing.T) {
if ok {
t.Error("got value even though it was supposed to be expired")
}
// Deletion of expired entries after Get is deferred to a background worker.
// Assert it eventually disappears from the map.
deadline := time.Now().Add(200 * time.Millisecond)
for time.Now().Before(deadline) {
if dm.Len() == 0 {
break
}
time.Sleep(5 * time.Millisecond)
}
if dm.Len() != 0 {
t.Fatalf("expected background cleanup to remove expired key; len=%d", dm.Len())
}
}
func TestCleanup(t *testing.T) {
dm := New[string, string]()
t.Cleanup(dm.Close)
dm.Set("test1", "hi1", 1*time.Second)
dm.Set("test2", "hi2", 2*time.Second)

View File

@@ -13,10 +13,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
<!-- This changes the project to: -->
- Fix lock convoy problem in decaymap ([#1103](https://github.com/TecharoHQ/anubis/issues/1103))
- [Log filtering](./admin/configuration/expressions.mdx#log-filtering) rules have been added. This allows users to write custom log filtering logic.
- Document missing environment variables in installation guide: `SLOG_LEVEL`, `COOKIE_PREFIX`, `FORCED_LANGUAGE`, and `TARGET_DISABLE_KEEPALIVE` ([#1086](https://github.com/TecharoHQ/anubis/pull/1086))
- Add validation warning when persistent storage is used without setting signing keys
- Fixed `robots2policy` to properly group consecutive user agents into `any:` instead of only processing the last one ([#925](https://github.com/TecharoHQ/anubis/pull/925))
- Add the [`s3api` storage backend](./admin/policies.mdx#s3api) to allow Anubis to use S3 API compatible object storage as its storage backend.
- Make `cmd/containerbuild` support commas for separating elements of the `--docker-tags` argument as well as newlines.
- Add the `DIFFICULTY_IN_JWT` option, which allows one to add the `difficulty` field in the JWT claims which indicates the difficulty of the token ([#1063](https://github.com/TecharoHQ/anubis/pull/1063))
- Ported the client-side JS to TypeScript to avoid egregious errors in the future.
- Fixes concurrency problems with very old browsers ([#1082](https://github.com/TecharoHQ/anubis/issues/1082)).
### Bug Fixes

View File

@@ -99,6 +99,10 @@ For this rule, if a request comes in matching [the signature of the `go get` com
Anubis exposes the following variables to expressions:
### Bot expressions
Bot expressions are used for evaluating [bot rules](../policies.mdx#bot-policies).
| Name | Type | Explanation | Example |
| :-------------- | :-------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------- | :----------------------------------------------------------- |
| `headers` | `map[string, string]` | The [headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers) of the request being processed. | `{"User-Agent": "Mozilla/5.0 Gecko/20100101 Firefox/137.0"}` |
@@ -182,6 +186,23 @@ Something to keep in mind about system load average is that it is not aware of t
Also keep in mind that this does not account for other kinds of latency like I/O latency. A system can have its web applications unresponsive due to high latency from a MySQL server but still have that web application server report a load near or at zero.
### Log filtering
Log filters are run on every time Anubis logs data. These are high throughput filters and should be written with care.
| Name | Type | Explanation | Example |
| :------ | :-------------------- | :----------------------------------------------------------------------------------------------------- | --------------------------------------- |
| `time` | Timestamp | The time that the log line was emitted. | `2025-08-18T06:45:38-04:00` |
| `msg` | `string` | The text-based message for the given log line. | `"invalid response"` |
| `level` | `string` | The [log level](https://pkg.go.dev/log/slog#Level) for the log message. | `"INFO"` |
| `attrs` | `map[string, string]` | The key -> value attributes for the given log line. Note that this is an expensive variable to access. | `{"err": "internal: the sun exploded"}` |
:::note
When you define a log filter, anything matching that filter is _removed_. Any remaining logs are sent through to the system journal or standard error.
:::
## Functions exposed to Anubis expressions
Anubis expressions can be augmented with the following functions:

View File

@@ -59,7 +59,7 @@ Currently the following settings are configurable via the policy file:
Anubis uses these environment variables for configuration:
| Environment Variable | Default value | Explanation |
|:-------------------------------|:------------------------|:--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| :----------------------------- | :---------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `BASE_PREFIX` | unset | If set, adds a global prefix to all Anubis endpoints (everything starting with `/.within.website/x/anubis/`). For example, setting this to `/myapp` would make Anubis accessible at `/myapp/` instead of `/`. This is useful when running Anubis behind a reverse proxy that routes based on path prefixes. |
| `BIND` | `:8923` | The network address that Anubis listens on. For `unix`, set this to a path: `/run/anubis/instance.sock` |
| `BIND_NETWORK` | `tcp` | The address family that Anubis listens on. Accepts `tcp`, `unix` and anything Go's [`net.Listen`](https://pkg.go.dev/net#Listen) supports. |
@@ -70,6 +70,7 @@ Anubis uses these environment variables for configuration:
| `COOKIE_PREFIX` | `anubis-cookie` | The prefix used for browser cookies created by Anubis. Useful for customization or avoiding conflicts with other applications. |
| `COOKIE_SECURE` | `true` | If set to `true`, enables the [Secure flag](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Cookies#block_access_to_your_cookies), meaning that the cookies will only be transmitted over HTTPS. If Anubis is used in an unsecure context (plain HTTP), this will be need to be set to false |
| `DIFFICULTY` | `4` | The difficulty of the challenge, or the number of leading zeroes that must be in successful responses. |
| `DIFFICULTY_IN_JWT` | `false` | If set to `true`, adds the `difficulty` field into JWT claims, which indicates the difficulty the token has been generated. This may be useful for statistics and debugging. |
| `ED25519_PRIVATE_KEY_HEX` | unset | The hex-encoded ed25519 private key used to sign Anubis responses. If this is not set, Anubis will generate one for you. This should be exactly 64 characters long. **Required when using persistent storage backends** (like bbolt) to ensure challenges survive service restarts. When running multiple instances on the same base domain, the key must be the same across all instances. See below for details. |
| `ED25519_PRIVATE_KEY_HEX_FILE` | unset | Path to a file containing the hex-encoded ed25519 private key. Only one of this or its sister option may be set. **Required when using persistent storage backends** (like bbolt) to ensure challenges survive service restarts. When running multiple instances on the same base domain, the key must be the same across all instances. |
| `JWT_RESTRICTION_HEADER` | `X-Real-IP` | If set, the JWT is only valid if the current value of this header matches the value when the JWT was created. You can use it e.g. to restrict a JWT to the source IP of the user using `X-Real-IP`. |
@@ -100,14 +101,14 @@ If you don't know or understand what these settings mean, ignore them. These are
:::
| Environment Variable | Default value | Explanation |
| :---------------------------- | :------------ | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `FORCED_LANGUAGE` | unset | If set, forces Anubis to display challenge pages in the specified language instead of using the browser's Accept-Language header. Use ISO 639-1 language codes (e.g., `de` for German, `fr` for French). |
| Environment Variable | Default value | Explanation |
| :---------------------------- | :------------ | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `FORCED_LANGUAGE` | unset | If set, forces Anubis to display challenge pages in the specified language instead of using the browser's Accept-Language header. Use ISO 639-1 language codes (e.g., `de` for German, `fr` for French). |
| `HS512_SECRET` | unset | Secret string for JWT HS512 algorithm. If this is not set, Anubis will use ED25519 as defined via the variables above. The longer the better; 128 chars should suffice. **Required when using persistent storage backends** (like bbolt) to ensure challenges survive service restarts. When running multiple instances on the same base domain, the key must be the same across all instances. |
| `TARGET_DISABLE_KEEPALIVE` | `false` | If `true`, disables HTTP keep-alive for connections to the target backend. Useful for backends that don't handle keep-alive properly. |
| `TARGET_HOST` | unset | If set, overrides the Host header in requests forwarded to `TARGET`. |
| `TARGET_INSECURE_SKIP_VERIFY` | `false` | If `true`, skip TLS certificate validation for targets that listen over `https`. If your backend does not listen over `https`, ignore this setting. |
| `TARGET_SNI` | unset | If set, overrides the TLS handshake hostname in requests forwarded to `TARGET`. |
| `TARGET_DISABLE_KEEPALIVE` | `false` | If `true`, disables HTTP keep-alive for connections to the target backend. Useful for backends that don't handle keep-alive properly. |
| `TARGET_HOST` | unset | If set, overrides the Host header in requests forwarded to `TARGET`. |
| `TARGET_INSECURE_SKIP_VERIFY` | `false` | If `true`, skip TLS certificate validation for targets that listen over `https`. If your backend does not listen over `https`, ignore this setting. |
| `TARGET_SNI` | unset | If set, overrides the TLS handshake hostname in requests forwarded to `TARGET`. |
</details>

View File

@@ -123,6 +123,10 @@ remote_addresses:
Anubis has support for showing imprint / impressum information. This is defined in the `impressum` block of your configuration. See [Imprint / Impressum configuration](./configuration/impressum.mdx) for more information.
## Logging
Anubis has support for configuring log filtering using expressions. See the [log filters](./configuration/expressions.mdx#log-filters) of the [expression](./configuration/expressions.mdx) documentation for more information.
## Storage backends
Anubis needs to store temporary data in order to determine if a user is legitimate or not. Administrators should choose a storage backend based on their infrastructure needs. Each backend has its own advantages and disadvantages.

View File

@@ -1,31 +1,10 @@
package internal
import (
"fmt"
"log"
"log/slog"
"net/http"
"os"
"strings"
)
func InitSlog(level string) {
var programLevel slog.Level
if err := (&programLevel).UnmarshalText([]byte(level)); err != nil {
fmt.Fprintf(os.Stderr, "invalid log level %s: %v, using info\n", level, err)
programLevel = slog.LevelInfo
}
leveler := &slog.LevelVar{}
leveler.Set(programLevel)
h := slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{
AddSource: true,
Level: leveler,
})
slog.SetDefault(slog.New(h))
}
func GetRequestLogger(base *slog.Logger, r *http.Request) *slog.Logger {
host := r.Host
if host == "" {
@@ -44,27 +23,3 @@ func GetRequestLogger(base *slog.Logger, r *http.Request) *slog.Logger {
"x-real-ip", r.Header.Get("X-Real-Ip"),
)
}
// ErrorLogFilter is used to suppress "context canceled" logs from the http server when a request is canceled (e.g., when a client disconnects).
type ErrorLogFilter struct {
Unwrap *log.Logger
}
func (elf *ErrorLogFilter) Write(p []byte) (n int, err error) {
logMessage := string(p)
if strings.Contains(logMessage, "context canceled") {
return len(p), nil // Suppress the log by doing nothing
}
if strings.Contains(logMessage, "Unsolicited response received on idle HTTP channel") {
return len(p), nil
}
if elf.Unwrap != nil {
return elf.Unwrap.Writer().Write(p)
}
return len(p), nil
}
func GetFilteredHTTPLogger() *log.Logger {
stdErrLogger := log.New(os.Stderr, "", log.LstdFlags) // essentially what the default logger is.
return log.New(&ErrorLogFilter{Unwrap: stdErrLogger}, "", 0)
}

View File

@@ -1,82 +0,0 @@
package internal
import (
"bytes"
"log"
"log/slog"
"net/http"
"strings"
"testing"
)
func TestErrorLogFilter(t *testing.T) {
var buf bytes.Buffer
destLogger := log.New(&buf, "", 0)
errorFilterWriter := &ErrorLogFilter{Unwrap: destLogger}
testErrorLogger := log.New(errorFilterWriter, "", 0)
// Test Case 1: Suppressed message
suppressedMessage := "http: proxy error: context canceled"
testErrorLogger.Println(suppressedMessage)
if buf.Len() != 0 {
t.Errorf("Suppressed message was written to output. Output: %q", buf.String())
}
buf.Reset()
// Test Case 2: Allowed message
allowedMessage := "http: another error occurred"
testErrorLogger.Println(allowedMessage)
output := buf.String()
if !strings.Contains(output, allowedMessage) {
t.Errorf("Allowed message was not written to output. Output: %q", output)
}
if !strings.HasSuffix(output, "\n") {
t.Errorf("Allowed message output is missing newline. Output: %q", output)
}
buf.Reset()
// Test Case 3: Partially matching message (should be suppressed)
partiallyMatchingMessage := "Some other log before http: proxy error: context canceled and after"
testErrorLogger.Println(partiallyMatchingMessage)
if buf.Len() != 0 {
t.Errorf("Partially matching message was written to output. Output: %q", buf.String())
}
buf.Reset()
}
func TestGetRequestLogger(t *testing.T) {
// Test case 1: Normal request with Host header
req1, _ := http.NewRequest("GET", "http://example.com/test", nil)
req1.Host = "example.com"
logger := slog.Default()
reqLogger := GetRequestLogger(logger, req1)
// We can't easily test the actual log output without setting up a test handler,
// but we can verify the function doesn't panic and returns a logger
if reqLogger == nil {
t.Error("GetRequestLogger returned nil")
}
// Test case 2: Subrequest auth mode with X-Forwarded-Host
req2, _ := http.NewRequest("GET", "http://test.com/auth", nil)
req2.Host = ""
req2.Header.Set("X-Forwarded-Host", "original-site.com")
reqLogger2 := GetRequestLogger(logger, req2)
if reqLogger2 == nil {
t.Error("GetRequestLogger returned nil for X-Forwarded-Host case")
}
// Test case 3: No host information available
req3, _ := http.NewRequest("GET", "http://test.com/nohost", nil)
req3.Host = ""
reqLogger3 := GetRequestLogger(logger, req3)
if reqLogger3 == nil {
t.Error("GetRequestLogger returned nil for no host case")
}
}

View File

@@ -78,6 +78,10 @@ type Server struct {
logger *slog.Logger
}
func (s *Server) GetLogger(subsystem string) *slog.Logger {
return s.logger.With("subsystem", subsystem)
}
func (s *Server) getTokenKeyfunc() jwt.Keyfunc {
// return ED25519 key if HS512 is not set
if len(s.hs512Secret) == 0 {
@@ -501,6 +505,12 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) {
var tokenString string
// check if JWTRestrictionHeader is set and header is in request
claims := jwt.MapClaims{
"challenge": chall.ID,
"method": rule.Challenge.Algorithm,
"policyRule": rule.Hash(),
"action": string(cr.Rule),
}
if s.opts.JWTRestrictionHeader != "" {
if r.Header.Get(s.opts.JWTRestrictionHeader) == "" {
lg.Error("JWTRestrictionHeader is set in config but not found in request, please check your reverse proxy config.")
@@ -508,22 +518,13 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) {
s.respondWithError(w, r, "failed to sign JWT")
return
} else {
tokenString, err = s.signJWT(jwt.MapClaims{
"challenge": chall.ID,
"method": rule.Challenge.Algorithm,
"policyRule": rule.Hash(),
"action": string(cr.Rule),
"restriction": internal.SHA256sum(r.Header.Get(s.opts.JWTRestrictionHeader)),
})
claims["restriction"] = internal.SHA256sum(r.Header.Get(s.opts.JWTRestrictionHeader))
}
} else {
tokenString, err = s.signJWT(jwt.MapClaims{
"challenge": chall.ID,
"method": rule.Challenge.Algorithm,
"policyRule": rule.Hash(),
"action": string(cr.Rule),
})
}
if s.opts.DifficultyInJWT {
claims["difficulty"] = rule.Challenge.Difficulty
}
tokenString, err = s.signJWT(claims)
if err != nil {
lg.Error("failed to sign JWT", "err", err)

View File

@@ -93,9 +93,9 @@ func page(redir string, difficulty int, loc *localization.SimpleLocalizer) templ
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d; url=%s", difficulty, redir))
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d; url=%s", difficulty+1, redir))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `metarefresh.templ`, Line: 16, Col: 83}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `metarefresh.templ`, Line: 16, Col: 85}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {

View File

@@ -40,9 +40,9 @@ for the JavaScript code in this page.
mkdir -p static/js
for file in js/*.jsx; do
for file in js/*.tsx; do
filename="${file##*/}" # Extracts "app.jsx" from "./js/app.jsx"
output="${filename%.jsx}.js" # Changes "app.jsx" to "app.js"
output="${filename%.tsx}.js" # Changes "app.jsx" to "app.js"
echo $output
esbuild "${file}" --minify --bundle --outfile=static/"${output}" --banner:js="${LICENSE}"

View File

@@ -1,62 +0,0 @@
import { render, h, Fragment } from 'preact';
import { useState, useEffect } from 'preact/hooks';
import { g, j, u, x } from "./xeact.js";
import { Sha256 } from '@aws-crypto/sha256-js';
/** @jsx h */
/** @jsxFrag Fragment */
function toHexString(arr) {
return Array.from(arr)
.map((c) => c.toString(16).padStart(2, "0"))
.join("");
}
const App = () => {
const [state, setState] = useState(null);
const [imageURL, setImageURL] = useState(null);
const [passed, setPassed] = useState(false);
const [challenge, setChallenge] = useState(null);
useEffect(() => {
setState(j("preact_info"));
});
useEffect(() => {
setImageURL(state.pensive_url);
const hash = new Sha256('');
hash.update(state.challenge);
setChallenge(toHexString(hash.digestSync()));
}, [state]);
useEffect(() => {
const timer = setTimeout(() => {
setPassed(true);
}, state.difficulty * 125);
return () => clearTimeout(timer);
}, [challenge]);
useEffect(() => {
window.location.href = u(state.redir, {
result: challenge,
});
}, [passed]);
return (
<>
{imageURL !== null && (
<img src={imageURL} style="width:100%;max-width:256px;" />
)}
{state !== null && (
<>
<p id="status">{state.loading_message}</p>
<p>{state.connection_security_message}</p>
</>
)}
</>
);
};
x(g("app"));
render(<App />, g("app"));

View File

@@ -0,0 +1,87 @@
import { render, h, Fragment } from "preact";
import { useState, useEffect } from "preact/hooks";
import { g, j, r, u, x } from "./xeact.js";
import { Sha256 } from "@aws-crypto/sha256-js";
/** @jsx h */
/** @jsxFrag Fragment */
function toHexString(arr: Uint8Array) {
return Array.from(arr)
.map((c) => c.toString(16).padStart(2, "0"))
.join("");
}
interface PreactInfo {
redir: string;
challenge: string;
difficulty: number;
connection_security_message: string;
loading_message: string;
pensive_url: string;
}
const App = () => {
const [state, setState] = useState<PreactInfo>();
const [imageURL, setImageURL] = useState<string | null>(null);
const [passed, setPassed] = useState<boolean>(false);
const [challenge, setChallenge] = useState<string | null>(null);
useEffect(() => {
setState(j("preact_info"));
});
useEffect(() => {
if (state === undefined) {
return;
}
setImageURL(state?.pensive_url);
const hash = new Sha256("");
hash.update(state.challenge);
setChallenge(toHexString(hash.digestSync()));
}, [state]);
useEffect(() => {
if (state === undefined) {
return;
}
const timer = setTimeout(() => {
setPassed(true);
}, state?.difficulty * 125);
return () => clearTimeout(timer);
}, [challenge]);
useEffect(() => {
if (state === undefined) {
return;
}
if (challenge === null) {
return;
}
window.location.href = u(state.redir, {
result: challenge,
});
}, [passed]);
return (
<>
{imageURL !== null && (
<img src={imageURL} style={{ width: "100%", maxWidth: "256px" }} />
)}
{state !== undefined && (
<>
<p id="status">{state.loading_message}</p>
<p>{state.connection_security_message}</p>
</>
)}
</>
);
};
x(g("app"));
render(<App />, g("app"));

View File

@@ -46,6 +46,7 @@ type Options struct {
Logger *slog.Logger
PublicUrl string
JWTRestrictionHeader string
DifficultyInJWT bool
}
func LoadPoliciesOrDefault(ctx context.Context, fname string, defaultDifficulty int) (*policy.ParsedConfig, error) {
@@ -119,6 +120,14 @@ func New(opts Options) (*Server, error) {
logger: opts.Logger,
}
if opts.Policy.Logging != nil {
var err error
result.logger, err = opts.Policy.ApplyLogFilters(opts.Logger)
if err != nil {
return nil, fmt.Errorf("can't create log filters: %w", err)
}
}
mux := http.NewServeMux()
xess.Mount(mux)

View File

@@ -62,5 +62,6 @@
"js_iterations": "iteracijų",
"js_finished_reading": "Viską perskaičiau, tęskime →",
"js_calculation_error": "Skaičiavimo klaida!",
"js_calculation_error_msg": "Nepavyko įveikti iššūkio:"
"js_calculation_error_msg": "Nepavyko įveikti iššūkio:",
"missing_required_forwarded_headers": "Trūksta privalomų X-Forwarded-* antraščių"
}

67
lib/logging/filter.go Normal file
View File

@@ -0,0 +1,67 @@
package logging
import (
"context"
"log/slog"
)
// Filterer is the shape of any type that can perform log filtering. This takes
// the context of the log filtering call and the log record to be filtered.
type Filterer interface {
Filter(ctx context.Context, r slog.Record) bool
}
// FilterFunc lets you make inline log filters with plain functions.
type FilterFunc func(ctx context.Context, r *slog.Record) bool
// Filter implements Filterer for FilterFunc.
func (ff FilterFunc) Filter(ctx context.Context, r *slog.Record) bool {
return ff(ctx, r)
}
// FilterHandler wraps a slog Handler with one or more filters, enabling administrators
// to customize the logging subsystem of Anubis.
type FilterHandler struct {
next slog.Handler
filters []Filterer
}
// NewFilterHandler creates a new filtering handler with the given base handler and filters.
func NewFilterHandler(handler slog.Handler, filters ...Filterer) *FilterHandler {
return &FilterHandler{
next: handler,
filters: filters,
}
}
// Enabled passes through to the upstream slog Handler.
func (h *FilterHandler) Enabled(ctx context.Context, level slog.Level) bool {
return h.next.Enabled(ctx, level)
}
// Handle implements slog.Handler and applies all filters before delegating to the base handler.
func (h *FilterHandler) Handle(ctx context.Context, r slog.Record) error {
// Apply all filters - if any filter returns false, skip the log
for _, filter := range h.filters {
if !filter.Filter(ctx, r) {
return nil // Skip this log record
}
}
return h.next.Handle(ctx, r)
}
// WithAttrs implements slog.Handler.
func (h *FilterHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
return &FilterHandler{
next: h.next.WithAttrs(attrs),
filters: h.filters,
}
}
// WithGroup implements slog.Handler.
func (h *FilterHandler) WithGroup(name string) slog.Handler {
return &FilterHandler{
next: h.next.WithGroup(name),
filters: h.filters,
}
}

24
lib/logging/logging.go Normal file
View File

@@ -0,0 +1,24 @@
package logging
import (
"fmt"
"log/slog"
"os"
)
func Init(level string) slog.Handler {
var programLevel slog.Level
if err := (&programLevel).UnmarshalText([]byte(level)); err != nil {
fmt.Fprintf(os.Stderr, "invalid log level %s: %v, using info\n", level, err)
programLevel = slog.LevelInfo
}
leveler := &slog.LevelVar{}
leveler.Set(programLevel)
h := slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{
AddSource: true,
Level: leveler,
})
return h
}

36
lib/logging/stdlib.go Normal file
View File

@@ -0,0 +1,36 @@
package logging
import (
"bytes"
"context"
"log"
"log/slog"
"time"
)
// handlerWriter is an io.Writer that calls a Handler.
// It is used to link the default log.Logger to the default slog.Logger.
//
// Adapted from https://cs.opensource.google/go/go/+/refs/tags/go1.24.5:src/log/slog/logger.go;l=62
type handlerWriter struct {
h slog.Handler
level slog.Leveler
}
func (w *handlerWriter) Write(buf []byte) (int, error) {
level := w.level.Level()
if !w.h.Enabled(context.Background(), level) {
return 0, nil
}
var pc uintptr
// Remove final newline.
origLen := len(buf) // Report that the entire buf was written.
buf = bytes.TrimSuffix(buf, []byte{'\n'})
r := slog.NewRecord(time.Now(), level, string(buf), pc)
return origLen, w.h.Handle(context.Background(), r)
}
func StdlibLogger(next slog.Handler, level slog.Level) *log.Logger {
return log.New(&handlerWriter{h: next, level: level}, "", log.LstdFlags)
}

View File

@@ -326,6 +326,7 @@ func (sc StatusCodes) Valid() error {
type fileConfig struct {
Bots []BotOrImport `json:"bots"`
DNSBL bool `json:"dnsbl"`
Logging *Logging `json:"logging"`
OpenGraph openGraphFileConfig `json:"openGraph,omitempty"`
Impressum *Impressum `json:"impressum,omitempty"`
StatusCodes StatusCodes `json:"status_codes"`
@@ -368,6 +369,12 @@ func (c *fileConfig) Valid() error {
}
}
if c.Logging != nil {
if err := c.Logging.Valid(); err != nil {
errs = append(errs, err)
}
}
if len(errs) != 0 {
return fmt.Errorf("config is not valid:\n%w", errors.Join(errs...))
}
@@ -401,6 +408,7 @@ func Load(fin io.Reader, fname string) (*Config, error) {
ConsiderHost: c.OpenGraph.ConsiderHost,
Override: c.OpenGraph.Override,
},
Logging: c.Logging,
StatusCodes: c.StatusCodes,
Store: c.Store,
}
@@ -441,6 +449,12 @@ func Load(fin io.Reader, fname string) (*Config, error) {
result.Impressum = c.Impressum
}
if c.Logging != nil {
if err := c.Logging.Valid(); err != nil {
validationErrs = append(validationErrs, err)
}
}
if len(c.Thresholds) == 0 {
c.Thresholds = DefaultThresholds
}
@@ -465,6 +479,7 @@ type Config struct {
Bots []BotConfig
Thresholds []Threshold
DNSBL bool
Logging *Logging
Impressum *Impressum
OpenGraph OpenGraph
StatusCodes StatusCodes

View File

@@ -0,0 +1,49 @@
package config
import (
"errors"
"fmt"
)
type Logging struct {
Filters []LogFilter `json:"filters,omitempty" yaml:"filters,omitempty"`
}
func (l *Logging) Valid() error {
var errs []error
for _, lf := range l.Filters {
if err := lf.Valid(); err != nil {
errs = append(errs, err)
}
}
if len(errs) != 0 {
return errors.Join(errs...)
}
return nil
}
type LogFilter struct {
Name string `json:"name" yaml:"name"`
Expression ExpressionOrList `json:"expression" yaml:"expression"`
}
func (lf LogFilter) Valid() error {
var errs []error
if lf.Name == "" {
errs = append(errs, fmt.Errorf("%w: log filter has no name", ErrMissingValue))
}
if err := lf.Expression.Valid(); err != nil {
errs = append(errs, err)
}
if len(errs) != 0 {
return fmt.Errorf("log filter %q is not valid: %w", lf.Name, errors.Join(errs...))
}
return nil
}

View File

@@ -0,0 +1,114 @@
package expressions
import (
"context"
"fmt"
"log/slog"
"time"
"github.com/google/cel-go/cel"
"github.com/google/cel-go/common/types"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
timestamp "google.golang.org/protobuf/types/known/timestamppb"
)
var (
filterExecutionTime = promauto.NewHistogramVec(prometheus.HistogramOpts{
Namespace: "anubis",
Subsystem: "slog",
Name: "filter_execution_time_nanoseconds",
Help: "How long each log filter took to execute (nanoseconds)",
Buckets: []float64{10, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000, 50000, 100000, 200000, 500000, 1000000, 2000000, 5000000, 10000000}, // 10 nanoseconds to 10 milliseconds
}, []string{"name"})
)
func LogFilter(opts ...cel.EnvOption) (*cel.Env, error) {
return New(
// Slog record metadata
cel.Variable("time", cel.TimestampType),
cel.Variable("msg", cel.StringType),
cel.Variable("level", cel.StringType),
cel.Variable("attrs", cel.MapType(cel.StringType, cel.StringType)),
)
}
func NewFilter(lg *slog.Logger, name, src string) (*Filter, error) {
env, err := LogFilter()
if err != nil {
return nil, fmt.Errorf("logging: can't create CEL env: %w", err)
}
program, err := Compile(env, src)
if err != nil {
return nil, fmt.Errorf("logging: can't compile expression: Compile(%q): %w", src, err)
}
return &Filter{
program: program,
name: name,
src: src,
log: lg.With("filter", name),
}, nil
}
type Filter struct {
program cel.Program
name string
src string
log *slog.Logger
}
func (f Filter) Filter(ctx context.Context, r slog.Record) bool {
t0 := time.Now()
result, _, err := f.program.ContextEval(ctx, &Record{
Record: r,
})
if err != nil {
f.log.Error("error executing log filter", "err", err, "src", f.src)
return false
}
dur := time.Since(t0)
filterExecutionTime.WithLabelValues(f.name).Observe(float64(dur.Nanoseconds()))
//f.log.Debug("filter execution", "dur", dur.Nanoseconds())
if val, ok := result.(types.Bool); ok {
return !bool(val)
}
return false
}
type Record struct {
slog.Record
attrs map[string]string
}
func (r *Record) Parent() cel.Activation { return nil }
func (r *Record) ResolveName(name string) (any, bool) {
switch name {
case "time":
return &timestamp.Timestamp{Seconds: r.Time.Unix()}, true
case "msg":
return r.Message, true
case "level":
return r.Level.String(), true
case "attrs":
if r.attrs == nil {
attrs := map[string]string{}
r.Attrs(func(attr slog.Attr) bool {
attrs[attr.Key] = attr.Value.String()
return true
})
r.attrs = attrs
return attrs, true
}
return r.attrs, true
default:
return nil, false
}
}

View File

@@ -0,0 +1,65 @@
package expressions
import (
"context"
"fmt"
"io"
"log/slog"
"strings"
"testing"
"time"
)
func BenchmarkFilter(b *testing.B) {
log := slog.New(slog.NewTextHandler(io.Discard, nil))
filter, err := NewFilter(log, "benchmark", `msg == "hello"`)
if err != nil {
b.Fatalf("NewFilter() error = %v", err)
}
record := slog.NewRecord(time.Now(), slog.LevelInfo, "hello", 0)
record.AddAttrs(slog.String("foo", "bar"))
ctx := context.Background()
b.ReportAllocs()
for b.Loop() {
filter.Filter(ctx, record)
}
}
func BenchmarkFilterAttributes(b *testing.B) {
for _, numAttrs := range []int{1, 2, 4, 8, 16, 32} {
b.Run(fmt.Sprintf("%d_attributes", numAttrs), func(b *testing.B) {
log := slog.New(slog.NewTextHandler(io.Discard, nil))
var sb strings.Builder
sb.WriteString(`msg == "hello" && "foo" in attrs`)
attrs := make([]slog.Attr, numAttrs)
for i := range numAttrs {
key := fmt.Sprintf("foo%d", i)
val := "bar"
attrs[i] = slog.String(key, val)
}
filter, err := NewFilter(log, "benchmark", sb.String())
if err != nil {
b.Fatalf("NewFilter() error = %v", err)
}
record := slog.NewRecord(time.Now(), slog.LevelInfo, "hello", 0)
record.AddAttrs(attrs...)
ctx := context.Background()
b.ResetTimer()
b.ReportAllocs()
for b.Loop() {
filter.Filter(ctx, record)
}
})
}
}

View File

@@ -8,8 +8,10 @@ import (
"log/slog"
"sync/atomic"
"github.com/TecharoHQ/anubis/lib/logging"
"github.com/TecharoHQ/anubis/lib/policy/checker"
"github.com/TecharoHQ/anubis/lib/policy/config"
"github.com/TecharoHQ/anubis/lib/policy/expressions"
"github.com/TecharoHQ/anubis/lib/store"
"github.com/TecharoHQ/anubis/lib/thoth"
"github.com/prometheus/client_golang/prometheus"
@@ -35,17 +37,42 @@ type ParsedConfig struct {
Thresholds []*Threshold
DNSBL bool
Impressum *config.Impressum
Logging *config.Logging
OpenGraph config.OpenGraph
DefaultDifficulty int
StatusCodes config.StatusCodes
Store store.Interface
}
func (pc *ParsedConfig) ApplyLogFilters(base *slog.Logger) (*slog.Logger, error) {
var errs []error
var filters []logging.Filterer
for _, f := range pc.Logging.Filters {
filter, err := expressions.NewFilter(base, f.Name, f.Expression.String())
if err != nil {
errs = append(errs, fmt.Errorf("filter %s invalid: %w", f.Name, err))
continue
}
filters = append(filters, filter)
}
result := slog.New(logging.NewFilterHandler(base.Handler(), filters...))
slog.SetDefault(result)
if len(errs) != 0 {
return nil, errors.Join(errs...)
}
return result, nil
}
func newParsedConfig(orig *config.Config) *ParsedConfig {
return &ParsedConfig{
orig: orig,
OpenGraph: orig.OpenGraph,
StatusCodes: orig.StatusCodes,
Logging: orig.Logging,
}
}

View File

@@ -39,9 +39,18 @@ for the JavaScript code in this page.
mkdir -p static/locales
cp ../lib/localization/locales/*.json static/locales/
for file in js/*.mjs js/worker/*.mjs; do
esbuild "${file}" --sourcemap --bundle --minify --outfile=static/"${file}" --banner:js="${LICENSE}"
gzip -f -k -n static/${file}
zstd -f -k --ultra -22 static/${file}
brotli -fZk static/${file}
shopt -s nullglob globstar
for file in js/**/*.ts js/**/*.mjs; do
out="static/${file}"
if [[ "$file" == *.ts ]]; then
out="static/${file%.ts}.mjs"
fi
mkdir -p "$(dirname "$out")"
esbuild "$file" --sourcemap --bundle --minify --outfile="$out" --banner:js="$LICENSE"
gzip -f -k -n "$out"
zstd -f -k --ultra -22 "$out"
brotli -fZk "$out"
done

View File

@@ -1,11 +1,21 @@
type ProgressCallback = (nonce: number) => void;
interface ProcessOptions {
basePrefix: string;
version: string;
}
const getHardwareConcurrency = () =>
navigator.hardwareConcurrency !== undefined ? navigator.hardwareConcurrency : 1;
export default function process(
{ basePrefix, version },
data,
difficulty = 5,
signal = null,
progressCallback = null,
threads = Math.trunc(Math.max(navigator.hardwareConcurrency / 2, 1)),
) {
options: ProcessOptions,
data: string,
difficulty: number = 5,
signal: AbortSignal | null = null,
progressCallback?: ProgressCallback,
threads: number = Math.trunc(Math.max(getHardwareConcurrency() / 2, 1)),
): Promise<string> {
console.debug("fast algo");
let workerMethod = window.crypto !== undefined ? "webcrypto" : "purejs";
@@ -16,13 +26,17 @@ export default function process(
}
return new Promise((resolve, reject) => {
let webWorkerURL = `${basePrefix}/.within.website/x/cmd/anubis/static/js/worker/sha256-${workerMethod}.mjs?cacheBuster=${version}`;
let webWorkerURL = `${options.basePrefix}/.within.website/x/cmd/anubis/static/js/worker/sha256-${workerMethod}.mjs?cacheBuster=${options.version}`;
console.log(webWorkerURL);
const workers = [];
const workers: Worker[] = [];
let settled = false;
const onAbort = () => {
console.log("PoW aborted");
cleanup();
reject(new DOMException("Aborted", "AbortError"));
};
const cleanup = () => {
if (settled) {
return;
@@ -34,12 +48,6 @@ export default function process(
}
};
const onAbort = () => {
console.log("PoW aborted");
cleanup();
reject(new DOMException("Aborted", "AbortError"));
};
if (signal != null) {
if (signal.aborted) {
return onAbort();

View File

@@ -1,4 +1,4 @@
import fast from "./fast.mjs";
import fast from "./fast";
export default {
fast: fast,

View File

@@ -1,20 +1,24 @@
import algorithms from "./algorithms/index.mjs";
import algorithms from "./algorithms";
const defaultDifficulty = 4;
const status = document.getElementById("status");
const difficultyInput = document.getElementById("difficulty-input");
const algorithmSelect = document.getElementById("algorithm-select");
const compareSelect = document.getElementById("compare-select");
const header = document.getElementById("table-header");
const headerCompare = document.getElementById("table-header-compare");
const results = document.getElementById("results");
const status: HTMLParagraphElement = document.getElementById("status") as HTMLParagraphElement;
const difficultyInput: HTMLInputElement = document.getElementById("difficulty-input") as HTMLInputElement;
const algorithmSelect: HTMLSelectElement = document.getElementById("algorithm-select") as HTMLSelectElement;
const compareSelect: HTMLSelectElement = document.getElementById("compare-select") as HTMLSelectElement;
const header: HTMLTableRowElement = document.getElementById("table-header") as HTMLTableRowElement;
const headerCompare: HTMLTableSectionElement = document.getElementById("table-header-compare") as HTMLTableSectionElement;
const results: HTMLTableRowElement = document.getElementById("results") as HTMLTableRowElement;
const setupControls = () => {
difficultyInput.value = defaultDifficulty;
if (defaultDifficulty == null) {
return;
}
difficultyInput.value = defaultDifficulty.toString();
for (const alg of Object.keys(algorithms)) {
const option1 = document.createElement("option");
algorithmSelect.append(option1);
algorithmSelect?.append(option1);
const option2 = document.createElement("option");
compareSelect.append(option2);
option1.value = option1.innerText = option2.value = option2.innerText = alg;
@@ -116,13 +120,13 @@ const benchmarkLoop = async (controller) => {
await benchmarkLoop(controller);
};
let controller = null;
let controller: AbortController | null = null;
const reset = () => {
stats.time = stats.iters = 0;
comparison.time = comparison.iters = 0;
results.innerHTML = status.innerText = "";
const table = results.parentElement;
const table = results.parentElement as HTMLElement;
if (compareSelect.value !== "NONE") {
table.style.gridTemplateColumns = "repeat(4,auto)";
header.style.display = "none";

View File

@@ -1,12 +1,21 @@
import algorithms from "./algorithms/index.mjs";
import algorithms from "./algorithms";
// from Xeact
const u = (url = "", params = {}) => {
const u = (url: string = "", params: Record<string, any> = {}) => {
let result = new URL(url, window.location.href);
Object.entries(params).forEach(([k, v]) => result.searchParams.set(k, v));
return result.toString();
};
const j = (id: string): any | null => {
const elem = document.getElementById(id);
if (elem === null) {
return null;
}
return JSON.parse(elem.textContent);
};
const imageURL = (mood, cacheBuster, basePrefix) =>
u(`${basePrefix}/.within.website/x/cmd/anubis/static/img/${mood}.webp`, {
cacheBuster,
@@ -14,9 +23,10 @@ const imageURL = (mood, cacheBuster, basePrefix) =>
// Detect available languages by loading the manifest
const getAvailableLanguages = async () => {
const basePrefix = JSON.parse(
document.getElementById("anubis_base_prefix").textContent,
);
const basePrefix = j("anubis_base_prefix");
if (basePrefix === null) {
return;
}
try {
const response = await fetch(`${basePrefix}/.within.website/x/cmd/anubis/static/locales/manifest.json`);
@@ -38,9 +48,11 @@ const getBrowserLanguage = async () =>
// Load translations from JSON files
const loadTranslations = async (lang) => {
const basePrefix = JSON.parse(
document.getElementById("anubis_base_prefix").textContent,
);
const basePrefix = j("anubis_base_prefix");
if (basePrefix === null) {
return;
}
try {
const response = await fetch(`${basePrefix}/.within.website/x/cmd/anubis/static/locales/${lang}.json`);
return await response.json();
@@ -54,9 +66,10 @@ const loadTranslations = async (lang) => {
};
const getRedirectUrl = () => {
const publicUrl = JSON.parse(
document.getElementById("anubis_public_url").textContent,
);
const publicUrl = j("anubis_public_url");
if (publicUrl === null) {
return;
}
if (publicUrl && window.location.href.startsWith(publicUrl)) {
const urlParams = new URLSearchParams(window.location.search);
return urlParams.get('redir');
@@ -91,16 +104,14 @@ const t = (key) => translations[`js_${key}`] || translations[key] || key;
value: navigator.cookieEnabled,
},
];
const status = document.getElementById("status");
const image = document.getElementById("image");
const title = document.getElementById("title");
const progress = document.getElementById("progress");
const anubisVersion = JSON.parse(
document.getElementById("anubis_version").textContent,
);
const basePrefix = JSON.parse(
document.getElementById("anubis_base_prefix").textContent,
);
const status: HTMLParagraphElement = document.getElementById("status") as HTMLParagraphElement;
const image: HTMLImageElement = document.getElementById("image") as HTMLImageElement;
const title: HTMLHeadingElement = document.getElementById("title") as HTMLHeadingElement;
const progress: HTMLDivElement = document.getElementById("progress") as HTMLDivElement;
const anubisVersion = j("anubis_version");
const basePrefix = j("anubis_base_prefix");
const details = document.querySelector("details");
let userReadDetails = false;
@@ -132,9 +143,7 @@ const t = (key) => translations[`js_${key}`] || translations[key] || key;
}
}
const { challenge, rules } = JSON.parse(
document.getElementById("anubis_challenge").textContent,
);
const { challenge, rules } = j("anubis_challenge");
const process = algorithms[rules.algorithm];
if (!process) {
@@ -182,7 +191,9 @@ const t = (key) => translations[`js_${key}`] || translations[key] || key;
const probability = Math.pow(1 - likelihood, iters);
const distance = (1 - Math.pow(probability, 2)) * 100;
progress["aria-valuenow"] = distance;
progress.firstElementChild.style.width = `${distance}%`;
if (progress.firstElementChild !== null) {
(progress.firstElementChild as HTMLElement).style.width = `${distance}%`;
}
if (probability < 0.1 && !showingApology) {
status.append(
@@ -197,7 +208,7 @@ const t = (key) => translations[`js_${key}`] || translations[key] || key;
console.log({ hash, nonce });
if (userReadDetails) {
const container = document.getElementById("progress");
const container: HTMLDivElement = document.getElementById("progress") as HTMLDivElement;
// Style progress bar as a continue button
container.style.display = "flex";

View File

@@ -6,7 +6,7 @@ const calculateSHA256 = (text) => {
return hash.digest();
};
function toHexString(arr) {
function toHexString(arr: Uint8Array): string {
return Array.from(arr)
.map((c) => c.toString(16).padStart(2, "0"))
.join("");

View File

@@ -1,10 +1,11 @@
const encoder = new TextEncoder();
const calculateSHA256 = async (input) => {
const calculateSHA256 = async (input: string) => {
const data = encoder.encode(input);
return await crypto.subtle.digest("SHA-256", data);
};
const toHexString = (byteArray) => {
const toHexString = (byteArray: Uint8Array) => {
return byteArray.reduce((str, byte) => str + byte.toString(16).padStart(2, "0"), "");
};