Skip to content

Commit f4bba32

Browse files
chore: copilot fix
1 parent 2e707df commit f4bba32

6 files changed

Lines changed: 173 additions & 97 deletions

File tree

src/backend/health-api.ts

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
type TimeStep,
77
type TimeRange,
88
} from "./utils/container-health.ts";
9-
import { restartContainer, stopContainer, getRestartCount, getStopCount } from "./utils/auto-restart.ts";
9+
import { restartContainer, stopContainer, getRestartCount, getStopCount, validateContainerName } from "./utils/auto-restart.ts";
1010
import { getMonitorStatus, triggerHealthCheck } from "./health-monitor.ts";
1111
import { checkJWT } from "./utils/jwt.ts";
1212

@@ -127,6 +127,12 @@ export async function getHealthDashboard(ctx: Context): Promise<void> {
127127

128128
export async function restartContainerHandler(ctx: Context): Promise<void> {
129129
const subdomain = ctx.params.subdomain;
130+
let safeSubdomain = "";
131+
try {
132+
safeSubdomain = validateContainerName(subdomain);
133+
} catch {
134+
ctx.throw(400, "Invalid container identifier");
135+
}
130136

131137
const body = await ctx.request.body().value;
132138
let document;
@@ -145,24 +151,31 @@ export async function restartContainerHandler(ctx: Context): Promise<void> {
145151
}
146152

147153
try {
148-
await restartContainer(subdomain);
154+
await restartContainer(safeSubdomain);
149155

150156

151157
ctx.response.body = {
152158
status: "success",
153-
message: `Container ${subdomain} restart initiated`,
159+
message: `Container ${safeSubdomain} restart initiated`,
154160
};
155161
} catch (error) {
162+
console.error(`Failed to restart container ${safeSubdomain}`, error);
156163
ctx.response.status = 500;
157164
ctx.response.body = {
158165
status: "error",
159-
message: `Failed to restart ${subdomain}: ${error}`,
166+
message: `Failed to restart ${safeSubdomain}`,
160167
};
161168
}
162169
}
163170

164171
export async function stopContainerHandler(ctx: Context): Promise<void> {
165172
const subdomain = ctx.params.subdomain;
173+
let safeSubdomain = "";
174+
try {
175+
safeSubdomain = validateContainerName(subdomain);
176+
} catch {
177+
ctx.throw(400, "Invalid container identifier");
178+
}
166179

167180
const body = await ctx.request.body().value;
168181
let document;
@@ -181,18 +194,19 @@ export async function stopContainerHandler(ctx: Context): Promise<void> {
181194
}
182195

183196
try {
184-
await stopContainer(subdomain);
197+
await stopContainer(safeSubdomain);
185198

186199

187200
ctx.response.body = {
188201
status: "success",
189-
message: `Container ${subdomain} stop initiated`,
202+
message: `Container ${safeSubdomain} stop initiated`,
190203
};
191204
} catch (error) {
205+
console.error(`Failed to stop container ${safeSubdomain}`, error);
192206
ctx.response.status = 500;
193207
ctx.response.body = {
194208
status: "error",
195-
message: `Failed to stop ${subdomain}: ${error}`,
209+
message: `Failed to stop ${safeSubdomain}`,
196210
};
197211
}
198212
}

src/backend/shell_scripts/restart.sh

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,23 @@ fi
1010

1111
arg1=$1
1212

13+
if [[ ! "$arg1" =~ ^[a-zA-Z0-9_.-]+$ ]]; then
14+
echo "Error: Invalid container name '$arg1'. Allowed characters: letters, digits, '.', '-', '_'." >&2
15+
exit 1
16+
fi
17+
1318
echo "Restarting... $arg1"
1419

15-
sudo docker restart $arg1
20+
sudo docker restart -- "$arg1"
1621

1722
# Re-enable nginx routing (check for .conf suffix)
1823
if [ ! -L "/etc/nginx/sites-enabled/$arg1.conf" ] && [ ! -f "/etc/nginx/sites-enabled/$arg1.conf" ] && [ -f "/etc/nginx/sites-available/$arg1.conf" ]; then
19-
sudo ln -s /etc/nginx/sites-available/$arg1.conf /etc/nginx/sites-enabled/$arg1.conf
24+
sudo ln -s -- "/etc/nginx/sites-available/$arg1.conf" "/etc/nginx/sites-enabled/$arg1.conf"
2025
fi
2126

2227
# Re-enable nginx routing (check for no suffix)
2328
if [ ! -L "/etc/nginx/sites-enabled/$arg1" ] && [ ! -f "/etc/nginx/sites-enabled/$arg1" ] && [ -f "/etc/nginx/sites-available/$arg1" ]; then
24-
sudo ln -s /etc/nginx/sites-available/$arg1 /etc/nginx/sites-enabled/$arg1
29+
sudo ln -s -- "/etc/nginx/sites-available/$arg1" "/etc/nginx/sites-enabled/$arg1"
2530
fi
2631

2732
sudo systemctl reload nginx

src/backend/shell_scripts/stop.sh

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,22 @@ fi
1010

1111
arg1=$1
1212

13+
if [[ ! "$arg1" =~ ^[a-zA-Z0-9_.-]+$ ]]; then
14+
echo "Error: Invalid container name '$arg1'. Allowed characters: letters, digits, '.', '-', '_'." >&2
15+
exit 1
16+
fi
17+
1318
echo "Stopping... $arg1"
1419

15-
sudo docker stop $arg1
20+
sudo docker stop -- "$arg1"
1621

1722
# Disable nginx routing for this domain so it doesn't return 502
1823
if [ -L "/etc/nginx/sites-enabled/$arg1.conf" ] || [ -f "/etc/nginx/sites-enabled/$arg1.conf" ]; then
19-
sudo rm /etc/nginx/sites-enabled/$arg1.conf
24+
sudo rm -- "/etc/nginx/sites-enabled/$arg1.conf"
2025
fi
2126

2227
if [ -L "/etc/nginx/sites-enabled/$arg1" ] || [ -f "/etc/nginx/sites-enabled/$arg1" ]; then
23-
sudo rm /etc/nginx/sites-enabled/$arg1
28+
sudo rm -- "/etc/nginx/sites-enabled/$arg1"
2429
fi
2530

2631
sudo systemctl reload nginx

src/backend/tests/container-health.test.ts

Lines changed: 61 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,14 @@ import {
44
assert,
55
assertThrows,
66
} from "https://deno.land/std@0.208.0/assert/mod.ts";
7+
import {
8+
getRestartCount,
9+
getStopCount,
10+
restartContainer,
11+
stopContainer,
12+
resetContainerActionStatsForTest,
13+
setCommandExecutorForTest,
14+
} from "../utils/auto-restart.ts";
715

816
type ContainerStatus = 'running' | 'exited' | 'paused' | 'unhealthy' | 'unknown';
917
type TimeStep = '1s' | '15s' | '1m' | '5m' | '1h' | '1d';
@@ -623,105 +631,93 @@ Deno.test("TIME_RANGE_PRESETS - all presets defined", () => {
623631
});
624632

625633
Deno.test("getRestartCount - returns 0 for unknown container", () => {
626-
const restartCounts = new Map<string, { count: number; lastRestart: Date }>();
627-
628-
const getRestartCount = (containerName: string): number => {
629-
return restartCounts.get(containerName)?.count || 0;
630-
};
634+
resetContainerActionStatsForTest();
631635

632636
assertEquals(getRestartCount("unknown-container"), 0);
633637
});
634638

635-
Deno.test("getRestartCount - returns correct count", () => {
636-
const restartCounts = new Map<string, { count: number; lastRestart: Date }>();
637-
restartCounts.set("test-container", { count: 3, lastRestart: new Date() });
639+
Deno.test("getRestartCount - returns correct count", async () => {
640+
resetContainerActionStatsForTest();
641+
setCommandExecutorForTest(async () => {});
638642

639-
const getRestartCount = (containerName: string): number => {
640-
return restartCounts.get(containerName)?.count || 0;
641-
};
643+
try {
644+
await restartContainer("test-container");
645+
await restartContainer("test-container");
646+
await restartContainer("test-container");
647+
} finally {
648+
setCommandExecutorForTest(null);
649+
}
642650

643651
assertEquals(getRestartCount("test-container"), 3);
644652
});
645653

646-
Deno.test("getRestartCount - tracks multiple containers separately", () => {
647-
const restartCounts = new Map<string, { count: number; lastRestart: Date }>();
648-
restartCounts.set("container-a", { count: 2, lastRestart: new Date() });
649-
restartCounts.set("container-b", { count: 5, lastRestart: new Date() });
654+
Deno.test("getRestartCount - tracks multiple containers separately", async () => {
655+
resetContainerActionStatsForTest();
656+
setCommandExecutorForTest(async () => {});
650657

651-
const getRestartCount = (containerName: string): number => {
652-
return restartCounts.get(containerName)?.count || 0;
653-
};
658+
try {
659+
await restartContainer("container-a");
660+
await restartContainer("container-a");
661+
await restartContainer("container-b");
662+
await restartContainer("container-b");
663+
await restartContainer("container-b");
664+
await restartContainer("container-b");
665+
await restartContainer("container-b");
666+
} finally {
667+
setCommandExecutorForTest(null);
668+
}
654669

655670
assertEquals(getRestartCount("container-a"), 2);
656671
assertEquals(getRestartCount("container-b"), 5);
657672
});
658673

659-
Deno.test("getStopCount - returns correct count", () => {
660-
const stopCounts = new Map<string, { count: number; lastStop: Date }>();
661-
stopCounts.set("test-container", { count: 3, lastStop: new Date() });
674+
Deno.test("getStopCount - returns correct count", async () => {
675+
resetContainerActionStatsForTest();
676+
setCommandExecutorForTest(async () => {});
662677

663-
const getStopCount = (containerName: string): number => {
664-
return stopCounts.get(containerName)?.count || 0;
665-
};
678+
try {
679+
await stopContainer("test-container");
680+
await stopContainer("test-container");
681+
await stopContainer("test-container");
682+
} finally {
683+
setCommandExecutorForTest(null);
684+
}
666685

667686
assertEquals(getStopCount("test-container"), 3);
668687
});
669688

670689
Deno.test("restartContainer - updates restart count", async () => {
671-
const restartCounts = new Map<string, { count: number; lastRestart: Date }>();
672-
restartCounts.set("test-container", { count: 1, lastRestart: new Date(Date.now() - 10000) });
673-
690+
resetContainerActionStatsForTest();
674691
let execCalledWith = "";
675-
const mockExec = async (cmd: string) => {
692+
setCommandExecutorForTest(async (cmd: string) => {
676693
execCalledWith = cmd;
677-
};
678-
679-
const restartContainer = async (containerName: string): Promise<void> => {
680-
await mockExec(`bash -c "echo 'bash ../../src/backend/shell_scripts/restart.sh ${containerName}' > /hostpipe/pipe"`);
681-
const current = restartCounts.get(containerName);
682-
restartCounts.set(containerName, {
683-
count: (current?.count || 0) + 1,
684-
lastRestart: new Date(),
685-
});
686-
};
694+
});
687695

688-
const before = new Date();
689-
await restartContainer("test-container");
690-
const after = new Date();
696+
try {
697+
await restartContainer("test-container");
698+
} finally {
699+
setCommandExecutorForTest(null);
700+
}
691701

692702
assertEquals(execCalledWith, `bash -c "echo 'bash ../../src/backend/shell_scripts/restart.sh test-container' > /hostpipe/pipe"`);
693-
694-
const stats = restartCounts.get("test-container");
695-
assertExists(stats);
696-
assertEquals(stats.count, 2);
697-
assert(stats.lastRestart.getTime() >= before.getTime() && stats.lastRestart.getTime() <= after.getTime());
703+
assertEquals(getRestartCount("test-container"), 1);
698704
});
699705

700706
Deno.test("stopContainer - updates stop count and calls stop script", async () => {
701-
const stopCounts = new Map<string, { count: number; lastStop: Date }>();
707+
resetContainerActionStatsForTest();
702708
let execCalledWith = "";
703-
const mockExec = async (cmd: string) => {
709+
setCommandExecutorForTest(async (cmd: string) => {
704710
execCalledWith = cmd;
705-
};
706-
707-
const stopContainer = async (containerName: string): Promise<void> => {
708-
await mockExec(`bash -c "echo 'bash ../../src/backend/shell_scripts/stop.sh ${containerName}' > /hostpipe/pipe"`);
709-
const current = stopCounts.get(containerName);
710-
stopCounts.set(containerName, {
711-
count: (current?.count || 0) + 1,
712-
lastStop: new Date(),
713-
});
714-
};
711+
});
715712

716-
const before = new Date();
717-
await stopContainer("test-container");
718-
const after = new Date();
713+
try {
714+
await stopContainer("test-container");
715+
} finally {
716+
setCommandExecutorForTest(null);
717+
}
719718

720719
assertEquals(execCalledWith, `bash -c "echo 'bash ../../src/backend/shell_scripts/stop.sh test-container' > /hostpipe/pipe"`);
721-
const stats = stopCounts.get("test-container");
722-
assertExists(stats);
723-
assertEquals(stats.count, 1);
724-
assert(stats.lastStop.getTime() >= before.getTime() && stats.lastStop.getTime() <= after.getTime());
720+
assertEquals(getStopCount("test-container"), 1);
725721
});
726722

727723
Deno.test("ContainerStats - validates all required fields", () => {

src/backend/utils/auto-restart.ts

Lines changed: 42 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,36 @@ import { exec } from "../dependencies.ts";
22

33
const restartCounts = new Map<string, { count: number; lastRestart: Date }>();
44
const stopCounts = new Map<string, { count: number; lastStop: Date }>();
5+
const SAFE_CONTAINER_NAME_PATTERN = /^[a-zA-Z0-9_.-]+$/;
6+
7+
let execCommand = async (command: string): Promise<void> => {
8+
await exec(command);
9+
};
10+
11+
export function validateContainerName(containerName: string): string {
12+
if (!containerName || !SAFE_CONTAINER_NAME_PATTERN.test(containerName)) {
13+
throw new Error(`Invalid container name: ${containerName}`);
14+
}
15+
return containerName;
16+
}
17+
18+
function buildHostPipeCommand(scriptName: "restart.sh" | "stop.sh", containerName: string): string {
19+
const safeName = validateContainerName(containerName);
20+
return `bash -c "echo 'bash ../../src/backend/shell_scripts/${scriptName} ${safeName}' > /hostpipe/pipe"`;
21+
}
22+
23+
export function setCommandExecutorForTest(
24+
executor: ((command: string) => Promise<void>) | null,
25+
): void {
26+
execCommand = executor ?? (async (command: string): Promise<void> => {
27+
await exec(command);
28+
});
29+
}
30+
31+
export function resetContainerActionStatsForTest(): void {
32+
restartCounts.clear();
33+
stopCounts.clear();
34+
}
535

636
export function getRestartCount(containerName: string): number {
737
return restartCounts.get(containerName)?.count || 0;
@@ -12,36 +42,36 @@ export function getStopCount(containerName: string): number {
1242
}
1343

1444
export async function restartContainer(containerName: string): Promise<void> {
45+
const safeContainerName = validateContainerName(containerName);
46+
1547
try {
16-
await exec(
17-
`bash -c "echo 'bash ../../src/backend/shell_scripts/restart.sh ${containerName}' > /hostpipe/pipe"`
18-
);
48+
await execCommand(buildHostPipeCommand("restart.sh", safeContainerName));
1949

20-
const current = restartCounts.get(containerName);
21-
restartCounts.set(containerName, {
50+
const current = restartCounts.get(safeContainerName);
51+
restartCounts.set(safeContainerName, {
2252
count: (current?.count || 0) + 1,
2353
lastRestart: new Date(),
2454
});
2555

2656
} catch (error) {
27-
console.error(`[Auto-Restart] Failed to restart ${containerName}:`, error);
57+
console.error(`[Auto-Restart] Failed to restart ${safeContainerName}:`, error);
2858
throw error;
2959
}
3060
}
3161

3262
export async function stopContainer(containerName: string): Promise<void> {
63+
const safeContainerName = validateContainerName(containerName);
64+
3365
try {
34-
await exec(
35-
`bash -c "echo 'bash ../../src/backend/shell_scripts/stop.sh ${containerName}' > /hostpipe/pipe"`
36-
);
66+
await execCommand(buildHostPipeCommand("stop.sh", safeContainerName));
3767

38-
const current = stopCounts.get(containerName);
39-
stopCounts.set(containerName, {
68+
const current = stopCounts.get(safeContainerName);
69+
stopCounts.set(safeContainerName, {
4070
count: (current?.count || 0) + 1,
4171
lastStop: new Date(),
4272
});
4373
} catch (error) {
44-
console.error(`Failed to stop ${containerName}:`, error);
74+
console.error(`Failed to stop ${safeContainerName}:`, error);
4575
throw error;
4676
}
4777
}

0 commit comments

Comments
 (0)