Send every log line to a syslog server as well (closes #28)
check / check (push) Successful in 3m3s

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:
2026-10-06 22:02:36 +02:00
parent e77dfb6891
commit 0797e5def2
9 changed files with 1737 additions and 24 deletions
+171
View File
@@ -10,6 +10,8 @@ import (
"net/http/httptest"
"os"
"path/filepath"
"slices"
"strconv"
"strings"
"sync"
"testing"
@@ -385,6 +387,112 @@ func TestRulesDirThatDoesNotExistStopsTheStart(t *testing.T) {
": no such file or directory")
}
func TestEveryLineIsAlsoSentToTheRemoteLogEndpoint(t *testing.T) {
t.Parallel()
endpoint, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", localhost+":0")
if err != nil {
t.Fatalf("listen: %v", err)
}
defer func() {
_ = endpoint.Close()
}()
env := map[string]string{
listenAddr: localhost + ":0",
upstreamURL: startApp(t),
stateDir: t.TempDir(),
rulesDir: t.TempDir(),
"SWWAF_LOG_REMOTE_URL": "syslog+tcp://" + endpoint.Addr().String(),
}
out := runUntilStopped(t, env, func(url string) {
wantGreeting(t, url)
})
out.line(t, "type", "request")
// smallwebwaf connected as it started, and closes the connection once
// it has sent the lines written as it stopped.
conn, err := endpoint.Accept()
if err != nil {
t.Fatalf("accept: %v", err)
}
received, err := io.ReadAll(conn)
_ = conn.Close()
if err != nil {
t.Fatalf("read: %v", err)
}
// Lines written at once by several goroutines may reach stdout and
// the endpoint in different orders.
sent := messages(t, string(received))
written := slices.Collect(strings.Lines(out.text()))
slices.Sort(sent)
slices.Sort(written)
if !slices.Equal(sent, written) {
t.Errorf("sent\n%v\nwrote\n%v", sent, written)
}
}
func TestStalledRemoteLogEndpointHoldsUpNoRequest(t *testing.T) {
t.Parallel()
const token = "0123456789abcdef0123456789abcdef"
// The endpoint takes connections and never answers, so the TLS
// handshake of each waits on it, and no line is ever sent.
endpoint, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", localhost+":0")
if err != nil {
t.Fatalf("listen: %v", err)
}
defer func() {
_ = endpoint.Close()
}()
env := map[string]string{
listenAddr: localhost + ":0",
upstreamURL: startApp(t),
stateDir: t.TempDir(),
rulesDir: t.TempDir(),
"SWWAF_LOG_REMOTE_URL": "syslog+tls://" + endpoint.Addr().String(),
"SWWAF_LOG_REMOTE_BUFFER": "1",
"SWWAF_METRICS_TOKEN": token,
}
out := runUntilStopped(t, env, func(url string) {
wantGreeting(t, url)
// More than one line has been written, and the buffer holds the
// last.
metrics := metricsText(t, url+"_smallwebwaf/metrics", token)
for _, series := range []string{
"smallwebwaf_remote_log_lines_sent_total 0",
"smallwebwaf_remote_log_buffer_depth 1",
} {
if !strings.Contains(metrics, "\n"+series+"\n") {
t.Errorf("no %q in the metrics:\n%s", series, metrics)
}
}
if strings.Contains(metrics, "\nsmallwebwaf_remote_log_lines_dropped_total 0\n") ||
!strings.Contains(metrics, "\nsmallwebwaf_remote_log_lines_dropped_total ") {
t.Errorf("no line dropped in the metrics:\n%s", metrics)
}
// Closed, the endpoint refuses the connection made to send the
// lines still waiting at the stop, which then does not wait.
_ = endpoint.Close()
})
out.line(t, "type", "request")
}
func TestStateFileThatDoesNotParseStopsTheStart(t *testing.T) {
t.Parallel()
@@ -581,6 +689,69 @@ func wantGreeting(t *testing.T, url string) {
}
}
// messages returns the message of each record in received, octet-counted
// frames of RFC 5424 records with the default facility and app name, each
// with the newline that ends a line on stdout.
func messages(t *testing.T, received string) []string {
t.Helper()
hostname, _ := os.Hostname()
header := " " + hostname + " " + hostname + " - - - "
var found []string
for received != "" {
count, rest, _ := strings.Cut(received, " ")
length, err := strconv.Atoi(count)
if err != nil || length > len(rest) {
t.Fatalf("no frame at %q", received)
}
record := rest[:length]
received = rest[length:]
_, message, ok := strings.Cut(record, header)
if !ok || !strings.HasPrefix(record, "<134>1 ") {
t.Fatalf("record %q, want priority <134> and header %q", record, header)
}
found = append(found, message+"\n")
}
return found
}
// metricsText asks for the metrics at url with token, and returns them.
func metricsText(t *testing.T, url, token string) string {
t.Helper()
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, url,
http.NoBody)
if err != nil {
t.Fatalf("new request: %v", err)
}
req.Header.Set("Authorization", "Bearer "+token)
transport := &http.Transport{}
defer transport.CloseIdleConnections()
res, err := (&http.Client{Transport: transport}).Do(req)
if err != nil {
t.Fatalf("request: %v", err)
}
body, err := io.ReadAll(res.Body)
_ = res.Body.Close()
if err != nil || res.StatusCode != http.StatusOK {
t.Fatalf("metrics answered %d (%v)", res.StatusCode, err)
}
return string(body)
}
// wantRefused checks that a request to url is refused with 403, the
// default SWWAF_BAN_RESPONSE.
func wantRefused(t *testing.T, url string) {