Compare commits

..

3 Commits

Author SHA1 Message Date
Jason Cameron
818a1844d0 Merge branch 'main' of https://github.com/TecharoHQ/anubis into docs/fix-1080 2025-11-16 19:11:01 -05:00
Jason Cameron
31e854865c docs: add note about unsetting PUBLIC_URL in Kubernetes setup 2025-11-16 18:42:45 -05:00
Jason Cameron
2a6678283f docs: clarify usage of PUBLIC_URL and REDIRECT_DOMAINS in installation guide
Closes: 1080
2025-11-16 18:31:29 -05:00
127 changed files with 281 additions and 1658 deletions

View File

@@ -8,5 +8,4 @@ msgbox
xeact
ABee
tencent
maintnotifications
azurediamond
maintnotifications

View File

@@ -106,7 +106,6 @@ externalfetcher
extldflags
facebookgo
Factset
fahedouch
fastcgi
fediverse
ffprobe
@@ -195,14 +194,12 @@ lcj
ldflags
letsencrypt
Lexentale
lfc
lgbt
licend
licstart
lightpanda
limsa
Linting
listor
LLU
loadbalancer
lol
@@ -220,10 +217,6 @@ mnt
Mojeek
mojeekbot
mozilla
myclient
mymaster
mypass
myuser
nbf
nepeat
netsurf
@@ -274,6 +267,7 @@ qwantbot
rac
rawler
rcvar
rdb
redhat
redir
redirectscheme

View File

@@ -22,7 +22,6 @@ jobs:
- git-push
- healthcheck
- i18n
- log-file
- palemoon/amd64
#- palemoon/i386
- robots_txt

View File

@@ -31,8 +31,8 @@ import (
"github.com/TecharoHQ/anubis/data"
"github.com/TecharoHQ/anubis/internal"
libanubis "github.com/TecharoHQ/anubis/lib"
"github.com/TecharoHQ/anubis/lib/config"
botPolicy "github.com/TecharoHQ/anubis/lib/policy"
"github.com/TecharoHQ/anubis/lib/policy/config"
"github.com/TecharoHQ/anubis/lib/thoth"
"github.com/TecharoHQ/anubis/web"
"github.com/facebookgo/flagenv"
@@ -273,11 +273,9 @@ func main() {
return
}
internal.InitSlog(*slogLevel)
internal.SetHealth("anubis", healthv1.HealthCheckResponse_NOT_SERVING)
lg := internal.InitSlog(*slogLevel, os.Stderr)
lg.Info("starting up Anubis")
if *healthcheck {
log.Println("running healthcheck")
if err := doHealthCheck(); err != nil {
@@ -305,7 +303,7 @@ func main() {
if *metricsBind != "" {
wg.Add(1)
go metricsServer(ctx, *lg.With("subsystem", "metrics"), wg.Done)
go metricsServer(ctx, wg.Done)
}
var rp http.Handler
@@ -325,11 +323,11 @@ func main() {
// Thoth configuration
switch {
case *thothURL != "" && *thothToken == "":
lg.Warn("THOTH_URL is set but no THOTH_TOKEN is set")
slog.Warn("THOTH_URL is set but no THOTH_TOKEN is set")
case *thothURL == "" && *thothToken != "":
lg.Warn("THOTH_TOKEN is set but no THOTH_URL is set")
slog.Warn("THOTH_TOKEN is set but no THOTH_URL is set")
case *thothURL != "" && *thothToken != "":
lg.Debug("connecting to Thoth")
slog.Debug("connecting to Thoth")
thothClient, err := thoth.New(ctx, *thothURL, *thothToken, *thothInsecure)
if err != nil {
log.Fatalf("can't dial thoth at %s: %v", *thothURL, err)
@@ -338,19 +336,15 @@ func main() {
ctx = thoth.With(ctx, thothClient)
}
lg.Info("loading policy file", "fname", *policyFname)
policy, err := libanubis.LoadPoliciesOrDefault(ctx, *policyFname, *challengeDifficulty, *slogLevel)
policy, err := libanubis.LoadPoliciesOrDefault(ctx, *policyFname, *challengeDifficulty)
if err != nil {
log.Fatalf("can't parse policy file: %v", err)
}
lg = policy.Logger
lg.Debug("swapped to new logger")
slog.SetDefault(lg)
// Warn if persistent storage is used without a configured signing key
if policy.Store.IsPersistent() {
if *hs512Secret == "" && *ed25519PrivateKeyHex == "" && *ed25519PrivateKeyHexFile == "" {
lg.Warn("[misconfiguration] persistent storage backend is configured, but no private key is set. " +
slog.Warn("[misconfiguration] persistent storage backend is configured, but no private key is set. " +
"Challenges will be invalidated when Anubis restarts. " +
"Set HS512_SECRET, ED25519_PRIVATE_KEY_HEX, or ED25519_PRIVATE_KEY_HEX_FILE to ensure challenges survive service restarts. " +
"See: https://anubis.techaro.lol/docs/admin/installation#key-generation")
@@ -413,7 +407,7 @@ func main() {
log.Fatalf("failed to generate ed25519 key: %v", err)
}
lg.Warn("generating random key, Anubis will have strange behavior when multiple instances are behind the same load balancer target, for more information: see https://anubis.techaro.lol/docs/admin/installation#key-generation")
slog.Warn("generating random key, Anubis will have strange behavior when multiple instances are behind the same load balancer target, for more information: see https://anubis.techaro.lol/docs/admin/installation#key-generation")
}
var redirectDomainsList []string
@@ -427,7 +421,7 @@ func main() {
redirectDomainsList = append(redirectDomainsList, strings.TrimSpace(domain))
}
} else {
lg.Warn("REDIRECT_DOMAINS is not set, Anubis will only redirect to the same domain a request is coming from, see https://anubis.techaro.lol/docs/admin/configuration/redirect-domains")
slog.Warn("REDIRECT_DOMAINS is not set, Anubis will only redirect to the same domain a request is coming from, see https://anubis.techaro.lol/docs/admin/configuration/redirect-domains")
}
anubis.CookieName = *cookiePrefix + "-auth"
@@ -445,30 +439,26 @@ func main() {
}
s, err := libanubis.New(libanubis.Options{
BasePrefix: *basePrefix,
StripBasePrefix: *stripBasePrefix,
Next: rp,
Policy: policy,
TargetHost: *targetHost,
TargetSNI: *targetSNI,
TargetInsecureSkipVerify: *targetInsecureSkipVerify,
ServeRobotsTXT: *robotsTxt,
ED25519PrivateKey: ed25519Priv,
HS512Secret: []byte(*hs512Secret),
CookieDomain: *cookieDomain,
CookieDynamicDomain: *cookieDynamicDomain,
CookieExpiration: *cookieExpiration,
CookiePartitioned: *cookiePartitioned,
RedirectDomains: redirectDomainsList,
Target: *target,
WebmasterEmail: *webmasterEmail,
OpenGraph: policy.OpenGraph,
CookieSecure: *cookieSecure,
CookieSameSite: parseSameSite(*cookieSameSite),
PublicUrl: *publicUrl,
JWTRestrictionHeader: *jwtRestrictionHeader,
Logger: policy.Logger.With("subsystem", "anubis"),
DifficultyInJWT: *difficultyInJWT,
BasePrefix: *basePrefix,
StripBasePrefix: *stripBasePrefix,
Next: rp,
Policy: policy,
ServeRobotsTXT: *robotsTxt,
ED25519PrivateKey: ed25519Priv,
HS512Secret: []byte(*hs512Secret),
CookieDomain: *cookieDomain,
CookieDynamicDomain: *cookieDynamicDomain,
CookieExpiration: *cookieExpiration,
CookiePartitioned: *cookiePartitioned,
RedirectDomains: redirectDomainsList,
Target: *target,
WebmasterEmail: *webmasterEmail,
OpenGraph: policy.OpenGraph,
CookieSecure: *cookieSecure,
CookieSameSite: parseSameSite(*cookieSameSite),
PublicUrl: *publicUrl,
JWTRestrictionHeader: *jwtRestrictionHeader,
DifficultyInJWT: *difficultyInJWT,
})
if err != nil {
log.Fatalf("can't construct libanubis.Server: %v", err)
@@ -484,7 +474,7 @@ func main() {
srv := http.Server{Handler: h, ErrorLog: internal.GetFilteredHTTPLogger()}
listener, listenerUrl := setupListener(*bindNetwork, *bind)
lg.Info(
slog.Info(
"listening",
"url", listenerUrl,
"difficulty", *challengeDifficulty,
@@ -518,7 +508,7 @@ func main() {
wg.Wait()
}
func metricsServer(ctx context.Context, lg slog.Logger, done func()) {
func metricsServer(ctx context.Context, done func()) {
defer done()
mux := http.NewServeMux()
@@ -544,7 +534,7 @@ func metricsServer(ctx context.Context, lg slog.Logger, done func()) {
srv := http.Server{Handler: mux, ErrorLog: internal.GetFilteredHTTPLogger()}
listener, metricsUrl := setupListener(*metricsBindNetwork, *metricsBind)
lg.Debug("listening for metrics", "url", metricsUrl)
slog.Debug("listening for metrics", "url", metricsUrl)
go func() {
<-ctx.Done()

View File

@@ -28,7 +28,7 @@ func main() {
flagenv.Parse()
flag.Parse()
slog.SetDefault(internal.InitSlog(*slogLevel, os.Stderr))
internal.InitSlog(*slogLevel)
koDockerRepo := strings.TrimSuffix(*dockerRepo, "/"+filepath.Base(*dockerRepo))

View File

@@ -12,7 +12,7 @@ import (
"regexp"
"strings"
"github.com/TecharoHQ/anubis/lib/config"
"github.com/TecharoHQ/anubis/lib/policy/config"
"sigs.k8s.io/yaml"
)

View File

@@ -21,29 +21,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Allow Renovate as an OCI registry client.
- Properly handle 4in6 addresses so that IP matching works with those addresses.
- Add support to simple Valkey/Redis cluster mode
- Open Graph passthrough now reuses the configured target Host/SNI/TLS settings, so metadata fetches succeed when the upstream certificate differs from the public domain. ([1283](https://github.com/TecharoHQ/anubis/pull/1283))
- Stabilize the CVE-2025-24369 regression test by always submitting an invalid proof instead of relying on random POW failures.
### Logging customization
Anubis now supports the ability to log to multiple backends ("sinks"). This allows you to have Anubis [log to a file](./admin/policies.mdx#file-sink) instead of just logging to standard out. You can also customize the [logging level](./admin/policies.mdx#log-levels) in the policy file:
```yaml
logging:
level: "warn" # much less verbose logging
sink: file # log to a file
parameters:
file: "./var/anubis.log"
maxBackups: 3 # keep at least 3 old copies
maxBytes: 67108864 # each file can have up to 64 Mi of logs
maxAge: 7 # rotate files out every n days
oldFileTimeFormat: 2006-01-02T15-04-05 # RFC 3339-ish
compress: true # gzip-compress old log files
useLocalTime: false # timezone for rotated files is UTC
```
Additionally, information about [how Anubis uses each logging level](./admin/policies.mdx#log-levels) has been added to the documentation.
## v1.23.1: Lyse Hext - Echo 1
- Fix `SERVE_ROBOTS_TXT` setting after the double slash fix broke it.

View File

@@ -225,10 +225,10 @@ Using this backend will cause a lot of S3 operations, at least one for creating
The `s3api` backend takes the following configuration options:
| Name | Type | Example | Description |
| :----------- | :------ | :------------ | :------------------------------------------------------------------------------------------------------------------------------------------ |
| `bucketName` | string | `anubis-data` | (Required) The name of the dedicated bucket for Anubis to store information in. |
| `pathStyle` | boolean | `false` | If true, use path-style S3 API operations. Please consult your storage provider's documentation if you don't know what you should put here. |
| Name | Type | Example | Description |
| :----------- | :------ | :------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------ |
| `bucketName` | string | The name of the dedicated bucket for Anubis to store information in. |
| `pathStyle` | boolean | `false` | If true, use path-style S3 API operations. Please consult your storage provider's documentation if you don't know what you should put here. |
:::note
@@ -279,7 +279,7 @@ store:
:::note
You can also use [Redis](http://redis.io/) with Anubis.
You can also use [Redis](http://redis.io/) with Anubis.
:::
@@ -291,17 +291,15 @@ This backend is ideal if you are running multiple instances of Anubis in a worke
| Does your service get a lot of traffic? | ✅ Yes |
| Do you want to store data persistently when Anubis restarts? | ✅ Yes |
| Do you run Anubis without mutable filesystem storage? | ✅ Yes |
| Do you have Redis or Valkey installed? | ✅ Yes |
| Do you have Redis or Valkey installed? | ✅ Yes |
#### Configuration
The `valkey` backend takes the following configuration options:
| Name | Type | Example | Description |
| :--------- | :----- | :---------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------ |
| `cluster` | bool | `false` | If true, use [Redis™ Clustering](https://redis.io/topics/cluster-spec) for storing Anubis data. |
| `sentinel` | object | `{}` | See [Redis™ Sentinel docs](#redis-sentinel) for more detail and examples |
| `url` | string | `redis://valkey:6379/0` | The URL for the instance of Redis™ or Valkey that Anubis should store data in. This is in the same format as `REDIS_URL` in many cloud providers. |
| Name | Type | Example | Description |
| :---- | :----- | :---------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------- |
| `url` | string | `redis://valkey:6379/0` | The URL for the instance of Redis or Valkey that Anubis should store data in. This is in the same format as `REDIS_URL` in many cloud providers. |
Example:
@@ -316,96 +314,6 @@ store:
This would have the Valkey client connect to host `valkey.int.techaro.lol` on port `6379` with database `0` (the default database).
#### Redis™ Sentinel
If you are using [Redis™ Sentinel](https://redis.io/docs/latest/operate/oss_and_stack/management/sentinel/) for a high availability setup, you need to configure the `sentinel` object. This object takes the following configuration options:
| Name | Type | Example | Description |
| :----------- | :----------------------- | :-------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `addr` | string or list of string | `10.43.208.130:26379` | (Required) The host and port of the Redis™ Sentinel server. When possible, use DNS names for this. If you have multiple addresses, supply a list of them. |
| `clientName` | string | `Anubis` | The client name reported to Redis™ Sentinel. Set this if you want to track Anubis connections to your Redis™ Sentinel. |
| `masterName` | string | `mymaster` | (Required) The name of the master in the Redis™ Sentinel configuration. This is used to discover where to find client connection hosts/ports. |
| `username` | string | `azurediamond` | The username used to authenticate against the Redis™ Sentinel and Redis™ servers. |
| `password` | string | `hunter2` | The password used to authenticate against the Redis™ Sentinel and Redis™ servers. |
## Logging management
Anubis has very verbose logging out of the box. This is intentional and allows administrators to be sure that it is working merely by watching it work in real time. Some administrators may not appreciate this level of logging out of the box. As such, Anubis lets you customize details about how it logs data.
Anubis uses a practice called [structured logging](https://stackify.com/what-is-structured-logging-and-why-developers-need-it/) to emit log messages with key-value pair context. In order to make analyzing large amounts of log messages easier, Anubis encodes all logs in JSON. This allows you to use any tool that can parse JSON to perform analytics or monitor for issues.
Anubis exposes the following logging settings in the policy file:
| Name | Type | Example | Description |
| :----------- | :----------------------- | :-------------- | :--------------------------------------------------------------------------------------------------------------------------------------- |
| `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. |
Anubis supports the following logging sinks:
1. `file`: logs are emitted to a file that is rotated based on size and age. Old log files are compressed with gzip to save space. This allows for better integration with users that decide to use legacy service managers (OpenRC, FreeBSD's init, etc).
2. `stdio`: logs are emitted to the standard error stream of the Anubis process. This allows runtimes such as Docker, Podman, Systemd, and Kubernetes to capture logs with their native logging subsystems without any additional configuration.
### Log levels
Anubis uses Go's [standard library `log/slog` package](https://pkg.go.dev/log/slog) to emit structured logs. By default, Anubis logs at the [Info level](https://pkg.go.dev/log/slog#Level), which is fairly verbose out of the box. Here are the possible logging levels in Anubis:
| Log level | Use in Anubis |
| :-------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `DEBUG` | The raw unfiltered torrent of doom. Only use this if you are actively working on Anubis or have very good reasons to use it. |
| `INFO` | The default logging level, fairly verbose in order to make it easier for automation to parse. |
| `WARN` | A "more silent" logging level. Much less verbose. Some things that are now at the `info` level need to be moved up to the `warn` level in future patches. |
| `ERROR` | Only log error messages. |
Additionally, you can set a "slightly higher" log level if you need to, such as:
```yaml
logging:
sink: stdio
level: "INFO+1"
```
This isn't currently used by Anubis, but will be in the future for "slightly important" information.
### `file` sink
The `file` sink makes Anubis write its logs to the filesystem and rotate them out when the log file meets certain thresholds. This logging sink takes the following parameters:
| Name | Type | Example | Description |
| :------------- | :-------------- | :-------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `file` | string | `/var/log/anubis.log` | The file where Anubis logs should be written to. Make sure the user Anubis is running as has write and file creation permissions to this directory. |
| `maxBackups` | number | `3` | The number of old log files that should be maintained when log files are rotated out. |
| `maxBytes` | number of bytes | `67108864` (64Mi) | The maximum size of each log file before it is rotated out. |
| `maxAge` | number of days | `7` | If a log file is more than this many days old, rotate it out. |
| `compress` | boolean | `true` | If true, compress old log files with gzip. This should be set to `true` and is only exposed as an option for dealing with legacy workflows where there is magical thinking about log files at play. |
| `useLocalTime` | boolean | `false` | If true, use the system local time zone to create log filenames instead of UTC. This should almost always be set to `false` and is only exposed for legacy workflows where there is magical thinking about time zones at play. |
```yaml
logging:
sink: file
parameters:
file: "./var/anubis.log"
maxBackups: 3 # keep at least 3 old copies
maxBytes: 67108864 # each file can have up to 64 Mi of logs
maxAge: 7 # rotate files out every n days
compress: true # gzip-compress old log files
useLocalTime: false # timezone for rotated files is UTC
```
When files are rotated out, the old files will be named after the rotation timestamp in [RFC 3339 format](https://www.rfc-editor.org/rfc/rfc3339).
### `stdio` sink
By default, Anubis logs everything to the standard error stream of its process. This requires no configuration:
```yaml
logging:
sink: stdio
```
If you use a service orchestration platform that does not capture the standard error stream of processes, you need to use a different logging sink.
## Risk calculation for downstream services
In case your service needs it for risk calculation reasons, Anubis exposes information about the rules that any requests match using a few headers:

2
go.mod
View File

@@ -10,7 +10,6 @@ require (
github.com/aws/aws-sdk-go-v2/service/s3 v1.90.2
github.com/cespare/xxhash/v2 v2.3.0
github.com/facebookgo/flagenv v0.0.0-20160425205200-fcd59fca7456
github.com/fahedouch/go-logrotate v0.3.0
github.com/gaissmai/bart v0.26.0
github.com/golang-jwt/jwt/v5 v5.3.0
github.com/google/cel-go v0.26.1
@@ -87,7 +86,6 @@ require (
github.com/deckarep/golang-set/v2 v2.8.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/djherbis/times v1.6.0 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/docker/docker v28.5.1+incompatible // indirect
github.com/docker/go-connections v0.6.0 // indirect

5
go.sum
View File

@@ -139,8 +139,6 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c=
github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/docker/docker v28.5.1+incompatible h1:Bm8DchhSD2J6PsFzxC35TZo4TLGR2PdW/E69rU45NhM=
@@ -165,8 +163,6 @@ github.com/facebookgo/stack v0.0.0-20160209184415-751773369052 h1:JWuenKqqX8nojt
github.com/facebookgo/stack v0.0.0-20160209184415-751773369052/go.mod h1:UbMTZqLaRiH3MsBH8va0n7s1pQYcu3uTb8G4tygF4Zg=
github.com/facebookgo/subset v0.0.0-20150612182917-8dac2c3c4870 h1:E2s37DuLxFhQDg5gKsWoLBOB0n+ZW8s599zru8FJ2/Y=
github.com/facebookgo/subset v0.0.0-20150612182917-8dac2c3c4870/go.mod h1:5tD+neXqOorC30/tWg0LCSkrqj/AR6gu8yY8/fpw1q0=
github.com/fahedouch/go-logrotate v0.3.0 h1:XP+dHIDgWZ1ckz43mG6gl5ASer3PZDVr755SVMyzaUQ=
github.com/fahedouch/go-logrotate v0.3.0/go.mod h1:X49m0bvPLkk71MHNCQ1yEfVEw8W/u+qvHa/hOnhCYf4=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
@@ -483,7 +479,6 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=

View File

@@ -1,39 +0,0 @@
package internal
import (
"encoding/json"
)
// ListOr[T any] is a slice that can contain either a single T or multiple T values.
// During JSON unmarshaling, it checks if the first character is '[' to determine
// whether to treat the JSON as an array or a single value.
type ListOr[T any] []T
func (lo *ListOr[T]) UnmarshalJSON(data []byte) error {
if len(data) == 0 {
return nil
}
// Check if first non-whitespace character is '['
firstChar := data[0]
for i := 0; i < len(data); i++ {
if data[i] != ' ' && data[i] != '\t' && data[i] != '\n' && data[i] != '\r' {
firstChar = data[i]
break
}
}
if firstChar == '[' {
// It's an array, unmarshal directly
return json.Unmarshal(data, (*[]T)(lo))
} else {
// It's a single value, unmarshal as a single item in a slice
var single T
if err := json.Unmarshal(data, &single); err != nil {
return err
}
*lo = ListOr[T]{single}
}
return nil
}

View File

@@ -1,79 +0,0 @@
package internal
import (
"encoding/json"
"testing"
)
func TestListOr_UnmarshalJSON(t *testing.T) {
t.Run("single value should be unmarshaled as single item", func(t *testing.T) {
var lo ListOr[string]
err := json.Unmarshal([]byte(`"hello"`), &lo)
if err != nil {
t.Fatalf("Failed to unmarshal single string: %v", err)
}
if len(lo) != 1 {
t.Fatalf("Expected 1 item, got %d", len(lo))
}
if lo[0] != "hello" {
t.Errorf("Expected 'hello', got %q", lo[0])
}
})
t.Run("array should be unmarshaled as multiple items", func(t *testing.T) {
var lo ListOr[string]
err := json.Unmarshal([]byte(`["hello", "world"]`), &lo)
if err != nil {
t.Fatalf("Failed to unmarshal array: %v", err)
}
if len(lo) != 2 {
t.Fatalf("Expected 2 items, got %d", len(lo))
}
if lo[0] != "hello" {
t.Errorf("Expected 'hello', got %q", lo[0])
}
if lo[1] != "world" {
t.Errorf("Expected 'world', got %q", lo[1])
}
})
t.Run("single number should be unmarshaled as single item", func(t *testing.T) {
var lo ListOr[int]
err := json.Unmarshal([]byte(`42`), &lo)
if err != nil {
t.Fatalf("Failed to unmarshal single number: %v", err)
}
if len(lo) != 1 {
t.Fatalf("Expected 1 item, got %d", len(lo))
}
if lo[0] != 42 {
t.Errorf("Expected 42, got %d", lo[0])
}
})
t.Run("array of numbers should be unmarshaled as multiple items", func(t *testing.T) {
var lo ListOr[int]
err := json.Unmarshal([]byte(`[1, 2, 3]`), &lo)
if err != nil {
t.Fatalf("Failed to unmarshal number array: %v", err)
}
if len(lo) != 3 {
t.Fatalf("Expected 3 items, got %d", len(lo))
}
if lo[0] != 1 || lo[1] != 2 || lo[2] != 3 {
t.Errorf("Expected [1, 2, 3], got %v", lo)
}
})
}

View File

@@ -2,7 +2,6 @@ package internal
import (
"fmt"
"io"
"log"
"log/slog"
"net/http"
@@ -10,7 +9,7 @@ import (
"strings"
)
func InitSlog(level string, sink io.Writer) *slog.Logger {
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)
@@ -20,12 +19,11 @@ func InitSlog(level string, sink io.Writer) *slog.Logger {
leveler := &slog.LevelVar{}
leveler.Set(programLevel)
h := slog.NewJSONHandler(sink, &slog.HandlerOptions{
h := slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{
AddSource: true,
Level: leveler,
})
result := slog.New(h)
return result
slog.SetDefault(slog.New(h))
}
func GetRequestLogger(base *slog.Logger, r *http.Request) *slog.Logger {
@@ -41,7 +39,8 @@ func GetRequestLogger(base *slog.Logger, r *http.Request) *slog.Logger {
"user_agent", r.UserAgent(),
"accept_language", r.Header.Get("Accept-Language"),
"priority", r.Header.Get("Priority"),
"x-forwarded-for", r.Header.Get("X-Forwarded-For"),
"x-forwarded-for",
r.Header.Get("X-Forwarded-For"),
"x-real-ip", r.Header.Get("X-Real-Ip"),
)
}

View File

@@ -9,7 +9,7 @@ import (
"testing"
"time"
"github.com/TecharoHQ/anubis/lib/config"
"github.com/TecharoHQ/anubis/lib/policy/config"
"github.com/TecharoHQ/anubis/lib/store"
"github.com/TecharoHQ/anubis/lib/store/memory"
)
@@ -24,7 +24,7 @@ func TestCacheReturnsDefault(t *testing.T) {
TimeToLive: time.Minute,
ConsiderHost: false,
Override: want,
}, memory.New(t.Context()), TargetOptions{})
}, memory.New(t.Context()))
u, err := url.Parse("https://anubis.techaro.lol")
if err != nil {
@@ -52,7 +52,7 @@ func TestCheckCache(t *testing.T) {
Enabled: true,
TimeToLive: time.Minute,
ConsiderHost: false,
}, memory.New(t.Context()), TargetOptions{})
}, memory.New(t.Context()))
// Set up test data
urlStr := "http://example.com/page"
@@ -115,7 +115,7 @@ func TestGetOGTags(t *testing.T) {
Enabled: true,
TimeToLive: time.Minute,
ConsiderHost: false,
}, memory.New(t.Context()), TargetOptions{})
}, memory.New(t.Context()))
// Parse the test server URL
parsedURL, err := url.Parse(ts.URL)
@@ -271,7 +271,7 @@ func TestGetOGTagsWithHostConsideration(t *testing.T) {
Enabled: true,
TimeToLive: time.Minute,
ConsiderHost: tc.ogCacheConsiderHost,
}, memory.New(t.Context()), TargetOptions{})
}, memory.New(t.Context()))
for i, req := range tc.requests {
ogTags, err := cache.GetOGTags(t.Context(), parsedURL, req.host)

View File

@@ -27,29 +27,16 @@ func (c *OGTagCache) fetchHTMLDocumentWithCache(ctx context.Context, urlStr stri
}
// Set the Host header to the original host
var hostForRequest string
switch {
case c.targetHost != "":
hostForRequest = c.targetHost
case originalHost != "":
hostForRequest = originalHost
}
if hostForRequest != "" {
req.Host = hostForRequest
if originalHost != "" {
req.Host = originalHost
}
// Add proxy headers
req.Header.Set("X-Forwarded-Proto", "https")
req.Header.Set("User-Agent", "Anubis-OGTag-Fetcher/1.0") // For tracking purposes
serverName := hostForRequest
if serverName == "" {
serverName = req.URL.Hostname()
}
client := c.clientForSNI(serverName)
// Send the request
resp, err := client.Do(req)
resp, err := c.client.Do(req)
if err != nil {
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {

View File

@@ -11,7 +11,7 @@ import (
"testing"
"time"
"github.com/TecharoHQ/anubis/lib/config"
"github.com/TecharoHQ/anubis/lib/policy/config"
"github.com/TecharoHQ/anubis/lib/store/memory"
"golang.org/x/net/html"
)
@@ -87,7 +87,7 @@ func TestFetchHTMLDocument(t *testing.T) {
Enabled: true,
TimeToLive: time.Minute,
ConsiderHost: false,
}, memory.New(t.Context()), TargetOptions{})
}, memory.New(t.Context()))
doc, err := cache.fetchHTMLDocument(t.Context(), ts.URL, "anything")
if tt.expectError {
@@ -118,7 +118,7 @@ func TestFetchHTMLDocumentInvalidURL(t *testing.T) {
Enabled: true,
TimeToLive: time.Minute,
ConsiderHost: false,
}, memory.New(t.Context()), TargetOptions{})
}, memory.New(t.Context()))
doc, err := cache.fetchHTMLDocument(t.Context(), "http://invalid.url.that.doesnt.exist.example", "anything")

View File

@@ -7,7 +7,7 @@ import (
"testing"
"time"
"github.com/TecharoHQ/anubis/lib/config"
"github.com/TecharoHQ/anubis/lib/policy/config"
"github.com/TecharoHQ/anubis/lib/store/memory"
)
@@ -111,7 +111,7 @@ func TestIntegrationGetOGTags(t *testing.T) {
Enabled: true,
TimeToLive: time.Minute,
ConsiderHost: false,
}, memory.New(t.Context()), TargetOptions{})
}, memory.New(t.Context()))
// Create URL for test
testURL, _ := url.Parse(ts.URL)

View File

@@ -6,7 +6,7 @@ import (
"strings"
"testing"
"github.com/TecharoHQ/anubis/lib/config"
"github.com/TecharoHQ/anubis/lib/policy/config"
"github.com/TecharoHQ/anubis/lib/store/memory"
"golang.org/x/net/html"
)
@@ -31,7 +31,7 @@ func BenchmarkGetTarget(b *testing.B) {
for _, tt := range tests {
b.Run(tt.name, func(b *testing.B) {
cache := NewOGTagCache(tt.target, config.OpenGraph{}, memory.New(b.Context()), TargetOptions{})
cache := NewOGTagCache(tt.target, config.OpenGraph{}, memory.New(b.Context()))
urls := make([]*url.URL, len(tt.paths))
for i, path := range tt.paths {
u, _ := url.Parse(path)
@@ -67,7 +67,7 @@ func BenchmarkExtractOGTags(b *testing.B) {
</head><body><div><p>Content</p></div></body></html>`,
}
cache := NewOGTagCache("http://example.com", config.OpenGraph{}, memory.New(b.Context()), TargetOptions{})
cache := NewOGTagCache("http://example.com", config.OpenGraph{}, memory.New(b.Context()))
docs := make([]*html.Node, len(htmlSamples))
for i, sample := range htmlSamples {
@@ -85,7 +85,7 @@ func BenchmarkExtractOGTags(b *testing.B) {
// Memory usage test
func TestMemoryUsage(t *testing.T) {
cache := NewOGTagCache("http://example.com", config.OpenGraph{}, memory.New(t.Context()), TargetOptions{})
cache := NewOGTagCache("http://example.com", config.OpenGraph{}, memory.New(t.Context()))
// Force GC and wait for it to complete
runtime.GC()

View File

@@ -2,16 +2,14 @@ package ogtags
import (
"context"
"crypto/tls"
"log/slog"
"net"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/TecharoHQ/anubis/lib/config"
"github.com/TecharoHQ/anubis/lib/policy/config"
"github.com/TecharoHQ/anubis/lib/store"
)
@@ -24,34 +22,21 @@ const (
)
type OGTagCache struct {
ogOverride map[string]string
targetURL *url.URL
client *http.Client
transport *http.Transport
ogOverride map[string]string
cache store.JSON[map[string]string]
// Pre-built strings for optimization
unixPrefix string // "http://unix"
targetSNI string
targetHost string
approvedPrefixes []string
approvedTags []string
approvedPrefixes []string
ogTimeToLive time.Duration
ogPassthrough bool
ogCacheConsiderHost bool
targetSNIAuto bool
insecureSkipVerify bool
sniClients map[string]*http.Client
transportMu sync.RWMutex
ogPassthrough bool
}
type TargetOptions struct {
Host string
SNI string
InsecureSkipVerify bool
}
func NewOGTagCache(target string, conf config.OpenGraph, backend store.Interface, targetOpts TargetOptions) *OGTagCache {
func NewOGTagCache(target string, conf config.OpenGraph, backend store.Interface) *OGTagCache {
// Predefined approved tags and prefixes
defaultApprovedTags := []string{"description", "keywords", "author"}
defaultApprovedPrefixes := []string{"og:", "twitter:", "fediverse:"}
@@ -77,37 +62,20 @@ func NewOGTagCache(target string, conf config.OpenGraph, backend store.Interface
}
}
transport := http.DefaultTransport.(*http.Transport).Clone()
client := &http.Client{
Timeout: httpTimeout,
}
// Configure custom transport for Unix sockets
if parsedTargetURL.Scheme == "unix" {
socketPath := parsedTargetURL.Path // For unix scheme, path is the socket path
transport.DialContext = func(_ context.Context, _, _ string) (net.Conn, error) {
return net.Dial("unix", socketPath)
client.Transport = &http.Transport{
DialContext: func(_ context.Context, _, _ string) (net.Conn, error) {
return net.Dial("unix", socketPath)
},
}
}
targetSNIAuto := targetOpts.SNI == "auto"
if targetOpts.SNI != "" && !targetSNIAuto {
if transport.TLSClientConfig == nil {
transport.TLSClientConfig = &tls.Config{}
}
transport.TLSClientConfig.ServerName = targetOpts.SNI
}
if targetOpts.InsecureSkipVerify {
if transport.TLSClientConfig == nil {
transport.TLSClientConfig = &tls.Config{}
}
transport.TLSClientConfig.InsecureSkipVerify = true
}
client := &http.Client{
Timeout: httpTimeout,
Transport: transport,
}
return &OGTagCache{
cache: store.JSON[map[string]string]{
Underlying: backend,
@@ -121,13 +89,7 @@ func NewOGTagCache(target string, conf config.OpenGraph, backend store.Interface
approvedTags: defaultApprovedTags,
approvedPrefixes: defaultApprovedPrefixes,
client: client,
transport: transport,
unixPrefix: "http://unix",
targetHost: targetOpts.Host,
targetSNI: targetOpts.SNI,
targetSNIAuto: targetSNIAuto,
insecureSkipVerify: targetOpts.InsecureSkipVerify,
sniClients: make(map[string]*http.Client),
}
}

View File

@@ -7,7 +7,7 @@ import (
"testing"
"unicode/utf8"
"github.com/TecharoHQ/anubis/lib/config"
"github.com/TecharoHQ/anubis/lib/policy/config"
"github.com/TecharoHQ/anubis/lib/store/memory"
"golang.org/x/net/html"
)
@@ -48,7 +48,7 @@ func FuzzGetTarget(f *testing.F) {
}
// Create cache - should not panic
cache := NewOGTagCache(target, config.OpenGraph{}, memory.New(context.Background()), TargetOptions{})
cache := NewOGTagCache(target, config.OpenGraph{}, memory.New(context.Background()))
// Create URL
u := &url.URL{
@@ -132,7 +132,7 @@ func FuzzExtractOGTags(f *testing.F) {
return
}
cache := NewOGTagCache("http://example.com", config.OpenGraph{}, memory.New(context.Background()), TargetOptions{})
cache := NewOGTagCache("http://example.com", config.OpenGraph{}, memory.New(context.Background()))
// Should not panic
tags := cache.extractOGTags(doc)
@@ -188,7 +188,7 @@ func FuzzGetTargetRoundTrip(f *testing.F) {
t.Skip()
}
cache := NewOGTagCache(target, config.OpenGraph{}, memory.New(context.Background()), TargetOptions{})
cache := NewOGTagCache(target, config.OpenGraph{}, memory.New(context.Background()))
u := &url.URL{Path: path, RawQuery: query}
result := cache.getTarget(u)
@@ -245,7 +245,7 @@ func FuzzExtractMetaTagInfo(f *testing.F) {
},
}
cache := NewOGTagCache("http://example.com", config.OpenGraph{}, memory.New(context.Background()), TargetOptions{})
cache := NewOGTagCache("http://example.com", config.OpenGraph{}, memory.New(context.Background()))
// Should not panic
property, content := cache.extractMetaTagInfo(node)
@@ -298,7 +298,7 @@ func BenchmarkFuzzedGetTarget(b *testing.B) {
for _, input := range inputs {
b.Run(input.name, func(b *testing.B) {
cache := NewOGTagCache(input.target, config.OpenGraph{}, memory.New(context.Background()), TargetOptions{})
cache := NewOGTagCache(input.target, config.OpenGraph{}, memory.New(context.Background()))
u := &url.URL{Path: input.path, RawQuery: input.query}
b.ResetTimer()

View File

@@ -2,27 +2,19 @@ package ogtags
import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"errors"
"fmt"
"math/big"
"net"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"reflect"
"strings"
"sync"
"testing"
"time"
"github.com/TecharoHQ/anubis/lib/config"
"github.com/TecharoHQ/anubis/lib/policy/config"
"github.com/TecharoHQ/anubis/lib/store/memory"
)
@@ -53,7 +45,7 @@ func TestNewOGTagCache(t *testing.T) {
Enabled: tt.ogPassthrough,
TimeToLive: tt.ogTimeToLive,
ConsiderHost: false,
}, memory.New(t.Context()), TargetOptions{})
}, memory.New(t.Context()))
if cache == nil {
t.Fatal("expected non-nil cache, got nil")
@@ -93,7 +85,7 @@ func TestNewOGTagCache_UnixSocket(t *testing.T) {
Enabled: true,
TimeToLive: 5 * time.Minute,
ConsiderHost: false,
}, memory.New(t.Context()), TargetOptions{})
}, memory.New(t.Context()))
if cache == nil {
t.Fatal("expected non-nil cache, got nil")
@@ -178,7 +170,7 @@ func TestGetTarget(t *testing.T) {
Enabled: true,
TimeToLive: time.Minute,
ConsiderHost: false,
}, memory.New(t.Context()), TargetOptions{})
}, memory.New(t.Context()))
u := &url.URL{
Path: tt.path,
@@ -251,7 +243,7 @@ func TestIntegrationGetOGTags_UnixSocket(t *testing.T) {
Enabled: true,
TimeToLive: time.Minute,
ConsiderHost: false,
}, memory.New(t.Context()), TargetOptions{})
}, memory.New(t.Context()))
// Create a dummy URL for the request (path and query matter)
testReqURL, _ := url.Parse("/some/page?query=1")
@@ -282,244 +274,3 @@ func TestIntegrationGetOGTags_UnixSocket(t *testing.T) {
t.Errorf("Expected cached OG tags %v, got %v", expectedTags, cachedTags)
}
}
func TestGetOGTagsWithTargetHostOverride(t *testing.T) {
originalHost := "example.test"
overrideHost := "backend.internal"
seenHosts := make(chan string, 10)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seenHosts <- r.Host
w.Header().Set("Content-Type", "text/html")
fmt.Fprintln(w, `<!DOCTYPE html><html><head><meta property="og:title" content="HostOverride" /></head><body>ok</body></html>`)
}))
defer ts.Close()
targetURL, err := url.Parse(ts.URL)
if err != nil {
t.Fatalf("failed to parse server URL: %v", err)
}
conf := config.OpenGraph{
Enabled: true,
TimeToLive: time.Minute,
ConsiderHost: false,
}
t.Run("default host uses original", func(t *testing.T) {
cache := NewOGTagCache(ts.URL, conf, memory.New(t.Context()), TargetOptions{})
if _, err := cache.GetOGTags(t.Context(), targetURL, originalHost); err != nil {
t.Fatalf("GetOGTags failed: %v", err)
}
select {
case host := <-seenHosts:
if host != originalHost {
t.Fatalf("expected host %q, got %q", originalHost, host)
}
case <-time.After(time.Second):
t.Fatal("server did not receive request")
}
})
t.Run("override host respected", func(t *testing.T) {
cache := NewOGTagCache(ts.URL, conf, memory.New(t.Context()), TargetOptions{
Host: overrideHost,
})
if _, err := cache.GetOGTags(t.Context(), targetURL, originalHost); err != nil {
t.Fatalf("GetOGTags failed: %v", err)
}
select {
case host := <-seenHosts:
if host != overrideHost {
t.Fatalf("expected host %q, got %q", overrideHost, host)
}
case <-time.After(time.Second):
t.Fatal("server did not receive request")
}
})
}
func TestGetOGTagsWithInsecureSkipVerify(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
fmt.Fprintln(w, `<!DOCTYPE html><html><head><meta property="og:title" content="Self-Signed" /></head><body>hello</body></html>`)
})
ts := httptest.NewTLSServer(handler)
defer ts.Close()
parsedURL, err := url.Parse(ts.URL)
if err != nil {
t.Fatalf("failed to parse server URL: %v", err)
}
conf := config.OpenGraph{
Enabled: true,
TimeToLive: time.Minute,
ConsiderHost: false,
}
// Without skip verify we should get a TLS error
cacheStrict := NewOGTagCache(ts.URL, conf, memory.New(t.Context()), TargetOptions{})
if _, err := cacheStrict.GetOGTags(t.Context(), parsedURL, parsedURL.Host); err == nil {
t.Fatal("expected TLS verification error without InsecureSkipVerify")
}
cacheSkip := NewOGTagCache(ts.URL, conf, memory.New(t.Context()), TargetOptions{
InsecureSkipVerify: true,
})
tags, err := cacheSkip.GetOGTags(t.Context(), parsedURL, parsedURL.Host)
if err != nil {
t.Fatalf("expected successful fetch with InsecureSkipVerify, got: %v", err)
}
if tags["og:title"] != "Self-Signed" {
t.Fatalf("expected og:title to be %q, got %q", "Self-Signed", tags["og:title"])
}
}
func TestGetOGTagsWithTargetSNI(t *testing.T) {
originalHost := "hecate.test"
conf := config.OpenGraph{
Enabled: true,
TimeToLive: time.Minute,
ConsiderHost: false,
}
t.Run("explicit SNI override", func(t *testing.T) {
expectedSNI := "backend.internal"
ts, recorder := newSNIServer(t, `<!DOCTYPE html><html><head><meta property="og:title" content="SNI Works" /></head><body>ok</body></html>`)
defer ts.Close()
targetURL, err := url.Parse(ts.URL)
if err != nil {
t.Fatalf("failed to parse server URL: %v", err)
}
cacheExplicit := NewOGTagCache(ts.URL, conf, memory.New(t.Context()), TargetOptions{
SNI: expectedSNI,
InsecureSkipVerify: true,
})
if _, err := cacheExplicit.GetOGTags(t.Context(), targetURL, originalHost); err != nil {
t.Fatalf("expected successful fetch with explicit SNI, got: %v", err)
}
if got := recorder.last(); got != expectedSNI {
t.Fatalf("expected server to see SNI %q, got %q", expectedSNI, got)
}
})
t.Run("auto SNI uses original host", func(t *testing.T) {
ts, recorder := newSNIServer(t, `<!DOCTYPE html><html><head><meta property="og:title" content="SNI Auto" /></head><body>ok</body></html>`)
defer ts.Close()
targetURL, err := url.Parse(ts.URL)
if err != nil {
t.Fatalf("failed to parse server URL: %v", err)
}
cacheAuto := NewOGTagCache(ts.URL, conf, memory.New(t.Context()), TargetOptions{
SNI: "auto",
InsecureSkipVerify: true,
})
if _, err := cacheAuto.GetOGTags(t.Context(), targetURL, originalHost); err != nil {
t.Fatalf("expected successful fetch with auto SNI, got: %v", err)
}
if got := recorder.last(); got != originalHost {
t.Fatalf("expected server to see SNI %q with auto, got %q", originalHost, got)
}
})
t.Run("default SNI uses backend host", func(t *testing.T) {
ts, recorder := newSNIServer(t, `<!DOCTYPE html><html><head><meta property="og:title" content="SNI Default" /></head><body>ok</body></html>`)
defer ts.Close()
targetURL, err := url.Parse(ts.URL)
if err != nil {
t.Fatalf("failed to parse server URL: %v", err)
}
cacheDefault := NewOGTagCache(ts.URL, conf, memory.New(t.Context()), TargetOptions{
InsecureSkipVerify: true,
})
if _, err := cacheDefault.GetOGTags(t.Context(), targetURL, originalHost); err != nil {
t.Fatalf("expected successful fetch without explicit SNI, got: %v", err)
}
wantSNI := ""
if net.ParseIP(targetURL.Hostname()) == nil {
wantSNI = targetURL.Hostname()
}
if got := recorder.last(); got != wantSNI {
t.Fatalf("expected default SNI %q, got %q", wantSNI, got)
}
})
}
func newSNIServer(t *testing.T, body string) (*httptest.Server, *sniRecorder) {
t.Helper()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
fmt.Fprint(w, body)
})
recorder := &sniRecorder{}
ts := httptest.NewUnstartedServer(handler)
cert := mustCertificateForHost(t, "sni.test")
ts.TLS = &tls.Config{
GetCertificate: func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
recorder.record(hello.ServerName)
return &cert, nil
},
}
ts.StartTLS()
return ts, recorder
}
func mustCertificateForHost(t *testing.T, host string) tls.Certificate {
t.Helper()
priv, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("failed to generate key: %v", err)
}
template := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{
CommonName: host,
},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
BasicConstraintsValid: true,
DNSNames: []string{host},
}
der, err := x509.CreateCertificate(rand.Reader, template, template, &priv.PublicKey, priv)
if err != nil {
t.Fatalf("failed to create certificate: %v", err)
}
return tls.Certificate{
Certificate: [][]byte{der},
PrivateKey: priv,
}
}
type sniRecorder struct {
mu sync.Mutex
names []string
}
func (r *sniRecorder) record(name string) {
r.mu.Lock()
defer r.mu.Unlock()
r.names = append(r.names, name)
}
func (r *sniRecorder) last() string {
r.mu.Lock()
defer r.mu.Unlock()
if len(r.names) == 0 {
return ""
}
return r.names[len(r.names)-1]
}

View File

@@ -6,7 +6,7 @@ import (
"testing"
"time"
"github.com/TecharoHQ/anubis/lib/config"
"github.com/TecharoHQ/anubis/lib/policy/config"
"github.com/TecharoHQ/anubis/lib/store/memory"
"golang.org/x/net/html"
)
@@ -18,7 +18,7 @@ func TestExtractOGTags(t *testing.T) {
Enabled: false,
ConsiderHost: false,
TimeToLive: time.Minute,
}, memory.New(t.Context()), TargetOptions{})
}, memory.New(t.Context()))
// Manually set approved tags/prefixes based on the user request for clarity
testCache.approvedTags = []string{"description"}
testCache.approvedPrefixes = []string{"og:"}
@@ -199,7 +199,7 @@ func TestExtractMetaTagInfo(t *testing.T) {
Enabled: false,
ConsiderHost: false,
TimeToLive: time.Minute,
}, memory.New(t.Context()), TargetOptions{})
}, memory.New(t.Context()))
testCache.approvedTags = []string{"description"}
testCache.approvedPrefixes = []string{"og:"}

View File

@@ -1,42 +0,0 @@
package ogtags
import (
"crypto/tls"
"net/http"
)
// clientForSNI returns a cached client for the given server name, creating one if needed.
func (c *OGTagCache) clientForSNI(serverName string) *http.Client {
if !c.targetSNIAuto || serverName == "" {
return c.client
}
c.transportMu.RLock()
cli, ok := c.sniClients[serverName]
c.transportMu.RUnlock()
if ok {
return cli
}
c.transportMu.Lock()
defer c.transportMu.Unlock()
if cli, ok := c.sniClients[serverName]; ok {
return cli
}
tr := c.transport.Clone()
if tr.TLSClientConfig == nil {
tr.TLSClientConfig = &tls.Config{}
}
tr.TLSClientConfig.ServerName = serverName
if c.insecureSkipVerify {
tr.TLSClientConfig.InsecureSkipVerify = true
}
cli = &http.Client{
Timeout: httpTimeout,
Transport: tr,
}
c.sniClients[serverName] = cli
return cli
}

View File

@@ -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)
if err != nil {
t.Fatal(err)
}

View File

@@ -27,10 +27,10 @@ import (
"github.com/TecharoHQ/anubis/internal/dnsbl"
"github.com/TecharoHQ/anubis/internal/ogtags"
"github.com/TecharoHQ/anubis/lib/challenge"
"github.com/TecharoHQ/anubis/lib/config"
"github.com/TecharoHQ/anubis/lib/localization"
"github.com/TecharoHQ/anubis/lib/policy"
"github.com/TecharoHQ/anubis/lib/policy/checker"
"github.com/TecharoHQ/anubis/lib/policy/config"
"github.com/TecharoHQ/anubis/lib/store"
// challenge implementations

View File

@@ -20,8 +20,8 @@ import (
"github.com/TecharoHQ/anubis/data"
"github.com/TecharoHQ/anubis/internal"
"github.com/TecharoHQ/anubis/lib/challenge"
"github.com/TecharoHQ/anubis/lib/config"
"github.com/TecharoHQ/anubis/lib/policy"
"github.com/TecharoHQ/anubis/lib/policy/config"
"github.com/TecharoHQ/anubis/lib/store"
"github.com/TecharoHQ/anubis/lib/thoth/thothmock"
)
@@ -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)
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); err != nil {
t.Fatal(err)
}
})

View File

@@ -6,8 +6,8 @@ import (
"sort"
"sync"
"github.com/TecharoHQ/anubis/lib/config"
"github.com/TecharoHQ/anubis/lib/policy"
"github.com/TecharoHQ/anubis/lib/policy/config"
"github.com/TecharoHQ/anubis/lib/store"
"github.com/a-h/templ"
)

View File

@@ -8,8 +8,8 @@ import (
"testing"
"github.com/TecharoHQ/anubis/lib/challenge"
"github.com/TecharoHQ/anubis/lib/config"
"github.com/TecharoHQ/anubis/lib/policy"
"github.com/TecharoHQ/anubis/lib/policy/config"
)
func mkRequest(t *testing.T, values map[string]string) *http.Request {

View File

@@ -18,43 +18,39 @@ import (
"github.com/TecharoHQ/anubis/internal"
"github.com/TecharoHQ/anubis/internal/ogtags"
"github.com/TecharoHQ/anubis/lib/challenge"
"github.com/TecharoHQ/anubis/lib/config"
"github.com/TecharoHQ/anubis/lib/localization"
"github.com/TecharoHQ/anubis/lib/policy"
"github.com/TecharoHQ/anubis/lib/policy/config"
"github.com/TecharoHQ/anubis/web"
"github.com/TecharoHQ/anubis/xess"
"github.com/a-h/templ"
)
type Options struct {
Next http.Handler
Policy *policy.ParsedConfig
Target string
TargetHost string
TargetSNI string
TargetInsecureSkipVerify bool
CookieDynamicDomain bool
CookieDomain string
CookieExpiration time.Duration
CookiePartitioned bool
BasePrefix string
WebmasterEmail string
RedirectDomains []string
ED25519PrivateKey ed25519.PrivateKey
HS512Secret []byte
StripBasePrefix bool
OpenGraph config.OpenGraph
ServeRobotsTXT bool
CookieSecure bool
CookieSameSite http.SameSite
Logger *slog.Logger
LogLevel string
PublicUrl string
JWTRestrictionHeader string
DifficultyInJWT bool
Next http.Handler
Policy *policy.ParsedConfig
Logger *slog.Logger
OpenGraph config.OpenGraph
PublicUrl string
CookieDomain string
JWTRestrictionHeader string
BasePrefix string
WebmasterEmail string
Target string
RedirectDomains []string
ED25519PrivateKey ed25519.PrivateKey
HS512Secret []byte
CookieExpiration time.Duration
CookieSameSite http.SameSite
ServeRobotsTXT bool
CookieSecure bool
StripBasePrefix bool
CookiePartitioned bool
CookieDynamicDomain bool
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) (*policy.ParsedConfig, error) {
var fin io.ReadCloser
var err error
@@ -78,7 +74,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)
if err != nil {
return nil, fmt.Errorf("can't parse policy file %s: %w", fname, err)
}
@@ -120,13 +116,9 @@ func New(opts Options) (*Server, error) {
hs512Secret: opts.HS512Secret,
policy: opts.Policy,
opts: opts,
OGTags: ogtags.NewOGTagCache(opts.Target, opts.Policy.OpenGraph, opts.Policy.Store, ogtags.TargetOptions{
Host: opts.TargetHost,
SNI: opts.TargetSNI,
InsecureSkipVerify: opts.TargetInsecureSkipVerify,
}),
store: opts.Policy.Store,
logger: opts.Logger,
OGTags: ogtags.NewOGTagCache(opts.Target, opts.Policy.OpenGraph, opts.Policy.Store),
store: opts.Policy.Store,
logger: opts.Logger,
}
mux := http.NewServeMux()

View File

@@ -1,124 +0,0 @@
package config
import (
"errors"
"fmt"
"log/slog"
)
var (
ErrMissingLoggingFileConfig = errors.New("config.Logging: missing value parameters in logging block")
ErrInvalidLoggingSink = errors.New("config.Logging: invalid sink")
ErrInvalidLoggingFileConfig = errors.New("config.LoggingFileConfig: invalid parameters")
ErrOutOfRange = errors.New("config: error out of range")
)
type Logging struct {
Sink string `json:"sink"` // Logging sink, either "stdio" or "file"
Level *slog.Level `json:"level"` // Log level, if set supercedes the level in flags
Parameters *LoggingFileConfig `json:"parameters"` // Logging parameters, to be dynamic in the future
}
const (
LogSinkStdio = "stdio"
LogSinkFile = "file"
)
func (l *Logging) Valid() error {
var errs []error
switch l.Sink {
case LogSinkStdio:
// no validation needed
case LogSinkFile:
if l.Parameters == nil {
errs = append(errs, ErrMissingLoggingFileConfig)
}
if err := l.Parameters.Valid(); err != nil {
errs = append(errs, err)
}
default:
errs = append(errs, fmt.Errorf("%w: sink %s is unknown to me", ErrInvalidLoggingSink, l.Sink))
}
if len(errs) != 0 {
return errors.Join(errs...)
}
return nil
}
func (Logging) Default() *Logging {
return &Logging{
Sink: "stdio",
}
}
type LoggingFileConfig struct {
Filename string `json:"file"`
MaxBackups int `json:"maxBackups"`
MaxBytes int64 `json:"maxBytes"`
MaxAge int `json:"maxAge"`
Compress bool `json:"compress"`
UseLocalTime bool `json:"useLocalTime"`
}
func (lfc *LoggingFileConfig) Valid() error {
if lfc == nil {
return fmt.Errorf("logging file config is nil, why are you calling this?")
}
var errs []error
if lfc.Zero() {
errs = append(errs, ErrMissingValue)
}
if lfc.Filename == "" {
errs = append(errs, fmt.Errorf("%w: filename", ErrMissingValue))
}
if lfc.MaxBackups < 0 {
errs = append(errs, fmt.Errorf("%w: max backup count %d is not greater than or equal to zero", ErrOutOfRange, lfc.MaxBackups))
}
if lfc.MaxAge < 0 {
errs = append(errs, fmt.Errorf("%w: max backup count %d is not greater than or equal to zero", ErrOutOfRange, lfc.MaxAge))
}
if len(errs) != 0 {
errs = append([]error{ErrInvalidLoggingFileConfig}, errs...)
return errors.Join(errs...)
}
return nil
}
func (lfc LoggingFileConfig) Zero() bool {
for _, cond := range []bool{
lfc.Filename != "",
lfc.MaxBackups != 0,
lfc.MaxBytes != 0,
lfc.MaxAge != 0,
lfc.Compress,
lfc.UseLocalTime,
} {
if cond {
return false
}
}
return true
}
func (LoggingFileConfig) Default() *LoggingFileConfig {
return &LoggingFileConfig{
Filename: "./var/anubis.log",
MaxBackups: 3,
MaxBytes: 104857600, // 100 Mi
MaxAge: 7, // 7 days
Compress: true,
UseLocalTime: false,
}
}

View File

@@ -1,103 +0,0 @@
package config
import (
"errors"
"testing"
)
func TestLoggingValid(t *testing.T) {
for _, tt := range []struct {
name string
input *Logging
want error
}{
{
name: "simple happy",
input: (Logging{}).Default(),
},
{
name: "default file config",
input: &Logging{
Sink: LogSinkFile,
Parameters: (&LoggingFileConfig{}).Default(),
},
},
{
name: "invalid sink",
input: &Logging{
Sink: "taco invalid",
},
want: ErrInvalidLoggingSink,
},
{
name: "missing parameters",
input: &Logging{
Sink: LogSinkFile,
},
want: ErrMissingLoggingFileConfig,
},
{
name: "invalid parameters",
input: &Logging{
Sink: LogSinkFile,
Parameters: &LoggingFileConfig{},
},
want: ErrInvalidLoggingFileConfig,
},
{
name: "file sink with no filename",
input: &Logging{
Sink: LogSinkFile,
Parameters: &LoggingFileConfig{
Filename: "",
MaxBackups: 3,
MaxBytes: 104857600, // 100 Mi
MaxAge: 7, // 7 days
Compress: true,
UseLocalTime: false,
},
},
want: ErrMissingValue,
},
{
name: "file sink with negative max backups",
input: &Logging{
Sink: LogSinkFile,
Parameters: &LoggingFileConfig{
Filename: "./var/anubis.log",
MaxBackups: -3,
MaxBytes: 104857600, // 100 Mi
MaxAge: 7, // 7 days
Compress: true,
UseLocalTime: false,
},
},
want: ErrOutOfRange,
},
{
name: "file sink with negative max age",
input: &Logging{
Sink: LogSinkFile,
Parameters: &LoggingFileConfig{
Filename: "./var/anubis.log",
MaxBackups: 3,
MaxBytes: 104857600, // 100 Mi
MaxAge: -7, // 7 days
Compress: true,
UseLocalTime: false,
},
},
want: ErrOutOfRange,
},
} {
t.Run(tt.name, func(t *testing.T) {
err := tt.input.Valid()
if !errors.Is(err, tt.want) {
t.Logf("wanted error: %v", tt.want)
t.Logf(" got error: %v", err)
t.Fatal("got wrong error")
}
})
}
}

View File

@@ -1,2 +0,0 @@
logging:
sink: "nope"

View File

@@ -1,2 +0,0 @@
logging:
sink: "file"

View File

@@ -1,15 +0,0 @@
bots:
- name: simple
action: CHALLENGE
user_agent_regex: Mozilla
logs:
sink: "file"
parameters:
file: "/var/log/botstopper/default.log"
maxBackups: 3 # keep at least 3 old copies
maxBytes: 67108864 # each file can have up to 64 MB of logs
maxAge: 7 # rotate files out every n days
oldFileTimeFormat: 2006-01-02T15-04-05 # RFC 3339-ish
compress: true
useLocalTime: false # timezone for rotated files is UTC

View File

@@ -1,7 +0,0 @@
bots:
- name: simple
action: CHALLENGE
user_agent_regex: Mozilla
logging:
sink: "stdio"

View File

@@ -12,13 +12,13 @@ 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); !errors.Is(err, policy.ErrChallengeRuleHasWrongAlgorithm) {
t.Fatalf("wanted error %v but got %v", policy.ErrChallengeRuleHasWrongAlgorithm, err)
}
}
func TestBadConfigs(t *testing.T) {
finfos, err := os.ReadDir("config/testdata/bad")
finfos, err := os.ReadDir("policy/config/testdata/bad")
if err != nil {
t.Fatal(err)
}
@@ -26,7 +26,7 @@ func TestBadConfigs(t *testing.T) {
for _, st := range finfos {
st := st
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("policy", "config", "testdata", "bad", st.Name()), anubis.DefaultDifficulty); err == nil {
t.Fatal(err)
} else {
t.Log(err)
@@ -36,7 +36,7 @@ func TestBadConfigs(t *testing.T) {
}
func TestGoodConfigs(t *testing.T) {
finfos, err := os.ReadDir("config/testdata/good")
finfos, err := os.ReadDir("policy/config/testdata/good")
if err != nil {
t.Fatal(err)
}
@@ -46,13 +46,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("policy", "config", "testdata", "good", st.Name()), anubis.DefaultDifficulty); 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("policy", "config", "testdata", "good", st.Name()), anubis.DefaultDifficulty); err != nil {
t.Fatal(err)
}
})

View File

@@ -4,8 +4,8 @@ import (
"fmt"
"github.com/TecharoHQ/anubis/internal"
"github.com/TecharoHQ/anubis/lib/config"
"github.com/TecharoHQ/anubis/lib/policy/checker"
"github.com/TecharoHQ/anubis/lib/policy/config"
)
type Bot struct {

View File

@@ -5,7 +5,7 @@ import (
"net/http"
"github.com/TecharoHQ/anubis/internal"
"github.com/TecharoHQ/anubis/lib/config"
"github.com/TecharoHQ/anubis/lib/policy/config"
"github.com/TecharoHQ/anubis/lib/policy/expressions"
"github.com/google/cel-go/cel"
"github.com/google/cel-go/common/types"

View File

@@ -3,7 +3,7 @@ package policy
import (
"log/slog"
"github.com/TecharoHQ/anubis/lib/config"
"github.com/TecharoHQ/anubis/lib/policy/config"
)
type CheckResult struct {

View File

@@ -62,14 +62,11 @@ type BotConfig struct {
Expression *ExpressionOrList `json:"expression,omitempty" yaml:"expression,omitempty"`
Challenge *ChallengeRules `json:"challenge,omitempty" yaml:"challenge,omitempty"`
Weight *Weight `json:"weight,omitempty" yaml:"weight,omitempty"`
// Thoth features
GeoIP *GeoIP `json:"geoip,omitempty"`
ASNs *ASNs `json:"asns,omitempty"`
Name string `json:"name" yaml:"name"`
Action Rule `json:"action" yaml:"action"`
RemoteAddr []string `json:"remote_addresses,omitempty" yaml:"remote_addresses,omitempty"`
GeoIP *GeoIP `json:"geoip,omitempty"`
ASNs *ASNs `json:"asns,omitempty"`
Name string `json:"name" yaml:"name"`
Action Rule `json:"action" yaml:"action"`
RemoteAddr []string `json:"remote_addresses,omitempty" yaml:"remote_addresses,omitempty"`
}
func (b BotConfig) Zero() bool {
@@ -332,7 +329,6 @@ type fileConfig struct {
Thresholds []Threshold `json:"thresholds"`
StatusCodes StatusCodes `json:"status_codes"`
DNSBL bool `json:"dnsbl"`
Logging *Logging `json:"logging"`
}
func (c *fileConfig) Valid() error {
@@ -364,10 +360,6 @@ func (c *fileConfig) Valid() error {
}
}
if err := c.Logging.Valid(); err != nil {
errs = append(errs, err)
}
if c.Store != nil {
if err := c.Store.Valid(); err != nil {
errs = append(errs, err)
@@ -390,7 +382,6 @@ func Load(fin io.Reader, fname string) (*Config, error) {
Store: &Store{
Backend: "memory",
},
Logging: (Logging{}).Default(),
}
if err := yaml.NewYAMLToJSONDecoder(fin).Decode(&c); err != nil {
@@ -410,7 +401,6 @@ func Load(fin io.Reader, fname string) (*Config, error) {
},
StatusCodes: c.StatusCodes,
Store: c.Store,
Logging: c.Logging,
}
if c.OpenGraph.TimeToLive != "" {
@@ -476,7 +466,6 @@ type Config struct {
Bots []BotConfig
Thresholds []Threshold
StatusCodes StatusCodes
Logging *Logging
DNSBL bool
}

View File

@@ -8,7 +8,7 @@ import (
"testing"
"github.com/TecharoHQ/anubis/data"
. "github.com/TecharoHQ/anubis/lib/config"
. "github.com/TecharoHQ/anubis/lib/policy/config"
)
func p[V any](v V) *V { return &v }

View File

@@ -5,7 +5,7 @@ import (
"errors"
"testing"
"github.com/TecharoHQ/anubis/lib/config"
"github.com/TecharoHQ/anubis/lib/policy/config"
"github.com/TecharoHQ/anubis/lib/store/bbolt"
"github.com/TecharoHQ/anubis/lib/store/valkey"
)

Some files were not shown because too many files have changed in this diff Show More