diff --git a/src/backend/auth/github.ts b/src/backend/auth/github.ts index ae3ce5e..d215c71 100644 --- a/src/backend/auth/github.ts +++ b/src/backend/auth/github.ts @@ -77,7 +77,7 @@ async function authenticateAndCreateJWT( console.log("DB Status for User:", userId, status); - ctx.response.headers.set("Access-Control-Allow-Origin", "*"); + if (status.matchedCount == 1 || status.upsertedId !== undefined) { const id_jwt = await createJWT(provider, userId); @@ -94,7 +94,6 @@ async function authenticateAndCreateJWT( } async function handleJwtAuthentication(ctx: Context) { - ctx.response.headers.set("Access-Control-Allow-Origin", "*"); if (!ctx.request.hasBody) { ctx.throw(415); } diff --git a/src/backend/health-api.ts b/src/backend/health-api.ts index 6bca7c7..027b999 100644 --- a/src/backend/health-api.ts +++ b/src/backend/health-api.ts @@ -6,17 +6,17 @@ import { type TimeStep, type TimeRange, } from "./utils/container-health.ts"; -import { restartContainer, getRestartCount } from "./utils/auto-restart.ts"; +import { restartContainer, stopContainer, getRestartCount, getStopCount, validateContainerName } from "./utils/auto-restart.ts"; import { getMonitorStatus, triggerHealthCheck } from "./health-monitor.ts"; import { checkJWT } from "./utils/jwt.ts"; const TIME_RANGE_PRESETS: Record = { - '1s': { step: '1s', duration: '5m' }, - '15s': { step: '15s', duration: '15m' }, - '1m': { step: '1m', duration: '1h' }, - '5m': { step: '5m', duration: '6h' }, - '1h': { step: '1h', duration: '24h' }, - '1d': { step: '1d', duration: '7d' }, + '1s': { step: '1s', duration: '5m' }, + '15s': { step: '15s', duration: '15m' }, + '1m': { step: '1m', duration: '1h' }, + '5m': { step: '5m', duration: '6h' }, + '1h': { step: '1h', duration: '24h' }, + '1d': { step: '1d', duration: '7d' }, }; @@ -31,7 +31,7 @@ export async function getContainerHealth(ctx: Context): Promise { const summary = await getHealthSummary(); - ctx.response.headers.set("Access-Control-Allow-Origin", "*"); + ctx.response.body = { total: summary.total, healthy: summary.healthy, @@ -44,6 +44,7 @@ export async function getContainerHealth(ctx: Context): Promise { memoryPercent: Math.round(c.memoryPercent * 100) / 100, memoryUsageMB: Math.round(c.memoryUsage / (1024 * 1024)), restartCount: getRestartCount(c.name), + stopCount: getStopCount(c.name), isHealthy: !isUnhealthy(c), lastUpdated: c.lastUpdated.toISOString(), })), @@ -67,7 +68,7 @@ export async function getContainerMetrics(ctx: Context): Promise { const history = await getContainerHistory(subdomain, range); - ctx.response.headers.set("Access-Control-Allow-Origin", "*"); + ctx.response.body = { subdomain, step: range.step, @@ -97,7 +98,7 @@ export async function getHealthDashboard(ctx: Context): Promise { const summary = await getHealthSummary(); const monitorStatus = getMonitorStatus(); - ctx.response.headers.set("Access-Control-Allow-Origin", "*"); + ctx.response.body = { overview: { total: summary.total, @@ -126,6 +127,55 @@ export async function getHealthDashboard(ctx: Context): Promise { export async function restartContainerHandler(ctx: Context): Promise { const subdomain = ctx.params.subdomain; + let safeSubdomain = ""; + try { + safeSubdomain = validateContainerName(subdomain); + } catch { + ctx.throw(400, "Invalid container identifier"); + } + + const body = await ctx.request.body().value; + let document; + try { + document = typeof body === 'string' ? JSON.parse(body) : body; + } catch { + document = body; + } + + const author = document?.author; + const token = document?.token; + const provider = document?.provider; + + if (author !== await checkJWT(provider, token)) { + ctx.throw(401); + } + + try { + await restartContainer(safeSubdomain); + + + ctx.response.body = { + status: "success", + message: `Container ${safeSubdomain} restart initiated`, + }; + } catch (error) { + console.error(`Failed to restart container ${safeSubdomain}`, error); + ctx.response.status = 500; + ctx.response.body = { + status: "error", + message: `Failed to restart ${safeSubdomain}`, + }; + } +} + +export async function stopContainerHandler(ctx: Context): Promise { + const subdomain = ctx.params.subdomain; + let safeSubdomain = ""; + try { + safeSubdomain = validateContainerName(subdomain); + } catch { + ctx.throw(400, "Invalid container identifier"); + } const body = await ctx.request.body().value; let document; @@ -144,18 +194,19 @@ export async function restartContainerHandler(ctx: Context): Promise { } try { - await restartContainer(subdomain); + await stopContainer(safeSubdomain); + - ctx.response.headers.set("Access-Control-Allow-Origin", "*"); ctx.response.body = { status: "success", - message: `Container ${subdomain} restart initiated`, + message: `Container ${safeSubdomain} stop initiated`, }; } catch (error) { + console.error(`Failed to stop container ${safeSubdomain}`, error); ctx.response.status = 500; ctx.response.body = { status: "error", - message: `Failed to restart ${subdomain}: ${error}`, + message: `Failed to stop ${safeSubdomain}`, }; } } @@ -181,7 +232,6 @@ export async function triggerHealthCheckHandler(ctx: Context): Promise { await triggerHealthCheck(); - ctx.response.headers.set("Access-Control-Allow-Origin", "*"); ctx.response.body = { status: "success", message: "Health check triggered", diff --git a/src/backend/main.ts b/src/backend/main.ts index eca17ba..223c88e 100644 --- a/src/backend/main.ts +++ b/src/backend/main.ts @@ -13,7 +13,7 @@ async function getSubdomains(ctx: Context) { ctx.throw(401); } const data = await getMaps(author, ADMIN_LIST!); - ctx.response.headers.set("Access-Control-Allow-Origin", "*"); + ctx.response.body = data.documents; } @@ -44,7 +44,7 @@ async function addSubdomain(ctx: Context) { ctx.throw(401); } const success: boolean = await addMaps(document); - ctx.response.headers.set("Access-Control-Allow-Origin", "*"); + if (success) { await addScript( @@ -93,7 +93,7 @@ async function deleteSubdomain(ctx: Context) { "info", ); } - ctx.response.headers.set("Access-Control-Allow-Origin", "*"); + ctx.response.body = data; } diff --git a/src/backend/server.ts b/src/backend/server.ts index f2c7c12..71b38b4 100644 --- a/src/backend/server.ts +++ b/src/backend/server.ts @@ -19,6 +19,7 @@ import { getContainerMetrics, getHealthDashboard, restartContainerHandler, + stopContainerHandler, triggerHealthCheckHandler, } from "./health-api.ts"; import { startHealthMonitor } from "./health-monitor.ts"; @@ -76,9 +77,10 @@ router .get("/health/summary", (ctx) => getHealthDashboard(ctx)) .get("/health/:subdomain/metrics", (ctx) => getContainerMetrics(ctx)) .post("/health/:subdomain/restart", (ctx) => restartContainerHandler(ctx)) + .post("/health/:subdomain/stop", (ctx) => stopContainerHandler(ctx)) .post("/health/check", (ctx) => triggerHealthCheckHandler(ctx)); -app.use(oakCors()); +app.use(oakCors({ origin: frontend })); app.use(router.routes()); app.use(router.allowedMethods()); diff --git a/src/backend/shell_scripts/restart.sh b/src/backend/shell_scripts/restart.sh new file mode 100755 index 0000000..cf89038 --- /dev/null +++ b/src/backend/shell_scripts/restart.sh @@ -0,0 +1,32 @@ +#!/bin/bash + +# This script takes in 1 command line argument (the container name) + +id -u +if [ "$#" -ne 1 ]; then + echo "Usage: $0 container_name" + exit 1 +fi + +arg1=$1 + +if [[ ! "$arg1" =~ ^[a-zA-Z0-9_.-]+$ ]]; then + echo "Error: Invalid container name '$arg1'. Allowed characters: letters, digits, '.', '-', '_'." >&2 + exit 1 +fi + +echo "Restarting... $arg1" + +sudo docker restart -- "$arg1" + +# Re-enable nginx routing (check for .conf suffix) +if [ ! -L "/etc/nginx/sites-enabled/$arg1.conf" ] && [ ! -f "/etc/nginx/sites-enabled/$arg1.conf" ] && [ -f "/etc/nginx/sites-available/$arg1.conf" ]; then + sudo ln -s -- "/etc/nginx/sites-available/$arg1.conf" "/etc/nginx/sites-enabled/$arg1.conf" +fi + +# Re-enable nginx routing (check for no suffix) +if [ ! -L "/etc/nginx/sites-enabled/$arg1" ] && [ ! -f "/etc/nginx/sites-enabled/$arg1" ] && [ -f "/etc/nginx/sites-available/$arg1" ]; then + sudo ln -s -- "/etc/nginx/sites-available/$arg1" "/etc/nginx/sites-enabled/$arg1" +fi + +sudo systemctl reload nginx diff --git a/src/backend/shell_scripts/stop.sh b/src/backend/shell_scripts/stop.sh new file mode 100755 index 0000000..bf2f14c --- /dev/null +++ b/src/backend/shell_scripts/stop.sh @@ -0,0 +1,31 @@ +#!/bin/bash + +# This script takes in 1 command line argument (the container name) + +id -u +if [ "$#" -ne 1 ]; then + echo "Usage: $0 container_name" + exit 1 +fi + +arg1=$1 + +if [[ ! "$arg1" =~ ^[a-zA-Z0-9_.-]+$ ]]; then + echo "Error: Invalid container name '$arg1'. Allowed characters: letters, digits, '.', '-', '_'." >&2 + exit 1 +fi + +echo "Stopping... $arg1" + +sudo docker stop -- "$arg1" + +# Disable nginx routing for this domain so it doesn't return 502 +if [ -L "/etc/nginx/sites-enabled/$arg1.conf" ] || [ -f "/etc/nginx/sites-enabled/$arg1.conf" ]; then + sudo rm -- "/etc/nginx/sites-enabled/$arg1.conf" +fi + +if [ -L "/etc/nginx/sites-enabled/$arg1" ] || [ -f "/etc/nginx/sites-enabled/$arg1" ]; then + sudo rm -- "/etc/nginx/sites-enabled/$arg1" +fi + +sudo systemctl reload nginx diff --git a/src/backend/tests/container-health.test.ts b/src/backend/tests/container-health.test.ts index 7448251..ee5ce57 100644 --- a/src/backend/tests/container-health.test.ts +++ b/src/backend/tests/container-health.test.ts @@ -4,6 +4,14 @@ import { assert, assertThrows, } from "https://deno.land/std@0.208.0/assert/mod.ts"; +import { + getRestartCount, + getStopCount, + restartContainer, + stopContainer, + resetContainerActionStatsForTest, + setCommandExecutorForTest, +} from "../utils/auto-restart.ts"; type ContainerStatus = 'running' | 'exited' | 'paused' | 'unhealthy' | 'unknown'; type TimeStep = '1s' | '15s' | '1m' | '5m' | '1h' | '1d'; @@ -623,39 +631,95 @@ Deno.test("TIME_RANGE_PRESETS - all presets defined", () => { }); Deno.test("getRestartCount - returns 0 for unknown container", () => { - const restartCounts = new Map(); - - const getRestartCount = (containerName: string): number => { - return restartCounts.get(containerName)?.count || 0; - }; + resetContainerActionStatsForTest(); assertEquals(getRestartCount("unknown-container"), 0); }); -Deno.test("getRestartCount - returns correct count", () => { - const restartCounts = new Map(); - restartCounts.set("test-container", { count: 3, lastRestart: new Date() }); +Deno.test("getRestartCount - returns correct count", async () => { + resetContainerActionStatsForTest(); + setCommandExecutorForTest(async () => {}); - const getRestartCount = (containerName: string): number => { - return restartCounts.get(containerName)?.count || 0; - }; + try { + await restartContainer("test-container"); + await restartContainer("test-container"); + await restartContainer("test-container"); + } finally { + setCommandExecutorForTest(null); + } assertEquals(getRestartCount("test-container"), 3); }); -Deno.test("getRestartCount - tracks multiple containers separately", () => { - const restartCounts = new Map(); - restartCounts.set("container-a", { count: 2, lastRestart: new Date() }); - restartCounts.set("container-b", { count: 5, lastRestart: new Date() }); +Deno.test("getRestartCount - tracks multiple containers separately", async () => { + resetContainerActionStatsForTest(); + setCommandExecutorForTest(async () => {}); - const getRestartCount = (containerName: string): number => { - return restartCounts.get(containerName)?.count || 0; - }; + try { + await restartContainer("container-a"); + await restartContainer("container-a"); + await restartContainer("container-b"); + await restartContainer("container-b"); + await restartContainer("container-b"); + await restartContainer("container-b"); + await restartContainer("container-b"); + } finally { + setCommandExecutorForTest(null); + } assertEquals(getRestartCount("container-a"), 2); assertEquals(getRestartCount("container-b"), 5); }); +Deno.test("getStopCount - returns correct count", async () => { + resetContainerActionStatsForTest(); + setCommandExecutorForTest(async () => {}); + + try { + await stopContainer("test-container"); + await stopContainer("test-container"); + await stopContainer("test-container"); + } finally { + setCommandExecutorForTest(null); + } + + assertEquals(getStopCount("test-container"), 3); +}); + +Deno.test("restartContainer - updates restart count", async () => { + resetContainerActionStatsForTest(); + let execCalledWith = ""; + setCommandExecutorForTest(async (cmd: string) => { + execCalledWith = cmd; + }); + + try { + await restartContainer("test-container"); + } finally { + setCommandExecutorForTest(null); + } + + assertEquals(execCalledWith, `bash -c "echo 'bash ../../src/backend/shell_scripts/restart.sh test-container' > /hostpipe/pipe"`); + assertEquals(getRestartCount("test-container"), 1); +}); + +Deno.test("stopContainer - updates stop count and calls stop script", async () => { + resetContainerActionStatsForTest(); + let execCalledWith = ""; + setCommandExecutorForTest(async (cmd: string) => { + execCalledWith = cmd; + }); + + try { + await stopContainer("test-container"); + } finally { + setCommandExecutorForTest(null); + } + + assertEquals(execCalledWith, `bash -c "echo 'bash ../../src/backend/shell_scripts/stop.sh test-container' > /hostpipe/pipe"`); + assertEquals(getStopCount("test-container"), 1); +}); + Deno.test("ContainerStats - validates all required fields", () => { const validStats: ContainerStats = { containerId: "abc123", diff --git a/src/backend/utils/auto-restart.ts b/src/backend/utils/auto-restart.ts index e3c99fc..6e81bab 100644 --- a/src/backend/utils/auto-restart.ts +++ b/src/backend/utils/auto-restart.ts @@ -1,25 +1,77 @@ import { exec } from "../dependencies.ts"; const restartCounts = new Map(); +const stopCounts = new Map(); +const SAFE_CONTAINER_NAME_PATTERN = /^[a-zA-Z0-9_.-]+$/; + +let execCommand = async (command: string): Promise => { + await exec(command); +}; + +export function validateContainerName(containerName: string): string { + if (!containerName || !SAFE_CONTAINER_NAME_PATTERN.test(containerName)) { + throw new Error(`Invalid container name: ${containerName}`); + } + return containerName; +} + +function buildHostPipeCommand(scriptName: "restart.sh" | "stop.sh", containerName: string): string { + const safeName = validateContainerName(containerName); + return `bash -c "echo 'bash ../../src/backend/shell_scripts/${scriptName} ${safeName}' > /hostpipe/pipe"`; +} + +export function setCommandExecutorForTest( + executor: ((command: string) => Promise) | null, +): void { + execCommand = executor ?? (async (command: string): Promise => { + await exec(command); + }); +} + +export function resetContainerActionStatsForTest(): void { + restartCounts.clear(); + stopCounts.clear(); +} export function getRestartCount(containerName: string): number { return restartCounts.get(containerName)?.count || 0; } +export function getStopCount(containerName: string): number { + return stopCounts.get(containerName)?.count || 0; +} + export async function restartContainer(containerName: string): Promise { + const safeContainerName = validateContainerName(containerName); + try { - await exec( - `bash -c "echo 'docker restart ${containerName}' > /hostpipe/pipe"` - ); + await execCommand(buildHostPipeCommand("restart.sh", safeContainerName)); - const current = restartCounts.get(containerName); - restartCounts.set(containerName, { + const current = restartCounts.get(safeContainerName); + restartCounts.set(safeContainerName, { count: (current?.count || 0) + 1, lastRestart: new Date(), }); } catch (error) { - console.error(`[Auto-Restart] Failed to restart ${containerName}:`, error); + console.error(`[Auto-Restart] Failed to restart ${safeContainerName}:`, error); + throw error; + } +} + +export async function stopContainer(containerName: string): Promise { + const safeContainerName = validateContainerName(containerName); + + try { + await execCommand(buildHostPipeCommand("stop.sh", safeContainerName)); + + const current = stopCounts.get(safeContainerName); + stopCounts.set(safeContainerName, { + count: (current?.count || 0) + 1, + lastStop: new Date(), + }); + } catch (error) { + console.error(`Failed to stop ${safeContainerName}:`, error); throw error; } } diff --git a/src/frontend/src/components/ContainerHealth.vue b/src/frontend/src/components/ContainerHealth.vue index e0fae88..8556f56 100644 --- a/src/frontend/src/components/ContainerHealth.vue +++ b/src/frontend/src/components/ContainerHealth.vue @@ -78,12 +78,16 @@
Restarts: {{ container.restartCount }} + Stops: {{ container.stopCount }}
+ @@ -140,6 +144,7 @@ interface Container { memoryPercent: number; memoryUsageMB: number; restartCount: number; + stopCount: number; isHealthy: boolean; lastUpdated: string; } @@ -342,23 +347,72 @@ export default defineComponent({ }; const restartContainer = async (container: Container) => { - if (!confirm(`Restart container ${container.subdomain}?`)) return; + const containerIdentifier = container.subdomain || container.name; + if (!confirm(`Restart container ${containerIdentifier}?`)) return; try { const token = localStorage.getItem('JWTUser') || ''; const provider = localStorage.getItem('provider') || ''; - - await fetch(`${BACKEND_URL}/health/${container.subdomain}/restart`, { + + const response = await fetch(`${BACKEND_URL}/health/${containerIdentifier}/restart`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ author: username.value, token: token, provider }), }); - alert(`Restart initiated for ${container.subdomain}`); + if (!response.ok) { + let message = `Failed to restart ${containerIdentifier}`; + try { + const data = await response.json(); + if (data?.message && typeof data.message === 'string') { + message = data.message; + } + } catch { + message = `${message} (HTTP ${response.status})`; + } + throw new Error(message); + } + + alert(`Restart initiated for ${containerIdentifier}`); fetchHealth(); } catch (error) { console.error('Failed to restart:', error); - alert('Failed to restart container'); + alert(error instanceof Error ? error.message : 'Failed to restart container'); + } + }; + + const stopContainer = async (container: Container) => { + const containerIdentifier = container.subdomain || container.name; + if (!confirm(`Stop container ${containerIdentifier}?`)) return; + + try { + const token = localStorage.getItem('JWTUser') || ''; + const provider = localStorage.getItem('provider') || ''; + + const response = await fetch(`${BACKEND_URL}/health/${containerIdentifier}/stop`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ author: username.value, token: token, provider }), + }); + + if (!response.ok) { + let message = `Failed to stop ${containerIdentifier}`; + try { + const data = await response.json(); + if (data?.message && typeof data.message === 'string') { + message = data.message; + } + } catch { + message = `${message} (HTTP ${response.status})`; + } + throw new Error(message); + } + + alert(`Stop initiated for ${containerIdentifier}`); + fetchHealth(); + } catch (error) { + console.error('Failed to stop:', error); + alert(error instanceof Error ? error.message : 'Failed to stop container'); } }; @@ -382,6 +436,7 @@ export default defineComponent({ closeMetrics, loadMetrics, restartContainer, + stopContainer, }; }, }); @@ -570,7 +625,7 @@ export default defineComponent({ gap: 0.75rem; } -.view-btn, .restart-btn { +.view-btn, .restart-btn, .stop-btn { flex: 1; padding: 0.5rem; border-radius: 6px; @@ -585,6 +640,12 @@ export default defineComponent({ border: 1px solid #bfdbfe; } +.stop-btn { + background: #fee2e2; + color: #991b1b; + border: 1px solid #fecaca; +} + .restart-btn { background: #fef3c7; color: #92400e;