Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -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
15 changes: 10 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
32 changes: 22 additions & 10 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
61 changes: 53 additions & 8 deletions app.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"fmt"
"log/slog"
"net"
neturl "net/url"
"os"
"strings"
"time"
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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()
}
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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()
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions build/windows/installer/project.nsi
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,7 @@ Section "Uninstall"
RMDir "$INSTDIR"

; ---- Remove WebView2 data ----
RMDir /r "$LOCALAPPDATA\SyslogStudio\WebView2"
RMDir /r "$TEMP\SyslogStudio"

; ---- Remove shortcuts ----
Expand Down
16 changes: 15 additions & 1 deletion frontend/src/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/AlertConfig.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@
</div>
<div class="rule-actions">
<button class="edit-btn" on:click={() => startEdit(rule)}>{$_('alerts.edit')}</button>
<button class="delete-btn" on:click={() => removeRule(rule.id)}>&times;</button>
<button class="delete-btn" on:click={() => removeRule(rule.id)} aria-label={$_('common.delete')}>&times;</button>
</div>
</div>
{:else}
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/components/FilterBar.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
6 changes: 3 additions & 3 deletions frontend/src/components/LogDetail.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
<div class="detail-panel">
<div class="detail-header">
<span class="detail-title">{$_('log.messageDetail')}</span>
<button class="close-btn" on:click={close}>&times;</button>
<button class="close-btn" on:click={close} aria-label={$_('common.close')}>&times;</button>
</div>

<div class="detail-body">
Expand Down Expand Up @@ -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 {
Expand Down
6 changes: 3 additions & 3 deletions frontend/src/components/LogViewer.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -502,9 +502,9 @@
{/if}
{#if $logViewMode === 'history' && !isGrouped && historyTotalPages > 1}
<div class="pagination">
<button class="page-btn" disabled={historyPage <= 1} on:click={() => loadHistoryPage(historyPage - 1)}>&laquo;</button>
<button class="page-btn" disabled={historyPage <= 1} on:click={() => loadHistoryPage(historyPage - 1)} aria-label={$_('common.previousPage')}>&laquo;</button>
<span class="page-info">{historyPage} / {historyTotalPages}</span>
<button class="page-btn" disabled={historyPage >= historyTotalPages} on:click={() => loadHistoryPage(historyPage + 1)}>&raquo;</button>
<button class="page-btn" disabled={historyPage >= historyTotalPages} on:click={() => loadHistoryPage(historyPage + 1)} aria-label={$_('common.nextPage')}>&raquo;</button>
</div>
{/if}
<span class="footer-sep">|</span>
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading