Send every log line to a syslog server as well (closes #28)
check / check (push) Waiting to run
check / check (push) Waiting to run
With SWWAF_LOG_REMOTE_URL set (syslog+udp, syslog+tcp or syslog+tls), every line on stdout is also sent as the message of an RFC 5424 record, octet-counted over TCP and TLS, from a bounded buffer that drops its oldest line when full, so a slow or unreachable server holds up nothing. Failed connections are retried with backoff; lines sent, dropped and waiting are metrics. At a stop the lines still waiting get at most two seconds. SWWAF_LOG_REMOTE_APP_NAME defaults to SWWAF_INSTANCE_NAME; while sending, an app name RFC 5424 does not allow stops the start. Standard library only: log/syslog writes only the older format. Model: opus-5-5
This commit was merged in pull request #84.
This commit is contained in:
@@ -2,11 +2,13 @@ package config_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -54,15 +56,41 @@ const (
|
||||
logRequestHeaders = "SWWAF_LOG_REQUEST_HEADERS"
|
||||
rulesDir = "SWWAF_RULES_DIR"
|
||||
rulesEnabled = "SWWAF_RULES_ENABLED"
|
||||
logRemoteURL = "SWWAF_LOG_REMOTE_URL"
|
||||
logRemoteTLSCAFile = "SWWAF_LOG_REMOTE_TLS_CA_FILE"
|
||||
logRemoteBuffer = "SWWAF_LOG_REMOTE_BUFFER"
|
||||
logRemoteFacility = "SWWAF_LOG_REMOTE_FACILITY"
|
||||
logRemoteAppName = "SWWAF_LOG_REMOTE_APP_NAME"
|
||||
)
|
||||
|
||||
// defaultLogRequestHeaders is the default of SWWAF_LOG_REQUEST_HEADERS.
|
||||
const defaultLogRequestHeaders = "accept,accept-language,accept-encoding," +
|
||||
"content-type,origin,range"
|
||||
|
||||
// testCA is a CA certificate, of which only that it reads matters here.
|
||||
const testCA = `-----BEGIN CERTIFICATE-----
|
||||
MIIBkzCCATmgAwIBAgIUeySaE27dnr6A2HijrMB13gTLUKIwCgYIKoZIzj0EAwIw
|
||||
HjEcMBoGA1UEAwwTc21hbGx3ZWJ3YWYgdGVzdCBDQTAgFw0yNjEwMDYxNDI2MTNa
|
||||
GA8yMTI2MDkxMjE0MjYxM1owHjEcMBoGA1UEAwwTc21hbGx3ZWJ3YWYgdGVzdCBD
|
||||
QTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABKDEhcWKKhet2KgSdME+iEPxyEyn
|
||||
2sd9IdElbt8DM2SfCdB2JsXo0C07UNZaywMPMfn/n8LNI/PKwu+N2uX7gfSjUzBR
|
||||
MB0GA1UdDgQWBBRZo3BPLv0KbV4drw6JI1JIUKmoRzAfBgNVHSMEGDAWgBRZo3BP
|
||||
Lv0KbV4drw6JI1JIUKmoRzAPBgNVHRMBAf8EBTADAQH/MAoGCCqGSM49BAMCA0gA
|
||||
MEUCICC5k+76UpWoSwVbZA+atu5WcALEOJGqwOUWua3zemhcAiEA8Hfdxgwp0z2v
|
||||
rlG9y/jrJb6ORy3kTLWo2EA0BA67vuI=
|
||||
-----END CERTIFICATE-----
|
||||
`
|
||||
|
||||
// token is a token of 32 characters, the shortest allowed.
|
||||
const token = "0123456789abcdef0123456789abcdef"
|
||||
|
||||
// instance is an SWWAF_INSTANCE_NAME that is a valid app name too, and
|
||||
// remoteURL an SWWAF_LOG_REMOTE_URL, for the tests that send the lines.
|
||||
const (
|
||||
instance = "fsn1app1/gitea"
|
||||
remoteURL = "syslog+udp://192.0.2.1:514"
|
||||
)
|
||||
|
||||
// off switches a timeout, a size limit or a rate limit off.
|
||||
const off = "off"
|
||||
|
||||
@@ -273,6 +301,177 @@ func TestInstanceNameAndLoggedHeadersAsSet(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteLogSettingsDefaults(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := fromEnvironment(t, environment{instanceName: instance})
|
||||
|
||||
if cfg.LogRemoteURL != nil || cfg.LogRemoteTLSCAs != nil ||
|
||||
cfg.LogRemoteBuffer != 10000 || cfg.LogRemoteFacility != 16 ||
|
||||
cfg.LogRemoteAppName != instance {
|
||||
t.Errorf("remote log settings %v, %v, %d, %d and %q, want no URL, no "+
|
||||
"certificates, 10000, 16 and %s's %s", cfg.LogRemoteURL,
|
||||
cfg.LogRemoteTLSCAs, cfg.LogRemoteBuffer, cfg.LogRemoteFacility,
|
||||
cfg.LogRemoteAppName, instanceName, instance)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteLogSettingsAsSet(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
caFile := filepath.Join(t.TempDir(), "ca.pem")
|
||||
|
||||
err := os.WriteFile(caFile, []byte(testCA), 0o600)
|
||||
if err != nil {
|
||||
t.Fatalf("write %s: %v", caFile, err)
|
||||
}
|
||||
|
||||
cfg := fromEnvironment(t, environment{
|
||||
logRemoteURL: "syslog+tls://logs.example:6514",
|
||||
logRemoteTLSCAFile: caFile,
|
||||
logRemoteBuffer: "500",
|
||||
logRemoteFacility: "daemon",
|
||||
logRemoteAppName: instance,
|
||||
})
|
||||
|
||||
roots := x509.NewCertPool()
|
||||
roots.AppendCertsFromPEM([]byte(testCA))
|
||||
|
||||
if cfg.LogRemoteURL.String() != "syslog+tls://logs.example:6514" ||
|
||||
!roots.Equal(cfg.LogRemoteTLSCAs) || cfg.LogRemoteBuffer != 500 ||
|
||||
cfg.LogRemoteFacility != 3 || cfg.LogRemoteAppName != instance {
|
||||
t.Errorf("remote log settings %v, %v, %d, %d and %q", cfg.LogRemoteURL,
|
||||
cfg.LogRemoteTLSCAs, cfg.LogRemoteBuffer, cfg.LogRemoteFacility,
|
||||
cfg.LogRemoteAppName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteLogURLForms(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, value := range []string{
|
||||
"syslog+udp://192.0.2.1:514",
|
||||
"syslog+tcp://[2001:db8::1]:514",
|
||||
"syslog+tls://logs.example:6514/",
|
||||
} {
|
||||
cfg := fromEnvironment(t, environment{logRemoteURL: value})
|
||||
if cfg.LogRemoteURL.String() != value {
|
||||
t.Errorf("%s read as %v", value, cfg.LogRemoteURL)
|
||||
}
|
||||
}
|
||||
|
||||
cfg := fromEnvironment(t, environment{logRemoteURL: ""})
|
||||
if cfg.LogRemoteURL != nil {
|
||||
t.Errorf("set but empty, %s read as %v", logRemoteURL, cfg.LogRemoteURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteLogFacilitiesByNumber(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for name, number := range map[string]int{
|
||||
"kern": 0, "user": 1, "auth": 4, "authpriv": 10, "ftp": 11,
|
||||
"local0": 16, "local5": 21, "local7": 23,
|
||||
} {
|
||||
cfg := fromEnvironment(t, environment{logRemoteFacility: name})
|
||||
if cfg.LogRemoteFacility != number {
|
||||
t.Errorf("%s read as %d, want %d", name, cfg.LogRemoteFacility, number)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidRemoteLogSettingStopsTheStart(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct{ name, value string }{
|
||||
{logRemoteURL, "logs.example:514"},
|
||||
{logRemoteURL, "syslog://logs.example:514"},
|
||||
{logRemoteURL, "http://logs.example:514"},
|
||||
{logRemoteURL, "syslog+udp://logs.example"},
|
||||
{logRemoteURL, "syslog+tcp://:514"},
|
||||
{logRemoteURL, "syslog+tcp://logs.example:0"},
|
||||
{logRemoteURL, "syslog+tls://logs.example:65536"},
|
||||
{logRemoteURL, "syslog+tls://user@logs.example:6514"},
|
||||
{logRemoteURL, "syslog+tcp://logs.example:514/app"},
|
||||
{logRemoteURL, "syslog+tcp://logs.example:514?tls=1"},
|
||||
{logRemoteTLSCAFile, "/nonexistent/ca.pem"},
|
||||
{logRemoteBuffer, off}, {logRemoteBuffer, "0"}, {logRemoteBuffer, "10K"},
|
||||
{logRemoteFacility, "local8"}, {logRemoteFacility, "LOCAL0"},
|
||||
{logRemoteFacility, "16"}, {logRemoteFacility, ""},
|
||||
{logRemoteAppName, ""}, {logRemoteAppName, "my app"},
|
||||
{logRemoteAppName, "gitéa"}, {logRemoteAppName, strings.Repeat("a", 49)},
|
||||
} {
|
||||
_, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv)
|
||||
if err == nil || !strings.HasPrefix(err.Error(), tc.name+": ") {
|
||||
t.Errorf("%s=%q: error %v, want one naming it", tc.name, tc.value, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteLogCAFileWithoutCertificateStopsTheStart(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
caFile := filepath.Join(t.TempDir(), "ca.pem")
|
||||
|
||||
err := os.WriteFile(caFile, []byte("not a certificate\n"), 0o600)
|
||||
if err != nil {
|
||||
t.Fatalf("write %s: %v", caFile, err)
|
||||
}
|
||||
|
||||
_, err = config.FromEnvironment(environment{logRemoteTLSCAFile: caFile}.lookupEnv)
|
||||
|
||||
want := logRemoteTLSCAFile + `: "` + caFile + `" holds no PEM certificate`
|
||||
if err == nil || err.Error() != want {
|
||||
t.Errorf("error %v, want %s", err, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstanceNameNotAnAppNameStopsTheStartOnlyWhileSending(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const spaced = "fsn1 app1"
|
||||
|
||||
sending := environment{logRemoteURL: remoteURL, instanceName: spaced}
|
||||
|
||||
_, err := config.FromEnvironment(sending.lookupEnv)
|
||||
|
||||
want := logRemoteAppName + `: is unset, and ` + instanceName +
|
||||
` "fsn1 app1", its default, is not 1 to 48 printable ASCII characters ` +
|
||||
`without a space, such as gitea`
|
||||
if err == nil || err.Error() != want {
|
||||
t.Errorf("error %v, want %s", err, want)
|
||||
}
|
||||
|
||||
cfg := fromEnvironment(t, environment{instanceName: spaced})
|
||||
if cfg.LogRemoteAppName != spaced {
|
||||
t.Errorf("not sending, %s is %q", logRemoteAppName, cfg.LogRemoteAppName)
|
||||
}
|
||||
|
||||
sending[logRemoteAppName] = instance
|
||||
|
||||
cfg = fromEnvironment(t, sending)
|
||||
if cfg.LogRemoteAppName != instance {
|
||||
t.Errorf("set to %s, %s is %q", instance, logRemoteAppName,
|
||||
cfg.LogRemoteAppName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppNameSetStopsTheStartWhileSending(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := config.FromEnvironment(environment{
|
||||
logRemoteURL: remoteURL,
|
||||
instanceName: instance,
|
||||
logRemoteAppName: "my app",
|
||||
}.lookupEnv)
|
||||
|
||||
want := logRemoteAppName + `: "my app" is not 1 to 48 printable ASCII ` +
|
||||
`characters without a space, such as gitea`
|
||||
if err == nil || err.Error() != want {
|
||||
t.Errorf("error %v, want %s", err, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodeOnBothCountryListsStopsTheStart(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -581,6 +780,11 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
|
||||
logRequestHeaders: defaultLogRequestHeaders,
|
||||
rulesDir: "/etc/smallwebwaf/rules.d",
|
||||
rulesEnabled: "true",
|
||||
logRemoteURL: "",
|
||||
logRemoteTLSCAFile: "",
|
||||
logRemoteBuffer: "10000",
|
||||
logRemoteFacility: "local0",
|
||||
logRemoteAppName: hostname,
|
||||
}
|
||||
if !maps.Equal(line.Settings, want) {
|
||||
t.Errorf("logged settings\n%v\nwant\n%v", line.Settings, want)
|
||||
|
||||
Reference in New Issue
Block a user