From 3b8271f3415b16e74388acf7226774b0fd9b9e90 Mon Sep 17 00:00:00 2001 From: Annonnymmousss Date: Fri, 20 Mar 2026 00:30:53 +0530 Subject: [PATCH 1/5] fix: container restart --- src/backend/shell_scripts/restart.sh | 15 +++++++++++++++ src/backend/utils/auto-restart.ts | 2 +- 2 files changed, 16 insertions(+), 1 deletion(-) create mode 100755 src/backend/shell_scripts/restart.sh diff --git a/src/backend/shell_scripts/restart.sh b/src/backend/shell_scripts/restart.sh new file mode 100755 index 0000000..e4afa13 --- /dev/null +++ b/src/backend/shell_scripts/restart.sh @@ -0,0 +1,15 @@ +#!/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 + +echo "Restarting... $arg1" + +sudo docker restart $arg1 diff --git a/src/backend/utils/auto-restart.ts b/src/backend/utils/auto-restart.ts index e3c99fc..dc191bc 100644 --- a/src/backend/utils/auto-restart.ts +++ b/src/backend/utils/auto-restart.ts @@ -9,7 +9,7 @@ export function getRestartCount(containerName: string): number { export async function restartContainer(containerName: string): Promise { try { await exec( - `bash -c "echo 'docker restart ${containerName}' > /hostpipe/pipe"` + `bash -c "echo 'bash ../../src/backend/shell_scripts/restart.sh ${containerName}' > /hostpipe/pipe"` ); const current = restartCounts.get(containerName); From 07c005fc5353d777a3347750f0d9671403830409 Mon Sep 17 00:00:00 2001 From: Annonnymmousss Date: Fri, 20 Mar 2026 06:38:50 +0530 Subject: [PATCH 2/5] feat: stop container --- src/backend/health-api.ts | 51 ++++++++++++-- src/backend/server.ts | 2 + src/backend/shell_scripts/restart.sh | 6 ++ src/backend/shell_scripts/stop.sh | 21 ++++++ src/backend/tests/container-health.test.ts | 68 +++++++++++++++++++ src/backend/utils/auto-restart.ts | 22 ++++++ .../src/components/ContainerHealth.vue | 45 ++++++++++-- 7 files changed, 203 insertions(+), 12 deletions(-) create mode 100755 src/backend/shell_scripts/stop.sh diff --git a/src/backend/health-api.ts b/src/backend/health-api.ts index 6bca7c7..5dc16e9 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 } 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' }, }; @@ -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(), })), @@ -160,6 +161,42 @@ export async function restartContainerHandler(ctx: Context): Promise { } } +export async function stopContainerHandler(ctx: Context): Promise { + const subdomain = ctx.params.subdomain; + + 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 stopContainer(subdomain); + + ctx.response.headers.set("Access-Control-Allow-Origin", "*"); + ctx.response.body = { + status: "success", + message: `Container ${subdomain} stop initiated`, + }; + } catch (error) { + ctx.response.status = 500; + ctx.response.body = { + status: "error", + message: `Failed to stop ${subdomain}: ${error}`, + }; + } +} + export async function triggerHealthCheckHandler(ctx: Context): Promise { const body = await ctx.request.body().value; diff --git a/src/backend/server.ts b/src/backend/server.ts index f2c7c12..7ac53ad 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,6 +77,7 @@ 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()); diff --git a/src/backend/shell_scripts/restart.sh b/src/backend/shell_scripts/restart.sh index e4afa13..dc2b74a 100755 --- a/src/backend/shell_scripts/restart.sh +++ b/src/backend/shell_scripts/restart.sh @@ -13,3 +13,9 @@ arg1=$1 echo "Restarting... $arg1" sudo docker restart $arg1 + +# Re-enable nginx routing if it was previously stopped +if [ ! -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 + sudo systemctl reload nginx +fi diff --git a/src/backend/shell_scripts/stop.sh b/src/backend/shell_scripts/stop.sh new file mode 100755 index 0000000..a0e277e --- /dev/null +++ b/src/backend/shell_scripts/stop.sh @@ -0,0 +1,21 @@ +#!/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 + +echo "Stopping... $arg1" + +sudo docker stop $arg1 + +# Disable nginx routing for this domain so it doesn't return 502 +if [ -f "/etc/nginx/sites-enabled/$arg1.conf" ]; then + sudo rm /etc/nginx/sites-enabled/$arg1.conf + sudo systemctl reload nginx +fi diff --git a/src/backend/tests/container-health.test.ts b/src/backend/tests/container-health.test.ts index 7448251..e4ead8a 100644 --- a/src/backend/tests/container-health.test.ts +++ b/src/backend/tests/container-health.test.ts @@ -656,6 +656,74 @@ Deno.test("getRestartCount - tracks multiple containers separately", () => { assertEquals(getRestartCount("container-b"), 5); }); +Deno.test("getStopCount - returns correct count", () => { + const stopCounts = new Map(); + stopCounts.set("test-container", { count: 3, lastStop: new Date() }); + + const getStopCount = (containerName: string): number => { + return stopCounts.get(containerName)?.count || 0; + }; + + assertEquals(getStopCount("test-container"), 3); +}); + +Deno.test("restartContainer - updates restart count", async () => { + const restartCounts = new Map(); + restartCounts.set("test-container", { count: 1, lastRestart: new Date(Date.now() - 10000) }); + + let execCalledWith = ""; + const mockExec = async (cmd: string) => { + execCalledWith = cmd; + }; + + const restartContainer = async (containerName: string): Promise => { + await mockExec(`bash -c "echo 'bash ../../src/backend/shell_scripts/restart.sh ${containerName}' > /hostpipe/pipe"`); + const current = restartCounts.get(containerName); + restartCounts.set(containerName, { + count: (current?.count || 0) + 1, + lastRestart: new Date(), + }); + }; + + const before = new Date(); + await restartContainer("test-container"); + const after = new Date(); + + assertEquals(execCalledWith, `bash -c "echo 'bash ../../src/backend/shell_scripts/restart.sh test-container' > /hostpipe/pipe"`); + + const stats = restartCounts.get("test-container"); + assertExists(stats); + assertEquals(stats.count, 2); + assert(stats.lastRestart.getTime() >= before.getTime() && stats.lastRestart.getTime() <= after.getTime()); +}); + +Deno.test("stopContainer - updates stop count and calls stop script", async () => { + const stopCounts = new Map(); + let execCalledWith = ""; + const mockExec = async (cmd: string) => { + execCalledWith = cmd; + }; + + const stopContainer = async (containerName: string): Promise => { + await mockExec(`bash -c "echo 'bash ../../src/backend/shell_scripts/stop.sh ${containerName}' > /hostpipe/pipe"`); + const current = stopCounts.get(containerName); + stopCounts.set(containerName, { + count: (current?.count || 0) + 1, + lastStop: new Date(), + }); + }; + + const before = new Date(); + await stopContainer("test-container"); + const after = new Date(); + + assertEquals(execCalledWith, `bash -c "echo 'bash ../../src/backend/shell_scripts/stop.sh test-container' > /hostpipe/pipe"`); + const stats = stopCounts.get("test-container"); + assertExists(stats); + assertEquals(stats.count, 1); + assert(stats.lastStop.getTime() >= before.getTime() && stats.lastStop.getTime() <= after.getTime()); +}); + 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 dc191bc..230e616 100644 --- a/src/backend/utils/auto-restart.ts +++ b/src/backend/utils/auto-restart.ts @@ -1,11 +1,16 @@ import { exec } from "../dependencies.ts"; const restartCounts = new Map(); +const stopCounts = new Map(); 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 { try { await exec( @@ -23,3 +28,20 @@ export async function restartContainer(containerName: string): Promise { throw error; } } + +export async function stopContainer(containerName: string): Promise { + try { + await exec( + `bash -c "echo 'bash ../../src/backend/shell_scripts/stop.sh ${containerName}' > /hostpipe/pipe"` + ); + + const current = stopCounts.get(containerName); + stopCounts.set(containerName, { + count: (current?.count || 0) + 1, + lastStop: new Date(), + }); + } catch (error) { + console.error(`Failed to stop ${containerName}:`, error); + throw error; + } +} diff --git a/src/frontend/src/components/ContainerHealth.vue b/src/frontend/src/components/ContainerHealth.vue index e0fae88..a5450b8 100644 --- a/src/frontend/src/components/ContainerHealth.vue +++ b/src/frontend/src/components/ContainerHealth.vue @@ -77,13 +77,17 @@
- Restarts: {{ container.restartCount }} + 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,19 +347,20 @@ 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`, { + 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}`); + alert(`Restart initiated for ${containerIdentifier}`); fetchHealth(); } catch (error) { console.error('Failed to restart:', error); @@ -362,6 +368,28 @@ export default defineComponent({ } }; + 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') || ''; + + await fetch(`${BACKEND_URL}/health/${containerIdentifier}/stop`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ author: username.value, token: token, provider }), + }); + + alert(`Stop initiated for ${containerIdentifier}`); + fetchHealth(); + } catch (error) { + console.error('Failed to stop:', error); + alert('Failed to stop container'); + } + }; + onMounted(async() => { await fetchUsername(); fetchHealth(); @@ -382,6 +410,7 @@ export default defineComponent({ closeMetrics, loadMetrics, restartContainer, + stopContainer, }; }, }); @@ -570,7 +599,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 +614,12 @@ export default defineComponent({ border: 1px solid #bfdbfe; } +.stop-btn { + background: #fee2e2; + color: #991b1b; + border: 1px solid #fecaca; +} + .restart-btn { background: #fef3c7; color: #92400e; From f7d35ae39dea84790ffd041e66ec571007d138b9 Mon Sep 17 00:00:00 2001 From: Annonnymmousss Date: Fri, 20 Mar 2026 06:47:03 +0530 Subject: [PATCH 3/5] fix: nginx --- src/backend/shell_scripts/restart.sh | 12 +++++++++--- src/backend/shell_scripts/stop.sh | 9 +++++++-- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/backend/shell_scripts/restart.sh b/src/backend/shell_scripts/restart.sh index dc2b74a..0d84d1b 100755 --- a/src/backend/shell_scripts/restart.sh +++ b/src/backend/shell_scripts/restart.sh @@ -14,8 +14,14 @@ echo "Restarting... $arg1" sudo docker restart $arg1 -# Re-enable nginx routing if it was previously stopped -if [ ! -f "/etc/nginx/sites-enabled/$arg1.conf" ] && [ -f "/etc/nginx/sites-available/$arg1.conf" ]; then +# 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 - sudo systemctl reload nginx 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 index a0e277e..313cfec 100755 --- a/src/backend/shell_scripts/stop.sh +++ b/src/backend/shell_scripts/stop.sh @@ -15,7 +15,12 @@ echo "Stopping... $arg1" sudo docker stop $arg1 # Disable nginx routing for this domain so it doesn't return 502 -if [ -f "/etc/nginx/sites-enabled/$arg1.conf" ]; then +if [ -L "/etc/nginx/sites-enabled/$arg1.conf" ] || [ -f "/etc/nginx/sites-enabled/$arg1.conf" ]; then sudo rm /etc/nginx/sites-enabled/$arg1.conf - sudo systemctl reload nginx 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 From 2e707dfd55df5d81670ab111201e97f6298b7e31 Mon Sep 17 00:00:00 2001 From: Annonnymmousss Date: Sat, 21 Mar 2026 10:45:39 +0530 Subject: [PATCH 4/5] chore: CORS permission changed from * to frontend --- src/backend/auth/github.ts | 3 +-- src/backend/health-api.ts | 11 +++++------ src/backend/main.ts | 6 +++--- src/backend/server.ts | 2 +- 4 files changed, 10 insertions(+), 12 deletions(-) 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 5dc16e9..d211752 100644 --- a/src/backend/health-api.ts +++ b/src/backend/health-api.ts @@ -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, @@ -68,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, @@ -98,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, @@ -147,7 +147,7 @@ export async function restartContainerHandler(ctx: Context): Promise { try { await restartContainer(subdomain); - ctx.response.headers.set("Access-Control-Allow-Origin", "*"); + ctx.response.body = { status: "success", message: `Container ${subdomain} restart initiated`, @@ -183,7 +183,7 @@ export async function stopContainerHandler(ctx: Context): Promise { try { await stopContainer(subdomain); - ctx.response.headers.set("Access-Control-Allow-Origin", "*"); + ctx.response.body = { status: "success", message: `Container ${subdomain} stop initiated`, @@ -218,7 +218,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 7ac53ad..71b38b4 100644 --- a/src/backend/server.ts +++ b/src/backend/server.ts @@ -80,7 +80,7 @@ router .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()); From f4bba32b16af3010e0c93b2f60cfa5e1e5b61853 Mon Sep 17 00:00:00 2001 From: Annonnymmousss Date: Wed, 8 Apr 2026 15:33:16 +0530 Subject: [PATCH 5/5] chore: copilot fix --- src/backend/health-api.ts | 28 +++- src/backend/shell_scripts/restart.sh | 11 +- src/backend/shell_scripts/stop.sh | 11 +- src/backend/tests/container-health.test.ts | 126 +++++++++--------- src/backend/utils/auto-restart.ts | 54 ++++++-- .../src/components/ContainerHealth.vue | 40 +++++- 6 files changed, 173 insertions(+), 97 deletions(-) diff --git a/src/backend/health-api.ts b/src/backend/health-api.ts index d211752..027b999 100644 --- a/src/backend/health-api.ts +++ b/src/backend/health-api.ts @@ -6,7 +6,7 @@ import { type TimeStep, type TimeRange, } from "./utils/container-health.ts"; -import { restartContainer, stopContainer, getRestartCount, getStopCount } 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"; @@ -127,6 +127,12 @@ 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; @@ -145,24 +151,31 @@ export async function restartContainerHandler(ctx: Context): Promise { } try { - await restartContainer(subdomain); + await restartContainer(safeSubdomain); ctx.response.body = { status: "success", - message: `Container ${subdomain} restart initiated`, + 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 ${subdomain}: ${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; @@ -181,18 +194,19 @@ export async function stopContainerHandler(ctx: Context): Promise { } try { - await stopContainer(subdomain); + await stopContainer(safeSubdomain); ctx.response.body = { status: "success", - message: `Container ${subdomain} stop 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 stop ${subdomain}: ${error}`, + message: `Failed to stop ${safeSubdomain}`, }; } } diff --git a/src/backend/shell_scripts/restart.sh b/src/backend/shell_scripts/restart.sh index 0d84d1b..cf89038 100755 --- a/src/backend/shell_scripts/restart.sh +++ b/src/backend/shell_scripts/restart.sh @@ -10,18 +10,23 @@ 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 +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 + 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 + 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 index 313cfec..bf2f14c 100755 --- a/src/backend/shell_scripts/stop.sh +++ b/src/backend/shell_scripts/stop.sh @@ -10,17 +10,22 @@ 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 +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 + 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 + 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 e4ead8a..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,105 +631,93 @@ 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", () => { - const stopCounts = new Map(); - stopCounts.set("test-container", { count: 3, lastStop: new Date() }); +Deno.test("getStopCount - returns correct count", async () => { + resetContainerActionStatsForTest(); + setCommandExecutorForTest(async () => {}); - const getStopCount = (containerName: string): number => { - return stopCounts.get(containerName)?.count || 0; - }; + 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 () => { - const restartCounts = new Map(); - restartCounts.set("test-container", { count: 1, lastRestart: new Date(Date.now() - 10000) }); - + resetContainerActionStatsForTest(); let execCalledWith = ""; - const mockExec = async (cmd: string) => { + setCommandExecutorForTest(async (cmd: string) => { execCalledWith = cmd; - }; - - const restartContainer = async (containerName: string): Promise => { - await mockExec(`bash -c "echo 'bash ../../src/backend/shell_scripts/restart.sh ${containerName}' > /hostpipe/pipe"`); - const current = restartCounts.get(containerName); - restartCounts.set(containerName, { - count: (current?.count || 0) + 1, - lastRestart: new Date(), - }); - }; + }); - const before = new Date(); - await restartContainer("test-container"); - const after = new Date(); + try { + await restartContainer("test-container"); + } finally { + setCommandExecutorForTest(null); + } assertEquals(execCalledWith, `bash -c "echo 'bash ../../src/backend/shell_scripts/restart.sh test-container' > /hostpipe/pipe"`); - - const stats = restartCounts.get("test-container"); - assertExists(stats); - assertEquals(stats.count, 2); - assert(stats.lastRestart.getTime() >= before.getTime() && stats.lastRestart.getTime() <= after.getTime()); + assertEquals(getRestartCount("test-container"), 1); }); Deno.test("stopContainer - updates stop count and calls stop script", async () => { - const stopCounts = new Map(); + resetContainerActionStatsForTest(); let execCalledWith = ""; - const mockExec = async (cmd: string) => { + setCommandExecutorForTest(async (cmd: string) => { execCalledWith = cmd; - }; - - const stopContainer = async (containerName: string): Promise => { - await mockExec(`bash -c "echo 'bash ../../src/backend/shell_scripts/stop.sh ${containerName}' > /hostpipe/pipe"`); - const current = stopCounts.get(containerName); - stopCounts.set(containerName, { - count: (current?.count || 0) + 1, - lastStop: new Date(), - }); - }; + }); - const before = new Date(); - await stopContainer("test-container"); - const after = new Date(); + try { + await stopContainer("test-container"); + } finally { + setCommandExecutorForTest(null); + } assertEquals(execCalledWith, `bash -c "echo 'bash ../../src/backend/shell_scripts/stop.sh test-container' > /hostpipe/pipe"`); - const stats = stopCounts.get("test-container"); - assertExists(stats); - assertEquals(stats.count, 1); - assert(stats.lastStop.getTime() >= before.getTime() && stats.lastStop.getTime() <= after.getTime()); + assertEquals(getStopCount("test-container"), 1); }); Deno.test("ContainerStats - validates all required fields", () => { diff --git a/src/backend/utils/auto-restart.ts b/src/backend/utils/auto-restart.ts index 230e616..6e81bab 100644 --- a/src/backend/utils/auto-restart.ts +++ b/src/backend/utils/auto-restart.ts @@ -2,6 +2,36 @@ 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; @@ -12,36 +42,36 @@ export function getStopCount(containerName: string): number { } export async function restartContainer(containerName: string): Promise { + const safeContainerName = validateContainerName(containerName); + try { - await exec( - `bash -c "echo 'bash ../../src/backend/shell_scripts/restart.sh ${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 exec( - `bash -c "echo 'bash ../../src/backend/shell_scripts/stop.sh ${containerName}' > /hostpipe/pipe"` - ); + await execCommand(buildHostPipeCommand("stop.sh", safeContainerName)); - const current = stopCounts.get(containerName); - stopCounts.set(containerName, { + const current = stopCounts.get(safeContainerName); + stopCounts.set(safeContainerName, { count: (current?.count || 0) + 1, lastStop: new Date(), }); } catch (error) { - console.error(`Failed to stop ${containerName}:`, 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 a5450b8..8556f56 100644 --- a/src/frontend/src/components/ContainerHealth.vue +++ b/src/frontend/src/components/ContainerHealth.vue @@ -77,7 +77,7 @@
- Restarts: {{ container.restartCount }} | + Restarts: {{ container.restartCount }} Stops: {{ container.stopCount }}
@@ -353,18 +353,31 @@ export default defineComponent({ try { const token = localStorage.getItem('JWTUser') || ''; const provider = localStorage.getItem('provider') || ''; - - await fetch(`${BACKEND_URL}/health/${containerIdentifier}/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 }), }); + 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'); } }; @@ -375,18 +388,31 @@ export default defineComponent({ try { const token = localStorage.getItem('JWTUser') || ''; const provider = localStorage.getItem('provider') || ''; - - await fetch(`${BACKEND_URL}/health/${containerIdentifier}/stop`, { + + 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('Failed to stop container'); + alert(error instanceof Error ? error.message : 'Failed to stop container'); } };