Settings > Sources: turn syslog reception on/off, choose UDP/TCP

- SyslogServer opens and closes the UDP/TCP listeners at runtime from the
  configuration saved in /data/syslog.json; a busy port no longer stops
  Logstream, the error is shown in Settings instead.
- Sources tab: syslog section with an on/off switch, UDP and TCP labels,
  the live listening state and the published port (SYSLOG_PORT, passed as
  SYSLOG_PUBLIC_PORT for display; the mapping stays in docker-compose).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cedricandClaude Opus 5.5 committed 2026-09-28 17:37:08 +02:00
1 parent 7beb791e44
commit 32593b9515
9 files changed
+278 -28

No files matched your search

+6
View File
@@ -28,6 +28,11 @@ Then open <http://localhost:8080>.
- **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`) |
+23
View File
@@ -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 {
+1
View File
@@ -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:-}
+3 -4
View File
@@ -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 {
-24
View File
@@ -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 {
+145
View File
@@ -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,
}
}
+74
View File
@@ -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();
+18
View File
@@ -178,6 +178,24 @@
<!-- Sources -->
<div class="set-panel" role="tabpanel" data-panel="sources" hidden>
<section class="set-section">
<h3>Syslog</h3>
<p id="syslogStatus" class="docker-status"></p>
<label class="switch-row">
<input id="syslogEnabled" type="checkbox" class="switch">
<span data-i18n="syslogEnabled">Receive syslog messages</span>
</label>
<div class="sl-grid">
<span class="lbl" data-i18n="syslogProtocols">Protocols</span>
<div id="syslogProtos" class="chip-field sl-protos">
<button type="button" class="chip" data-proto="udp" style="--h:205">UDP</button>
<button type="button" class="chip" data-proto="tcp" style="--h:265">TCP</button>
</div>
<span class="lbl" data-i18n="syslogPort">Port</span>
<div class="sl-port"><code id="syslogPortVal">514</code><span id="syslogPortHelp" class="muted small"></span></div>
</div>
</section>
<section class="set-section">
<h3 data-i18n="dockerTitle">Docker containers</h3>
<p id="dockerStatus" class="docker-status"></p>
+8
View File
@@ -426,6 +426,14 @@ select.field:focus { outline: 0; border-color: var(--accent); box-shadow: 0 0 0
.docker-status.bad { color: var(--sev-err); }
.switch-row { display: flex; align-items: center; gap: 10px; font-size: 13px; font-weight: 550; cursor: pointer; }
.dk-opts { display: flex; flex-direction: column; gap: 8px; }
.sl-grid { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 12px 14px; align-items: baseline; margin-top: 14px; }
.sl-protos { display: inline-flex; min-height: 0; padding: 5px; justify-self: start; }
.sl-protos.off { opacity: .5; }
.sl-port { display: flex; flex-direction: column; align-items: flex-start; gap: 5px; }
.sl-port code {
font: 12px var(--mono); padding: 2px 8px; border-radius: 5px;
background: var(--panel-2); border: 1px solid var(--border);
}
.dk-toolbar { display: flex; align-items: center; gap: 12px; margin: 14px 0 6px; }
.dk-filter {
flex: 1; min-width: 0; height: 28px; padding: 0 10px;