diff --git a/config-raw.json b/config-raw.json
index 641285994..3fbf5dd5e 100644
--- a/config-raw.json
+++ b/config-raw.json
@@ -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```"
}
]
},
diff --git a/pkg/audit/doc.go b/pkg/audit/doc.go
index f6d04d64d..810b91788 100644
--- a/pkg/audit/doc.go
+++ b/pkg/audit/doc.go
@@ -15,7 +15,8 @@
// along with this program. If not, see .
// 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
diff --git a/pkg/audit/sinks/sink.go b/pkg/audit/sinks/sink.go
new file mode 100644
index 000000000..ff951d452
--- /dev/null
+++ b/pkg/audit/sinks/sink.go
@@ -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 .
+
+// 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
+}
diff --git a/pkg/audit/sinks/stdout.go b/pkg/audit/sinks/stdout.go
new file mode 100644
index 000000000..95d89af03
--- /dev/null
+++ b/pkg/audit/sinks/stdout.go
@@ -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 .
+
+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
+}
diff --git a/pkg/audit/sinks/syslog.go b/pkg/audit/sinks/syslog.go
new file mode 100644
index 000000000..ba85f17f2
--- /dev/null
+++ b/pkg/audit/sinks/syslog.go
@@ -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 .
+
+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
+}
diff --git a/pkg/audit/sinks/webhook.go b/pkg/audit/sinks/webhook.go
new file mode 100644
index 000000000..c642d7e43
--- /dev/null
+++ b/pkg/audit/sinks/webhook.go
@@ -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 .
+
+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
+}
diff --git a/pkg/audit/writer.go b/pkg/audit/writer.go
index 548c380fe..ec7528af0 100644
--- a/pkg/audit/writer.go
+++ b/pkg/audit/writer.go
@@ -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
}
diff --git a/pkg/config/config.go b/pkg/config/config.go
index 2443cb627..e7c2a75f0 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -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`