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:
1 parent
7beb791e44
commit
32593b9515
9 files changed
+278
-28
No files matched your search
@@ -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`) |
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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:-}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user