From 02ce6c263fe054b1f120c2f7d542ecfd7f0b0072 Mon Sep 17 00:00:00 2001 From: Massiles Ghernaout <749-gm213204@users.noreply.www-apps.univ-lehavre.fr> Date: Mon, 9 Feb 2026 16:11:57 +0100 Subject: [PATCH 1/3] added a small section about the new tester pogram integration --- README.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/README.md b/README.md index d9772fb..9298af5 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,31 @@ La stack crée un service Redis, un backend qui gère la file de jobs dans Redis --- +### Intégration avec le tester de l’enseignant + +Le dépôt peut être testé automatiquement via l’image Docker `servuc/hash_extractor` fournie par l’enseignant : + +- **Proxy WebSocket** (`hash_proxy`) : + - Service Node.js (dossier `proxy/`) qui expose l’interface WebSocket attendue par `hash_extractor`. + - Il reçoit des commandes `search MD5_HASH BEGIN END`, les traduit en appels HTTP vers le backend et renvoie `found MD5_HASH PLAINTEXT` ou `notfound MD5_HASH ""` selon le résultat. +- **Endpoints dédiés `/tester`** dans le backend : + - `POST /tester/search` : accepte `{ hash, begin, end }`, crée un job de bruteforce borné et renvoie `{ id }`. + - `GET /tester/job/:id` : renvoie un état normalisé du job, par exemple `{ status: "done", found: true, plaintext: "pina" }`. +- **Worker range-aware** : + - Le worker lit éventuellement `begin` et `end` dans le job et ne considère que les candidats compris dans cet intervalle (selon l’ordre de génération des chaînes). + +Dans `infra/stack.yml`, deux services supplémentaires sont définis : + +- `hash_proxy` : connecté au backend sur le réseau `md5_net`. +- `hash_checker` : conteneur `servuc/hash_extractor:1.0.0` lancé en mode `c`, configuré avec `MASTER_WS=ws://hash_proxy:3000` pour piloter automatiquement le cluster. + +Pour lancer un test de bout en bout localement : + +- `cd infra && docker stack deploy -c stack.yml md5-swarm` +- Surveiller les logs des services `hash_proxy`, `hash_checker`, `api_backend` et `hash_worker` avec `docker service logs -f ...` pour voir le scénario se dérouler. + +--- + ### Tests & couverture - **Tests backend** : -- GitLab From e55704ecaffa8cc1528415d761d5da810b52e0f2 Mon Sep 17 00:00:00 2001 From: Massiles Ghernaout <749-gm213204@users.noreply.www-apps.univ-lehavre.fr> Date: Mon, 9 Feb 2026 16:13:31 +0100 Subject: [PATCH 2/3] Impl of the teacher's testing program integration. The idea of our implementation is to add a proxy between the backend and the teacher's program since our backend does not offer a websocket based API. This proxy's duty is to offer such API and then forward the incoming requests from the tester program to our backend. We had to tweak the backend to add a couple new endpoints for the tester and also we had to tweak the worker to accept range based hash submission (begin charset bound, end charset bound). This does not break retrocompability and leave the legacy code intact ! TODO; We should next consider adding new tests to keep the coverage reasonable. --- backend/src/index.js | 2 + backend/src/routes/tester.js | 77 +++++++++++++++ cmds.txt | 4 +- infra/stack.yml | 28 +++++- proxy/Dockerfile | 13 +++ proxy/index.js | 177 +++++++++++++++++++++++++++++++++++ proxy/package.json | 13 +++ worker/src/index.js | 18 +++- 8 files changed, 325 insertions(+), 7 deletions(-) create mode 100644 backend/src/routes/tester.js create mode 100644 proxy/Dockerfile create mode 100644 proxy/index.js create mode 100644 proxy/package.json diff --git a/backend/src/index.js b/backend/src/index.js index b27b723..df8bd99 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -2,6 +2,7 @@ import express from "express"; import cors from "cors"; import hashRouter from "./routes/hash.js"; import clusterRouter from "./routes/cluster.js"; +import testerRouter from "./routes/tester.js"; import { startScaler } from "./services/scaler.js"; import { PORT, SCALER_ENABLED } from "./config.js"; @@ -26,6 +27,7 @@ app.get("/health", (req, res) => { app.use("/hash", hashRouter); app.use("/cluster", clusterRouter); +app.use("/tester", testerRouter); app.listen(PORT, () => { console.log(`Backend API listening on port ${PORT}`); diff --git a/backend/src/routes/tester.js b/backend/src/routes/tester.js new file mode 100644 index 0000000..70139c2 --- /dev/null +++ b/backend/src/routes/tester.js @@ -0,0 +1,77 @@ +import express from "express"; +import { v4 as uuidv4 } from "uuid"; +import { REDIS_KEYS } from "../config.js"; +import { redis } from "../clients.js"; + +const router = express.Router(); + +function isNonEmptyString(value) { + return typeof value === "string" && value.length > 0; +} + +// POST /tester/search - enqueue a range-aware bruteforce job +router.post("/search", async (req, res) => { + const { hash, begin, end } = req.body ?? {}; + + if (!isNonEmptyString(hash)) { + return res.status(400).json({ error: "hash is required" }); + } + + if (!isNonEmptyString(begin) || !isNonEmptyString(end)) { + return res + .status(400) + .json({ error: "both begin and end must be non-empty strings" }); + } + + if (begin.length > end.length || (begin.length === end.length && begin > end)) { + return res + .status(400) + .json({ error: "begin must be <= end in lexicographic order" }); + } + + const jobId = uuidv4(); + const job = { + id: jobId, + hash, + begin, + end, + createdAt: Date.now(), + }; + + await redis.lpush(REDIS_KEYS.JOBS_PENDING, JSON.stringify(job)); + await redis.hset( + REDIS_KEYS.JOBS_STATUS, + jobId, + JSON.stringify({ status: "queued" }) + ); + + return res.status(202).json({ id: jobId }); +}); + +// GET /tester/job/:id - normalized view over status/results +router.get("/job/:id", async (req, res) => { + const { id } = req.params; + const rawResult = await redis.hget(REDIS_KEYS.JOBS_RESULTS, id); + + if (rawResult) { + const parsed = JSON.parse(rawResult); + return res.json({ + status: "done", + found: Boolean(parsed.found), + plaintext: parsed.plaintext ?? null, + }); + } + + const rawStatus = await redis.hget(REDIS_KEYS.JOBS_STATUS, id); + if (!rawStatus) { + return res.status(404).json({ status: "unknown" }); + } + + const statusObj = JSON.parse(rawStatus); + return res.json({ + status: statusObj.status ?? "queued", + }); +}); + +export default router; + diff --git a/cmds.txt b/cmds.txt index 55f61b3..02a2d55 100644 --- a/cmds.txt +++ b/cmds.txt @@ -6,8 +6,8 @@ docker build -t md5-swarm-worker:latest ./worker docker build -t md5-swarm-frontend:latest ./frontend - docker stack deploy -c infra/stack.yml md5 - docker stack services md5 + docker stack deploy -c infra/stack.yml md5-swarm + docker stack services md5-swarm docker service logs service_id diff --git a/infra/stack.yml b/infra/stack.yml index bd1a3ef..40dab49 100644 --- a/infra/stack.yml +++ b/infra/stack.yml @@ -13,7 +13,7 @@ services: environment: - REDIS_URL=redis://redis:6379 - DOCKER_SOCKET=/var/run/docker.sock - - WORKER_SERVICE_NAME=md5_hash_worker + - WORKER_SERVICE_NAME=md5-swarm_hash_worker # Scaler (auto-scale workers from queue size) - SCALER_ENABLED=true - SCALER_INTERVAL_MS=5000 @@ -45,6 +45,32 @@ services: networks: - md5_net + hash_proxy: + image: md5-swarm-proxy:latest + build: + context: ../proxy + depends_on: + - api_backend + environment: + - BACKEND_BASE_URL=http://api_backend:8080 + - POLL_INTERVAL_MS=1000 + - POLL_TIMEOUT_MS=300000 + networks: + - md5_net + + hash_checker: + image: servuc/hash_extractor:1.0.0 + command: ["c"] + environment: + - MASTER_WS=ws://hash_proxy:3000 + depends_on: + - hash_proxy + networks: + - md5_net + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - /tmp/hash_output/:/app/output/ + frontend: image: md5-swarm-frontend:latest build: diff --git a/proxy/Dockerfile b/proxy/Dockerfile new file mode 100644 index 0000000..727efc6 --- /dev/null +++ b/proxy/Dockerfile @@ -0,0 +1,13 @@ +FROM node:20-alpine + +WORKDIR /usr/src/app + +COPY package.json ./ +RUN npm install --omit=dev + +COPY . . + +EXPOSE 3000 + +CMD ["node", "index.js"] + diff --git a/proxy/index.js b/proxy/index.js new file mode 100644 index 0000000..cf8a0cc --- /dev/null +++ b/proxy/index.js @@ -0,0 +1,177 @@ +const WebSocket = require("ws"); + +const BACKEND_BASE_URL = + process.env.BACKEND_BASE_URL || "http://api_backend:8080"; +const PORT = Number(process.env.PORT || 3000); +const POLL_INTERVAL_MS = Number(process.env.POLL_INTERVAL_MS || 1000); +const POLL_TIMEOUT_MS = Number(process.env.POLL_TIMEOUT_MS || 5 * 60 * 1000); + +async function submitJobLegacy(hash) { + const res = await fetch(`${BACKEND_BASE_URL}/hash/manual`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ hash }), + }); + + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error( + `Backend /hash/manual error: ${res.status} ${res.statusText} ${text}` + ); + } + + const data = await res.json(); + if (!data.id) { + throw new Error("Backend /hash/manual response missing id"); + } + return data.id; +} + +async function submitJobTester(hash, begin, end) { + const res = await fetch(`${BACKEND_BASE_URL}/tester/search`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ hash, begin, end }), + }); + + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error( + `Backend /tester/search error: ${res.status} ${res.statusText} ${text}` + ); + } + + const data = await res.json(); + if (!data.id) { + throw new Error("Backend /tester/search response missing id"); + } + return data.id; +} + +async function waitForResult(jobId) { + const start = Date.now(); + + while (Date.now() - start < POLL_TIMEOUT_MS) { + const res = await fetch(`${BACKEND_BASE_URL}/tester/job/${jobId}`); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error( + `Backend /hash/${jobId} error: ${res.status} ${res.statusText} ${text}` + ); + } + + const data = await res.json(); + + // When result exists, backend returns the full result object + if (Object.prototype.hasOwnProperty.call(data, "found")) { + return data; + } + + // Otherwise we get a status object like { status: "queued" | "done" | "failed" } + if (data.status === "failed") { + return data; + } + + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); // sleep + } + + return null; +} + +function handleSearch(ws, hash, begin, end) { + if (!hash) { + ws.send("error missing_hash"); + return; + } + + (async () => { + try { + console.log( + `[proxy] submitting job for hash ${hash}` + + (begin && end ? ` in range [${begin}, ${end}]` : "") + ); + + const jobId = + begin && end + ? await submitJobTester(hash, begin, end) + : await submitJobLegacy(hash); + console.log(`[proxy] job ${jobId} created for hash ${hash}`); + + const result = await waitForResult(jobId); + if (!result) { + console.warn( + `[proxy] timeout waiting for result of job ${jobId} (hash ${hash})` + ); + ws.send(`notfound ${hash} ""`); + return; + } + + if (result.found && result.plaintext) { + console.log( + `[proxy] found solution for hash ${hash}: ${result.plaintext}` + ); + ws.send(`found ${hash} ${result.plaintext}`); + } else { + console.log(`[proxy] no solution for hash ${hash}`); + ws.send(`notfound ${hash} ""`); + } + } catch (err) { + console.error(`[proxy] error handling search for ${hash}:`, err); + ws.send(`notfound ${hash} ""`); + } + })(); +} + +const wss = new WebSocket.Server({ port: PORT }); + +wss.on("listening", () => { + console.log( + `[proxy] WebSocket server listening on port ${PORT}, backend=${BACKEND_BASE_URL}` + ); +}); + +wss.on("connection", (ws) => { + console.log("[proxy] tester connected"); + + ws.on("message", (data) => { + const msg = data.toString().trim(); + console.log("[proxy] received:", msg); + + // The checker in mode \"c\" typically sends: + // client + // search + if (msg === "client" || msg === "slave") { + // Greeting, nothing special to do. + return; + } + + if (msg.startsWith("search ")) { + const parts = msg.split(/\s+/); + // Expected forms: + // - search + // - search + const hash = parts[1]; + const begin = parts[2]; + const end = parts[3]; + handleSearch(ws, hash, begin, end); + return; + } + + if (msg === "stop" || msg === "exit") { + console.log("[proxy] received stop/exit, closing connection"); + ws.close(); + return; + } + + console.warn("[proxy] unknown command:", msg); + }); + + ws.on("close", () => { + console.log("[proxy] tester disconnected"); + }); + + ws.on("error", (err) => { + console.error("[proxy] WebSocket error:", err); + }); +}); + diff --git a/proxy/package.json b/proxy/package.json new file mode 100644 index 0000000..ea10f6c --- /dev/null +++ b/proxy/package.json @@ -0,0 +1,13 @@ +{ + "name": "md5-swarm-proxy", + "version": "1.0.0", + "description": "WebSocket proxy between servuc/hash_extractor tester and md5-swarm backend HTTP API", + "main": "index.js", + "scripts": { + "start": "node index.js" + }, + "dependencies": { + "ws": "^8.16.0" + } +} + diff --git a/worker/src/index.js b/worker/src/index.js index 2987da1..cac1249 100644 --- a/worker/src/index.js +++ b/worker/src/index.js @@ -4,10 +4,11 @@ import crypto from "crypto"; const redisUrl = process.env.REDIS_URL || "redis://redis:6379"; const redis = new Redis(redisUrl); -const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; +const charset = + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; const maxLength = Number(process.env.MAX_LENGTH || 4); -async function bruteforce(hash) { +async function bruteforce(hash, begin, end) { const start = Date.now(); @@ -41,6 +42,15 @@ async function bruteforce(hash) { } for (const candidate of generateStrings(maxLength)) { + if (begin && candidate < begin) { + continue; + } + if (end && candidate > end) { + // We cannot safely break here because the generator + // is not strictly ordered, but we can skip this value. + continue; + } + const md5 = crypto.createHash("md5").update(candidate).digest("hex"); if (md5 === hash) { const elapsedMs = Date.now() - start; @@ -75,8 +85,8 @@ async function processJobs() { await redis.sadd("jobs:in_progress", job.id); - // solve the job hash - const result = await bruteforce(job.hash); + // solve the job hash (optionally within a BEGIN/END range) + const result = await bruteforce(job.hash, job.begin, job.end); // save the results -- GitLab From 1986853d3d81959dd368d33b6619c2323ac07faa Mon Sep 17 00:00:00 2001 From: Massiles Ghernaout <749-gm213204@users.noreply.www-apps.univ-lehavre.fr> Date: Mon, 9 Feb 2026 16:18:02 +0100 Subject: [PATCH 3/3] added a script for running the whole project --- run-swarm.sh | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100755 run-swarm.sh diff --git a/run-swarm.sh b/run-swarm.sh new file mode 100755 index 0000000..5b21654 --- /dev/null +++ b/run-swarm.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +set -euo pipefail + +STACK_NAME="md5-swarm" +INFRA_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/infra" && pwd)" + +echo "[*] Construction des images locales..." +docker build -t md5-swarm-backend:latest ./backend +docker build -t md5-swarm-worker:latest ./worker +docker build -t md5-swarm-frontend:latest ./frontend +docker build -t md5-swarm-proxy:latest ./proxy + +echo "[*] Vérification du mode Swarm..." +if ! docker info 2>/dev/null | grep -q "Swarm: active"; then + echo "[*] Swarm non initialisé, exécution de 'docker swarm init'..." + docker swarm init --advertise-addr 10.67.138.118 +else + echo "[*] Swarm déjà actif." +fi + +echo "[*] Déploiement de la stack '${STACK_NAME}'..." +cd "${INFRA_DIR}" +docker stack deploy -c stack.yml "${STACK_NAME}" + +echo "[*] Stack déployée." +echo " - Backend : http://localhost:8080" +echo " - Frontend : http://localhost:5173" +echo " - Vérifier les services : docker stack services ${STACK_NAME}" \ No newline at end of file -- GitLab