@@ -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 |
|
||||||
@@ -242,4 +238,4 @@ composer serve
|
|||||||
├── FileWatcher.php
|
├── FileWatcher.php
|
||||||
├── Orchestrator.php
|
├── Orchestrator.php
|
||||||
└── SocketListener.php
|
└── SocketListener.php
|
||||||
```
|
```
|
||||||
|
|||||||
+11
-7
@@ -626,6 +626,9 @@ async function api(path, opts = {}) {
|
|||||||
if (opts.body) {
|
if (opts.body) {
|
||||||
headers['Content-Type'] = 'application/json';
|
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) {
|
if (opts.method && opts.method !== 'GET' && !opts.noCsrf && csrfToken) {
|
||||||
headers['X-CSRF-TOKEN'] = csrfToken;
|
headers['X-CSRF-TOKEN'] = csrfToken;
|
||||||
}
|
}
|
||||||
@@ -1044,11 +1047,10 @@ async function loadSettings() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await api('/config/telegram');
|
const res = await api('/config/telegram');
|
||||||
if (res.bot_token) {
|
document.getElementById('telegramBotToken').value = '';
|
||||||
document.getElementById('telegramBotToken').value = res.bot_token;
|
document.getElementById('telegramBotToken').placeholder = res.bot_token_configured
|
||||||
} else {
|
? (res.bot_token_masked || 'Token configured')
|
||||||
document.getElementById('telegramBotToken').placeholder = res.bot_token_masked || 'Enter bot token';
|
: 'Enter bot token';
|
||||||
}
|
|
||||||
document.getElementById('telegramChatId').value = res.chat_id || '';
|
document.getElementById('telegramChatId').value = res.chat_id || '';
|
||||||
} catch (e) { console.error('load telegram error', e); }
|
} catch (e) { console.error('load telegram error', e); }
|
||||||
|
|
||||||
@@ -1090,13 +1092,15 @@ document.getElementById('saveTokensBtn').addEventListener('click', async () => {
|
|||||||
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';
|
||||||
@@ -1445,4 +1449,4 @@ function initApp() {
|
|||||||
checkAuth();
|
checkAuth();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+7
-1
@@ -67,6 +67,12 @@ $repo = new Repository($db);
|
|||||||
$allowedTokens = $repo->getAllowedUserTokens();
|
$allowedTokens = $repo->getAllowedUserTokens();
|
||||||
|
|
||||||
if (empty($allowedTokens)) {
|
if (empty($allowedTokens)) {
|
||||||
|
$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.';
|
||||||
@@ -86,4 +92,4 @@ unset($_SESSION['auth_error']);
|
|||||||
|
|
||||||
$redirect = isSafeRedirect($rawRedirect) ? $rawRedirect : '/';
|
$redirect = isSafeRedirect($rawRedirect) ? $rawRedirect : '/';
|
||||||
header('Location: ' . $redirect);
|
header('Location: ' . $redirect);
|
||||||
exit;
|
exit;
|
||||||
|
|||||||
+41
-13
@@ -39,7 +39,7 @@ class Router
|
|||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$publicPaths = ['/health', '/oauth', '/auth/me', '/auth/logout', '/auth/csrf', '/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 . '/')) {
|
||||||
@@ -63,7 +63,7 @@ class Router
|
|||||||
|
|
||||||
$path === '/auth/csrf' && $method === 'GET' => $this->csrfToken(),
|
$path === '/auth/csrf' && $method === 'GET' => $this->csrfToken(),
|
||||||
|
|
||||||
$path === '/ingest' && $method === 'POST' => $this->ingest(),
|
$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(),
|
||||||
@@ -189,10 +189,23 @@ class Router
|
|||||||
|
|
||||||
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();
|
||||||
@@ -294,8 +307,8 @@ class Router
|
|||||||
|
|
||||||
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, $_GET['since'] ?? null, $_GET['until'] ?? null);
|
return $this->repo->getAlerts($limit, $offset, $status, $severity, $_GET['since'] ?? null, $_GET['until'] ?? null);
|
||||||
@@ -327,7 +340,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,8 +350,8 @@ 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, $_GET['since'] ?? null, $_GET['until'] ?? null)];
|
return ['data' => $this->repo->searchLogEntries($query, $limit, $offset, $_GET['since'] ?? null, $_GET['until'] ?? null)];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -361,6 +374,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);
|
||||||
|
|
||||||
@@ -378,16 +395,18 @@ class Router
|
|||||||
? substr($token, 0, 8) . '...'
|
? substr($token, 0, 8) . '...'
|
||||||
: ($token ? substr($token, 0, 3) . '...' : '');
|
: ($token ? substr($token, 0, 3) . '...' : '');
|
||||||
return [
|
return [
|
||||||
'bot_token' => $token,
|
|
||||||
'bot_token_masked' => $masked,
|
'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());
|
$this->repo->logAudit('update', 'config', null, 'Telegram config updated', $this->user());
|
||||||
return $this->getTelegramConfig();
|
return $this->getTelegramConfig();
|
||||||
@@ -547,7 +566,16 @@ class Router
|
|||||||
|
|
||||||
private function getAuditLog(): array
|
private function getAuditLog(): array
|
||||||
{
|
{
|
||||||
$limit = (int) ($_GET['limit'] ?? 50);
|
$limit = $this->boundedInt($_GET['limit'] ?? 50, 1, 500);
|
||||||
return ['data' => $this->repo->getAuditLog($limit)];
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user