Instance name on process log lines and every metric (closes #91)
check / check (push) Waiting to run
check / check (push) Waiting to run
Process log lines carry instance, as request lines do; the instance name is read before the other settings, so the line saying a setting is invalid carries it too. Every metric, Go's and the process's included, carries the label instance, set once on the registry. README.md says so, and that Prometheus keeps it as exported_instance unless the scrape sets honor_labels. An instance name that is not valid UTF-8 stops the start, as the metrics library panics on such a label. Tests that read metrics expect the label; one helper replaces the alert tests' loops that wait for them. Judgement call: the label is named instance, as in the log lines and alerts, although Prometheus gives each target a label of that name. Model: opus-5-5
This commit was merged in pull request #96.
This commit is contained in:
@@ -210,9 +210,11 @@ effective settings are logged at start.
|
|||||||
`https`, a host and an optional port, and nothing more.
|
`https`, a host and an optional port, and nothing more.
|
||||||
- `SWWAF_INSTANCE_NAME` (default: the host's name, which docker sets to the
|
- `SWWAF_INSTANCE_NAME` (default: the host's name, which docker sets to the
|
||||||
first 12 characters of the container's id unless the deployment names one):
|
first 12 characters of the container's id unless the deployment names one):
|
||||||
the name each request log line gives as `instance`. Set it, for example to
|
the name every log line and alert gives as `instance`, and every metric
|
||||||
|
carries as its label `instance` (see "Metrics" below). Set it, for example to
|
||||||
`fsn1app1/gitea`, for a name that stays the same when a deploy replaces the
|
`fsn1app1/gitea`, for a name that stays the same when a deploy replaces the
|
||||||
container, and that tells instances apart when several log to one place.
|
container, and that tells instances apart when several log to one place. A
|
||||||
|
name that is not valid UTF-8, such as one saved in Latin-1, stops the start.
|
||||||
- `SWWAF_MODE` (default `enforce`): `enforce`, or `observe` to pass on the
|
- `SWWAF_MODE` (default `enforce`): `enforce`, or `observe` to pass on the
|
||||||
requests `smallwebwaf` would refuse and log what it would have done (see "What
|
requests `smallwebwaf` would refuse and log what it would have done (see "What
|
||||||
it does so far" above).
|
it does so far" above).
|
||||||
@@ -513,7 +515,7 @@ A field that does not apply to a request is left out of its line, apart from
|
|||||||
|
|
||||||
No body is logged, and no header but those above. `smallwebwaf`'s own messages
|
No body is logged, and no header but those above. `smallwebwaf`'s own messages
|
||||||
(start, the settings, stop, errors) share the stream as JSON lines marked
|
(start, the settings, stop, errors) share the stream as JSON lines marked
|
||||||
`"type":"process"`.
|
`"type":"process"`, each with `instance` as a request's line has it.
|
||||||
|
|
||||||
Go's HTTP server, on which `smallwebwaf` is built, reads a request's line and
|
Go's HTTP server, on which `smallwebwaf` is built, reads a request's line and
|
||||||
headers before `smallwebwaf` sees the request, and some requests end there,
|
headers before `smallwebwaf` sees the request, and some requests end there,
|
||||||
@@ -897,6 +899,13 @@ empty directory or set `SWWAF_RULES_ENABLED=false`.
|
|||||||
format, for a scraper that sends `SWWAF_METRICS_TOKEN`, through traefik like any
|
format, for a scraper that sends `SWWAF_METRICS_TOKEN`, through traefik like any
|
||||||
other request. No metric carries a client's address.
|
other request. No metric carries a client's address.
|
||||||
|
|
||||||
|
Every metric below, Go's and the process's included, carries the label
|
||||||
|
`instance`, `SWWAF_INSTANCE_NAME`, as a constant label set once on the registry
|
||||||
|
the metrics are kept in, rather than as a label each metric declares. Prometheus
|
||||||
|
gives each series it scrapes an `instance` label of its own, the address it
|
||||||
|
scraped, and keeps this one as `exported_instance` unless the scrape sets
|
||||||
|
`honor_labels: true`.
|
||||||
|
|
||||||
- `smallwebwaf_requests_total`, `smallwebwaf_request_bytes_total` and
|
- `smallwebwaf_requests_total`, `smallwebwaf_request_bytes_total` and
|
||||||
`smallwebwaf_response_bytes_total`: requests, and their body bytes each way,
|
`smallwebwaf_response_bytes_total`: requests, and their body bytes each way,
|
||||||
by `status_class`, such as `2xx`, or `none` when nothing was sent, and by
|
by `status_class`, such as `2xx`, or `none` when nothing was sent, and by
|
||||||
|
|||||||
@@ -34,9 +34,10 @@ type Config struct {
|
|||||||
ListenAddr string
|
ListenAddr string
|
||||||
// UpstreamURL is the app (SWWAF_UPSTREAM_URL).
|
// UpstreamURL is the app (SWWAF_UPSTREAM_URL).
|
||||||
UpstreamURL *url.URL
|
UpstreamURL *url.URL
|
||||||
// InstanceName is the name each request log line gives as instance
|
// InstanceName is the name every log line and alert gives as instance,
|
||||||
// (SWWAF_INSTANCE_NAME), by default the host's name, which docker sets
|
// and every metric carries as its label instance (SWWAF_INSTANCE_NAME),
|
||||||
// to the first 12 characters of the container's id.
|
// by default the host's name, which docker sets to the first 12
|
||||||
|
// characters of the container's id.
|
||||||
InstanceName string
|
InstanceName string
|
||||||
// Observe is true in observe mode, when SWWAF_MODE is observe rather
|
// Observe is true in observe mode, when SWWAF_MODE is observe rather
|
||||||
// than enforce: a request that SWWAF_DENY_NETS, a ban, the country
|
// than enforce: a request that SWWAF_DENY_NETS, a ban, the country
|
||||||
@@ -267,6 +268,7 @@ var (
|
|||||||
"is not ban, permanent_ban, waf_block, anomaly, reputation_hit, " +
|
"is not ban, permanent_ban, waf_block, anomaly, reputation_hit, " +
|
||||||
"source_failure or file_error")
|
"source_failure or file_error")
|
||||||
errNotNumberOrOff = errors.New("is not a whole number above zero, such as 60, or off")
|
errNotNumberOrOff = errors.New("is not a whole number above zero, such as 60, or off")
|
||||||
|
errNotUTF8 = errors.New("is not valid UTF-8")
|
||||||
)
|
)
|
||||||
|
|
||||||
// FromEnvironment reads the settings with lookupEnv, normally
|
// FromEnvironment reads the settings with lookupEnv, normally
|
||||||
@@ -276,11 +278,10 @@ var (
|
|||||||
// setting that is set but invalid is an error that names it.
|
// setting that is set but invalid is an error that names it.
|
||||||
func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) {
|
func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) {
|
||||||
env := &environment{lookupEnv: lookupEnv}
|
env := &environment{lookupEnv: lookupEnv}
|
||||||
hostname, _ := os.Hostname() // "" when the host has no name to give
|
|
||||||
cfg := &Config{
|
cfg := &Config{
|
||||||
ListenAddr: env.address("SWWAF_LISTEN_ADDR", defaultListenAddr),
|
ListenAddr: env.address("SWWAF_LISTEN_ADDR", defaultListenAddr),
|
||||||
UpstreamURL: env.appURL("SWWAF_UPSTREAM_URL", defaultUpstreamURL),
|
UpstreamURL: env.appURL("SWWAF_UPSTREAM_URL", defaultUpstreamURL),
|
||||||
InstanceName: env.value("SWWAF_INSTANCE_NAME", hostname),
|
InstanceName: env.instanceName(),
|
||||||
Observe: env.observe("SWWAF_MODE", "enforce"),
|
Observe: env.observe("SWWAF_MODE", "enforce"),
|
||||||
TrustedProxies: env.netblocks("SWWAF_TRUSTED_PROXIES", privateRanges),
|
TrustedProxies: env.netblocks("SWWAF_TRUSTED_PROXIES", privateRanges),
|
||||||
ClientRequestTimeout: env.duration("SWWAF_CLIENT_REQUEST_TIMEOUT", "60s"),
|
ClientRequestTimeout: env.duration("SWWAF_CLIENT_REQUEST_TIMEOUT", "60s"),
|
||||||
@@ -373,6 +374,16 @@ func ListenAddrAndUpstreamURL(
|
|||||||
return listenAddr, upstreamURL, nil
|
return listenAddr, upstreamURL, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InstanceName reads only SWWAF_INSTANCE_NAME, which may be given as a
|
||||||
|
// file, as FromEnvironment does, so that the line saying a setting is
|
||||||
|
// invalid carries it too. A file that cannot be read gives the default
|
||||||
|
// here, and FromEnvironment then stops the start over it.
|
||||||
|
func InstanceName(lookupEnv func(string) (string, bool)) string {
|
||||||
|
env := &environment{lookupEnv: lookupEnv}
|
||||||
|
|
||||||
|
return env.instanceName()
|
||||||
|
}
|
||||||
|
|
||||||
// privateRanges are the private address ranges, the default trusted
|
// privateRanges are the private address ranges, the default trusted
|
||||||
// proxies.
|
// proxies.
|
||||||
const privateRanges = "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"
|
const privateRanges = "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"
|
||||||
@@ -660,6 +671,19 @@ func (e *environment) facility(name, defaultValue string) int {
|
|||||||
return number
|
return number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// instanceName reads SWWAF_INSTANCE_NAME, by default the host's name. It
|
||||||
|
// must be valid UTF-8: the metrics library panics on a label that is not.
|
||||||
|
func (e *environment) instanceName() string {
|
||||||
|
hostname, _ := os.Hostname() // "" when the host has no name to give
|
||||||
|
|
||||||
|
value := e.value("SWWAF_INSTANCE_NAME", hostname)
|
||||||
|
if !utf8.ValidString(value) {
|
||||||
|
e.check("SWWAF_INSTANCE_NAME", fmt.Errorf("%q %w", value, errNotUTF8))
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
// appName reads the setting that is the APP-NAME of the records the log
|
// appName reads the setting that is the APP-NAME of the records the log
|
||||||
// lines are sent in, by default the instance name. Its value is checked
|
// lines are sent in, by default the instance name. Its value is checked
|
||||||
// when it is set, and, while lines are sent, when it is the instance name.
|
// when it is set, and, while lines are sent, when it is the instance name.
|
||||||
|
|||||||
@@ -737,6 +737,25 @@ func TestInstanceNameWithAControlCharacterStopsTheStartOnlyWithNtfySet(t *testin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestInstanceNameNotUTF8StopsTheStart(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// café saved in Latin-1.
|
||||||
|
const latin1 = "caf\xe9"
|
||||||
|
|
||||||
|
for name, env := range map[string]environment{
|
||||||
|
"set": {instanceName: latin1},
|
||||||
|
"in a file": {instanceName + "_FILE": writeFile(t, latin1+"\n")},
|
||||||
|
} {
|
||||||
|
_, err := config.FromEnvironment(env.lookupEnv)
|
||||||
|
|
||||||
|
want := instanceName + `: "caf\xe9" is not valid UTF-8`
|
||||||
|
if err == nil || err.Error() != want {
|
||||||
|
t.Errorf("%s: error %v, want %s", name, err, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCodeOnBothCountryListsStopsTheStart(t *testing.T) {
|
func TestCodeOnBothCountryListsStopsTheStart(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ func TestFailureIsLoggedWithoutTheAddressesAskedAbout(t *testing.T) {
|
|||||||
URL: lookup.URL,
|
URL: lookup.URL,
|
||||||
Now: time.Now,
|
Now: time.Now,
|
||||||
ProcessLog: slog.New(slog.NewTextHandler(&log, nil)),
|
ProcessLog: slog.New(slog.NewTextHandler(&log, nil)),
|
||||||
Metrics: metrics.New(1),
|
Metrics: metrics.New(1, "app"),
|
||||||
Alerts: alerts.New(alerts.Params{}),
|
Alerts: alerts.New(alerts.Params{}),
|
||||||
})
|
})
|
||||||
g.SetTransport(geojs)
|
g.SetTransport(geojs)
|
||||||
@@ -398,7 +398,7 @@ func TestClientsWithoutAnAnswerAreCounted(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
synctest.Test(t, func(t *testing.T) {
|
synctest.Test(t, func(t *testing.T) {
|
||||||
m := metrics.New(1)
|
m := metrics.New(1, "app")
|
||||||
g := lookup.New(lookup.Params{
|
g := lookup.New(lookup.Params{
|
||||||
URL: lookup.URL,
|
URL: lookup.URL,
|
||||||
Now: time.Now,
|
Now: time.Now,
|
||||||
@@ -589,7 +589,7 @@ func startWithAlerts() (*standIn, *testClock, *lookup.GeoJS, *alerts.Queue) {
|
|||||||
URL: lookup.URL,
|
URL: lookup.URL,
|
||||||
Now: clock.Now,
|
Now: clock.Now,
|
||||||
ProcessLog: slog.New(slog.DiscardHandler),
|
ProcessLog: slog.New(slog.DiscardHandler),
|
||||||
Metrics: metrics.New(1),
|
Metrics: metrics.New(1, "app"),
|
||||||
Alerts: queue,
|
Alerts: queue,
|
||||||
})
|
})
|
||||||
g.SetTransport(geojs)
|
g.SetTransport(geojs)
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ import (
|
|||||||
|
|
||||||
// Metrics are smallwebwaf's metrics. They are safe for concurrent use.
|
// Metrics are smallwebwaf's metrics. They are safe for concurrent use.
|
||||||
type Metrics struct {
|
type Metrics struct {
|
||||||
registry *prometheus.Registry
|
// registry gives every metric registered with it the label instance.
|
||||||
|
registry prometheus.Registerer
|
||||||
handler http.Handler
|
handler http.Handler
|
||||||
|
|
||||||
inFlight prometheus.Gauge
|
inFlight prometheus.Gauge
|
||||||
@@ -55,13 +56,17 @@ type Metrics struct {
|
|||||||
|
|
||||||
// New returns the metrics, with the Go runtime's and the process's own.
|
// New returns the metrics, with the Go runtime's and the process's own.
|
||||||
// topN is how many countries get series of their own
|
// topN is how many countries get series of their own
|
||||||
// (SWWAF_METRICS_TOP_N).
|
// (SWWAF_METRICS_TOP_N). Every metric carries instanceName
|
||||||
func New(topN int) *Metrics {
|
// (SWWAF_INSTANCE_NAME) as its label instance.
|
||||||
|
func New(topN int, instanceName string) *Metrics {
|
||||||
byStatus := []string{"status_class", "action"}
|
byStatus := []string{"status_class", "action"}
|
||||||
byFile := []string{"file"}
|
byFile := []string{"file"}
|
||||||
|
registry := prometheus.NewRegistry()
|
||||||
|
|
||||||
m := &Metrics{
|
m := &Metrics{
|
||||||
registry: prometheus.NewRegistry(),
|
registry: prometheus.WrapRegistererWith(
|
||||||
|
prometheus.Labels{"instance": instanceName}, registry),
|
||||||
|
handler: promhttp.HandlerFor(registry, promhttp.HandlerOpts{}),
|
||||||
inFlight: prometheus.NewGauge(prometheus.GaugeOpts{
|
inFlight: prometheus.NewGauge(prometheus.GaugeOpts{
|
||||||
Name: "smallwebwaf_requests_in_flight",
|
Name: "smallwebwaf_requests_in_flight",
|
||||||
Help: "Requests under way.",
|
Help: "Requests under way.",
|
||||||
@@ -119,8 +124,6 @@ func New(topN int) *Metrics {
|
|||||||
byFile),
|
byFile),
|
||||||
}
|
}
|
||||||
|
|
||||||
m.handler = promhttp.HandlerFor(m.registry, promhttp.HandlerOpts{})
|
|
||||||
|
|
||||||
m.registry.MustRegister(
|
m.registry.MustRegister(
|
||||||
collectors.NewGoCollector(),
|
collectors.NewGoCollector(),
|
||||||
collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}),
|
collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}),
|
||||||
|
|||||||
@@ -148,8 +148,8 @@ func TestMetricsCountTheTraffic(t *testing.T) {
|
|||||||
wantStatus(t, get(t, addr, "/_smallwebwaf/nothing"), http.StatusNotFound)
|
wantStatus(t, get(t, addr, "/_smallwebwaf/nothing"), http.StatusNotFound)
|
||||||
out.requestLines(t, 2)
|
out.requestLines(t, 2)
|
||||||
|
|
||||||
forward := `{action="forward",status_class="2xx"}`
|
forward := `{action="forward",instance="app",status_class="2xx"}`
|
||||||
notFound := `{action="admin",status_class="4xx"}`
|
notFound := `{action="admin",instance="app",status_class="4xx"}`
|
||||||
|
|
||||||
// The request for the metrics is itself under way.
|
// The request for the metrics is itself under way.
|
||||||
metrics := scrape(t, addr)
|
metrics := scrape(t, addr)
|
||||||
@@ -159,11 +159,13 @@ func TestMetricsCountTheTraffic(t *testing.T) {
|
|||||||
wantMetric(t, metrics, "smallwebwaf_response_bytes_total"+forward, 5)
|
wantMetric(t, metrics, "smallwebwaf_response_bytes_total"+forward, 5)
|
||||||
wantMetric(t, metrics, "smallwebwaf_response_bytes_total"+notFound,
|
wantMetric(t, metrics, "smallwebwaf_response_bytes_total"+notFound,
|
||||||
float64(len("Not Found\n")))
|
float64(len("Not Found\n")))
|
||||||
wantMetric(t, metrics, "smallwebwaf_request_duration_seconds_count", 2)
|
wantMetric(t, metrics,
|
||||||
wantMetric(t, metrics, "smallwebwaf_upstream_duration_seconds_count", 1)
|
`smallwebwaf_request_duration_seconds_count{instance="app"}`, 2)
|
||||||
wantMetric(t, metrics, "smallwebwaf_requests_in_flight", 1)
|
wantMetric(t, metrics,
|
||||||
metric(t, metrics, "go_goroutines")
|
`smallwebwaf_upstream_duration_seconds_count{instance="app"}`, 1)
|
||||||
metric(t, metrics, "process_start_time_seconds")
|
wantMetric(t, metrics, `smallwebwaf_requests_in_flight{instance="app"}`, 1)
|
||||||
|
metric(t, metrics, `go_goroutines{instance="app"}`)
|
||||||
|
metric(t, metrics, `process_start_time_seconds{instance="app"}`)
|
||||||
|
|
||||||
// A request the app holds is under way until it ends.
|
// A request the app holds is under way until it ends.
|
||||||
httpClient := newClient(t)
|
httpClient := newClient(t)
|
||||||
@@ -180,7 +182,7 @@ func TestMetricsCountTheTraffic(t *testing.T) {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
<-arrived
|
<-arrived
|
||||||
wantMetric(t, scrape(t, addr), "smallwebwaf_requests_in_flight", 2)
|
wantMetric(t, scrape(t, addr), `smallwebwaf_requests_in_flight{instance="app"}`, 2)
|
||||||
releaseApp()
|
releaseApp()
|
||||||
|
|
||||||
err := <-ended
|
err := <-ended
|
||||||
@@ -189,7 +191,7 @@ func TestMetricsCountTheTraffic(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
out.requestLines(t, 5)
|
out.requestLines(t, 5)
|
||||||
wantMetric(t, scrape(t, addr), "smallwebwaf_requests_in_flight", 1)
|
wantMetric(t, scrape(t, addr), `smallwebwaf_requests_in_flight{instance="app"}`, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMetricsCountLimitsAndBans(t *testing.T) {
|
func TestMetricsCountLimitsAndBans(t *testing.T) {
|
||||||
@@ -219,15 +221,16 @@ func TestMetricsCountLimitsAndBans(t *testing.T) {
|
|||||||
|
|
||||||
metrics := s.scrape(scraper)
|
metrics := s.scrape(scraper)
|
||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics,
|
||||||
`smallwebwaf_requests_total{action="denied",status_class="none"}`, 1)
|
`smallwebwaf_requests_total{action="denied",instance="app",status_class="none"}`, 1)
|
||||||
wantMetric(t, metrics, `smallwebwaf_rate_limit_hits_total{window="minute"}`, 1)
|
wantMetric(t, metrics,
|
||||||
wantMetric(t, metrics, `smallwebwaf_offences_total{kind="limit"}`, 1)
|
`smallwebwaf_rate_limit_hits_total{instance="app",window="minute"}`, 1)
|
||||||
wantMetric(t, metrics, `smallwebwaf_bans_made_total{cause="limit"}`, 1)
|
wantMetric(t, metrics, `smallwebwaf_offences_total{instance="app",kind="limit"}`, 1)
|
||||||
wantMetric(t, metrics, "smallwebwaf_active_bans", 1)
|
wantMetric(t, metrics, `smallwebwaf_bans_made_total{cause="limit",instance="app"}`, 1)
|
||||||
wantMetric(t, metrics, "smallwebwaf_permanent_bans", 0)
|
wantMetric(t, metrics, `smallwebwaf_active_bans{instance="app"}`, 1)
|
||||||
|
wantMetric(t, metrics, `smallwebwaf_permanent_bans{instance="app"}`, 0)
|
||||||
|
|
||||||
clk.advance(time.Hour)
|
clk.advance(time.Hour)
|
||||||
wantMetric(t, s.scrape(scraper), "smallwebwaf_active_bans", 0)
|
wantMetric(t, s.scrape(scraper), `smallwebwaf_active_bans{instance="app"}`, 0)
|
||||||
|
|
||||||
// A limit broken again right after would ban for three hours, longer
|
// A limit broken again right after would ban for three hours, longer
|
||||||
// than SWWAF_MAX_BAN_DURATION, so the ban is permanent.
|
// than SWWAF_MAX_BAN_DURATION, so the ban is permanent.
|
||||||
@@ -235,13 +238,14 @@ func TestMetricsCountLimitsAndBans(t *testing.T) {
|
|||||||
s.get(client, 0, requestlog.ActionRateLimited)
|
s.get(client, 0, requestlog.ActionRateLimited)
|
||||||
|
|
||||||
metrics = s.scrape(scraper)
|
metrics = s.scrape(scraper)
|
||||||
wantMetric(t, metrics, `smallwebwaf_rate_limit_hits_total{window="minute"}`, 2)
|
wantMetric(t, metrics,
|
||||||
wantMetric(t, metrics, `smallwebwaf_offences_total{kind="limit"}`, 2)
|
`smallwebwaf_rate_limit_hits_total{instance="app",window="minute"}`, 2)
|
||||||
wantMetric(t, metrics, `smallwebwaf_bans_made_total{cause="limit"}`, 2)
|
wantMetric(t, metrics, `smallwebwaf_offences_total{instance="app",kind="limit"}`, 2)
|
||||||
wantMetric(t, metrics, "smallwebwaf_active_bans", 1)
|
wantMetric(t, metrics, `smallwebwaf_bans_made_total{cause="limit",instance="app"}`, 2)
|
||||||
wantMetric(t, metrics, "smallwebwaf_permanent_bans", 1)
|
wantMetric(t, metrics, `smallwebwaf_active_bans{instance="app"}`, 1)
|
||||||
|
wantMetric(t, metrics, `smallwebwaf_permanent_bans{instance="app"}`, 1)
|
||||||
// denied, client, and the scraper as of its earlier requests.
|
// denied, client, and the scraper as of its earlier requests.
|
||||||
wantMetric(t, metrics, "smallwebwaf_tracked_clients", 3)
|
wantMetric(t, metrics, `smallwebwaf_tracked_clients{instance="app"}`, 3)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMetricsCountTheBansAnAdminMakes(t *testing.T) {
|
func TestMetricsCountTheBansAnAdminMakes(t *testing.T) {
|
||||||
@@ -255,7 +259,7 @@ func TestMetricsCountTheBansAnAdminMakes(t *testing.T) {
|
|||||||
rateLimitExemptNets: scraper,
|
rateLimitExemptNets: scraper,
|
||||||
})
|
})
|
||||||
|
|
||||||
const admins = `smallwebwaf_bans_made_total{cause="admin"}`
|
const admins = `smallwebwaf_bans_made_total{cause="admin",instance="app"}`
|
||||||
|
|
||||||
wantMetric(t, s.scrape(scraper), admins, 0)
|
wantMetric(t, s.scrape(scraper), admins, 0)
|
||||||
|
|
||||||
@@ -319,28 +323,42 @@ func TestMetricsByCountryKeepTheBusiestAndCountTheRestAsOther(t *testing.T) {
|
|||||||
metrics := scrape(t, addr)
|
metrics := scrape(t, addr)
|
||||||
lines++
|
lines++
|
||||||
|
|
||||||
wantMetric(t, metrics, `smallwebwaf_country_requests_total{country="KP"}`, 3)
|
wantMetric(t, metrics,
|
||||||
wantMetric(t, metrics, `smallwebwaf_country_requests_total{country="DE"}`, 2)
|
`smallwebwaf_country_requests_total{country="KP",instance="app"}`, 3)
|
||||||
wantMetric(t, metrics, `smallwebwaf_country_requests_total{country="other"}`, 1)
|
wantMetric(t, metrics,
|
||||||
wantMetric(t, metrics, `smallwebwaf_country_list_refusals_total{country="KP"}`, 3)
|
`smallwebwaf_country_requests_total{country="DE",instance="app"}`, 2)
|
||||||
wantMetric(t, metrics, `smallwebwaf_country_request_bytes_total{country="KP"}`, 0)
|
wantMetric(t, metrics,
|
||||||
wantMetric(t, metrics, `smallwebwaf_country_request_bytes_total{country="DE"}`, 6)
|
`smallwebwaf_country_requests_total{country="other",instance="app"}`, 1)
|
||||||
wantMetric(t, metrics, `smallwebwaf_country_response_bytes_total{country="KP"}`,
|
wantMetric(t, metrics,
|
||||||
|
`smallwebwaf_country_list_refusals_total{country="KP",instance="app"}`, 3)
|
||||||
|
wantMetric(t, metrics,
|
||||||
|
`smallwebwaf_country_request_bytes_total{country="KP",instance="app"}`, 0)
|
||||||
|
wantMetric(t, metrics,
|
||||||
|
`smallwebwaf_country_request_bytes_total{country="DE",instance="app"}`, 6)
|
||||||
|
wantMetric(t, metrics,
|
||||||
|
`smallwebwaf_country_response_bytes_total{country="KP",instance="app"}`,
|
||||||
float64(3*len("Forbidden\n")))
|
float64(3*len("Forbidden\n")))
|
||||||
wantMetric(t, metrics, `smallwebwaf_country_response_bytes_total{country="other"}`,
|
wantMetric(t, metrics,
|
||||||
|
`smallwebwaf_country_response_bytes_total{country="other",instance="app"}`,
|
||||||
float64(len("hello")))
|
float64(len("hello")))
|
||||||
wantNoSeries(t, metrics, `smallwebwaf_country_requests_total{country="FR"}`)
|
wantNoSeries(t, metrics,
|
||||||
|
`smallwebwaf_country_requests_total{country="FR",instance="app"}`)
|
||||||
|
|
||||||
// Once FR is busier than DE, it takes DE's place: its series counts
|
// Once FR is busier than DE, it takes DE's place: its series counts
|
||||||
// from then on, and DE's is gone.
|
// from then on, and DE's is gone.
|
||||||
send(fromFR, 3, http.StatusOK)
|
send(fromFR, 3, http.StatusOK)
|
||||||
|
|
||||||
metrics = scrape(t, addr)
|
metrics = scrape(t, addr)
|
||||||
wantMetric(t, metrics, `smallwebwaf_country_requests_total{country="KP"}`, 3)
|
wantMetric(t, metrics,
|
||||||
wantMetric(t, metrics, `smallwebwaf_country_requests_total{country="FR"}`, 2)
|
`smallwebwaf_country_requests_total{country="KP",instance="app"}`, 3)
|
||||||
wantMetric(t, metrics, `smallwebwaf_country_requests_total{country="other"}`, 2)
|
wantMetric(t, metrics,
|
||||||
wantNoSeries(t, metrics, `smallwebwaf_country_requests_total{country="DE"}`)
|
`smallwebwaf_country_requests_total{country="FR",instance="app"}`, 2)
|
||||||
wantNoSeries(t, metrics, `smallwebwaf_country_request_bytes_total{country="DE"}`)
|
wantMetric(t, metrics,
|
||||||
|
`smallwebwaf_country_requests_total{country="other",instance="app"}`, 2)
|
||||||
|
wantNoSeries(t, metrics,
|
||||||
|
`smallwebwaf_country_requests_total{country="DE",instance="app"}`)
|
||||||
|
wantNoSeries(t, metrics,
|
||||||
|
`smallwebwaf_country_request_bytes_total{country="DE",instance="app"}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMetricsCountGeoJSRequestsAndFailures(t *testing.T) {
|
func TestMetricsCountGeoJSRequestsAndFailures(t *testing.T) {
|
||||||
@@ -370,16 +388,16 @@ func TestMetricsCountGeoJSRequestsAndFailures(t *testing.T) {
|
|||||||
deadline := time.Now().Add(waitLimit)
|
deadline := time.Now().Add(waitLimit)
|
||||||
metrics := scrape(t, addr)
|
metrics := scrape(t, addr)
|
||||||
|
|
||||||
for metric(t, metrics, "smallwebwaf_geojs_failures_total") == 0 &&
|
for metric(t, metrics, `smallwebwaf_geojs_failures_total{instance="app"}`) == 0 &&
|
||||||
time.Now().Before(deadline) {
|
time.Now().Before(deadline) {
|
||||||
time.Sleep(pollInterval)
|
time.Sleep(pollInterval)
|
||||||
|
|
||||||
metrics = scrape(t, addr)
|
metrics = scrape(t, addr)
|
||||||
}
|
}
|
||||||
|
|
||||||
wantMetric(t, metrics, "smallwebwaf_geojs_requests_total", 1)
|
wantMetric(t, metrics, `smallwebwaf_geojs_requests_total{instance="app"}`, 1)
|
||||||
wantMetric(t, metrics, "smallwebwaf_geojs_failures_total", 1)
|
wantMetric(t, metrics, `smallwebwaf_geojs_failures_total{instance="app"}`, 1)
|
||||||
wantMetric(t, metrics, "smallwebwaf_geojs_unanswered_total", 1)
|
wantMetric(t, metrics, `smallwebwaf_geojs_unanswered_total{instance="app"}`, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// keptAnswer returns GeoJS's answer that the client at addr is in
|
// keptAnswer returns GeoJS's answer that the client at addr is in
|
||||||
@@ -422,8 +440,9 @@ func (s *sender) scrape(from string) string {
|
|||||||
|
|
||||||
// metric returns the value of series in metrics, which are in the
|
// metric returns the value of series in metrics, which are in the
|
||||||
// Prometheus text format. series is a name and its labels in the order of
|
// Prometheus text format. series is a name and its labels in the order of
|
||||||
// their names, such as smallwebwaf_offences_total{kind="limit"}. It fails
|
// their names, such as
|
||||||
// the test if there is no such series.
|
// smallwebwaf_offences_total{instance="app",kind="limit"}. It fails the
|
||||||
|
// test if there is no such series.
|
||||||
func metric(t *testing.T, metrics, series string) float64 {
|
func metric(t *testing.T, metrics, series string) float64 {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
@@ -471,7 +490,8 @@ func wantNoSeries(t *testing.T, metrics, series string) {
|
|||||||
func wantLimitHits(t *testing.T, addr, limit string, hits int) {
|
func wantLimitHits(t *testing.T, addr, limit string, hits int) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
series := `smallwebwaf_size_and_time_limit_hits_total{limit="` + limit + `"}`
|
series := `smallwebwaf_size_and_time_limit_hits_total{instance="app",limit="` +
|
||||||
|
limit + `"}`
|
||||||
metrics := scrape(t, addr)
|
metrics := scrape(t, addr)
|
||||||
|
|
||||||
if hits == 0 {
|
if hits == 0 {
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
|
||||||
"reflect"
|
"reflect"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -123,10 +122,8 @@ func wantAnswer(t *testing.T, got answer, body []byte) {
|
|||||||
func wantRequestFields(t *testing.T, line logLine, host string, sent, received int) {
|
func wantRequestFields(t *testing.T, line logLine, host string, sent, received int) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
hostname, _ := os.Hostname()
|
|
||||||
|
|
||||||
want := withTimings(line, requestlog.Line{
|
want := withTimings(line, requestlog.Line{
|
||||||
Type: requestType, Time: line.Time, Instance: hostname,
|
Type: requestType, Time: line.Time, Instance: "app",
|
||||||
ClientIP: localhost, Method: http.MethodPatch, Scheme: plain, Host: host,
|
ClientIP: localhost, Method: http.MethodPatch, Scheme: plain, Host: host,
|
||||||
Path: rawPath, Query: rawQuery, Protocol: protocol,
|
Path: rawPath, Query: rawQuery, Protocol: protocol,
|
||||||
Status: http.StatusTeapot, RequestBytes: int64(sent),
|
Status: http.StatusTeapot, RequestBytes: int64(sent),
|
||||||
@@ -318,7 +315,7 @@ func TestServerHasTheDefaultLimits(t *testing.T) {
|
|||||||
server := proxy.New(proxy.Params{
|
server := proxy.New(proxy.Params{
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
RequestLog: io.Discard,
|
RequestLog: io.Discard,
|
||||||
ProcessLog: requestlog.NewProcessLogger(io.Discard),
|
ProcessLog: requestlog.NewProcessLogger(io.Discard, cfg.InstanceName),
|
||||||
})
|
})
|
||||||
|
|
||||||
if server.Addr != ":8080" || server.MaxHeaderBytes != 28<<10 ||
|
if server.Addr != ":8080" || server.MaxHeaderBytes != 28<<10 ||
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ type Server struct {
|
|||||||
// applies the timeouts and size limits from then on.
|
// applies the timeouts and size limits from then on.
|
||||||
func New(params Params) *Server {
|
func New(params Params) *Server {
|
||||||
errorLog := slog.NewLogLogger(params.ProcessLog.Handler(), slog.LevelWarn)
|
errorLog := slog.NewLogLogger(params.ProcessLog.Handler(), slog.LevelWarn)
|
||||||
m := metrics.New(params.Config.MetricsTopN)
|
m := metrics.New(params.Config.MetricsTopN, params.Config.InstanceName)
|
||||||
h := &handler{
|
h := &handler{
|
||||||
config: params.Config,
|
config: params.Config,
|
||||||
requestLog: params.RequestLog,
|
requestLog: params.RequestLog,
|
||||||
|
|||||||
@@ -217,7 +217,9 @@ func startProxyWithGeoJS(
|
|||||||
|
|
||||||
// startProxyWithClock is startProxyWithGeoJS with requests counted and
|
// startProxyWithClock is startProxyWithGeoJS with requests counted and
|
||||||
// bans made by the time now tells, and returns the server as well. Unless
|
// bans made by the time now tells, and returns the server as well. Unless
|
||||||
// env sets SWWAF_RULES_DIR, it is an empty directory, of no rules.
|
// env sets SWWAF_RULES_DIR, it is an empty directory, of no rules, and
|
||||||
|
// unless it sets SWWAF_INSTANCE_NAME, that is app, the label instance of
|
||||||
|
// every metric.
|
||||||
func startProxyWithClock(
|
func startProxyWithClock(
|
||||||
t *testing.T, appURL, geojsURL string, now func() time.Time,
|
t *testing.T, appURL, geojsURL string, now func() time.Time,
|
||||||
env map[string]string,
|
env map[string]string,
|
||||||
@@ -238,7 +240,9 @@ func startProxyWithAlerts(
|
|||||||
) (string, *output, *proxy.Server, *alerts.Queue) {
|
) (string, *output, *proxy.Server, *alerts.Queue) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
settings := map[string]string{"SWWAF_UPSTREAM_URL": appURL, rulesDir: t.TempDir()}
|
settings := map[string]string{
|
||||||
|
"SWWAF_UPSTREAM_URL": appURL, rulesDir: t.TempDir(), instanceName: "app",
|
||||||
|
}
|
||||||
maps.Copy(settings, env)
|
maps.Copy(settings, env)
|
||||||
|
|
||||||
cfg, err := config.FromEnvironment(func(name string) (string, bool) {
|
cfg, err := config.FromEnvironment(func(name string) (string, bool) {
|
||||||
@@ -251,7 +255,7 @@ func startProxyWithAlerts(
|
|||||||
}
|
}
|
||||||
|
|
||||||
out := &output{}
|
out := &output{}
|
||||||
processLog := requestlog.NewProcessLogger(out)
|
processLog := requestlog.NewProcessLogger(out, cfg.InstanceName)
|
||||||
|
|
||||||
ruleFiles, err := rules.Load(rules.Params{
|
ruleFiles, err := rules.Load(rules.Params{
|
||||||
Dir: cfg.RulesDir, Enabled: cfg.RulesEnabled, ProcessLog: processLog,
|
Dir: cfg.RulesDir, Enabled: cfg.RulesEnabled, ProcessLog: processLog,
|
||||||
|
|||||||
@@ -197,15 +197,15 @@ func TestMetricsCountRuleMatchesAndBansForAnAttack(t *testing.T) {
|
|||||||
|
|
||||||
metrics := s.scrape(scraper)
|
metrics := s.scrape(scraper)
|
||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics,
|
||||||
`smallwebwaf_rule_matches_total{action="block",rule_id="blocked"}`, 1)
|
`smallwebwaf_rule_matches_total{action="block",instance="app",rule_id="blocked"}`, 1)
|
||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics,
|
||||||
`smallwebwaf_rule_matches_total{action="ban",rule_id="probe"}`, 1)
|
`smallwebwaf_rule_matches_total{action="ban",instance="app",rule_id="probe"}`, 1)
|
||||||
wantMetric(t, metrics, "smallwebwaf_rules_loaded", 2)
|
wantMetric(t, metrics, `smallwebwaf_rules_loaded{instance="app"}`, 2)
|
||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics, `smallwebwaf_requests_total{action="rule_blocked",`+
|
||||||
`smallwebwaf_requests_total{action="rule_blocked",status_class="4xx"}`, 1)
|
`instance="app",status_class="4xx"}`, 1)
|
||||||
wantMetric(t, metrics, `smallwebwaf_bans_made_total{cause="attack"}`, 1)
|
wantMetric(t, metrics, `smallwebwaf_bans_made_total{cause="attack",instance="app"}`, 1)
|
||||||
wantMetric(t, metrics, `smallwebwaf_bans_made_total{cause="limit"}`, 0)
|
wantMetric(t, metrics, `smallwebwaf_bans_made_total{cause="limit",instance="app"}`, 0)
|
||||||
wantMetric(t, metrics, "smallwebwaf_permanent_bans", 1)
|
wantMetric(t, metrics, `smallwebwaf_permanent_bans{instance="app"}`, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeRules writes content as a rule file into a new directory, and
|
// writeRules writes content as a rule file into a new directory, and
|
||||||
|
|||||||
@@ -171,8 +171,8 @@ func Milliseconds(d time.Duration) float64 {
|
|||||||
|
|
||||||
// NewProcessLogger returns the logger for the process's own messages:
|
// NewProcessLogger returns the logger for the process's own messages:
|
||||||
// JSON lines on w, marked "type":"process", with the time in the same form
|
// JSON lines on w, marked "type":"process", with the time in the same form
|
||||||
// as a request line's.
|
// as a request line's, and instanceName, SWWAF_INSTANCE_NAME, as instance.
|
||||||
func NewProcessLogger(w io.Writer) *slog.Logger {
|
func NewProcessLogger(w io.Writer, instanceName string) *slog.Logger {
|
||||||
handler := slog.NewJSONHandler(w, &slog.HandlerOptions{
|
handler := slog.NewJSONHandler(w, &slog.HandlerOptions{
|
||||||
ReplaceAttr: func(groups []string, attr slog.Attr) slog.Attr {
|
ReplaceAttr: func(groups []string, attr slog.Attr) slog.Attr {
|
||||||
if attr.Key == slog.TimeKey && len(groups) == 0 {
|
if attr.Key == slog.TimeKey && len(groups) == 0 {
|
||||||
@@ -183,5 +183,5 @@ func NewProcessLogger(w io.Writer) *slog.Logger {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
return slog.New(handler).With("type", "process")
|
return slog.New(handler).With("type", "process", "instance", instanceName)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,12 +65,12 @@ func TestWriteWritesOneJSONLineMarkedRequest(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestProcessLinesAreMarkedProcess(t *testing.T) {
|
func TestProcessLinesAreMarkedProcessAndGiveTheInstance(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
var out bytes.Buffer
|
var out bytes.Buffer
|
||||||
|
|
||||||
requestlog.NewProcessLogger(&out).Info("starting", "version", "v1")
|
requestlog.NewProcessLogger(&out, "fsn1app1/gitea").Info("starting", "version", "v1")
|
||||||
|
|
||||||
var fields map[string]any
|
var fields map[string]any
|
||||||
|
|
||||||
@@ -79,8 +79,9 @@ func TestProcessLinesAreMarkedProcess(t *testing.T) {
|
|||||||
t.Fatalf("decode %q: %v", out.String(), err)
|
t.Fatalf("decode %q: %v", out.String(), err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if fields["type"] != "process" || fields["msg"] != "starting" ||
|
if fields["type"] != "process" || fields["instance"] != "fsn1app1/gitea" ||
|
||||||
fields["level"] != "INFO" || fields["version"] != "v1" {
|
fields["msg"] != "starting" || fields["level"] != "INFO" ||
|
||||||
|
fields["version"] != "v1" {
|
||||||
t.Errorf("process line %v", fields)
|
t.Errorf("process line %v", fields)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -68,7 +68,8 @@ func Main(version string) int {
|
|||||||
// requests until ctx is done. It returns the process's exit status, 1
|
// requests until ctx is done. It returns the process's exit status, 1
|
||||||
// when smallwebwaf cannot start.
|
// when smallwebwaf cannot start.
|
||||||
func Run(ctx context.Context, params Params) int {
|
func Run(ctx context.Context, params Params) int {
|
||||||
processLog := requestlog.NewProcessLogger(params.Stdout)
|
processLog := requestlog.NewProcessLogger(params.Stdout,
|
||||||
|
config.InstanceName(params.LookupEnv))
|
||||||
|
|
||||||
cfg, err := config.FromEnvironment(params.LookupEnv)
|
cfg, err := config.FromEnvironment(params.LookupEnv)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -86,7 +87,7 @@ func Run(ctx context.Context, params Params) int {
|
|||||||
if cfg.LogRemoteURL != nil {
|
if cfg.LogRemoteURL != nil {
|
||||||
remote = newRemoteLogSender(cfg)
|
remote = newRemoteLogSender(cfg)
|
||||||
stdout = io.MultiWriter(params.Stdout, remote)
|
stdout = io.MultiWriter(params.Stdout, remote)
|
||||||
processLog = requestlog.NewProcessLogger(stdout)
|
processLog = requestlog.NewProcessLogger(stdout, cfg.InstanceName)
|
||||||
|
|
||||||
stopSending := startSending(ctx, remote, processLog)
|
stopSending := startSending(ctx, remote, processLog)
|
||||||
defer stopSending()
|
defer stopSending()
|
||||||
|
|||||||
@@ -38,10 +38,14 @@ const (
|
|||||||
stateCounterInterval = "SWWAF_STATE_COUNTER_INTERVAL"
|
stateCounterInterval = "SWWAF_STATE_COUNTER_INTERVAL"
|
||||||
rateLimitPerDay = "SWWAF_RATE_LIMIT_PER_DAY"
|
rateLimitPerDay = "SWWAF_RATE_LIMIT_PER_DAY"
|
||||||
rulesDir = "SWWAF_RULES_DIR"
|
rulesDir = "SWWAF_RULES_DIR"
|
||||||
|
instanceName = "SWWAF_INSTANCE_NAME"
|
||||||
adminToken = "SWWAF_ADMIN_TOKEN" //nolint:gosec // the setting's name
|
adminToken = "SWWAF_ADMIN_TOKEN" //nolint:gosec // the setting's name
|
||||||
metricsToken = "SWWAF_METRICS_TOKEN" //nolint:gosec // the setting's name
|
metricsToken = "SWWAF_METRICS_TOKEN" //nolint:gosec // the setting's name
|
||||||
// adminSecret is the SWWAF_ADMIN_TOKEN the tests set.
|
// adminSecret is the SWWAF_ADMIN_TOKEN the tests set.
|
||||||
adminSecret = "fedcba9876543210fedcba9876543210"
|
adminSecret = "fedcba9876543210fedcba9876543210"
|
||||||
|
// instance is the SWWAF_INSTANCE_NAME the tests set where they look at
|
||||||
|
// it.
|
||||||
|
instance = "fsn1app1/gitea"
|
||||||
// greeting is what the tests' app answers.
|
// greeting is what the tests' app answers.
|
||||||
greeting = "hello from the app"
|
greeting = "hello from the app"
|
||||||
)
|
)
|
||||||
@@ -118,7 +122,10 @@ func TestInvalidSettingStopsTheStart(t *testing.T) {
|
|||||||
|
|
||||||
out := &output{}
|
out := &output{}
|
||||||
|
|
||||||
status := run(t.Context(), map[string]string{"SWWAF_REQUEST_MAX_BYTES": "lots"}, out)
|
status := run(t.Context(), map[string]string{
|
||||||
|
"SWWAF_REQUEST_MAX_BYTES": "lots",
|
||||||
|
instanceName: instance,
|
||||||
|
}, out)
|
||||||
if status != 1 {
|
if status != 1 {
|
||||||
t.Errorf("exit status %d, want 1", status)
|
t.Errorf("exit status %d, want 1", status)
|
||||||
}
|
}
|
||||||
@@ -126,7 +133,8 @@ func TestInvalidSettingStopsTheStart(t *testing.T) {
|
|||||||
line := out.line(t, "msg", "invalid setting")
|
line := out.line(t, "msg", "invalid setting")
|
||||||
message, _ := line["error"].(string)
|
message, _ := line["error"].(string)
|
||||||
|
|
||||||
if line["type"] != "process" || line["level"] != "ERROR" ||
|
if line["type"] != "process" || line["instance"] != instance ||
|
||||||
|
line["level"] != "ERROR" ||
|
||||||
!strings.HasPrefix(message, "SWWAF_REQUEST_MAX_BYTES: ") {
|
!strings.HasPrefix(message, "SWWAF_REQUEST_MAX_BYTES: ") {
|
||||||
t.Errorf("start refused with %v", line)
|
t.Errorf("start refused with %v", line)
|
||||||
}
|
}
|
||||||
@@ -231,6 +239,46 @@ func TestServesUntilToldToStop(t *testing.T) {
|
|||||||
out.line(t, "msg", "stopped")
|
out.line(t, "msg", "stopped")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestEveryLogLineAndMetricCarriesTheInstanceName(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const token = "0123456789abcdef0123456789abcdef"
|
||||||
|
|
||||||
|
env := map[string]string{
|
||||||
|
listenAddr: localhost + ":0",
|
||||||
|
upstreamURL: startApp(t),
|
||||||
|
stateDir: t.TempDir(),
|
||||||
|
rulesDir: t.TempDir(),
|
||||||
|
metricsToken: token,
|
||||||
|
instanceName: instance,
|
||||||
|
}
|
||||||
|
|
||||||
|
var metrics string
|
||||||
|
|
||||||
|
out := runUntilStopped(t, env, func(url string) {
|
||||||
|
metrics = metricsText(t, url+"_smallwebwaf/metrics", token)
|
||||||
|
})
|
||||||
|
|
||||||
|
// The process's lines from its start to its stop, and the request's.
|
||||||
|
for line := range strings.Lines(out.text()) {
|
||||||
|
var fields map[string]any
|
||||||
|
|
||||||
|
err := json.Unmarshal([]byte(line), &fields)
|
||||||
|
if err != nil || fields["instance"] != instance {
|
||||||
|
t.Errorf("line %q (%v), want instance %s", line, err, instance)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each series, Go's and the process's included; the other lines are
|
||||||
|
// the comments.
|
||||||
|
for line := range strings.Lines(metrics) {
|
||||||
|
if !strings.HasPrefix(line, "#") &&
|
||||||
|
!strings.Contains(line, `instance="fsn1app1/gitea"`) {
|
||||||
|
t.Errorf("series %q, without instance=\"fsn1app1/gitea\"", line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestStateKeptAcrossRestarts(t *testing.T) {
|
func TestStateKeptAcrossRestarts(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -519,6 +567,7 @@ func TestStalledRemoteLogEndpointHoldsUpNoRequest(t *testing.T) {
|
|||||||
"SWWAF_LOG_REMOTE_URL": "syslog+tls://" + endpoint.Addr().String(),
|
"SWWAF_LOG_REMOTE_URL": "syslog+tls://" + endpoint.Addr().String(),
|
||||||
"SWWAF_LOG_REMOTE_BUFFER": "1",
|
"SWWAF_LOG_REMOTE_BUFFER": "1",
|
||||||
metricsToken: token,
|
metricsToken: token,
|
||||||
|
instanceName: instance,
|
||||||
}
|
}
|
||||||
|
|
||||||
out := runUntilStopped(t, env, func(url string) {
|
out := runUntilStopped(t, env, func(url string) {
|
||||||
@@ -528,16 +577,17 @@ func TestStalledRemoteLogEndpointHoldsUpNoRequest(t *testing.T) {
|
|||||||
// last.
|
// last.
|
||||||
metrics := metricsText(t, url+"_smallwebwaf/metrics", token)
|
metrics := metricsText(t, url+"_smallwebwaf/metrics", token)
|
||||||
for _, series := range []string{
|
for _, series := range []string{
|
||||||
"smallwebwaf_remote_log_lines_sent_total 0",
|
`smallwebwaf_remote_log_lines_sent_total{instance="fsn1app1/gitea"} 0`,
|
||||||
"smallwebwaf_remote_log_buffer_depth 1",
|
`smallwebwaf_remote_log_buffer_depth{instance="fsn1app1/gitea"} 1`,
|
||||||
} {
|
} {
|
||||||
if !strings.Contains(metrics, "\n"+series+"\n") {
|
if !strings.Contains(metrics, "\n"+series+"\n") {
|
||||||
t.Errorf("no %q in the metrics:\n%s", series, metrics)
|
t.Errorf("no %q in the metrics:\n%s", series, metrics)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.Contains(metrics, "\nsmallwebwaf_remote_log_lines_dropped_total 0\n") ||
|
const dropped = "\nsmallwebwaf_remote_log_lines_dropped_total" +
|
||||||
!strings.Contains(metrics, "\nsmallwebwaf_remote_log_lines_dropped_total ") {
|
`{instance="fsn1app1/gitea"} `
|
||||||
|
if strings.Contains(metrics, dropped+"0\n") || !strings.Contains(metrics, dropped) {
|
||||||
t.Errorf("no line dropped in the metrics:\n%s", metrics)
|
t.Errorf("no line dropped in the metrics:\n%s", metrics)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -547,6 +597,13 @@ func TestStalledRemoteLogEndpointHoldsUpNoRequest(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
out.line(t, "type", "request")
|
out.line(t, "type", "request")
|
||||||
|
|
||||||
|
// While the lines are sent, the process's lines give the instance name
|
||||||
|
// too.
|
||||||
|
line := out.line(t, "msg", "starting")
|
||||||
|
if line["instance"] != instance {
|
||||||
|
t.Errorf("start logged with instance %v, want %s", line["instance"], instance)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBanIsAlertedAndAnAlertNotSentIsKeptAcrossARestart(t *testing.T) {
|
func TestBanIsAlertedAndAnAlertNotSentIsKeptAcrossARestart(t *testing.T) {
|
||||||
@@ -569,6 +626,7 @@ func TestBanIsAlertedAndAnAlertNotSentIsKeptAcrossARestart(t *testing.T) {
|
|||||||
rulesDir: rules,
|
rulesDir: rules,
|
||||||
"SWWAF_ALERT_WEBHOOK_URL": webhook.url,
|
"SWWAF_ALERT_WEBHOOK_URL": webhook.url,
|
||||||
"SWWAF_ALERT_WEBHOOK_HEADERS": "Authorization:Bearer " + adminSecret,
|
"SWWAF_ALERT_WEBHOOK_HEADERS": "Authorization:Bearer " + adminSecret,
|
||||||
|
instanceName: instance,
|
||||||
}
|
}
|
||||||
|
|
||||||
runUntilStopped(t, env, func(url string) {
|
runUntilStopped(t, env, func(url string) {
|
||||||
@@ -622,19 +680,13 @@ func TestBanIsAlertedAndAnAlertNotSentIsKeptAcrossARestart(t *testing.T) {
|
|||||||
runUntilStopped(t, env, func(url string) {
|
runUntilStopped(t, env, func(url string) {
|
||||||
webhook.waitFor(t, "permanent_ban", true)
|
webhook.waitFor(t, "permanent_ban", true)
|
||||||
|
|
||||||
// As long as that takes, so that a slow test process cannot fail
|
const ofWebhook = `{destination="webhook",instance="fsn1app1/gitea"}`
|
||||||
// the test.
|
|
||||||
const sent = "\nsmallwebwaf_alerts_sent_total{destination=\"webhook\"} 1\n"
|
|
||||||
|
|
||||||
metrics := metricsText(t, url+"_smallwebwaf/metrics", token)
|
metrics := metricsWith(t, url+"_smallwebwaf/metrics", token,
|
||||||
for !strings.Contains(metrics, sent) {
|
"\nsmallwebwaf_alerts_sent_total"+ofWebhook+" 1\n")
|
||||||
time.Sleep(pollInterval)
|
|
||||||
|
|
||||||
metrics = metricsText(t, url+"_smallwebwaf/metrics", token)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, series := range []string{"failed", "suppressed", "dropped"} {
|
for _, series := range []string{"failed", "suppressed", "dropped"} {
|
||||||
zero := "\nsmallwebwaf_alerts_" + series + "_total{destination=\"webhook\"} 0\n"
|
zero := "\nsmallwebwaf_alerts_" + series + "_total" + ofWebhook + " 0\n"
|
||||||
if !strings.Contains(metrics, zero) {
|
if !strings.Contains(metrics, zero) {
|
||||||
t.Errorf("no %q in the metrics:\n%s", zero, metrics)
|
t.Errorf("no %q in the metrics:\n%s", zero, metrics)
|
||||||
}
|
}
|
||||||
@@ -662,7 +714,7 @@ func TestBanIsAlertedToSlackAndNtfy(t *testing.T) {
|
|||||||
// The metrics are read from 127.0.0.1, which no limit counts.
|
// The metrics are read from 127.0.0.1, which no limit counts.
|
||||||
"SWWAF_ALLOW_NETS": localhost + "/32",
|
"SWWAF_ALLOW_NETS": localhost + "/32",
|
||||||
metricsToken: token,
|
metricsToken: token,
|
||||||
"SWWAF_INSTANCE_NAME": "fsn1app1/gitea",
|
instanceName: instance,
|
||||||
"SWWAF_ALERT_SLACK_WEBHOOK_URL": slack.url,
|
"SWWAF_ALERT_SLACK_WEBHOOK_URL": slack.url,
|
||||||
"SWWAF_ALERT_NTFY_URL": ntfy.url,
|
"SWWAF_ALERT_NTFY_URL": ntfy.url,
|
||||||
"SWWAF_ALERT_NTFY_TOKEN": ntfyToken,
|
"SWWAF_ALERT_NTFY_TOKEN": ntfyToken,
|
||||||
@@ -698,17 +750,13 @@ func TestBanIsAlertedToSlackAndNtfy(t *testing.T) {
|
|||||||
// webhook, which is not set. As long as that takes, so that a slow
|
// webhook, which is not set. As long as that takes, so that a slow
|
||||||
// test process cannot fail the test.
|
// test process cannot fail the test.
|
||||||
sent := []string{
|
sent := []string{
|
||||||
"\nsmallwebwaf_alerts_sent_total{destination=\"slack\"} 1\n",
|
"\nsmallwebwaf_alerts_sent_total{destination=\"slack\"," +
|
||||||
"\nsmallwebwaf_alerts_sent_total{destination=\"ntfy\"} 1\n",
|
"instance=\"fsn1app1/gitea\"} 1\n",
|
||||||
}
|
"\nsmallwebwaf_alerts_sent_total{destination=\"ntfy\"," +
|
||||||
|
"instance=\"fsn1app1/gitea\"} 1\n",
|
||||||
metrics := metricsText(t, url+"_smallwebwaf/metrics", token)
|
|
||||||
for !strings.Contains(metrics, sent[0]) || !strings.Contains(metrics, sent[1]) {
|
|
||||||
time.Sleep(pollInterval)
|
|
||||||
|
|
||||||
metrics = metricsText(t, url+"_smallwebwaf/metrics", token)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
metrics := metricsWith(t, url+"_smallwebwaf/metrics", token, sent...)
|
||||||
if strings.Contains(metrics, `destination="webhook"`) {
|
if strings.Contains(metrics, `destination="webhook"`) {
|
||||||
t.Errorf("the metrics give the webhook:\n%s", metrics)
|
t.Errorf("the metrics give the webhook:\n%s", metrics)
|
||||||
}
|
}
|
||||||
@@ -974,6 +1022,26 @@ func metricsText(t *testing.T, url, token string) string {
|
|||||||
return string(body)
|
return string(body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// metricsWith asks for the metrics at url with token until they hold each
|
||||||
|
// of series, as long as that takes, so that a slow test process cannot
|
||||||
|
// fail the test, and returns them.
|
||||||
|
func metricsWith(t *testing.T, url, token string, series ...string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
for {
|
||||||
|
metrics := metricsText(t, url, token)
|
||||||
|
|
||||||
|
missing := slices.ContainsFunc(series, func(one string) bool {
|
||||||
|
return !strings.Contains(metrics, one)
|
||||||
|
})
|
||||||
|
if !missing {
|
||||||
|
return metrics
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(pollInterval)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// askAsAdmin sends a request with method to url, with body and
|
// askAsAdmin sends a request with method to url, with body and
|
||||||
// adminSecret, and checks that it is answered 200.
|
// adminSecret, and checks that it is answered 200.
|
||||||
func askAsAdmin(t *testing.T, method, url, body string) {
|
func askAsAdmin(t *testing.T, method, url, body string) {
|
||||||
|
|||||||
@@ -736,8 +736,8 @@ func TestWritesAreCountedInTheMetrics(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
ofBans = `{file="bans.json"}`
|
ofBans = `{file="bans.json",instance="app"}`
|
||||||
ofClients = `{file="clients.json"}`
|
ofClients = `{file="clients.json",instance="app"}`
|
||||||
)
|
)
|
||||||
|
|
||||||
got := scrape(t, params)
|
got := scrape(t, params)
|
||||||
@@ -1162,7 +1162,7 @@ func TestEditsTakenInAreCountedInTheMetrics(t *testing.T) {
|
|||||||
wantTakenIn(t, lines, dir, bansJSON)
|
wantTakenIn(t, lines, dir, bansJSON)
|
||||||
|
|
||||||
wantMetric(t, scrape(t, params),
|
wantMetric(t, scrape(t, params),
|
||||||
`smallwebwaf_state_file_edits_taken_in_total{file="bans.json"}`, 2)
|
`smallwebwaf_state_file_edits_taken_in_total{file="bans.json",instance="app"}`, 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEditTakenInByAWriteIsLoggedAsWatchLogsIt(t *testing.T) {
|
func TestEditTakenInByAWriteIsLoggedAsWatchLogsIt(t *testing.T) {
|
||||||
@@ -1227,7 +1227,7 @@ func TestEditsSetAsideAreCountedInTheMetrics(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
wantMetric(t, scrape(t, params),
|
wantMetric(t, scrape(t, params),
|
||||||
`smallwebwaf_state_file_edits_set_aside_total{file="bans.json"}`, 1)
|
`smallwebwaf_state_file_edits_set_aside_total{file="bans.json",instance="app"}`, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDirectoryThatCannotBeWatchedIsLogged(t *testing.T) {
|
func TestDirectoryThatCannotBeWatchedIsLogged(t *testing.T) {
|
||||||
@@ -1262,7 +1262,7 @@ func midnight() time.Time {
|
|||||||
// hour, are never sent.
|
// hour, are never sent.
|
||||||
func newParams(dir string) state.Params {
|
func newParams(dir string) state.Params {
|
||||||
discard := slog.New(slog.DiscardHandler)
|
discard := slog.New(slog.DiscardHandler)
|
||||||
m := metrics.New(1)
|
m := metrics.New(1, "app")
|
||||||
|
|
||||||
return state.Params{
|
return state.Params{
|
||||||
Dir: dir,
|
Dir: dir,
|
||||||
@@ -1647,8 +1647,8 @@ func scrape(t *testing.T, params state.Params) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// metric returns the value of series in text, the metrics, such as
|
// metric returns the value of series in text, the metrics, such as
|
||||||
// smallwebwaf_state_file_writes_total{file="bans.json"}, or fails the test
|
// smallwebwaf_state_file_writes_total{file="bans.json",instance="app"}, or
|
||||||
// if there is no such series.
|
// fails the test if there is no such series.
|
||||||
func metric(t *testing.T, text, series string) float64 {
|
func metric(t *testing.T, text, series string) float64 {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
@@ -1677,7 +1677,7 @@ func wantWriteFailed(t *testing.T, params state.Params, name string) {
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
got := scrape(t, params)
|
got := scrape(t, params)
|
||||||
file := `{file="` + name + `"}`
|
file := `{file="` + name + `",instance="app"}`
|
||||||
|
|
||||||
wantMetric(t, got, "smallwebwaf_state_file_writes_total"+file, 1)
|
wantMetric(t, got, "smallwebwaf_state_file_writes_total"+file, 1)
|
||||||
wantMetric(t, got, "smallwebwaf_state_file_write_failures_total"+file, 1)
|
wantMetric(t, got, "smallwebwaf_state_file_write_failures_total"+file, 1)
|
||||||
|
|||||||
Reference in New Issue
Block a user