From a14f97b270d8dcecf40f06b93a41ec00fbd135a1 Mon Sep 17 00:00:00 2001 From: Matthew Hansen Date: Sun, 15 Mar 2026 13:45:51 +1100 Subject: [PATCH 01/10] add tunnel system, AWS reports backend, and dashboard UI overhaul Tunnel feature (new): - dashboard/tunnel.php: provider-agnostic tunnel UI with Cloudflare quick tunnel, manual URL management, recent URLs, uptime timer, auto-refresh polling, log viewer, browser notifications, and inline connectivity testing with result alerts - dashboard/index.php: tunnel API endpoints (start, stop, configure, test, logs, status) with cloudflared process lifecycle management - dashboard/start-dev.sh: cleanup trap to kill orphaned cloudflared on dashboard shutdown AWS reports backend (new): - dashboard/aws.php: AWS report execution backend and API handlers - dashboard/aws_ui.php: AWS reports UI with overview cards, tabbed results, cost analysis with inline bar visualization, rightsizing, security scanning, and CLI runner - lib/aws/_aws-common.sh: shared AWS auth and env loader - lib/aws/aws-costs.sh: Cost Explorer analysis script - lib/aws/aws-rightsizing.sh: CloudWatch rightsizing analysis - lib/aws/aws-security.sh: WAF, IAM, SG, S3, and secrets scan - lib/aws/aws-cli.sh: updated AWS CLI wrapper - .env.example: AWS credential template Dashboard UI improvements: - Shared CSS patterns: status badges, result alerts with slide-in animation, collapsible sections, focus-visible outlines - Tunnel page: full-width status hero card, click-to-copy URL, collapsible logs/notes/request details, result-alert test feedback - Main dashboard: terminal completion and stop alerts, sidebar running indicator with accent border, centered welcome state, consistent collapsible chevrons - AWS reports: hero treatment for total cost card, inline bar visualization in cost tables, active tab-to-card connection, last-run status line, overview card hover states - Cross-cutting: consistent back navigation text, icon-only theme toggles, button vertical centering fix, stop button disabled state neutralized, tunnel button with visible label --- .env.example | 3 + dashboard/aws.php | 1386 ++++++++++++++++++++++++++++++ dashboard/aws_ui.php | 1665 ++++++++++++++++++++++++++++++++++++ dashboard/frontend.php | 404 +++++++-- dashboard/index.php | 445 +++++++++- dashboard/start-dev.sh | 26 + dashboard/tunnel.php | 1137 ++++++++++++++++++++++++ lib/aws/_aws-common.sh | 137 +++ lib/aws/aws-cli.sh | 187 ++-- lib/aws/aws-costs.sh | 547 ++++++++++++ lib/aws/aws-rightsizing.sh | 699 +++++++++++++++ lib/aws/aws-security.sh | 525 ++++++++++++ 12 files changed, 6994 insertions(+), 167 deletions(-) create mode 100644 .env.example create mode 100644 dashboard/aws.php create mode 100644 dashboard/aws_ui.php create mode 100644 dashboard/tunnel.php create mode 100755 lib/aws/_aws-common.sh create mode 100755 lib/aws/aws-costs.sh create mode 100755 lib/aws/aws-rightsizing.sh create mode 100755 lib/aws/aws-security.sh diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f99d65c --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= +AWS_DEFAULT_REGION= diff --git a/dashboard/aws.php b/dashboard/aws.php new file mode 100644 index 0000000..42e0eca --- /dev/null +++ b/dashboard/aws.php @@ -0,0 +1,1386 @@ + + */ +function getAwsReportRegistry(): array +{ + return [ + 'costs' => [ + 'label' => 'Costs', + 'script' => 'lib/aws/aws-costs.sh', + 'description' => 'Cost Explorer summary plus AWS inventory counts.', + ], + 'rightsizing' => [ + 'label' => 'Rightsizing', + 'script' => 'lib/aws/aws-rightsizing.sh', + 'description' => 'Utilisation review for RDS, ECS, ALB, NAT, EC2, and logs.', + ], + 'security' => [ + 'label' => 'Security', + 'script' => 'lib/aws/aws-security.sh', + 'description' => 'Read-only security posture scan across common services.', + ], + 'cli' => [ + 'label' => 'AWS CLI', + 'script' => 'lib/aws/aws-cli.sh', + 'description' => 'Run a wrapped AWS CLI or Terraform command with the shared auth loader.', + ], + ]; +} + +function handleAwsDashboardRequest(string $method): void +{ + if ($method === 'POST') { + handleApiAwsRun(); + return; + } + + serveAwsDashboardUi(); +} + +function handleApiAwsRun(): void +{ + $body = getJsonBody(); + $reportId = (string) ($body['report'] ?? ''); + $reports = getAwsReportRegistry(); + + if ($reportId === '' || !isset($reports[$reportId])) { + jsonResponse(['error' => 'Unknown AWS report'], 400); + return; + } + + $scriptPath = SCRIPTS_DIR . '/' . $reports[$reportId]['script']; + if (!is_file($scriptPath)) { + jsonResponse(['error' => 'Script not found: ' . $reports[$reportId]['script']], 500); + return; + } + + try { + [$args, $displayArgs] = buildAwsReportArgs($reportId, $body); + } catch (InvalidArgumentException $e) { + jsonResponse(['error' => $e->getMessage()], 400); + return; + } + + $command = array_merge(['/usr/bin/env', 'bash', $scriptPath], $args); + $commandLabel = 'bash ' . $reports[$reportId]['script']; + if ($displayArgs !== []) { + $commandLabel .= ' ' . implode(' ', array_map(static fn (string $arg): string => escapeshellarg($arg), $displayArgs)); + } + + $start = microtime(true); + $descriptors = [ + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + + $process = proc_open($command, $descriptors, $pipes, SCRIPTS_DIR); + if (!is_resource($process)) { + jsonResponse(['error' => 'Failed to start AWS report process'], 500); + return; + } + + $stdout = is_resource($pipes[1] ?? null) ? stream_get_contents($pipes[1]) : ''; + $stderr = is_resource($pipes[2] ?? null) ? stream_get_contents($pipes[2]) : ''; + + if (is_resource($pipes[1] ?? null)) { + fclose($pipes[1]); + } + if (is_resource($pipes[2] ?? null)) { + fclose($pipes[2]); + } + + $exitCode = proc_close($process); + $rawOutput = ($stdout === false ? '' : $stdout) . ($stderr === false ? '' : $stderr); + $durationMs = (int) round((microtime(true) - $start) * 1000); + $plainText = stripAnsiText($rawOutput); + + jsonResponse([ + 'report' => $reportId, + 'label' => $reports[$reportId]['label'], + 'command' => $commandLabel, + 'exit_code' => $exitCode, + 'duration_ms' => $durationMs, + 'html' => ansiToHtml($rawOutput), + 'text' => $plainText, + 'summary' => summarizeAwsOutput($plainText, $exitCode), + 'ran_at' => gmdate('Y-m-d H:i:s') . ' UTC', + ], $exitCode === 0 ? 200 : 422); +} + +/** + * @param array $body + * + * @return array{0: list, 1: list} + */ +function buildAwsReportArgs(string $reportId, array $body): array +{ + return match ($reportId) { + 'costs' => buildAwsCostArgs($body), + 'rightsizing' => buildAwsRightsizingArgs($body), + 'security' => [[], []], + 'cli' => buildAwsCliArgs($body), + default => throw new InvalidArgumentException('Unsupported AWS report'), + }; +} + +/** + * @param array $body + * + * @return array{0: list, 1: list} + */ +function buildAwsCostArgs(array $body): array +{ + $args = []; + $display = []; + + $start = trim((string) ($body['start_month'] ?? '')); + $end = trim((string) ($body['end_month'] ?? '')); + + if ($start !== '') { + $args[] = '--start'; + $args[] = $start; + $display[] = '--start'; + $display[] = $start; + } + + if ($end !== '') { + $args[] = '--end'; + $args[] = $end; + $display[] = '--end'; + $display[] = $end; + } + + return [$args, $display]; +} + +/** + * @param array $body + * + * @return array{0: list, 1: list} + */ +function buildAwsRightsizingArgs(array $body): array +{ + $days = trim((string) ($body['days'] ?? '7')); + if ($days === '') { + $days = '7'; + } + + return [['--days', $days], ['--days', $days]]; +} + +/** + * @param array $body + * + * @return array{0: list, 1: list} + */ +function buildAwsCliArgs(array $body): array +{ + $command = trim((string) ($body['command'] ?? '')); + if ($command === '') { + throw new InvalidArgumentException('AWS CLI command is required'); + } + + $parts = splitCommandString($command); + if ($parts === []) { + throw new InvalidArgumentException('AWS CLI command is required'); + } + + if (count($parts) > 64) { + throw new InvalidArgumentException('AWS CLI command is too long'); + } + + return [$parts, $parts]; +} + +/** + * @return list + */ +function splitCommandString(string $command): array +{ + $tokens = []; + $length = strlen($command); + $buffer = ''; + $quote = null; + + for ($i = 0; $i < $length; $i++) { + $char = $command[$i]; + + if ($quote !== null) { + if ($char === '\\' && $quote === '"' && $i + 1 < $length) { + $i++; + $buffer .= $command[$i]; + continue; + } + + if ($char === $quote) { + $quote = null; + continue; + } + + $buffer .= $char; + continue; + } + + if ($char === '"' || $char === "'") { + $quote = $char; + continue; + } + + if (ctype_space($char)) { + if ($buffer !== '') { + $tokens[] = $buffer; + $buffer = ''; + } + continue; + } + + if ($char === '\\' && $i + 1 < $length) { + $i++; + $buffer .= $command[$i]; + continue; + } + + $buffer .= $char; + } + + if ($quote !== null) { + throw new InvalidArgumentException('Unterminated quote in AWS CLI command'); + } + + if ($buffer !== '') { + $tokens[] = $buffer; + } + + return array_values(array_filter($tokens, static fn (string $token): bool => $token !== '')); +} + +function stripAnsiText(string $text): string +{ + $text = str_replace("\r", '', $text); + return preg_replace('/\x1b\[[0-9;]*[A-Za-z]/', '', $text) ?? $text; +} + +/** + * @return array + */ +function summarizeAwsOutput(string $text, int $exitCode): array +{ + $alerts = preg_match_all('/(^|\s)(\[ERROR\]|✗)/mu', $text) ?: 0; + $warnings = preg_match_all('/(^|\s)(\[WARN\]|⚠)/mu', $text) ?: 0; + $oks = preg_match_all('/(^|\s)(\[OK\]|✓)/mu', $text) ?: 0; + + $headline = $exitCode === 0 ? 'Completed successfully' : 'Completed with errors'; + + if (preg_match('/([0-9]+ findings.*)$/mi', $text, $matches) === 1) { + $headline = trim($matches[1]); + } elseif (preg_match('/(No security issues found!|No issues found .*|No cost data returned .*|No issues found)/mi', $text, $matches) === 1) { + $headline = trim($matches[1]); + } + + return [ + 'headline' => $headline, + 'alerts' => $alerts, + 'warnings' => $warnings, + 'oks' => $oks, + ]; +} + +/** + * @return array{ + * has_env_file: bool, + * access_key_preview: string, + * secret_preview: string, + * has_secret: bool, + * region: string, + * saved_at: string|null + * } + */ +function getAwsEnvSummary(): array +{ + $envFilePath = SCRIPTS_DIR . '/.env'; + $values = [ + 'AWS_ACCESS_KEY_ID' => '', + 'AWS_SECRET_ACCESS_KEY' => '', + 'AWS_DEFAULT_REGION' => '', + ]; + + if (is_file($envFilePath)) { + $lines = file($envFilePath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); + if (is_array($lines)) { + foreach ($lines as $line) { + $trimmed = trim($line); + if ($trimmed === '' || str_starts_with($trimmed, '#') || !str_contains($trimmed, '=')) { + continue; + } + + [$key, $value] = array_map('trim', explode('=', $trimmed, 2)); + if (!array_key_exists($key, $values)) { + continue; + } + + $unquoted = trim($value, "\"'"); + $values[$key] = $unquoted; + } + } + } + + foreach (array_keys($values) as $key) { + if ($values[$key] === '') { + $envValue = getenv($key); + if (is_string($envValue) && $envValue !== '') { + $values[$key] = $envValue; + } + } + } + + $accessKey = $values['AWS_ACCESS_KEY_ID']; + $secretKey = $values['AWS_SECRET_ACCESS_KEY']; + + if ($accessKey === '') { + $accessKeyPreview = 'Not configured'; + } elseif (strlen($accessKey) <= 10) { + $accessKeyPreview = str_repeat('*', strlen($accessKey)); + } else { + $accessKeyPreview = substr($accessKey, 0, 6) . str_repeat('*', max(4, strlen($accessKey) - 10)) . substr($accessKey, -4); + } + + $secretPreview = $secretKey === '' ? 'Not configured' : str_repeat('*', 24); + $savedAt = is_file($envFilePath) ? date('d/m/Y, g:i:s a', (int) filemtime($envFilePath)) : null; + + return [ + 'has_env_file' => is_file($envFilePath), + 'access_key_preview' => $accessKeyPreview, + 'secret_preview' => $secretPreview, + 'has_secret' => $secretKey !== '', + 'region' => $values['AWS_DEFAULT_REGION'] !== '' ? $values['AWS_DEFAULT_REGION'] : 'Not configured', + 'saved_at' => $savedAt, + ]; +} + +function serveAwsDashboardHtml(): void +{ + header('Content-Type: text/html; charset=UTF-8'); + + $projectTitle = htmlspecialchars(PROJECT_NAME, ENT_QUOTES); + $envLabel = htmlspecialchars(ENV_NAME, ENT_QUOTES); + $envFilePath = SCRIPTS_DIR . '/.env'; + $envExamplePath = SCRIPTS_DIR . '/.env.example'; + $hasEnvFile = is_file($envFilePath); + $reportsJson = json_encode(getAwsReportRegistry(), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + if (!is_string($reportsJson)) { + $reportsJson = '{}'; + } + + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo 'AWS Reports - ' . $projectTitle . ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo <<<'CSS' + +CSS; + echo ''; + echo ''; + echo '
'; + echo '
'; + echo '
'; + echo '
'; + echo '

AWS Reports

'; + echo '

Run the AWS wrapper, cost summary, rightsizing audit, and security scan from one page. Each tab keeps its own latest result so you can compare reports without losing output.

'; + echo '
'; + echo '
'; + echo ' Main Dashboard'; + echo ' '; + echo '
'; + echo '
'; + echo '
'; + echo '
Project' . $projectTitle . '
'; + echo '
Environment' . $envLabel . '
'; + echo '
AWS Env File' . ($hasEnvFile ? '.env present' : '.env missing') . '
'; + echo '
Template' . htmlspecialchars($envExamplePath, ENT_QUOTES) . '
'; + echo '
'; + echo '
'; + echo '
'; + echo ' '; + echo '
'; + echo '
'; + echo '
'; + echo '
'; + echo '
'; + echo ''; + echo ''; + echo ''; +} diff --git a/dashboard/aws_ui.php b/dashboard/aws_ui.php new file mode 100644 index 0000000..6eb88a7 --- /dev/null +++ b/dashboard/aws_ui.php @@ -0,0 +1,1665 @@ +'; + echo ''; + echo ''; + echo ''; + echo ''; + echo 'AWS Reports - ' . $projectTitle . ''; + echo ''; + echo <<<'CSS' + +CSS; + echo ''; + echo ''; + echo '
'; + echo '
'; + echo '
'; + echo ' ← Back to Dashboard'; + echo '
AWS Operations Console
'; + echo '

AWS Reports

'; + echo '

Run validation, cost analysis, rightsizing, security scans, and direct CLI calls from one page. Each tab keeps its own last result so you can compare reports without losing context.

'; + echo '
'; + echo '
'; + echo '
'; + echo ' Project ' . $projectTitle . ''; + echo ' Env ' . $envLabel . ''; + echo ' '; + echo '
'; + echo '
'; + echo ' '; + echo ' '; + echo '
'; + echo '
'; + echo '
'; + echo '
'; + echo '
'; + echo '
'; + echo '
'; + echo '
'; + echo '
'; + echo ''; + echo ''; + echo ''; +} diff --git a/dashboard/frontend.php b/dashboard/frontend.php index ecfea76..d380c7b 100644 --- a/dashboard/frontend.php +++ b/dashboard/frontend.php @@ -1,5 +1,7 @@ $accent, 'accent_hover' => $accentHover, - 'dark' => ['badge_bg' => 'rgba(129,140,248,0.15)', 'badge_border' => 'rgba(129,140,248,0.4)', 'badge_text' => '#a5b4fc'], - 'light' => ['badge_bg' => 'rgba(99,102,241,0.12)', 'badge_border' => 'rgba(99,102,241,0.35)', 'badge_text' => '#4f46e5'], + 'dark' => ['badge_bg' => 'rgba(96,165,250,0.14)', 'badge_border' => 'rgba(96,165,250,0.32)', 'badge_text' => '#93c5fd'], + 'light' => ['badge_bg' => 'rgba(37,99,235,0.1)', 'badge_border' => 'rgba(37,99,235,0.22)', 'badge_text' => '#1d4ed8'], ]; $dark = $colors['dark']; $light = $colors['light']; @@ -44,35 +46,45 @@ function serveDashboardHtml(): void echo '' . $env . ' — ' . $projectTitle . ''; echo ''; echo ''; - echo ''; - echo ''; - echo ''; - echo ''; echo ''; @@ -334,6 +553,8 @@ function serveDashboardHtml(): void $siteLabel = htmlspecialchars(is_string($parsedHost) ? $parsedHost : $siteUrlConst, ENT_QUOTES); echo ' ' . $siteLabel . ''; } + echo ' AWS Reports'; + echo tunnelHeaderIconHtml(); echo ' ' . $defaultDir . ''; echo ' '; echo ' '; echo ' '; + echo ' '; if (IS_EXAMPLE_CONFIG) { echo '
'; echo 'You\'re using the default example config. Edit dashboard/config.php to add the scripts useful for your project. Run Help to see what\'s available.'; @@ -361,11 +583,13 @@ function serveDashboardHtml(): void echo '
'; } echo '
'; - echo ' Select a script from the sidebar to run it.'; + echo '
Select a script from the sidebar to run it.
'; echo '
'; echo ' '; echo ''; + echo tunnelPageHtml(); + echo <<<'HTML_BODY'