Add local login, agent extra log paths, IPv4/IPv6 metrics; remediate security audit findings
This is a large squashed commit covering two batches of prior uncommitted work plus a full security-audit remediation pass, kept together because go.mod/go.sum and several shared files (main.go, handler.go) were touched by both and splitting risked non-building intermediate commits. Features (built earlier, previously uncommitted): - Local username/password login for single-tenant deployments with no SSO configured (api/localauth, alerting/internal/sessioncheck, sentryctl users, web/src/routes/login, metadata migrations 0040/0041). - Remotely-editable additional log file paths for agents, on top of their existing primary source (api/agents, agent/sentry-agent extra-file-path diffing, web agent config UI). - IPv4/IPv6 addresses reported alongside other host system metrics. Security audit remediation (this pass, all live-verified in production): - Critical: block ClickHouse SSRF table functions (url/remote/file/s3/...) in the raw-SQL query escape hatch. - High: deny sensitive paths and require Admin to add agent extra_file_paths (Editor could previously point an agent at /etc/shadow or an SSH key); alerting webhook targets now validate against internal/metadata/loopback addresses, both at creation and send time; alerting's session middleware now enforces an Editor+ floor on mutating requests instead of "any authenticated session"; bumped goxmldsig to close a SAML signature-verification bypass (GO-2026-4753). - Medium: per-IP login rate limiting; security response headers (HSTS/CSP/nosniff/X-Frame-Options/Referrer-Policy/Permissions-Policy) on web/nginx.conf; a DevCredentialWarnings check in every Go service's config loader, logging loudly at startup if a deployment is still on docker-compose.yml's literal dev-only credentials; dependency bumps (golang.org/x/text, grpc, x/net, quick-xml, h2) across every affected Go module and both Rust crates, including a previously-uncovered x/net vulnerability in deploy/operator; a new security-scan.yml CI workflow running cargo-deny/govulncheck/npm-audit, mirroring the existing license-compliance.yml matrix shape. - Low: removed sentryctl's plaintext --password flag (shell history/`ps` exposure) in favor of stdin and a --password-stdin flag for reset-password's optional specific-password path; a dummy bcrypt comparison closes a login response-time username-enumeration side-channel.
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
<script lang="ts">
|
||||
import { listMetricsHosts, type HostSummary } from '$lib/api';
|
||||
import { EmptyState, Skeleton, Table } from '$lib/components/ui';
|
||||
|
||||
let hosts = $state<HostSummary[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
hosts = await listMetricsHosts();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
load();
|
||||
</script>
|
||||
|
||||
<main>
|
||||
<h1>Hosts</h1>
|
||||
<p class="subtitle">
|
||||
CPU, memory, and disk usage for every host reporting metrics. Only one agent process per
|
||||
physical host reports these -- see agent/README.md's "Host CPU/memory/disk metrics" section.
|
||||
</p>
|
||||
{#if error}<p class="error">Error: {error}</p>{/if}
|
||||
|
||||
{#if loading}
|
||||
<div class="skeleton-list">
|
||||
{#each Array(3) as _, i (i)}
|
||||
<Skeleton height="2.25rem" />
|
||||
{/each}
|
||||
</div>
|
||||
{:else if hosts.length === 0}
|
||||
<EmptyState
|
||||
icon="▣"
|
||||
title="No hosts reporting metrics yet"
|
||||
description="A host appears here once an agent with [metrics] enabled = true has sent its first sample -- see agent/README.md."
|
||||
/>
|
||||
{:else}
|
||||
<Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Host</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each hosts as h (h.host)}
|
||||
<tr>
|
||||
<td><a href={`/hosts/${encodeURIComponent(h.host)}`}>{h.host}</a></td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</Table>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<style>
|
||||
main {
|
||||
max-width: 56rem;
|
||||
}
|
||||
h1 {
|
||||
font-size: var(--text-xl);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
.subtitle {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-sm);
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
.error {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
.skeleton-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
a {
|
||||
color: var(--color-text);
|
||||
font-weight: var(--font-weight-medium);
|
||||
text-decoration: none;
|
||||
}
|
||||
a:hover {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,3 @@
|
||||
// No route params, data comes from a client-side fetch -- same shape as
|
||||
// the agents list page's +page.ts.
|
||||
export const prerender = true;
|
||||
@@ -0,0 +1,186 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { getHostMetrics, type HostMetrics } from '$lib/api';
|
||||
import { Card, Skeleton } from '$lib/components/ui';
|
||||
|
||||
const host = page.params.host!;
|
||||
|
||||
let metrics = $state<HostMetrics | null>(null);
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
metrics = await getHostMetrics(host);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
load();
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes <= 0) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(1024)));
|
||||
return `${(bytes / 1024 ** i).toFixed(1)} ${units[i]}`;
|
||||
}
|
||||
|
||||
function percent(used: number, total: number): number {
|
||||
if (total <= 0) return 0;
|
||||
return Math.min(100, Math.max(0, (used / total) * 100));
|
||||
}
|
||||
|
||||
function relativeTime(iso: string): string {
|
||||
const ms = Date.now() - new Date(iso).getTime();
|
||||
if (ms < 60_000) return `${Math.max(0, Math.round(ms / 1000))}s ago`;
|
||||
if (ms < 3_600_000) return `${Math.round(ms / 60_000)}m ago`;
|
||||
if (ms < 86_400_000) return `${Math.round(ms / 3_600_000)}h ago`;
|
||||
return `${Math.round(ms / 86_400_000)}d ago`;
|
||||
}
|
||||
|
||||
function formatUptime(seconds: number): string {
|
||||
if (seconds <= 0) return '—';
|
||||
const days = Math.floor(seconds / 86400);
|
||||
const hours = Math.floor((seconds % 86400) / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
if (days > 0) return `${days}d ${hours}h`;
|
||||
if (hours > 0) return `${hours}h ${minutes}m`;
|
||||
return `${minutes}m`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<main>
|
||||
<a class="back" href="/hosts">← Hosts</a>
|
||||
<h1>{host}</h1>
|
||||
|
||||
{#if loading}
|
||||
<Skeleton height="12rem" />
|
||||
{:else if error}
|
||||
<p class="error">Error: {error}</p>
|
||||
{:else if !metrics}
|
||||
<p class="hint">No metrics samples for this host yet.</p>
|
||||
{:else}
|
||||
<p class="hint">Last sample {relativeTime(metrics.timestamp)}.</p>
|
||||
|
||||
<section class="system">
|
||||
<dl>
|
||||
<dt>OS</dt>
|
||||
<dd>{metrics.osName}</dd>
|
||||
<dt>Kernel</dt>
|
||||
<dd>{metrics.kernelVersion}</dd>
|
||||
<dt>Architecture</dt>
|
||||
<dd>{metrics.arch}</dd>
|
||||
<dt>Uptime</dt>
|
||||
<dd>{formatUptime(metrics.uptimeSeconds)}</dd>
|
||||
<dt>IPv4</dt>
|
||||
<dd>{metrics.ipv4Addresses.length > 0 ? metrics.ipv4Addresses.join(', ') : '—'}</dd>
|
||||
<dt>IPv6</dt>
|
||||
<dd>{metrics.ipv6Addresses.length > 0 ? metrics.ipv6Addresses.join(', ') : '—'}</dd>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<div class="stats">
|
||||
<Card title="CPU">
|
||||
<div class="big-number">{metrics.cpuPercent.toFixed(1)}%</div>
|
||||
<div class="bar">
|
||||
<div class="bar-fill" style="width: {metrics.cpuPercent.toFixed(1)}%"></div>
|
||||
</div>
|
||||
<div class="detail">{metrics.cpuCores} core{metrics.cpuCores === 1 ? '' : 's'}</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Memory">
|
||||
<div class="big-number">{percent(metrics.memUsedBytes, metrics.memTotalBytes).toFixed(1)}%</div>
|
||||
<div class="bar">
|
||||
<div
|
||||
class="bar-fill"
|
||||
style="width: {percent(metrics.memUsedBytes, metrics.memTotalBytes).toFixed(1)}%"
|
||||
></div>
|
||||
</div>
|
||||
<div class="detail">{formatBytes(metrics.memUsedBytes)} / {formatBytes(metrics.memTotalBytes)}</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Disk (/)">
|
||||
<div class="big-number">{percent(metrics.diskUsedBytes, metrics.diskTotalBytes).toFixed(1)}%</div>
|
||||
<div class="bar">
|
||||
<div
|
||||
class="bar-fill"
|
||||
style="width: {percent(metrics.diskUsedBytes, metrics.diskTotalBytes).toFixed(1)}%"
|
||||
></div>
|
||||
</div>
|
||||
<div class="detail">{formatBytes(metrics.diskUsedBytes)} / {formatBytes(metrics.diskTotalBytes)}</div>
|
||||
</Card>
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<style>
|
||||
main {
|
||||
max-width: 48rem;
|
||||
}
|
||||
.back {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-muted);
|
||||
text-decoration: none;
|
||||
}
|
||||
.back:hover {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
h1 {
|
||||
font-size: var(--text-xl);
|
||||
margin: var(--space-2) 0 var(--space-2);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.hint {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-sm);
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
.error {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
.system {
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
.system dl {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: var(--space-1) var(--space-4);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.system dt {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.system dd {
|
||||
margin: 0;
|
||||
}
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
|
||||
gap: var(--space-4);
|
||||
}
|
||||
.big-number {
|
||||
font-size: var(--text-xl);
|
||||
font-weight: var(--font-weight-bold);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
.bar {
|
||||
height: 0.5rem;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
overflow: hidden;
|
||||
}
|
||||
.bar-fill {
|
||||
height: 100%;
|
||||
background: var(--color-accent);
|
||||
}
|
||||
.detail {
|
||||
margin-top: var(--space-2);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,4 @@
|
||||
// The host param doesn't exist at build time -- same reasoning as
|
||||
// agents/[host]/+page.ts.
|
||||
export const prerender = false;
|
||||
export const ssr = false;
|
||||
Reference in New Issue
Block a user