diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..1818ce5
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,19 @@
+# Normalize line endings to LF for all text so checkouts on Windows/macOS/Linux
+# don't churn CRLF<->LF and shell/workflow scripts stay LF.
+* text=auto eol=lf
+
+# Explicit binaries (never touch their bytes).
+*.png binary
+*.ico binary
+*.icns binary
+*.jpg binary
+*.jpeg binary
+*.gif binary
+*.woff binary
+*.woff2 binary
+*.ttf binary
+*.dmg binary
+*.exe binary
+*.zip binary
+*.gz binary
+*.deb binary
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index d67f176..5587046 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -12,19 +12,24 @@ jobs:
runs-on: ${{ matrix.os }}
strategy:
matrix:
- os: [ubuntu-22.04, windows-latest, macos-latest]
+ os: [ubuntu-24.04, windows-latest, macos-latest]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
- go-version: "1.23"
+ go-version-file: go.mod
- name: Install Linux dependencies
if: runner.os == 'Linux'
- run: sudo apt-get update && sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev pkg-config
+ run: sudo apt-get update && sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev pkg-config
- name: Create frontend/dist placeholder for go:embed
run: mkdir -p frontend/dist && touch frontend/dist/.gitkeep
- - run: go vet ./...
- - run: go test -race -count=1 ./...
+ - name: Vet & test
+ shell: bash
+ run: |
+ TAGS=""
+ if [ "$RUNNER_OS" = "Linux" ]; then TAGS="-tags webkit2_41"; fi
+ go vet $TAGS ./...
+ go test $TAGS -race -count=1 ./...
frontend:
name: Frontend (check + build)
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 94a62fa..cfadb2d 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -27,7 +27,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
- go-version: "1.23"
+ go-version-file: go.mod
- uses: actions/setup-node@v4
with:
node-version: "20"
@@ -78,7 +78,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
- go-version: "1.23"
+ go-version-file: go.mod
- uses: actions/setup-node@v4
with:
node-version: "20"
@@ -118,26 +118,26 @@ jobs:
build-linux:
name: Linux (amd64)
- runs-on: ubuntu-22.04
+ runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
- go-version: "1.23"
+ go-version-file: go.mod
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Install dependencies
- run: sudo apt-get update && sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev pkg-config
+ run: sudo apt-get update && sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev pkg-config
- name: Install Wails
run: go install github.com/wailsapp/wails/v2/cmd/wails@latest
- name: Install frontend deps
run: npm ci
working-directory: frontend
- name: Build
- run: wails build -ldflags "-X SyslogStudio/internal/updater.AppVersion=${{ github.ref_name }}"
+ run: wails build -tags webkit2_41 -ldflags "-X SyslogStudio/internal/updater.AppVersion=${{ github.ref_name }}"
- name: Package assets
run: |
cp build/bin/SyslogStudio SyslogStudio-linux-amd64
@@ -157,7 +157,7 @@ jobs:
Description: Lightweight desktop syslog viewer and analyzer
Multi-protocol syslog server (UDP, TCP, TLS) with real-time log viewing,
filtering, alerts, and statistics dashboard.
- Depends: libgtk-3-0, libwebkit2gtk-4.0-37
+ Depends: libgtk-3-0, libwebkit2gtk-4.1-0
EOF
dpkg-deb --build pkg "SyslogStudio-linux-amd64.deb"
- uses: actions/upload-artifact@v4
@@ -176,18 +176,30 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
- go-version: "1.23"
+ go-version-file: go.mod
- uses: actions/download-artifact@v4
with:
path: artifacts
merge-multiple: true
- name: Generate checksums
working-directory: artifacts
- run: sha256sum SyslogStudio-windows-* SyslogStudio-macos-* SyslogStudio-linux-* > SyslogStudio-checksums.txt
+ run: |
+ # The first line binds the signed manifest to this release tag; the
+ # app rejects a manifest whose version doesn't match (replay guard).
+ {
+ echo "version ${GITHUB_REF_NAME}"
+ sha256sum SyslogStudio-windows-* SyslogStudio-macos-* SyslogStudio-linux-*
+ } > SyslogStudio-checksums.txt
- name: Sign checksums
env:
UPDATER_PRIVATE_KEY: ${{ secrets.UPDATER_PRIVATE_KEY }}
- run: go run ./tools/updatersign sign artifacts/SyslogStudio-checksums.txt
+ run: |
+ if [ -z "$UPDATER_PRIVATE_KEY" ]; then
+ echo "::error::UPDATER_PRIVATE_KEY secret is not set; releases must be signed." >&2
+ exit 1
+ fi
+ go run ./tools/updatersign sign artifacts/SyslogStudio-checksums.txt
+ test -s artifacts/SyslogStudio-checksums.txt.sig
- name: List release assets
run: ls -lh artifacts/
- name: Create GitHub Release
diff --git a/app.go b/app.go
index c4195c5..4326746 100644
--- a/app.go
+++ b/app.go
@@ -7,6 +7,7 @@ import (
"fmt"
"log/slog"
"net"
+ neturl "net/url"
"os"
"strings"
"time"
@@ -43,6 +44,11 @@ const (
baseLockoutBackoff = 30 * time.Second
// maxLockoutBackoff caps the backoff delay.
maxLockoutBackoff = 15 * time.Minute
+ // minPasswordLen is the minimum at-rest encryption password length. The
+ // unlock lockout only rate-limits attempts through the UI; a weak password
+ // is brute-forced offline against a copied logs.db.enc, so the strong KDF
+ // alone is not enough.
+ minPasswordLen = 8
)
// NewApp creates a new App application struct.
@@ -70,7 +76,7 @@ func (a *App) startup(ctx context.Context) {
slog.Warn("failed to initialize log store, persistence disabled", "error", err)
} else {
a.logStore = ls
- a.server.LogStore = ls
+ a.server.SetLogStore(ls)
}
// Restore alert rules
@@ -97,8 +103,8 @@ func (a *App) startup(ctx context.Context) {
func (a *App) shutdown(ctx context.Context) {
if a.server != nil {
a.server.Stop()
+ a.configStore.SaveAlertRules(a.server.AlertManager.GetRules())
}
- a.configStore.SaveAlertRules(a.server.AlertManager.GetRules())
if a.logStore != nil {
a.logStore.Close()
}
@@ -251,6 +257,13 @@ func (a *App) LoadPersistedCA() error {
return a.tlsManager.LoadCAMaterial(certPEM, keyPEM)
}
+// IsCAKeyUnencrypted reports whether a persisted CA private key is stored in
+// plaintext on disk (i.e. a CA exists but at-rest encryption is off). The UI
+// surfaces this so the user knows the signing key is not protected at rest.
+func (a *App) IsCAKeyUnencrypted() bool {
+ return a.caStore != nil && a.caStore.Exists() && !a.caStore.IsEncrypted()
+}
+
func (a *App) GenerateServerCert(opts models.CertOptions) (models.CertInfo, error) {
return a.tlsManager.GenerateServerCertSignedByCA(opts)
}
@@ -363,6 +376,34 @@ func (a *App) GetLocalIPs() []string {
return ips
}
+// GetNetworkInterfaces lists bindable local IPv4 addresses with their
+// interface name, for the bind-address selector. Interfaces that are down are
+// skipped; loopback is included so the server can be restricted to localhost.
+func (a *App) GetNetworkInterfaces() []models.NetworkInterface {
+ var out []models.NetworkInterface
+ ifaces, err := net.Interfaces()
+ if err != nil {
+ return out
+ }
+ for _, iface := range ifaces {
+ if iface.Flags&net.FlagUp == 0 {
+ continue
+ }
+ addrs, err := iface.Addrs()
+ if err != nil {
+ continue
+ }
+ for _, addr := range addrs {
+ if ipnet, ok := addr.(*net.IPNet); ok {
+ if ip4 := ipnet.IP.To4(); ip4 != nil {
+ out = append(out, models.NetworkInterface{Name: iface.Name, IP: ip4.String()})
+ }
+ }
+ }
+ }
+ return out
+}
+
// --- Alert Methods ---
func (a *App) GetAlertRules() []models.AlertRule {
@@ -483,7 +524,7 @@ func (a *App) UnlockDatabase(password string) error {
// Success: clear persisted lockout state.
a.configStore.SaveLockout(models.LockoutState{})
a.encryptionPassword = password
- a.server.LogStore = a.logStore
+ a.server.SetLogStore(a.logStore)
// Restore alert rules now that the store is available
rules := a.configStore.LoadAlertRules()
@@ -500,8 +541,8 @@ func (a *App) UnlockDatabase(password string) error {
// EnableEncryption enables at-rest encryption with the given password.
func (a *App) EnableEncryption(password string) error {
- if password == "" {
- return fmt.Errorf("password cannot be empty")
+ if len(password) < minPasswordLen {
+ return fmt.Errorf("password must be at least %d characters", minPasswordLen)
}
cfg := a.configStore.LoadStorage()
cfg.EncryptionEnabled = true
@@ -554,8 +595,8 @@ func (a *App) ChangeEncryptionPassword(oldPassword, newPassword string) error {
if subtle.ConstantTimeCompare([]byte(oldPassword), []byte(a.encryptionPassword)) != 1 {
return fmt.Errorf("incorrect current password")
}
- if newPassword == "" {
- return fmt.Errorf("new password cannot be empty")
+ if len(newPassword) < minPasswordLen {
+ return fmt.Errorf("new password must be at least %d characters", minPasswordLen)
}
a.encryptionPassword = newPassword
if a.logStore != nil {
@@ -598,7 +639,11 @@ func (a *App) GetAppVersion() string {
// OpenURL opens a URL in the user's default browser.
func (a *App) OpenURL(url string) {
- wailsRuntime.BrowserOpenURL(a.ctx, url)
+ // Only open web URLs — never file:, javascript:, or custom-protocol
+ // handlers, which BrowserOpenURL would otherwise dispatch.
+ if u, err := neturl.Parse(url); err == nil && (u.Scheme == "http" || u.Scheme == "https") {
+ wailsRuntime.BrowserOpenURL(a.ctx, url)
+ }
}
// GetUpdateConfig returns the persisted update-check preferences.
diff --git a/build/windows/installer/project.nsi b/build/windows/installer/project.nsi
index 7283319..9496e72 100644
--- a/build/windows/installer/project.nsi
+++ b/build/windows/installer/project.nsi
@@ -329,6 +329,7 @@ Section "Uninstall"
RMDir "$INSTDIR"
; ---- Remove WebView2 data ----
+ RMDir /r "$LOCALAPPDATA\SyslogStudio\WebView2"
RMDir /r "$TEMP\SyslogStudio"
; ---- Remove shortcuts ----
diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte
index 24ed32d..e3fce9d 100644
--- a/frontend/src/App.svelte
+++ b/frontend/src/App.svelte
@@ -14,7 +14,8 @@
import ToastContainer from './components/ToastContainer.svelte';
import AlertConfig from './components/AlertConfig.svelte';
import UnlockScreen from './components/UnlockScreen.svelte';
- import { isEncryptionLocked, getUpdateConfig } from './lib/api';
+ import { isEncryptionLocked, getUpdateConfig, isCAKeyUnencrypted } from './lib/api';
+ import { toastError } from './lib/toast';
import { updateStore } from './lib/updateStore';
import UpdateBanner from './components/UpdateBanner.svelte';
@@ -26,6 +27,19 @@
initEventListeners();
updateStore.loadVersion();
maybeAutoCheck();
+ maybeWarnCAPlaintext();
+ }
+
+ // maybeWarnCAPlaintext alerts the user when a certificate authority private
+ // key is persisted to disk without encryption, so they can act on it.
+ async function maybeWarnCAPlaintext() {
+ try {
+ if (await isCAKeyUnencrypted()) {
+ toastError($_('warnings.caKeyUnencrypted'));
+ }
+ } catch {
+ /* ignore */
+ }
}
// maybeAutoCheck runs the automatic update check, honoring the persisted
diff --git a/frontend/src/components/AlertConfig.svelte b/frontend/src/components/AlertConfig.svelte
index 5b95ad8..6b49758 100644
--- a/frontend/src/components/AlertConfig.svelte
+++ b/frontend/src/components/AlertConfig.svelte
@@ -184,7 +184,7 @@
-
+
{:else}
diff --git a/frontend/src/components/FilterBar.svelte b/frontend/src/components/FilterBar.svelte
index fd5549d..c1f0a9e 100644
--- a/frontend/src/components/FilterBar.svelte
+++ b/frontend/src/components/FilterBar.svelte
@@ -232,7 +232,8 @@
.date-input {
width: 155px;
font-size: 11px;
- color-scheme: dark;
+ /* color-scheme is inherited from :root per theme so the native
+ date/time picker popup matches light and dark. */
}
.severity-selector {
diff --git a/frontend/src/components/LogDetail.svelte b/frontend/src/components/LogDetail.svelte
index eefbcdf..764e8bd 100644
--- a/frontend/src/components/LogDetail.svelte
+++ b/frontend/src/components/LogDetail.svelte
@@ -25,7 +25,7 @@
@@ -190,8 +190,8 @@
padding: 1px 6px;
border-radius: 3px;
font-size: 10px;
- font-weight: 600;
- color: white;
+ font-weight: 700;
+ color: #10161d;
}
.sev-num {
diff --git a/frontend/src/components/LogViewer.svelte b/frontend/src/components/LogViewer.svelte
index d1bc473..0d5adfa 100644
--- a/frontend/src/components/LogViewer.svelte
+++ b/frontend/src/components/LogViewer.svelte
@@ -502,9 +502,9 @@
{/if}
{#if $logViewMode === 'history' && !isGrouped && historyTotalPages > 1}
{/if}
@@ -598,7 +598,7 @@
.col-app { width: 100px; flex-shrink: 0; color: var(--accent); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.col-message { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
- .severity-badge { display: inline-block; padding: 1px 6px; border-radius: 3px; font-size: 10px; font-weight: 600; color: white; text-align: center; min-width: 60px; }
+ .severity-badge { display: inline-block; padding: 1px 6px; border-radius: 3px; font-size: 10px; font-weight: 700; color: #10161d; text-align: center; min-width: 60px; }
.empty-state { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); color: var(--text-muted); font-size: 14px; text-align: center; }
.loading-overlay {
diff --git a/frontend/src/components/ServerControls.svelte b/frontend/src/components/ServerControls.svelte
index 32bf7dd..87a783c 100644
--- a/frontend/src/components/ServerControls.svelte
+++ b/frontend/src/components/ServerControls.svelte
@@ -1,7 +1,8 @@
+
+
{#if visible}
@@ -327,7 +347,7 @@
{/if}
-
+ {#if storageDropped > 0}
+
+ {$_('settings.droppedWrites')}
+ {storageDropped.toLocaleString()}
+
+ {/if}
{/if}
@@ -892,6 +922,11 @@
font-size: 11px;
}
+ .info-value.dropped {
+ color: var(--danger);
+ font-weight: 700;
+ }
+
/* --- Storage actions --- */
.storage-actions {
display: flex;
diff --git a/frontend/src/components/TLSConfig.svelte b/frontend/src/components/TLSConfig.svelte
index bef843e..4e70f23 100644
--- a/frontend/src/components/TLSConfig.svelte
+++ b/frontend/src/components/TLSConfig.svelte
@@ -18,8 +18,14 @@
}
function clearStatus() { error = ''; success = ''; }
+
+ function handleKeydown(e: KeyboardEvent) {
+ if (visible && e.key === 'Escape') onClose();
+ }
+
+
{#if visible}
@@ -27,7 +33,7 @@
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index 97380f5..dea882e 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -53,6 +53,7 @@ export const getDefaultCertOptions = (): Promise
=> _GetDefaultCert
export const exportCACertificate = (): Promise => _ExportCACertificate();
export const exportServerCertificate = (): Promise => _ExportServerCertificate();
export const exportCertificate = (): Promise => _ExportCertificate();
+export const isCAKeyUnencrypted = (): Promise => callGo('IsCAKeyUnencrypted');
// --- Alerts ---
// These use window.go directly since bindings are generated at build time
@@ -114,7 +115,9 @@ export const setUpdateConfig = (cfg: UpdateConfig): Promise => callGo('Set
export const skipUpdateVersion = (version: string): Promise => callGo('SkipUpdateVersion', version);
// --- Utilities ---
+export interface NetworkInterface { name: string; ip: string; }
export const getLocalIPs = (): Promise => _GetLocalIPs();
+export const getNetworkInterfaces = (): Promise => callGo('GetNetworkInterfaces');
export const selectCertFile = (): Promise => _SelectCertFile();
export const selectKeyFile = (): Promise => _SelectKeyFile();
export const selectCAFile = (): Promise => _SelectCAFile();
diff --git a/frontend/src/lib/events.ts b/frontend/src/lib/events.ts
index 5ca0ae0..8c9c0c1 100644
--- a/frontend/src/lib/events.ts
+++ b/frontend/src/lib/events.ts
@@ -38,8 +38,15 @@ function formatNotification(event: AlertEvent): { title: string; body: string }
};
}
+// notificationsEnabled reflects the Settings "system notifications" toggle,
+// persisted to localStorage (default on).
+function notificationsEnabled(): boolean {
+ return localStorage.getItem('syslogstudio-notifications') !== 'false';
+}
+
function sendSystemNotification(event: AlertEvent) {
if (!notificationsReady || !window.runtime?.SendNotification) return;
+ if (!notificationsEnabled()) return;
try {
const { title, body } = formatNotification(event);
window.runtime.SendNotification({ id: event.id, title, body });
diff --git a/frontend/src/lib/i18n/de.json b/frontend/src/lib/i18n/de.json
index bd96e58..caab52a 100644
--- a/frontend/src/lib/i18n/de.json
+++ b/frontend/src/lib/i18n/de.json
@@ -1,4 +1,10 @@
{
+ "common": {
+ "close": "Schließen",
+ "previousPage": "Vorherige Seite",
+ "nextPage": "Nächste Seite",
+ "delete": "Löschen"
+ },
"nav": {
"logs": "Protokolle",
"stats": "Statistiken",
@@ -16,7 +22,9 @@
"rate": "Nachrichtenrate",
"buffer": "Ringpuffer-Auslastung",
"dbMessages": "Gespeicherte Nachrichten",
- "dbSize": "Datenbankgröße"
+ "dbSize": "Datenbankgröße",
+ "dbLoading": "Datenbank wird geladen …",
+ "dbIndexing": "Indizierung …"
},
"server": {
"start": "Starten",
@@ -26,7 +34,9 @@
"allInterfaces": "Alle Schnittstellen",
"bindAddressHint": "Bind-Adresse: lokale IP, auf der gelauscht wird. Leer = alle Schnittstellen.",
"allowedSources": "Erlaubte Quellen",
- "allowedSourcesHint": "Erlaubte Quellen: IPs oder CIDR-Bereiche, kommagetrennt. Leer = alle erlauben. Hinweis: UDP-Quellen können gefälscht sein."
+ "allowedSourcesHint": "Erlaubte Quellen: IPs oder CIDR-Bereiche, kommagetrennt. Leer = alle erlauben. Hinweis: UDP-Quellen können gefälscht sein.",
+ "maxConnsPerIP": "Max. Verb./IP",
+ "maxConnsPerIPHint": "Maximale gleichzeitige TCP/TLS-Verbindungen von einer einzelnen Quell-IP. Für ein Syslog-Relay oder NAT-Gateway erhöhen, das viele Sender hinter einer Adresse bündelt."
},
"filter": {
"severity": "Schweregrad",
@@ -47,8 +57,11 @@
"textExportFailed": "Textexport fehlgeschlagen",
"dateFrom": "Von",
"dateTo": "Bis",
- "regex": "Regex",
- "regexTitle": "Regex-Suche aktivieren"
+ "modeText": "Textsuche (klicken für FTS)",
+ "modeFts": "FTS erweitert: OR, AND, NOT, \"Phrase\", Präfix* (klicken für Regex)",
+ "modeRegex": "Regex-Suche (klicken für Text)",
+ "ftsPlaceholder": "error OR fail OR timeout",
+ "regexPlaceholder": "(error|fail|timeout)"
},
"log": {
"severity": "Schweregrad",
@@ -88,7 +101,8 @@
"rawMessage": "Rohnachricht",
"copy": "Kopieren",
"copiedToClipboard": "In die Zwischenablage kopiert",
- "failedToCopy": "Kopieren in die Zwischenablage fehlgeschlagen"
+ "failedToCopy": "Kopieren in die Zwischenablage fehlgeschlagen",
+ "matchesFound": "Treffer gefunden"
},
"dashboard": {
"totalMessages": "Nachrichten gesamt",
@@ -179,7 +193,9 @@
"checkNow": "Nach Updates suchen",
"checkingUpdates": "Wird geprüft…",
"upToDate": "Sie verwenden die neueste Version.",
- "updateCheckFailed": "Update-Prüfung fehlgeschlagen: {error}"
+ "updateCheckFailed": "Update-Prüfung fehlgeschlagen: {error}",
+ "autoUpdateCheck": "Beim Start nach Updates suchen",
+ "droppedWrites": "Verworfene Logs (Überlast)"
},
"alerts": {
"rules": "Warnregeln",
@@ -211,24 +227,40 @@
"clearFailed": "Verlauf konnte nicht geleert werden"
},
"encryption": {
- "title": "Encryption",
- "enableEncryption": "Encrypt database at rest",
- "hint": "When enabled, the database is encrypted when the app closes and decrypted on startup with your password.",
- "password": "Password",
- "confirmPassword": "Confirm password",
- "currentPassword": "Current password",
- "enable": "Enable Encryption",
- "disable": "Disable Encryption",
- "passwordMismatch": "Passwords do not match.",
- "passwordRequired": "Password is required.",
- "wrongPassword": "Incorrect password. Please try again.",
- "enableSuccess": "Encryption enabled. The database will be encrypted when the app closes.",
- "disableSuccess": "Encryption disabled.",
- "unlockTitle": "Database Locked",
- "unlockMessage": "The log database is encrypted. Enter your password to unlock.",
- "unlock": "Unlock",
- "unlocking": "Unlocking...",
- "lockedOutSeconds": "Zu viele Fehlversuche. Gesperrt für {seconds}s."
+ "title": "Verschlüsselung",
+ "enableEncryption": "Datenbank im Ruhezustand verschlüsseln",
+ "hint": "Wenn aktiviert, wird die Datenbank beim Schließen der App verschlüsselt und beim Start mit Ihrem Passwort entschlüsselt.",
+ "password": "Passwort",
+ "confirmPassword": "Passwort bestätigen",
+ "currentPassword": "Aktuelles Passwort",
+ "enable": "Verschlüsselung aktivieren",
+ "disable": "Verschlüsselung deaktivieren",
+ "passwordMismatch": "Die Passwörter stimmen nicht überein.",
+ "passwordRequired": "Passwort ist erforderlich.",
+ "wrongPassword": "Falsches Passwort. Bitte erneut versuchen.",
+ "enableSuccess": "Verschlüsselung aktiviert. Die Datenbank wird beim Schließen der App verschlüsselt.",
+ "disableSuccess": "Verschlüsselung deaktiviert.",
+ "unlockTitle": "Datenbank gesperrt",
+ "unlockMessage": "Die Protokolldatenbank ist verschlüsselt. Geben Sie Ihr Passwort zum Entsperren ein.",
+ "unlock": "Entsperren",
+ "unlocking": "Wird entsperrt …",
+ "lockedOutSeconds": "Zu viele Fehlversuche. Gesperrt für {seconds}s.",
+ "wrongPasswordAttempts": "Falsches Passwort. Noch {remaining} Versuch(e).",
+ "lockedOut": "Zu viele Fehlversuche. Die Anwendung wird geschlossen.",
+ "warningTitle": "Wichtig — bitte aufmerksam lesen",
+ "warningMessage": "Das Verschlüsselungspasswort wird niemals auf der Festplatte gespeichert. Es existiert nur im Arbeitsspeicher, solange die App läuft.",
+ "warningPoint1": "Wenn Sie Ihr Passwort vergessen, ist Ihre Protokolldatenbank dauerhaft unzugänglich.",
+ "warningPoint2": "Es gibt keinen Wiederherstellungsmechanismus — die Daten sind mit AES-256 verschlüsselt.",
+ "warningPoint3": "Merken Sie sich Ihr Passwort oder speichern Sie es in einem Passwort-Manager.",
+ "warningCancel": "Abbrechen",
+ "warningConfirm": "Verstanden, Verschlüsselung aktivieren",
+ "progressReading": "Verschlüsselte Datenbank wird gelesen …",
+ "progressDeriving": "Passwort wird überprüft …",
+ "progressDecrypting": "Wird entschlüsselt …",
+ "progressWriting": "Datenbank wird geschrieben …",
+ "progressInitializing": "Datenbank wird geöffnet …",
+ "progressIndexing": "Suchindex wird neu aufgebaut …",
+ "progressDone": "Fertig"
},
"update": {
"available": "SyslogStudio {version} ist verfügbar",
@@ -238,5 +270,8 @@
"download": "Herunterladen",
"skip": "Diese Version überspringen",
"later": "Später"
+ },
+ "warnings": {
+ "caKeyUnencrypted": "Warnung: Der private Schlüssel der Zertifizierungsstelle ist unverschlüsselt auf der Festplatte gespeichert."
}
-}
+}
\ No newline at end of file
diff --git a/frontend/src/lib/i18n/en.json b/frontend/src/lib/i18n/en.json
index 6cbba22..287ef5b 100644
--- a/frontend/src/lib/i18n/en.json
+++ b/frontend/src/lib/i18n/en.json
@@ -1,4 +1,10 @@
{
+ "common": {
+ "close": "Close",
+ "previousPage": "Previous page",
+ "nextPage": "Next page",
+ "delete": "Delete"
+ },
"nav": {
"logs": "Logs",
"stats": "Stats",
@@ -28,7 +34,9 @@
"allInterfaces": "All interfaces",
"bindAddressHint": "Bind address: local IP to listen on. Empty = all interfaces.",
"allowedSources": "Allowed sources",
- "allowedSourcesHint": "Allowed sources: IPs or CIDR ranges, comma-separated. Empty = allow all. Note: UDP sources can be spoofed."
+ "allowedSourcesHint": "Allowed sources: IPs or CIDR ranges, comma-separated. Empty = allow all. Note: UDP sources can be spoofed.",
+ "maxConnsPerIP": "Max conn/IP",
+ "maxConnsPerIPHint": "Maximum concurrent TCP/TLS connections from a single source IP. Raise it for a syslog relay or NAT gateway that multiplexes many senders behind one address."
},
"filter": {
"severity": "Severity",
@@ -186,7 +194,8 @@
"checkNow": "Check for updates",
"checkingUpdates": "Checking…",
"upToDate": "You're on the latest version.",
- "updateCheckFailed": "Update check failed: {error}"
+ "updateCheckFailed": "Update check failed: {error}",
+ "droppedWrites": "Dropped logs (overload)"
},
"alerts": {
"rules": "Alert Rules",
@@ -261,5 +270,8 @@
"download": "Download",
"skip": "Skip this version",
"later": "Later"
+ },
+ "warnings": {
+ "caKeyUnencrypted": "Warning: the certificate authority private key is stored unencrypted on disk."
}
-}
+}
\ No newline at end of file
diff --git a/frontend/src/lib/i18n/es.json b/frontend/src/lib/i18n/es.json
index 7ee25cc..d7d99d5 100644
--- a/frontend/src/lib/i18n/es.json
+++ b/frontend/src/lib/i18n/es.json
@@ -1,4 +1,10 @@
{
+ "common": {
+ "close": "Cerrar",
+ "previousPage": "Página anterior",
+ "nextPage": "Página siguiente",
+ "delete": "Eliminar"
+ },
"nav": {
"logs": "Registros",
"stats": "Estadísticas",
@@ -16,7 +22,9 @@
"rate": "Tasa de mensajes",
"buffer": "Uso del búfer circular",
"dbMessages": "Mensajes almacenados",
- "dbSize": "Tamaño de la base de datos"
+ "dbSize": "Tamaño de la base de datos",
+ "dbLoading": "Cargando base de datos…",
+ "dbIndexing": "Indexando…"
},
"server": {
"start": "Iniciar",
@@ -26,7 +34,9 @@
"allInterfaces": "Todas las interfaces",
"bindAddressHint": "Dirección de escucha: IP local en la que escuchar. Vacío = todas las interfaces.",
"allowedSources": "Fuentes permitidas",
- "allowedSourcesHint": "Fuentes permitidas: IP o rangos CIDR, separados por comas. Vacío = permitir todas. Nota: las fuentes UDP pueden falsificarse."
+ "allowedSourcesHint": "Fuentes permitidas: IP o rangos CIDR, separados por comas. Vacío = permitir todas. Nota: las fuentes UDP pueden falsificarse.",
+ "maxConnsPerIP": "Máx. con./IP",
+ "maxConnsPerIPHint": "Número máximo de conexiones TCP/TLS simultáneas desde una misma IP de origen. Auméntalo para un relé syslog o pasarela NAT que multiplexa muchos emisores tras una sola dirección."
},
"filter": {
"severity": "Severidad",
@@ -47,8 +57,11 @@
"textExportFailed": "Error al exportar texto",
"dateFrom": "Desde",
"dateTo": "Hasta",
- "regex": "Regex",
- "regexTitle": "Activar búsqueda con regex"
+ "modeText": "Búsqueda de texto (clic para cambiar a FTS)",
+ "modeFts": "FTS avanzada: OR, AND, NOT, \"frase\", prefijo* (clic para cambiar a Regex)",
+ "modeRegex": "Búsqueda por regex (clic para cambiar a Texto)",
+ "ftsPlaceholder": "error OR fail OR timeout",
+ "regexPlaceholder": "(error|fail|timeout)"
},
"log": {
"severity": "Severidad",
@@ -88,7 +101,8 @@
"rawMessage": "Mensaje sin procesar",
"copy": "Copiar",
"copiedToClipboard": "Copiado al portapapeles",
- "failedToCopy": "Error al copiar al portapapeles"
+ "failedToCopy": "Error al copiar al portapapeles",
+ "matchesFound": "coincidencias encontradas"
},
"dashboard": {
"totalMessages": "Total de mensajes",
@@ -179,7 +193,9 @@
"checkNow": "Buscar actualizaciones",
"checkingUpdates": "Comprobando…",
"upToDate": "Ya tienes la última versión.",
- "updateCheckFailed": "Error al buscar actualizaciones: {error}"
+ "updateCheckFailed": "Error al buscar actualizaciones: {error}",
+ "autoUpdateCheck": "Buscar actualizaciones al iniciar",
+ "droppedWrites": "Registros descartados (sobrecarga)"
},
"alerts": {
"rules": "Reglas de alerta",
@@ -211,24 +227,40 @@
"clearFailed": "Error al limpiar el historial"
},
"encryption": {
- "title": "Encryption",
- "enableEncryption": "Encrypt database at rest",
- "hint": "When enabled, the database is encrypted when the app closes and decrypted on startup with your password.",
- "password": "Password",
- "confirmPassword": "Confirm password",
- "currentPassword": "Current password",
- "enable": "Enable Encryption",
- "disable": "Disable Encryption",
- "passwordMismatch": "Passwords do not match.",
- "passwordRequired": "Password is required.",
- "wrongPassword": "Incorrect password. Please try again.",
- "enableSuccess": "Encryption enabled. The database will be encrypted when the app closes.",
- "disableSuccess": "Encryption disabled.",
- "unlockTitle": "Database Locked",
- "unlockMessage": "The log database is encrypted. Enter your password to unlock.",
- "unlock": "Unlock",
- "unlocking": "Unlocking...",
- "lockedOutSeconds": "Demasiados intentos fallidos. Bloqueado durante {seconds}s."
+ "title": "Cifrado",
+ "enableEncryption": "Cifrar la base de datos en reposo",
+ "hint": "Si se activa, la base de datos se cifra al cerrar la app y se descifra al iniciar con tu contraseña.",
+ "password": "Contraseña",
+ "confirmPassword": "Confirmar contraseña",
+ "currentPassword": "Contraseña actual",
+ "enable": "Activar cifrado",
+ "disable": "Desactivar cifrado",
+ "passwordMismatch": "Las contraseñas no coinciden.",
+ "passwordRequired": "La contraseña es obligatoria.",
+ "wrongPassword": "Contraseña incorrecta. Inténtalo de nuevo.",
+ "enableSuccess": "Cifrado activado. La base de datos se cifrará al cerrar la app.",
+ "disableSuccess": "Cifrado desactivado.",
+ "unlockTitle": "Base de datos bloqueada",
+ "unlockMessage": "La base de datos de registros está cifrada. Introduce tu contraseña para desbloquear.",
+ "unlock": "Desbloquear",
+ "unlocking": "Desbloqueando…",
+ "lockedOutSeconds": "Demasiados intentos fallidos. Bloqueado durante {seconds}s.",
+ "wrongPasswordAttempts": "Contraseña incorrecta. Quedan {remaining} intento(s).",
+ "lockedOut": "Demasiados intentos fallidos. La aplicación se cerrará.",
+ "warningTitle": "Importante — léelo con atención",
+ "warningMessage": "La contraseña de cifrado nunca se guarda en disco. Solo existe en memoria mientras la app está en ejecución.",
+ "warningPoint1": "Si olvidas tu contraseña, tu base de datos de registros será permanentemente inaccesible.",
+ "warningPoint2": "No hay mecanismo de recuperación: los datos están cifrados con AES-256.",
+ "warningPoint3": "Asegúrate de recordar tu contraseña o guárdala en un gestor de contraseñas.",
+ "warningCancel": "Cancelar",
+ "warningConfirm": "Entiendo, activar cifrado",
+ "progressReading": "Leyendo la base de datos cifrada…",
+ "progressDeriving": "Verificando la contraseña…",
+ "progressDecrypting": "Descifrando…",
+ "progressWriting": "Escribiendo la base de datos…",
+ "progressInitializing": "Abriendo la base de datos…",
+ "progressIndexing": "Reconstruyendo el índice de búsqueda…",
+ "progressDone": "Listo"
},
"update": {
"available": "SyslogStudio {version} está disponible",
@@ -238,5 +270,8 @@
"download": "Descargar",
"skip": "Omitir esta versión",
"later": "Más tarde"
+ },
+ "warnings": {
+ "caKeyUnencrypted": "Advertencia: la clave privada de la autoridad de certificación se almacena sin cifrar en el disco."
}
-}
+}
\ No newline at end of file
diff --git a/frontend/src/lib/i18n/fr.json b/frontend/src/lib/i18n/fr.json
index b5dde49..ea9829c 100644
--- a/frontend/src/lib/i18n/fr.json
+++ b/frontend/src/lib/i18n/fr.json
@@ -1,4 +1,10 @@
{
+ "common": {
+ "close": "Fermer",
+ "previousPage": "Page précédente",
+ "nextPage": "Page suivante",
+ "delete": "Supprimer"
+ },
"nav": {
"logs": "Journaux",
"stats": "Statistiques",
@@ -28,7 +34,9 @@
"allInterfaces": "Toutes les interfaces",
"bindAddressHint": "Adresse d'écoute : IP locale sur laquelle écouter. Vide = toutes les interfaces.",
"allowedSources": "Sources autorisées",
- "allowedSourcesHint": "Sources autorisées : IP ou plages CIDR, séparées par des virgules. Vide = tout autoriser. Remarque : les sources UDP peuvent être usurpées."
+ "allowedSourcesHint": "Sources autorisées : IP ou plages CIDR, séparées par des virgules. Vide = tout autoriser. Remarque : les sources UDP peuvent être usurpées.",
+ "maxConnsPerIP": "Max conn./IP",
+ "maxConnsPerIPHint": "Nombre maximum de connexions TCP/TLS simultanées depuis une même IP source. À augmenter pour un relais syslog ou une passerelle NAT qui multiplexe plusieurs émetteurs derrière une seule adresse."
},
"filter": {
"severity": "Sévérité",
@@ -186,7 +194,8 @@
"checkNow": "Vérifier les mises à jour",
"checkingUpdates": "Vérification…",
"upToDate": "Vous utilisez la dernière version.",
- "updateCheckFailed": "Échec de la vérification : {error}"
+ "updateCheckFailed": "Échec de la vérification : {error}",
+ "droppedWrites": "Logs perdus (surcharge)"
},
"alerts": {
"rules": "Règles d'alerte",
@@ -261,5 +270,8 @@
"download": "Télécharger",
"skip": "Ignorer cette version",
"later": "Plus tard"
+ },
+ "warnings": {
+ "caKeyUnencrypted": "Attention : la clé privée de l'autorité de certification est stockée en clair sur le disque."
}
-}
+}
\ No newline at end of file
diff --git a/frontend/src/lib/i18n/it.json b/frontend/src/lib/i18n/it.json
index c97673a..bd4b26e 100644
--- a/frontend/src/lib/i18n/it.json
+++ b/frontend/src/lib/i18n/it.json
@@ -1,4 +1,10 @@
{
+ "common": {
+ "close": "Chiudi",
+ "previousPage": "Pagina precedente",
+ "nextPage": "Pagina successiva",
+ "delete": "Elimina"
+ },
"nav": {
"logs": "Registri",
"stats": "Statistiche",
@@ -16,7 +22,9 @@
"rate": "Frequenza messaggi",
"buffer": "Utilizzo buffer circolare",
"dbMessages": "Messaggi archiviati",
- "dbSize": "Dimensione database"
+ "dbSize": "Dimensione database",
+ "dbLoading": "Caricamento del database…",
+ "dbIndexing": "Indicizzazione…"
},
"server": {
"start": "Avvia",
@@ -26,7 +34,9 @@
"allInterfaces": "Tutte le interfacce",
"bindAddressHint": "Indirizzo di ascolto: IP locale su cui ascoltare. Vuoto = tutte le interfacce.",
"allowedSources": "Origini consentite",
- "allowedSourcesHint": "Origini consentite: IP o intervalli CIDR, separati da virgole. Vuoto = consenti tutti. Nota: le origini UDP possono essere contraffatte."
+ "allowedSourcesHint": "Origini consentite: IP o intervalli CIDR, separati da virgole. Vuoto = consenti tutti. Nota: le origini UDP possono essere contraffatte.",
+ "maxConnsPerIP": "Max conn./IP",
+ "maxConnsPerIPHint": "Numero massimo di connessioni TCP/TLS simultanee da un singolo IP di origine. Aumentalo per un relay syslog o gateway NAT che multiplexa molti mittenti dietro un unico indirizzo."
},
"filter": {
"severity": "Gravità",
@@ -47,8 +57,11 @@
"textExportFailed": "Esportazione testo non riuscita",
"dateFrom": "Da",
"dateTo": "A",
- "regex": "Regex",
- "regexTitle": "Attiva ricerca con regex"
+ "modeText": "Ricerca testuale (clic per passare a FTS)",
+ "modeFts": "FTS avanzata: OR, AND, NOT, \"frase\", prefisso* (clic per passare a Regex)",
+ "modeRegex": "Ricerca regex (clic per passare a Testo)",
+ "ftsPlaceholder": "error OR fail OR timeout",
+ "regexPlaceholder": "(error|fail|timeout)"
},
"log": {
"severity": "Gravità",
@@ -88,7 +101,8 @@
"rawMessage": "Messaggio grezzo",
"copy": "Copia",
"copiedToClipboard": "Copiato negli appunti",
- "failedToCopy": "Impossibile copiare negli appunti"
+ "failedToCopy": "Impossibile copiare negli appunti",
+ "matchesFound": "corrispondenze trovate"
},
"dashboard": {
"totalMessages": "Totale messaggi",
@@ -179,7 +193,9 @@
"checkNow": "Controlla aggiornamenti",
"checkingUpdates": "Controllo…",
"upToDate": "Stai usando l'ultima versione.",
- "updateCheckFailed": "Controllo aggiornamenti non riuscito: {error}"
+ "updateCheckFailed": "Controllo aggiornamenti non riuscito: {error}",
+ "autoUpdateCheck": "Controlla aggiornamenti all'avvio",
+ "droppedWrites": "Log scartati (sovraccarico)"
},
"alerts": {
"rules": "Regole di avviso",
@@ -211,24 +227,40 @@
"clearFailed": "Impossibile cancellare la cronologia"
},
"encryption": {
- "title": "Encryption",
- "enableEncryption": "Encrypt database at rest",
- "hint": "When enabled, the database is encrypted when the app closes and decrypted on startup with your password.",
+ "title": "Crittografia",
+ "enableEncryption": "Cifra il database a riposo",
+ "hint": "Se abilitata, il database viene cifrato alla chiusura dell'app e decifrato all'avvio con la tua password.",
"password": "Password",
- "confirmPassword": "Confirm password",
- "currentPassword": "Current password",
- "enable": "Enable Encryption",
- "disable": "Disable Encryption",
- "passwordMismatch": "Passwords do not match.",
- "passwordRequired": "Password is required.",
- "wrongPassword": "Incorrect password. Please try again.",
- "enableSuccess": "Encryption enabled. The database will be encrypted when the app closes.",
- "disableSuccess": "Encryption disabled.",
- "unlockTitle": "Database Locked",
- "unlockMessage": "The log database is encrypted. Enter your password to unlock.",
- "unlock": "Unlock",
- "unlocking": "Unlocking...",
- "lockedOutSeconds": "Troppi tentativi falliti. Bloccato per {seconds}s."
+ "confirmPassword": "Conferma password",
+ "currentPassword": "Password attuale",
+ "enable": "Abilita crittografia",
+ "disable": "Disabilita crittografia",
+ "passwordMismatch": "Le password non corrispondono.",
+ "passwordRequired": "La password è obbligatoria.",
+ "wrongPassword": "Password errata. Riprova.",
+ "enableSuccess": "Crittografia abilitata. Il database verrà cifrato alla chiusura dell'app.",
+ "disableSuccess": "Crittografia disabilitata.",
+ "unlockTitle": "Database bloccato",
+ "unlockMessage": "Il database dei log è cifrato. Inserisci la password per sbloccarlo.",
+ "unlock": "Sblocca",
+ "unlocking": "Sblocco in corso…",
+ "lockedOutSeconds": "Troppi tentativi falliti. Bloccato per {seconds}s.",
+ "wrongPasswordAttempts": "Password errata. {remaining} tentativo/i rimasti.",
+ "lockedOut": "Troppi tentativi falliti. L'applicazione verrà chiusa.",
+ "warningTitle": "Importante — leggi attentamente",
+ "warningMessage": "La password di crittografia non viene mai salvata su disco. Esiste solo in memoria mentre l'app è in esecuzione.",
+ "warningPoint1": "Se dimentichi la password, il database dei log sarà definitivamente inaccessibile.",
+ "warningPoint2": "Non esiste alcun meccanismo di recupero: i dati sono cifrati con AES-256.",
+ "warningPoint3": "Assicurati di ricordare la password o conservala in un gestore di password.",
+ "warningCancel": "Annulla",
+ "warningConfirm": "Ho capito, abilita la crittografia",
+ "progressReading": "Lettura del database cifrato…",
+ "progressDeriving": "Verifica della password…",
+ "progressDecrypting": "Decifratura…",
+ "progressWriting": "Scrittura del database…",
+ "progressInitializing": "Apertura del database…",
+ "progressIndexing": "Ricostruzione dell'indice di ricerca…",
+ "progressDone": "Fatto"
},
"update": {
"available": "SyslogStudio {version} è disponibile",
@@ -238,5 +270,8 @@
"download": "Scarica",
"skip": "Ignora questa versione",
"later": "Più tardi"
+ },
+ "warnings": {
+ "caKeyUnencrypted": "Attenzione: la chiave privata dell'autorità di certificazione è archiviata non cifrata su disco."
}
-}
+}
\ No newline at end of file
diff --git a/frontend/src/lib/i18n/ja.json b/frontend/src/lib/i18n/ja.json
index 5970928..c6c50e1 100644
--- a/frontend/src/lib/i18n/ja.json
+++ b/frontend/src/lib/i18n/ja.json
@@ -1,4 +1,10 @@
{
+ "common": {
+ "close": "閉じる",
+ "previousPage": "前のページ",
+ "nextPage": "次のページ",
+ "delete": "削除"
+ },
"nav": {
"logs": "ログ",
"stats": "統計",
@@ -16,7 +22,9 @@
"rate": "メッセージレート",
"buffer": "リングバッファ使用率",
"dbMessages": "保存済みメッセージ",
- "dbSize": "データベースサイズ"
+ "dbSize": "データベースサイズ",
+ "dbLoading": "データベースを読み込み中…",
+ "dbIndexing": "インデックス作成中…"
},
"server": {
"start": "開始",
@@ -26,7 +34,9 @@
"allInterfaces": "すべてのインターフェース",
"bindAddressHint": "バインドアドレス:待ち受けるローカルIP。空欄=すべてのインターフェース。",
"allowedSources": "許可された送信元",
- "allowedSourcesHint": "許可された送信元:IPまたはCIDR範囲、カンマ区切り。空欄=すべて許可。注意:UDPの送信元は偽装可能です。"
+ "allowedSourcesHint": "許可された送信元:IPまたはCIDR範囲、カンマ区切り。空欄=すべて許可。注意:UDPの送信元は偽装可能です。",
+ "maxConnsPerIP": "最大接続数/IP",
+ "maxConnsPerIPHint": "単一の送信元IPからの同時TCP/TLS接続の最大数。多数の送信元を1つのアドレスに集約するsyslogリレーやNATゲートウェイでは増やしてください。"
},
"filter": {
"severity": "重大度",
@@ -47,8 +57,11 @@
"textExportFailed": "テキストエクスポートに失敗しました",
"dateFrom": "開始日",
"dateTo": "終了日",
- "regex": "Regex",
- "regexTitle": "正規表現検索を有効にする"
+ "modeText": "テキスト検索(クリックで FTS に切替)",
+ "modeFts": "FTS 詳細検索:OR、AND、NOT、\"フレーズ\"、接頭辞*(クリックで正規表現に切替)",
+ "modeRegex": "正規表現検索(クリックでテキストに切替)",
+ "ftsPlaceholder": "error OR fail OR timeout",
+ "regexPlaceholder": "(error|fail|timeout)"
},
"log": {
"severity": "重大度",
@@ -88,7 +101,8 @@
"rawMessage": "生メッセージ",
"copy": "コピー",
"copiedToClipboard": "クリップボードにコピーしました",
- "failedToCopy": "クリップボードへのコピーに失敗しました"
+ "failedToCopy": "クリップボードへのコピーに失敗しました",
+ "matchesFound": "件の一致"
},
"dashboard": {
"totalMessages": "メッセージ合計",
@@ -179,7 +193,9 @@
"checkNow": "更新を確認",
"checkingUpdates": "確認中…",
"upToDate": "最新バージョンを使用しています。",
- "updateCheckFailed": "更新の確認に失敗しました: {error}"
+ "updateCheckFailed": "更新の確認に失敗しました: {error}",
+ "autoUpdateCheck": "起動時に更新を確認",
+ "droppedWrites": "破棄されたログ(過負荷)"
},
"alerts": {
"rules": "アラートルール",
@@ -211,24 +227,40 @@
"clearFailed": "履歴のクリアに失敗しました"
},
"encryption": {
- "title": "Encryption",
- "enableEncryption": "Encrypt database at rest",
- "hint": "When enabled, the database is encrypted when the app closes and decrypted on startup with your password.",
- "password": "Password",
- "confirmPassword": "Confirm password",
- "currentPassword": "Current password",
- "enable": "Enable Encryption",
- "disable": "Disable Encryption",
- "passwordMismatch": "Passwords do not match.",
- "passwordRequired": "Password is required.",
- "wrongPassword": "Incorrect password. Please try again.",
- "enableSuccess": "Encryption enabled. The database will be encrypted when the app closes.",
- "disableSuccess": "Encryption disabled.",
- "unlockTitle": "Database Locked",
- "unlockMessage": "The log database is encrypted. Enter your password to unlock.",
- "unlock": "Unlock",
- "unlocking": "Unlocking...",
- "lockedOutSeconds": "試行回数が多すぎます。{seconds}秒間ロックされます。"
+ "title": "暗号化",
+ "enableEncryption": "データベースを保存時に暗号化",
+ "hint": "有効にすると、アプリ終了時にデータベースが暗号化され、起動時にパスワードで復号されます。",
+ "password": "パスワード",
+ "confirmPassword": "パスワードの確認",
+ "currentPassword": "現在のパスワード",
+ "enable": "暗号化を有効にする",
+ "disable": "暗号化を無効にする",
+ "passwordMismatch": "パスワードが一致しません。",
+ "passwordRequired": "パスワードは必須です。",
+ "wrongPassword": "パスワードが正しくありません。もう一度お試しください。",
+ "enableSuccess": "暗号化を有効にしました。アプリ終了時にデータベースが暗号化されます。",
+ "disableSuccess": "暗号化を無効にしました。",
+ "unlockTitle": "データベースがロックされています",
+ "unlockMessage": "ログデータベースは暗号化されています。ロックを解除するにはパスワードを入力してください。",
+ "unlock": "ロック解除",
+ "unlocking": "ロック解除中…",
+ "lockedOutSeconds": "失敗回数が多すぎます。{seconds}秒間ロックされます。",
+ "wrongPasswordAttempts": "パスワードが正しくありません。残り {remaining} 回。",
+ "lockedOut": "失敗回数が多すぎます。アプリケーションを終了します。",
+ "warningTitle": "重要 — よくお読みください",
+ "warningMessage": "暗号化パスワードはディスクに保存されません。アプリの実行中、メモリ上にのみ存在します。",
+ "warningPoint1": "パスワードを忘れると、ログデータベースには永久にアクセスできなくなります。",
+ "warningPoint2": "復旧手段はありません。データは AES-256 で暗号化されます。",
+ "warningPoint3": "パスワードを必ず覚えるか、パスワードマネージャーに保存してください。",
+ "warningCancel": "キャンセル",
+ "warningConfirm": "理解しました。暗号化を有効にする",
+ "progressReading": "暗号化されたデータベースを読み込み中…",
+ "progressDeriving": "パスワードを検証中…",
+ "progressDecrypting": "復号中…",
+ "progressWriting": "データベースを書き込み中…",
+ "progressInitializing": "データベースを開いています…",
+ "progressIndexing": "検索インデックスを再構築中…",
+ "progressDone": "完了"
},
"update": {
"available": "SyslogStudio {version} が利用可能です",
@@ -238,5 +270,8 @@
"download": "ダウンロード",
"skip": "このバージョンをスキップ",
"later": "後で"
+ },
+ "warnings": {
+ "caKeyUnencrypted": "警告:認証局の秘密鍵が暗号化されずにディスクに保存されています。"
}
-}
+}
\ No newline at end of file
diff --git a/frontend/src/lib/i18n/pt.json b/frontend/src/lib/i18n/pt.json
index 63e034a..d785790 100644
--- a/frontend/src/lib/i18n/pt.json
+++ b/frontend/src/lib/i18n/pt.json
@@ -1,4 +1,10 @@
{
+ "common": {
+ "close": "Fechar",
+ "previousPage": "Página anterior",
+ "nextPage": "Próxima página",
+ "delete": "Excluir"
+ },
"nav": {
"logs": "Registos",
"stats": "Estatísticas",
@@ -16,7 +22,9 @@
"rate": "Taxa de mensagens",
"buffer": "Utilização do buffer circular",
"dbMessages": "Mensagens armazenadas",
- "dbSize": "Tamanho da base de dados"
+ "dbSize": "Tamanho da base de dados",
+ "dbLoading": "Carregando banco de dados…",
+ "dbIndexing": "Indexando…"
},
"server": {
"start": "Iniciar",
@@ -26,7 +34,9 @@
"allInterfaces": "Todas as interfaces",
"bindAddressHint": "Endereço de escuta: IP local no qual escutar. Vazio = todas as interfaces.",
"allowedSources": "Fontes permitidas",
- "allowedSourcesHint": "Fontes permitidas: IPs ou faixas CIDR, separados por vírgulas. Vazio = permitir todos. Nota: fontes UDP podem ser falsificadas."
+ "allowedSourcesHint": "Fontes permitidas: IPs ou faixas CIDR, separados por vírgulas. Vazio = permitir todos. Nota: fontes UDP podem ser falsificadas.",
+ "maxConnsPerIP": "Máx. con./IP",
+ "maxConnsPerIPHint": "Número máximo de conexões TCP/TLS simultâneas de um mesmo IP de origem. Aumente para um relé syslog ou gateway NAT que multiplexa vários remetentes atrás de um único endereço."
},
"filter": {
"severity": "Severidade",
@@ -47,8 +57,11 @@
"textExportFailed": "Falha ao exportar texto",
"dateFrom": "De",
"dateTo": "Até",
- "regex": "Regex",
- "regexTitle": "Ativar pesquisa com regex"
+ "modeText": "Busca de texto (clique para mudar para FTS)",
+ "modeFts": "FTS avançada: OR, AND, NOT, \"frase\", prefixo* (clique para mudar para Regex)",
+ "modeRegex": "Busca por regex (clique para mudar para Texto)",
+ "ftsPlaceholder": "error OR fail OR timeout",
+ "regexPlaceholder": "(error|fail|timeout)"
},
"log": {
"severity": "Severidade",
@@ -88,7 +101,8 @@
"rawMessage": "Mensagem em bruto",
"copy": "Copiar",
"copiedToClipboard": "Copiado para a área de transferência",
- "failedToCopy": "Falha ao copiar para a área de transferência"
+ "failedToCopy": "Falha ao copiar para a área de transferência",
+ "matchesFound": "correspondências encontradas"
},
"dashboard": {
"totalMessages": "Total de mensagens",
@@ -179,7 +193,9 @@
"checkNow": "Procurar atualizações",
"checkingUpdates": "Verificando…",
"upToDate": "Você está na versão mais recente.",
- "updateCheckFailed": "Falha ao procurar atualizações: {error}"
+ "updateCheckFailed": "Falha ao procurar atualizações: {error}",
+ "autoUpdateCheck": "Procurar atualizações ao iniciar",
+ "droppedWrites": "Logs descartados (sobrecarga)"
},
"alerts": {
"rules": "Regras de alerta",
@@ -211,24 +227,40 @@
"clearFailed": "Falha ao limpar o histórico"
},
"encryption": {
- "title": "Encryption",
- "enableEncryption": "Encrypt database at rest",
- "hint": "When enabled, the database is encrypted when the app closes and decrypted on startup with your password.",
- "password": "Password",
- "confirmPassword": "Confirm password",
- "currentPassword": "Current password",
- "enable": "Enable Encryption",
- "disable": "Disable Encryption",
- "passwordMismatch": "Passwords do not match.",
- "passwordRequired": "Password is required.",
- "wrongPassword": "Incorrect password. Please try again.",
- "enableSuccess": "Encryption enabled. The database will be encrypted when the app closes.",
- "disableSuccess": "Encryption disabled.",
- "unlockTitle": "Database Locked",
- "unlockMessage": "The log database is encrypted. Enter your password to unlock.",
- "unlock": "Unlock",
- "unlocking": "Unlocking...",
- "lockedOutSeconds": "Muitas tentativas com falha. Bloqueado por {seconds}s."
+ "title": "Criptografia",
+ "enableEncryption": "Criptografar o banco de dados em repouso",
+ "hint": "Quando ativado, o banco de dados é criptografado ao fechar o app e descriptografado ao iniciar com sua senha.",
+ "password": "Senha",
+ "confirmPassword": "Confirmar senha",
+ "currentPassword": "Senha atual",
+ "enable": "Ativar criptografia",
+ "disable": "Desativar criptografia",
+ "passwordMismatch": "As senhas não coincidem.",
+ "passwordRequired": "A senha é obrigatória.",
+ "wrongPassword": "Senha incorreta. Tente novamente.",
+ "enableSuccess": "Criptografia ativada. O banco de dados será criptografado ao fechar o app.",
+ "disableSuccess": "Criptografia desativada.",
+ "unlockTitle": "Banco de dados bloqueado",
+ "unlockMessage": "O banco de dados de logs está criptografado. Digite sua senha para desbloquear.",
+ "unlock": "Desbloquear",
+ "unlocking": "Desbloqueando…",
+ "lockedOutSeconds": "Muitas tentativas com falha. Bloqueado por {seconds}s.",
+ "wrongPasswordAttempts": "Senha incorreta. Resta(m) {remaining} tentativa(s).",
+ "lockedOut": "Muitas tentativas com falha. O aplicativo será fechado.",
+ "warningTitle": "Importante — leia com atenção",
+ "warningMessage": "A senha de criptografia nunca é armazenada em disco. Ela existe apenas na memória enquanto o app está em execução.",
+ "warningPoint1": "Se você esquecer sua senha, seu banco de dados de logs ficará permanentemente inacessível.",
+ "warningPoint2": "Não há mecanismo de recuperação — os dados são criptografados com AES-256.",
+ "warningPoint3": "Certifique-se de lembrar sua senha ou guarde-a em um gerenciador de senhas.",
+ "warningCancel": "Cancelar",
+ "warningConfirm": "Entendi, ativar criptografia",
+ "progressReading": "Lendo o banco de dados criptografado…",
+ "progressDeriving": "Verificando a senha…",
+ "progressDecrypting": "Descriptografando…",
+ "progressWriting": "Gravando o banco de dados…",
+ "progressInitializing": "Abrindo o banco de dados…",
+ "progressIndexing": "Reconstruindo o índice de busca…",
+ "progressDone": "Concluído"
},
"update": {
"available": "SyslogStudio {version} está disponível",
@@ -238,5 +270,8 @@
"download": "Baixar",
"skip": "Ignorar esta versão",
"later": "Mais tarde"
+ },
+ "warnings": {
+ "caKeyUnencrypted": "Aviso: a chave privada da autoridade de certificação está armazenada sem criptografia no disco."
}
-}
+}
\ No newline at end of file
diff --git a/frontend/src/lib/i18n/zh.json b/frontend/src/lib/i18n/zh.json
index 0e1e252..f976a1f 100644
--- a/frontend/src/lib/i18n/zh.json
+++ b/frontend/src/lib/i18n/zh.json
@@ -1,4 +1,10 @@
{
+ "common": {
+ "close": "关闭",
+ "previousPage": "上一页",
+ "nextPage": "下一页",
+ "delete": "删除"
+ },
"nav": {
"logs": "日志",
"stats": "统计",
@@ -16,7 +22,9 @@
"rate": "消息速率",
"buffer": "环形缓冲区使用率",
"dbMessages": "已存储消息",
- "dbSize": "数据库大小"
+ "dbSize": "数据库大小",
+ "dbLoading": "正在加载数据库…",
+ "dbIndexing": "正在建立索引…"
},
"server": {
"start": "启动",
@@ -26,7 +34,9 @@
"allInterfaces": "所有接口",
"bindAddressHint": "绑定地址:监听的本地IP。留空=所有接口。",
"allowedSources": "允许的来源",
- "allowedSourcesHint": "允许的来源:IP或CIDR范围,逗号分隔。留空=允许所有。注意:UDP来源可能被伪造。"
+ "allowedSourcesHint": "允许的来源:IP或CIDR范围,逗号分隔。留空=允许所有。注意:UDP来源可能被伪造。",
+ "maxConnsPerIP": "每IP最大连接",
+ "maxConnsPerIPHint": "来自单个源IP的最大并发TCP/TLS连接数。对于将多个发送端聚合在单个地址后的syslog中继或NAT网关,请调高此值。"
},
"filter": {
"severity": "严重程度",
@@ -47,8 +57,11 @@
"textExportFailed": "文本导出失败",
"dateFrom": "起始",
"dateTo": "截止",
- "regex": "Regex",
- "regexTitle": "启用正则表达式搜索"
+ "modeText": "文本搜索(点击切换到 FTS)",
+ "modeFts": "FTS 高级:OR、AND、NOT、\"短语\"、前缀*(点击切换到正则)",
+ "modeRegex": "正则搜索(点击切换到文本)",
+ "ftsPlaceholder": "error OR fail OR timeout",
+ "regexPlaceholder": "(error|fail|timeout)"
},
"log": {
"severity": "严重程度",
@@ -88,7 +101,8 @@
"rawMessage": "原始消息",
"copy": "复制",
"copiedToClipboard": "已复制到剪贴板",
- "failedToCopy": "复制到剪贴板失败"
+ "failedToCopy": "复制到剪贴板失败",
+ "matchesFound": "个匹配项"
},
"dashboard": {
"totalMessages": "消息总数",
@@ -179,7 +193,9 @@
"checkNow": "检查更新",
"checkingUpdates": "正在检查…",
"upToDate": "您使用的是最新版本。",
- "updateCheckFailed": "检查更新失败:{error}"
+ "updateCheckFailed": "检查更新失败:{error}",
+ "autoUpdateCheck": "启动时检查更新",
+ "droppedWrites": "丢弃的日志(过载)"
},
"alerts": {
"rules": "告警规则",
@@ -211,24 +227,40 @@
"clearFailed": "清除历史记录失败"
},
"encryption": {
- "title": "Encryption",
- "enableEncryption": "Encrypt database at rest",
- "hint": "When enabled, the database is encrypted when the app closes and decrypted on startup with your password.",
- "password": "Password",
- "confirmPassword": "Confirm password",
- "currentPassword": "Current password",
- "enable": "Enable Encryption",
- "disable": "Disable Encryption",
- "passwordMismatch": "Passwords do not match.",
- "passwordRequired": "Password is required.",
- "wrongPassword": "Incorrect password. Please try again.",
- "enableSuccess": "Encryption enabled. The database will be encrypted when the app closes.",
- "disableSuccess": "Encryption disabled.",
- "unlockTitle": "Database Locked",
- "unlockMessage": "The log database is encrypted. Enter your password to unlock.",
- "unlock": "Unlock",
- "unlocking": "Unlocking...",
- "lockedOutSeconds": "失败次数过多。已锁定{seconds}秒。"
+ "title": "加密",
+ "enableEncryption": "静态加密数据库",
+ "hint": "启用后,应用关闭时数据库会被加密,启动时用您的密码解密。",
+ "password": "密码",
+ "confirmPassword": "确认密码",
+ "currentPassword": "当前密码",
+ "enable": "启用加密",
+ "disable": "禁用加密",
+ "passwordMismatch": "两次输入的密码不一致。",
+ "passwordRequired": "必须输入密码。",
+ "wrongPassword": "密码错误,请重试。",
+ "enableSuccess": "已启用加密。应用关闭时数据库将被加密。",
+ "disableSuccess": "已禁用加密。",
+ "unlockTitle": "数据库已锁定",
+ "unlockMessage": "日志数据库已加密。请输入密码以解锁。",
+ "unlock": "解锁",
+ "unlocking": "正在解锁…",
+ "lockedOutSeconds": "失败次数过多。已锁定 {seconds} 秒。",
+ "wrongPasswordAttempts": "密码错误。还剩 {remaining} 次尝试。",
+ "lockedOut": "失败次数过多,应用将关闭。",
+ "warningTitle": "重要 — 请仔细阅读",
+ "warningMessage": "加密密码绝不会保存到磁盘。它仅在应用运行期间存在于内存中。",
+ "warningPoint1": "如果忘记密码,您的日志数据库将永久无法访问。",
+ "warningPoint2": "没有任何恢复机制——数据使用 AES-256 加密。",
+ "warningPoint3": "请务必记住密码,或将其保存在密码管理器中。",
+ "warningCancel": "取消",
+ "warningConfirm": "我已了解,启用加密",
+ "progressReading": "正在读取加密的数据库…",
+ "progressDeriving": "正在验证密码…",
+ "progressDecrypting": "正在解密…",
+ "progressWriting": "正在写入数据库…",
+ "progressInitializing": "正在打开数据库…",
+ "progressIndexing": "正在重建搜索索引…",
+ "progressDone": "完成"
},
"update": {
"available": "SyslogStudio {version} 可用",
@@ -238,5 +270,8 @@
"download": "下载",
"skip": "跳过此版本",
"later": "稍后"
+ },
+ "warnings": {
+ "caKeyUnencrypted": "警告:证书颁发机构的私钥以未加密形式存储在磁盘上。"
}
-}
+}
\ No newline at end of file
diff --git a/frontend/src/lib/stores.ts b/frontend/src/lib/stores.ts
index 3c42fd4..065d700 100644
--- a/frontend/src/lib/stores.ts
+++ b/frontend/src/lib/stores.ts
@@ -62,6 +62,7 @@ export interface ServerConfig {
certOptions: CertOptions;
mutualTLS: boolean;
caFile: string;
+ maxConnsPerIP: number;
}
export interface ServerStatus {
@@ -131,6 +132,7 @@ export interface StorageStats {
messageCount: number;
databaseSizeMB: number;
oldestTimestamp: string;
+ droppedWrites: number;
}
export interface PagedResult {
@@ -154,7 +156,7 @@ export const serverStatus = writable({
udpPort: 514, tcpPort: 514, tlsPort: 6514, bindAddress: '', allowedSources: [],
maxBuffer: 10000, certFile: '', keyFile: '', useSelfSigned: false,
certOptions: { algorithm: 'ECDSA-P256', validityDays: 365, commonName: 'SyslogStudio', organization: 'SyslogStudio', dnsNames: ['localhost'], ipAddresses: ['127.0.0.1', '::1'] },
- mutualTLS: false, caFile: '',
+ mutualTLS: false, caFile: '', maxConnsPerIP: 128,
},
});
diff --git a/frontend/src/style.css b/frontend/src/style.css
index 0580c9a..03aaef7 100644
--- a/frontend/src/style.css
+++ b/frontend/src/style.css
@@ -7,7 +7,7 @@
--bg-input: #1a2535;
--text-primary: #e0e6ed;
--text-secondary: #8899aa;
- --text-muted: #5a6a7a;
+ --text-muted: #8b9db0;
--border-color: #2a3f58;
--accent: #4a9eff;
--accent-hover: #3a8eef;
@@ -25,9 +25,11 @@
--overlay-bg: rgba(0, 0, 0, 0.5);
--shadow-color: rgba(0, 0, 0, 0.3);
--success-text: #69db7c;
+ color-scheme: dark;
}
[data-theme="light"] {
+ color-scheme: light;
--bg-primary: #f5f7fa;
--bg-secondary: #ffffff;
--bg-tertiary: #edf0f5;
@@ -35,7 +37,7 @@
--bg-input: #ffffff;
--text-primary: #1a2332;
--text-secondary: #4a5568;
- --text-muted: #8899aa;
+ --text-muted: #5a6675;
--border-color: #d1d9e6;
--accent: #2b7de9;
--accent-hover: #1a6dd8;
diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts
index 234290d..8e3fc54 100644
--- a/frontend/wailsjs/go/main/App.d.ts
+++ b/frontend/wailsjs/go/main/App.d.ts
@@ -56,6 +56,8 @@ export function GetLocalIPs():Promise>;
export function GetMessages(arg1:models.FilterCriteria):Promise>;
+export function GetNetworkInterfaces():Promise>;
+
export function GetServerCertInfo():Promise;
export function GetServerStatus():Promise;
@@ -72,6 +74,8 @@ export function GetUnlockLockoutSeconds():Promise;
export function GetUpdateConfig():Promise;
+export function IsCAKeyUnencrypted():Promise;
+
export function IsEncryptionEnabled():Promise;
export function IsEncryptionLocked():Promise;
diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js
index 60fea49..af02052 100644
--- a/frontend/wailsjs/go/main/App.js
+++ b/frontend/wailsjs/go/main/App.js
@@ -110,6 +110,10 @@ export function GetMessages(arg1) {
return window['go']['main']['App']['GetMessages'](arg1);
}
+export function GetNetworkInterfaces() {
+ return window['go']['main']['App']['GetNetworkInterfaces']();
+}
+
export function GetServerCertInfo() {
return window['go']['main']['App']['GetServerCertInfo']();
}
@@ -142,6 +146,10 @@ export function GetUpdateConfig() {
return window['go']['main']['App']['GetUpdateConfig']();
}
+export function IsCAKeyUnencrypted() {
+ return window['go']['main']['App']['IsCAKeyUnencrypted']();
+}
+
export function IsEncryptionEnabled() {
return window['go']['main']['App']['IsEncryptionEnabled']();
}
diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts
index 8614e91..094d86e 100644
--- a/frontend/wailsjs/go/models.ts
+++ b/frontend/wailsjs/go/models.ts
@@ -171,6 +171,20 @@ export namespace models {
this.count = source["count"];
}
}
+ export class NetworkInterface {
+ name: string;
+ ip: string;
+
+ static createFrom(source: any = {}) {
+ return new NetworkInterface(source);
+ }
+
+ constructor(source: any = {}) {
+ if ('string' === typeof source) source = JSON.parse(source);
+ this.name = source["name"];
+ this.ip = source["ip"];
+ }
+ }
export class SyslogMessage {
id: string;
// Go type: time
@@ -327,7 +341,8 @@ export namespace models {
certOptions: CertOptions;
mutualTLS: boolean;
caFile: string;
-
+ maxConnsPerIP: number;
+
static createFrom(source: any = {}) {
return new ServerConfig(source);
}
@@ -349,6 +364,7 @@ export namespace models {
this.certOptions = this.convertValues(source["certOptions"], CertOptions);
this.mutualTLS = source["mutualTLS"];
this.caFile = source["caFile"];
+ this.maxConnsPerIP = source["maxConnsPerIP"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
@@ -490,16 +506,18 @@ export namespace models {
messageCount: number;
databaseSizeMB: number;
oldestTimestamp: string;
-
+ droppedWrites: number;
+
static createFrom(source: any = {}) {
return new StorageStats(source);
}
-
+
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.messageCount = source["messageCount"];
this.databaseSizeMB = source["databaseSizeMB"];
this.oldestTimestamp = source["oldestTimestamp"];
+ this.droppedWrites = source["droppedWrites"];
}
}
diff --git a/internal/alert/manager.go b/internal/alert/manager.go
index a6dad40..6440a91 100644
--- a/internal/alert/manager.go
+++ b/internal/alert/manager.go
@@ -2,6 +2,7 @@ package alert
import (
"log/slog"
+ "regexp"
"strings"
"sync"
"time"
@@ -16,6 +17,7 @@ import (
type AlertManager struct {
mu sync.RWMutex
rules []models.AlertRule
+ compiled map[string]*regexp.Regexp // ruleID -> compiled regex (regex rules only)
history []models.AlertEvent
lastFire map[string]time.Time // ruleID -> last fire time
emitter event.EventEmitter
@@ -24,16 +26,36 @@ type AlertManager struct {
// NewAlertManager creates an AlertManager.
func NewAlertManager(emitter event.EventEmitter) *AlertManager {
return &AlertManager{
+ compiled: make(map[string]*regexp.Regexp),
lastFire: make(map[string]time.Time),
emitter: emitter,
}
}
+// rebuildCompiled recompiles the regex cache from the current rules. Must be
+// called under am.mu (write). Compiling once here — rather than per message in
+// matchesRule — keeps the hot path off the regex compiler.
+func (am *AlertManager) rebuildCompiled() {
+ compiled := make(map[string]*regexp.Regexp, len(am.rules))
+ for _, r := range am.rules {
+ if r.UseRegex && r.Pattern != "" {
+ re, err := models.SafeCompileRegex(r.Pattern)
+ if err != nil {
+ slog.Debug("invalid alert regex", "rule", r.Name, "pattern", r.Pattern, "error", err)
+ continue
+ }
+ compiled[r.ID] = re
+ }
+ }
+ am.compiled = compiled
+}
+
// SetRules replaces the entire rule set.
func (am *AlertManager) SetRules(rules []models.AlertRule) {
am.mu.Lock()
defer am.mu.Unlock()
am.rules = rules
+ am.rebuildCompiled()
}
// GetRules returns the current rule set.
@@ -51,6 +73,7 @@ func (am *AlertManager) AddRule(rule models.AlertRule) models.AlertRule {
defer am.mu.Unlock()
rule.ID = uuid.New().String()
am.rules = append(am.rules, rule)
+ am.rebuildCompiled()
return rule
}
@@ -61,6 +84,7 @@ func (am *AlertManager) UpdateRule(rule models.AlertRule) bool {
for i, r := range am.rules {
if r.ID == rule.ID {
am.rules[i] = rule
+ am.rebuildCompiled()
return true
}
}
@@ -75,6 +99,7 @@ func (am *AlertManager) DeleteRule(id string) bool {
if r.ID == id {
am.rules = append(am.rules[:i], am.rules[i+1:]...)
delete(am.lastFire, id)
+ am.rebuildCompiled()
return true
}
}
@@ -170,9 +195,9 @@ func (am *AlertManager) matchesRule(msg models.SyslogMessage, rule models.AlertR
// Pattern match
if rule.Pattern != "" {
if rule.UseRegex {
- re, err := models.SafeCompileRegex(rule.Pattern)
- if err != nil {
- slog.Debug("invalid alert regex", "rule", rule.Name, "pattern", rule.Pattern, "error", err)
+ re := am.compiled[rule.ID]
+ if re == nil {
+ // Not in the cache (invalid pattern, logged at build time).
return false
}
if !re.MatchString(msg.Message) && !re.MatchString(msg.RawMessage) {
diff --git a/internal/models/models.go b/internal/models/models.go
index 77c69dd..8c75deb 100644
--- a/internal/models/models.go
+++ b/internal/models/models.go
@@ -139,6 +139,12 @@ type ServerConfig struct {
CertOptions CertOptions `json:"certOptions"`
MutualTLS bool `json:"mutualTLS"`
CAFile string `json:"caFile"`
+ // MaxConnsPerIP caps concurrent TCP/TLS connections from a single source
+ // IP so one host cannot occupy every global slot (Slowloris/log-blinding).
+ // A syslog relay or NAT gateway multiplexes many senders behind one IP, so
+ // this must be high enough for those deployments. 0 or negative means use
+ // the default. Applies only to connection-oriented listeners (TCP/TLS).
+ MaxConnsPerIP int `json:"maxConnsPerIP"`
}
// ServerStatus describes the current state of the server.
@@ -195,6 +201,9 @@ type StorageStats struct {
MessageCount int64 `json:"messageCount"`
DatabaseSizeMB float64 `json:"databaseSizeMB"`
OldestTimestamp string `json:"oldestTimestamp"`
+ // DroppedWrites counts messages discarded because the write buffer stayed
+ // full (persistence could not keep up). Non-zero means logs were lost.
+ DroppedWrites int64 `json:"droppedWrites"`
}
// PagedResult holds a paginated query result.
@@ -291,6 +300,13 @@ type UpdateConfig struct {
LastCheckUnix int64 `json:"lastCheckUnix"`
}
+// NetworkInterface describes a bindable local IPv4 address and the interface
+// it belongs to, for the bind-address selector in the UI.
+type NetworkInterface struct {
+ Name string `json:"name"`
+ IP string `json:"ip"`
+}
+
// --- Label converters ---
// SeverityToLabel converts a severity level to its string label.
@@ -394,17 +410,28 @@ func DefaultUpdateConfig() UpdateConfig {
// DefaultServerConfig returns sensible defaults.
func DefaultServerConfig() ServerConfig {
return ServerConfig{
- UDPEnabled: true,
- TCPEnabled: false,
- TLSEnabled: false,
- UDPPort: 514,
- TCPPort: 514,
+ UDPEnabled: true,
+ TCPEnabled: false,
+ TLSEnabled: false,
+ // Non-privileged defaults so the first run binds without root on
+ // Linux/macOS (ports < 1024 need elevation there). Existing saved
+ // configs keep their values; users who need the standard 514 can set it
+ // and run with the required privileges.
+ UDPPort: 1514,
+ TCPPort: 1514,
TLSPort: 6514,
MaxBuffer: 10000,
+ MaxConnsPerIP: DefaultMaxConnsPerIP,
UseSelfSigned: false,
}
}
+// DefaultMaxConnsPerIP is the default per-source-IP concurrent TCP/TLS
+// connection cap. Chosen high enough not to break a syslog relay/NAT gateway
+// that multiplexes many senders behind one IP, while still preventing a single
+// host from monopolizing the global connection pool.
+const DefaultMaxConnsPerIP = 128
+
// --- Validation ---
// ValidateServerConfig checks the configuration for errors before starting.
@@ -482,7 +509,13 @@ func ValidateServerConfig(c ServerConfig) error {
}
}
- if c.TLSEnabled && c.MutualTLS && c.CAFile != "" {
+ if c.TLSEnabled && c.MutualTLS {
+ // A CA is mandatory for mutual TLS: without it, client-certificate
+ // verification is silently not enforced and any anonymous client is
+ // accepted while the UI still reports mTLS as enabled.
+ if c.CAFile == "" {
+ return fmt.Errorf("mutual TLS requires a CA certificate file to verify client certificates")
+ }
if _, err := os.Stat(c.CAFile); err != nil {
return fmt.Errorf("CA certificate file not found: %s", c.CAFile)
}
diff --git a/internal/pki/manager.go b/internal/pki/manager.go
index c104a19..926a53e 100644
--- a/internal/pki/manager.go
+++ b/internal/pki/manager.go
@@ -77,8 +77,13 @@ func (t *TLSManager) GetTLSConfig(config models.ServerConfig) (*tls.Config, erro
}
}
- // Apply mutual TLS if enabled
- if config.MutualTLS && config.CAFile != "" {
+ // Apply mutual TLS if enabled. A missing/empty CA is a hard error, not a
+ // silent skip: otherwise ClientAuth stays NoClientCert and the listener
+ // accepts any anonymous client while the UI reports mTLS as enabled.
+ if config.MutualTLS {
+ if config.CAFile == "" {
+ return nil, fmt.Errorf("mutual TLS requires a CA certificate file")
+ }
caPool, caErr := t.LoadCACertificateFromFile(config.CAFile)
if caErr != nil {
return nil, fmt.Errorf("failed to load CA for mutual TLS: %w", caErr)
diff --git a/internal/storage/config.go b/internal/storage/config.go
index cd98392..e2997bf 100644
--- a/internal/storage/config.go
+++ b/internal/storage/config.go
@@ -5,6 +5,7 @@ import (
"log/slog"
"os"
"path/filepath"
+ "sync"
"SyslogStudio/internal/models"
)
@@ -12,8 +13,14 @@ import (
const configFileName = "config.json"
// ConfigStore handles persisting and loading user configuration.
+//
+// Every public Load*/Save* is a read-modify-write of config.json (loadAll then
+// saveAll). The mutex makes each of those atomic so concurrent savers on
+// different Wails goroutines cannot lose each other's changes, and saveAll
+// writes via a temp file + rename so a crash mid-write cannot corrupt the file.
type ConfigStore struct {
dir string
+ mu sync.Mutex
}
// NewConfigStore creates a ConfigStore using the user's config directory.
@@ -103,18 +110,64 @@ func (cs *ConfigStore) saveAll(cfg models.AppConfig) {
slog.Warn("failed to marshal config", "error", err)
return
}
- if err := os.WriteFile(cs.path(), data, 0600); err != nil {
+ // Write atomically: a partial write (crash/full disk) must not corrupt
+ // config.json, which would otherwise fall back to defaults on next load.
+ // The temp file is fsynced before the rename so a crash right after the
+ // rename cannot leave a present-but-zero-length config (which would silently
+ // reset persisted state, including the unlock-lockout backoff).
+ tmp := cs.path() + ".tmp"
+ if err := writeFileSync(tmp, data, 0600); err != nil {
slog.Warn("failed to write config", "error", err)
+ _ = os.Remove(tmp)
+ return
+ }
+ if err := os.Rename(tmp, cs.path()); err != nil {
+ slog.Warn("failed to replace config", "error", err)
+ _ = os.Remove(tmp)
+ return
+ }
+ syncDir(cs.dir)
+}
+
+// writeFileSync writes data to path and fsyncs it before returning, so a crash
+// after a subsequent rename cannot leave a present-but-unflushed (zero-length)
+// file.
+func writeFileSync(path string, data []byte, perm os.FileMode) error {
+ f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, perm)
+ if err != nil {
+ return err
+ }
+ if _, err := f.Write(data); err != nil {
+ f.Close()
+ return err
+ }
+ if err := f.Sync(); err != nil {
+ f.Close()
+ return err
+ }
+ return f.Close()
+}
+
+// syncDir best-effort fsyncs a directory so a rename within it is durable.
+// Directory fsync is unsupported on Windows; the error is ignored there.
+func syncDir(dir string) {
+ if d, err := os.Open(dir); err == nil {
+ _ = d.Sync()
+ _ = d.Close()
}
}
// Load reads the saved ServerConfig from disk.
func (cs *ConfigStore) Load() models.ServerConfig {
+ cs.mu.Lock()
+ defer cs.mu.Unlock()
return cs.loadAll().Server
}
// Save writes the ServerConfig to disk.
func (cs *ConfigStore) Save(cfg models.ServerConfig) {
+ cs.mu.Lock()
+ defer cs.mu.Unlock()
all := cs.loadAll()
all.Server = cfg
cs.saveAll(all)
@@ -122,11 +175,15 @@ func (cs *ConfigStore) Save(cfg models.ServerConfig) {
// LoadStorage reads the saved StorageConfig.
func (cs *ConfigStore) LoadStorage() models.StorageConfig {
+ cs.mu.Lock()
+ defer cs.mu.Unlock()
return cs.loadAll().Storage
}
// SaveStorage writes the StorageConfig.
func (cs *ConfigStore) SaveStorage(cfg models.StorageConfig) {
+ cs.mu.Lock()
+ defer cs.mu.Unlock()
all := cs.loadAll()
all.Storage = cfg
cs.saveAll(all)
@@ -134,11 +191,15 @@ func (cs *ConfigStore) SaveStorage(cfg models.StorageConfig) {
// LoadAlertRules reads the saved alert rules.
func (cs *ConfigStore) LoadAlertRules() []models.AlertRule {
+ cs.mu.Lock()
+ defer cs.mu.Unlock()
return cs.loadAll().Alerts
}
// SaveAlertRules writes the alert rules.
func (cs *ConfigStore) SaveAlertRules(rules []models.AlertRule) {
+ cs.mu.Lock()
+ defer cs.mu.Unlock()
all := cs.loadAll()
all.Alerts = rules
cs.saveAll(all)
@@ -146,11 +207,15 @@ func (cs *ConfigStore) SaveAlertRules(rules []models.AlertRule) {
// LoadLockout reads the persisted unlock-lockout state.
func (cs *ConfigStore) LoadLockout() models.LockoutState {
+ cs.mu.Lock()
+ defer cs.mu.Unlock()
return cs.loadAll().Lockout
}
// SaveLockout writes the unlock-lockout state.
func (cs *ConfigStore) SaveLockout(state models.LockoutState) {
+ cs.mu.Lock()
+ defer cs.mu.Unlock()
all := cs.loadAll()
all.Lockout = state
cs.saveAll(all)
@@ -158,11 +223,15 @@ func (cs *ConfigStore) SaveLockout(state models.LockoutState) {
// LoadUpdateConfig reads the persisted update-check preferences.
func (cs *ConfigStore) LoadUpdateConfig() models.UpdateConfig {
+ cs.mu.Lock()
+ defer cs.mu.Unlock()
return cs.loadAll().Updates
}
// SaveUpdateConfig writes the update-check preferences.
func (cs *ConfigStore) SaveUpdateConfig(cfg models.UpdateConfig) {
+ cs.mu.Lock()
+ defer cs.mu.Unlock()
all := cs.loadAll()
all.Updates = cfg
cs.saveAll(all)
diff --git a/internal/storage/config_race_test.go b/internal/storage/config_race_test.go
new file mode 100644
index 0000000..a7b7a0c
--- /dev/null
+++ b/internal/storage/config_race_test.go
@@ -0,0 +1,35 @@
+package storage
+
+import (
+ "sync"
+ "testing"
+
+ "SyslogStudio/internal/models"
+)
+
+// TestConfigStore_ConcurrentWrites hammers distinct Save* methods from many
+// goroutines. Without the mutex + atomic write, the read-modify-write on
+// config.json races and can leave a truncated/corrupted file that loadAll then
+// silently replaces with defaults. It must run clean under -race and keep the
+// persisted ServerConfig intact.
+func TestConfigStore_ConcurrentWrites(t *testing.T) {
+ cs := &ConfigStore{dir: t.TempDir()}
+ // Seed a distinctive server config; a defaults-reset corruption would put
+ // UDPPort back to 514.
+ cs.Save(models.ServerConfig{UDPPort: 5514, TCPPort: 5515, TLSPort: 6514, MaxBuffer: 10000})
+
+ var wg sync.WaitGroup
+ for i := 0; i < 40; i++ {
+ wg.Add(4)
+ go func() { defer wg.Done(); cs.SaveLockout(models.LockoutState{FailedAttempts: 3}) }()
+ go func() { defer wg.Done(); cs.SaveUpdateConfig(models.UpdateConfig{AutoCheck: true, IntervalHours: 24}) }()
+ go func() { defer wg.Done(); cs.SaveStorage(models.DefaultStorageConfig()) }()
+ go func() { defer wg.Done(); _ = cs.LoadAlertRules() }()
+ }
+ wg.Wait()
+
+ got := cs.Load()
+ if got.UDPPort != 5514 || got.TCPPort != 5515 {
+ t.Fatalf("server config lost/corrupted under concurrency: %+v", got)
+ }
+}
diff --git a/internal/storage/crypto.go b/internal/storage/crypto.go
index aac3539..0f5a21c 100644
--- a/internal/storage/crypto.go
+++ b/internal/storage/crypto.go
@@ -8,6 +8,7 @@ import (
"fmt"
"io"
"os"
+ "path/filepath"
"golang.org/x/crypto/argon2"
)
@@ -106,7 +107,7 @@ func EncryptFileWithProgress(srcPath, dstPath, password string, progress Progres
copy(header[1+saltLen:], nonce)
tmpPath := dstPath + ".tmp"
- f, err := os.Create(tmpPath)
+ f, err := os.OpenFile(tmpPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
return fmt.Errorf("create temp file: %w", err)
}
@@ -123,6 +124,15 @@ func EncryptFileWithProgress(srcPath, dstPath, password string, progress Progres
os.Remove(tmpPath)
return fmt.Errorf("write ciphertext: %w", err)
}
+ // fsync before rename so the encrypted data is durable on disk. The caller
+ // deletes the plaintext database once this returns, so a crash here must not
+ // leave a renamed-but-unflushed (zero/partial) .enc — that would lose every
+ // stored log irreversibly.
+ if err := f.Sync(); err != nil {
+ f.Close()
+ os.Remove(tmpPath)
+ return fmt.Errorf("sync temp file: %w", err)
+ }
if err := f.Close(); err != nil {
os.Remove(tmpPath)
return fmt.Errorf("close temp file: %w", err)
@@ -131,6 +141,7 @@ func EncryptFileWithProgress(srcPath, dstPath, password string, progress Progres
os.Remove(tmpPath)
return fmt.Errorf("rename to final: %w", err)
}
+ syncDir(filepath.Dir(dstPath))
emit(progress, "done", 100, sizeMB)
return nil
@@ -196,7 +207,7 @@ func DecryptFileWithProgress(srcPath, dstPath, password string, progress Progres
// Phase 4: Write (75-100%)
tmpPath := dstPath + ".tmp"
- f, err := os.Create(tmpPath)
+ f, err := os.OpenFile(tmpPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
return fmt.Errorf("create temp file: %w", err)
}
@@ -208,6 +219,11 @@ func DecryptFileWithProgress(srcPath, dstPath, password string, progress Progres
os.Remove(tmpPath)
return fmt.Errorf("write decrypted file: %w", err)
}
+ if err := f.Sync(); err != nil {
+ f.Close()
+ os.Remove(tmpPath)
+ return fmt.Errorf("sync temp file: %w", err)
+ }
if err := f.Close(); err != nil {
os.Remove(tmpPath)
return fmt.Errorf("close temp file: %w", err)
@@ -216,6 +232,7 @@ func DecryptFileWithProgress(srcPath, dstPath, password string, progress Progres
os.Remove(tmpPath)
return fmt.Errorf("rename decrypted file: %w", err)
}
+ syncDir(filepath.Dir(dstPath))
emit(progress, "done", 100, sizeMB)
return nil
diff --git a/internal/storage/logstore.go b/internal/storage/logstore.go
index 448a661..7bdb966 100644
--- a/internal/storage/logstore.go
+++ b/internal/storage/logstore.go
@@ -21,15 +21,20 @@ const (
logStoreFlushInterval = 500 * time.Millisecond
logStoreCleanupInterval = 5 * time.Minute
dbFileName = "logs.db"
+ // maxWriteBuffer caps the in-memory pending-write buffer so a message
+ // flood that outruns SQLite commits (slow disk, VACUUM, retention delete)
+ // bounds memory instead of growing without limit until OOM.
+ maxWriteBuffer = 200000
)
// LogStore handles SQLite-based message persistence.
type LogStore struct {
- mu sync.Mutex
- db *sql.DB
- config models.StorageConfig
- buffer []models.SyslogMessage
- emitter event.EventEmitter
+ mu sync.Mutex
+ db *sql.DB
+ config models.StorageConfig
+ buffer []models.SyslogMessage
+ droppedWrites int64
+ emitter event.EventEmitter
stopCh chan struct{}
wg sync.WaitGroup
@@ -188,6 +193,8 @@ func (ls *LogStore) IsReady() bool {
// IsLocked returns true if the database is encrypted and not yet unlocked.
func (ls *LogStore) IsLocked() bool {
+ ls.mu.Lock()
+ defer ls.mu.Unlock()
return ls.locked
}
@@ -289,12 +296,32 @@ func (ls *LogStore) rebuildFTS() {
// BufferMessage adds a message to the write buffer (non-blocking).
func (ls *LogStore) BufferMessage(msg models.SyslogMessage) {
+ ls.mu.Lock()
+ defer ls.mu.Unlock()
if ls.db == nil {
return
}
- ls.mu.Lock()
+ if len(ls.buffer) >= maxWriteBuffer {
+ // Persistence can't keep up: drop the oldest to bound memory. Drop a
+ // chunk at once (one memmove per chunk) rather than one element per
+ // call — the latter is an O(n) copy of the whole 200k buffer on every
+ // message, during the exact overload this guard exists for.
+ drop := maxWriteBuffer / 8
+ n := copy(ls.buffer, ls.buffer[drop:])
+ ls.buffer = ls.buffer[:n]
+ ls.droppedWrites += int64(drop)
+ slog.Warn("log write buffer full, dropping oldest messages",
+ "droppedThisChunk", drop, "droppedTotal", ls.droppedWrites)
+ }
ls.buffer = append(ls.buffer, msg)
- ls.mu.Unlock()
+}
+
+// droppedWriteCount returns how many buffered messages have been dropped due
+// to sustained buffer-full overload. Read under the same lock that mutates it.
+func (ls *LogStore) droppedWriteCount() int64 {
+ ls.mu.Lock()
+ defer ls.mu.Unlock()
+ return ls.droppedWrites
}
func (ls *LogStore) flushLoop() {
@@ -743,7 +770,8 @@ func (ls *LogStore) GetStats() models.StorageStats {
}
stats := models.StorageStats{
- MessageCount: ls.messageCount(),
+ MessageCount: ls.messageCount(),
+ DroppedWrites: ls.droppedWriteCount(),
}
// DB file size (main + WAL + SHM)
diff --git a/internal/syslog/framing.go b/internal/syslog/framing.go
index cab660e..8ca2850 100644
--- a/internal/syslog/framing.go
+++ b/internal/syslog/framing.go
@@ -7,9 +7,11 @@ import (
)
// maxOctetCount bounds the declared length of an octet-counted frame so a
-// malformed or hostile MSG-LEN cannot force an unbounded buffer. It matches
-// the scanner's buffer capacity.
-const maxOctetCount = tcpScanBufSize
+// malformed or hostile MSG-LEN cannot force an unbounded buffer. It sits
+// strictly below the scanner buffer capacity so that a maximum-length frame,
+// including its "MSG-LEN SP" prefix, still fits in one token (otherwise a
+// conformant ~64 KiB frame would trip bufio.ErrTooLong and drop the connection).
+const maxOctetCount = tcpScanBufSize - 8
// syslogFrameSplit is a bufio.SplitFunc implementing both syslog TCP
// framings from RFC 6587:
diff --git a/internal/syslog/server.go b/internal/syslog/server.go
index 18b3fc8..6cb8a8a 100644
--- a/internal/syslog/server.go
+++ b/internal/syslog/server.go
@@ -10,6 +10,7 @@ import (
"strconv"
"strings"
"sync"
+ "sync/atomic"
"time"
"SyslogStudio/internal/alert"
@@ -32,6 +33,12 @@ const (
// maxTCPConnections caps concurrent TCP+TLS connections to bound
// goroutine and memory usage under connection flooding.
maxTCPConnections = 512
+ // firstReadTimeout bounds the wait for the first frame (and, for TLS, the
+ // handshake). Idle/half-open connections are dropped instead of holding a
+ // slot for the full steady-state tcpReadTimeout. Kept generous so clients
+ // that open a persistent connection and only transmit on an event are not
+ // dropped; the per-IP and global caps provide the real Slowloris bound.
+ firstReadTimeout = 120 * time.Second
)
// listenAddr builds a listen address from an optional bind IP and a port.
@@ -46,7 +53,9 @@ type SyslogServer struct {
emitter event.EventEmitter
stats *StatsCollector
AlertManager *alert.AlertManager
- LogStore *storage.LogStore
+ // logStore is swapped atomically: startup and UnlockDatabase set it from
+ // their own goroutines while worker goroutines read it on every message.
+ logStore atomic.Pointer[storage.LogStore]
mu sync.RWMutex
messages []models.SyslogMessage // Ring buffer
@@ -61,6 +70,12 @@ type SyslogServer struct {
tcpListener net.Listener
tlsListener net.Listener
+ // startStopMu serializes the entire Start/Stop lifecycle. Stop releases
+ // s.mu before its slow conn cleanup and wg.Wait(), so without this a Start
+ // racing an in-flight Stop could allocate fresh conn maps that the Stop then
+ // nils out — leaving the new listener rejecting every connection.
+ startStopMu sync.Mutex
+
// Cancellation
cancel context.CancelFunc
running bool
@@ -77,8 +92,10 @@ type SyslogServer struct {
// Active TCP/TLS connections, tracked so Stop can close them
// immediately instead of waiting for idle read deadlines to elapse.
- conns map[net.Conn]struct{}
- connsMu sync.Mutex
+ conns map[net.Conn]struct{}
+ connPerIP map[string]int // source IP -> active connection count
+ maxConnsPerIP int // effective per-IP cap for the current run
+ connsMu sync.Mutex
tlsManager *pki.TLSManager
tlsConfig *tls.Config
@@ -101,12 +118,22 @@ func NewSyslogServer(emitter event.EventEmitter, tlsMgr *pki.TLSManager) *Syslog
}
}
+// SetLogStore atomically installs (or clears) the persistence store used by
+// worker goroutines. Safe to call while the server is running.
+func (s *SyslogServer) SetLogStore(ls *storage.LogStore) {
+ s.logStore.Store(ls)
+}
+
// Start begins listening on enabled protocols.
func (s *SyslogServer) Start(config models.ServerConfig) error {
if err := models.ValidateServerConfig(config); err != nil {
return err
}
+ // Serialize the whole lifecycle against Stop (see startStopMu doc).
+ s.startStopMu.Lock()
+ defer s.startStopMu.Unlock()
+
s.mu.Lock()
if s.running {
s.mu.Unlock()
@@ -138,6 +165,11 @@ func (s *SyslogServer) Start(config models.ServerConfig) error {
workCh := s.workCh
s.connSem = make(chan struct{}, maxTCPConnections)
s.conns = make(map[net.Conn]struct{})
+ s.connPerIP = make(map[string]int)
+ s.maxConnsPerIP = config.MaxConnsPerIP
+ if s.maxConnsPerIP <= 0 {
+ s.maxConnsPerIP = models.DefaultMaxConnsPerIP
+ }
// Compile the source allowlist. Config was validated above, so
// parse errors are not expected here; skip defensively regardless.
@@ -149,13 +181,24 @@ func (s *SyslogServer) Start(config models.ServerConfig) error {
}
s.mu.Unlock()
- // Start worker pool
+ // Start worker pool. Workers drain workCh but also watch ctx so they
+ // exit on Stop() without the channel ever being closed — closing workCh
+ // while a producer is mid-send in submitWork would panic ("send on
+ // closed channel"). Stop() cancels ctx to unblock them instead.
for i := 0; i < maxWorkers; i++ {
s.wg.Add(1)
go func() {
defer s.wg.Done()
- for fn := range workCh {
- fn()
+ for {
+ select {
+ case fn, ok := <-workCh:
+ if !ok {
+ return
+ }
+ fn()
+ case <-ctx.Done():
+ return
+ }
}
}()
}
@@ -258,6 +301,10 @@ func (s *SyslogServer) startTLSListener(ctx context.Context, config models.Serve
// Stop gracefully shuts down all listeners.
func (s *SyslogServer) Stop() error {
+ // Serialize the whole lifecycle against Start (see startStopMu doc).
+ s.startStopMu.Lock()
+ defer s.startStopMu.Unlock()
+
s.mu.Lock()
if !s.running {
s.mu.Unlock()
@@ -279,7 +326,6 @@ func (s *SyslogServer) Stop() error {
s.tlsListener.Close()
s.tlsListener = nil
}
- workCh := s.workCh
s.workCh = nil
s.connSem = nil
s.mu.Unlock()
@@ -292,12 +338,9 @@ func (s *SyslogServer) Stop() error {
c.Close()
}
s.conns = nil
+ s.connPerIP = nil
s.connsMu.Unlock()
- if workCh != nil {
- close(workCh)
- }
-
s.wg.Wait()
slog.Info("syslog server stopped")
s.emitStatus()
@@ -564,24 +607,47 @@ func (s *SyslogServer) acquireConnSlot() chan struct{} {
// is already stopping (conns map cleared), in which case the caller
// should close the connection and return.
func (s *SyslogServer) trackConn(conn net.Conn) bool {
+ ip := connIP(conn)
s.connsMu.Lock()
defer s.connsMu.Unlock()
if s.conns == nil {
- return false
+ return false // server is stopping
+ }
+ if s.connPerIP[ip] >= s.maxConnsPerIP {
+ // Distinct from the stopping case above so operators can diagnose why a
+ // busy source (e.g. a relay behind one IP) sees connection resets.
+ slog.Warn("per-IP connection limit reached, rejecting connection",
+ "ip", ip, "limit", s.maxConnsPerIP)
+ return false // per-source connection limit reached
}
s.conns[conn] = struct{}{}
+ s.connPerIP[ip]++
return true
}
// untrackConn removes a connection from the active set.
func (s *SyslogServer) untrackConn(conn net.Conn) {
+ ip := connIP(conn)
s.connsMu.Lock()
if s.conns != nil {
delete(s.conns, conn)
+ if n := s.connPerIP[ip]; n <= 1 {
+ delete(s.connPerIP, ip)
+ } else {
+ s.connPerIP[ip] = n - 1
+ }
}
s.connsMu.Unlock()
}
+// connIP returns the source IP of a connection for per-IP accounting.
+func connIP(conn net.Conn) string {
+ if tcpAddr, ok := conn.RemoteAddr().(*net.TCPAddr); ok {
+ return tcpAddr.IP.String()
+ }
+ return conn.RemoteAddr().String()
+}
+
func (s *SyslogServer) handleTCPConnection(ctx context.Context, conn net.Conn, protocol string, sem chan struct{}) {
defer s.wg.Done()
defer conn.Close()
@@ -603,6 +669,7 @@ func (s *SyslogServer) handleTCPConnection(ctx context.Context, conn net.Conn, p
scanner.Buffer(make([]byte, 0, tcpScanBufSize), tcpScanBufSize)
scanner.Split(syslogFrameSplit)
+ first := true
for {
select {
case <-ctx.Done():
@@ -610,10 +677,17 @@ func (s *SyslogServer) handleTCPConnection(ctx context.Context, conn net.Conn, p
default:
}
- conn.SetReadDeadline(time.Now().Add(tcpReadTimeout))
+ // Short deadline for the first frame (and the TLS handshake it drives),
+ // then the steady-state timeout once the peer has proven it is live.
+ timeout := tcpReadTimeout
+ if first {
+ timeout = firstReadTimeout
+ }
+ conn.SetReadDeadline(time.Now().Add(timeout))
if !scanner.Scan() {
return
}
+ first = false
line := scanner.Bytes()
if len(line) == 0 {
@@ -665,8 +739,8 @@ func (s *SyslogServer) addMessage(msg models.SyslogMessage) {
s.stats.RecordMessage(msg)
// Persist to SQLite
- if s.LogStore != nil {
- s.LogStore.BufferMessage(msg)
+ if ls := s.logStore.Load(); ls != nil {
+ ls.BufferMessage(msg)
}
// Check alert rules
diff --git a/internal/syslog/server_race_test.go b/internal/syslog/server_race_test.go
new file mode 100644
index 0000000..aa0e39f
--- /dev/null
+++ b/internal/syslog/server_race_test.go
@@ -0,0 +1,170 @@
+package syslog
+
+import (
+ "net"
+ "strconv"
+ "sync"
+ "testing"
+ "time"
+
+ "SyslogStudio/internal/event"
+ "SyslogStudio/internal/models"
+)
+
+// TestStop_UnderTraffic floods the UDP listener and calls Stop() while
+// datagrams are in flight. The previous code closed workCh in Stop while
+// producers were still sending into it -> "send on closed channel" panic.
+// With workers exiting via ctx (no close), this must not panic and must run
+// clean under -race.
+func TestStop_UnderTraffic(t *testing.T) {
+ s := NewSyslogServer(event.NewMockEventEmitter(), nil)
+ cfg := models.DefaultServerConfig()
+ cfg.BindAddress = "127.0.0.1"
+ cfg.UDPEnabled = true
+ cfg.UDPPort = freeUDPPort(t)
+ cfg.TCPEnabled = false
+ cfg.TLSEnabled = false
+ if err := s.Start(cfg); err != nil {
+ t.Fatalf("start: %v", err)
+ }
+
+ addr := net.JoinHostPort("127.0.0.1", strconv.Itoa(cfg.UDPPort))
+ stop := make(chan struct{})
+ go func() {
+ conn, err := net.Dial("udp", addr)
+ if err != nil {
+ return
+ }
+ defer conn.Close()
+ msg := []byte("<13>1 2003-10-11T22:14:15Z host app - - - flood")
+ for {
+ select {
+ case <-stop:
+ return
+ default:
+ _, _ = conn.Write(msg)
+ }
+ }
+ }()
+
+ time.Sleep(80 * time.Millisecond) // let datagrams flow through the workers
+
+ done := make(chan struct{})
+ go func() { s.Stop(); close(done) }()
+ select {
+ case <-done:
+ case <-time.After(10 * time.Second):
+ t.Fatal("Stop did not return under traffic")
+ }
+ close(stop)
+}
+
+func freeUDPPort(t *testing.T) int {
+ t.Helper()
+ c, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0})
+ if err != nil {
+ t.Fatalf("reserve udp port: %v", err)
+ }
+ defer c.Close()
+ return c.LocalAddr().(*net.UDPAddr).Port
+}
+
+func tcpBaseConfig(t *testing.T) models.ServerConfig {
+ cfg := models.DefaultServerConfig()
+ cfg.BindAddress = "127.0.0.1"
+ cfg.UDPEnabled = false
+ cfg.TLSEnabled = false
+ cfg.TCPEnabled = true
+ cfg.TCPPort = freeTCPPort(t)
+ return cfg
+}
+
+// perIPCount reads the live per-source-IP connection count (same package, so it
+// may touch unexported state under the same lock the server uses).
+func perIPCount(s *SyslogServer, ip string) int {
+ s.connsMu.Lock()
+ defer s.connsMu.Unlock()
+ return s.connPerIP[ip]
+}
+
+func waitFor(t *testing.T, what string, cond func() bool) {
+ t.Helper()
+ deadline := time.Now().Add(3 * time.Second)
+ for time.Now().Before(deadline) {
+ if cond() {
+ return
+ }
+ time.Sleep(10 * time.Millisecond)
+ }
+ t.Fatalf("condition not met in time: %s", what)
+}
+
+// TestStartStopOverlap_NoClobber hammers Start and Stop concurrently. Start
+// allocates the conn maps under s.mu while Stop nils them under connsMu; before
+// the lifecycle was serialized, an overlapping pair could let a stale Stop null
+// the new run's maps, leaving trackConn rejecting every connection. This must
+// run clean under -race and leave the server accepting connections afterwards.
+func TestStartStopOverlap_NoClobber(t *testing.T) {
+ s := NewSyslogServer(event.NewMockEventEmitter(), nil)
+ for i := 0; i < 20; i++ {
+ cfg := tcpBaseConfig(t)
+ var wg sync.WaitGroup
+ wg.Add(2)
+ go func() { defer wg.Done(); _ = s.Start(cfg) }()
+ go func() { defer wg.Done(); _ = s.Stop() }()
+ wg.Wait()
+ _ = s.Stop() // guarantee stopped between iterations
+ }
+
+ cfg := tcpBaseConfig(t)
+ if err := s.Start(cfg); err != nil {
+ t.Fatalf("final start: %v", err)
+ }
+ defer s.Stop()
+ addr := net.JoinHostPort("127.0.0.1", strconv.Itoa(cfg.TCPPort))
+ var conn net.Conn
+ var err error
+ for i := 0; i < 50; i++ { // listener may not be ready the instant Start returns
+ if conn, err = net.DialTimeout("tcp", addr, 200*time.Millisecond); err == nil {
+ break
+ }
+ time.Sleep(20 * time.Millisecond)
+ }
+ if err != nil {
+ t.Fatalf("server not accepting after Start/Stop churn: %v", err)
+ }
+ conn.Close()
+}
+
+// TestPerIPConnLimit_Balance opens connections up to the per-IP cap and verifies
+// the counter both counts up and drains back to zero on close, so a busy source
+// is never permanently locked out by a leaked count.
+func TestPerIPConnLimit_Balance(t *testing.T) {
+ s := NewSyslogServer(event.NewMockEventEmitter(), nil)
+ cfg := tcpBaseConfig(t)
+ cfg.MaxConnsPerIP = 4
+ if err := s.Start(cfg); err != nil {
+ t.Fatalf("start: %v", err)
+ }
+ defer s.Stop()
+ addr := net.JoinHostPort("127.0.0.1", strconv.Itoa(cfg.TCPPort))
+
+ var conns []net.Conn
+ for i := 0; i < cfg.MaxConnsPerIP; i++ {
+ c, err := net.DialTimeout("tcp", addr, time.Second)
+ if err != nil {
+ t.Fatalf("dial %d: %v", i, err)
+ }
+ conns = append(conns, c)
+ }
+ waitFor(t, "per-IP count reaches cap", func() bool {
+ return perIPCount(s, "127.0.0.1") == cfg.MaxConnsPerIP
+ })
+
+ for _, c := range conns {
+ c.Close()
+ }
+ waitFor(t, "per-IP count drains to zero", func() bool {
+ return perIPCount(s, "127.0.0.1") == 0
+ })
+}
diff --git a/internal/updater/apply.go b/internal/updater/apply.go
index 8817617..b500ac8 100644
--- a/internal/updater/apply.go
+++ b/internal/updater/apply.go
@@ -19,10 +19,28 @@ import (
const progressEvent = "update:progress"
+// cleanupLeftoverOld removes a stale ".old" binary left by a previous Windows
+// self-replace: minio/selfupdate renames the running exe aside and cannot
+// delete it until the process exits, so it lingers until the next launch.
+func cleanupLeftoverOld() {
+ exe, err := os.Executable()
+ if err != nil {
+ return
+ }
+ dir, base := filepath.Split(exe)
+ _ = os.Remove(exe + ".old")
+ _ = os.Remove(filepath.Join(dir, "."+base+".old"))
+}
+
// DownloadAndApply downloads the pending update, verifies its checksum (and,
// when enforced, the manifest signature), and applies it according to the
// resolved mode. Requires a prior successful CheckForUpdate.
func (s *Service) DownloadAndApply() error {
+ if !s.applyMu.TryLock() {
+ return fmt.Errorf("an update is already being downloaded and applied")
+ }
+ defer s.applyMu.Unlock()
+
s.mu.Lock()
p := s.pending
ctx := s.ctx
@@ -39,15 +57,21 @@ func (s *Service) DownloadAndApply() error {
return nil
}
+ // Downgrade guard: never self-apply a version that is not strictly newer.
+ if !isNewer(AppVersion, p.version) {
+ return fmt.Errorf("refusing update: %s is not newer than the running %s",
+ displayVersion(p.version), displayVersion(AppVersion))
+ }
+
if p.checksumURL == "" {
return fmt.Errorf("release is missing the checksums manifest; cannot verify the update")
}
- wantSum, err := s.verifiedChecksum(ctx, p.checksumURL, p.checksumSigURL, p.assetName)
+ wantSum, err := s.verifiedChecksum(ctx, p.checksumURL, p.checksumSigURL, p.assetName, p.version)
if err != nil {
return err
}
- tmpPath, gotSum, err := s.download(ctx, p.assetURL, p.assetName)
+ tmpPath, gotSum, err := s.download(ctx, p.assetURL, p.assetName, p.size)
if err != nil {
return err
}
@@ -65,36 +89,48 @@ func (s *Service) DownloadAndApply() error {
}
}
-// verifiedChecksum fetches the checksums manifest, verifies its signature when
-// enforced, and returns the expected SHA-256 for asset.
-func (s *Service) verifiedChecksum(ctx context.Context, checksumURL, sigURL, asset string) (string, error) {
+// verifiedChecksum fetches the checksums manifest, verifies its mandatory
+// Ed25519 signature and that it is bound to expectedVersion, and returns the
+// expected SHA-256 for asset.
+func (s *Service) verifiedChecksum(ctx context.Context, checksumURL, sigURL, asset, expectedVersion string) (string, error) {
manifest, err := s.fetchBytes(ctx, checksumURL)
if err != nil {
return "", fmt.Errorf("fetch checksums: %w", err)
}
- if signatureEnforced() {
- if sigURL == "" {
- return "", fmt.Errorf("release checksums are not signed but a signature is required")
- }
- sig, err := s.fetchBytes(ctx, sigURL)
- if err != nil {
- return "", fmt.Errorf("fetch checksums signature: %w", err)
- }
- if err := verifyManifestSignature(manifest, sig); err != nil {
- return "", err
- }
+ if sigURL == "" {
+ return "", fmt.Errorf("release checksums are not signed")
+ }
+ sig, err := s.fetchBytes(ctx, sigURL)
+ if err != nil {
+ return "", fmt.Errorf("fetch checksums signature: %w", err)
+ }
+ if err := verifyManifestSignature(manifest, sig); err != nil {
+ return "", err
+ }
+ // Bind the signed manifest to the release we intend to install, defeating a
+ // replay of an old but validly-signed manifest served by a compromised CDN.
+ mv := parseManifestVersion(manifest)
+ if mv == "" {
+ return "", fmt.Errorf("checksums manifest is missing a version line")
+ }
+ if normalizeVersion(mv) != normalizeVersion(expectedVersion) {
+ return "", fmt.Errorf("checksums manifest version %q does not match the expected release %q", mv, expectedVersion)
}
return parseChecksum(manifest, asset)
}
// download streams the asset to a temp file, returning its path and the
// hex SHA-256 computed on the fly. It emits progress events while downloading.
-func (s *Service) download(ctx context.Context, url, asset string) (path, sum string, err error) {
+func (s *Service) download(ctx context.Context, url, asset string, size int64) (path, sum string, err error) {
+ // Generous deadline: large installers on slow links must succeed, but a
+ // stalled connection must not hang forever (the client has no global timeout).
+ ctx, cancel := context.WithTimeout(ctx, 20*time.Minute)
+ defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return "", "", err
}
- resp, err := s.client.Do(req)
+ resp, err := s.dlClient.Do(req)
if err != nil {
return "", "", err
}
@@ -109,8 +145,15 @@ func (s *Service) download(ctx context.Context, url, asset string) (path, sum st
}
defer f.Close()
+ // Bound the read to the authenticated asset size (plus small slack) so a
+ // compromised CDN cannot stream unbounded data into the temp dir; if it
+ // serves more, the truncated bytes fail the checksum and the update aborts.
+ var body io.Reader = resp.Body
+ if size > 0 {
+ body = io.LimitReader(resp.Body, size+1024)
+ }
h := sha256.New()
- pr := &progressReader{ctx: ctx, reader: resp.Body, total: resp.ContentLength}
+ pr := &progressReader{ctx: ctx, reader: body, total: resp.ContentLength}
if _, err := io.Copy(io.MultiWriter(f, h), pr); err != nil {
os.Remove(f.Name())
return "", "", err
@@ -122,6 +165,8 @@ func (s *Service) download(ctx context.Context, url, asset string) (path, sum st
// fetchBytes downloads a small file (checksums/signature) fully into memory,
// capped at 1 MiB.
func (s *Service) fetchBytes(ctx context.Context, url string) ([]byte, error) {
+ ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
+ defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
@@ -138,19 +183,42 @@ func (s *Service) fetchBytes(ctx context.Context, url string) ([]byte, error) {
}
// parseChecksum finds asset's SHA-256 in a `sha256sum`-format manifest
-// ("␠␠" per line), tolerating the "*" binary-mode marker.
+// ("␠␠" per line), tolerating the "*" binary-mode marker. It matches
+// the manifest name exactly (the manifest uses bare asset names) and rejects
+// duplicate entries so a malicious manifest can't smuggle a second line.
func parseChecksum(manifest []byte, asset string) (string, error) {
+ var sum string
+ matches := 0
for _, line := range strings.Split(string(manifest), "\n") {
fields := strings.Fields(strings.TrimSpace(line))
- if len(fields) != 2 {
+ if len(fields) != 2 || fields[0] == "version" {
continue
}
name := strings.TrimPrefix(fields[1], "*")
- if filepath.Base(name) == asset {
- return fields[0], nil
+ if name == asset {
+ sum = fields[0]
+ matches++
}
}
- return "", fmt.Errorf("no checksum for %q in manifest", asset)
+ if matches == 0 {
+ return "", fmt.Errorf("no checksum for %q in manifest", asset)
+ }
+ if matches > 1 {
+ return "", fmt.Errorf("duplicate checksum entries for %q in manifest", asset)
+ }
+ return sum, nil
+}
+
+// parseManifestVersion extracts the "version " line the release workflow
+// prepends to the signed checksums manifest, or "" if absent.
+func parseManifestVersion(manifest []byte) string {
+ for _, line := range strings.Split(string(manifest), "\n") {
+ fields := strings.Fields(strings.TrimSpace(line))
+ if len(fields) == 2 && fields[0] == "version" {
+ return fields[1]
+ }
+ }
+ return ""
}
// applyBinary self-replaces the running executable with the downloaded binary
@@ -182,6 +250,11 @@ func (s *Service) runInstaller(ctx context.Context, path string) error {
}
}
if err := exec.Command(target).Start(); err != nil {
+ // The caller's deferred cleanup keys off the pre-rename path, so remove
+ // the renamed installer here to avoid leaking it into the temp dir.
+ if target != path {
+ os.Remove(target)
+ }
return fmt.Errorf("launch installer: %w", err)
}
wruntime.Quit(ctx)
diff --git a/internal/updater/apply_linux.go b/internal/updater/apply_linux.go
index 3a013a3..c51a9a4 100644
--- a/internal/updater/apply_linux.go
+++ b/internal/updater/apply_linux.go
@@ -2,7 +2,10 @@
package updater
-import "os"
+import (
+ "os"
+ "path/filepath"
+)
const (
linuxDebAsset = "SyslogStudio-linux-amd64.deb"
@@ -12,11 +15,32 @@ const (
debInstallPath = "/usr/bin/syslogstudio"
)
-// target on Linux: a copy installed from the .deb hands the new .deb to the
-// browser (dpkg needs root); a portable binary self-replaces.
+// target on Linux: a managed install (from the .deb, or any location the
+// running user cannot replace in place — e.g. a root-owned /usr/bin) hands the
+// new .deb to the browser; a portable binary the user owns self-replaces.
func target() (string, applyMode) {
- if exe, err := os.Executable(); err == nil && exe == debInstallPath {
+ exe, err := os.Executable()
+ if err != nil {
+ return linuxBinaryAsset, applyReplace
+ }
+ if real, rerr := filepath.EvalSymlinks(exe); rerr == nil {
+ exe = real
+ }
+ if exe == debInstallPath || !dirWritable(filepath.Dir(exe)) {
return linuxDebAsset, applyBrowser
}
return linuxBinaryAsset, applyReplace
}
+
+// dirWritable reports whether the running user can create files in dir (and
+// therefore replace the binary there via write + rename).
+func dirWritable(dir string) bool {
+ f, err := os.CreateTemp(dir, ".syslogstudio-upd-*")
+ if err != nil {
+ return false
+ }
+ name := f.Name()
+ f.Close()
+ os.Remove(name)
+ return true
+}
diff --git a/internal/updater/apply_windows.go b/internal/updater/apply_windows.go
index 54077d9..d59a5e2 100644
--- a/internal/updater/apply_windows.go
+++ b/internal/updater/apply_windows.go
@@ -15,9 +15,8 @@ const (
windowsBinaryAsset = "SyslogStudio-windows-amd64.exe"
)
-// target selects the Windows asset and apply mode. An installed copy (via the
-// NSIS installer) updates by running the new setup; a portable copy
-// self-replaces the exe.
+// target selects the Windows asset and apply mode. The NSIS-installed copy
+// updates by running the new setup; a portable copy self-replaces the exe.
func target() (string, applyMode) {
if isInstalled() {
return windowsSetupAsset, applyInstaller
@@ -25,53 +24,85 @@ func target() (string, applyMode) {
return windowsBinaryAsset, applyReplace
}
-// isInstalled reports whether SyslogStudio was installed by the NSIS installer.
-// The installer's uninstall key name is "SyslogStudio", which is
-// awkward to reconstruct, so we enumerate the Uninstall keys and match on
-// DisplayName instead. Falls back to a path heuristic.
+// isInstalled reports whether the RUNNING executable is the NSIS-installed copy,
+// not merely that an install exists somewhere. A portable exe on a machine that
+// also has an install must self-replace itself, not launch the installer
+// against the other copy.
func isInstalled() bool {
- const uninstall = `Software\Microsoft\Windows\CurrentVersion\Uninstall`
- for _, root := range []registry.Key{registry.CURRENT_USER, registry.LOCAL_MACHINE} {
- if hasUninstallEntry(root, uninstall) {
- return true
- }
- }
- // Fallback: the exe lives under a typical install location.
exe, err := os.Executable()
if err != nil {
return false
}
+ if real, rerr := filepath.EvalSymlinks(exe); rerr == nil {
+ exe = real
+ }
dir := strings.ToLower(filepath.Dir(exe))
- if pf := os.Getenv("ProgramFiles"); pf != "" && strings.HasPrefix(dir, strings.ToLower(pf)) {
+
+ if loc, ok := installLocation(); ok && loc != "" {
+ // An install is registered: this exe is "installed" only if it lives
+ // under that location.
+ return pathUnder(dir, loc)
+ }
+
+ // No registry entry: fall back to a path heuristic.
+ if pf := os.Getenv("ProgramFiles"); pf != "" && pathUnder(dir, pf) {
return true
}
if la := os.Getenv("LOCALAPPDATA"); la != "" &&
- strings.HasPrefix(dir, strings.ToLower(filepath.Join(la, "SyslogStudio"))) {
+ pathUnder(dir, filepath.Join(la, "SyslogStudio")) {
return true
}
return false
}
-func hasUninstallEntry(root registry.Key, path string) bool {
+// pathUnder reports whether dir is base or a subdirectory of it, comparing on
+// cleaned, lowercased paths with a separator boundary. A raw prefix match would
+// misclassify siblings — e.g. treat "...\SyslogStudio Portable" as under
+// "...\SyslogStudio", or fail to match when InstallLocation carries a trailing
+// separator that filepath.Dir(exe) does not.
+func pathUnder(dir, base string) bool {
+ if base == "" {
+ return false
+ }
+ dir = strings.ToLower(filepath.Clean(dir))
+ base = strings.ToLower(filepath.Clean(base))
+ return dir == base || strings.HasPrefix(dir, base+string(os.PathSeparator))
+}
+
+// installLocation returns the install directory recorded by the NSIS installer,
+// matched by DisplayName across HKCU (per-user) then HKLM (all-users).
+func installLocation() (string, bool) {
+ const uninstall = `Software\Microsoft\Windows\CurrentVersion\Uninstall`
+ for _, root := range []registry.Key{registry.CURRENT_USER, registry.LOCAL_MACHINE} {
+ if loc, ok := findInstallLocation(root, uninstall); ok {
+ return loc, true
+ }
+ }
+ return "", false
+}
+
+func findInstallLocation(root registry.Key, path string) (string, bool) {
k, err := registry.OpenKey(root, path, registry.ENUMERATE_SUB_KEYS)
if err != nil {
- return false
+ return "", false
}
defer k.Close()
names, err := k.ReadSubKeyNames(-1)
if err != nil {
- return false
+ return "", false
}
for _, name := range names {
sub, err := registry.OpenKey(root, path+`\`+name, registry.QUERY_VALUE)
if err != nil {
continue
}
- dn, _, err := sub.GetStringValue("DisplayName")
- sub.Close()
- if err == nil && dn == "SyslogStudio" {
- return true
+ dn, _, dnErr := sub.GetStringValue("DisplayName")
+ if dnErr == nil && dn == "SyslogStudio" {
+ loc, _, _ := sub.GetStringValue("InstallLocation")
+ sub.Close()
+ return loc, true
}
+ sub.Close()
}
- return false
+ return "", false
}
diff --git a/internal/updater/github.go b/internal/updater/github.go
index 826fa30..13e87d5 100644
--- a/internal/updater/github.go
+++ b/internal/updater/github.go
@@ -44,6 +44,16 @@ func (r *ghRelease) assetURL(name string) string {
return ""
}
+// assetSize returns the byte size of the named asset (0 if absent).
+func (r *ghRelease) assetSize(name string) int64 {
+ for _, a := range r.Assets {
+ if a.Name == name {
+ return a.Size
+ }
+ }
+ return 0
+}
+
// latestRelease fetches the repository's latest (non-draft) release.
func (s *Service) latestRelease(ctx context.Context) (*ghRelease, error) {
url := fmt.Sprintf("%s/repos/%s/%s/releases/latest", apiBase, s.owner, s.repo)
diff --git a/internal/updater/manifest_test.go b/internal/updater/manifest_test.go
new file mode 100644
index 0000000..32d53a0
--- /dev/null
+++ b/internal/updater/manifest_test.go
@@ -0,0 +1,33 @@
+package updater
+
+import "testing"
+
+func TestParseManifestVersion(t *testing.T) {
+ manifest := []byte("version v1.2.3\nabc SyslogStudio-linux-amd64\n")
+ if v := parseManifestVersion(manifest); v != "v1.2.3" {
+ t.Errorf("got %q, want v1.2.3", v)
+ }
+ if v := parseManifestVersion([]byte("abc file\n")); v != "" {
+ t.Errorf("expected empty version when the line is absent, got %q", v)
+ }
+}
+
+func TestParseChecksum_VersionLineAndDuplicates(t *testing.T) {
+ manifest := []byte(
+ "version v1.2.3\n" +
+ "h1 SyslogStudio-linux-amd64\n" +
+ "h2 SyslogStudio-windows-amd64.exe\n")
+
+ if sum, err := parseChecksum(manifest, "SyslogStudio-linux-amd64"); err != nil || sum != "h1" {
+ t.Errorf("got (%q, %v), want (h1, nil)", sum, err)
+ }
+ // The "version" line must not be mistaken for an asset checksum.
+ if _, err := parseChecksum(manifest, "v1.2.3"); err == nil {
+ t.Error("version line should not match as an asset")
+ }
+ // Duplicate entries for the same asset are rejected.
+ dup := []byte("h1 SyslogStudio-linux-amd64\nh2 SyslogStudio-linux-amd64\n")
+ if _, err := parseChecksum(dup, "SyslogStudio-linux-amd64"); err == nil {
+ t.Error("duplicate entries should be rejected")
+ }
+}
diff --git a/internal/updater/updater.go b/internal/updater/updater.go
index 2a8b3d2..acfbc5c 100644
--- a/internal/updater/updater.go
+++ b/internal/updater/updater.go
@@ -8,7 +8,9 @@ package updater
import (
"context"
+ "fmt"
"net/http"
+ "strings"
"sync"
"time"
@@ -32,6 +34,7 @@ type pending struct {
version string
assetURL string
assetName string
+ size int64 // authenticated asset size from the GitHub API (0 if unknown)
checksumURL string
checksumSigURL string
mode applyMode
@@ -39,30 +42,70 @@ type pending struct {
// Service checks GitHub Releases and applies updates for one repository.
type Service struct {
- owner string
- repo string
+ owner string
+ repo string
+ // client is host-pinned to GitHub for the API/manifest/signature requests.
client *http.Client
+ // dlClient downloads the release asset. Its redirects are only required to
+ // stay on HTTPS, not on a GitHub host: GitHub has changed its asset CDN
+ // before, and integrity is already guaranteed by the signed-manifest
+ // checksum, so pinning the host here would only add an availability
+ // failure mode without adding security.
+ dlClient *http.Client
mu sync.Mutex
ctx context.Context
pending *pending
+ applyMu sync.Mutex // serializes DownloadAndApply; rejects concurrent applies
}
// NewService creates an updater for the given GitHub owner/repo.
func NewService(owner, repo string) *Service {
return &Service{
- owner: owner,
- repo: repo,
- client: &http.Client{Timeout: 30 * time.Second},
+ owner: owner,
+ repo: repo,
+ client: newHTTPClient(true),
+ dlClient: newHTTPClient(false),
}
}
+// newHTTPClient builds an HTTP client for update traffic. It has no global
+// timeout — the asset download can be large and slow, so each request is bounded
+// by its own context deadline instead. Redirects are always constrained to
+// HTTPS so a hostile redirect cannot downgrade to plaintext. When pinHost is
+// true the redirect target must also be a GitHub host (for the API, manifest,
+// and signature); the asset download uses pinHost=false (see Service.dlClient).
+func newHTTPClient(pinHost bool) *http.Client {
+ return &http.Client{
+ CheckRedirect: func(req *http.Request, via []*http.Request) error {
+ if len(via) >= 10 {
+ return fmt.Errorf("stopped after 10 redirects")
+ }
+ if req.URL.Scheme != "https" {
+ return fmt.Errorf("refusing non-https redirect to %s", req.URL.Hostname())
+ }
+ if pinHost && !isGitHubHost(req.URL.Hostname()) {
+ return fmt.Errorf("refusing redirect to %s://%s", req.URL.Scheme, req.URL.Hostname())
+ }
+ return nil
+ },
+ }
+}
+
+func isGitHubHost(host string) bool {
+ host = strings.ToLower(host)
+ return host == "github.com" ||
+ strings.HasSuffix(host, ".github.com") ||
+ strings.HasSuffix(host, ".githubusercontent.com")
+}
+
// SetContext gives the updater the Wails runtime context, used for progress
// events, opening the browser, and quitting. Called from App.startup.
func (s *Service) SetContext(ctx context.Context) {
s.mu.Lock()
s.ctx = ctx
s.mu.Unlock()
+ cleanupLeftoverOld()
}
func (s *Service) context() context.Context {
@@ -82,6 +125,11 @@ func (s *Service) CheckForUpdate() (models.UpdateInfo, error) {
if isDevVersion() {
return info, nil
}
+ // Authenticity is mandatory: without an embedded signing key, updates are
+ // disabled entirely rather than silently downgraded to SHA-256-only.
+ if !signatureEnforced() {
+ return info, fmt.Errorf("auto-update is disabled: no updater signing key is configured")
+ }
ctx, cancel := context.WithTimeout(s.context(), 15*time.Second)
defer cancel()
@@ -110,9 +158,19 @@ func (s *Service) CheckForUpdate() (models.UpdateInfo, error) {
assetName, mode := target()
assetURL := rel.assetURL(assetName)
if assetURL == "" {
- // No self-update asset for this platform in this release: fall back
- // to opening the release page in the browser.
+ // No self-update asset for this platform in this release.
+ mode = applyBrowser
+ }
+ // Self-apply needs the signed checksums manifest AND its signature. If the
+ // release lacks either (e.g. a hand-made release), don't advertise a
+ // self-apply that would only fail at download time — offer the manual path.
+ if mode != applyBrowser &&
+ (rel.assetURL(checksumsAsset) == "" || rel.assetURL(checksumsAsset+".sig") == "") {
mode = applyBrowser
+ }
+ if mode == applyBrowser {
+ // Open the release page (where the signed checksums are visible)
+ // rather than a direct, unverified asset link.
assetURL = rel.HTMLURL
}
info.AssetName = assetName
@@ -124,6 +182,7 @@ func (s *Service) CheckForUpdate() (models.UpdateInfo, error) {
version: rel.TagName,
assetURL: assetURL,
assetName: assetName,
+ size: rel.assetSize(assetName),
checksumURL: rel.assetURL(checksumsAsset),
checksumSigURL: rel.assetURL(checksumsAsset + ".sig"),
mode: mode,
diff --git a/main.go b/main.go
index e2061bc..729c3b9 100644
--- a/main.go
+++ b/main.go
@@ -22,6 +22,14 @@ func main() {
log.Fatal("Failed to create sub FS:", err)
}
+ // Store WebView2 data (localStorage: theme, locale) under a stable per-user
+ // directory rather than the temp dir, which OS temp cleaners (Disk Cleanup,
+ // Storage Sense) periodically wipe — silently resetting the user's prefs.
+ webviewDataPath := filepath.Join(os.TempDir(), "SyslogStudio")
+ if cacheDir, cerr := os.UserCacheDir(); cerr == nil {
+ webviewDataPath = filepath.Join(cacheDir, "SyslogStudio", "WebView2")
+ }
+
app := NewApp()
err = wails.Run(&options.App{
@@ -35,7 +43,7 @@ func main() {
},
BackgroundColour: &options.RGBA{R: 27, G: 38, B: 54, A: 1},
Windows: &windows.Options{
- WebviewUserDataPath: filepath.Join(os.TempDir(), "SyslogStudio"),
+ WebviewUserDataPath: webviewDataPath,
},
OnStartup: app.startup,
OnShutdown: app.shutdown,
diff --git a/wails.json b/wails.json
index 568416a..f56daa9 100644
--- a/wails.json
+++ b/wails.json
@@ -9,5 +9,11 @@
"author": {
"name": "Geoffrey Lecoq",
"email": ""
+ },
+ "info": {
+ "companyName": "Geoffrey Lecoq",
+ "productName": "SyslogStudio",
+ "copyright": "Copyright © 2026 Geoffrey Lecoq",
+ "comments": "Multi-protocol syslog server and log viewer"
}
}