From d3f92c408b4236141062db79974e195aee08a02f Mon Sep 17 00:00:00 2001 From: root Date: Mon, 3 Aug 2026 13:29:40 +0200 Subject: [PATCH 1/4] feat: agent-aware notification links via web-identity push (v4.6.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Notification links (email / Telegram) now include ?agent_id=X so that clicking them activates the correct agent context directly – even after session expiry, because the login-redirect preserves the parameter. - New agent endpoint PUT /settings/web-identity stores the web container's public URL and agent ID in agent_settings (section "web") - MailNotifier and TelegramNotifier read web.web_url / web.web_agent_id from the DB-backed config and append ?agent_id=X to all links - WEB_URL moves from the agent container to the web container (app.web_url in config.json); agents receive the value via push - AgentIdentityPusher pushes identity on web startup, agent create/update/select; BaseController::selectedAgent() auto-switches the session agent from ?agent_id= URL parameters - GET /api/v1/agents response gains web_url field - GET /api/v1/settings returns the new web section (read-only via API) - Tests: WebIdentityEndpointTest (9), NotificationUrlBuilderTest (8), 3 new cases in AgentsApiControllerTest; 288/288 green --- API.md | 23 +- CHANGELOG.md | 35 +++ agent/VERSION | 2 +- agent/agent.php | 5 + agent/bin/send-notification.php | 9 +- agent/src/Config/DbConfig.php | 4 +- .../Settings/WebIdentityEndpoint.php | 104 +++++++++ agent/src/Notification/MailNotifier.php | 74 ++++-- agent/src/Notification/TelegramNotifier.php | 52 +++-- docker/.env.example | 9 +- docker/agent/entrypoint.sh | 4 - docker/docker-compose-full.yml | 4 +- docker/web/entrypoint.sh | 5 + .../Endpoints/WebIdentityEndpointTest.php | 217 ++++++++++++++++++ .../Web/AgentsApiControllerTest.php | 56 +++++ .../Unit/Agent/NotificationUrlBuilderTest.php | 128 +++++++++++ web/VERSION | 2 +- web/config/config.json | 3 + web/index.php | 20 ++ web/src/Api/AgentsApiController.php | 5 +- web/src/Controller/AgentController.php | 32 ++- web/src/Controller/BaseController.php | 12 + web/src/Service/AgentIdentityPusher.php | 132 +++++++++++ 23 files changed, 873 insertions(+), 64 deletions(-) create mode 100644 agent/src/Endpoints/Settings/WebIdentityEndpoint.php create mode 100644 tests/Integration/Endpoints/WebIdentityEndpointTest.php create mode 100644 tests/Unit/Agent/NotificationUrlBuilderTest.php create mode 100644 web/src/Service/AgentIdentityPusher.php diff --git a/API.md b/API.md index 4123b5a..262093a 100644 --- a/API.md +++ b/API.md @@ -648,7 +648,10 @@ Delete a maintenance window. Scope: **`maintenance:write`** Required scope for read: **`settings:read`** Required scope for write: **`settings:write`** -Settings are grouped into sections: `mail`, `telegram`, `influxdb`, `notifications`. +Settings are grouped into sections: `mail`, `telegram`, `influxdb`, `notifications`, `performance_monitor`, `web`. + +The `web` section is **read-only via the API** — it is populated automatically when the web container +pushes its identity to the agent (startup, agent create/update/select). --- @@ -681,7 +684,8 @@ Read all agent settings. "org": "", "bucket": "" }, - "notifications": { + "web": { + "web_agent_id": 3, "web_url": "https://cronmanager.example.com" } } @@ -691,7 +695,7 @@ Read all agent settings. ### GET /api/v1/settings/{section} -Read a single settings section (`mail`, `telegram`, `influxdb`, `notifications`). +Read a single settings section (`mail`, `telegram`, `influxdb`, `notifications`, `web`). **Response 200:** Section object. @@ -701,6 +705,9 @@ Read a single settings section (`mail`, `telegram`, `influxdb`, `notifications`) Update a settings section. Scope: **`settings:write`** +Writable sections: `mail`, `telegram`, `influxdb`, `notifications`, `performance_monitor`. +The `web` section is silently skipped — use the agent identity push instead. + **Request body:** Partial or full section object (only provided keys are updated). **Response 200:** Updated section object. @@ -791,19 +798,24 @@ List all agents visible to this API key. "id": 1, "name": "Default", "description": "Lokaler Agent", - "enabled": true + "enabled": true, + "web_url": "https://cronmanager.example.com" }, { "id": 2, "name": "Remote-Server", "description": "Agent auf server2.example.com", - "enabled": true + "enabled": true, + "web_url": "https://cronmanager.example.com" } ], "count": 2 } ``` +`web_url` is the public base URL of the web container (from `app.web_url` in the web config). +It is the same value for all agents — `null` when not configured. + **Response 401 / 403:** See §6 Error Responses. --- @@ -956,6 +968,7 @@ to 60 seconds of delay before the daemon picks up the entry. | Version | Change | |---|---| +| 4.6.0 | Added `web` section to `GET /api/v1/settings` (read-only, push-managed; contains `web_agent_id` and `web_url`); added `web_url` field to `GET /api/v1/agents` response; `PUT /api/v1/settings` silently ignores the `web` section | | 4.5.0 | Added `notify_on_silence` (bool), `silence_grace_minutes` (int\|null), `last_silence_alert_at` (string\|null, read-only) to job objects; `GET /health` extended with `silent_jobs` (int\|null) and `last_execution_at` (string\|null) | | 4.3.4 | Added `GET /api/v1/audit` endpoint (`audit:read` scope, admin-only); added §15 Audit Log | | 4.2.0 | Added `GET /api/v1/agents` endpoint (`settings:read` scope; respects `agent_ids` restriction; omits sensitive fields) | diff --git a/CHANGELOG.md b/CHANGELOG.md index 9817bc9..50f1e65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,41 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). --- +## [4.6.0] – branch: `feature/agent-web-identity` + +### Added + +- **Agent-aware Notification Links**: E-Mail- und Telegram-Benachrichtigungen enthalten jetzt + `?agent_id=X` im Link, wenn die Web-Identity konfiguriert ist. Klicks landen direkt beim richtigen + Agent – auch nach Session-Ablauf (Login-Redirect bewahrt den Parameter). +- **Web-Identity-Push**: Der Web-Container teilt jedem Agent beim Start sowie beim Anlegen, + Bearbeiten und Auswählen eines Agents seine öffentliche URL und die Agent-ID mit + (`PUT /settings/web-identity`, HMAC-gesichert). Agents speichern beides in `agent_settings` + (Section `web`). +- **`AgentIdentityPusher`** (Web): neuer Service `web/src/Service/AgentIdentityPusher.php` + – kapselt Push-Logik, schluckt Verbindungsfehler und loggt nur eine Warnung. +- **`WEB_URL` im Web-Container**: die Env-Variable wandert vom Agent- in den Web-Container + (`WEB_URL` → `app.web_url` in `config.json`). Agents beziehen die URL künftig ausschließlich + per Push, nicht mehr aus ihrem eigenen `config.json`. +- **`BaseController::selectedAgent()`** prüft jetzt zuerst `$_GET['agent_id']`: Notification-Links + aktivieren direkt den richtigen Agent und schreiben ihn in die Session. +- **REST API – `GET /api/v1/agents`**: neues Feld `web_url` (string|null) in jedem Agent-Objekt. +- **REST API – `GET /api/v1/settings/web`**: gibt die gespeicherte Web-Identity zurück + (`{"web_agent_id": N, "web_url": "..."}`, Scope `settings:read`). + `PUT /api/v1/settings/web` gibt 405 zurück – die Section wird ausschließlich per Push verwaltet. +- **Neue Tests**: `WebIdentityEndpointTest` (Integration), `NotificationUrlBuilderTest` (Unit, + prüft `buildNotificationUrl()` in MailNotifier und TelegramNotifier via Reflection), + drei neue Fälle in `AgentsApiControllerTest` für das `web_url`-Feld. + +### Changed + +- `MailNotifier` und `TelegramNotifier` lesen `web_url` und `web_agent_id` jetzt aus der + DB-gesicherten Section `web` (statt `notifications.web_url`). +- `send-notification.php` nutzt `DbConfig` (statt rohem Noodlehaus-Config) damit die + DB-gesicherten Werte in Hintergrundprozessen verfügbar sind. + +--- + ## [4.5.0] – branch: `feature/silence-detection` ### Added diff --git a/agent/VERSION b/agent/VERSION index a84947d..6016e8a 100644 --- a/agent/VERSION +++ b/agent/VERSION @@ -1 +1 @@ -4.5.0 +4.6.0 diff --git a/agent/agent.php b/agent/agent.php index e59ed9e..59e4fdd 100644 --- a/agent/agent.php +++ b/agent/agent.php @@ -344,6 +344,11 @@ function jsonResponse(int $statusCode, array $data): void // -- Persistent agent settings (notification / integration config in DB) -- + // PUT /settings/web-identity must be registered before the generic /settings + // routes so the router does not mis-match the static path segment. + $webIdentity = new \Cronmanager\Agent\Endpoints\Settings\WebIdentityEndpoint($dbConfig, $logger); + $router->addRoute('PUT', '/settings/web-identity', [$webIdentity, 'handle']); + $settingsEndpoint = new \Cronmanager\Agent\Endpoints\SettingsEndpoint($dbConfig, $logger, $auditLogger); $router->addRoute('GET', '/settings', [$settingsEndpoint, 'handle']); $router->addRoute('PUT', '/settings', [$settingsEndpoint, 'handle']); diff --git a/agent/bin/send-notification.php b/agent/bin/send-notification.php index d587fb8..453eb68 100755 --- a/agent/bin/send-notification.php +++ b/agent/bin/send-notification.php @@ -62,6 +62,8 @@ }); use Cronmanager\Agent\Bootstrap; +use Cronmanager\Agent\Config\DbConfig; +use Cronmanager\Agent\Database\Connection; use Cronmanager\Agent\Notification\MailNotifier; use Cronmanager\Agent\Notification\TelegramNotifier; @@ -100,6 +102,9 @@ $logger = $bootstrap->getLogger(); $config = $bootstrap->getConfig(); + $pdo = Connection::getInstance()->getPdo(); + $dbConfig = new DbConfig($config, $pdo); + $jobId = (int) ($data['job_id'] ?? 0); $description = (string) ($data['description'] ?? ''); $linuxUser = (string) ($data['linux_user'] ?? ''); @@ -110,8 +115,8 @@ $finishedAt= (string) ($data['finished_at'] ?? ''); $target = (string) ($data['target'] ?? ''); - $mailNotifier = new MailNotifier($logger, $config); - $telegramNotifier = new TelegramNotifier($logger, $config); + $mailNotifier = new MailNotifier($logger, $dbConfig); + $telegramNotifier = new TelegramNotifier($logger, $dbConfig); if ($type === 'silence') { $lastStartedAt = isset($data['last_started_at']) && $data['last_started_at'] !== null diff --git a/agent/src/Config/DbConfig.php b/agent/src/Config/DbConfig.php index 6598a10..c048783 100644 --- a/agent/src/Config/DbConfig.php +++ b/agent/src/Config/DbConfig.php @@ -15,7 +15,7 @@ * 1. Key prefix matches a DB-managed section AND a row exists in DB → DB value. * 2. Otherwise → delegate to the wrapped Noodlehaus\Config (config.json). * - * DB-managed sections: mail, telegram, influxdb, notifications. + * DB-managed sections: mail, telegram, influxdb, performance_monitor, web. * Infrastructure keys (agent.*, database.*, logging.*, cron.*) are always * read from config.json because they are needed before the DB is available. * @@ -42,7 +42,7 @@ final class DbConfig implements ConfigInterface // ------------------------------------------------------------------------- /** Sections whose values are stored in and read from the database. */ - private const DB_SECTIONS = ['mail', 'telegram', 'influxdb', 'notifications', 'performance_monitor']; + private const DB_SECTIONS = ['mail', 'telegram', 'influxdb', 'notifications', 'performance_monitor', 'web']; /** * Sensitive fields that are encrypted at rest when AGENT_SETTINGS_KEY is set. diff --git a/agent/src/Endpoints/Settings/WebIdentityEndpoint.php b/agent/src/Endpoints/Settings/WebIdentityEndpoint.php new file mode 100644 index 0000000..e995978 --- /dev/null +++ b/agent/src/Endpoints/Settings/WebIdentityEndpoint.php @@ -0,0 +1,104 @@ + + * @license GNU General Public License version 3 or later + */ + +namespace Cronmanager\Agent\Endpoints\Settings; + +use Cronmanager\Agent\Config\DbConfig; +use Monolog\Logger; + +/** + * Class WebIdentityEndpoint + * + * Persists the web container's identity (agent ID and public URL) into the + * agent_settings table so that notification links can reference the correct agent. + */ +final class WebIdentityEndpoint +{ + // ------------------------------------------------------------------------- + // Constructor + // ------------------------------------------------------------------------- + + /** + * @param DbConfig $dbConfig DB-backed configuration wrapper. + * @param Logger $logger Monolog logger instance. + */ + public function __construct( + private readonly DbConfig $dbConfig, + private readonly Logger $logger, + ) {} + + // ------------------------------------------------------------------------- + // Entry point + // ------------------------------------------------------------------------- + + /** + * Handle PUT /settings/web-identity. + * + * @param array $params Path parameters (unused). + */ + public function handle(array $params = []): void + { + $raw = (string) file_get_contents('php://input'); + $body = json_decode($raw, true); + + if (!is_array($body)) { + jsonResponse(400, ['error' => 'Invalid or missing JSON body', 'code' => 400]); + return; + } + + $webAgentId = isset($body['web_agent_id']) ? (int) $body['web_agent_id'] : 0; + $webUrl = isset($body['web_url']) ? trim((string) $body['web_url']) : ''; + + if ($webAgentId <= 0) { + jsonResponse(400, ['error' => 'web_agent_id must be a positive integer', 'code' => 400]); + return; + } + + if ($webUrl === '') { + jsonResponse(400, ['error' => 'web_url must not be empty', 'code' => 400]); + return; + } + + $this->dbConfig->setSection('web', [ + 'web_agent_id' => $webAgentId, + 'web_url' => rtrim($webUrl, '/'), + ]); + + $this->logger->info('WebIdentityEndpoint: web identity stored', [ + 'web_agent_id' => $webAgentId, + 'web_url' => $webUrl, + ]); + + jsonResponse(200, ['ok' => true]); + } +} diff --git a/agent/src/Notification/MailNotifier.php b/agent/src/Notification/MailNotifier.php index c85594c..cad426b 100644 --- a/agent/src/Notification/MailNotifier.php +++ b/agent/src/Notification/MailNotifier.php @@ -265,7 +265,8 @@ public function sendRecoveryAlert( $fromName = (string) $this->config->get('mail.from_name', 'Cronmanager'); $toAddr = (string) $this->config->get('mail.to', ''); $encryption = (string) $this->config->get('mail.encryption', 'tls'); - $baseUrl = rtrim((string) $this->config->get('notifications.web_url', ''), '/'); + $baseUrl = rtrim((string) $this->config->get('web.web_url', ''), '/'); + $webAgentId = (int) $this->config->get('web.web_agent_id', 0); try { $mail = new \PHPMailer\PHPMailer\PHPMailer(true); @@ -288,9 +289,9 @@ public function sendRecoveryAlert( $e = fn(string $s): string => htmlspecialchars($s, ENT_QUOTES, 'UTF-8'); $mail->Body = $this->buildRecoveryHtmlBody($jobId, $description, $linuxUser, $schedule, - $consecutiveFailures, $startedAt, $finishedAt, $target, $baseUrl, $e); + $consecutiveFailures, $startedAt, $finishedAt, $target, $baseUrl, $webAgentId, $e); $mail->AltBody = $this->buildRecoveryPlainBody($jobId, $description, $linuxUser, $schedule, - $consecutiveFailures, $startedAt, $finishedAt, $target, $baseUrl); + $consecutiveFailures, $startedAt, $finishedAt, $target, $baseUrl, $webAgentId); $mail->isHTML(true); $mail->send(); @@ -335,7 +336,8 @@ public function sendSilenceAlert( $fromName = (string) $this->config->get('mail.from_name', 'Cronmanager'); $toAddr = (string) $this->config->get('mail.to', ''); $encryption = (string) $this->config->get('mail.encryption', 'tls'); - $baseUrl = rtrim((string) $this->config->get('notifications.web_url', ''), '/'); + $baseUrl = rtrim((string) $this->config->get('web.web_url', ''), '/'); + $webAgentId = (int) $this->config->get('web.web_agent_id', 0); try { $mail = new \PHPMailer\PHPMailer\PHPMailer(true); @@ -358,9 +360,9 @@ public function sendSilenceAlert( $e = fn(string $s): string => htmlspecialchars($s, ENT_QUOTES, 'UTF-8'); $mail->Body = $this->buildSilenceHtmlBody($jobId, $description, $schedule, - $lastStartedAt, $expectedLastRun, $silenceSinceMinutes, $target, $baseUrl, $e); + $lastStartedAt, $expectedLastRun, $silenceSinceMinutes, $target, $baseUrl, $webAgentId, $e); $mail->AltBody = $this->buildSilencePlainBody($jobId, $description, $schedule, - $lastStartedAt, $expectedLastRun, $silenceSinceMinutes, $target, $baseUrl); + $lastStartedAt, $expectedLastRun, $silenceSinceMinutes, $target, $baseUrl, $webAgentId); $mail->isHTML(true); $mail->send(); @@ -505,6 +507,28 @@ private function finishedRow(int $exitCode, string $finishedAt, callable $e, boo return sprintf('%s%s', $label, $e($finishedAt)); } + // ------------------------------------------------------------------------- + // Private helpers – URL builder + // ------------------------------------------------------------------------- + + /** + * Build a notification link, appending ?agent_id=X when web_agent_id is set. + * + * @param string $baseUrl Base URL of the web UI (already rtrimmed of '/'). + * @param string $path Path including any query string (e.g. '/crons/42'). + * @param int $agentId Web agent ID; 0 means single-agent, no parameter appended. + * + * @return string Complete URL. + */ + private function buildNotificationUrl(string $baseUrl, string $path, int $agentId): string + { + $url = $baseUrl . $path; + if ($agentId > 0) { + $url .= (str_contains($url, '?') ? '&' : '?') . 'agent_id=' . $agentId; + } + return $url; + } + // ------------------------------------------------------------------------- // Private helpers – message body builders // ------------------------------------------------------------------------- @@ -581,11 +605,12 @@ private function buildPlainBody( $lines[] = ''; } - $webUrl = (string) $this->config->get('notifications.web_url', ''); + $webUrl = rtrim((string) $this->config->get('web.web_url', ''), '/'); + $webAgentId = (int) $this->config->get('web.web_agent_id', 0); if ($webUrl !== '') { $link = $stillRunning - ? rtrim($webUrl, '/') . '/crons/' . $jobId - : rtrim($webUrl, '/') . '/timeline?job_id=' . $jobId . '&target=' . urlencode($target) . '&status=failed&_direct=1'; + ? $this->buildNotificationUrl($webUrl, '/crons/' . $jobId, $webAgentId) + : $this->buildNotificationUrl($webUrl, '/timeline?job_id=' . $jobId . '&target=' . urlencode($target) . '&status=failed&_direct=1', $webAgentId); $lines[] = 'View details: ' . $link; $lines[] = ''; } @@ -662,12 +687,13 @@ private function buildHtmlBody( ], }; - $webUrl = (string) $this->config->get('notifications.web_url', ''); - $linkHtml = ''; + $webUrl = rtrim((string) $this->config->get('web.web_url', ''), '/'); + $webAgentId = (int) $this->config->get('web.web_agent_id', 0); + $linkHtml = ''; if ($webUrl !== '') { $link = $stillRunning - ? rtrim($webUrl, '/') . '/crons/' . $jobId - : rtrim($webUrl, '/') . '/timeline?job_id=' . $jobId . '&target=' . urlencode($target) . '&status=failed&_direct=1'; + ? $this->buildNotificationUrl($webUrl, '/crons/' . $jobId, $webAgentId) + : $this->buildNotificationUrl($webUrl, '/timeline?job_id=' . $jobId . '&target=' . urlencode($target) . '&status=failed&_direct=1', $webAgentId); $linkHtml = sprintf( '

🔗 View in Cronmanager

', $e($link), @@ -731,6 +757,7 @@ private function buildRecoveryPlainBody( string $finishedAt, string $target, string $baseUrl, + int $webAgentId = 0, ): string { $lines = [ 'CRONMANAGER – JOB RECOVERED', @@ -751,7 +778,7 @@ private function buildRecoveryPlainBody( $lines[] = ''; if ($baseUrl !== '') { - $lines[] = sprintf('View in Cronmanager: %s/crons/%d', $baseUrl, $jobId); + $lines[] = sprintf('View in Cronmanager: %s', $this->buildNotificationUrl($baseUrl, '/crons/' . $jobId, $webAgentId)); } return implode("\n", $lines); @@ -766,6 +793,7 @@ private function buildSilencePlainBody( int $silenceSinceMinutes, string $target, string $baseUrl, + int $webAgentId = 0, ): string { $lastStartLine = $lastStartedAt !== null ? sprintf('Last seen : %s', $lastStartedAt) @@ -798,7 +826,7 @@ private function buildSilencePlainBody( $lines[] = ''; if ($baseUrl !== '') { - $lines[] = sprintf('View in Cronmanager: %s/crons/%d', $baseUrl, $jobId); + $lines[] = sprintf('View in Cronmanager: %s', $this->buildNotificationUrl($baseUrl, '/crons/' . $jobId, $webAgentId)); } return implode("\n", $lines); @@ -811,8 +839,9 @@ private function buildSilenceHtmlBody( ?string $lastStartedAt, string $expectedLastRun, int $silenceSinceMinutes, - string $target, - string $baseUrl, + string $target, + string $baseUrl, + int $webAgentId = 0, callable $e, ): string { $targetRow = ($target !== '' && $target !== 'local') @@ -831,9 +860,8 @@ private function buildSilenceHtmlBody( $linkHtml = $baseUrl !== '' ? sprintf( - '

🔗 View in Cronmanager

', - $e($baseUrl), - $jobId + '

🔗 View in Cronmanager

', + $e($this->buildNotificationUrl($baseUrl, '/crons/' . $jobId, $webAgentId)) ) : ''; @@ -884,6 +912,7 @@ private function buildRecoveryHtmlBody( string $finishedAt, string $target, string $baseUrl, + int $webAgentId = 0, callable $e, ): string { $targetRow = ($target !== '' && $target !== 'local') @@ -892,9 +921,8 @@ private function buildRecoveryHtmlBody( $linkHtml = $baseUrl !== '' ? sprintf( - '

🔗 View in Cronmanager

', - $e($baseUrl), - $jobId + '

🔗 View in Cronmanager

', + $e($this->buildNotificationUrl($baseUrl, '/crons/' . $jobId, $webAgentId)) ) : ''; diff --git a/agent/src/Notification/TelegramNotifier.php b/agent/src/Notification/TelegramNotifier.php index b4e62fb..4c0af8f 100644 --- a/agent/src/Notification/TelegramNotifier.php +++ b/agent/src/Notification/TelegramNotifier.php @@ -244,10 +244,11 @@ public function sendRecoveryAlert( return false; } - $botToken = (string) $this->config->get('telegram.bot_token', ''); - $chatId = (string) $this->config->get('telegram.chat_id', ''); - $timeout = (int) $this->config->get('telegram.timeout', 15); - $baseUrl = rtrim((string) $this->config->get('notifications.web_url', ''), '/'); + $botToken = (string) $this->config->get('telegram.bot_token', ''); + $chatId = (string) $this->config->get('telegram.chat_id', ''); + $timeout = (int) $this->config->get('telegram.timeout', 15); + $baseUrl = rtrim((string) $this->config->get('web.web_url', ''), '/'); + $webAgentId = (int) $this->config->get('web.web_agent_id', 0); if ($botToken === '' || $chatId === '') { $this->logger->warning('TelegramNotifier: bot_token or chat_id not configured, skipping recovery alert', [ @@ -261,7 +262,7 @@ public function sendRecoveryAlert( : ''; $linkLine = $baseUrl !== '' - ? sprintf("\n\n🔗 View in Cronmanager", htmlspecialchars($baseUrl, ENT_QUOTES, 'UTF-8'), $jobId) + ? sprintf("\n\n🔗 View in Cronmanager", htmlspecialchars($this->buildNotificationUrl($baseUrl, '/crons/' . $jobId, $webAgentId), ENT_QUOTES, 'UTF-8')) : ''; $text = sprintf( @@ -327,10 +328,11 @@ public function sendSilenceAlert( return false; } - $botToken = (string) $this->config->get('telegram.bot_token', ''); - $chatId = (string) $this->config->get('telegram.chat_id', ''); - $timeout = (int) $this->config->get('telegram.timeout', 15); - $baseUrl = rtrim((string) $this->config->get('notifications.web_url', ''), '/'); + $botToken = (string) $this->config->get('telegram.bot_token', ''); + $chatId = (string) $this->config->get('telegram.chat_id', ''); + $timeout = (int) $this->config->get('telegram.timeout', 15); + $baseUrl = rtrim((string) $this->config->get('web.web_url', ''), '/'); + $webAgentId = (int) $this->config->get('web.web_agent_id', 0); if ($botToken === '' || $chatId === '') { $this->logger->warning('TelegramNotifier: bot_token or chat_id not configured, skipping silence alert', [ @@ -354,7 +356,7 @@ public function sendSilenceAlert( : 'never (job has not run yet)'; $linkLine = $baseUrl !== '' - ? sprintf("\n\n🔗 View in Cronmanager", htmlspecialchars($baseUrl, ENT_QUOTES, 'UTF-8'), $jobId) + ? sprintf("\n\n🔗 View in Cronmanager", htmlspecialchars($this->buildNotificationUrl($baseUrl, '/crons/' . $jobId, $webAgentId), ENT_QUOTES, 'UTF-8')) : ''; $text = sprintf( @@ -462,6 +464,27 @@ public function sendTest(): array // Private helpers // ------------------------------------------------------------------------- + /** + * Build a notification URL with an optional agent_id query parameter. + * + * When $agentId is 0 (single-agent setup or identity not yet pushed), the + * URL is returned unchanged so backward-compatibility is preserved. + * + * @param string $baseUrl The web container's public base URL (no trailing slash). + * @param string $path The path and any existing query string. + * @param int $agentId The agent's web-side ID; 0 means "no parameter". + * + * @return string Complete URL, with ?agent_id=X appended when applicable. + */ + private function buildNotificationUrl(string $baseUrl, string $path, int $agentId): string + { + $url = $baseUrl . $path; + if ($agentId > 0) { + $url .= (str_contains($url, '?') ? '&' : '?') . 'agent_id=' . $agentId; + } + return $url; + } + /** * Build the HTML-formatted Telegram message. * @@ -518,12 +541,13 @@ private function buildMessage( $finishedLabel = ($exitCode === -3 && $stillRunning) ? 'Notified At' : 'Finished'; // Optional link to the UI - $webUrl = (string) $this->config->get('notifications.web_url', ''); - $linkBlock = ''; + $webUrl = rtrim((string) $this->config->get('web.web_url', ''), '/'); + $webAgentId = (int) $this->config->get('web.web_agent_id', 0); + $linkBlock = ''; if ($webUrl !== '') { $link = $stillRunning - ? rtrim($webUrl, '/') . '/crons/' . $jobId - : rtrim($webUrl, '/') . '/timeline?job_id=' . $jobId . '&target=' . urlencode($target) . '&status=failed&_direct=1'; + ? $this->buildNotificationUrl($webUrl, '/crons/' . $jobId, $webAgentId) + : $this->buildNotificationUrl($webUrl, '/timeline?job_id=' . $jobId . '&target=' . urlencode($target) . '&status=failed&_direct=1', $webAgentId); $linkBlock = sprintf("\n\n\u{1F517} View in Cronmanager", $e($link)); } diff --git a/docker/.env.example b/docker/.env.example index 1cb954c..576e81f 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -23,6 +23,11 @@ AGENT_HMAC_SECRET=change-me # ← REQUIRED # ── Web UI port ─────────────────────────────────────────────────────────────── WEB_PORT=8880 +# ── Public URL of this web container (optional) ─────────────────────────────── +# When set, failure/silence/recovery notifications include a ?agent_id=X link +# that routes users directly to the correct agent context after login. +# WEB_URL=https://cronmanager.example.com + # ── Logging ─────────────────────────────────────────────────────────────────── LOG_LEVEL=info LOG_MAX_DAYS=30 @@ -74,10 +79,6 @@ AGENT_TLS_ENABLED=true # TELEGRAM_CHAT_ID=-1001234567890 # TELEGRAM_TIMEOUT=15 -# ── Notification link (optional) ───────────────────────────────────────────── -# When set, failure notifications include a direct link to the relevant UI page. -# WEB_URL=https://cronmanager.example.com - # ── InfluxDB metrics (optional) ────────────────────────────────────────────── # When enabled, every completed execution writes a data point to InfluxDB 2.x. # INFLUXDB_ENABLED=true diff --git a/docker/agent/entrypoint.sh b/docker/agent/entrypoint.sh index 074ecb3..2c82502 100644 --- a/docker/agent/entrypoint.sh +++ b/docker/agent/entrypoint.sh @@ -42,7 +42,6 @@ # TELEGRAM_TIMEOUT 15 # CRON_WRAPPER_SCRIPT /opt/cronmanager/agent/bin/cron-wrapper.sh # AGENT_TLS_ENABLED true -# WEB_URL "" (base URL of the web UI, e.g. https://cronmanager.example.com – appended to alert links) # TLS_CERT_FILE /opt/cronmanager/agent/tls/cert.pem # TLS_KEY_FILE /opt/cronmanager/agent/tls/key.pem # INFLUXDB_ENABLED false @@ -132,9 +131,6 @@ php -r " 'cron' => [ 'wrapper_script' => getenv('CRON_WRAPPER_SCRIPT') ?: '/opt/cronmanager/agent/bin/cron-wrapper.sh', ], - 'notifications' => [ - 'web_url' => getenv('WEB_URL') ?: '', - ], 'influxdb' => [ 'enabled' => filter_var(getenv('INFLUXDB_ENABLED') ?: 'false', FILTER_VALIDATE_BOOLEAN), 'url' => getenv('INFLUXDB_URL') ?: 'http://influxdb:8086', diff --git a/docker/docker-compose-full.yml b/docker/docker-compose-full.yml index 1f72244..95b4e59 100644 --- a/docker/docker-compose-full.yml +++ b/docker/docker-compose-full.yml @@ -89,8 +89,6 @@ services: # TELEGRAM_BOT_TOKEN: "123456789:AABBccDDeeFFggHH..." # TELEGRAM_CHAT_ID: "-1001234567890" # TELEGRAM_TIMEOUT: "15" - # Optional: notification link (base URL of the web UI) - # WEB_URL: "${WEB_URL:-}" # Optional: InfluxDB 2.x metrics INFLUXDB_ENABLED: "${INFLUXDB_ENABLED:-false}" INFLUXDB_URL: "${INFLUXDB_URL:-http://influxdb:8086}" @@ -156,6 +154,8 @@ services: SESSION_NAME: "cronmanager_sess" # Internationalisation I18N_LANGUAGE: "${I18N_LANGUAGE:-en}" + # Optional: public web URL (pushed to agents for agent-aware notification links) + # WEB_URL: "${WEB_URL:-}" # Optional: OIDC / SSO # OIDC_ENABLED: "true" # OIDC_PROVIDER_URL: "https://auth.example.com/application/o/cronmanager/" diff --git a/docker/web/entrypoint.sh b/docker/web/entrypoint.sh index 577144b..3bd66a5 100644 --- a/docker/web/entrypoint.sh +++ b/docker/web/entrypoint.sh @@ -34,6 +34,8 @@ # OIDC_REDIRECT_URI "" # OIDC_SSL_VERIFY true # OIDC_SSL_CA_BUNDLE "" +# WEB_URL "" (public base URL of this web UI, e.g. https://cronmanager.example.com; +# pushed to every agent so notification links include ?agent_id=X) # # @author Christian Schulz # @license GNU General Public License version 3 or later @@ -117,6 +119,9 @@ php -r " 'default_language' => getenv('I18N_LANGUAGE') ?: 'en', 'available' => ['en', 'de'], ], + 'app' => [ + 'web_url' => rtrim(getenv('WEB_URL') ?: '', '/'), + ], 'auth' => [ 'oidc_enabled' => filter_var(getenv('OIDC_ENABLED') ?: 'false', FILTER_VALIDATE_BOOLEAN), 'oidc_provider_url' => getenv('OIDC_PROVIDER_URL') ?: '', diff --git a/tests/Integration/Endpoints/WebIdentityEndpointTest.php b/tests/Integration/Endpoints/WebIdentityEndpointTest.php new file mode 100644 index 0000000..8fd6ddc --- /dev/null +++ b/tests/Integration/Endpoints/WebIdentityEndpointTest.php @@ -0,0 +1,217 @@ + + * @license GNU General Public License version 3 or later + */ + +namespace Tests\Integration\Endpoints; + +use Cronmanager\Agent\Config\DbConfig; +use Cronmanager\Agent\Endpoints\Settings\WebIdentityEndpoint; +use Noodlehaus\Config; +use Noodlehaus\Parser\Json as JsonParser; +use PHPUnit\Framework\Attributes\Test; +use Tests\Integration\Base\AgentEndpointTestCase; +use Tests\Support\PhpInputStream; + +final class WebIdentityEndpointTest extends AgentEndpointTestCase +{ + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private function makeEndpoint(): WebIdentityEndpoint + { + $baseConfig = new Config('{}', new JsonParser(), true); + $dbConfig = new DbConfig($baseConfig, $this->pdo); + + return new WebIdentityEndpoint($dbConfig, $this->createNullLogger()); + } + + /** + * Read a section from agent_settings directly. + * + * @return array|null + */ + private function readSettingsSection(string $section): ?array + { + $stmt = $this->pdo->prepare('SELECT config FROM agent_settings WHERE section = :section'); + $stmt->execute(['section' => $section]); + $row = $stmt->fetchColumn(); + + if ($row === false) { + return null; + } + + $decoded = json_decode((string) $row, true); + + return is_array($decoded) ? $decoded : null; + } + + // ========================================================================= + // Happy path + // ========================================================================= + + #[Test] + public function validPushStoresWebIdentityInDatabase(): void + { + $endpoint = $this->makeEndpoint(); + $this->callHandle($endpoint, [ + 'web_agent_id' => 3, + 'web_url' => 'https://cronmanager.example.com', + ]); + + $this->assertStatus(200); + + $stored = $this->readSettingsSection('web'); + $this->assertNotNull($stored); + $this->assertSame(3, $stored['web_agent_id']); + $this->assertSame('https://cronmanager.example.com', $stored['web_url']); + } + + #[Test] + public function responseContainsOkTrue(): void + { + $endpoint = $this->makeEndpoint(); + $this->callHandle($endpoint, [ + 'web_agent_id' => 1, + 'web_url' => 'https://cm.example.com', + ]); + + $this->assertStatus(200); + $this->assertBodyHas('ok', true); + } + + #[Test] + public function trailingSlashIsStrippedFromUrl(): void + { + $endpoint = $this->makeEndpoint(); + $this->callHandle($endpoint, [ + 'web_agent_id' => 1, + 'web_url' => 'https://cronmanager.example.com/', + ]); + + $this->assertStatus(200); + + $stored = $this->readSettingsSection('web'); + $this->assertSame('https://cronmanager.example.com', $stored['web_url']); + } + + #[Test] + public function subsequentPushOverwritesPreviousIdentity(): void + { + $endpoint = $this->makeEndpoint(); + + $this->callHandle($endpoint, [ + 'web_agent_id' => 1, + 'web_url' => 'https://old.example.com', + ]); + + $this->callHandle($endpoint, [ + 'web_agent_id' => 5, + 'web_url' => 'https://new.example.com', + ]); + + $stored = $this->readSettingsSection('web'); + $this->assertSame(5, $stored['web_agent_id']); + $this->assertSame('https://new.example.com', $stored['web_url']); + } + + // ========================================================================= + // Validation errors + // ========================================================================= + + #[Test] + public function missingBodyReturns400(): void + { + $endpoint = $this->makeEndpoint(); + $this->callHandle($endpoint, []); + + // Empty JSON object {} is valid JSON but has no required fields → 400 + $this->assertStatus(400); + } + + #[Test] + public function missingWebAgentIdReturns400(): void + { + $endpoint = $this->makeEndpoint(); + $this->callHandle($endpoint, ['web_url' => 'https://cronmanager.example.com']); + + $this->assertStatus(400); + $this->assertBodyHasKey('error'); + } + + #[Test] + public function zeroWebAgentIdReturns400(): void + { + $endpoint = $this->makeEndpoint(); + $this->callHandle($endpoint, [ + 'web_agent_id' => 0, + 'web_url' => 'https://cronmanager.example.com', + ]); + + $this->assertStatus(400); + } + + #[Test] + public function negativeWebAgentIdReturns400(): void + { + $endpoint = $this->makeEndpoint(); + $this->callHandle($endpoint, [ + 'web_agent_id' => -1, + 'web_url' => 'https://cronmanager.example.com', + ]); + + $this->assertStatus(400); + } + + #[Test] + public function emptyWebUrlReturns400(): void + { + $endpoint = $this->makeEndpoint(); + $this->callHandle($endpoint, [ + 'web_agent_id' => 1, + 'web_url' => '', + ]); + + $this->assertStatus(400); + $this->assertBodyHasKey('error'); + } + + #[Test] + public function missingWebUrlReturns400(): void + { + $endpoint = $this->makeEndpoint(); + $this->callHandle($endpoint, ['web_agent_id' => 1]); + + $this->assertStatus(400); + } + + #[Test] + public function invalidJsonBodyReturns400(): void + { + PhpInputStream::set('not-valid-json'); + + $baseConfig = new Config('{}', new JsonParser(), true); + $dbConfig = new DbConfig($baseConfig, $this->pdo); + $endpoint = new WebIdentityEndpoint($dbConfig, $this->createNullLogger()); + $endpoint->handle([]); + + PhpInputStream::restore(); + + $this->assertStatus(400); + } +} diff --git a/tests/Integration/Web/AgentsApiControllerTest.php b/tests/Integration/Web/AgentsApiControllerTest.php index 03883c9..0dba1e1 100644 --- a/tests/Integration/Web/AgentsApiControllerTest.php +++ b/tests/Integration/Web/AgentsApiControllerTest.php @@ -322,4 +322,60 @@ public function nullDescriptionIsPreservedInResponse(): void $this->assertSame(200, $r['status']); $this->assertNull($r['body']['data'][0]['description']); } + + // ========================================================================= + // web_url field + // ========================================================================= + + #[Test] + public function responseItemContainsWebUrlField(): void + { + $this->seedAgent(); + $result = $this->seedKey(); + + $r = $this->callIndex($result['plainText']); + + $this->assertSame(200, $r['status']); + $this->assertArrayHasKey('web_url', $r['body']['data'][0]); + } + + #[Test] + public function webUrlIsNullWhenConfigEmpty(): void + { + $this->seedAgent(); + $result = $this->seedKey(); + + // Config is always '{}' in callIndex() → app.web_url is unset → null + $r = $this->callIndex($result['plainText']); + + $this->assertSame(200, $r['status']); + $this->assertNull($r['body']['data'][0]['web_url']); + } + + #[Test] + public function webUrlFromConfigIsReturnedForAllAgents(): void + { + $this->seedAgent(['name' => 'Alpha']); + $this->seedAgent(['name' => 'Beta']); + $result = $this->seedKey(); + + // Inject a config with app.web_url set + $_SERVER['HTTP_AUTHORIZATION'] = 'Bearer ' . $result['plainText']; + http_response_code(200); + + $configJson = json_encode(['app' => ['web_url' => 'https://cronmanager.example.com']]); + $config = new Config((string) $configJson, new JsonParser(), true); + $controller = new AgentsApiController($config, new Logger('test'), $this->pdo); + + ob_start(); + $controller->index([]); + $output = ob_get_clean(); + + $body = json_decode((string) $output, true); + + $this->assertSame(200, (int) http_response_code()); + foreach ($body['data'] as $item) { + $this->assertSame('https://cronmanager.example.com', $item['web_url']); + } + } } diff --git a/tests/Unit/Agent/NotificationUrlBuilderTest.php b/tests/Unit/Agent/NotificationUrlBuilderTest.php new file mode 100644 index 0000000..118144e --- /dev/null +++ b/tests/Unit/Agent/NotificationUrlBuilderTest.php @@ -0,0 +1,128 @@ + + * @license GNU General Public License version 3 or later + */ + +namespace Tests\Unit\Agent; + +use Cronmanager\Agent\Notification\MailNotifier; +use Cronmanager\Agent\Notification\TelegramNotifier; +use Monolog\Logger; +use Noodlehaus\Config; +use Noodlehaus\Parser\Json as JsonParser; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; + +final class NotificationUrlBuilderTest extends TestCase +{ + // ------------------------------------------------------------------------- + // Data providers + // ------------------------------------------------------------------------- + + /** + * @return array + */ + public static function urlCases(): array + { + return [ + 'no agent_id when agentId is 0' => [ + 'https://cm.example.com', + '/crons/42', + 0, + 'https://cm.example.com/crons/42', + ], + 'appends ?agent_id when positive' => [ + 'https://cm.example.com', + '/crons/7', + 3, + 'https://cm.example.com/crons/7?agent_id=3', + ], + 'appends &agent_id when path already has query string' => [ + 'https://cm.example.com', + '/timeline?job_id=5&status=failed&_direct=1', + 2, + 'https://cm.example.com/timeline?job_id=5&status=failed&_direct=1&agent_id=2', + ], + 'no parameter when agentId is 0 and path has query string' => [ + 'https://cm.example.com', + '/timeline?job_id=5', + 0, + 'https://cm.example.com/timeline?job_id=5', + ], + ]; + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private function buildUrl(object $notifier, string $baseUrl, string $path, int $agentId): string + { + $ref = new \ReflectionClass($notifier); + $method = $ref->getMethod('buildNotificationUrl'); + $method->setAccessible(true); + + return (string) $method->invoke($notifier, $baseUrl, $path, $agentId); + } + + private function makeMailNotifier(): MailNotifier + { + $config = new Config('{}', new JsonParser(), true); + + return new MailNotifier(new Logger('test'), $config); + } + + private function makeTelegramNotifier(): TelegramNotifier + { + $config = new Config('{}', new JsonParser(), true); + + return new TelegramNotifier(new Logger('test'), $config); + } + + // ========================================================================= + // MailNotifier + // ========================================================================= + + #[Test] + #[DataProvider('urlCases')] + public function mailNotifierBuildsCorrectUrl( + string $baseUrl, + string $path, + int $agentId, + string $expected, + ): void { + $url = $this->buildUrl($this->makeMailNotifier(), $baseUrl, $path, $agentId); + + $this->assertSame($expected, $url); + } + + // ========================================================================= + // TelegramNotifier + // ========================================================================= + + #[Test] + #[DataProvider('urlCases')] + public function telegramNotifierBuildsCorrectUrl( + string $baseUrl, + string $path, + int $agentId, + string $expected, + ): void { + $url = $this->buildUrl($this->makeTelegramNotifier(), $baseUrl, $path, $agentId); + + $this->assertSame($expected, $url); + } +} diff --git a/web/VERSION b/web/VERSION index a84947d..6016e8a 100644 --- a/web/VERSION +++ b/web/VERSION @@ -1 +1 @@ -4.5.0 +4.6.0 diff --git a/web/config/config.json b/web/config/config.json index 8f59f63..e0592af 100644 --- a/web/config/config.json +++ b/web/config/config.json @@ -27,6 +27,9 @@ "default_language": "en", "available": ["en", "de"] }, + "app": { + "web_url": "" + }, "auth": { "oidc_enabled": false, "oidc_provider_url": "https://auth.example.com/application/o/cronmanager/", diff --git a/web/index.php b/web/index.php index dd17472..3f09455 100644 --- a/web/index.php +++ b/web/index.php @@ -53,6 +53,7 @@ use Cronmanager\Web\Controller\UserController; use Cronmanager\Web\Database\Connection; use Cronmanager\Web\Http\Request; +use Cronmanager\Web\Service\AgentIdentityPusher; use Cronmanager\Web\Http\Response; use Cronmanager\Web\Http\Router; use Cronmanager\Web\Session\SessionManager; @@ -122,6 +123,25 @@ $logger->error('ApiKeySchema::ensure failed', ['message' => $e->getMessage()]); } + // ------------------------------------------------------------------------- + // Agent identity push – once per PHP-FPM worker process lifetime + // Pushes this web container's public URL and each agent's own DB ID so + // that notification links include ?agent_id=X for direct agent selection. + // ------------------------------------------------------------------------- + static $agentIdentityPushed = false; + if (!$agentIdentityPushed) { + $agentIdentityPushed = true; + try { + (new AgentIdentityPusher( + $logger, + Connection::getInstance()->getPdo(), + rtrim((string) $config->get('app.web_url', ''), '/'), + ))->pushToAllAgents(); + } catch (\Throwable $e) { + $logger->warning('AgentIdentityPusher startup push failed', ['message' => $e->getMessage()]); + } + } + // ------------------------------------------------------------------------- // Request // ------------------------------------------------------------------------- diff --git a/web/src/Api/AgentsApiController.php b/web/src/Api/AgentsApiController.php index b7f6aee..0a0ea06 100644 --- a/web/src/Api/AgentsApiController.php +++ b/web/src/Api/AgentsApiController.php @@ -66,11 +66,14 @@ public function index(array $params): void ); } - $data = array_map(static fn(array $a): array => [ + $webUrl = rtrim((string) $this->config->get('app.web_url', ''), '/') ?: null; + + $data = array_map(fn(array $a): array => [ 'id' => (int) $a['id'], 'name' => (string) $a['name'], 'description' => $a['description'] !== null ? (string) $a['description'] : null, 'enabled' => (bool) $a['enabled'], + 'web_url' => $webUrl, ], $agents); $this->jsonOk([ diff --git a/web/src/Controller/AgentController.php b/web/src/Controller/AgentController.php index f54f797..7260752 100644 --- a/web/src/Controller/AgentController.php +++ b/web/src/Controller/AgentController.php @@ -28,6 +28,7 @@ use Cronmanager\Web\Database\Connection; use Cronmanager\Web\Http\Response; use Cronmanager\Web\Repository\AgentRepository; +use Cronmanager\Web\Service\AgentIdentityPusher; use Cronmanager\Web\Session\SessionManager; /** @@ -83,9 +84,17 @@ public function store(array $params = []): void } try { - $pdo = Connection::getInstance()->getPdo(); - $repo = new AgentRepository($pdo); - $repo->create($data); + $pdo = Connection::getInstance()->getPdo(); + $repo = new AgentRepository($pdo); + $newId = $repo->create($data); + $newAgent = $repo->findById($newId); + if ($newAgent !== null) { + (new AgentIdentityPusher( + $this->logger, + $pdo, + rtrim((string) $this->config->get('app.web_url', ''), '/'), + ))->pushToAgent($newAgent); + } } catch (\Throwable $e) { $this->logger->error('AgentController::store: failed', ['message' => $e->getMessage()]); SessionManager::set('_flash_agent_error', 'error_500'); @@ -167,6 +176,14 @@ public function update(array $params): void $pdo = Connection::getInstance()->getPdo(); $repo = new AgentRepository($pdo); $repo->update($id, $data); + $updatedAgent = $repo->findById($id); + if ($updatedAgent !== null) { + (new AgentIdentityPusher( + $this->logger, + $pdo, + rtrim((string) $this->config->get('app.web_url', ''), '/'), + ))->pushToAgent($updatedAgent); + } } catch (\Throwable $e) { $this->logger->error('AgentController::update: failed', [ 'id' => $id, @@ -303,11 +320,16 @@ public function select(array $params = []): void if ($agentId > 0) { try { - $pdo = Connection::getInstance()->getPdo(); - $agent = (new AgentRepository($pdo))->findById($agentId); + $pdo = Connection::getInstance()->getPdo(); + $agent = (new AgentRepository($pdo))->findById($agentId); if ($agent !== null && (bool) $agent['enabled']) { SessionManager::set('selected_agent_id', $agentId); + (new AgentIdentityPusher( + $this->logger, + $pdo, + rtrim((string) $this->config->get('app.web_url', ''), '/'), + ))->pushToAgent($agent); } } catch (\Throwable $e) { $this->logger->warning('AgentController::select: lookup failed', [ diff --git a/web/src/Controller/BaseController.php b/web/src/Controller/BaseController.php index 953ca0c..e1be100 100644 --- a/web/src/Controller/BaseController.php +++ b/web/src/Controller/BaseController.php @@ -243,6 +243,18 @@ protected function selectedAgent(): array $pdo = Connection::getInstance()->getPdo(); $repo = new AgentRepository($pdo); + // Check URL parameter first: notification links carry ?agent_id=X so + // clicking them switches to the correct agent even after session expiry. + $urlAgentId = isset($_GET['agent_id']) ? (int) $_GET['agent_id'] : 0; + if ($urlAgentId > 0) { + $agent = $repo->findById($urlAgentId); + if ($agent !== null && (bool) $agent['enabled']) { + SessionManager::set('selected_agent_id', $urlAgentId); + $this->selectedAgentCache = $agent; + return $agent; + } + } + // Try the session-persisted agent ID first $agentId = (int) SessionManager::get('selected_agent_id', 0); diff --git a/web/src/Service/AgentIdentityPusher.php b/web/src/Service/AgentIdentityPusher.php new file mode 100644 index 0000000..fc675d4 --- /dev/null +++ b/web/src/Service/AgentIdentityPusher.php @@ -0,0 +1,132 @@ + + * @license GNU General Public License version 3 or later + */ + +namespace Cronmanager\Web\Service; + +use Cronmanager\Web\Agent\HostAgentClient; +use Cronmanager\Web\Repository\AgentRepository; +use Monolog\Logger; +use PDO; + +/** + * Class AgentIdentityPusher + * + * Sends PUT /settings/web-identity to one or all enabled agents, storing + * the web container's public URL and the agent's own web-side ID. + */ +final class AgentIdentityPusher +{ + // ------------------------------------------------------------------------- + // Constructor + // ------------------------------------------------------------------------- + + /** + * @param Logger $logger Monolog logger instance. + * @param PDO $pdo Active database connection (used to read agents). + * @param string $webUrl Public base URL of the web UI (no trailing slash). + */ + public function __construct( + private readonly Logger $logger, + private readonly PDO $pdo, + private readonly string $webUrl, + ) {} + + // ------------------------------------------------------------------------- + // Public API + // ------------------------------------------------------------------------- + + /** + * Push web identity to a single agent. + * + * Swallows all exceptions: an unreachable agent must not prevent normal + * operation (the next successful push will repair the stored value). + * + * @param array $agent Agent row from the `agents` table. + * + * @return void + */ + public function pushToAgent(array $agent): void + { + $agentId = (int) ($agent['id'] ?? 0); + if ($agentId <= 0 || $this->webUrl === '') { + return; + } + + try { + $client = new HostAgentClient( + logger: $this->logger, + agentUrl: (string) $agent['url'], + hmacSecret: (string) $agent['hmac_secret'], + userId: 0, + username: '', + timeout: (int) ($agent['timeout'] ?? 10), + sslVerify: (bool) ($agent['ssl_verify'] ?? true), + sslCaBundle: (string)($agent['ssl_ca_bundle'] ?? ''), + ); + + $client->put('/settings/web-identity', [ + 'web_agent_id' => $agentId, + 'web_url' => $this->webUrl, + ]); + + $this->logger->info('AgentIdentityPusher: web identity pushed', [ + 'agent_id' => $agentId, + 'web_url' => $this->webUrl, + ]); + } catch (\Throwable $e) { + $this->logger->warning('AgentIdentityPusher: push failed (agent unreachable?)', [ + 'agent_id' => $agentId, + 'message' => $e->getMessage(), + ]); + } + } + + /** + * Push web identity to all enabled agents. + * + * Errors per agent are logged but do not abort the remaining pushes. + * + * @return void + */ + public function pushToAllAgents(): void + { + if ($this->webUrl === '') { + $this->logger->debug('AgentIdentityPusher: WEB_URL not configured, skipping push'); + return; + } + + try { + $agents = (new AgentRepository($this->pdo))->findEnabled(); + } catch (\Throwable $e) { + $this->logger->warning('AgentIdentityPusher: could not load agents', [ + 'message' => $e->getMessage(), + ]); + return; + } + + foreach ($agents as $agent) { + $this->pushToAgent($agent); + } + } +} From 7f11992882d7909e43dcb6a94ac0349520019c3b Mon Sep 17 00:00:00 2001 From: root Date: Mon, 3 Aug 2026 13:31:35 +0200 Subject: [PATCH 2/4] chore: update guzzlehttp/guzzle to 7.15.2 Fixes 6 security advisories reported 2026-07-20 (all affecting <7.15.1): cookie disclosure/injection, URI fragment leaks in Referer, unbounded response cookies (DoS), host-only cookie scope bypass, Proxy-Authorization header leakage. The constraint ^7.8 in composer.json already allows this version; only composer.lock updated. --- composer.lock | 60 +++++++++++++++++++++++++-------------------------- 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/composer.lock b/composer.lock index 05a10b7..3fc9ead 100644 --- a/composer.lock +++ b/composer.lock @@ -72,26 +72,26 @@ }, { "name": "guzzlehttp/guzzle", - "version": "7.12.1", + "version": "7.15.2", "source": { "type": "git", "url": "https://github.com/guzzle/guzzle.git", - "reference": "d34627490fbc03bf5c5d7cfed81f2faa19519425" + "reference": "744101956d78b7c1384d0cbf379db13e859167bf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/d34627490fbc03bf5c5d7cfed81f2faa19519425", - "reference": "d34627490fbc03bf5c5d7cfed81f2faa19519425", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/744101956d78b7c1384d0cbf379db13e859167bf", + "reference": "744101956d78b7c1384d0cbf379db13e859167bf", "shasum": "" }, "require": { "ext-json": "*", - "guzzlehttp/promises": "^2.5", - "guzzlehttp/psr7": "^2.12.1", + "guzzlehttp/promises": "^2.5.1", + "guzzlehttp/psr7": "^2.13", "php": "^7.2.5 || ^8.0", "psr/http-client": "^1.0", "symfony/deprecation-contracts": "^2.5 || ^3.0", - "symfony/polyfill-php80": "^1.24" + "symfony/polyfill-php80": "^1.25" }, "provide": { "psr/http-client-implementation": "1.0" @@ -99,8 +99,8 @@ "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", "ext-curl": "*", - "guzzle/client-integration-tests": "3.0.2", - "guzzlehttp/test-server": "^0.5.1", + "guzzle/client-integration-tests": "3.0.3", + "guzzlehttp/test-server": "^0.7", "php-http/message-factory": "^1.1", "phpunit/phpunit": "^8.5.52 || ^9.6.34", "psr/log": "^1.1 || ^2.0 || ^3.0" @@ -180,7 +180,7 @@ ], "support": { "issues": "https://github.com/guzzle/guzzle/issues", - "source": "https://github.com/guzzle/guzzle/tree/7.12.1" + "source": "https://github.com/guzzle/guzzle/tree/7.15.2" }, "funding": [ { @@ -196,20 +196,20 @@ "type": "tidelift" } ], - "time": "2026-06-18T14:12:49+00:00" + "time": "2026-07-26T23:23:20+00:00" }, { "name": "guzzlehttp/promises", - "version": "2.5.0", + "version": "2.5.1", "source": { "type": "git", "url": "https://github.com/guzzle/promises.git", - "reference": "4360e982f87f5f258bf872d094647791db2f4c8e" + "reference": "9ad1e4fc607446a055b95870c7f668e93b5cff29" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/promises/zipball/4360e982f87f5f258bf872d094647791db2f4c8e", - "reference": "4360e982f87f5f258bf872d094647791db2f4c8e", + "url": "https://api.github.com/repos/guzzle/promises/zipball/9ad1e4fc607446a055b95870c7f668e93b5cff29", + "reference": "9ad1e4fc607446a055b95870c7f668e93b5cff29", "shasum": "" }, "require": { @@ -264,7 +264,7 @@ ], "support": { "issues": "https://github.com/guzzle/promises/issues", - "source": "https://github.com/guzzle/promises/tree/2.5.0" + "source": "https://github.com/guzzle/promises/tree/2.5.1" }, "funding": [ { @@ -280,20 +280,20 @@ "type": "tidelift" } ], - "time": "2026-06-02T12:23:43+00:00" + "time": "2026-07-08T15:48:39+00:00" }, { "name": "guzzlehttp/psr7", - "version": "2.12.1", + "version": "2.13.0", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "172ef2f4e9824c1e058b7f30be8ae25a02c0f2b7" + "reference": "dad89620b7a6edb60c15858442eb2e408b45d8f4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/172ef2f4e9824c1e058b7f30be8ae25a02c0f2b7", - "reference": "172ef2f4e9824c1e058b7f30be8ae25a02c0f2b7", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/dad89620b7a6edb60c15858442eb2e408b45d8f4", + "reference": "dad89620b7a6edb60c15858442eb2e408b45d8f4", "shasum": "" }, "require": { @@ -302,7 +302,7 @@ "psr/http-message": "^1.1 || ^2.0", "ralouphie/getallheaders": "^3.0", "symfony/deprecation-contracts": "^2.5 || ^3.0", - "symfony/polyfill-php80": "^1.24" + "symfony/polyfill-php80": "^1.25" }, "provide": { "psr/http-factory-implementation": "1.0", @@ -383,7 +383,7 @@ ], "support": { "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.12.1" + "source": "https://github.com/guzzle/psr7/tree/2.13.0" }, "funding": [ { @@ -399,7 +399,7 @@ "type": "tidelift" } ], - "time": "2026-06-18T09:49:37+00:00" + "time": "2026-07-16T22:23:49+00:00" }, { "name": "hassankhan/config", @@ -959,16 +959,16 @@ }, { "name": "symfony/deprecation-contracts", - "version": "v3.7.0", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b" + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b", - "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", "shasum": "" }, "require": { @@ -1006,7 +1006,7 @@ "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0" + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1" }, "funding": [ { @@ -1026,7 +1026,7 @@ "type": "tidelift" } ], - "time": "2026-04-13T15:52:40+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/polyfill-php80", From efe66ee38afadfb69739dabe77eb502cefb6fbf9 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 3 Aug 2026 14:20:50 +0200 Subject: [PATCH 3/4] fix: agent_id in all API responses and UI links (v4.6.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Job IDs are only unique per agent, not globally. Without agent_id context, shared links and API consumers cannot reliably identify which agent a job belongs to. - REST API: every agent-specific endpoint (jobs, maintenance, export/json, audit, settings, timeline, tags) now includes "agent_id" as the first field in its response. BaseApiController stores resolvedAgentId in agentClient() and controllers inject it via array_merge. - UI links: all internal links in layout, cron list/detail/form/ monitor/import/transfer, dashboard, timeline, audit, maintenance list/form, housekeeping and error templates now carry ?agent_id=X when an agent is active. Controller redirects use new BaseController::agentPath() helper. - Pagination and filter-reset links preserve agent_id via $agParam prefix pattern in list templates. - API.md: §5 documents the agent_id field; all response examples updated; §19 changelog entry for v4.6.1. - CHANGELOG.md: v4.6.1 entry added. --- API.md | 29 ++++++++++++++++------ CHANGELOG.md | 22 +++++++++++++++++ agent/VERSION | 2 +- web/VERSION | 2 +- web/src/Api/AuditApiController.php | 2 +- web/src/Api/BaseApiController.php | 14 +++++++++++ web/src/Api/ExportApiController.php | 2 +- web/src/Api/JobsApiController.php | 20 +++++++-------- web/src/Api/MaintenanceApiController.php | 10 ++++---- web/src/Api/SettingsApiController.php | 10 +++++--- web/src/Controller/BaseController.php | 31 ++++++++++++++++++++++++ web/src/Controller/CronController.php | 18 +++++++------- web/src/Controller/TargetController.php | 2 +- web/templates/audit/list.php | 8 ++++-- web/templates/cron/detail.php | 13 +++++++--- web/templates/cron/form.php | 5 +++- web/templates/cron/import.php | 5 +++- web/templates/cron/list.php | 17 ++++++++----- web/templates/cron/monitor.php | 5 +++- web/templates/cron/transfer.php | 7 ++++-- web/templates/dashboard.php | 6 ++++- web/templates/error.php | 5 +++- web/templates/housekeeping/index.php | 5 +++- web/templates/layout.php | 26 ++++++++++++-------- web/templates/maintenance/form.php | 7 ++++-- web/templates/maintenance/index.php | 5 +++- web/templates/maintenance/list.php | 7 ++++-- web/templates/swimlane.php | 3 +++ web/templates/timeline.php | 31 ++++++++++++++---------- 29 files changed, 231 insertions(+), 88 deletions(-) diff --git a/API.md b/API.md index 262093a..60dacf0 100644 --- a/API.md +++ b/API.md @@ -167,6 +167,7 @@ Paginated response envelope: ```json { + "agent_id": 1, "data": [ ... ], "count": 42, "limit": 100, @@ -174,6 +175,14 @@ Paginated response envelope: } ``` +### `agent_id` field (since v4.6.1) + +Every response from an agent-specific endpoint includes `"agent_id"` as the **first field**. +This resolves the ambiguity that job IDs are only unique per agent — in multi-agent setups, +the same numeric ID may refer to different jobs on different agents. + +Endpoints that are not agent-specific (`GET /api/v1/agents`) do not carry `agent_id`. + --- ## 6. Error Responses @@ -306,6 +315,7 @@ List all cron jobs. ```json { + "agent_id": 1, "data": [ { "id": 1, @@ -348,7 +358,7 @@ List all cron jobs. Get a single cron job by ID. -**Response 200:** Single job object (same structure as in the list, without envelope). +**Response 200:** Single job object (same structure as in the list, without envelope, but with `agent_id` as the first field). **Response 404:** @@ -414,7 +424,7 @@ Delete a cron job and remove it from the crontab. Scope: **`jobs:write`** **Response 200:** ```json -{ "success": true } +{ "agent_id": 1, "success": true } ``` --- @@ -426,7 +436,7 @@ Trigger an immediate one-time execution of the job. Scope: **`jobs:execute`** **Response 200:** ```json -{ "success": true, "message": "Job queued for immediate execution." } +{ "agent_id": 1, "success": true, "message": "Job queued for immediate execution." } ``` --- @@ -438,7 +448,7 @@ Kill a running execution by its execution log ID. Scope: **`jobs:execute`** **Response 200:** ```json -{ "success": true } +{ "agent_id": 1, "success": true } ``` --- @@ -500,6 +510,7 @@ List all tags. ```json { + "agent_id": 1, "data": [ { "id": 1, "name": "backup" }, { "id": 2, "name": "monitoring" } @@ -576,6 +587,7 @@ List all maintenance windows. ```json { + "agent_id": 1, "data": [ { "id": 1, @@ -597,7 +609,7 @@ List all maintenance windows. Get a single maintenance window. -**Response 200:** Single window object. +**Response 200:** Single window object (with `agent_id` as the first field). --- @@ -638,7 +650,7 @@ Delete a maintenance window. Scope: **`maintenance:write`** **Response 200:** ```json -{ "success": true } +{ "agent_id": 1, "success": true } ``` --- @@ -721,7 +733,7 @@ Resync crontab from database. Scope: **`settings:write`** **Response 200:** ```json -{ "success": true, "message": "Crontab resynced." } +{ "agent_id": 1, "success": true, "message": "Crontab resynced." } ``` --- @@ -749,6 +761,7 @@ Execution history across all jobs. ```json { + "agent_id": 1, "data": [ { "execution_id": 101, @@ -849,6 +862,7 @@ Return a paginated list of audit log entries with optional filters. ```json { + "agent_id": 1, "data": [ { "id": 42, @@ -968,6 +982,7 @@ to 60 seconds of delay before the daemon picks up the entry. | Version | Change | |---|---| +| 4.6.1 | Every agent-specific endpoint now includes `"agent_id"` as the first field in its response (jobs, maintenance, export/json, audit, settings, timeline, tags). Resolves ambiguity in multi-agent setups where the same numeric job ID may refer to different jobs on different agents. UI links (notifications, breadcrumbs, filter resets, pagination) now carry `?agent_id=X` throughout. | | 4.6.0 | Added `web` section to `GET /api/v1/settings` (read-only, push-managed; contains `web_agent_id` and `web_url`); added `web_url` field to `GET /api/v1/agents` response; `PUT /api/v1/settings` silently ignores the `web` section | | 4.5.0 | Added `notify_on_silence` (bool), `silence_grace_minutes` (int\|null), `last_silence_alert_at` (string\|null, read-only) to job objects; `GET /health` extended with `silent_jobs` (int\|null) and `last_execution_at` (string\|null) | | 4.3.4 | Added `GET /api/v1/audit` endpoint (`audit:read` scope, admin-only); added §15 Audit Log | diff --git a/CHANGELOG.md b/CHANGELOG.md index 50f1e65..4719364 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,28 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). --- +## [4.6.1] – branch: `feature/agent-web-identity` + +### Added + +- **`agent_id` in allen API-Antworten**: Jeder agent-spezifische Endpunkt enthält `"agent_id"` als + erstes Feld in seiner Antwort (Jobs, Maintenance Windows, Export/JSON, Audit, Settings, Timeline, + Tags). Im Multi-Agent-Betrieb sind Job-IDs nur pro Agent eindeutig; das neue Feld verhindert + Ambiguitäten beim programmatischen Auswerten von Responses. + +### Fixed + +- **UI-Links tragen `?agent_id=X`**: Alle internen Links (Sidebar-Navigation, Breadcrumbs, + Paginierung, Filter-Reset, Job-Detail, Monitor, Edit, Kopieren, Import, Export, Audit, Timeline, + Maintenance-Windows, Dashboard-Filter) enthalten jetzt `?agent_id=X`, wenn ein Agent aktiv ist. + Zuvor fehlte der Parameter, sodass geteilte Links oder E-Mail-/Telegram-Benachrichtigungen + nach dem Login beim falschen Agent landeten. +- **Weiterleitungen nach POST-Aktionen**: Alle Controller-Redirects nach Formulareingaben + (`store`, `update`, `destroy`, `bulkAction`, `executeNow`, `importStore`) nutzen + `agentPath()` und erhalten `?agent_id=X`. + +--- + ## [4.6.0] – branch: `feature/agent-web-identity` ### Added diff --git a/agent/VERSION b/agent/VERSION index 6016e8a..8ac28bf 100644 --- a/agent/VERSION +++ b/agent/VERSION @@ -1 +1 @@ -4.6.0 +4.6.1 diff --git a/web/VERSION b/web/VERSION index 6016e8a..8ac28bf 100644 --- a/web/VERSION +++ b/web/VERSION @@ -1 +1 @@ -4.6.0 +4.6.1 diff --git a/web/src/Api/AuditApiController.php b/web/src/Api/AuditApiController.php index eafb4ad..dc15211 100644 --- a/web/src/Api/AuditApiController.php +++ b/web/src/Api/AuditApiController.php @@ -77,6 +77,6 @@ public function index(array $params): void return; } - $this->jsonOk($response); + $this->jsonOk(array_merge(['agent_id' => $this->resolvedAgentId], $response)); } } diff --git a/web/src/Api/BaseApiController.php b/web/src/Api/BaseApiController.php index 77dd996..89fbf97 100644 --- a/web/src/Api/BaseApiController.php +++ b/web/src/Api/BaseApiController.php @@ -42,6 +42,18 @@ abstract class BaseApiController protected const DEFAULT_LIMIT = 100; protected const MAX_LIMIT = 500; + // ------------------------------------------------------------------------- + // Resolved agent context + // ------------------------------------------------------------------------- + + /** + * The DB id of the agent resolved by the most recent agentClient() call. + * Injected into every agent-specific response so API consumers can + * unambiguously identify which agent a job/execution/window belongs to + * (job IDs are only unique per agent, not globally). + */ + protected int $resolvedAgentId = 0; + // ------------------------------------------------------------------------- // Constructor // ------------------------------------------------------------------------- @@ -181,6 +193,8 @@ protected function agentClient(ApiKey $apiKey): ?HostAgentClient return null; } + $this->resolvedAgentId = (int) $agentRow['id']; + return new HostAgentClient( logger: $this->logger, agentUrl: (string) $agentRow['url'], diff --git a/web/src/Api/ExportApiController.php b/web/src/Api/ExportApiController.php index 867f8d9..5438cf7 100644 --- a/web/src/Api/ExportApiController.php +++ b/web/src/Api/ExportApiController.php @@ -73,7 +73,7 @@ public function download(array $params): void } if ($format === 'json') { - $this->jsonOk($response); + $this->jsonOk(array_merge(['agent_id' => $this->resolvedAgentId], $response)); return; } diff --git a/web/src/Api/JobsApiController.php b/web/src/Api/JobsApiController.php index 1171f0a..06360d1 100644 --- a/web/src/Api/JobsApiController.php +++ b/web/src/Api/JobsApiController.php @@ -79,7 +79,7 @@ public function index(array $params): void $page = array_slice((array) $all, $offset, $limit); - $this->jsonOk($this->paginated($page, $total, $limit, $offset)); + $this->jsonOk(array_merge(['agent_id' => $this->resolvedAgentId], $this->paginated($page, $total, $limit, $offset))); } /** @@ -115,7 +115,7 @@ public function show(array $params): void return; } - $this->jsonOk($response); + $this->jsonOk(array_merge(['agent_id' => $this->resolvedAgentId], $response)); } /** @@ -149,7 +149,7 @@ public function store(array $params): void return; } - $this->jsonOk($response, 201); + $this->jsonOk(array_merge(['agent_id' => $this->resolvedAgentId], $response), 201); } /** @@ -190,7 +190,7 @@ public function update(array $params): void return; } - $this->jsonOk($response); + $this->jsonOk(array_merge(['agent_id' => $this->resolvedAgentId], $response)); } /** @@ -226,7 +226,7 @@ public function destroy(array $params): void return; } - $this->jsonOk(['success' => true]); + $this->jsonOk(['agent_id' => $this->resolvedAgentId, 'success' => true]); } /** @@ -262,7 +262,7 @@ public function execute(array $params): void return; } - $this->jsonOk(['success' => true, 'message' => 'Job queued for immediate execution.']); + $this->jsonOk(['agent_id' => $this->resolvedAgentId, 'success' => true, 'message' => 'Job queued for immediate execution.']); } /** @@ -298,7 +298,7 @@ public function kill(array $params): void return; } - $this->jsonOk(['success' => true]); + $this->jsonOk(['agent_id' => $this->resolvedAgentId, 'success' => true]); } /** @@ -346,7 +346,7 @@ public function history(array $params): void $data = $response['data'] ?? $response; $count = $response['count'] ?? count((array) $data); - $this->jsonOk($this->paginated((array) $data, (int) $count, $limit, $offset)); + $this->jsonOk(array_merge(['agent_id' => $this->resolvedAgentId], $this->paginated((array) $data, (int) $count, $limit, $offset))); } /** @@ -378,7 +378,7 @@ public function tags(array $params): void $data = $response['data'] ?? $response; $count = is_array($data) ? count($data) : 0; - $this->jsonOk(['data' => $data, 'count' => $count]); + $this->jsonOk(['agent_id' => $this->resolvedAgentId, 'data' => $data, 'count' => $count]); } /** @@ -427,6 +427,6 @@ public function timeline(array $params): void $data = $response['data'] ?? $response; $count = $response['count'] ?? count((array) $data); - $this->jsonOk($this->paginated((array) $data, (int) $count, $limit, $offset)); + $this->jsonOk(array_merge(['agent_id' => $this->resolvedAgentId], $this->paginated((array) $data, (int) $count, $limit, $offset))); } } diff --git a/web/src/Api/MaintenanceApiController.php b/web/src/Api/MaintenanceApiController.php index 8c5bd3c..82bc769 100644 --- a/web/src/Api/MaintenanceApiController.php +++ b/web/src/Api/MaintenanceApiController.php @@ -67,7 +67,7 @@ public function index(array $params): void $data = $response['data'] ?? $response; $count = is_array($data) ? count($data) : 0; - $this->jsonOk(['data' => $data, 'count' => $count]); + $this->jsonOk(['agent_id' => $this->resolvedAgentId, 'data' => $data, 'count' => $count]); } /** @@ -103,7 +103,7 @@ public function show(array $params): void return; } - $this->jsonOk($response); + $this->jsonOk(array_merge(['agent_id' => $this->resolvedAgentId], $response)); } /** @@ -137,7 +137,7 @@ public function store(array $params): void return; } - $this->jsonOk($response, 201); + $this->jsonOk(array_merge(['agent_id' => $this->resolvedAgentId], $response), 201); } /** @@ -178,7 +178,7 @@ public function update(array $params): void return; } - $this->jsonOk($response); + $this->jsonOk(array_merge(['agent_id' => $this->resolvedAgentId], $response)); } /** @@ -214,6 +214,6 @@ public function destroy(array $params): void return; } - $this->jsonOk(['success' => true]); + $this->jsonOk(['agent_id' => $this->resolvedAgentId, 'success' => true]); } } diff --git a/web/src/Api/SettingsApiController.php b/web/src/Api/SettingsApiController.php index 3feadae..ea0d53d 100644 --- a/web/src/Api/SettingsApiController.php +++ b/web/src/Api/SettingsApiController.php @@ -65,7 +65,7 @@ public function index(array $params): void return; } - $this->jsonOk($response); + $this->jsonOk(array_merge(['agent_id' => $this->resolvedAgentId], $response)); } /** @@ -108,7 +108,8 @@ public function show(array $params): void } $sectionData = $response[$section] ?? []; - $this->jsonOk(is_array($sectionData) ? $sectionData : []); + $payload = is_array($sectionData) ? $sectionData : []; + $this->jsonOk(array_merge(['agent_id' => $this->resolvedAgentId], $payload)); } /** @@ -156,7 +157,8 @@ public function update(array $params): void } $sectionData = $response[$section] ?? $body; - $this->jsonOk(is_array($sectionData) ? $sectionData : $body); + $payload = is_array($sectionData) ? $sectionData : $body; + $this->jsonOk(array_merge(['agent_id' => $this->resolvedAgentId], $payload)); } /** @@ -187,6 +189,6 @@ public function resync(array $params): void return; } - $this->jsonOk(['success' => true, 'message' => 'Crontab resynced.']); + $this->jsonOk(['agent_id' => $this->resolvedAgentId, 'success' => true, 'message' => 'Crontab resynced.']); } } diff --git a/web/src/Controller/BaseController.php b/web/src/Controller/BaseController.php index e1be100..fa7918c 100644 --- a/web/src/Controller/BaseController.php +++ b/web/src/Controller/BaseController.php @@ -112,6 +112,10 @@ protected function render( } catch (\Throwable) { $data['selectedAgent'] = null; } + // Convenience shortcut: $agentId is used by templates to append + // ?agent_id=X to links so that shared URLs and notification links + // always open in the correct agent context (job IDs are per-agent). + $data['agentId'] = (int) ($data['selectedAgent']['id'] ?? 0); try { $pdo = Connection::getInstance()->getPdo(); $data['enabledAgents'] = (new AgentRepository($pdo))->findEnabled(); @@ -373,6 +377,33 @@ protected function buildClientFor(array $agent): HostAgentClient ); } + /** + * Build an agent-aware path for redirects. + * + * Appends ?agent_id=X (or &agent_id=X) to $path so that after a redirect + * the browser URL carries the agent context explicitly — necessary when + * job IDs are only unique per agent (not globally). + * + * @param string $path Relative URL path (may already contain query string). + * + * @return string Path with agent_id appended, or original path when no agent selected. + */ + protected function agentPath(string $path): string + { + try { + $agentId = (int) ($this->selectedAgent()['id'] ?? 0); + } catch (\Throwable) { + $agentId = 0; + } + + if ($agentId <= 0) { + return $path; + } + + $sep = str_contains($path, '?') ? '&' : '?'; + return $path . $sep . 'agent_id=' . $agentId; + } + /** * Resolve a filter parameter from GET, falling back to a persistent cookie. * diff --git a/web/src/Controller/CronController.php b/web/src/Controller/CronController.php index da43186..9d54dfb 100644 --- a/web/src/Controller/CronController.php +++ b/web/src/Controller/CronController.php @@ -372,7 +372,7 @@ public function store(array $params): void ]); $returnUrl = trim((string) ($_POST['_return'] ?? '')); - $safe = ($returnUrl !== '' && str_starts_with($returnUrl, '/crons')) ? $returnUrl : '/crons'; + $safe = ($returnUrl !== '' && str_starts_with($returnUrl, '/crons')) ? $returnUrl : $this->agentPath('/crons'); (new Response())->redirect($safe); } @@ -543,7 +543,7 @@ public function update(array $params): void // Redirect back to the list page (preserving any active filters) if a // validated return URL was passed through the form, otherwise show detail. $returnUrl = trim((string) ($_POST['_return'] ?? '')); - $safe = ($returnUrl !== '' && str_starts_with($returnUrl, '/crons')) ? $returnUrl : '/crons/' . rawurlencode($id); + $safe = ($returnUrl !== '' && str_starts_with($returnUrl, '/crons')) ? $returnUrl : $this->agentPath('/crons/' . rawurlencode($id)); (new Response())->redirect($safe); } @@ -668,7 +668,7 @@ public function importStore(array $params): void } if ($user === '' || empty($selected)) { - (new Response())->redirect('/crons/import'); + (new Response())->redirect($this->agentPath('/crons/import')); return; } @@ -750,7 +750,7 @@ public function importStore(array $params): void if ($target !== 'local') { $redirect .= '&target=' . rawurlencode($target); } - (new Response())->redirect($redirect); + (new Response())->redirect($this->agentPath($redirect)); } /** @@ -867,7 +867,7 @@ public function bulkAction(array $params): void if ($ids === []) { SessionManager::set('_flash_error', $t->t('bulk_error_agent')); - (new Response())->redirect('/crons'); + (new Response())->redirect($this->agentPath('/crons')); return; } @@ -897,7 +897,7 @@ public function bulkAction(array $params): void case 'tag_remove': if ($tag === '') { SessionManager::set('_flash_error', $t->t('bulk_error_no_tag')); - (new Response())->redirect('/crons'); + (new Response())->redirect($this->agentPath('/crons')); return; } $tagAction = $action === 'tag_add' ? 'add' : 'remove'; @@ -925,7 +925,7 @@ public function bulkAction(array $params): void SessionManager::set('_flash_error', $t->t('bulk_error_agent')); } - (new Response())->redirect('/crons'); + (new Response())->redirect($this->agentPath('/crons')); } public function destroy(array $params): void @@ -948,7 +948,7 @@ public function destroy(array $params): void // Redirect back to the list page preserving any active filters. // Validate the return URL to prevent open-redirect: must start with /crons. $returnUrl = trim((string) ($_POST['_return'] ?? '')); - $safe = ($returnUrl !== '' && str_starts_with($returnUrl, '/crons')) ? $returnUrl : '/crons'; + $safe = ($returnUrl !== '' && str_starts_with($returnUrl, '/crons')) ? $returnUrl : $this->agentPath('/crons'); (new Response())->redirect($safe); } @@ -991,7 +991,7 @@ public function executeNow(array $params): void // Redirect back to the referring page (list or detail), validated to // prevent open-redirect. $returnUrl = trim((string) ($_POST['_return'] ?? '')); - $safe = ($returnUrl !== '' && str_starts_with($returnUrl, '/crons')) ? $returnUrl : '/crons'; + $safe = ($returnUrl !== '' && str_starts_with($returnUrl, '/crons')) ? $returnUrl : $this->agentPath('/crons'); (new Response())->redirect($safe); } diff --git a/web/src/Controller/TargetController.php b/web/src/Controller/TargetController.php index 33bb8df..4564915 100644 --- a/web/src/Controller/TargetController.php +++ b/web/src/Controller/TargetController.php @@ -167,7 +167,7 @@ public function storeWindow(array $params): void 'target' => $target, ]); - (new Response())->redirect('/maintenance'); + (new Response())->redirect($this->agentPath('/maintenance')); } /** diff --git a/web/templates/audit/list.php b/web/templates/audit/list.php index fd93bfc..a143871 100644 --- a/web/templates/audit/list.php +++ b/web/templates/audit/list.php @@ -39,9 +39,13 @@ $showFrom = $total === 0 ? 0 : ($page - 1) * $pageSize + 1; $showTo = min($page * $pageSize, $total); +$agentId = isset($agentId) ? (int) $agentId : 0; +$agSuffix = $agentId > 0 ? '?agent_id=' . $agentId : ''; + /** Build a pagination URL preserving current filters. */ -$pageUrl = static function (int $targetPage) use ($username, $actionPrefix, $dateFrom, $dateTo, $pageSize): string { +$pageUrl = static function (int $targetPage) use ($username, $actionPrefix, $dateFrom, $dateTo, $pageSize, $agentId): string { return '/audit?' . http_build_query(array_filter([ + 'agent_id' => $agentId > 0 ? (string) $agentId : '', 'page' => (string) $targetPage, 'username' => $username, 'action_prefix' => $actionPrefix, @@ -121,7 +125,7 @@ class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium ro - diff --git a/web/templates/cron/detail.php b/web/templates/cron/detail.php index 77503dd..d7363a2 100644 --- a/web/templates/cron/detail.php +++ b/web/templates/cron/detail.php @@ -16,6 +16,11 @@ * @license GNU General Public License version 3 or later */ +/** @var int $agentId */ +$agentId = isset($agentId) ? (int) $agentId : 0; +$agSuffix = $agentId > 0 ? '?agent_id=' . $agentId : ''; +$agParam = $agentId > 0 ? 'agent_id=' . $agentId . '&' : ''; + /** @var \Cronmanager\Web\I18n\Translator $translator */ $t = fn(string $k, array $r = []): string => $translator->t($k, $r); @@ -47,7 +52,7 @@ Breadcrumb / back link ====================================================================== --> @@ -123,7 +128,7 @@
- @@ -138,13 +143,13 @@ class="inline-flex items-center gap-1 bg-indigo-50 hover:bg-indigo-100 text-indi - - diff --git a/web/templates/cron/form.php b/web/templates/cron/form.php index 9da48f4..d09b4c1 100644 --- a/web/templates/cron/form.php +++ b/web/templates/cron/form.php @@ -18,6 +18,9 @@ * @license GNU General Public License version 3 or later */ +$agentId = isset($agentId) ? (int) $agentId : 0; +$agSuffix = $agentId > 0 ? '?agent_id=' . $agentId : ''; + /** @var \Cronmanager\Web\I18n\Translator $translator */ $t = fn(string $k, array $r = []): string => $translator->t($k, $r); @@ -614,7 +617,7 @@ class="bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"> - diff --git a/web/templates/cron/import.php b/web/templates/cron/import.php index 012ed07..c4076e0 100644 --- a/web/templates/cron/import.php +++ b/web/templates/cron/import.php @@ -21,6 +21,9 @@ * @license GNU General Public License version 3 or later */ +$agentId = isset($agentId) ? (int) $agentId : 0; +$agSuffix = $agentId > 0 ? '?agent_id=' . $agentId : ''; + /** @var \Cronmanager\Web\I18n\Translator $translator */ $t = fn(string $k, array $r = []): string => $translator->t($k, $r); @@ -46,7 +49,7 @@

- diff --git a/web/templates/cron/list.php b/web/templates/cron/list.php index b2e08c9..5e488f6 100644 --- a/web/templates/cron/list.php +++ b/web/templates/cron/list.php @@ -50,8 +50,13 @@ * * @param int $targetPage The page number for the link. */ -$pageUrl = static function (int $targetPage) use ($filterTag, $filterUser, $filterTarget, $filterSearch, $filterResult, $filterActive, $pageSize): string { +$agentId = isset($agentId) ? (int) $agentId : 0; +$agSuffix = $agentId > 0 ? '?agent_id=' . $agentId : ''; +$agParam = $agentId > 0 ? 'agent_id=' . $agentId . '&' : ''; + +$pageUrl = static function (int $targetPage) use ($filterTag, $filterUser, $filterTarget, $filterSearch, $filterResult, $filterActive, $pageSize, $agentId): string { $params = array_filter([ + 'agent_id' => $agentId > 0 ? (string) $agentId : '', 'tag' => $filterTag, 'user' => $filterUser, 'target' => $filterTarget, @@ -104,7 +109,7 @@
- @@ -113,7 +118,7 @@ class="inline-flex items-center gap-1.5 bg-gray-600 hover:bg-gray-700 text-white - @@ -289,7 +294,7 @@ class="bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium
- × @@ -519,7 +524,7 @@ class="cm-row-check rounded border-gray-300 dark:border-gray-600 - @@ -618,7 +623,7 @@ class="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium bg - diff --git a/web/templates/cron/monitor.php b/web/templates/cron/monitor.php index 0570bf3..33aa629 100644 --- a/web/templates/cron/monitor.php +++ b/web/templates/cron/monitor.php @@ -26,6 +26,9 @@ * @license GNU General Public License version 3 or later */ +$agentId = isset($agentId) ? (int) $agentId : 0; +$agSuffix = $agentId > 0 ? '?agent_id=' . $agentId : ''; + /** @var \Cronmanager\Web\I18n\Translator $translator */ $t = fn(string $k, array $r = []): string => $translator->t($k, $r); @@ -79,7 +82,7 @@ Breadcrumb ====================================================================== -->
- diff --git a/web/templates/cron/transfer.php b/web/templates/cron/transfer.php index 2082aa2..00d932a 100644 --- a/web/templates/cron/transfer.php +++ b/web/templates/cron/transfer.php @@ -17,6 +17,9 @@ * @license GNU General Public License version 3 or later */ +$agentId = isset($agentId) ? (int) $agentId : 0; +$agSuffix = $agentId > 0 ? '?agent_id=' . $agentId : ''; + /** @var \Cronmanager\Web\I18n\Translator $translator */ $t = fn(string $k, array $r = []): string => $translator->t($k, $r); @@ -46,7 +49,7 @@

-
- diff --git a/web/templates/dashboard.php b/web/templates/dashboard.php index 75936f0..a6849d5 100644 --- a/web/templates/dashboard.php +++ b/web/templates/dashboard.php @@ -17,6 +17,10 @@ * @license GNU General Public License version 3 or later */ +$agentId = isset($agentId) ? (int) $agentId : 0; +$agSuffix = $agentId > 0 ? '?agent_id=' . $agentId : ''; +$agParam = $agentId > 0 ? 'agent_id=' . $agentId . '&' : ''; + /** @var \Cronmanager\Web\I18n\Translator $translator */ $t = fn(string $k, array $r = []): string => $translator->t($k, $r); @@ -265,7 +269,7 @@ class="text-blue-600 hover:underline font-medium"> ?> - diff --git a/web/templates/error.php b/web/templates/error.php index 4be554b..55471a8 100644 --- a/web/templates/error.php +++ b/web/templates/error.php @@ -21,6 +21,9 @@ $errorCode = isset($errorCode) ? (int) $errorCode : 500; $errorMessage = isset($errorMessage) ? (string) $errorMessage : $t('error_500'); + +$agentId = isset($agentId) ? (int) $agentId : 0; +$agSuffix = $agentId > 0 ? '?agent_id=' . $agentId : ''; ?>
@@ -37,7 +40,7 @@

- diff --git a/web/templates/housekeeping/index.php b/web/templates/housekeeping/index.php index 972de21..c076c64 100644 --- a/web/templates/housekeeping/index.php +++ b/web/templates/housekeeping/index.php @@ -26,6 +26,9 @@ /** @var \Cronmanager\Web\I18n\Translator $translator */ $t = fn(string $k, array $r = []): string => $translator->t($k, $r); + +$agentId = isset($agentId) ? (int) $agentId : 0; +$agSuffix = $agentId > 0 ? '?agent_id=' . $agentId : ''; ?>
@@ -401,7 +404,7 @@ class="stuck-cb rounded cursor-pointer"> # - diff --git a/web/templates/layout.php b/web/templates/layout.php index 2b652f9..f854430 100644 --- a/web/templates/layout.php +++ b/web/templates/layout.php @@ -53,6 +53,12 @@ }; } +// Agent-aware URL suffix (?agent_id=X) for sidebar navigation links. +// Job IDs are only unique per agent, so links must carry the agent context +// to avoid showing the wrong job when a URL is shared or session expires. +$agentId = isset($agentId) && is_int($agentId) ? $agentId : 0; +$agSuffix = $agentId > 0 ? '?agent_id=' . $agentId : ''; + // Returns the sidebar nav-item CSS classes for a given path $navClass = static function (string $path) use ($currentPath): string { $isActive = ($currentPath === $path) @@ -120,7 +126,7 @@ - @@ -182,7 +185,7 @@ class="inline-flex items-center gap-1.5 text-sm font-medium text-indigo-600 dark
- diff --git a/web/templates/swimlane.php b/web/templates/swimlane.php index 5a2fbdb..45aa0d7 100644 --- a/web/templates/swimlane.php +++ b/web/templates/swimlane.php @@ -31,6 +31,9 @@ $t = static fn(string $k, array $r = []): string => htmlspecialchars($translator->t($k, $r), ENT_QUOTES, 'UTF-8'); +$agentId = isset($agentId) ? (int) $agentId : 0; +$agSuffix = $agentId > 0 ? '?agent_id=' . $agentId : ''; + // Ensure variables are defined even when template is included standalone $swimlaneJobsJson = isset($swimlaneJobsJson) ? (string) $swimlaneJobsJson : '[]'; $tags = isset($tags) ? (array) $tags : []; diff --git a/web/templates/timeline.php b/web/templates/timeline.php index b095706..de7941e 100644 --- a/web/templates/timeline.php +++ b/web/templates/timeline.php @@ -51,6 +51,10 @@ // date-range cookie cannot silently narrow the result set across pages. $isDirect = isset($isDirect) && (bool) $isDirect; +$agentId = isset($agentId) ? (int) $agentId : 0; +$agSuffix = $agentId > 0 ? '?agent_id=' . $agentId : ''; +$agParam = $agentId > 0 ? 'agent_id=' . $agentId . '&' : ''; + // Pagination helpers $prevOffset = max(0, $offset - $limit); $nextOffset = $offset + $limit; @@ -64,18 +68,19 @@ * * @param int $newOffset Offset to use in the generated URL. */ -$pageUrl = static function (int $newOffset) use ($filters, $limit, $isDirect): string { +$pageUrl = static function (int $newOffset) use ($filters, $limit, $isDirect, $agentId): string { $params = array_filter([ - 'search' => $filters['search'] ?? '', - 'job_id' => $filters['job_id'] ?? '', - 'tag' => $filters['tag'] ?? '', - 'user' => $filters['user'] ?? '', - 'target' => $filters['target'] ?? '', - 'status' => $filters['status'] ?? '', - 'from' => $filters['from'] ?? '', - 'to' => $filters['to'] ?? '', - 'limit' => (string) $limit, - 'offset' => (string) $newOffset, + 'agent_id' => $agentId > 0 ? (string) $agentId : '', + 'search' => $filters['search'] ?? '', + 'job_id' => $filters['job_id'] ?? '', + 'tag' => $filters['tag'] ?? '', + 'user' => $filters['user'] ?? '', + 'target' => $filters['target'] ?? '', + 'status' => $filters['status'] ?? '', + 'from' => $filters['from'] ?? '', + 'to' => $filters['to'] ?? '', + 'limit' => (string) $limit, + 'offset' => (string) $newOffset, ], static fn(string $v): bool => $v !== ''); if ($isDirect) { @@ -269,7 +274,7 @@ class="bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium
- × @@ -383,7 +388,7 @@ class="text-sm text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:t - From b0c24a924cd291901a1235527748a3793d36ecaf Mon Sep 17 00:00:00 2001 From: root Date: Mon, 3 Aug 2026 16:01:35 +0200 Subject: [PATCH 4/4] fix: add agent_id to dashboard failure timeline links (v4.6.1) --- web/templates/dashboard.php | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/web/templates/dashboard.php b/web/templates/dashboard.php index a6849d5..a094db5 100644 --- a/web/templates/dashboard.php +++ b/web/templates/dashboard.php @@ -187,10 +187,11 @@ // Deep-link to Timeline pre-filtered for this specific job/target/status. // _direct=1 prevents saved date-range cookies from hiding the entry. $timelineParams = array_filter([ - 'job_id' => $jobId, - 'target' => $entryTarget, - 'status' => 'failed', - '_direct' => '1', + 'agent_id' => $agentId > 0 ? (string) $agentId : '', + 'job_id' => $jobId, + 'target' => $entryTarget, + 'status' => 'failed', + '_direct' => '1', ], static fn(string $v): bool => $v !== ''); $timelineUrl = '/timeline?' . http_build_query($timelineParams); ?>