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 @@
{$_('log.messageDetail')} - +
@@ -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}