Compare commits
23 Commits
4c4b7dbb1e
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 037d2f114b | |||
| 29fc12385e | |||
| 9b19c1e6b4 | |||
| 71cb3beeee | |||
| a41971a11e | |||
| 2f2037f4da | |||
| 0d61e623a7 | |||
| 4fd6bfa8da | |||
| f2e76bf07b | |||
| 9e95fe7403 | |||
| e68266bac9 | |||
| 0c6d3b128a | |||
| d3d60dcaa9 | |||
| 051efb957f | |||
| 78eb0e8430 | |||
| 0c3b75d7a8 | |||
| fa559ba4be | |||
| b63f462cc8 | |||
| 201c786ae8 | |||
| 9273b30e47 | |||
| 33d825df8b | |||
| cdd50bff2a | |||
| 80330a8a76 |
@@ -59,11 +59,11 @@ docker compose exec api php bin/seed
|
|||||||
open http://localhost:8080
|
open http://localhost:8080
|
||||||
```
|
```
|
||||||
|
|
||||||
The first user to log in via Jakach Auth is automatically added as admin.
|
Set `ALLOW_FIRST_USER_BOOTSTRAP=true` only during initial setup if you want the first Jakach Auth user to be automatically added as admin.
|
||||||
|
|
||||||
## Authentication
|
## Authentication
|
||||||
|
|
||||||
Uses [Jakach Login](https://github.com/jakani24/jakach-login) for authentication. Users authenticate via `auth.jakach.ch`. The first user is automatically authorized; subsequent users must have their `user_token` added in Settings > Security.
|
Uses [Jakach Login](https://github.com/jakani24/jakach-login) for authentication. Users authenticate via `auth.jakach.ch`. Authorized users must have their `user_token` added in Settings > Security. First-user auto-authorization is disabled unless `ALLOW_FIRST_USER_BOOTSTRAP=true` is set for initial setup.
|
||||||
|
|
||||||
## Log Sources (Ingestion)
|
## Log Sources (Ingestion)
|
||||||
|
|
||||||
@@ -97,11 +97,7 @@ services:
|
|||||||
```
|
```
|
||||||
|
|
||||||
### HTTP POST
|
### HTTP POST
|
||||||
```bash
|
`/ingest` requires an authenticated session and CSRF token. For unauthenticated service-to-service ingestion, put it behind a trusted reverse proxy or add a dedicated ingest-token flow before exposing it publicly.
|
||||||
curl -X POST http://localhost:8080/ingest \
|
|
||||||
-H 'Content-Type: application/json' \
|
|
||||||
-d '{"line":"2024-01-01 ERROR: something broke","source":"my-app"}'
|
|
||||||
```
|
|
||||||
|
|
||||||
### Shared volume
|
### Shared volume
|
||||||
```yaml
|
```yaml
|
||||||
@@ -164,7 +160,7 @@ Quick actions in the table: ✓ (acknowledge), ✓✓ (resolve). Click any row f
|
|||||||
| Method | Path | Auth | Description |
|
| Method | Path | Auth | Description |
|
||||||
|--------|------|------|-------------|
|
|--------|------|------|-------------|
|
||||||
| GET | `/health` | No | Health check |
|
| GET | `/health` | No | Health check |
|
||||||
| POST | `/ingest` | No | Ingest a log line |
|
| POST | `/ingest` | Yes | Ingest a log line |
|
||||||
| GET | `/auth/me` | No | Current session user |
|
| GET | `/auth/me` | No | Current session user |
|
||||||
| POST | `/auth/logout` | No | Destroy session |
|
| POST | `/auth/logout` | No | Destroy session |
|
||||||
| GET | `/sources` | Yes | List sources |
|
| GET | `/sources` | Yes | List sources |
|
||||||
|
|||||||
@@ -4,8 +4,17 @@ return [
|
|||||||
'db' => [
|
'db' => [
|
||||||
'path' => '/app/data/logging.db',
|
'path' => '/app/data/logging.db',
|
||||||
],
|
],
|
||||||
|
'clickhouse' => [
|
||||||
|
'host' => 'clickhouse',
|
||||||
|
'port' => 8123,
|
||||||
|
'database' => 'jakach_logging',
|
||||||
|
'username' => 'default',
|
||||||
|
'password' => '',
|
||||||
|
],
|
||||||
'worker' => [
|
'worker' => [
|
||||||
'file_check_interval' => 500000,
|
'file_check_interval' => 500000,
|
||||||
|
'buffer_flush_interval_ms' => 100,
|
||||||
|
'buffer_max_rows' => 1000,
|
||||||
],
|
],
|
||||||
'sources' => [],
|
'sources' => [],
|
||||||
'rules' => [
|
'rules' => [
|
||||||
|
|||||||
+19
-4
@@ -11,7 +11,7 @@ services:
|
|||||||
- ./composer.json:/app/composer.json
|
- ./composer.json:/app/composer.json
|
||||||
- data:/app/data
|
- data:/app/data
|
||||||
depends_on:
|
depends_on:
|
||||||
- redis
|
- clickhouse
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
nginx:
|
nginx:
|
||||||
@@ -41,13 +41,28 @@ services:
|
|||||||
- data:/app/data
|
- data:/app/data
|
||||||
- log_collect:/collect
|
- log_collect:/collect
|
||||||
depends_on:
|
depends_on:
|
||||||
- redis
|
- clickhouse
|
||||||
command: ["php", "bin/consume", "--daemon"]
|
command: ["php", "bin/consume", "--daemon"]
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
redis:
|
clickhouse:
|
||||||
image: redis:7-alpine
|
image: clickhouse/clickhouse-server:24.3-alpine
|
||||||
|
ports:
|
||||||
|
- "8123:8123"
|
||||||
|
- "9000:9000"
|
||||||
|
volumes:
|
||||||
|
- clickhouse_data:/var/lib/clickhouse
|
||||||
|
environment:
|
||||||
|
CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: 1
|
||||||
|
CLICKHOUSE_PASSWORD: ""
|
||||||
|
CLICKHOUSE_USER: default
|
||||||
|
ulimits:
|
||||||
|
nofile:
|
||||||
|
soft: 262144
|
||||||
|
hard: 262144
|
||||||
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
data:
|
data:
|
||||||
log_collect:
|
log_collect:
|
||||||
|
clickhouse_data:
|
||||||
@@ -4,6 +4,8 @@ RUN apk add --no-cache linux-headers curl-dev \
|
|||||||
&& docker-php-ext-install curl pcntl sockets || \
|
&& docker-php-ext-install curl pcntl sockets || \
|
||||||
docker-php-ext-install curl pcntl
|
docker-php-ext-install curl pcntl
|
||||||
|
|
||||||
|
RUN apk add --no-cache curl
|
||||||
|
|
||||||
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
FROM php:8.3-cli-alpine
|
FROM php:8.3-cli-alpine
|
||||||
|
|
||||||
RUN apk add --no-cache curl-dev git linux-headers
|
RUN apk add --no-cache curl-dev git linux-headers curl
|
||||||
|
|
||||||
RUN docker-php-ext-install curl pcntl sockets
|
RUN docker-php-ext-install curl pcntl sockets
|
||||||
|
|
||||||
|
|||||||
+508
-52
@@ -152,12 +152,21 @@ pre.raw-line { background: var(--bs-tertiary-bg); padding: .75rem; border-radius
|
|||||||
<button class="btn btn-outline-secondary btn-sm" id="refreshAlertsBtn"><i class="bi bi-arrow-clockwise"></i></button>
|
<button class="btn btn-outline-secondary btn-sm" id="refreshAlertsBtn"><i class="bi bi-arrow-clockwise"></i></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="d-flex gap-2 align-items-center mb-2" id="bulkBar" style="display:none">
|
||||||
|
<small class="text-secondary"><span id="selectedCount">0</span> selected</small>
|
||||||
|
<button class="btn btn-outline-primary btn-sm py-0" onclick="selectAllVisible()"><i class="bi bi-check-all"></i> Select all</button>
|
||||||
|
<button class="btn btn-outline-secondary btn-sm py-0" onclick="deselectAll()"><i class="bi bi-x"></i> Clear</button>
|
||||||
|
<button class="btn btn-outline-success btn-sm" onclick="bulkAction('acknowledged')"><i class="bi bi-check"></i> Acknowledge</button>
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" onclick="bulkAction('resolved')"><i class="bi bi-check-all"></i> Resolve</button>
|
||||||
|
<button class="btn btn-outline-info btn-sm" onclick="exportAlerts('json')"><i class="bi bi-download"></i> Export JSON</button>
|
||||||
|
<button class="btn btn-outline-info btn-sm" onclick="exportAlerts('csv')"><i class="bi bi-filetype-csv"></i> Export CSV</button>
|
||||||
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-body p-0">
|
<div class="card-body p-0">
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-hover table-sm mb-0" id="alertsTable">
|
<table class="table table-hover table-sm mb-0" id="alertsTable">
|
||||||
<thead class="table-dark"><tr>
|
<thead class="table-dark"><tr>
|
||||||
<th style="width:60px;cursor:pointer" onclick="sortAlerts('id')">ID <i class="bi bi-arrow-down-up" style="font-size:.7rem"></i></th>
|
<th style="width:80px"><input type="checkbox" class="form-check-input" id="selectAllCheckbox" onchange="toggleSelectAll()" title="Select all visible"> <span style="cursor:pointer" onclick="sortAlerts('id')">ID <i class="bi bi-arrow-down-up" style="font-size:.7rem"></i></span></th>
|
||||||
<th style="width:90px;cursor:pointer" onclick="sortAlerts('severity')">Severity <i class="bi bi-arrow-down-up" style="font-size:.7rem"></i></th>
|
<th style="width:90px;cursor:pointer" onclick="sortAlerts('severity')">Severity <i class="bi bi-arrow-down-up" style="font-size:.7rem"></i></th>
|
||||||
<th style="width:100px;cursor:pointer" onclick="sortAlerts('status')">Status <i class="bi bi-arrow-down-up" style="font-size:.7rem"></i></th>
|
<th style="width:100px;cursor:pointer" onclick="sortAlerts('status')">Status <i class="bi bi-arrow-down-up" style="font-size:.7rem"></i></th>
|
||||||
<th>Message</th>
|
<th>Message</th>
|
||||||
@@ -180,6 +189,7 @@ pre.raw-line { background: var(--bs-tertiary-bg); padding: .75rem; border-radius
|
|||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
<h5 class="mb-0"><i class="bi bi-search me-2"></i>Log Search</h5>
|
<h5 class="mb-0"><i class="bi bi-search me-2"></i>Log Search</h5>
|
||||||
<small class="text-secondary" id="logsCount"></small>
|
<small class="text-secondary" id="logsCount"></small>
|
||||||
|
<button class="btn btn-outline-info btn-sm" onclick="exportLogs()"><i class="bi bi-download"></i> Export</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="card mb-3">
|
<div class="card mb-3">
|
||||||
<div class="card-body py-3">
|
<div class="card-body py-3">
|
||||||
@@ -187,7 +197,11 @@ pre.raw-line { background: var(--bs-tertiary-bg); padding: .75rem; border-radius
|
|||||||
<input type="text" class="form-control" id="logSearchInput" placeholder="Search all log lines..." maxlength="200">
|
<input type="text" class="form-control" id="logSearchInput" placeholder="Search all log lines..." maxlength="200">
|
||||||
<button class="btn btn-primary" id="logSearchBtn"><i class="bi bi-search me-1"></i>Search</button>
|
<button class="btn btn-primary" id="logSearchBtn"><i class="bi bi-search me-1"></i>Search</button>
|
||||||
</div>
|
</div>
|
||||||
<small class="text-secondary mt-1 d-block">Use <code>*</code> as wildcard. <code>bla</code> matches "blabla". <code>*</code> shows all logs. Separate terms match all (AND).</small>
|
<div class="d-flex gap-2 mt-2 align-items-center">
|
||||||
|
<small class="text-secondary">Use <code>*</code> as wildcard. <code>bla</code> matches "blabla". <code>*</code> shows all logs. Separate terms match all (AND).</small>
|
||||||
|
<input type="datetime-local" class="form-control form-control-sm" id="logSince" style="width:auto" title="From">
|
||||||
|
<input type="datetime-local" class="form-control form-control-sm" id="logUntil" style="width:auto" title="To">
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
@@ -251,70 +265,138 @@ pre.raw-line { background: var(--bs-tertiary-bg); padding: .75rem; border-radius
|
|||||||
<div class="page-section" id="page-settings">
|
<div class="page-section" id="page-settings">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
<h5 class="mb-0"><i class="bi bi-gear me-2"></i>Settings</h5>
|
<h5 class="mb-0"><i class="bi bi-gear me-2"></i>Settings</h5>
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" onclick="loadSettings()"><i class="bi bi-arrow-clockwise"></i></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
|
<!-- Left column -->
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
|
|
||||||
|
<!-- Database status -->
|
||||||
<div class="card mb-3">
|
<div class="card mb-3">
|
||||||
<div class="card-header"><i class="bi bi-info-circle me-1"></i>System Info</div>
|
<div class="card-header"><i class="bi bi-database me-1"></i>Database</div>
|
||||||
<div class="card-body">
|
<div class="card-body py-2">
|
||||||
<dl class="row mb-0">
|
<div class="row g-2">
|
||||||
<dt class="col-sm-4">Health</dt><dd class="col-sm-8"><span id="sysHealth" class="badge bg-secondary">checking...</span></dd>
|
<div class="col-6"><small class="text-secondary d-block">SQLite</small><span id="sysSqlite" class="badge bg-secondary">checking...</span></div>
|
||||||
<dt class="col-sm-4">Auth Server</dt><dd class="col-sm-8"><a href="https://auth.jakach.ch" target="_blank">auth.jakach.ch</a></dd>
|
<div class="col-6"><small class="text-secondary d-block">ClickHouse</small><span id="sysClickhouse" class="badge bg-secondary">checking...</span></div>
|
||||||
<dt class="col-sm-4">Logged in as</dt><dd class="col-sm-8" id="settingsUser">—</dd>
|
<div class="col-6"><small class="text-secondary d-block">Health</small><span id="sysHealth" class="badge bg-secondary">checking...</span></div>
|
||||||
<dt class="col-sm-4">DB Path</dt><dd class="col-sm-8"><code>/app/data/logging.db</code></dd>
|
<div class="col-6"><small class="text-secondary d-block">DB Size</small><span id="sysDbSize">—</span></div>
|
||||||
<dt class="col-sm-4">Worker</dt><dd class="col-sm-8"><code>php bin/consume</code></dd>
|
</div>
|
||||||
</dl>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
|
||||||
<div class="card-header"><i class="bi bi-book me-1"></i>Quick Reference</div>
|
<!-- Data Retention -->
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header"><i class="bi bi-clock-history me-1"></i>Data Retention</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<p class="mb-1"><strong>File sources</strong> — path to a log file on the worker container</p>
|
<p class="small text-secondary mb-2">ClickHouse TTL auto-deletes old data. Manual purge below removes rate-limiter state.</p>
|
||||||
<p class="mb-1"><strong>TCP/UDP sources</strong> — <code>tcp://0.0.0.0:9514</code></p>
|
<div class="row g-2 mb-2">
|
||||||
<p class="mb-0"><strong>Rules</strong> — PHP regex patterns, e.g. <code>/error/i</code></p>
|
<div class="col">
|
||||||
|
<label class="form-label">Keep logs (days)</label>
|
||||||
|
<input type="number" class="form-control form-control-sm" id="retentionLogDays" value="30">
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<label class="form-label">Keep resolved alerts (days)</label>
|
||||||
|
<input type="number" class="form-control form-control-sm" id="retentionAlertDays" value="90">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-outline-danger btn-sm" id="runRetentionBtn"><i class="bi bi-trash3"></i> Purge Now</button>
|
||||||
|
<small id="retentionResult" class="ms-2"></small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- False Positives -->
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<span><i class="bi bi-x-circle me-1"></i>False Positives</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="table-responsive mb-2" style="max-height:240px;overflow-y:auto">
|
||||||
|
<table class="table table-sm mb-0">
|
||||||
|
<thead><tr><th>Pattern</th><th>Description</th><th style="width:50px"></th></tr></thead>
|
||||||
|
<tbody id="falsePositivesBody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="input-group input-group-sm">
|
||||||
|
<input type="text" class="form-control font-monospace" id="fpPattern" placeholder="/healthcheck/i">
|
||||||
|
<input type="text" class="form-control" id="fpDescription" placeholder="Description" style="max-width:160px">
|
||||||
|
<button class="btn btn-primary" id="addFpBtn"><i class="bi bi-plus-lg"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Audit Log -->
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header"><i class="bi bi-journal-text me-1"></i>Audit Log</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="table-responsive" style="max-height:250px;overflow-y:auto">
|
||||||
|
<table class="table table-sm mb-0">
|
||||||
|
<tbody id="auditLogBody"><tr><td class="text-secondary small text-center">Loading...</td></tr></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Right column -->
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
|
|
||||||
|
<!-- Security -->
|
||||||
<div class="card mb-3">
|
<div class="card mb-3">
|
||||||
<div class="card-header"><i class="bi bi-shield-check me-1"></i>Security</div>
|
<div class="card-header"><i class="bi bi-shield-check me-1"></i>Security</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="mb-3">
|
<div class="mb-2">
|
||||||
<label class="form-label">Allowed User Tokens</label>
|
<label class="form-label">Allowed User Tokens <i class="bi bi-question-circle text-secondary" title="Only these Jakach user tokens can access this system. Leave empty to allow any authenticated Jakach user."></i></label>
|
||||||
<p class="small text-secondary">Only these Jakach user tokens can access this system. Leave empty to allow any authenticated Jakach user.</p>
|
<textarea class="form-control font-monospace" id="allowedTokensInput" rows="3" placeholder="One user_token per line"></textarea>
|
||||||
<textarea class="form-control font-monospace" id="allowedTokensInput" rows="4" placeholder="One user_token per line"></textarea>
|
</div>
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<button class="btn btn-primary btn-sm" id="saveTokensBtn"><i class="bi bi-floppy"></i> Save</button>
|
||||||
|
<small id="tokenSaveStatus"></small>
|
||||||
|
<span class="ms-auto text-secondary small">Your token: <code id="settingsUserToken" class="user-select-all">—</code></span>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-primary btn-sm" id="saveTokensBtn"><i class="bi bi-floppy"></i> Save</button>
|
|
||||||
<small id="tokenSaveStatus" class="ms-2"></small>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Telegram -->
|
||||||
<div class="card mb-3">
|
<div class="card mb-3">
|
||||||
<div class="card-header"><i class="bi bi-telegram me-1"></i>Telegram Notifications</div>
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<span><i class="bi bi-telegram me-1"></i>Telegram Notifications</span>
|
||||||
|
<span id="telegramStatusBadge"></span>
|
||||||
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<p class="small text-secondary">Send alerts to a Telegram chat via a bot.</p>
|
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
<label class="form-label">Bot Token</label>
|
<label class="form-label">Bot Token</label>
|
||||||
<input type="text" class="form-control" id="telegramBotToken" placeholder="123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11">
|
<div class="input-group input-group-sm">
|
||||||
|
<input type="password" class="form-control" id="telegramBotToken" placeholder="Enter bot token">
|
||||||
|
<button class="btn btn-outline-secondary" id="telegramTokenToggle" onclick="toggleTelegramToken()"><i class="bi bi-eye"></i></button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
<label class="form-label">Chat ID</label>
|
<label class="form-label">Chat ID</label>
|
||||||
<input type="text" class="form-control" id="telegramChatId" placeholder="-1001234567890">
|
<input type="text" class="form-control form-control-sm" id="telegramChatId" placeholder="-1001234567890">
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<button class="btn btn-primary btn-sm" id="saveTelegramBtn"><i class="bi bi-floppy"></i> Save</button>
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" id="testTelegramBtn"><i class="bi bi-send"></i> Test</button>
|
||||||
|
<small id="telegramSaveStatus"></small>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-primary btn-sm" id="saveTelegramBtn"><i class="bi bi-floppy"></i> Save</button>
|
|
||||||
<small id="telegramSaveStatus" class="ms-2"></small>
|
|
||||||
<button class="btn btn-outline-secondary btn-sm ms-2" id="testTelegramBtn"><i class="bi bi-send"></i> Test</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
|
||||||
<div class="card-header"><i class="bi bi-info-circle me-1"></i>How to get your user token</div>
|
<!-- System -->
|
||||||
<div class="card-body small">
|
<div class="card mb-3">
|
||||||
<ol class="mb-0 ps-3">
|
<div class="card-header"><i class="bi bi-info-circle me-1"></i>System</div>
|
||||||
<li>Log in at <a href="https://auth.jakach.ch" target="_blank">auth.jakach.ch</a></li>
|
<div class="card-body">
|
||||||
<li>Your <code>user_token</code> is shown in your profile</li>
|
<dl class="row mb-0 small">
|
||||||
<li>Paste it above to grant access</li>
|
<dt class="col-sm-4">Logged in as</dt><dd class="col-sm-8" id="settingsUser">—</dd>
|
||||||
</ol>
|
<dt class="col-sm-4">Auth Server</dt><dd class="col-sm-8"><a href="https://auth.jakach.ch" target="_blank">auth.jakach.ch</a></dd>
|
||||||
|
<dt class="col-sm-4">File sources</dt><dd class="col-sm-8">path on worker container</dd>
|
||||||
|
<dt class="col-sm-4">TCP/UDP</dt><dd class="col-sm-8"><code>tcp://0.0.0.0:9514</code></dd>
|
||||||
|
<dt class="col-sm-4">Rules</dt><dd class="col-sm-8">PHP regex, e.g. <code>/error/i</code></dd>
|
||||||
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -433,6 +515,29 @@ pre.raw-line { background: var(--bs-tertiary-bg); padding: .75rem; border-radius
|
|||||||
</form>
|
</form>
|
||||||
</div></div></div>
|
</div></div></div>
|
||||||
|
|
||||||
|
<!-- Rule Test Modal -->
|
||||||
|
<div class="modal fade" id="ruleTestModal" tabindex="-1" style="z-index:10001">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header"><h5 class="modal-title">Test Rule Matching</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Log Line</label>
|
||||||
|
<textarea class="form-control font-monospace" id="ruleTestInput" rows="3" placeholder="Paste a log line to test..."></textarea>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary btn-sm" id="ruleTestBtn"><i class="bi bi-play"></i> Test</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" id="ruleTestResults" style="display:none"></div>
|
||||||
|
</div></div></div>
|
||||||
|
|
||||||
|
<!-- Log Context Modal -->
|
||||||
|
<div class="modal fade" id="contextModal" tabindex="-1" style="z-index:9999">
|
||||||
|
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header"><h5 class="modal-title">Log Context</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
|
||||||
|
<div class="modal-body" id="contextBody"><p class="text-secondary">Loading...</p></div>
|
||||||
|
</div></div></div>
|
||||||
|
|
||||||
<!-- Toast -->
|
<!-- Toast -->
|
||||||
<div class="toast-container position-fixed bottom-0 end-0 p-3"></div>
|
<div class="toast-container position-fixed bottom-0 end-0 p-3"></div>
|
||||||
|
|
||||||
@@ -454,6 +559,7 @@ async function checkAuth() {
|
|||||||
document.getElementById('settingsUser').textContent = res.user.username + ' (' + res.user.user_token.substring(0, 12) + '...)';
|
document.getElementById('settingsUser').textContent = res.user.username + ' (' + res.user.user_token.substring(0, 12) + '...)';
|
||||||
document.getElementById('appLogin').style.display = 'none';
|
document.getElementById('appLogin').style.display = 'none';
|
||||||
document.getElementById('appMain').style.display = '';
|
document.getElementById('appMain').style.display = '';
|
||||||
|
fetchCsrf();
|
||||||
initApp();
|
initApp();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -530,11 +636,27 @@ function loadPage(name) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- API Helpers ---
|
// --- API Helpers ---
|
||||||
|
let csrfToken = '';
|
||||||
|
|
||||||
|
async function fetchCsrf() {
|
||||||
|
try {
|
||||||
|
const res = await api('/auth/csrf', { method: 'GET', noCsrf: true });
|
||||||
|
csrfToken = res.csrf_token || '';
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
async function api(path, opts = {}) {
|
async function api(path, opts = {}) {
|
||||||
const res = await fetch(API + path, {
|
const headers = { 'Accept': 'application/json' };
|
||||||
headers: { 'Accept': 'application/json', ...(opts.body ? { 'Content-Type': 'application/json' } : {}) },
|
if (opts.body) {
|
||||||
...opts,
|
headers['Content-Type'] = 'application/json';
|
||||||
});
|
}
|
||||||
|
if (opts.method && opts.method !== 'GET' && !opts.noCsrf && !csrfToken) {
|
||||||
|
await fetchCsrf();
|
||||||
|
}
|
||||||
|
if (opts.method && opts.method !== 'GET' && !opts.noCsrf && csrfToken) {
|
||||||
|
headers['X-CSRF-TOKEN'] = csrfToken;
|
||||||
|
}
|
||||||
|
const res = await fetch(API + path, { headers, ...opts });
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = new Error(data.error || 'Request failed');
|
const err = new Error(data.error || 'Request failed');
|
||||||
@@ -639,8 +761,8 @@ async function loadDashboard() {
|
|||||||
const maxVal = Math.max(...Object.values(severityCounts), 1);
|
const maxVal = Math.max(...Object.values(severityCounts), 1);
|
||||||
chartEl.innerHTML = `<div class="d-flex align-items-end gap-2" style="height:120px">${Object.entries(severityCounts).map(([sev, cnt]) => {
|
chartEl.innerHTML = `<div class="d-flex align-items-end gap-2" style="height:120px">${Object.entries(severityCounts).map(([sev, cnt]) => {
|
||||||
const color = { critical: 'danger', warning: 'warning', info: 'info' }[sev] || 'secondary';
|
const color = { critical: 'danger', warning: 'warning', info: 'info' }[sev] || 'secondary';
|
||||||
const pct = (cnt / maxVal) * 100;
|
const pct = maxVal > 0 ? (cnt / maxVal) * 100 : 0;
|
||||||
return `<div class="d-flex flex-column align-items-center flex-fill"><div class="bg-${color}" style="width:100%;height:${pct}%;min-height:4px;border-radius:4px 4px 0 0"></div><small class="mt-1">${sev}<br><strong>${cnt}</strong></small></div>`;
|
return `<div class="d-flex flex-column align-items-center justify-content-end flex-fill" style="height:100%"><div class="bg-${color}" style="width:100%;height:${pct}%;min-height:4px;border-radius:4px 4px 0 0"></div><small class="mt-1" style="line-height:1">${sev}<br><strong>${cnt}</strong></small></div>`;
|
||||||
}).join('')}</div>`;
|
}).join('')}</div>`;
|
||||||
|
|
||||||
document.getElementById('lastUpdated').textContent = 'Updated ' + new Date().toLocaleTimeString();
|
document.getElementById('lastUpdated').textContent = 'Updated ' + new Date().toLocaleTimeString();
|
||||||
@@ -930,25 +1052,59 @@ document.getElementById('ruleForm').addEventListener('submit', async e => {
|
|||||||
// --- SETTINGS ---
|
// --- SETTINGS ---
|
||||||
async function loadSettings() {
|
async function loadSettings() {
|
||||||
try {
|
try {
|
||||||
await api('/health');
|
const health = await api('/health');
|
||||||
document.getElementById('sysHealth').textContent = 'Healthy';
|
document.getElementById('sysHealth').textContent = health.status === 'ok' ? 'Healthy' : 'Degraded';
|
||||||
document.getElementById('sysHealth').className = 'badge bg-success';
|
document.getElementById('sysHealth').className = 'badge bg-' + (health.status === 'ok' ? 'success' : 'warning');
|
||||||
|
document.getElementById('sysSqlite').textContent = health.sqlite === 'connected' ? 'Connected' : 'Error';
|
||||||
|
document.getElementById('sysSqlite').className = 'badge bg-' + (health.sqlite === 'connected' ? 'success' : 'danger');
|
||||||
|
document.getElementById('sysClickhouse').textContent = health.clickhouse === 'connected' ? 'Connected' : 'Error';
|
||||||
|
document.getElementById('sysClickhouse').className = 'badge bg-' + (health.clickhouse === 'connected' ? 'success' : 'danger');
|
||||||
|
document.getElementById('sysDbSize').textContent = health.db_size || '—';
|
||||||
} catch {
|
} catch {
|
||||||
document.getElementById('sysHealth').textContent = 'Unreachable';
|
document.getElementById('sysHealth').textContent = 'Unreachable';
|
||||||
document.getElementById('sysHealth').className = 'badge bg-danger';
|
document.getElementById('sysHealth').className = 'badge bg-danger';
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await api('/config/allowed_tokens');
|
const [tokensRes, meRes] = await Promise.all([
|
||||||
const tokens = res.tokens || [];
|
api('/config/allowed_tokens'),
|
||||||
|
api('/auth/me'),
|
||||||
|
]);
|
||||||
|
const tokens = tokensRes.tokens || [];
|
||||||
document.getElementById('allowedTokensInput').value = tokens.join('\n');
|
document.getElementById('allowedTokensInput').value = tokens.join('\n');
|
||||||
|
const userToken = meRes.user?.user_token || '';
|
||||||
|
document.getElementById('settingsUserToken').textContent = userToken || '—';
|
||||||
|
document.getElementById('settingsUser').textContent = meRes.user?.username || '—';
|
||||||
} catch (e) { console.error('load tokens error', e); }
|
} catch (e) { console.error('load tokens error', e); }
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await api('/config/telegram');
|
const res = await api('/config/telegram');
|
||||||
document.getElementById('telegramBotToken').value = res.bot_token || '';
|
document.getElementById('telegramBotToken').value = '';
|
||||||
|
document.getElementById('telegramBotToken').placeholder = res.bot_token_configured
|
||||||
|
? 'Token configured (enter new to change)'
|
||||||
|
: 'Enter bot token';
|
||||||
document.getElementById('telegramChatId').value = res.chat_id || '';
|
document.getElementById('telegramChatId').value = res.chat_id || '';
|
||||||
|
const badge = document.getElementById('telegramStatusBadge');
|
||||||
|
if (res.bot_token_configured && res.chat_id) {
|
||||||
|
badge.innerHTML = '<span class="badge bg-success">Configured</span>';
|
||||||
|
} else if (res.bot_token_configured) {
|
||||||
|
badge.innerHTML = '<span class="badge bg-warning text-dark">Missing Chat ID</span>';
|
||||||
|
} else {
|
||||||
|
badge.innerHTML = '<span class="badge bg-secondary">Not configured</span>';
|
||||||
|
}
|
||||||
} catch (e) { console.error('load telegram error', e); }
|
} catch (e) { console.error('load telegram error', e); }
|
||||||
|
|
||||||
|
try {
|
||||||
|
await loadFalsePositives();
|
||||||
|
} catch (e) { console.error('load false positives error', e); }
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ret = await api('/system/retention');
|
||||||
|
if (ret.log_days) document.getElementById('retentionLogDays').value = ret.log_days;
|
||||||
|
if (ret.alert_days) document.getElementById('retentionAlertDays').value = ret.alert_days;
|
||||||
|
} catch (e) { /* not critical */ }
|
||||||
|
|
||||||
|
loadAuditLog();
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('saveTokensBtn').addEventListener('click', async () => {
|
document.getElementById('saveTokensBtn').addEventListener('click', async () => {
|
||||||
@@ -973,16 +1129,30 @@ document.getElementById('saveTokensBtn').addEventListener('click', async () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function toggleTelegramToken() {
|
||||||
|
const input = document.getElementById('telegramBotToken');
|
||||||
|
const btn = document.getElementById('telegramTokenToggle');
|
||||||
|
if (input.type === 'password') {
|
||||||
|
input.type = 'text';
|
||||||
|
btn.innerHTML = '<i class="bi bi-eye-slash"></i>';
|
||||||
|
} else {
|
||||||
|
input.type = 'password';
|
||||||
|
btn.innerHTML = '<i class="bi bi-eye"></i>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
document.getElementById('saveTelegramBtn').addEventListener('click', async () => {
|
document.getElementById('saveTelegramBtn').addEventListener('click', async () => {
|
||||||
const botToken = document.getElementById('telegramBotToken').value.trim();
|
const botToken = document.getElementById('telegramBotToken').value.trim();
|
||||||
const chatId = document.getElementById('telegramChatId').value.trim();
|
const chatId = document.getElementById('telegramChatId').value.trim();
|
||||||
|
const payload = { chat_id: chatId };
|
||||||
|
if (botToken) payload.bot_token = botToken;
|
||||||
const statusEl = document.getElementById('telegramSaveStatus');
|
const statusEl = document.getElementById('telegramSaveStatus');
|
||||||
statusEl.textContent = 'Saving...';
|
statusEl.textContent = 'Saving...';
|
||||||
statusEl.className = 'ms-2 text-secondary';
|
statusEl.className = 'ms-2 text-secondary';
|
||||||
try {
|
try {
|
||||||
await api('/config/telegram', {
|
await api('/config/telegram', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify({ bot_token: botToken, chat_id: chatId }),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
statusEl.textContent = 'Saved';
|
statusEl.textContent = 'Saved';
|
||||||
statusEl.className = 'ms-2 text-success';
|
statusEl.className = 'ms-2 text-success';
|
||||||
@@ -1010,6 +1180,87 @@ document.getElementById('testTelegramBtn').addEventListener('click', async () =>
|
|||||||
btn.innerHTML = '<i class="bi bi-send"></i> Test';
|
btn.innerHTML = '<i class="bi bi-send"></i> Test';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- FALSE POSITIVES ---
|
||||||
|
let fpItems = [];
|
||||||
|
|
||||||
|
async function loadFalsePositives() {
|
||||||
|
try {
|
||||||
|
const res = await api('/false-positives');
|
||||||
|
fpItems = res.data || res || [];
|
||||||
|
renderFalsePositives();
|
||||||
|
} catch (e) { console.error('fp load error', e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFalsePositives() {
|
||||||
|
const tbody = document.getElementById('falsePositivesBody');
|
||||||
|
if (!fpItems.length) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="3" class="text-secondary small text-center">No false positives configured</td></tr>';
|
||||||
|
} else {
|
||||||
|
tbody.innerHTML = fpItems.map(fp => `<tr>
|
||||||
|
<td><code>${esc(fp.pattern)}</code></td>
|
||||||
|
<td class="text-secondary">${esc(fp.description || '')}</td>
|
||||||
|
<td><button class="btn btn-outline-danger btn-sm py-0" onclick="deleteFp(${fp.id})"><i class="bi bi-trash"></i></button></td>
|
||||||
|
</tr>`).join('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteFp(id) {
|
||||||
|
if (!confirm('Delete this false positive pattern?')) return;
|
||||||
|
try {
|
||||||
|
await api('/false-positives/' + id, { method: 'DELETE' });
|
||||||
|
toast('False positive deleted');
|
||||||
|
loadFalsePositives();
|
||||||
|
} catch (e) { toast('Delete failed', 'danger'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('addFpBtn').addEventListener('click', async () => {
|
||||||
|
const pattern = document.getElementById('fpPattern').value.trim();
|
||||||
|
const description = document.getElementById('fpDescription').value.trim();
|
||||||
|
if (!pattern) { toast('Enter a pattern', 'danger'); return; }
|
||||||
|
try {
|
||||||
|
await api('/false-positives', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ pattern, description }),
|
||||||
|
});
|
||||||
|
document.getElementById('fpPattern').value = '';
|
||||||
|
document.getElementById('fpDescription').value = '';
|
||||||
|
toast('False positive added');
|
||||||
|
loadFalsePositives();
|
||||||
|
} catch (e) { toast('Failed to add', 'danger'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- RULE TEST ---
|
||||||
|
document.getElementById('ruleTestBtn').addEventListener('click', async () => {
|
||||||
|
const line = document.getElementById('ruleTestInput').value.trim();
|
||||||
|
if (!line) { toast('Enter a log line', 'danger'); return; }
|
||||||
|
try {
|
||||||
|
const res = await api('/rules/test', { method: 'POST', body: JSON.stringify({ line }) });
|
||||||
|
const matches = res.data || [];
|
||||||
|
const results = document.getElementById('ruleTestResults');
|
||||||
|
if (!matches.length) {
|
||||||
|
results.innerHTML = '<div class="alert alert-success mb-0">No rules matched this line.</div>';
|
||||||
|
} else {
|
||||||
|
results.innerHTML = matches.map(m => `<div class="alert ${m.is_false_positive ? 'alert-warning' : 'alert-info'} mb-1 py-2 small">${m.is_false_positive ? '<i class="bi bi-x-circle me-1"></i>Blocked by false positive — ' : ''}<strong>${esc(m.rule_name)}</strong> → ${severityBadge(m.severity)}</div>`).join('');
|
||||||
|
}
|
||||||
|
results.style.display = 'block';
|
||||||
|
} catch (e) { toast('Test failed', 'danger'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelector('[data-page="rules"]').addEventListener('click', () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!document.getElementById('ruleTestModal')) return;
|
||||||
|
const toolbar = document.querySelector('#page-rules .d-flex');
|
||||||
|
if (toolbar && !toolbar.querySelector('#openRuleTestBtn')) {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.className = 'btn btn-outline-secondary btn-sm ms-2';
|
||||||
|
btn.id = 'openRuleTestBtn';
|
||||||
|
btn.innerHTML = '<i class="bi bi-play-circle"></i> Test Rule';
|
||||||
|
btn.onclick = () => bootstrap.Modal.getOrCreateInstance(document.getElementById('ruleTestModal')).show();
|
||||||
|
toolbar.querySelector('.btn-primary').after(btn);
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
});
|
||||||
|
|
||||||
// --- LOGS ---
|
// --- LOGS ---
|
||||||
async function loadLogs(query) {
|
async function loadLogs(query) {
|
||||||
if (!query) {
|
if (!query) {
|
||||||
@@ -1021,14 +1272,20 @@ async function loadLogs(query) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let url = '/logs/search?q=' + encodeURIComponent(query) + '&limit=200';
|
||||||
|
const since = document.getElementById('logSince').value;
|
||||||
|
const until = document.getElementById('logUntil').value;
|
||||||
|
if (since) url += '&since=' + encodeURIComponent(since);
|
||||||
|
if (until) url += '&until=' + encodeURIComponent(until);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await api('/logs/search?q=' + encodeURIComponent(query) + '&limit=200');
|
const res = await api(url);
|
||||||
const entries = res.data || [];
|
const entries = res.data || [];
|
||||||
const tbody = document.getElementById('logsBody');
|
const tbody = document.getElementById('logsBody');
|
||||||
if (!entries.length) {
|
if (!entries.length) {
|
||||||
tbody.innerHTML = '<tr><td colspan="4" class="empty-state"><i class="bi bi-inbox"></i><p class="mb-0">No results for "' + esc(query) + '"</p></td></tr>';
|
tbody.innerHTML = '<tr><td colspan="4" class="empty-state"><i class="bi bi-inbox"></i><p class="mb-0">No results for "' + esc(query) + '"</p></td></tr>';
|
||||||
} else {
|
} else {
|
||||||
tbody.innerHTML = entries.map(e => `<tr>
|
tbody.innerHTML = entries.map(e => `<tr style="cursor:pointer" onclick="showLogContext(${e.id})">
|
||||||
<td class="text-secondary" style="font-size:.75rem">#${e.id}</td>
|
<td class="text-secondary" style="font-size:.75rem">#${e.id}</td>
|
||||||
<td class="text-secondary" style="white-space:nowrap;font-size:.8rem">${new Date(e.created_at).toLocaleString()}</td>
|
<td class="text-secondary" style="white-space:nowrap;font-size:.8rem">${new Date(e.created_at).toLocaleString()}</td>
|
||||||
<td style="font-family:monospace;font-size:.8rem;max-width:600px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(e.line)}</td>
|
<td style="font-family:monospace;font-size:.8rem;max-width:600px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(e.line)}</td>
|
||||||
@@ -1039,9 +1296,208 @@ async function loadLogs(query) {
|
|||||||
} catch (e) { console.error('logs error', e); }
|
} catch (e) { console.error('logs error', e); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function showLogContext(id) {
|
||||||
|
try {
|
||||||
|
const res = await api('/logs/context/' + id + '?before=5&after=5');
|
||||||
|
const ctx = document.getElementById('contextBody');
|
||||||
|
let html = '';
|
||||||
|
if (res.before) res.before.forEach(l => {
|
||||||
|
html += `<div class="small mb-1" style="font-family:monospace;opacity:.6">#${l.id} <span class="text-secondary">${new Date(l.created_at).toLocaleString()}</span> ${esc(l.line)}</div>`;
|
||||||
|
});
|
||||||
|
if (res.current) {
|
||||||
|
html += `<div class="small mb-1 p-1" style="font-family:monospace;background:var(--bs-primary-bg-subtle);border-left:3px solid var(--bs-primary)">#${res.current.id} <span class="text-secondary">${new Date(res.current.created_at).toLocaleString()}</span> ${esc(res.current.line)}</div>`;
|
||||||
|
}
|
||||||
|
if (res.after) res.after.forEach(l => {
|
||||||
|
html += `<div class="small mb-1" style="font-family:monospace;opacity:.6">#${l.id} <span class="text-secondary">${new Date(l.created_at).toLocaleString()}</span> ${esc(l.line)}</div>`;
|
||||||
|
});
|
||||||
|
ctx.innerHTML = html || '<p class="text-secondary">No context found</p>';
|
||||||
|
bootstrap.Modal.getOrCreateInstance(document.getElementById('contextModal')).show();
|
||||||
|
} catch (e) { toast('Failed to load context', 'danger'); }
|
||||||
|
}
|
||||||
|
|
||||||
document.getElementById('logSearchBtn').addEventListener('click', () => loadLogs());
|
document.getElementById('logSearchBtn').addEventListener('click', () => loadLogs());
|
||||||
document.getElementById('logSearchInput').addEventListener('keydown', e => { if (e.key === 'Enter') loadLogs(); });
|
document.getElementById('logSearchInput').addEventListener('keydown', e => { if (e.key === 'Enter') loadLogs(); });
|
||||||
|
|
||||||
|
async function exportLogs() {
|
||||||
|
const query = document.getElementById('logSearchInput').value.trim();
|
||||||
|
if (!query) { toast('Search something first', 'warning'); return; }
|
||||||
|
const since = document.getElementById('logSince').value;
|
||||||
|
const until = document.getElementById('logUntil').value;
|
||||||
|
let url = '/logs/search?q=' + encodeURIComponent(query) + '&limit=10000';
|
||||||
|
if (since) url += '&since=' + encodeURIComponent(since);
|
||||||
|
if (until) url += '&until=' + encodeURIComponent(until);
|
||||||
|
try {
|
||||||
|
const res = await api(url);
|
||||||
|
const entries = res.data || [];
|
||||||
|
if (!entries.length) { toast('No results to export', 'warning'); return; }
|
||||||
|
const csv = ['ID,Line,Source,Level,Created', ...entries.map(e =>
|
||||||
|
[e.id, '"' + (e.line || '').replace(/"/g, '""') + '"',
|
||||||
|
e.source_name || '', e.level || '', e.created_at].join(','))].join('\n');
|
||||||
|
const blob = new Blob([csv], { type: 'text/csv' });
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = URL.createObjectURL(blob);
|
||||||
|
a.download = 'logs.csv';
|
||||||
|
a.click();
|
||||||
|
toast('Exported ' + entries.length + ' rows');
|
||||||
|
} catch (e) { toast('Export failed', 'danger'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- BULK ALERT OPERATIONS ---
|
||||||
|
let selectedAlertIds = new Set();
|
||||||
|
|
||||||
|
function toggleAlertSelection(id) {
|
||||||
|
if (selectedAlertIds.has(id)) selectedAlertIds.delete(id);
|
||||||
|
else selectedAlertIds.add(id);
|
||||||
|
updateBulkBar();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateBulkBar() {
|
||||||
|
document.getElementById('bulkBar').classList.toggle('d-none', selectedAlertIds.size === 0);
|
||||||
|
document.getElementById('selectedCount').textContent = selectedAlertIds.size;
|
||||||
|
const cb = document.getElementById('selectAllCheckbox');
|
||||||
|
if (cb) cb.checked = selectedAlertIds.size > 0 && selectedAlertIds.size === state.alerts.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSelectAll() {
|
||||||
|
const cb = document.getElementById('selectAllCheckbox');
|
||||||
|
if (cb.checked) {
|
||||||
|
state.alerts.forEach(a => selectedAlertIds.add(a.id));
|
||||||
|
} else {
|
||||||
|
selectedAlertIds.clear();
|
||||||
|
}
|
||||||
|
updateBulkBar();
|
||||||
|
renderAlerts();
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectAllVisible() {
|
||||||
|
state.alerts.forEach(a => selectedAlertIds.add(a.id));
|
||||||
|
const cb = document.getElementById('selectAllCheckbox');
|
||||||
|
if (cb) cb.checked = true;
|
||||||
|
updateBulkBar();
|
||||||
|
renderAlerts();
|
||||||
|
}
|
||||||
|
|
||||||
|
function deselectAll() {
|
||||||
|
selectedAlertIds.clear();
|
||||||
|
const cb = document.getElementById('selectAllCheckbox');
|
||||||
|
if (cb) cb.checked = false;
|
||||||
|
updateBulkBar();
|
||||||
|
renderAlerts();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAlerts(query) {
|
||||||
|
let sorted = [...state.alerts];
|
||||||
|
const field = state.sortField;
|
||||||
|
const dir = state.sortDir;
|
||||||
|
sorted.sort((a, b) => {
|
||||||
|
let va = a[field], vb = b[field];
|
||||||
|
if (field === 'severity') {
|
||||||
|
const order = ['debug','info','notice','warning_low','warning','warning_high','error','critical_low','critical','critical_high','emergency'];
|
||||||
|
va = order.indexOf(va);
|
||||||
|
vb = order.indexOf(vb);
|
||||||
|
} else if (field === 'created_at') {
|
||||||
|
va = new Date(va).getTime();
|
||||||
|
vb = new Date(vb).getTime();
|
||||||
|
} else {
|
||||||
|
va = (va || '').toString().toLowerCase();
|
||||||
|
vb = (vb || '').toString().toLowerCase();
|
||||||
|
if (va < vb) return dir === 'asc' ? -1 : 1;
|
||||||
|
if (va > vb) return dir === 'asc' ? 1 : -1;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return dir === 'asc' ? va - vb : vb - va;
|
||||||
|
});
|
||||||
|
|
||||||
|
const tbody = document.getElementById('alertsBody');
|
||||||
|
if (!sorted.length) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="7" class="empty-state"><i class="bi bi-inbox"></i><p class="mb-0">No alerts match those filters</p></td></tr>';
|
||||||
|
} else {
|
||||||
|
tbody.innerHTML = sorted.map(a => `<tr class="alert-row" onclick="showAlert(${a.id})">
|
||||||
|
<td class="text-secondary" onclick="event.stopPropagation()">
|
||||||
|
<input type="checkbox" class="form-check-input" ${selectedAlertIds.has(a.id) ? 'checked' : ''} onchange="toggleAlertSelection(${a.id})">
|
||||||
|
<span class="ms-1">#${a.id}</span>
|
||||||
|
</td>
|
||||||
|
<td>${severityBadge(a.severity)}</td>
|
||||||
|
<td>${statusBadge(a.status)}</td>
|
||||||
|
<td class="log-line">${esc(a.message)}</td>
|
||||||
|
<td>${esc(a.source_name || '—')}</td>
|
||||||
|
<td class="text-secondary" style="white-space:nowrap">${new Date(a.created_at).toLocaleString()}</td>
|
||||||
|
<td style="white-space:nowrap">
|
||||||
|
${a.status === 'open' ? `<button class="btn btn-outline-success btn-sm py-0 me-1" onclick="event.stopPropagation();quickAction(${a.id},'acknowledged')" title="Acknowledge"><i class="bi bi-check"></i></button>` : ''}
|
||||||
|
${a.status !== 'resolved' ? `<button class="btn btn-outline-secondary btn-sm py-0" onclick="event.stopPropagation();quickAction(${a.id},'resolved')" title="Resolve"><i class="bi bi-check-all"></i></button>` : ''}
|
||||||
|
</td>
|
||||||
|
</tr>`).join('');
|
||||||
|
}
|
||||||
|
const label = document.getElementById('searchInput').value.trim() ? 'search results' : 'alerts';
|
||||||
|
document.getElementById('alertsCount').textContent = sorted.length + ' ' + label;
|
||||||
|
updateBulkBar();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bulkAction(status) {
|
||||||
|
const ids = Array.from(selectedAlertIds);
|
||||||
|
if (!ids.length) return;
|
||||||
|
try {
|
||||||
|
await api('/alerts/bulk', { method: 'POST', body: JSON.stringify({ ids, status }) });
|
||||||
|
toast(ids.length + ' alerts ' + status);
|
||||||
|
selectedAlertIds.clear();
|
||||||
|
loadAlerts();
|
||||||
|
} catch (e) { toast('Bulk action failed', 'danger'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportAlerts(format) {
|
||||||
|
const ids = Array.from(selectedAlertIds);
|
||||||
|
if (!ids.length) { toast('Select alerts first', 'warning'); return; }
|
||||||
|
try {
|
||||||
|
const res = await api('/alerts/export', { method: 'POST', body: JSON.stringify({ ids, format: 'json' }) });
|
||||||
|
const alerts = res.data || [];
|
||||||
|
if (format === 'csv') {
|
||||||
|
const csv = ['ID,Rule,Severity,Status,Message,Source,Created', ...alerts.map(a =>
|
||||||
|
[a.id, '"' + (a.rule_name || '').replace(/"/g, '""') + '"', a.severity, a.status,
|
||||||
|
'"' + (a.message || '').replace(/"/g, '""') + '"', a.source_name || '', a.created_at].join(','))].join('\n');
|
||||||
|
const blob = new Blob([csv], { type: 'text/csv' });
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = URL.createObjectURL(blob);
|
||||||
|
a.download = 'alerts.csv';
|
||||||
|
a.click();
|
||||||
|
} else {
|
||||||
|
const blob = new Blob([JSON.stringify(res, null, 2)], { type: 'application/json' });
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = URL.createObjectURL(blob);
|
||||||
|
a.download = 'alerts.json';
|
||||||
|
a.click();
|
||||||
|
}
|
||||||
|
toast('Export done');
|
||||||
|
} catch (e) { toast('Export failed', 'danger'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- RETENTION ---
|
||||||
|
document.getElementById('runRetentionBtn').addEventListener('click', async () => {
|
||||||
|
const logDays = document.getElementById('retentionLogDays').value;
|
||||||
|
const alertDays = document.getElementById('retentionAlertDays').value;
|
||||||
|
const el = document.getElementById('retentionResult');
|
||||||
|
el.textContent = 'Purging...';
|
||||||
|
try {
|
||||||
|
const res = await api('/system/retention', { method: 'POST', body: JSON.stringify({ log_days: parseInt(logDays), alert_days: parseInt(alertDays) }) });
|
||||||
|
el.textContent = res.log_entries_deleted + ' logs, ' + res.alerts_deleted + ' alerts purged';
|
||||||
|
el.className = 'ms-2 text-success';
|
||||||
|
setTimeout(() => { el.textContent = ''; }, 5000);
|
||||||
|
} catch (e) { el.textContent = 'Failed'; el.className = 'ms-2 text-danger'; }
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- AUDIT LOG ---
|
||||||
|
async function loadAuditLog() {
|
||||||
|
try {
|
||||||
|
const res = await api('/system/audit-log');
|
||||||
|
const entries = res.data || [];
|
||||||
|
const tbody = document.getElementById('auditLogBody');
|
||||||
|
if (!entries.length) {
|
||||||
|
tbody.innerHTML = '<tr><td class="text-secondary small text-center">No audit entries</td></tr>';
|
||||||
|
} else {
|
||||||
|
tbody.innerHTML = entries.map(e => `<tr><td class="small"><span class="text-secondary">${new Date(e.created_at).toLocaleString()}</span> <strong>${esc(e.action)}</strong> ${esc(e.entity_type)}${e.entity_id ? ' #' + e.entity_id : ''}${e.username ? ' by ' + esc(e.username) : ''}${e.details ? '<br><small class="text-secondary">' + esc(e.details) + '</small>' : ''}</td></tr>`).join('');
|
||||||
|
}
|
||||||
|
} catch (e) { console.error('audit log error', e); }
|
||||||
|
}
|
||||||
|
|
||||||
// --- Helpers ---
|
// --- Helpers ---
|
||||||
function esc(s) {
|
function esc(s) {
|
||||||
const d = document.createElement('div');
|
const d = document.createElement('div');
|
||||||
|
|||||||
+21
-19
@@ -5,47 +5,48 @@ require_once __DIR__ . '/../vendor/autoload.php';
|
|||||||
use Jakach\Logging\Storage\Database;
|
use Jakach\Logging\Storage\Database;
|
||||||
use Jakach\Logging\Storage\Repository;
|
use Jakach\Logging\Storage\Repository;
|
||||||
|
|
||||||
$logFile = '/tmp/oauth_debug.log';
|
function isSafeRedirect(string $url): bool
|
||||||
file_put_contents($logFile, date('c') . " oauth.php called\n", FILE_APPEND);
|
{
|
||||||
file_put_contents($logFile, "GET: " . json_encode($_GET) . "\n", FILE_APPEND);
|
if ($url === '' || $url === '/') return true;
|
||||||
|
$host = parse_url($url, PHP_URL_HOST);
|
||||||
|
if ($host === null || $host === '') return true;
|
||||||
|
return str_ends_with($host, '.jakach.ch') || $host === 'jakach.ch';
|
||||||
|
}
|
||||||
|
|
||||||
session_set_cookie_params([
|
session_set_cookie_params([
|
||||||
'lifetime' => 86400 * 7,
|
'lifetime' => 86400 * 7,
|
||||||
'path' => '/',
|
'path' => '/',
|
||||||
'httponly' => true,
|
'httponly' => true,
|
||||||
|
'secure' => true,
|
||||||
'samesite' => 'Lax',
|
'samesite' => 'Lax',
|
||||||
]);
|
]);
|
||||||
session_start();
|
session_start();
|
||||||
|
|
||||||
$authToken = $_GET['auth'] ?? '';
|
$authToken = $_GET['auth'] ?? '';
|
||||||
$errorRedirect = $_GET['redirect'] ?? '/';
|
$rawRedirect = $_GET['redirect'] ?? '/';
|
||||||
|
$errorRedirect = isSafeRedirect($rawRedirect) ? $rawRedirect : '/';
|
||||||
file_put_contents($logFile, "authToken: $authToken\n", FILE_APPEND);
|
|
||||||
|
|
||||||
if (!$authToken) {
|
if (!$authToken) {
|
||||||
$_SESSION['auth_error'] = 'Missing authentication token.';
|
$_SESSION['auth_error'] = 'Missing authentication token.';
|
||||||
file_put_contents($logFile, "ERROR: missing auth token\n", FILE_APPEND);
|
|
||||||
header('Location: ' . $errorRedirect);
|
header('Location: ' . $errorRedirect);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
$checkUrl = 'https://auth.jakach.ch/api/auth/check_auth_key.php?auth_token=' . urlencode($authToken);
|
$checkUrl = 'https://auth.jakach.ch/api/auth/check_auth_key.php?auth_token=' . urlencode($authToken);
|
||||||
file_put_contents($logFile, "checkUrl: $checkUrl\n", FILE_APPEND);
|
|
||||||
|
|
||||||
$ch = curl_init();
|
$ch = curl_init();
|
||||||
curl_setopt($ch, CURLOPT_URL, $checkUrl);
|
curl_setopt($ch, CURLOPT_URL, $checkUrl);
|
||||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||||
curl_setopt($ch, CURLOPT_TIMEOUT, 15);
|
curl_setopt($ch, CURLOPT_TIMEOUT, 15);
|
||||||
|
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
|
||||||
|
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);
|
||||||
$response = curl_exec($ch);
|
$response = curl_exec($ch);
|
||||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
$curlError = curl_error($ch);
|
$curlError = curl_error($ch);
|
||||||
curl_close($ch);
|
curl_close($ch);
|
||||||
|
|
||||||
file_put_contents($logFile, "httpCode: $httpCode response: " . substr($response, 0, 500) . " curlError: $curlError\n", FILE_APPEND);
|
|
||||||
|
|
||||||
if ($httpCode !== 200 || !$response) {
|
if ($httpCode !== 200 || !$response) {
|
||||||
$_SESSION['auth_error'] = "Auth server unreachable ($httpCode)";
|
$_SESSION['auth_error'] = "Auth server unreachable ($httpCode)";
|
||||||
file_put_contents($logFile, "ERROR: bad response $httpCode\n", FILE_APPEND);
|
|
||||||
header('Location: ' . $errorRedirect);
|
header('Location: ' . $errorRedirect);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
@@ -54,30 +55,33 @@ $data = json_decode($response, true);
|
|||||||
|
|
||||||
if (!isset($data['status']) || $data['status'] !== 'success') {
|
if (!isset($data['status']) || $data['status'] !== 'success') {
|
||||||
$_SESSION['auth_error'] = 'Authentication failed: ' . ($data['msg'] ?? 'Unknown error');
|
$_SESSION['auth_error'] = 'Authentication failed: ' . ($data['msg'] ?? 'Unknown error');
|
||||||
file_put_contents($logFile, "ERROR: auth failed: " . json_encode($data) . "\n", FILE_APPEND);
|
|
||||||
header('Location: ' . $errorRedirect);
|
header('Location: ' . $errorRedirect);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
$userToken = $data['user_token'] ?? '';
|
$userToken = $data['user_token'] ?? '';
|
||||||
file_put_contents($logFile, "Auth success, user_token: $userToken\n", FILE_APPEND);
|
|
||||||
|
|
||||||
$db = new Database();
|
$db = new Database();
|
||||||
$repo = new Repository($db);
|
$repo = new Repository($db);
|
||||||
|
|
||||||
$allowedTokens = $repo->getAllowedUserTokens();
|
$allowedTokens = $repo->getAllowedUserTokens();
|
||||||
file_put_contents($logFile, "allowedTokens: " . json_encode($allowedTokens) . "\n", FILE_APPEND);
|
|
||||||
|
|
||||||
if (empty($allowedTokens)) {
|
if (empty($allowedTokens)) {
|
||||||
file_put_contents($logFile, "First user, adding to allowed tokens\n", FILE_APPEND);
|
$bootstrapAllowed = filter_var(getenv('ALLOW_FIRST_USER_BOOTSTRAP') ?: 'false', FILTER_VALIDATE_BOOL);
|
||||||
|
if (!$bootstrapAllowed) {
|
||||||
|
$_SESSION['auth_error'] = 'No users are authorized for this system. Set allowed_user_tokens or enable first-user bootstrap during initial setup.';
|
||||||
|
header('Location: ' . $errorRedirect);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
$repo->setAllowedUserTokens([$userToken]);
|
$repo->setAllowedUserTokens([$userToken]);
|
||||||
} elseif (!in_array($userToken, $allowedTokens, true)) {
|
} elseif (!in_array($userToken, $allowedTokens, true)) {
|
||||||
$_SESSION['auth_error'] = 'Your Jakach account is not authorized to access this system. Contact an administrator.';
|
$_SESSION['auth_error'] = 'Your Jakach account is not authorized to access this system. Contact an administrator.';
|
||||||
file_put_contents($logFile, "ERROR: user not allowed\n", FILE_APPEND);
|
|
||||||
header('Location: ' . $errorRedirect);
|
header('Location: ' . $errorRedirect);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
session_regenerate_id(true);
|
||||||
|
|
||||||
$_SESSION['loggedin'] = true;
|
$_SESSION['loggedin'] = true;
|
||||||
$_SESSION['username'] = $data['username'] ?? 'unknown';
|
$_SESSION['username'] = $data['username'] ?? 'unknown';
|
||||||
$_SESSION['id'] = $data['id'] ?? '';
|
$_SESSION['id'] = $data['id'] ?? '';
|
||||||
@@ -86,8 +90,6 @@ $_SESSION['telegram_id'] = $data['telegram_id'] ?? '';
|
|||||||
$_SESSION['user_token'] = $userToken;
|
$_SESSION['user_token'] = $userToken;
|
||||||
unset($_SESSION['auth_error']);
|
unset($_SESSION['auth_error']);
|
||||||
|
|
||||||
file_put_contents($logFile, "Session set, redirecting to: $errorRedirect\n", FILE_APPEND);
|
$redirect = isSafeRedirect($rawRedirect) ? $rawRedirect : '/';
|
||||||
|
|
||||||
$redirect = $_GET['redirect'] ?? '/';
|
|
||||||
header('Location: ' . $redirect);
|
header('Location: ' . $redirect);
|
||||||
exit;
|
exit;
|
||||||
@@ -20,6 +20,7 @@ class AuthMiddleware
|
|||||||
'lifetime' => 86400 * 7,
|
'lifetime' => 86400 * 7,
|
||||||
'path' => '/',
|
'path' => '/',
|
||||||
'httponly' => true,
|
'httponly' => true,
|
||||||
|
'secure' => true,
|
||||||
'samesite' => 'Lax',
|
'samesite' => 'Lax',
|
||||||
]);
|
]);
|
||||||
session_start();
|
session_start();
|
||||||
@@ -29,6 +30,10 @@ class AuthMiddleware
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (empty($_SESSION['csrf_token'])) {
|
||||||
|
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||||
|
}
|
||||||
|
|
||||||
$allowedTokens = $this->repo->getAllowedUserTokens();
|
$allowedTokens = $this->repo->getAllowedUserTokens();
|
||||||
$userToken = $_SESSION['user_token'] ?? '';
|
$userToken = $_SESSION['user_token'] ?? '';
|
||||||
|
|
||||||
@@ -41,6 +46,15 @@ class AuthMiddleware
|
|||||||
'username' => $_SESSION['username'] ?? 'unknown',
|
'username' => $_SESSION['username'] ?? 'unknown',
|
||||||
'user_token' => $userToken,
|
'user_token' => $userToken,
|
||||||
'email' => $_SESSION['email'] ?? '',
|
'email' => $_SESSION['email'] ?? '',
|
||||||
|
'csrf_token' => $_SESSION['csrf_token'] ?? '',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function validateCsrf(): bool
|
||||||
|
{
|
||||||
|
if (empty($_SESSION['csrf_token'])) return false;
|
||||||
|
$body = json_decode(file_get_contents('php://input'), true);
|
||||||
|
$token = $body['csrf_token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||||
|
return hash_equals($_SESSION['csrf_token'], $token);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+271
-27
@@ -13,6 +13,7 @@ class Router
|
|||||||
private AuthMiddleware $auth;
|
private AuthMiddleware $auth;
|
||||||
private Engine $engine;
|
private Engine $engine;
|
||||||
private TelegramNotifier $telegram;
|
private TelegramNotifier $telegram;
|
||||||
|
private ?string $currentUser = null;
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
@@ -23,6 +24,11 @@ class Router
|
|||||||
$this->telegram = new TelegramNotifier($this->repo);
|
$this->telegram = new TelegramNotifier($this->repo);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function user(): ?string
|
||||||
|
{
|
||||||
|
return $this->currentUser;
|
||||||
|
}
|
||||||
|
|
||||||
public function handle(): void
|
public function handle(): void
|
||||||
{
|
{
|
||||||
$method = $_SERVER['REQUEST_METHOD'];
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
@@ -33,7 +39,7 @@ class Router
|
|||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$publicPaths = ['/health', '/oauth', '/auth/me', '/auth/logout', '/ingest'];
|
$publicPaths = ['/health', '/oauth', '/auth/me', '/auth/logout', '/auth/csrf'];
|
||||||
$isPublic = false;
|
$isPublic = false;
|
||||||
foreach ($publicPaths as $pp) {
|
foreach ($publicPaths as $pp) {
|
||||||
if ($path === $pp || str_starts_with($path, $pp . '/')) {
|
if ($path === $pp || str_starts_with($path, $pp . '/')) {
|
||||||
@@ -49,50 +55,75 @@ class Router
|
|||||||
echo json_encode(['error' => 'Unauthorized', 'login_url' => $this->loginUrl()]);
|
echo json_encode(['error' => 'Unauthorized', 'login_url' => $this->loginUrl()]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
$this->currentUser = $user['username'] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$result = match (true) {
|
$result = match (true) {
|
||||||
$path === '/health' && $method === 'GET' => ['status' => 'ok'],
|
$path === '/health' && $method === 'GET' => $this->health(),
|
||||||
|
|
||||||
$path === '/ingest' && $method === 'POST' => $this->ingest(),
|
$path === '/auth/csrf' && $method === 'GET' => $this->csrfToken(),
|
||||||
|
|
||||||
|
$path === '/ingest' && $method === 'POST' => $this->requireCsrf(fn() => $this->ingest()),
|
||||||
|
|
||||||
$path === '/auth/me' && $method === 'GET' => $this->getMe(),
|
$path === '/auth/me' && $method === 'GET' => $this->getMe(),
|
||||||
$path === '/auth/logout' && $method === 'POST' => $this->logout(),
|
$path === '/auth/logout' && $method === 'POST' => $this->logout(),
|
||||||
|
|
||||||
$path === '/sources' && $method === 'GET' => $this->repo->getSources(),
|
$path === '/sources' && $method === 'GET' => $this->repo->getSources(),
|
||||||
$path === '/sources' && $method === 'POST' => $this->createSource(),
|
$path === '/sources' && $method === 'POST' => $this->requireCsrf(fn() => $this->createSource()),
|
||||||
preg_match('#^/sources/(\d+)$#', $path, $m) && $method === 'DELETE'
|
preg_match('#^/sources/(\d+)$#', $path, $m) && $method === 'DELETE'
|
||||||
=> $this->deleteEntity('source', (int) $m[1]),
|
=> $this->requireCsrf(fn() => $this->deleteEntity('source', (int) $m[1])),
|
||||||
preg_match('#^/sources/(\d+)$#', $path, $m) && $method === 'PUT'
|
preg_match('#^/sources/(\d+)$#', $path, $m) && $method === 'PUT'
|
||||||
=> $this->updateSource((int) $m[1]),
|
=> $this->requireCsrf(fn() => $this->updateSource((int) $m[1])),
|
||||||
|
|
||||||
$path === '/rules' && $method === 'GET' => $this->repo->getRules(),
|
$path === '/rules' && $method === 'GET' => $this->repo->getRules(),
|
||||||
$path === '/rules' && $method === 'POST' => $this->createRule(),
|
$path === '/rules' && $method === 'POST' => $this->requireCsrf(fn() => $this->createRule()),
|
||||||
|
$path === '/rules/test' && $method === 'POST' => $this->requireCsrf(fn() => $this->testRule()),
|
||||||
preg_match('#^/rules/(\d+)$#', $path, $m) && $method === 'DELETE'
|
preg_match('#^/rules/(\d+)$#', $path, $m) && $method === 'DELETE'
|
||||||
=> $this->deleteEntity('rule', (int) $m[1]),
|
=> $this->requireCsrf(fn() => $this->deleteEntity('rule', (int) $m[1])),
|
||||||
preg_match('#^/rules/(\d+)$#', $path, $m) && $method === 'PUT'
|
preg_match('#^/rules/(\d+)$#', $path, $m) && $method === 'PUT'
|
||||||
=> $this->updateRule((int) $m[1]),
|
=> $this->requireCsrf(fn() => $this->updateRule((int) $m[1])),
|
||||||
|
|
||||||
$path === '/alerts' && $method === 'GET' => $this->getAlerts(),
|
$path === '/alerts' && $method === 'GET' => $this->getAlerts(),
|
||||||
$path === '/alerts/search' && $method === 'GET' => $this->searchAlerts(),
|
$path === '/alerts/search' && $method === 'GET' => $this->searchAlerts(),
|
||||||
|
$path === '/alerts/bulk' && $method === 'POST' => $this->requireCsrf(fn() => $this->bulkAlertAction()),
|
||||||
preg_match('#^/alerts/(\d+)/ack$#', $path, $m) && $method === 'POST'
|
preg_match('#^/alerts/(\d+)/ack$#', $path, $m) && $method === 'POST'
|
||||||
=> $this->ackAlert((int) $m[1]),
|
=> $this->requireCsrf(fn() => $this->ackAlert((int) $m[1])),
|
||||||
preg_match('#^/alerts/(\d+)/status$#', $path, $m) && $method === 'POST'
|
preg_match('#^/alerts/(\d+)/status$#', $path, $m) && $method === 'POST'
|
||||||
=> $this->updateAlertStatus((int) $m[1]),
|
=> $this->requireCsrf(fn() => $this->updateAlertStatus((int) $m[1])),
|
||||||
preg_match('#^/alerts/counts$#', $path) && $method === 'GET'
|
preg_match('#^/alerts/counts$#', $path) && $method === 'GET'
|
||||||
=> $this->repo->getAlertCounts(),
|
=> $this->repo->getAlertCounts(),
|
||||||
|
preg_match('#^/alerts/export$#', $path) && $method === 'POST'
|
||||||
|
=> $this->requireCsrf(fn() => $this->exportAlerts()),
|
||||||
|
|
||||||
|
preg_match('#^/logs/context/(\d+)$#', $path, $m) && $method === 'GET'
|
||||||
|
=> $this->logContext((int) $m[1]),
|
||||||
$path === '/logs/search' && $method === 'GET' => $this->searchLogs(),
|
$path === '/logs/search' && $method === 'GET' => $this->searchLogs(),
|
||||||
|
preg_match('#^/logs/export$#', $path) && $method === 'POST'
|
||||||
|
=> $this->requireCsrf(fn() => $this->exportLogs()),
|
||||||
|
|
||||||
|
$path === '/system/retention' && $method === 'POST'
|
||||||
|
=> $this->requireCsrf(fn() => $this->runRetention()),
|
||||||
|
$path === '/system/retention' && $method === 'GET'
|
||||||
|
=> $this->getRetentionConfig(),
|
||||||
|
$path === '/system/audit-log' && $method === 'GET'
|
||||||
|
=> $this->getAuditLog(),
|
||||||
|
|
||||||
$path === '/config/allowed_tokens' && $method === 'GET'
|
$path === '/config/allowed_tokens' && $method === 'GET'
|
||||||
=> ['tokens' => $this->repo->getAllowedUserTokens()],
|
=> ['tokens' => $this->repo->getAllowedUserTokens()],
|
||||||
$path === '/config/allowed_tokens' && $method === 'PUT'
|
$path === '/config/allowed_tokens' && $method === 'PUT'
|
||||||
=> $this->updateAllowedTokens(),
|
=> $this->requireCsrf(fn() => $this->updateAllowedTokens()),
|
||||||
|
|
||||||
$path === '/config/telegram' && $method === 'GET'
|
$path === '/config/telegram' && $method === 'GET'
|
||||||
=> $this->getTelegramConfig(),
|
=> $this->getTelegramConfig(),
|
||||||
$path === '/config/telegram' && $method === 'PUT'
|
$path === '/config/telegram' && $method === 'PUT'
|
||||||
=> $this->updateTelegramConfig(),
|
=> $this->requireCsrf(fn() => $this->updateTelegramConfig()),
|
||||||
|
|
||||||
|
$path === '/false-positives' && $method === 'GET'
|
||||||
|
=> $this->getFalsePositives(),
|
||||||
|
$path === '/false-positives' && $method === 'POST'
|
||||||
|
=> $this->requireCsrf(fn() => $this->createFalsePositive()),
|
||||||
|
preg_match('#^/false-positives/(\d+)$#', $path, $m) && $method === 'DELETE'
|
||||||
|
=> $this->requireCsrf(fn() => $this->deleteFalsePositive((int) $m[1])),
|
||||||
|
|
||||||
default => throw new \RuntimeException('Not found', 404),
|
default => throw new \RuntimeException('Not found', 404),
|
||||||
};
|
};
|
||||||
@@ -131,12 +162,60 @@ class Router
|
|||||||
echo json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
echo json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function health(): array
|
||||||
|
{
|
||||||
|
$sqliteOk = true;
|
||||||
|
$clickhouseOk = true;
|
||||||
|
$dbSize = 'unknown';
|
||||||
|
try {
|
||||||
|
$this->repo->getAlerts(1);
|
||||||
|
$dbPath = '/app/data/logging.db';
|
||||||
|
if (file_exists($dbPath)) {
|
||||||
|
$bytes = filesize($dbPath);
|
||||||
|
$dbSize = $bytes > 1073741824 ? round($bytes / 1073741824, 1) . ' GB'
|
||||||
|
: ($bytes > 1048576 ? round($bytes / 1048576, 1) . ' MB'
|
||||||
|
: round($bytes / 1024, 1) . ' KB');
|
||||||
|
}
|
||||||
|
} catch (\Throwable) {
|
||||||
|
$sqliteOk = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->repo->clickhouse()->query('SELECT 1');
|
||||||
|
} catch (\Throwable) {
|
||||||
|
$clickhouseOk = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$allOk = $sqliteOk && $clickhouseOk;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'status' => $allOk ? 'ok' : 'degraded',
|
||||||
|
'sqlite' => $sqliteOk ? 'connected' : 'error',
|
||||||
|
'clickhouse' => $clickhouseOk ? 'connected' : 'error',
|
||||||
|
'db_size' => $dbSize,
|
||||||
|
'time' => date('c'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
private function loginUrl(): string
|
private function loginUrl(): string
|
||||||
{
|
{
|
||||||
$redirect = 'https://' . ($_SERVER['HTTP_HOST'] ?? 'localhost') . '/oauth.php';
|
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
|
||||||
|
if (!$this->isTrustedHost($host)) {
|
||||||
|
$host = 'localhost';
|
||||||
|
}
|
||||||
|
$redirect = 'https://' . $host . '/oauth.php';
|
||||||
return 'https://auth.jakach.ch/?send_to=' . urlencode($redirect);
|
return 'https://auth.jakach.ch/?send_to=' . urlencode($redirect);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function isTrustedHost(string $host): bool
|
||||||
|
{
|
||||||
|
$host = strtolower(preg_replace('/:\d+$/', '', $host));
|
||||||
|
return $host === 'localhost'
|
||||||
|
|| $host === '127.0.0.1'
|
||||||
|
|| $host === 'jakach.ch'
|
||||||
|
|| str_ends_with($host, '.jakach.ch');
|
||||||
|
}
|
||||||
|
|
||||||
private function getMe(): array
|
private function getMe(): array
|
||||||
{
|
{
|
||||||
$user = $this->auth->requireAuth();
|
$user = $this->auth->requireAuth();
|
||||||
@@ -168,32 +247,42 @@ class Router
|
|||||||
{
|
{
|
||||||
$body = json_decode(file_get_contents('php://input'), true);
|
$body = json_decode(file_get_contents('php://input'), true);
|
||||||
$type = LogSourceType::from($body['type'] ?? '');
|
$type = LogSourceType::from($body['type'] ?? '');
|
||||||
return $this->repo->createSource(
|
$result = $this->repo->createSource(
|
||||||
name: $body['name'],
|
name: $body['name'],
|
||||||
type: $type,
|
type: $type,
|
||||||
address: $body['address'],
|
address: $body['address'],
|
||||||
labels: $body['labels'] ?? [],
|
labels: $body['labels'] ?? [],
|
||||||
);
|
);
|
||||||
|
$this->repo->logAudit('create', 'source', $result->id, 'Source "' . ($body['name'] ?? '') . '" created', $this->user());
|
||||||
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function createRule(): mixed
|
private function createRule(): mixed
|
||||||
{
|
{
|
||||||
$body = json_decode(file_get_contents('php://input'), true);
|
$body = json_decode(file_get_contents('php://input'), true);
|
||||||
return $this->repo->createRule(
|
$result = $this->repo->createRule(
|
||||||
name: $body['name'],
|
name: $body['name'],
|
||||||
pattern: $body['pattern'],
|
pattern: $body['pattern'],
|
||||||
severity: $body['severity'] ?? 'warning',
|
severity: $body['severity'] ?? 'warning',
|
||||||
rateLimitSeconds: $body['rate_limit_seconds'] ?? null,
|
rateLimitSeconds: $body['rate_limit_seconds'] ?? null,
|
||||||
);
|
);
|
||||||
|
$this->repo->logAudit('create', 'rule', $result->id, 'Rule "' . ($body['name'] ?? '') . '" created', $this->user());
|
||||||
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function deleteEntity(string $type, int $id): array
|
private function deleteEntity(string $type, int $id): array
|
||||||
{
|
{
|
||||||
|
$name = '';
|
||||||
if ($type === 'source') {
|
if ($type === 'source') {
|
||||||
|
$s = $this->repo->getSource($id);
|
||||||
|
$name = $s ? $s->name : '';
|
||||||
$this->repo->deleteSource($id);
|
$this->repo->deleteSource($id);
|
||||||
} else {
|
} else {
|
||||||
|
$r = $this->repo->getRule($id);
|
||||||
|
$name = $r ? $r->name : '';
|
||||||
$this->repo->deleteRule($id);
|
$this->repo->deleteRule($id);
|
||||||
}
|
}
|
||||||
|
$this->repo->logAudit('delete', $type, $id, ucfirst($type) . ' "' . $name . '" deleted', $this->user());
|
||||||
return ['status' => 'deleted', 'id' => $id];
|
return ['status' => 'deleted', 'id' => $id];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,7 +303,7 @@ class Router
|
|||||||
private function updateRule(int $id): mixed
|
private function updateRule(int $id): mixed
|
||||||
{
|
{
|
||||||
$body = json_decode(file_get_contents('php://input'), true);
|
$body = json_decode(file_get_contents('php://input'), true);
|
||||||
return $this->repo->updateRule(
|
$result = $this->repo->updateRule(
|
||||||
id: $id,
|
id: $id,
|
||||||
name: $body['name'],
|
name: $body['name'],
|
||||||
pattern: $body['pattern'],
|
pattern: $body['pattern'],
|
||||||
@@ -222,20 +311,23 @@ class Router
|
|||||||
rateLimitSeconds: $body['rate_limit_seconds'] ?? null,
|
rateLimitSeconds: $body['rate_limit_seconds'] ?? null,
|
||||||
active: $body['active'] ?? true,
|
active: $body['active'] ?? true,
|
||||||
);
|
);
|
||||||
|
$this->repo->logAudit('update', 'rule', $id, 'Rule "' . ($body['name'] ?? '') . '" updated', $this->user());
|
||||||
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function getAlerts(): mixed
|
private function getAlerts(): mixed
|
||||||
{
|
{
|
||||||
$limit = (int) ($_GET['limit'] ?? 100);
|
$limit = $this->boundedInt($_GET['limit'] ?? 100, 1, 500);
|
||||||
$offset = (int) ($_GET['offset'] ?? 0);
|
$offset = $this->boundedInt($_GET['offset'] ?? 0, 0, 100000);
|
||||||
$status = $_GET['status'] ?? null;
|
$status = $_GET['status'] ?? null;
|
||||||
$severity = $_GET['severity'] ?? null;
|
$severity = $_GET['severity'] ?? null;
|
||||||
return $this->repo->getAlerts($limit, $offset, $status, $severity);
|
return $this->repo->getAlerts($limit, $offset, $status, $severity, $_GET['since'] ?? null, $_GET['until'] ?? null);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function ackAlert(int $id): array
|
private function ackAlert(int $id): array
|
||||||
{
|
{
|
||||||
$this->repo->updateAlertStatus($id, AlertStatus::Acknowledged);
|
$this->repo->updateAlertStatus($id, AlertStatus::Acknowledged);
|
||||||
|
$this->repo->logAudit('acknowledge', 'alert', $id, null, $this->user());
|
||||||
return ['status' => 'acknowledged', 'id' => $id];
|
return ['status' => 'acknowledged', 'id' => $id];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,6 +340,7 @@ class Router
|
|||||||
return ['error' => 'Invalid status. Use: open, acknowledged, resolved'];
|
return ['error' => 'Invalid status. Use: open, acknowledged, resolved'];
|
||||||
}
|
}
|
||||||
$this->repo->updateAlertStatus($id, $status);
|
$this->repo->updateAlertStatus($id, $status);
|
||||||
|
$this->repo->logAudit('status_change', 'alert', $id, 'Status set to ' . $status->value, $this->user());
|
||||||
return ['status' => $status->value, 'id' => $id];
|
return ['status' => $status->value, 'id' => $id];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,7 +350,7 @@ class Router
|
|||||||
if (empty($query)) {
|
if (empty($query)) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
$limit = (int) ($_GET['limit'] ?? 100);
|
$limit = $this->boundedInt($_GET['limit'] ?? 100, 1, 500);
|
||||||
return $this->repo->searchAlerts($query, $limit);
|
return $this->repo->searchAlerts($query, $limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,9 +360,9 @@ class Router
|
|||||||
if (empty($query)) {
|
if (empty($query)) {
|
||||||
return ['data' => []];
|
return ['data' => []];
|
||||||
}
|
}
|
||||||
$limit = (int) ($_GET['limit'] ?? 200);
|
$limit = $this->boundedInt($_GET['limit'] ?? 200, 1, 1000);
|
||||||
$offset = (int) ($_GET['offset'] ?? 0);
|
$offset = $this->boundedInt($_GET['offset'] ?? 0, 0, 100000);
|
||||||
return ['data' => $this->repo->searchLogEntries($query, $limit, $offset)];
|
return ['data' => $this->repo->searchLogEntries($query, $limit, $offset, $_GET['since'] ?? null, $_GET['until'] ?? null)];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function updateAllowedTokens(): array
|
private function updateAllowedTokens(): array
|
||||||
@@ -277,6 +370,7 @@ class Router
|
|||||||
$body = json_decode(file_get_contents('php://input'), true);
|
$body = json_decode(file_get_contents('php://input'), true);
|
||||||
$tokens = $body['tokens'] ?? [];
|
$tokens = $body['tokens'] ?? [];
|
||||||
$this->repo->setAllowedUserTokens($tokens);
|
$this->repo->setAllowedUserTokens($tokens);
|
||||||
|
$this->repo->logAudit('update', 'config', null, 'Allowed tokens updated', $this->user());
|
||||||
return ['status' => 'saved', 'tokens' => $this->repo->getAllowedUserTokens()];
|
return ['status' => 'saved', 'tokens' => $this->repo->getAllowedUserTokens()];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,6 +384,10 @@ class Router
|
|||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
return ['error' => 'Missing "line" field'];
|
return ['error' => 'Missing "line" field'];
|
||||||
}
|
}
|
||||||
|
if (strlen($line) > 65535) {
|
||||||
|
http_response_code(413);
|
||||||
|
return ['error' => 'Log line too large'];
|
||||||
|
}
|
||||||
|
|
||||||
$alert = $this->engine->evaluate($line, null);
|
$alert = $this->engine->evaluate($line, null);
|
||||||
|
|
||||||
@@ -302,17 +400,163 @@ class Router
|
|||||||
|
|
||||||
private function getTelegramConfig(): array
|
private function getTelegramConfig(): array
|
||||||
{
|
{
|
||||||
|
$token = $this->repo->getConfig('telegram_bot_token', '');
|
||||||
|
$masked = strlen($token) > 8
|
||||||
|
? substr($token, 0, 8) . '...'
|
||||||
|
: ($token ? substr($token, 0, 3) . '...' : '');
|
||||||
return [
|
return [
|
||||||
'bot_token' => $this->repo->getConfig('telegram_bot_token', ''),
|
'bot_token_masked' => $masked,
|
||||||
|
'bot_token_configured' => $token !== '',
|
||||||
'chat_id' => $this->repo->getConfig('telegram_chat_id', ''),
|
'chat_id' => $this->repo->getConfig('telegram_chat_id', ''),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function updateTelegramConfig(): array
|
private function updateTelegramConfig(): array
|
||||||
{
|
{
|
||||||
$body = json_decode(file_get_contents('php://input'), true);
|
$body = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||||
$this->repo->setConfig('telegram_bot_token', $body['bot_token'] ?? '');
|
if (array_key_exists('bot_token', $body) && trim((string) $body['bot_token']) !== '') {
|
||||||
|
$this->repo->setConfig('telegram_bot_token', trim((string) $body['bot_token']));
|
||||||
|
}
|
||||||
$this->repo->setConfig('telegram_chat_id', $body['chat_id'] ?? '');
|
$this->repo->setConfig('telegram_chat_id', $body['chat_id'] ?? '');
|
||||||
|
$this->repo->logAudit('update', 'config', null, 'Telegram config updated', $this->user());
|
||||||
return $this->getTelegramConfig();
|
return $this->getTelegramConfig();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function createFalsePositive(): array
|
||||||
|
{
|
||||||
|
$body = json_decode(file_get_contents('php://input'), true);
|
||||||
|
$pattern = $body['pattern'] ?? '';
|
||||||
|
$description = $body['description'] ?? '';
|
||||||
|
|
||||||
|
if (empty($pattern)) {
|
||||||
|
http_response_code(400);
|
||||||
|
return ['error' => 'Missing "pattern" field'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->repo->createFalsePositive($pattern, $description);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getFalsePositives(): array
|
||||||
|
{
|
||||||
|
return ['data' => $this->repo->getFalsePositives()];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function deleteFalsePositive(int $id): array
|
||||||
|
{
|
||||||
|
$this->repo->deleteFalsePositive($id);
|
||||||
|
$this->repo->logAudit('delete', 'false_positive', $id, null, $this->user());
|
||||||
|
return ['status' => 'deleted', 'id' => $id];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function csrfToken(): array
|
||||||
|
{
|
||||||
|
$this->auth->requireAuth();
|
||||||
|
return ['csrf_token' => $_SESSION['csrf_token'] ?? ''];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function requireCsrf(callable $fn): mixed
|
||||||
|
{
|
||||||
|
if (!$this->auth->validateCsrf()) {
|
||||||
|
http_response_code(403);
|
||||||
|
return ['error' => 'Invalid or missing CSRF token'];
|
||||||
|
}
|
||||||
|
return $fn();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- New Handlers ---
|
||||||
|
|
||||||
|
private function testRule(): array
|
||||||
|
{
|
||||||
|
$body = json_decode(file_get_contents('php://input'), true);
|
||||||
|
$line = $body['line'] ?? '';
|
||||||
|
if (empty($line)) {
|
||||||
|
http_response_code(400);
|
||||||
|
return ['error' => 'Missing "line" field'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$rules = $this->repo->getActiveRules();
|
||||||
|
$matches = [];
|
||||||
|
foreach ($rules as $rule) {
|
||||||
|
$delimiter = $rule->pattern[0] ?? '/';
|
||||||
|
if (preg_match($rule->pattern, $line)) {
|
||||||
|
$matches[] = [
|
||||||
|
'rule_id' => $rule->id,
|
||||||
|
'rule_name' => $rule->name,
|
||||||
|
'severity' => $rule->severity->value,
|
||||||
|
'is_false_positive' => $this->repo->isFalsePositive($line),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['data' => $matches, 'line' => substr($line, 0, 200)];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function bulkAlertAction(): array
|
||||||
|
{
|
||||||
|
$body = json_decode(file_get_contents('php://input'), true);
|
||||||
|
$ids = $body['ids'] ?? [];
|
||||||
|
$status = AlertStatus::tryFrom($body['status'] ?? '');
|
||||||
|
if (empty($ids) || !$status) {
|
||||||
|
http_response_code(400);
|
||||||
|
return ['error' => 'Missing "ids" array or valid "status"'];
|
||||||
|
}
|
||||||
|
$count = $this->repo->bulkUpdateAlertStatus($ids, $status);
|
||||||
|
$this->repo->logAudit('bulk_status', 'alert', null, count($ids) . ' alerts set to ' . $status->value, $this->user());
|
||||||
|
return ['updated' => $count];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function logContext(int $id): array
|
||||||
|
{
|
||||||
|
$before = (int) ($_GET['before'] ?? 5);
|
||||||
|
$after = (int) ($_GET['after'] ?? 5);
|
||||||
|
return $this->repo->getLogContext($id, min($before, 50), min($after, 50));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function exportAlerts(): array
|
||||||
|
{
|
||||||
|
$body = json_decode(file_get_contents('php://input'), true);
|
||||||
|
$ids = $body['ids'] ?? [];
|
||||||
|
$alerts = $this->repo->exportAlerts($ids);
|
||||||
|
return array_map(fn($a) => $a->toArray(), $alerts);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function exportLogs(): array
|
||||||
|
{
|
||||||
|
$body = json_decode(file_get_contents('php://input'), true);
|
||||||
|
$ids = $body['ids'] ?? [];
|
||||||
|
return $this->repo->exportLogs($ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function runRetention(): array
|
||||||
|
{
|
||||||
|
$body = json_decode(file_get_contents('php://input'), true);
|
||||||
|
$logDays = (int) ($body['log_days'] ?? 14);
|
||||||
|
$alertDays = (int) ($body['alert_days'] ?? 30);
|
||||||
|
$result = $this->repo->purgeOldData($logDays, $alertDays);
|
||||||
|
$this->repo->logAudit('retention_purge', 'system', null, json_encode($result), $this->user());
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getRetentionConfig(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'log_days' => (int) ($this->repo->getConfig('retention_log_days', '7')),
|
||||||
|
'alert_days' => (int) ($this->repo->getConfig('retention_alert_days', '14')),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getAuditLog(): array
|
||||||
|
{
|
||||||
|
$limit = $this->boundedInt($_GET['limit'] ?? 50, 1, 500);
|
||||||
|
return ['data' => $this->repo->getAuditLog($limit)];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function boundedInt(mixed $value, int $min, int $max): int
|
||||||
|
{
|
||||||
|
$value = filter_var($value, FILTER_VALIDATE_INT);
|
||||||
|
if ($value === false) {
|
||||||
|
return $min;
|
||||||
|
}
|
||||||
|
return max($min, min($max, $value));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -16,6 +16,10 @@ class Engine
|
|||||||
|
|
||||||
public function evaluate(string $line, ?LogSource $source = null): ?Alert
|
public function evaluate(string $line, ?LogSource $source = null): ?Alert
|
||||||
{
|
{
|
||||||
|
if ($this->repo->isFalsePositive($line)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
$rules = $this->repo->getActiveRules();
|
$rules = $this->repo->getActiveRules();
|
||||||
|
|
||||||
foreach ($rules as $rule) {
|
foreach ($rules as $rule) {
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Jakach\Logging\Storage;
|
||||||
|
|
||||||
|
class ClickHouseBuffer
|
||||||
|
{
|
||||||
|
private ClickHouseClient $client;
|
||||||
|
private int $maxRows;
|
||||||
|
private int $flushIntervalMs;
|
||||||
|
private array $logBuffer = [];
|
||||||
|
private array $alertBuffer = [];
|
||||||
|
private int $lastFlush;
|
||||||
|
private int $idCounter;
|
||||||
|
|
||||||
|
private const LOG_COLUMNS = ['id', 'line', 'source_id', 'source_name', 'level', 'created_at'];
|
||||||
|
private const ALERT_COLUMNS = ['id', 'rule_id', 'rule_name', 'severity', 'status', 'message', 'raw_line', 'source_id', 'source_name', 'created_at'];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
ClickHouseClient $client,
|
||||||
|
int $maxRows = 1000,
|
||||||
|
int $flushIntervalMs = 100,
|
||||||
|
) {
|
||||||
|
$this->client = $client;
|
||||||
|
$this->maxRows = $maxRows;
|
||||||
|
$this->flushIntervalMs = $flushIntervalMs;
|
||||||
|
$this->lastFlush = hrtime(true);
|
||||||
|
$this->idCounter = $this->loadMaxId();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function loadMaxId(): int
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$rows = $this->client->query("SELECT max(id) as max_id FROM (SELECT max(id) as id FROM log_entries UNION ALL SELECT max(id) as id FROM alerts)");
|
||||||
|
$max = $rows[0]['max_id'] ?? null;
|
||||||
|
return $max ? (int) $max : 0;
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function nextId(): int
|
||||||
|
{
|
||||||
|
return ++$this->idCounter;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function pushLog(string $line, ?int $sourceId, ?string $sourceName, ?string $level): void
|
||||||
|
{
|
||||||
|
$this->logBuffer[] = [
|
||||||
|
'id' => $this->nextId(),
|
||||||
|
'line' => $line,
|
||||||
|
'source_id' => $sourceId,
|
||||||
|
'source_name' => $sourceName,
|
||||||
|
'level' => $level,
|
||||||
|
'created_at' => gmdate('Y-m-d H:i:s'),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (count($this->logBuffer) >= $this->maxRows) {
|
||||||
|
$this->flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function pushAlert(int $ruleId, string $ruleName, string $severity, string $status, string $message, string $rawLine, ?int $sourceId, ?string $sourceName): array
|
||||||
|
{
|
||||||
|
$id = $this->nextId();
|
||||||
|
$createdAt = gmdate('Y-m-d H:i:s');
|
||||||
|
$this->alertBuffer[] = [
|
||||||
|
'id' => $id,
|
||||||
|
'rule_id' => $ruleId,
|
||||||
|
'rule_name' => $ruleName,
|
||||||
|
'severity' => $severity,
|
||||||
|
'status' => $status,
|
||||||
|
'message' => $message,
|
||||||
|
'raw_line' => $rawLine,
|
||||||
|
'source_id' => $sourceId,
|
||||||
|
'source_name' => $sourceName,
|
||||||
|
'created_at' => $createdAt,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (count($this->alertBuffer) >= $this->maxRows) {
|
||||||
|
$this->flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['id' => $id, 'created_at' => $createdAt];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function tick(): void
|
||||||
|
{
|
||||||
|
$elapsed = (hrtime(true) - $this->lastFlush) / 1_000_000;
|
||||||
|
if ($elapsed >= $this->flushIntervalMs && ($this->logBuffer || $this->alertBuffer)) {
|
||||||
|
$this->flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function flush(): void
|
||||||
|
{
|
||||||
|
$this->flushLogs();
|
||||||
|
$this->flushAlerts();
|
||||||
|
$this->lastFlush = hrtime(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function flushLogs(): void
|
||||||
|
{
|
||||||
|
if (empty($this->logBuffer)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$batch = $this->logBuffer;
|
||||||
|
$this->logBuffer = [];
|
||||||
|
try {
|
||||||
|
$this->client->insert('log_entries', self::LOG_COLUMNS, $batch);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
error_log("ClickHouse log insert error: " . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function flushAlerts(): void
|
||||||
|
{
|
||||||
|
if (empty($this->alertBuffer)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$batch = $this->alertBuffer;
|
||||||
|
$this->alertBuffer = [];
|
||||||
|
try {
|
||||||
|
$this->client->insert('alerts', self::ALERT_COLUMNS, $batch);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
error_log("ClickHouse alert insert error: " . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Jakach\Logging\Storage;
|
||||||
|
|
||||||
|
class ClickHouseClient
|
||||||
|
{
|
||||||
|
private string $host;
|
||||||
|
private int $port;
|
||||||
|
private string $database;
|
||||||
|
private string $username;
|
||||||
|
private string $password;
|
||||||
|
private int $timeout;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
string $host = 'clickhouse',
|
||||||
|
int $port = 8123,
|
||||||
|
string $database = 'jakach_logging',
|
||||||
|
string $username = 'default',
|
||||||
|
string $password = '',
|
||||||
|
int $timeout = 5,
|
||||||
|
) {
|
||||||
|
$this->host = $host;
|
||||||
|
$this->port = $port;
|
||||||
|
$this->database = $database;
|
||||||
|
$this->username = $username;
|
||||||
|
$this->password = $password;
|
||||||
|
$this->timeout = $timeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function url(): string
|
||||||
|
{
|
||||||
|
return "http://{$this->host}:{$this->port}/";
|
||||||
|
}
|
||||||
|
|
||||||
|
public function query(string $sql, array $params = []): array
|
||||||
|
{
|
||||||
|
if (!empty($params)) {
|
||||||
|
$sql = $this->formatParams($sql, $params);
|
||||||
|
}
|
||||||
|
|
||||||
|
$ch = curl_init();
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_URL => $this->url() . '?' . http_build_query([
|
||||||
|
'database' => $this->database,
|
||||||
|
'default_format' => 'JSON',
|
||||||
|
]),
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_POSTFIELDS => $sql,
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_TIMEOUT => $this->timeout,
|
||||||
|
CURLOPT_CONNECTTIMEOUT => $this->timeout,
|
||||||
|
CURLOPT_HTTPHEADER => ['Content-Type: text/plain'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($this->username) {
|
||||||
|
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
|
||||||
|
curl_setopt($ch, CURLOPT_USERPWD, $this->username . ':' . $this->password);
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = curl_exec($ch);
|
||||||
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
$error = curl_error($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
if ($error) {
|
||||||
|
throw new \RuntimeException("ClickHouse connection error: $error");
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($httpCode !== 200) {
|
||||||
|
throw new \RuntimeException("ClickHouse error (HTTP $httpCode): $response");
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded = json_decode($response, true);
|
||||||
|
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $decoded['data'] ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function execute(string $sql): void
|
||||||
|
{
|
||||||
|
$ch = curl_init();
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_URL => $this->url() . '?' . http_build_query([
|
||||||
|
'database' => $this->database,
|
||||||
|
]),
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_POSTFIELDS => $sql,
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_TIMEOUT => $this->timeout,
|
||||||
|
CURLOPT_CONNECTTIMEOUT => $this->timeout,
|
||||||
|
CURLOPT_HTTPHEADER => ['Content-Type: text/plain'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($this->username) {
|
||||||
|
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
|
||||||
|
curl_setopt($ch, CURLOPT_USERPWD, $this->username . ':' . $this->password);
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = curl_exec($ch);
|
||||||
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
$error = curl_error($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
if ($error) {
|
||||||
|
throw new \RuntimeException("ClickHouse connection error: $error");
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($httpCode !== 200) {
|
||||||
|
if (str_contains($response, 'already exists')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new \RuntimeException("ClickHouse error (HTTP $httpCode): $response");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function insert(string $table, array $columns, array $rows): void
|
||||||
|
{
|
||||||
|
if (empty($rows)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$escapedRows = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$vals = [];
|
||||||
|
foreach ($columns as $col) {
|
||||||
|
$v = $row[$col] ?? null;
|
||||||
|
if ($v === null) {
|
||||||
|
$vals[] = 'NULL';
|
||||||
|
} elseif (is_int($v) || is_float($v)) {
|
||||||
|
$vals[] = (string) $v;
|
||||||
|
} else {
|
||||||
|
$vals[] = "'" . str_replace(['\\', "'"], ['\\\\', "\\'"], (string) $v) . "'";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$escapedRows[] = '(' . implode(',', $vals) . ')';
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql = sprintf(
|
||||||
|
'INSERT INTO %s (%s) VALUES %s',
|
||||||
|
$table,
|
||||||
|
implode(', ', $columns),
|
||||||
|
implode(', ', $escapedRows)
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->execute($sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function formatParams(string $sql, array $params): string
|
||||||
|
{
|
||||||
|
$parts = explode('?', $sql);
|
||||||
|
$result = $parts[0];
|
||||||
|
for ($i = 0; $i < count($params); $i++) {
|
||||||
|
$v = $params[$i];
|
||||||
|
if ($v === null) {
|
||||||
|
$result .= 'NULL';
|
||||||
|
} elseif (is_int($v) || is_float($v)) {
|
||||||
|
$result .= (string) $v;
|
||||||
|
} else {
|
||||||
|
$result .= "'" . str_replace(["'", "\\"], ["\\'", "\\\\"], (string) $v) . "'";
|
||||||
|
}
|
||||||
|
$result .= $parts[$i + 1] ?? '';
|
||||||
|
}
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function migrate(): void
|
||||||
|
{
|
||||||
|
$ch = curl_init();
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_URL => $this->url() . '?' . http_build_query(['database' => 'default']),
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_POSTFIELDS => "CREATE DATABASE IF NOT EXISTS {$this->database}",
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_TIMEOUT => $this->timeout,
|
||||||
|
CURLOPT_CONNECTTIMEOUT => $this->timeout,
|
||||||
|
CURLOPT_HTTPHEADER => ['Content-Type: text/plain'],
|
||||||
|
]);
|
||||||
|
if ($this->username) {
|
||||||
|
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
|
||||||
|
curl_setopt($ch, CURLOPT_USERPWD, $this->username . ':' . $this->password);
|
||||||
|
}
|
||||||
|
$response = curl_exec($ch);
|
||||||
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
if ($httpCode !== 200 && !str_contains($response, 'already exists')) {
|
||||||
|
throw new \RuntimeException("ClickHouse error (HTTP $httpCode): $response");
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->execute("
|
||||||
|
CREATE TABLE IF NOT EXISTS log_entries (
|
||||||
|
id UInt64,
|
||||||
|
line String,
|
||||||
|
source_id Nullable(Int32),
|
||||||
|
source_name Nullable(String),
|
||||||
|
level Nullable(String),
|
||||||
|
created_at DateTime('UTC')
|
||||||
|
) ENGINE = MergeTree()
|
||||||
|
PARTITION BY toDate(created_at)
|
||||||
|
ORDER BY (created_at, id)
|
||||||
|
TTL toDate(created_at) + INTERVAL 90 DAY DELETE
|
||||||
|
SETTINGS index_granularity = 8192
|
||||||
|
");
|
||||||
|
|
||||||
|
$this->execute("
|
||||||
|
CREATE TABLE IF NOT EXISTS alerts (
|
||||||
|
id UInt64,
|
||||||
|
rule_id Int32,
|
||||||
|
rule_name String,
|
||||||
|
severity String,
|
||||||
|
status String,
|
||||||
|
message String,
|
||||||
|
raw_line String,
|
||||||
|
source_id Nullable(Int32),
|
||||||
|
source_name Nullable(String),
|
||||||
|
created_at DateTime('UTC')
|
||||||
|
) ENGINE = MergeTree()
|
||||||
|
PARTITION BY toDate(created_at)
|
||||||
|
ORDER BY (created_at, id)
|
||||||
|
TTL toDate(created_at) + INTERVAL 365 DAY DELETE
|
||||||
|
SETTINGS index_granularity = 8192
|
||||||
|
");
|
||||||
|
|
||||||
|
$this->query("SELECT count() FROM log_entries");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -156,5 +156,42 @@ $this->pdo->exec("
|
|||||||
INSERT OR IGNORE INTO log_entries_fts(rowid, line, source_name)
|
INSERT OR IGNORE INTO log_entries_fts(rowid, line, source_name)
|
||||||
SELECT id, line, source_name FROM log_entries
|
SELECT id, line, source_name FROM log_entries
|
||||||
");
|
");
|
||||||
|
|
||||||
|
$this->pdo->exec("
|
||||||
|
CREATE TABLE IF NOT EXISTS config (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
)
|
||||||
|
");
|
||||||
|
|
||||||
|
$this->pdo->exec("
|
||||||
|
CREATE TABLE IF NOT EXISTS rate_limiter (
|
||||||
|
rule_id INTEGER NOT NULL,
|
||||||
|
window_start INTEGER NOT NULL,
|
||||||
|
count INTEGER NOT NULL DEFAULT 1,
|
||||||
|
PRIMARY KEY (rule_id, window_start)
|
||||||
|
)
|
||||||
|
");
|
||||||
|
|
||||||
|
$this->pdo->exec("
|
||||||
|
CREATE TABLE IF NOT EXISTS false_positives (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
pattern TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
)
|
||||||
|
");
|
||||||
|
|
||||||
|
$this->pdo->exec("
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_log (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
entity_type TEXT NOT NULL,
|
||||||
|
entity_id INTEGER,
|
||||||
|
details TEXT,
|
||||||
|
username TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
)
|
||||||
|
");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+300
-78
@@ -2,31 +2,58 @@
|
|||||||
|
|
||||||
namespace Jakach\Logging\Storage;
|
namespace Jakach\Logging\Storage;
|
||||||
|
|
||||||
use Jakach\Logging\Model\{LogSource, Rule, Alert, AlertStatus, LogSourceType};
|
use Jakach\Logging\Model\{LogSource, Rule, Alert, AlertSeverity, AlertStatus, LogSourceType};
|
||||||
|
|
||||||
class Repository
|
class Repository
|
||||||
{
|
{
|
||||||
|
private \PDO $pdo;
|
||||||
|
private ClickHouseClient $clickhouse;
|
||||||
|
private ClickHouseBuffer $buffer;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private Database $db,
|
private Database $db,
|
||||||
) {}
|
?ClickHouseClient $clickhouse = null,
|
||||||
|
?ClickHouseBuffer $buffer = null,
|
||||||
|
) {
|
||||||
|
$this->pdo = $db->pdo();
|
||||||
|
$this->clickhouse = $clickhouse ?? new ClickHouseClient();
|
||||||
|
$this->buffer = $buffer ?? new ClickHouseBuffer($this->clickhouse);
|
||||||
|
|
||||||
// --- Log Sources ---
|
$this->clickhouse->migrate();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function clickhouse(): ClickHouseClient
|
||||||
|
{
|
||||||
|
return $this->clickhouse;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function buffer(): ClickHouseBuffer
|
||||||
|
{
|
||||||
|
return $this->buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function flush(): void
|
||||||
|
{
|
||||||
|
$this->buffer->flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Log Sources (SQLite) ---
|
||||||
|
|
||||||
public function getSources(): array
|
public function getSources(): array
|
||||||
{
|
{
|
||||||
$rows = $this->db->pdo()->query("SELECT * FROM log_sources ORDER BY name")->fetchAll();
|
$rows = $this->pdo->query("SELECT * FROM log_sources ORDER BY name")->fetchAll();
|
||||||
return array_map(fn(array $r) => LogSource::fromRow($r), $rows);
|
return array_map(fn(array $r) => LogSource::fromRow($r), $rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getActiveSources(): array
|
public function getActiveSources(): array
|
||||||
{
|
{
|
||||||
$rows = $this->db->pdo()->query("SELECT * FROM log_sources WHERE active = 1 ORDER BY name")->fetchAll();
|
$rows = $this->pdo->query("SELECT * FROM log_sources WHERE active = 1 ORDER BY name")->fetchAll();
|
||||||
return array_map(fn(array $r) => LogSource::fromRow($r), $rows);
|
return array_map(fn(array $r) => LogSource::fromRow($r), $rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getSource(int $id): ?LogSource
|
public function getSource(int $id): ?LogSource
|
||||||
{
|
{
|
||||||
$stmt = $this->db->pdo()->prepare("SELECT * FROM log_sources WHERE id = ?");
|
$stmt = $this->pdo->prepare("SELECT * FROM log_sources WHERE id = ?");
|
||||||
$stmt->execute([$id]);
|
$stmt->execute([$id]);
|
||||||
$row = $stmt->fetch();
|
$row = $stmt->fetch();
|
||||||
return $row ? LogSource::fromRow($row) : null;
|
return $row ? LogSource::fromRow($row) : null;
|
||||||
@@ -34,44 +61,44 @@ class Repository
|
|||||||
|
|
||||||
public function createSource(string $name, LogSourceType $type, string $address, array $labels = []): LogSource
|
public function createSource(string $name, LogSourceType $type, string $address, array $labels = []): LogSource
|
||||||
{
|
{
|
||||||
$stmt = $this->db->pdo()->prepare(
|
$stmt = $this->pdo->prepare(
|
||||||
"INSERT INTO log_sources (name, type, address, labels) VALUES (?, ?, ?, ?)"
|
"INSERT INTO log_sources (name, type, address, labels) VALUES (?, ?, ?, ?)"
|
||||||
);
|
);
|
||||||
$stmt->execute([$name, $type->value, $address, json_encode($labels)]);
|
$stmt->execute([$name, $type->value, $address, json_encode($labels)]);
|
||||||
return $this->getSource((int) $this->db->pdo()->lastInsertId());
|
return $this->getSource((int) $this->pdo->lastInsertId());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function deleteSource(int $id): void
|
public function deleteSource(int $id): void
|
||||||
{
|
{
|
||||||
$this->db->pdo()->prepare("DELETE FROM log_sources WHERE id = ?")->execute([$id]);
|
$this->pdo->prepare("DELETE FROM log_sources WHERE id = ?")->execute([$id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function updateSource(int $id, string $name, LogSourceType $type, string $address, array $labels = [], bool $active = true): LogSource
|
public function updateSource(int $id, string $name, LogSourceType $type, string $address, array $labels = [], bool $active = true): LogSource
|
||||||
{
|
{
|
||||||
$stmt = $this->db->pdo()->prepare(
|
$stmt = $this->pdo->prepare(
|
||||||
"UPDATE log_sources SET name = ?, type = ?, address = ?, labels = ?, active = ? WHERE id = ?"
|
"UPDATE log_sources SET name = ?, type = ?, address = ?, labels = ?, active = ? WHERE id = ?"
|
||||||
);
|
);
|
||||||
$stmt->execute([$name, $type->value, $address, json_encode($labels), (int) $active, $id]);
|
$stmt->execute([$name, $type->value, $address, json_encode($labels), (int) $active, $id]);
|
||||||
return $this->getSource($id);
|
return $this->getSource($id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Rules ---
|
// --- Rules (SQLite) ---
|
||||||
|
|
||||||
public function getRules(): array
|
public function getRules(): array
|
||||||
{
|
{
|
||||||
$rows = $this->db->pdo()->query("SELECT * FROM rules ORDER BY name")->fetchAll();
|
$rows = $this->pdo->query("SELECT * FROM rules ORDER BY name")->fetchAll();
|
||||||
return array_map(fn(array $r) => Rule::fromRow($r), $rows);
|
return array_map(fn(array $r) => Rule::fromRow($r), $rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getActiveRules(): array
|
public function getActiveRules(): array
|
||||||
{
|
{
|
||||||
$rows = $this->db->pdo()->query("SELECT * FROM rules WHERE active = 1 ORDER BY name")->fetchAll();
|
$rows = $this->pdo->query("SELECT * FROM rules WHERE active = 1 ORDER BY name")->fetchAll();
|
||||||
return array_map(fn(array $r) => Rule::fromRow($r), $rows);
|
return array_map(fn(array $r) => Rule::fromRow($r), $rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getRule(int $id): ?Rule
|
public function getRule(int $id): ?Rule
|
||||||
{
|
{
|
||||||
$stmt = $this->db->pdo()->prepare("SELECT * FROM rules WHERE id = ?");
|
$stmt = $this->pdo->prepare("SELECT * FROM rules WHERE id = ?");
|
||||||
$stmt->execute([$id]);
|
$stmt->execute([$id]);
|
||||||
$row = $stmt->fetch();
|
$row = $stmt->fetch();
|
||||||
return $row ? Rule::fromRow($row) : null;
|
return $row ? Rule::fromRow($row) : null;
|
||||||
@@ -79,138 +106,181 @@ class Repository
|
|||||||
|
|
||||||
public function createRule(string $name, string $pattern, string $severity, ?int $rateLimitSeconds = null): Rule
|
public function createRule(string $name, string $pattern, string $severity, ?int $rateLimitSeconds = null): Rule
|
||||||
{
|
{
|
||||||
$stmt = $this->db->pdo()->prepare(
|
$stmt = $this->pdo->prepare(
|
||||||
"INSERT INTO rules (name, pattern, severity, rate_limit_seconds) VALUES (?, ?, ?, ?)"
|
"INSERT INTO rules (name, pattern, severity, rate_limit_seconds) VALUES (?, ?, ?, ?)"
|
||||||
);
|
);
|
||||||
$stmt->execute([$name, $pattern, $severity, $rateLimitSeconds]);
|
$stmt->execute([$name, $pattern, $severity, $rateLimitSeconds]);
|
||||||
return $this->getRule((int) $this->db->pdo()->lastInsertId());
|
return $this->getRule((int) $this->pdo->lastInsertId());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function deleteRule(int $id): void
|
public function deleteRule(int $id): void
|
||||||
{
|
{
|
||||||
$this->db->pdo()->prepare("DELETE FROM rules WHERE id = ?")->execute([$id]);
|
$this->pdo->prepare("DELETE FROM rules WHERE id = ?")->execute([$id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function updateRule(int $id, string $name, string $pattern, string $severity, ?int $rateLimitSeconds = null, bool $active = true): Rule
|
public function updateRule(int $id, string $name, string $pattern, string $severity, ?int $rateLimitSeconds = null, bool $active = true): Rule
|
||||||
{
|
{
|
||||||
$stmt = $this->db->pdo()->prepare(
|
$stmt = $this->pdo->prepare(
|
||||||
"UPDATE rules SET name = ?, pattern = ?, severity = ?, rate_limit_seconds = ?, active = ? WHERE id = ?"
|
"UPDATE rules SET name = ?, pattern = ?, severity = ?, rate_limit_seconds = ?, active = ? WHERE id = ?"
|
||||||
);
|
);
|
||||||
$stmt->execute([$name, $pattern, $severity, $rateLimitSeconds, (int) $active, $id]);
|
$stmt->execute([$name, $pattern, $severity, $rateLimitSeconds, (int) $active, $id]);
|
||||||
return $this->getRule($id);
|
return $this->getRule($id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Alerts ---
|
// --- Alerts (ClickHouse) ---
|
||||||
|
|
||||||
public function createAlert(int $ruleId, string $ruleName, string $severity, string $message, string $rawLine, ?int $sourceId = null, ?string $sourceName = null): Alert
|
public function createAlert(int $ruleId, string $ruleName, string $severity, string $message, string $rawLine, ?int $sourceId = null, ?string $sourceName = null): Alert
|
||||||
{
|
{
|
||||||
$stmt = $this->db->pdo()->prepare(
|
$result = $this->buffer->pushAlert(
|
||||||
"INSERT INTO alerts (rule_id, rule_name, severity, status, message, raw_line, source_id, source_name)
|
ruleId: $ruleId,
|
||||||
VALUES (?, ?, ?, 'open', ?, ?, ?, ?)"
|
ruleName: $ruleName,
|
||||||
|
severity: $severity,
|
||||||
|
status: 'open',
|
||||||
|
message: $message,
|
||||||
|
rawLine: $rawLine,
|
||||||
|
sourceId: $sourceId,
|
||||||
|
sourceName: $sourceName,
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->buffer->flushAlerts();
|
||||||
|
|
||||||
|
$createdAt = new \DateTimeImmutable($result['created_at']);
|
||||||
|
|
||||||
|
return new Alert(
|
||||||
|
id: $result['id'],
|
||||||
|
ruleId: $ruleId,
|
||||||
|
ruleName: $ruleName,
|
||||||
|
severity: AlertSeverity::from($severity),
|
||||||
|
status: AlertStatus::Open,
|
||||||
|
message: $message,
|
||||||
|
rawLine: $rawLine,
|
||||||
|
sourceId: $sourceId,
|
||||||
|
sourceName: $sourceName,
|
||||||
|
createdAt: $createdAt,
|
||||||
);
|
);
|
||||||
$stmt->execute([$ruleId, $ruleName, $severity, $message, $rawLine, $sourceId, $sourceName]);
|
|
||||||
$id = (int) $this->db->pdo()->lastInsertId();
|
|
||||||
return $this->getAlert($id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getAlert(int $id): ?Alert
|
public function getAlert(int $id): ?Alert
|
||||||
{
|
{
|
||||||
$stmt = $this->db->pdo()->prepare("SELECT * FROM alerts WHERE id = ?");
|
$rows = $this->clickhouse->query(
|
||||||
$stmt->execute([$id]);
|
"SELECT * FROM alerts WHERE id = ? LIMIT 1",
|
||||||
$row = $stmt->fetch();
|
[$id]
|
||||||
return $row ? Alert::fromRow($row) : null;
|
);
|
||||||
|
if (empty($rows)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return $this->alertFromRow($rows[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getAlerts(int $limit = 100, int $offset = 0, ?string $status = null, ?string $severity = null): array
|
public function getAlerts(int $limit = 100, int $offset = 0, ?string $status = null, ?string $severity = null, ?string $since = null, ?string $until = null): array
|
||||||
{
|
{
|
||||||
$where = [];
|
$where = [];
|
||||||
$params = [];
|
$params = [];
|
||||||
|
|
||||||
if ($status) {
|
if ($status) {
|
||||||
$where[] = 'status = ?';
|
$where[] = "status = ?";
|
||||||
$params[] = $status;
|
$params[] = $status;
|
||||||
}
|
}
|
||||||
if ($severity) {
|
if ($severity) {
|
||||||
$where[] = 'severity = ?';
|
$where[] = "severity = ?";
|
||||||
$params[] = $severity;
|
$params[] = $severity;
|
||||||
}
|
}
|
||||||
|
if ($since) {
|
||||||
|
$where[] = "created_at >= ?";
|
||||||
|
$params[] = str_replace('T', ' ', $since);
|
||||||
|
}
|
||||||
|
if ($until) {
|
||||||
|
$where[] = "created_at <= ?";
|
||||||
|
$params[] = str_replace('T', ' ', $until);
|
||||||
|
}
|
||||||
|
|
||||||
$sql = "SELECT * FROM alerts";
|
$sql = "SELECT * FROM alerts";
|
||||||
if ($where) {
|
if ($where) {
|
||||||
$sql .= ' WHERE ' . implode(' AND ', $where);
|
$sql .= ' WHERE ' . implode(' AND ', $where);
|
||||||
}
|
}
|
||||||
$sql .= " ORDER BY created_at DESC LIMIT ? OFFSET ?";
|
$sql .= " ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?";
|
||||||
$params[] = $limit;
|
$params[] = $limit;
|
||||||
$params[] = $offset;
|
$params[] = $offset;
|
||||||
|
|
||||||
$stmt = $this->db->pdo()->prepare($sql);
|
$rows = $this->clickhouse->query($sql, $params);
|
||||||
$stmt->execute($params);
|
return array_map(fn(array $r) => $this->alertFromRow($r), $rows);
|
||||||
$rows = $stmt->fetchAll();
|
|
||||||
|
|
||||||
return array_map(fn(array $r) => Alert::fromRow($r), $rows);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function updateAlertStatus(int $id, AlertStatus $status): void
|
public function updateAlertStatus(int $id, AlertStatus $status): void
|
||||||
{
|
{
|
||||||
$stmt = $this->db->pdo()->prepare("UPDATE alerts SET status = ? WHERE id = ?");
|
$this->clickhouse->execute(
|
||||||
$stmt->execute([$status->value, $id]);
|
"ALTER TABLE alerts UPDATE status = '{$status->value}' WHERE id = {$id}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getAlertCounts(): array
|
public function getAlertCounts(): array
|
||||||
{
|
{
|
||||||
return $this->db->pdo()->query(
|
return $this->clickhouse->query(
|
||||||
"SELECT status, severity, COUNT(*) as count FROM alerts GROUP BY status, severity"
|
"SELECT status, severity, count() as count FROM alerts GROUP BY status, severity"
|
||||||
)->fetchAll();
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function searchAlerts(string $query, int $limit = 100): array
|
public function searchAlerts(string $query, int $limit = 100): array
|
||||||
{
|
{
|
||||||
$stmt = $this->db->pdo()->prepare(
|
$like = '%' . str_replace(['%', '_'], ['\%', '\_'], $query) . '%';
|
||||||
"SELECT a.* FROM alerts a
|
$rows = $this->clickhouse->query(
|
||||||
JOIN alerts_fts fts ON a.id = fts.rowid
|
"SELECT * FROM alerts WHERE message ILIKE ? OR raw_line ILIKE ? OR rule_name ILIKE ? ORDER BY created_at DESC LIMIT ?",
|
||||||
WHERE alerts_fts MATCH ?
|
[$like, $like, $like, $limit]
|
||||||
ORDER BY rank
|
|
||||||
LIMIT ?"
|
|
||||||
);
|
);
|
||||||
$stmt->execute([$query, $limit]);
|
return array_map(fn(array $r) => $this->alertFromRow($r), $rows);
|
||||||
$rows = $stmt->fetchAll();
|
|
||||||
return array_map(fn(array $r) => Alert::fromRow($r), $rows);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Log Entries ---
|
// --- Log Entries (ClickHouse) ---
|
||||||
|
|
||||||
public function storeLogEntry(string $line, ?int $sourceId = null, ?string $sourceName = null, ?string $level = null): void
|
public function storeLogEntry(string $line, ?int $sourceId = null, ?string $sourceName = null, ?string $level = null): void
|
||||||
{
|
{
|
||||||
$stmt = $this->db->pdo()->prepare(
|
$this->buffer->pushLog($line, $sourceId, $sourceName, $level);
|
||||||
"INSERT INTO log_entries (line, source_id, source_name, level) VALUES (?, ?, ?, ?)"
|
$this->buffer->flushLogs();
|
||||||
);
|
|
||||||
$stmt->execute([$line, $sourceId, $sourceName, $level]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function searchLogEntries(string $query, int $limit = 200, int $offset = 0): array
|
public function searchLogEntries(string $query, int $limit = 200, int $offset = 0, ?string $since = null, ?string $until = null): array
|
||||||
{
|
{
|
||||||
$query = trim($query);
|
$query = trim($query);
|
||||||
if ($query === '') {
|
if ($query === '') {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$where = [];
|
||||||
|
$params = [];
|
||||||
|
|
||||||
|
if ($since) {
|
||||||
|
$where[] = 'created_at >= ?';
|
||||||
|
$params[] = str_replace('T', ' ', $since);
|
||||||
|
}
|
||||||
|
if ($until) {
|
||||||
|
$where[] = 'created_at <= ?';
|
||||||
|
$params[] = str_replace('T', ' ', $until);
|
||||||
|
}
|
||||||
|
|
||||||
if ($query === '*') {
|
if ($query === '*') {
|
||||||
$stmt = $this->db->pdo()->prepare(
|
$sql = "SELECT * FROM log_entries e";
|
||||||
"SELECT * FROM log_entries ORDER BY created_at DESC LIMIT ? OFFSET ?"
|
if ($where) {
|
||||||
);
|
$sql .= ' WHERE ' . implode(' AND ', $where);
|
||||||
$stmt->execute([$limit, $offset]);
|
}
|
||||||
return $stmt->fetchAll();
|
$sql .= " ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?";
|
||||||
|
$params[] = $limit;
|
||||||
|
$params[] = $offset;
|
||||||
|
return $this->clickhouse->query($sql, $params);
|
||||||
}
|
}
|
||||||
|
|
||||||
$like = $this->toLikePattern($query);
|
$like = $this->toLikePattern($query);
|
||||||
$stmt = $this->db->pdo()->prepare(
|
$where[] = 'line ILIKE ?';
|
||||||
"SELECT e.* FROM log_entries e
|
$params[] = $like;
|
||||||
WHERE e.line LIKE ?
|
|
||||||
ORDER BY e.created_at DESC
|
$sql = "SELECT * FROM log_entries";
|
||||||
LIMIT ? OFFSET ?"
|
if ($where) {
|
||||||
);
|
$sql .= ' WHERE ' . implode(' AND ', $where);
|
||||||
$stmt->execute([$like, $limit, $offset]);
|
}
|
||||||
return $stmt->fetchAll();
|
$sql .= " ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?";
|
||||||
|
$params[] = $limit;
|
||||||
|
$params[] = $offset;
|
||||||
|
|
||||||
|
return $this->clickhouse->query($sql, $params);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function toLikePattern(string $query): string
|
private function toLikePattern(string $query): string
|
||||||
@@ -234,11 +304,11 @@ class Repository
|
|||||||
return implode('%', $likeParts);
|
return implode('%', $likeParts);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Config ---
|
// --- Config (SQLite) ---
|
||||||
|
|
||||||
public function getAllowedUserTokens(): array
|
public function getAllowedUserTokens(): array
|
||||||
{
|
{
|
||||||
$stmt = $this->db->pdo()->prepare("SELECT value FROM config WHERE key = ?");
|
$stmt = $this->pdo->prepare("SELECT value FROM config WHERE key = ?");
|
||||||
$stmt->execute(['allowed_user_tokens']);
|
$stmt->execute(['allowed_user_tokens']);
|
||||||
$row = $stmt->fetch();
|
$row = $stmt->fetch();
|
||||||
if (!$row || empty($row['value'])) {
|
if (!$row || empty($row['value'])) {
|
||||||
@@ -254,7 +324,7 @@ class Repository
|
|||||||
|
|
||||||
public function getConfig(string $key, mixed $default = null): mixed
|
public function getConfig(string $key, mixed $default = null): mixed
|
||||||
{
|
{
|
||||||
$stmt = $this->db->pdo()->prepare("SELECT value FROM config WHERE key = ?");
|
$stmt = $this->pdo->prepare("SELECT value FROM config WHERE key = ?");
|
||||||
$stmt->execute([$key]);
|
$stmt->execute([$key]);
|
||||||
$row = $stmt->fetch();
|
$row = $stmt->fetch();
|
||||||
return $row ? $row['value'] : $default;
|
return $row ? $row['value'] : $default;
|
||||||
@@ -262,27 +332,27 @@ class Repository
|
|||||||
|
|
||||||
public function setConfig(string $key, string $value): void
|
public function setConfig(string $key, string $value): void
|
||||||
{
|
{
|
||||||
$stmt = $this->db->pdo()->prepare(
|
$stmt = $this->pdo->prepare(
|
||||||
"INSERT INTO config (key, value) VALUES (?, ?)
|
"INSERT INTO config (key, value) VALUES (?, ?)
|
||||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value"
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value"
|
||||||
);
|
);
|
||||||
$stmt->execute([$key, $value]);
|
$stmt->execute([$key, $value]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Rate Limiting ---
|
// --- Rate Limiting (SQLite) ---
|
||||||
|
|
||||||
public function checkRateLimit(int $ruleId, int $windowSeconds): bool
|
public function checkRateLimit(int $ruleId, int $windowSeconds): bool
|
||||||
{
|
{
|
||||||
$now = time();
|
$now = time();
|
||||||
$window = intdiv($now, $windowSeconds) * $windowSeconds;
|
$window = intdiv($now, $windowSeconds) * $windowSeconds;
|
||||||
|
|
||||||
$this->db->pdo()->prepare(
|
$this->pdo->prepare(
|
||||||
"INSERT INTO rate_limiter (rule_id, window_start, count)
|
"INSERT INTO rate_limiter (rule_id, window_start, count)
|
||||||
VALUES (?, ?, 1)
|
VALUES (?, ?, 1)
|
||||||
ON CONFLICT(rule_id, window_start) DO UPDATE SET count = count + 1"
|
ON CONFLICT(rule_id, window_start) DO UPDATE SET count = count + 1"
|
||||||
)->execute([$ruleId, $window]);
|
)->execute([$ruleId, $window]);
|
||||||
|
|
||||||
$stmt = $this->db->pdo()->prepare(
|
$stmt = $this->pdo->prepare(
|
||||||
"SELECT count FROM rate_limiter WHERE rule_id = ? AND window_start = ?"
|
"SELECT count FROM rate_limiter WHERE rule_id = ? AND window_start = ?"
|
||||||
);
|
);
|
||||||
$stmt->execute([$ruleId, $window]);
|
$stmt->execute([$ruleId, $window]);
|
||||||
@@ -290,4 +360,156 @@ class Repository
|
|||||||
|
|
||||||
return $row['count'] <= 1;
|
return $row['count'] <= 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- False Positives (SQLite) ---
|
||||||
|
|
||||||
|
public function getFalsePositives(): array
|
||||||
|
{
|
||||||
|
return $this->pdo->query(
|
||||||
|
"SELECT id, pattern, description, created_at FROM false_positives ORDER BY id"
|
||||||
|
)->fetchAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createFalsePositive(string $pattern, string $description = ''): array
|
||||||
|
{
|
||||||
|
$stmt = $this->pdo->prepare(
|
||||||
|
"INSERT INTO false_positives (pattern, description) VALUES (?, ?)"
|
||||||
|
);
|
||||||
|
$stmt->execute([$pattern, $description]);
|
||||||
|
$id = (int) $this->pdo->lastInsertId();
|
||||||
|
return $this->getFalsePositive($id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getFalsePositive(int $id): ?array
|
||||||
|
{
|
||||||
|
$stmt = $this->pdo->prepare(
|
||||||
|
"SELECT id, pattern, description, created_at FROM false_positives WHERE id = ?"
|
||||||
|
);
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
$row = $stmt->fetch();
|
||||||
|
return $row ?: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteFalsePositive(int $id): void
|
||||||
|
{
|
||||||
|
$this->pdo->prepare("DELETE FROM false_positives WHERE id = ?")->execute([$id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isFalsePositive(string $line): bool
|
||||||
|
{
|
||||||
|
$patterns = $this->pdo->query(
|
||||||
|
"SELECT pattern FROM false_positives"
|
||||||
|
)->fetchAll(\PDO::FETCH_COLUMN);
|
||||||
|
|
||||||
|
foreach ($patterns as $pattern) {
|
||||||
|
if (preg_match($pattern, $line)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Audit Log (SQLite) ---
|
||||||
|
|
||||||
|
public function logAudit(string $action, string $entityType, ?int $entityId = null, ?string $details = null, ?string $username = null): void
|
||||||
|
{
|
||||||
|
$stmt = $this->pdo->prepare(
|
||||||
|
"INSERT INTO audit_log (action, entity_type, entity_id, details, username) VALUES (?, ?, ?, ?, ?)"
|
||||||
|
);
|
||||||
|
$stmt->execute([$action, $entityType, $entityId, $details, $username]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getAuditLog(int $limit = 50): array
|
||||||
|
{
|
||||||
|
$stmt = $this->pdo->prepare(
|
||||||
|
"SELECT * FROM audit_log ORDER BY created_at DESC LIMIT ?"
|
||||||
|
);
|
||||||
|
$stmt->execute([$limit]);
|
||||||
|
return $stmt->fetchAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Retention (ClickHouse TTL handles it; but keep purge for SQLite cleanup) ---
|
||||||
|
|
||||||
|
public function purgeOldData(int $logDays = 30, int $alertDays = 90): array
|
||||||
|
{
|
||||||
|
$this->pdo->exec("DELETE FROM rate_limiter WHERE window_start < " . (time() - 86400));
|
||||||
|
return ['log_entries_deleted' => -1, 'alerts_deleted' => -1, 'note' => 'ClickHouse TTL manages retention automatically'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Log Context (ClickHouse) ---
|
||||||
|
|
||||||
|
public function getLogContext(int $id, int $before = 5, int $after = 5): array
|
||||||
|
{
|
||||||
|
$beforeRows = $this->clickhouse->query(
|
||||||
|
"SELECT * FROM log_entries WHERE id < ? ORDER BY id DESC LIMIT ?",
|
||||||
|
[$id, $before]
|
||||||
|
);
|
||||||
|
$beforeRows = array_reverse($beforeRows);
|
||||||
|
|
||||||
|
$currentRows = $this->clickhouse->query(
|
||||||
|
"SELECT * FROM log_entries WHERE id = ? LIMIT 1",
|
||||||
|
[$id]
|
||||||
|
);
|
||||||
|
$currentRow = $currentRows[0] ?? null;
|
||||||
|
|
||||||
|
$afterRows = $this->clickhouse->query(
|
||||||
|
"SELECT * FROM log_entries WHERE id > ? ORDER BY id ASC LIMIT ?",
|
||||||
|
[$id, $after]
|
||||||
|
);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'before' => $beforeRows,
|
||||||
|
'current' => $currentRow,
|
||||||
|
'after' => $afterRows,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Bulk Operations (ClickHouse) ---
|
||||||
|
|
||||||
|
public function bulkUpdateAlertStatus(array $ids, AlertStatus $status): int
|
||||||
|
{
|
||||||
|
if (empty($ids)) return 0;
|
||||||
|
$idList = implode(',', array_map('intval', $ids));
|
||||||
|
$this->clickhouse->execute(
|
||||||
|
"ALTER TABLE alerts UPDATE status = '{$status->value}' WHERE id IN ({$idList})"
|
||||||
|
);
|
||||||
|
return count($ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function exportAlerts(array $ids): array
|
||||||
|
{
|
||||||
|
if (empty($ids)) return [];
|
||||||
|
$idList = implode(',', array_map('intval', $ids));
|
||||||
|
$rows = $this->clickhouse->query(
|
||||||
|
"SELECT * FROM alerts WHERE id IN ({$idList}) ORDER BY id"
|
||||||
|
);
|
||||||
|
return array_map(fn(array $r) => $this->alertFromRow($r), $rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function exportLogs(array $ids): array
|
||||||
|
{
|
||||||
|
if (empty($ids)) return [];
|
||||||
|
$idList = implode(',', array_map('intval', $ids));
|
||||||
|
return $this->clickhouse->query(
|
||||||
|
"SELECT * FROM log_entries WHERE id IN ({$idList}) ORDER BY id"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helpers ---
|
||||||
|
|
||||||
|
private function alertFromRow(array $row): Alert
|
||||||
|
{
|
||||||
|
return new Alert(
|
||||||
|
id: (int) $row['id'],
|
||||||
|
ruleId: (int) $row['rule_id'],
|
||||||
|
ruleName: $row['rule_name'],
|
||||||
|
severity: AlertSeverity::from($row['severity']),
|
||||||
|
status: AlertStatus::from($row['status']),
|
||||||
|
message: $row['message'],
|
||||||
|
rawLine: $row['raw_line'],
|
||||||
|
sourceId: isset($row['source_id']) ? (int) $row['source_id'] : null,
|
||||||
|
sourceName: $row['source_name'] ?? null,
|
||||||
|
createdAt: new \DateTimeImmutable($row['created_at']),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -11,6 +11,12 @@ class FileWatcher
|
|||||||
private array $patterns = [];
|
private array $patterns = [];
|
||||||
private int $checkInterval;
|
private int $checkInterval;
|
||||||
|
|
||||||
|
private const ALLOWED_DIRS = [
|
||||||
|
'/collect',
|
||||||
|
'/var/log',
|
||||||
|
'/app/logs',
|
||||||
|
];
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private \Closure $onLine,
|
private \Closure $onLine,
|
||||||
int $checkInterval = 500000,
|
int $checkInterval = 500000,
|
||||||
@@ -18,6 +24,19 @@ class FileWatcher
|
|||||||
$this->checkInterval = $checkInterval;
|
$this->checkInterval = $checkInterval;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function isPathSafe(string $path): bool
|
||||||
|
{
|
||||||
|
$real = realpath($path);
|
||||||
|
if ($real === false) return false;
|
||||||
|
foreach (self::ALLOWED_DIRS as $dir) {
|
||||||
|
$realDir = realpath($dir);
|
||||||
|
if ($realDir !== false && str_starts_with($real, $realDir)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
public function watch(LogSource $source): void
|
public function watch(LogSource $source): void
|
||||||
{
|
{
|
||||||
if ($source->type !== LogSourceType::File) {
|
if ($source->type !== LogSourceType::File) {
|
||||||
@@ -26,6 +45,11 @@ class FileWatcher
|
|||||||
|
|
||||||
$path = $source->address;
|
$path = $source->address;
|
||||||
|
|
||||||
|
if (!str_starts_with($path, '/')) {
|
||||||
|
fprintf(STDERR, "Rejected relative path: %s (source: %s)\n", $path, $source->name);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (str_contains($path, '*') || str_contains($path, '?')) {
|
if (str_contains($path, '*') || str_contains($path, '?')) {
|
||||||
$dir = dirname($path);
|
$dir = dirname($path);
|
||||||
$pattern = basename($path);
|
$pattern = basename($path);
|
||||||
@@ -48,7 +72,7 @@ class FileWatcher
|
|||||||
|
|
||||||
private function scanPattern(int $sourceId, string $dir, string $pattern): void
|
private function scanPattern(int $sourceId, string $dir, string $pattern): void
|
||||||
{
|
{
|
||||||
if (!is_dir($dir)) {
|
if (!is_dir($dir) || !$this->isPathSafe($dir)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
$files = glob($dir . '/' . $pattern);
|
$files = glob($dir . '/' . $pattern);
|
||||||
@@ -56,6 +80,7 @@ class FileWatcher
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
foreach ($files as $file) {
|
foreach ($files as $file) {
|
||||||
|
if (!$this->isPathSafe($file)) continue;
|
||||||
$key = $sourceId . ':' . $file;
|
$key = $sourceId . ':' . $file;
|
||||||
if (!isset($this->handles[$key])) {
|
if (!isset($this->handles[$key])) {
|
||||||
$this->openFile($key, $file);
|
$this->openFile($key, $file);
|
||||||
@@ -66,6 +91,10 @@ class FileWatcher
|
|||||||
|
|
||||||
private function openFile(string|int $key, string $path): void
|
private function openFile(string|int $key, string $path): void
|
||||||
{
|
{
|
||||||
|
if (!$this->isPathSafe($path)) {
|
||||||
|
fprintf(STDERR, "Rejected unsafe path: %s\n", $path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
$handle = fopen($path, 'r');
|
$handle = fopen($path, 'r');
|
||||||
if (!$handle) {
|
if (!$handle) {
|
||||||
fprintf(STDERR, "Cannot open file: %s\n", $path);
|
fprintf(STDERR, "Cannot open file: %s\n", $path);
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ class Orchestrator
|
|||||||
private TelegramNotifier $telegram;
|
private TelegramNotifier $telegram;
|
||||||
private array $sourceMap = [];
|
private array $sourceMap = [];
|
||||||
private bool $running = true;
|
private bool $running = true;
|
||||||
|
private int $lastCleanup = 0;
|
||||||
|
|
||||||
public function __construct(Repository $repo, Engine $engine)
|
public function __construct(Repository $repo, Engine $engine)
|
||||||
{
|
{
|
||||||
@@ -47,8 +48,21 @@ class Orchestrator
|
|||||||
pcntl_signal_dispatch();
|
pcntl_signal_dispatch();
|
||||||
$this->fileWatcher->tick();
|
$this->fileWatcher->tick();
|
||||||
$this->socketListener->tick();
|
$this->socketListener->tick();
|
||||||
|
$this->repo->buffer()->tick();
|
||||||
|
|
||||||
|
if (time() - $this->lastCleanup > 3600) {
|
||||||
|
$this->lastCleanup = time();
|
||||||
|
try {
|
||||||
|
$result = $this->repo->purgeOldData();
|
||||||
|
fprintf(STDERR, "Retention cleanup: %d log entries, %d alerts purged\n",
|
||||||
|
$result['log_entries_deleted'], $result['alerts_deleted']);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
fprintf(STDERR, "Retention cleanup error: %s\n", $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fprintf(STDERR, "Worker shutting down gracefully...\n");
|
||||||
$this->stop();
|
$this->stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,6 +104,8 @@ class Orchestrator
|
|||||||
{
|
{
|
||||||
$this->fileWatcher->stop();
|
$this->fileWatcher->stop();
|
||||||
$this->socketListener->stop();
|
$this->socketListener->stop();
|
||||||
|
fprintf(STDERR, "Flushing remaining buffers...\n");
|
||||||
|
$this->repo->flush();
|
||||||
fprintf(STDERR, "Worker stopped\n");
|
fprintf(STDERR, "Worker stopped\n");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user