Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,15 @@
"scripts": {
"dev": "vite",
"server": "bun run server/index.ts",
"server:debug": "bun run server/index.ts --debug",
"start": "concurrently \"bun run server\" \"bun run dev\"",
"start:debug": "concurrently \"bun run server:debug\" \"bun run dev\"",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@influxdata/influxdb-client": "^1.35.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"recharts": "^3.5.1"
Expand Down
54 changes: 54 additions & 0 deletions server/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,27 @@
import { cpus, totalmem, freemem, loadavg, hostname, uptime, platform, arch } from "os";
import { exec } from "child_process";
import { promisify } from "util";
import { initInfluxDB, writeMetrics, queryMetrics, isEnabled as isInfluxEnabled } from "./influxdb";

const execAsync = promisify(exec);

// Parse command-line flags
const args = process.argv.slice(2);
const DEBUG = args.includes("--debug");

function debug(...messages: unknown[]) {
if (DEBUG) {
console.log(`[DEBUG ${new Date().toISOString()}]`, ...messages);
}
}

if (DEBUG) {
console.log("🐛 Debug mode enabled");
}

// Initialize InfluxDB timeseries storage
initInfluxDB(debug);

interface ProcessInfo {
pid: number;
user: string;
Expand Down Expand Up @@ -48,6 +66,7 @@ interface SystemMetrics {
let prevCpuTimes: { user: number; nice: number; sys: number; idle: number; irq: number }[] = [];

function getCpuUsage(): CpuUsage[] {
debug("Collecting CPU usage");
const cpuInfo = cpus();
const usage: CpuUsage[] = [];

Expand Down Expand Up @@ -92,6 +111,7 @@ function getCpuUsage(): CpuUsage[] {
}

async function getProcesses(): Promise<ProcessInfo[]> {
debug("Fetching process list");
const os = platform();
let command: string;

Expand Down Expand Up @@ -128,6 +148,7 @@ async function getProcesses(): Promise<ProcessInfo[]> {
}
}

debug(`Found ${processes.length} processes`);
return processes;
} catch (error) {
console.error("Error getting processes:", error);
Expand Down Expand Up @@ -229,6 +250,7 @@ async function getMemoryInfo(): Promise<{ total: number; free: number; used: num
}

async function getSystemMetrics(): Promise<SystemMetrics> {
debug("Collecting system metrics");
const cpuInfo = cpus();
const memInfo = await getMemoryInfo();
const processes = await getProcesses();
Expand Down Expand Up @@ -264,12 +286,18 @@ const server = Bun.serve({
"Access-Control-Allow-Headers": "Content-Type",
};

debug(`${req.method} ${url.pathname}`);

if (req.method === "OPTIONS") {
return new Response(null, { headers: corsHeaders });
}

if (url.pathname === "/api/metrics") {
const metrics = await getSystemMetrics();

// Write to InfluxDB timeseries database
writeMetrics(metrics);

return new Response(JSON.stringify(metrics), {
headers: {
"Content-Type": "application/json",
Expand All @@ -278,6 +306,32 @@ const server = Bun.serve({
});
}

if (url.pathname === "/api/history") {
if (!isInfluxEnabled()) {
return new Response(JSON.stringify({ error: "Timeseries database not configured" }), {
status: 503,
headers: { "Content-Type": "application/json", ...corsHeaders },
});
}

const measurement = url.searchParams.get("measurement") || "cpu";
const range = url.searchParams.get("range") || "-1h";
const field = url.searchParams.get("field") || "usage_percent";

try {
const data = await queryMetrics(measurement, range, field);
return new Response(JSON.stringify({ measurement, range, field, data }), {
headers: { "Content-Type": "application/json", ...corsHeaders },
});
} catch (error) {
debug("History query error:", error);
return new Response(JSON.stringify({ error: "Query failed" }), {
status: 500,
headers: { "Content-Type": "application/json", ...corsHeaders },
});
}
}

if (url.pathname === "/api/health") {
return new Response(JSON.stringify({ status: "ok" }), {
headers: {
Expand Down
151 changes: 151 additions & 0 deletions server/influxdb.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { InfluxDB, Point, WriteApi, QueryApi } from "@influxdata/influxdb-client";
import { hostname } from "os";

// Configuration via environment variables
const INFLUX_URL = process.env.INFLUX_URL || "http://localhost:8086";
const INFLUX_TOKEN = process.env.INFLUX_TOKEN || "";
const INFLUX_ORG = process.env.INFLUX_ORG || "btop";
const INFLUX_BUCKET = process.env.INFLUX_BUCKET || "btop-metrics";

let writeApi: WriteApi | null = null;
let queryApi: QueryApi | null = null;
let enabled = false;

export function initInfluxDB(debug: (...msgs: unknown[]) => void): boolean {
if (!INFLUX_TOKEN) {
console.log("ℹ️ InfluxDB disabled: set INFLUX_TOKEN to enable timeseries storage");
return false;
}

try {
const client = new InfluxDB({ url: INFLUX_URL, token: INFLUX_TOKEN });
writeApi = client.getWriteApi(INFLUX_ORG, INFLUX_BUCKET, "s");
writeApi.useDefaultTags({ host: hostname() });
queryApi = client.getQueryApi(INFLUX_ORG);
enabled = true;
debug("InfluxDB initialized", { url: INFLUX_URL, org: INFLUX_ORG, bucket: INFLUX_BUCKET });
console.log(`📊 InfluxDB connected: ${INFLUX_URL} (org: ${INFLUX_ORG}, bucket: ${INFLUX_BUCKET})`);
return true;
} catch (error) {
console.error("❌ Failed to initialize InfluxDB:", error);
return false;
}
}

export function writeMetrics(metrics: {
cpuUsage: { core: number; usage: number; user: number; system: number; idle: number }[];
totalMem: number;
usedMem: number;
freeMem: number;
memPercent: number;
loadAvg: number[];
processCount: number;
}): void {
if (!enabled || !writeApi) return;

const timestamp = new Date();

// Write aggregate CPU usage
const avgCpu = metrics.cpuUsage.length > 0
? metrics.cpuUsage.reduce((sum, c) => sum + c.usage, 0) / metrics.cpuUsage.length
: 0;

writeApi.writePoint(
new Point("cpu")
.floatField("usage_percent", avgCpu)
.timestamp(timestamp)
);

// Write per-core CPU usage
for (const core of metrics.cpuUsage) {
writeApi.writePoint(
new Point("cpu_core")
.tag("core", String(core.core))
.floatField("usage_percent", core.usage)
.floatField("user_percent", core.user)
.floatField("system_percent", core.system)
.floatField("idle_percent", core.idle)
.timestamp(timestamp)
);
}

// Write memory metrics
writeApi.writePoint(
new Point("memory")
.intField("total_bytes", metrics.totalMem)
.intField("used_bytes", metrics.usedMem)
.intField("free_bytes", metrics.freeMem)
.floatField("usage_percent", metrics.memPercent)
.timestamp(timestamp)
);

// Write load averages
writeApi.writePoint(
new Point("load")
.floatField("avg_1m", metrics.loadAvg[0] || 0)
.floatField("avg_5m", metrics.loadAvg[1] || 0)
.floatField("avg_15m", metrics.loadAvg[2] || 0)
.timestamp(timestamp)
);

// Write process count
writeApi.writePoint(
new Point("processes")
.intField("count", metrics.processCount)
.timestamp(timestamp)
);

// Flush writes (non-blocking)
writeApi.flush().catch((err) => {
console.error("InfluxDB write error:", err.message);
});
}

export async function queryMetrics(
measurement: string,
range: string = "-1h",
field: string = "usage_percent"
): Promise<{ time: string; value: number }[]> {
if (!enabled || !queryApi) {
return [];
}

const query = `
from(bucket: "${INFLUX_BUCKET}")
|> range(start: ${range})
|> filter(fn: (r) => r._measurement == "${measurement}")
|> filter(fn: (r) => r._field == "${field}")
|> aggregateWindow(every: 10s, fn: mean, createEmpty: false)
|> yield(name: "mean")
`;
Comment on lines +113 to +120
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Critical: Flux query injection vulnerability.

The measurement, range, and field parameters are directly interpolated into the Flux query string without sanitization. A malicious user could inject arbitrary Flux code via the /api/history endpoint query parameters.

For example, a request like /api/history?measurement=cpu") |> drop() |> from(bucket: "other could manipulate the query logic.

🔒 Proposed fix: Validate and sanitize inputs
+// Allowed measurements and fields for safe querying
+const ALLOWED_MEASUREMENTS = ["cpu", "cpu_core", "memory", "load", "processes"];
+const ALLOWED_FIELDS = ["usage_percent", "user_percent", "system_percent", "idle_percent", 
+                        "total_bytes", "used_bytes", "free_bytes", "avg_1m", "avg_5m", "avg_15m", "count"];
+const RANGE_PATTERN = /^-\d+[smhdw]$/; // e.g., -1h, -30m, -7d
+
 export async function queryMetrics(
   measurement: string,
   range: string = "-1h",
   field: string = "usage_percent"
 ): Promise<{ time: string; value: number }[]> {
   if (!enabled || !queryApi) {
     return [];
   }

+  // Validate inputs to prevent Flux injection
+  if (!ALLOWED_MEASUREMENTS.includes(measurement)) {
+    throw new Error(`Invalid measurement: ${measurement}`);
+  }
+  if (!ALLOWED_FIELDS.includes(field)) {
+    throw new Error(`Invalid field: ${field}`);
+  }
+  if (!RANGE_PATTERN.test(range)) {
+    throw new Error(`Invalid range format: ${range}`);
+  }
+
   const query = `
     from(bucket: "${INFLUX_BUCKET}")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/influxdb.ts` around lines 113 - 120, The Flux query is vulnerable
because measurement, range, and field are directly interpolated into the query
string; validate and sanitize these inputs before building the query (e.g.,
enforce a strict whitelist/regex for measurement and field like
/^[A-Za-z0-9_.-]+$/ or map them against known allowed names, and validate range
is a safe numeric/ISO value or convert it server-side rather than accepting raw
strings). Update the code that constructs the query (the template using
INFLUX_BUCKET, measurement, field, range) to reject or sanitize any values
failing the checks and only use validated values when interpolating to eliminate
possible Flux injection.


const results: { time: string; value: number }[] = [];

return new Promise((resolve, reject) => {
queryApi!.queryRows(query, {
next(row, tableMeta) {
const obj = tableMeta.toObject(row);
results.push({
time: obj._time as string,
value: obj._value as number,
});
},
error(error) {
reject(error);
},
complete() {
resolve(results);
},
});
});
}

export function isEnabled(): boolean {
return enabled;
}

export async function closeInfluxDB(): Promise<void> {
if (writeApi) {
await writeApi.close();
}
}
Loading