Files
logstream/main.go
T
cedricandClaude Opus 5.5 3e703c8792 UI files: ETag and revalidation on each load
The embedded UI was served without any cache header, so a browser or a
proxy could keep an old app.js after an update (seen with the API token
display). Each file now has an ETag (hash of its content, computed at
startup); the page, app.js and style.css are sent with "no-cache", so
each load costs a 304 without body until the next version. Fonts are
cached for 30 days.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-01 16:11:41 +01:00

277 lines
8.6 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
filesFill 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),
filesFill: getenvDuration("FILES_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)
}
tokens, err := LoadTokenStore(filepath.Join(cfg.dataDir, "api_tokens.json"))
if err != nil {
log.Fatalf("api tokens: %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, tokens: tokens}
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)
// Log files of other programs (Traefik, nginx…) mounted in the container,
// declared in Settings > Sources.
api.files = NewFiles(cfg.hostRoot, cfg.dataDir, cfg.filesFill, sink)
go api.files.Run(ctx)
api.selfLog = selfLog
api.Routes(mux)
mux.Handle("GET /", staticFiles(static))
app := readOnly(mux)
handler, err := newAuth(cfg.auth, app)
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)
}
// API tokens (Settings > API) are checked before the login, in every auth mode.
handler = secure(tokenAuth(tokens, handler, app), 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")
}