diff --git a/README.md b/README.md index afeecdd..6433082 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,11 @@ Then open . - **Manual test**: `logger -n 127.0.0.1 -P 514 -d "hello error"` (util-linux) or `echo "<14>test ok" | nc -u -w1 127.0.0.1 514`. +**Settings > Sources > Syslog** turns syslog reception on or off and chooses the protocols +(UDP, TCP) without restarting; the choice is saved in `/data/syslog.json`. It also shows the +listening state (and the error if the port is already in use). The port itself is published +by docker-compose: change `SYSLOG_PORT` in `.env`, then run `docker compose up -d`. + Supported formats: RFC 3164 (BSD) and RFC 5424. Over TCP, both "one message per line" and "octet counting" (RFC 6587) framing are accepted. @@ -222,6 +227,7 @@ To update one of them: | `rdns.go` | cached reverse DNS lookups | | `export.go` | streamed CSV export | | `docker.go` | Docker container logs (API, followers, positions, level detection) | +| `syslogserver.go` | syslog listeners opened and closed from Settings > Sources | | `tags.go` | color tag storage | | `api.go` | `/api/*` HTTP routes | | `web/` | UI (HTML, CSS, plain JavaScript, no build step), embedded in the binary; translations live in `web/app.js` (`I18N`) | diff --git a/api.go b/api.go index b920e2e..c454a50 100644 --- a/api.go +++ b/api.go @@ -20,6 +20,7 @@ type API struct { allowPurge bool exportMax int docker *DockerManager // nil when DOCKER_LOGS is off + syslog *SyslogServer } func (a *API) Routes(mux *http.ServeMux) { @@ -37,10 +38,32 @@ func (a *API) Routes(mux *http.ServeMux) { mux.HandleFunc("GET /api/purge", a.purgeStatus) mux.HandleFunc("POST /api/purge", a.purge) mux.HandleFunc("GET /api/export.csv", a.exportCSV) + mux.HandleFunc("GET /api/syslog", a.syslogStatus) + mux.HandleFunc("PUT /api/syslog", a.syslogConfigure) mux.HandleFunc("GET /api/docker", a.dockerStatus) mux.HandleFunc("PUT /api/docker", a.dockerConfigure) } +// GET /api/syslog: syslog reception state (Settings > Sources). +func (a *API) syslogStatus(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, a.syslog.Status()) +} + +// PUT /api/syslog {"enabled": bool, "udp": bool, "tcp": bool} +func (a *API) syslogConfigure(w http.ResponseWriter, r *http.Request) { + var cfg syslogConfig + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&cfg); err != nil { + writeErr(w, http.StatusBadRequest, err) + return + } + if err := a.syslog.Configure(cfg); err != nil { + writeErr(w, http.StatusInternalServerError, err) + return + } + log.Printf("syslog reception changed from %s: %+v", r.RemoteAddr, cfg) + writeJSON(w, http.StatusOK, a.syslog.Status()) +} + // GET /api/docker: Docker source state and containers (Settings > Sources). func (a *API) dockerStatus(w http.ResponseWriter, r *http.Request) { if a.docker == nil { diff --git a/docker-compose.yml b/docker-compose.yml index 02f4097..96f3fab 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,6 +12,7 @@ services: - "${HTTP_PORT:-8080}:8080" environment: VLOGS_URL: http://victorialogs:9428 + SYSLOG_PUBLIC_PORT: ${SYSLOG_PORT:-514} # shown in Settings > Sources (the mapping is set above) TZ: ${TZ:-Europe/Paris} AUTH_USER: ${AUTH_USER:-} # leave empty to disable authentication AUTH_PASS: ${AUTH_PASS:-} diff --git a/main.go b/main.go index b10c5ab..8b938a6 100644 --- a/main.go +++ b/main.go @@ -120,16 +120,15 @@ func main() { store.Enqueue(e) hub.Publish(e) } - if err := StartSyslog(ctx, cfg.syslogAddr, sink); err != nil { - log.Fatalf("syslog: %v", err) - } + // 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() - api := &API{store: store, hub: hub, tags: tags, rdns: rdns, allowPurge: cfg.allowPurge, exportMax: cfg.exportMax} + api := &API{store: store, hub: hub, tags: tags, 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 { diff --git a/syslog.go b/syslog.go index 707ee3c..9239510 100644 --- a/syslog.go +++ b/syslog.go @@ -225,30 +225,6 @@ func parseHostTag(e *Entry, s string, withHost bool) { e.Message = s } -// StartSyslog starts the UDP and TCP listeners on the same address. -func StartSyslog(ctx context.Context, addr string, sink func(*Entry)) error { - pc, err := net.ListenPacket("udp", addr) - if err != nil { - return err - } - ln, err := net.Listen("tcp", addr) - if err != nil { - pc.Close() - return err - } - if uc, ok := pc.(*net.UDPConn); ok { - _ = uc.SetReadBuffer(8 << 20) - } - go func() { - <-ctx.Done() - pc.Close() - ln.Close() - }() - go serveUDP(ctx, pc, sink) - go serveTCP(ctx, ln, sink) - return nil -} - func serveUDP(ctx context.Context, pc net.PacketConn, sink func(*Entry)) { buf := make([]byte, 65536) for { diff --git a/syslogserver.go b/syslogserver.go new file mode 100644 index 0000000..2703acb --- /dev/null +++ b/syslogserver.go @@ -0,0 +1,145 @@ +package main + +import ( + "context" + "encoding/json" + "log" + "net" + "os" + "path/filepath" + "sync" +) + +// syslogConfig is saved in /data/syslog.json and edited in Settings > Sources. +type syslogConfig struct { + Enabled bool `json:"enabled"` + UDP bool `json:"udp"` + TCP bool `json:"tcp"` +} + +// SyslogServer owns the UDP and TCP listeners and opens or closes them when +// the configuration changes, without restarting Logstream. +type SyslogServer struct { + parent context.Context + addr string // listening address inside the container, e.g. ":5514" + publicPort string // port published on the host by docker-compose (display only) + sink func(*Entry) + cfgPath string + + mu sync.Mutex + cfg syslogConfig + cancel context.CancelFunc + pc net.PacketConn + ln net.Listener + errUDP string + errTCP string +} + +func NewSyslogServer(ctx context.Context, addr, publicPort, dataDir string, sink func(*Entry)) *SyslogServer { + s := &SyslogServer{ + parent: ctx, + addr: addr, + publicPort: publicPort, + sink: sink, + cfgPath: filepath.Join(dataDir, "syslog.json"), + cfg: syslogConfig{Enabled: true, UDP: true, TCP: true}, + } + if b, err := os.ReadFile(s.cfgPath); err == nil { + var cfg syslogConfig + if err := json.Unmarshal(b, &cfg); err == nil { + s.cfg = cfg + } + } + s.apply() + go func() { + <-ctx.Done() + s.mu.Lock() + s.closeLocked() + s.mu.Unlock() + }() + return s +} + +// closeLocked stops the listeners synchronously, so that the port is free +// before it is opened again; the caller holds the lock. +func (s *SyslogServer) closeLocked() { + if s.cancel != nil { + s.cancel() // also closes the open TCP connections + s.cancel = nil + } + if s.pc != nil { + s.pc.Close() + s.pc = nil + } + if s.ln != nil { + s.ln.Close() + s.ln = nil + } +} + +// apply opens the listeners required by the configuration. +func (s *SyslogServer) apply() { + s.mu.Lock() + defer s.mu.Unlock() + s.closeLocked() + s.errUDP, s.errTCP = "", "" + if !s.cfg.Enabled || s.parent.Err() != nil { + log.Printf("syslog: reception disabled") + return + } + ctx, cancel := context.WithCancel(s.parent) + s.cancel = cancel + var open []string + if s.cfg.UDP { + pc, err := net.ListenPacket("udp", s.addr) + if err != nil { + s.errUDP = err.Error() + log.Printf("syslog udp: %v", err) + } else { + if uc, ok := pc.(*net.UDPConn); ok { + _ = uc.SetReadBuffer(8 << 20) + } + s.pc = pc + go serveUDP(ctx, pc, s.sink) + open = append(open, "udp") + } + } + if s.cfg.TCP { + ln, err := net.Listen("tcp", s.addr) + if err != nil { + s.errTCP = err.Error() + log.Printf("syslog tcp: %v", err) + } else { + s.ln = ln + go serveTCP(ctx, ln, s.sink) + open = append(open, "tcp") + } + } + log.Printf("syslog: listening on %s %v", s.addr, open) +} + +// Configure saves and applies a new configuration. +func (s *SyslogServer) Configure(cfg syslogConfig) error { + s.mu.Lock() + s.cfg = cfg + s.mu.Unlock() + s.apply() + return writeJSONFile(s.cfgPath, cfg) +} + +// Status is the state shown in Settings > Sources. +func (s *SyslogServer) Status() map[string]any { + s.mu.Lock() + defer s.mu.Unlock() + return map[string]any{ + "enabled": s.cfg.Enabled, + "udp": s.cfg.UDP, + "tcp": s.cfg.TCP, + "udpListening": s.pc != nil, + "tcpListening": s.ln != nil, + "udpError": s.errUDP, + "tcpError": s.errTCP, + "port": s.publicPort, + "internalAddr": s.addr, + } +} diff --git a/web/app.js b/web/app.js index f26f36b..f297b06 100644 --- a/web/app.js +++ b/web/app.js @@ -67,6 +67,12 @@ const I18N = { fHostName: 'host name (DNS)', fHostIp: 'host IP', clickHost: 'Click to filter on this host', clickApp: 'Click to filter on this app', sourceAria: 'Source', srcAll: 'All sources', tabSources: 'Sources', + syslogEnabled: 'Receive syslog messages', syslogProtocols: 'Protocols', syslogPort: 'Port', + syslogListening: ({ port, protos }) => `Listening on port ${port} (${protos}).`, + syslogOff: 'Syslog reception is off: syslog messages are ignored (Docker logs are still collected).', + syslogNone: 'No protocol selected: syslog messages are ignored.', + syslogErr: (p) => `Cannot listen over ${p}: `, + syslogPortHelp: (i) => `Published by docker-compose (container port ${i}). To change it, set SYSLOG_PORT in .env, then run docker compose up -d.`, dockerTitle: 'Docker containers', dockerDefault: 'Follow new containers automatically', dockerAll: 'Enable all', dockerNone: 'Disable all', @@ -171,6 +177,12 @@ const I18N = { fHostName: 'nom d\'hôte (DNS)', fHostIp: 'IP de l\'hôte', clickHost: 'Cliquer pour filtrer sur cet hôte', clickApp: 'Cliquer pour filtrer sur cette appli', sourceAria: 'Source', srcAll: 'Toutes les sources', tabSources: 'Sources', + syslogEnabled: 'Recevoir les messages syslog', syslogProtocols: 'Protocoles', syslogPort: 'Port', + syslogListening: ({ port, protos }) => `Écoute sur le port ${port} (${protos}).`, + syslogOff: 'Réception syslog désactivée : les messages syslog sont ignorés (les logs Docker sont toujours collectés).', + syslogNone: 'Aucun protocole sélectionné : les messages syslog sont ignorés.', + syslogErr: (p) => `Impossible d'écouter en ${p} : `, + syslogPortHelp: (i) => `Publié par docker-compose (port ${i} dans le conteneur). Pour le changer, modifiez SYSLOG_PORT dans .env, puis lancez docker compose up -d.`, dockerTitle: 'Conteneurs Docker', dockerDefault: 'Suivre automatiquement les nouveaux conteneurs', dockerAll: 'Tout activer', dockerNone: 'Tout désactiver', @@ -395,6 +407,7 @@ function setLang(next) { renderTimeSettings(); renderPurge(); renderInterface(); + renderSyslog(); renderDocker(); } @@ -1095,6 +1108,66 @@ $('#exportMenu').addEventListener('click', (ev) => { document.addEventListener('click', (ev) => { if (!ev.target.closest('.export')) setExportMenu(false); }); document.addEventListener('keydown', (ev) => { if (ev.key === 'Escape') setExportMenu(false); }); +/* ================= Syslog source ================= */ + +let syslogState = null; + +async function loadSyslog() { + try { syslogState = await api('/api/syslog'); } catch { syslogState = null; } + renderSyslog(); +} + +function renderSyslog() { + const s = syslogState; + const status = $('#syslogStatus'); + if (!s) { status.textContent = ''; return; } + $('#syslogEnabled').checked = s.enabled; + for (const b of document.querySelectorAll('#syslogProtos [data-proto]')) { + const on = !!s[b.dataset.proto]; + b.classList.toggle('on', on); + b.setAttribute('aria-pressed', String(on)); + b.disabled = !s.enabled; + } + $('#syslogProtos').classList.toggle('off', !s.enabled); + $('#syslogPortVal').textContent = s.port || s.internalAddr; + const internal = String(s.internalAddr || '').replace(/^:/, ''); + $('#syslogPortHelp').textContent = t('syslogPortHelp', internal); + + const errs = []; + if (s.udpError) errs.push(t('syslogErr', 'UDP') + s.udpError); + if (s.tcpError) errs.push(t('syslogErr', 'TCP') + s.tcpError); + const protos = [s.udpListening && 'UDP', s.tcpListening && 'TCP'].filter(Boolean).join(' + '); + if (errs.length) { + status.textContent = errs.join(' · '); + status.className = 'docker-status bad'; + } else if (!s.enabled) { + status.textContent = t('syslogOff'); + status.className = 'docker-status muted'; + } else if (!protos) { + status.textContent = t('syslogNone'); + status.className = 'docker-status muted'; + } else { + status.textContent = t('syslogListening', { port: s.port || internal, protos }); + status.className = 'docker-status good'; + } +} + +async function configureSyslog(change) { + const s = syslogState || { enabled: true, udp: true, tcp: true }; + const body = { enabled: s.enabled, udp: s.udp, tcp: s.tcp, ...change }; + syslogState = { ...s, ...body }; + renderSyslog(); + try { syslogState = await api('/api/syslog', { method: 'PUT', body }); } catch (e) { toast(e.message); } + renderSyslog(); +} + +$('#syslogEnabled').addEventListener('change', (ev) => configureSyslog({ enabled: ev.target.checked })); +$('#syslogProtos').addEventListener('click', (ev) => { + const b = ev.target.closest('[data-proto]'); + if (!b || b.disabled || !syslogState) return; + configureSyslog({ [b.dataset.proto]: !syslogState[b.dataset.proto] }); +}); + /* ================= Docker source ================= */ let dockerState = null; @@ -1370,6 +1443,7 @@ $('#settingsBtn').addEventListener('click', () => { renderTagList(); renderInterface(); loadPurgeStatus(); + loadSyslog(); loadDocker(); showSettingsTab(store.get('settingsTab', 'locale')); $('#settingsDlg').showModal(); diff --git a/web/index.html b/web/index.html index 1869812..574f014 100644 --- a/web/index.html +++ b/web/index.html @@ -178,6 +178,24 @@