feat(audit): forward audit entries to stdout, syslog and webhook sinks

Adds the audit.forwarders config list and a Sink interface with three
implementations: stdout, RFC 5424 syslog over UDP/TCP (hand-rolled —
stdlib log/syslog only emits RFC 3164 and does not build on Windows),
and webhook POSTs through the SSRF-safe HTTP client. Forwarders are
best-effort fan-out: the local file stays the source of truth and a
dead sink is logged instead of poison-queueing every event.
This commit is contained in:
kolaente 2026-06-10 22:22:20 +02:00
parent ae908be716
commit ab038ec6c4
8 changed files with 351 additions and 9 deletions

View File

@ -1025,6 +1025,10 @@
"comment": "Delete rotated audit log files older than this many days. This only applies to the local rotated files, it is not a retention policy. Set to 0 to keep rotated files forever."
}
]
},
{
"key": "forwarders",
"comment": "A list of sinks to forward each audit entry to, in addition to the local logfile. Each entry needs a `type` of `stdout`, `syslog` or `webhook`. `syslog` requires `address` (e.g. `udp://logs.example.com:514`) and accepts an optional `facility` (default `local0`). `webhook` requires `url` and accepts an optional `headers` map sent with each request.\nExample:\n\n```yaml\nforwarders:\n- type: stdout\n- type: syslog\n address: udp://logs.example.com:514\n facility: local0\n- type: webhook\n url: https://siem.example.com/ingest\n headers:\n Authorization: Bearer something\n```"
}
]
},

View File

@ -15,7 +15,8 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
// Package audit persists an audit trail of authentication, authorization and
// data lifecycle events as JSONL.
// data lifecycle events as JSONL, with optional forwarding to stdout, syslog
// or webhook sinks.
//
// Events opt in via RegisterEventForAudit, which subscribes one audit
// listener per event on the existing watermill bus; the event→Entry mapping
@ -36,9 +37,10 @@
// pkg/events.RequestMeta. Events dispatched outside a request get
// source type "system" instead.
//
// A failed file write is returned to the router for retry. Tamper evidence
// comes from filesystem permissions (the file is created 0600) plus shipping
// the file to an external system, not from hash chains or signatures.
// Rotation is size-based with age-based cleanup of rotated files; retention
// is the operator's concern.
// The local file is the source of truth: a failed file write is returned to
// the router for retry, while forwarder failures are only logged so a dead
// sink cannot poison-queue every event. Tamper evidence comes from filesystem
// permissions (the file is created 0600) plus shipping entries to an external
// sink, not from hash chains or signatures. Rotation is size-based with
// age-based cleanup of rotated files; retention is the operator's concern.
package audit

24
pkg/audit/sinks/sink.go Normal file
View File

@ -0,0 +1,24 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
// Package sinks contains the forwarding targets for audit log entries.
package sinks
// Sink forwards a single audit entry, passed as its serialized JSON line
// without a trailing newline. Implementations must be safe for concurrent use.
type Sink interface {
Write(line []byte) error
}

44
pkg/audit/sinks/stdout.go Normal file
View File

@ -0,0 +1,44 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package sinks
import (
"io"
"os"
"sync"
)
// Stdout writes each entry as one line to standard output.
type Stdout struct {
mu sync.Mutex
// out exists so tests can capture the output.
out io.Writer
}
func NewStdout() *Stdout {
return &Stdout{out: os.Stdout}
}
func (s *Stdout) Write(line []byte) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, err := s.out.Write(line); err != nil {
return err
}
_, err := s.out.Write([]byte{'\n'})
return err
}

116
pkg/audit/sinks/syslog.go Normal file
View File

@ -0,0 +1,116 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package sinks
import (
"context"
"fmt"
"net"
"net/url"
"os"
"strings"
"sync"
"time"
)
// Hand-rolled RFC 5424 instead of log/syslog: the stdlib package only emits
// the older RFC 3164 format and does not build on Windows.
type Syslog struct {
network string
address string
facility int
hostname string
procid string
mu sync.Mutex
conn net.Conn
}
var syslogFacilities = map[string]int{
"kern": 0, "user": 1, "mail": 2, "daemon": 3, "auth": 4, "syslog": 5,
"lpr": 6, "news": 7, "uucp": 8, "cron": 9, "authpriv": 10, "ftp": 11,
"local0": 16, "local1": 17, "local2": 18, "local3": 19,
"local4": 20, "local5": 21, "local6": 22, "local7": 23,
}
// NewSyslog creates a syslog sink. The address has the form
// udp://host:port or tcp://host:port; the scheme defaults to udp.
func NewSyslog(address, facility string) (*Syslog, error) {
if address == "" {
return nil, fmt.Errorf("syslog forwarder requires an address")
}
if !strings.Contains(address, "://") {
address = "udp://" + address
}
u, err := url.Parse(address)
if err != nil {
return nil, fmt.Errorf("invalid syslog address %q: %w", address, err)
}
if u.Scheme != "udp" && u.Scheme != "tcp" {
return nil, fmt.Errorf("unsupported syslog scheme %q, must be udp or tcp", u.Scheme)
}
if facility == "" {
facility = "local0"
}
facilityCode, ok := syslogFacilities[strings.ToLower(facility)]
if !ok {
return nil, fmt.Errorf("unknown syslog facility %q", facility)
}
hostname, err := os.Hostname()
if err != nil || hostname == "" {
hostname = "-"
}
return &Syslog{
network: u.Scheme,
address: u.Host,
facility: facilityCode,
hostname: hostname,
procid: fmt.Sprintf("%d", os.Getpid()),
}, nil
}
func (s *Syslog) Write(line []byte) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.conn == nil {
dialer := &net.Dialer{Timeout: 10 * time.Second}
conn, err := dialer.DialContext(context.Background(), s.network, s.address)
if err != nil {
return fmt.Errorf("could not connect to syslog at %s://%s: %w", s.network, s.address, err)
}
s.conn = conn
}
pri := s.facility*8 + 6 // severity: informational
frame := fmt.Sprintf("<%d>1 %s %s vikunja %s audit - %s",
pri, time.Now().UTC().Format(time.RFC3339Nano), s.hostname, s.procid, line)
if s.network == "tcp" {
frame += "\n" // RFC 6587 non-transparent framing
}
if _, err := s.conn.Write([]byte(frame)); err != nil {
// Drop the connection so the next write redials.
_ = s.conn.Close()
s.conn = nil
return err
}
return nil
}

View File

@ -0,0 +1,69 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package sinks
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"code.vikunja.io/api/pkg/utils"
)
// Webhook POSTs each entry as a JSON body to a fixed URL.
type Webhook struct {
url string
headers map[string]string
client *http.Client
}
func NewWebhook(url string, headers map[string]string) (*Webhook, error) {
if url == "" {
return nil, fmt.Errorf("webhook forwarder requires a url")
}
return &Webhook{
url: url,
headers: headers,
client: utils.NewSSRFSafeHTTPClient(),
}, nil
}
func (w *Webhook) Write(line []byte) error {
req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, w.url, bytes.NewReader(line))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "Vikunja/audit")
for key, value := range w.headers {
req.Header.Set(key, value)
}
resp, err := w.client.Do(req) // #nosec G704 -- URL is the operator-configured sink target; the SSRF-safe client enforces IP restrictions
if err != nil {
return err
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, resp.Body)
if resp.StatusCode >= 400 {
return fmt.Errorf("audit webhook %s returned status %d", w.url, resp.StatusCode)
}
return nil
}

View File

@ -25,6 +25,7 @@ import (
"sync"
"time"
"code.vikunja.io/api/pkg/audit/sinks"
"code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/log"
@ -40,9 +41,10 @@ var (
maxSizeBytes int64
maxAge time.Duration
lastSync time.Time
forwarders []sinks.Sink
)
// Init opens the audit log file.
// Init opens the audit log file and sets up the configured forwarders.
// Safe to call again to re-read the config (used by tests).
func Init() error {
mu.Lock()
@ -64,6 +66,13 @@ func Init() error {
return err
}
var err error
forwarders, err = buildForwarders(config.AuditForwarders.Get())
if err != nil {
closeLocked()
return err
}
initialized = true
return nil
}
@ -81,6 +90,7 @@ func closeLocked() {
_ = logFile.Close()
logFile = nil
}
forwarders = nil
initialized = false
}
@ -99,8 +109,74 @@ func openLogFileLocked() error {
return nil
}
// WriteAuditEvent writes one entry to the local audit log. A failed write is
// returned so the event router retries it.
func buildForwarders(raw any) (built []sinks.Sink, err error) {
if raw == nil {
return nil, nil
}
rawList, ok := raw.([]any)
if !ok {
return nil, fmt.Errorf("audit.forwarders must be a list, got %T", raw)
}
for i, rawEntry := range rawList {
entry, ok := toStringMap(rawEntry)
if !ok {
return nil, fmt.Errorf("audit.forwarders[%d] must be a map", i)
}
var sink sinks.Sink
typ, _ := entry["type"].(string)
switch typ {
case "stdout":
sink = sinks.NewStdout()
case "syslog":
address, _ := entry["address"].(string)
facility, _ := entry["facility"].(string)
sink, err = sinks.NewSyslog(address, facility)
case "webhook":
targetURL, _ := entry["url"].(string)
headers := map[string]string{}
if rawHeaders, ok := toStringMap(entry["headers"]); ok {
for key, value := range rawHeaders {
headers[key], _ = value.(string)
}
}
sink, err = sinks.NewWebhook(targetURL, headers)
default:
return nil, fmt.Errorf("audit.forwarders[%d] has unknown type %q", i, typ)
}
if err != nil {
return nil, fmt.Errorf("audit.forwarders[%d]: %w", i, err)
}
built = append(built, sink)
}
return built, nil
}
// toStringMap normalizes the two map shapes viper produces depending on the
// config source (file vs. programmatic Set).
func toStringMap(raw any) (map[string]any, bool) {
switch m := raw.(type) {
case map[string]any:
return m, true
case map[any]any:
out := make(map[string]any, len(m))
for key, value := range m {
keyStr, ok := key.(string)
if !ok {
return nil, false
}
out[keyStr] = value
}
return out, true
}
return nil, false
}
// WriteAuditEvent writes one entry to the local audit log and forwards it to
// all configured sinks. The local write is the source of truth — its failure
// is returned so the event router retries; forwarder failures are only
// logged, since a dead sink must not poison-queue every event.
func WriteAuditEvent(entry *Entry) error {
if entry.EventID == "" {
id, err := uuid.NewV7()
@ -138,12 +214,18 @@ func WriteAuditEvent(entry *Entry) error {
err = logFile.Sync()
lastSync = time.Now()
}
currentForwarders := forwarders
mu.Unlock()
if err != nil {
return fmt.Errorf("could not write audit entry: %w", err)
}
for _, forwarder := range currentForwarders {
if ferr := forwarder.Write(line); ferr != nil {
log.Errorf("Could not forward audit entry %s: %s", entry.EventID, ferr)
}
}
return nil
}

View File

@ -224,6 +224,7 @@ const (
AuditLogfile Key = `audit.logfile`
AuditRotationMaxSizeMB Key = `audit.rotation.maxsizemb`
AuditRotationMaxAge Key = `audit.rotation.maxage`
AuditForwarders Key = `audit.forwarders`
OutgoingRequestsAllowNonRoutableIPs Key = `outgoingrequests.allownonroutableips`
OutgoingRequestsProxyURL Key = `outgoingrequests.proxyurl`