Audit source (source_type=audit), off until enabled in Settings > Sources: - follows /var/log/audit/audit.log (path configurable, AUDIT_LOG), with rotation (end of audit.log.1 read first) and an offset checkpoint in /data/audit-state.json; last AUDIT_BACKFILL (1h) read on first enable - groups the records of an event by msg=audit(time:serial), closed on EOE, after 2 s without a record, or once the log has moved 2 s past it - fields audit_type, audit_serial, auid, user (enriched name, host /etc/passwd or uid), exe, res, key, audit_raw; app = audit_type, host = node= / AUDIT_HOST / /host/etc/hostname; summary "who: what [key] → result"; warning when res=failed / success=no, info otherwise - hexadecimal values (proctitle, cmd, exec arguments…) decoded Internal logs (source_type=logstream, host=logstream, app=component): - logEvent() for VictoriaLogs unreachable / back, lost batches, spooled batches refused, syslog listener errors, Docker API and container streams lost, failing sources, start and stop - on by default from warning; switch and minimum level in Settings > Sources (/data/selflog.json) - bounded queue, never waits for the Store, same event at most once per 30 s with a count of the repeats: a failing Store cannot loop UI: violet (audit) and turquoise (LogStream) left edge and tint, AUDIT / LS badge before the app, raw audit record under the summary (folded in compact density until the row is opened), Audit and LogStream in the source filter, FR/EN translations. docker-compose: optional /var/log/audit, /etc/passwd and /etc/hostname mounts. README (EN/FR): host prerequisites (log_group, ACL for UID 10001). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
265 lines
8.1 KiB
Go
265 lines
8.1 KiB
Go
// Logstream: a syslog (UDP/TCP), Docker and host system logs sink stored in VictoriaLogs,
|
|
// with a web interface to browse and search the logs.
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"embed"
|
|
"errors"
|
|
"io/fs"
|
|
"log"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
_ "time/tzdata" // embedded time zones: TZ works without a system package
|
|
)
|
|
|
|
//go:embed web
|
|
var webFS embed.FS
|
|
|
|
type config struct {
|
|
syslogAddr string
|
|
httpAddr string
|
|
vlogsURL string
|
|
dataDir string
|
|
auth authConfig
|
|
rdns bool
|
|
dnsServer string
|
|
allowPurge bool
|
|
exportMax int
|
|
dockerLogs bool
|
|
dockerHost string
|
|
backfill time.Duration
|
|
hostRoot string
|
|
hostFill time.Duration
|
|
kmsgDevice string
|
|
kmsgFill time.Duration
|
|
auditLog string
|
|
auditHost string
|
|
auditFill time.Duration
|
|
batchSize int
|
|
queueSize int
|
|
flushEvery time.Duration
|
|
spoolMax int64
|
|
}
|
|
|
|
func getenv(key, def string) string {
|
|
if v := os.Getenv(key); v != "" {
|
|
return v
|
|
}
|
|
return def
|
|
}
|
|
|
|
func getenvInt(key string, def int) int {
|
|
if v, err := strconv.Atoi(os.Getenv(key)); err == nil && v > 0 {
|
|
return v
|
|
}
|
|
return def
|
|
}
|
|
|
|
// getenvIntZero is getenvInt that also accepts 0 (to turn a feature off).
|
|
func getenvIntZero(key string, def int) int {
|
|
if v, err := strconv.Atoi(os.Getenv(key)); err == nil && v >= 0 {
|
|
return v
|
|
}
|
|
return def
|
|
}
|
|
|
|
func getenvBool(key string, def bool) bool {
|
|
switch strings.ToLower(os.Getenv(key)) {
|
|
case "1", "true", "yes", "on":
|
|
return true
|
|
case "0", "false", "no", "off":
|
|
return false
|
|
}
|
|
return def
|
|
}
|
|
|
|
func getenvDuration(key string, def time.Duration) time.Duration {
|
|
if d, err := time.ParseDuration(os.Getenv(key)); err == nil && d >= 0 {
|
|
return d
|
|
}
|
|
return def
|
|
}
|
|
|
|
func main() {
|
|
cfg := config{
|
|
syslogAddr: getenv("SYSLOG_ADDR", ":5514"),
|
|
httpAddr: getenv("HTTP_ADDR", ":8080"),
|
|
vlogsURL: getenv("VLOGS_URL", "http://victorialogs:9428"),
|
|
dataDir: getenv("DATA_DIR", "/data"),
|
|
auth: authConfig{
|
|
mode: getenv("AUTH_MODE", "local"),
|
|
user: os.Getenv("AUTH_USER"),
|
|
pass: os.Getenv("AUTH_PASS"),
|
|
viewerUser: os.Getenv("AUTH_VIEWER_USER"),
|
|
viewerPass: os.Getenv("AUTH_VIEWER_PASS"),
|
|
adminGroup: os.Getenv("OIDC_ADMIN_GROUP"),
|
|
groupsClaim: os.Getenv("OIDC_GROUPS_CLAIM"),
|
|
issuer: os.Getenv("OIDC_ISSUER"),
|
|
clientID: os.Getenv("OIDC_CLIENT_ID"),
|
|
clientSecret: os.Getenv("OIDC_CLIENT_SECRET"),
|
|
redirectURL: os.Getenv("OIDC_REDIRECT_URL"),
|
|
scopes: os.Getenv("OIDC_SCOPES"),
|
|
// SESSION_TTL applies to both modes; OIDC_SESSION_TTL is its former name.
|
|
sessionTTL: getenvDuration("SESSION_TTL", getenvDuration("OIDC_SESSION_TTL", 12*time.Hour)),
|
|
loginLogo: os.Getenv("LOGIN_LOGO"),
|
|
},
|
|
rdns: getenvBool("RDNS", true),
|
|
dnsServer: os.Getenv("DNS_SERVER"),
|
|
allowPurge: getenvBool("ALLOW_PURGE", false),
|
|
exportMax: getenvInt("EXPORT_MAX", 100000),
|
|
dockerLogs: getenvBool("DOCKER_LOGS", false),
|
|
dockerHost: getenv("DOCKER_HOST", "unix:///var/run/docker.sock"),
|
|
backfill: getenvDuration("DOCKER_BACKFILL", time.Hour),
|
|
hostRoot: getenv("HOST_LOGS_ROOT", "/host"),
|
|
hostFill: getenvDuration("HOST_LOGS_BACKFILL", time.Hour),
|
|
kmsgDevice: getenv("DMESG_DEVICE", "/dev/kmsg"),
|
|
kmsgFill: getenvDuration("DMESG_BACKFILL", time.Hour),
|
|
auditLog: getenv("AUDIT_LOG", "/var/log/audit/audit.log"),
|
|
auditHost: os.Getenv("AUDIT_HOST"),
|
|
auditFill: getenvDuration("AUDIT_BACKFILL", time.Hour),
|
|
batchSize: getenvInt("BATCH_SIZE", 1000),
|
|
queueSize: getenvInt("QUEUE_SIZE", 100000),
|
|
flushEvery: time.Duration(getenvInt("FLUSH_MS", 1000)) * time.Millisecond,
|
|
spoolMax: int64(getenvIntZero("SPOOL_MAX_MB", 1024)) << 20,
|
|
}
|
|
|
|
cfg.auth.dataDir = cfg.dataDir
|
|
|
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
|
defer stop()
|
|
|
|
tags, err := LoadTagStore(filepath.Join(cfg.dataDir, "tags.json"))
|
|
if err != nil {
|
|
log.Fatalf("tags: %v", err)
|
|
}
|
|
|
|
var spool *Spool
|
|
if cfg.spoolMax > 0 {
|
|
if spool, err = OpenSpool(filepath.Join(cfg.dataDir, "spool"), cfg.spoolMax); err != nil {
|
|
log.Printf("disk buffer disabled: %v", err) // before the self-logs exist
|
|
spool = nil
|
|
}
|
|
}
|
|
store := NewStore(cfg.vlogsURL, cfg.batchSize, cfg.queueSize, cfg.flushEvery, spool)
|
|
// The store stops after everything else, so the stop event is still stored.
|
|
storeCtx, stopStore := context.WithCancel(context.Background())
|
|
storeDone := make(chan struct{})
|
|
go func() {
|
|
store.Run(storeCtx)
|
|
close(storeDone)
|
|
}()
|
|
|
|
hub := NewHub()
|
|
rdns := NewReverseDNS(cfg.rdns, cfg.dnsServer)
|
|
sink := func(e *Entry) {
|
|
// Host sent as an IP (or no host in the header): replace it with its DNS name.
|
|
// A new IP waits at most 300 ms, without holding up the listener; slower
|
|
// lookups finish in the background.
|
|
rdns.Resolve(e.Host, 300*time.Millisecond, func(name string) {
|
|
if name != "" {
|
|
e.HostIP, e.Host = e.Host, name
|
|
}
|
|
store.Enqueue(e)
|
|
hub.Publish(e)
|
|
})
|
|
}
|
|
// Internal events of LogStream (host=logstream), WARNING and above unless
|
|
// changed in Settings > Sources.
|
|
selfLog = NewSelfLog(cfg.dataDir, func(e *Entry) {
|
|
store.Enqueue(e)
|
|
hub.Publish(e)
|
|
})
|
|
selfCtx, stopSelf := context.WithCancel(context.Background())
|
|
selfDone := make(chan struct{})
|
|
go func() {
|
|
selfLog.Run(selfCtx)
|
|
close(selfDone)
|
|
}()
|
|
logEvent("main", 5, "LogStream started")
|
|
|
|
tcpMaxConns = getenvInt("SYSLOG_TCP_MAX_CONNS", tcpMaxConns)
|
|
if d := getenvDuration("SYSLOG_TCP_IDLE", tcpIdle); d > 0 {
|
|
tcpIdle = d
|
|
}
|
|
// Listening errors (port already used…) are shown in Settings > Sources.
|
|
syslogSrv := NewSyslogServer(ctx, cfg.syslogAddr, getenv("SYSLOG_PUBLIC_PORT", ""), cfg.dataDir, sink)
|
|
|
|
static, err := fs.Sub(webFS, "web")
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
mux := http.NewServeMux()
|
|
presets := getenv("PRESETS_FILE", filepath.Join(cfg.dataDir, "presets.json"))
|
|
api := &API{store: store, hub: hub, tags: tags, presets: presets, rdns: rdns, allowPurge: cfg.allowPurge, exportMax: cfg.exportMax, syslog: syslogSrv}
|
|
if cfg.dockerLogs {
|
|
dm, err := NewDockerManager(cfg.dockerHost, cfg.dataDir, cfg.backfill, sink)
|
|
if err != nil {
|
|
logEvent("docker", 3, "Docker logs disabled: %v", err)
|
|
} else {
|
|
go dm.Run(ctx)
|
|
api.docker = dm
|
|
log.Printf("docker logs enabled through %s", cfg.dockerHost)
|
|
}
|
|
}
|
|
// System logs of the host (journal or /var/log), off until enabled in Settings > Sources.
|
|
api.host = NewHostLogs(cfg.hostRoot, cfg.dataDir, cfg.hostFill, sink)
|
|
go api.host.Run(ctx)
|
|
// Kernel messages of the host (/dev/kmsg), off until enabled in Settings > Sources.
|
|
api.dmesg = NewDmesg(cfg.kmsgDevice, cfg.dataDir, cfg.kmsgFill, sink)
|
|
go api.dmesg.Run(ctx)
|
|
// Linux audit log of the host (auditd), off until enabled in Settings > Sources.
|
|
api.audit = NewAudit(cfg.auditLog, cfg.hostRoot, cfg.auditHost, cfg.dataDir, cfg.auditFill, sink)
|
|
go api.audit.Run(ctx)
|
|
api.selfLog = selfLog
|
|
api.Routes(mux)
|
|
mux.Handle("GET /", http.FileServer(http.FS(static)))
|
|
|
|
handler, err := newAuth(cfg.auth, readOnly(mux))
|
|
if err != nil {
|
|
log.Fatalf("auth: %v", err)
|
|
}
|
|
if o, ok := handler.(*OIDC); ok {
|
|
go o.checkProvider()
|
|
}
|
|
_, local := handler.(*Local)
|
|
_, oidc := handler.(*OIDC)
|
|
authOn := local || oidc
|
|
if !authOn {
|
|
log.Printf("warning: no authentication (AUTH_USER is empty): anyone who can reach %s can read the logs and change the settings", cfg.httpAddr)
|
|
}
|
|
handler = secure(handler, contentSecurityPolicy(static), authOn)
|
|
srv := &http.Server{
|
|
Addr: cfg.httpAddr,
|
|
Handler: handler,
|
|
ReadHeaderTimeout: 10 * time.Second,
|
|
IdleTimeout: 2 * time.Minute,
|
|
// Requests inherit the global context so SSE streams end on shutdown.
|
|
BaseContext: func(net.Listener) context.Context { return ctx },
|
|
}
|
|
go func() {
|
|
<-ctx.Done()
|
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
_ = srv.Shutdown(shutdownCtx)
|
|
}()
|
|
|
|
log.Printf("web UI on %s, syslog on %s (udp+tcp), storage %s", cfg.httpAddr, cfg.syslogAddr, cfg.vlogsURL)
|
|
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
|
log.Fatalf("http: %v", err)
|
|
}
|
|
logEvent("main", 5, "LogStream stopping")
|
|
stopSelf()
|
|
<-selfDone
|
|
stopStore()
|
|
<-storeDone
|
|
log.Println("shutdown complete")
|
|
}
|