diff --git a/README.md b/README.md index d9772fb622d1904400d8892a8ea0e5314575d979..9298af55e56876c85c7fe8d901150c89130148f6 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** : diff --git a/backend/src/index.js b/backend/src/index.js index b27b7238d39875d0496e6075662f6847c077c2f9..df8bd9917c3ff9f3396dbc0122b4a936043d2f4a 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 0000000000000000000000000000000000000000..70139c2339ed79b6b2a9aa38222e945da9e0f9e5 --- /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 55f61b3829f4eb22c94ac644ca6778fd70accb1b..02a2d556c4951cf9d6216a8616f1148ec900b886 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 bd1a3ef29baab26c9dd1d250d8bf5b836d38effe..40dab49a73d8d85568cb16a7e6cf5c0fb9752ef0 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 0000000000000000000000000000000000000000..727efc6635a5e287536b0922f20e483b23ec508c --- /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 0000000000000000000000000000000000000000..cf8a0cc25cad7fc3c4aecbfd5671182c4c548244 --- /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 0000000000000000000000000000000000000000..ea10f6c3a21ba94c7c4fdcbc12267b5943eb7038 --- /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/run-swarm.sh b/run-swarm.sh new file mode 100755 index 0000000000000000000000000000000000000000..5b21654a1eecca4b3aa2b0b7bfa558023891fa68 --- /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 diff --git a/worker/src/index.js b/worker/src/index.js index 2987da15cf704694ff811bcfd40c4a74a8311849..cac1249f74aff1dc1b7ba9e90ad0666bf8b5692d 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