From b5b646a68f6c3447e50db47b8040de00610b6195 Mon Sep 17 00:00:00 2001 From: Fernando Campione Date: Fri, 2 Jan 2026 20:35:17 +0000 Subject: [PATCH 1/6] Ehm.. refactor much? sorry --- docker-compose.yml | 1 + package-lock.json | 13 ++ package.json | 1 + public/app.js | 141 ++++++++++++ public/favicon.ico | Bin 0 -> 15406 bytes public/index.html | 71 ++++++ public/style.css | 87 +++++++ server.js | 478 +++++---------------------------------- src/config/constants.js | 38 ++++ src/core/identity.js | 21 ++ src/core/security.js | 43 ++++ src/p2p/messaging.js | 125 ++++++++++ src/p2p/relay.js | 16 ++ src/p2p/swarm.js | 118 ++++++++++ src/state/diagnostics.js | 48 ++++ src/state/peers.js | 66 ++++++ src/web/routes.js | 56 +++++ src/web/server.js | 20 ++ src/web/sse.js | 33 +++ 19 files changed, 952 insertions(+), 424 deletions(-) create mode 100644 public/app.js create mode 100644 public/favicon.ico create mode 100644 public/index.html create mode 100644 public/style.css create mode 100644 src/config/constants.js create mode 100644 src/core/identity.js create mode 100644 src/core/security.js create mode 100644 src/p2p/messaging.js create mode 100644 src/p2p/relay.js create mode 100644 src/p2p/swarm.js create mode 100644 src/state/diagnostics.js create mode 100644 src/state/peers.js create mode 100644 src/web/routes.js create mode 100644 src/web/server.js create mode 100644 src/web/sse.js diff --git a/docker-compose.yml b/docker-compose.yml index 9e185ed..fbf9053 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,3 +6,4 @@ services: restart: unless-stopped environment: - PORT=3000 + - MAX_PEERS=10000 diff --git a/package-lock.json b/package-lock.json index 960e8d8..350f0e7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "MIT", "dependencies": { + "dotenv": "^17.2.3", "express": "^5.2.1", "hyperswarm": "^4.16.0" } @@ -321,6 +322,18 @@ "udx-native": "^1.5.3" } }, + "node_modules/dotenv": { + "version": "17.2.3", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", + "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", diff --git a/package.json b/package.json index 8182bbe..8d6ea5e 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "homepage": "https://github.com/lklynet/hypermind#readme", "type": "commonjs", "dependencies": { + "dotenv": "^17.2.3", "express": "^5.2.1", "hyperswarm": "^4.16.0" } diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..248747c --- /dev/null +++ b/public/app.js @@ -0,0 +1,141 @@ +const countEl = document.getElementById('count'); +const directEl = document.getElementById('direct'); +const canvas = document.getElementById('network'); +const ctx = canvas.getContext('2d'); +let particles = []; + +function resize() { + canvas.width = window.innerWidth; + canvas.height = window.innerHeight; +} + +window.addEventListener('resize', resize); +resize(); + +class Particle { + constructor() { + this.x = Math.random() * canvas.width; + this.y = Math.random() * canvas.height; + this.vx = (Math.random() - 0.5) * 1; + this.vy = (Math.random() - 0.5) * 1; + this.size = 3; + } + + update() { + this.x += this.vx; + this.y += this.vy; + + if (this.x < 0 || this.x > canvas.width) this.vx *= -1; + if (this.y < 0 || this.y > canvas.height) this.vy *= -1; + } + + draw() { + ctx.beginPath(); + ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2); + ctx.fillStyle = '#4ade80'; + ctx.fill(); + } +} + +const updateParticles = (count) => { + const VISUAL_LIMIT = 500; + const visualCount = Math.min(count, VISUAL_LIMIT); + + const currentCount = particles.length; + if (visualCount > currentCount) { + for (let i = 0; i < visualCount - currentCount; i++) { + particles.push(new Particle()); + } + } else if (visualCount < currentCount) { + particles.splice(visualCount, currentCount - visualCount); + } +} + +const animate = () => { + ctx.clearRect(0, 0, canvas.width, canvas.height); + + ctx.strokeStyle = 'rgba(74, 222, 128, 0.15)'; + ctx.lineWidth = 1; + for (let i = 0; i < particles.length; i++) { + for (let j = i + 1; j < particles.length; j++) { + const dx = particles[i].x - particles[j].x; + const dy = particles[i].y - particles[j].y; + const distance = Math.sqrt(dx * dx + dy * dy); + + if (distance < 150) { + ctx.beginPath(); + ctx.moveTo(particles[i].x, particles[i].y); + ctx.lineTo(particles[j].x, particles[j].y); + ctx.stroke(); + } + } + } + + particles.forEach(p => { + p.update(); + p.draw(); + }); + + requestAnimationFrame(animate); +} + +const openDiagnostics = () => { + document.getElementById('diagnosticsModal').classList.add('active'); +} + +const closeDiagnostics = () => { + document.getElementById('diagnosticsModal').classList.remove('active'); +} + +document.getElementById('diagnosticsModal').addEventListener('click', (e) => { + if (e.target.id === 'diagnosticsModal') { + closeDiagnostics(); + } +}); + +document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + closeDiagnostics(); + } +}); + +const evtSource = new EventSource("/events"); + +evtSource.onmessage = (event) => { + const data = JSON.parse(event.data); + + updateParticles(data.count); + + if (countEl.innerText != data.count) { + countEl.innerText = data.count; + countEl.classList.remove('pulse'); + void countEl.offsetWidth; + countEl.classList.add('pulse'); + } + + directEl.innerText = data.direct; + + if (data.diagnostics) { + const d = data.diagnostics; + document.getElementById('diag-heartbeats-rx').innerText = d.heartbeatsReceived.toLocaleString(); + document.getElementById('diag-heartbeats-tx').innerText = d.heartbeatsRelayed.toLocaleString(); + document.getElementById('diag-new-peers').innerText = d.newPeersAdded.toLocaleString(); + document.getElementById('diag-dup-seq').innerText = d.duplicateSeq.toLocaleString(); + document.getElementById('diag-invalid-pow').innerText = d.invalidPoW.toLocaleString(); + document.getElementById('diag-invalid-sig').innerText = d.invalidSig.toLocaleString(); + document.getElementById('diag-bandwidth-in').innerText = (d.bytesReceived / 1024).toFixed(1) + ' KB'; + document.getElementById('diag-bandwidth-out').innerText = (d.bytesRelayed / 1024).toFixed(1) + ' KB'; + document.getElementById('diag-leave').innerText = d.leaveMessages.toLocaleString(); + } +}; + +evtSource.onerror = (err) => { + // Removing console error here as it's extremely spammy in the browser console and it will reconnct automatically anyway, so pretty redundant. +}; + +const initialCount = parseInt(countEl.dataset.initialCount) || 0; +countEl.innerText = initialCount; +countEl.classList.add('loaded'); +updateParticles(initialCount); +animate(); + diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..60c0acef162ae47a36be2e42b6d1de1f8ab47853 GIT binary patch literal 15406 zcmeHNU2j%J5I#nK0QPMyP%0HlX&@q1posK?uU3r25D;quY6JlhFiJqF@j??XOf)9O zs5fGwm&OYvQA{*?YF_9)GyYKGIGtWFbyXTzuZB^Y= z-CQkMQb9XXoxG*0wp3L$GGacD-&$3dU~J(4D`)9Rx>wAKb&gKx(4XC4!C5YOQooQ3x%)ze>2tI0!a42F%x zb`a0?4KE!F@xk`Q5p9GXTpK;6PLmNdca?EM;wR)el7GxAB%x{kS52G|Mk5(f3(wcU~A@yr}ZaX zYlrxnKg6FQ{cH@Q5#nK7%LDyke&?C>YTfKgtp)3MjPy6WiJz$#%R^(g)c)7+Ru})A zR}Z{=mpXL?*U#{GHEQC~XIXQY;~g7@)Ui*e^tz$%B3#IyV{#72#cJh^wN(E=OU<9Z zQ@!xpoO<#1Iko*L@dxb!TdgVRK=`HxEH>Es_Ga9RtMSm^=s@eF*Av&u#xwZNj@Mqt zMqg=bK5H9VSBr0Sv-2<)Jrvp)#B*i@u35P7d`kW4e3*;V!SE4lA#9|kvM+tyzLS_n zOODBF=JNJ1w1(2Zu+O;8KE*RN&Kz~#mbcdzV=|uX3(fqz94~*IFsV)1*>Q~3X}r)6 zK6_d)w&Y^^xF_)JUU+v>Exd=<7Pary3B7MAAKovX1`-Q%pwHCC@{P>F{yXsICVd9s zeFQH&lN_GV2jk;e=<&d@Sgi6f6W_$<_>38e=jH0pqwY2B+1_yS=D|J|pU$dUu9Q3R zqn5@CTgloMfzlS4ojK&l5F+cL5*4j{AQGC3QBadhF`q#B0ZBZM1p3pgHOpFhg zz?b>4hCDC%QvJ#Nr#-wVmdLYdp5qH2IfmySo3r~Ccep?_n*L$T5&&i4kUeC4(u;%Q~8rE=R!VSPPXp6 zF~}7@yFF~`EyOXOWA%sUAL>PZrX@bIrDqp87VFE{zE3WGux4S7q=(lM=fKyq@#k27 zcn+rZ_c?;SUT?&POKa<6=$vEx#5#;_9#Xr`u7_t&<4c-wd^~@E!>KFNdVlD7jcH=C zu3j9IuRD9}=SaTe_YLcJg`=NrhiB;Jc8%lbKgZHI4wGsoHIL(z7u(viQpdMv?D=2z zsO|91&;l*I{9)eQf33b=uA%0RmyGLkb6T7DZlB{?KmPGH_1H)E>UF2{--{pQ@38&Y ztbc~@z+g-5du2i`UYpnPFn;Xv;u9XV$Omgte%2hNHpne)vte#TJ^S5Gj*ai1l7|<+ zhArbzKYzYC%`A7|wru5~dgL9r@Bih;MjjZyw*MkLsLpb^a%|FzT8lTiutq)i{VsI@ z>XEMFpG}GVPB{jx#z||e;|8&qOOQ*ezfZr9;yWpx4cOLf3$bc`Hdb0sibZXZr}ody z#q4pc6Q2Ofo*Sv0dN$smt*bM^uffOd&p-#^=kCt}7xb>j0}Sh@=LAN%1_2MQK{-x+ zYAr8{M=ksMyi?x`{9nfLdlLAHwt51t?Dr(ey%Fdxb#Bl;TL&e+B{sd{BXPK&$(?Ov zy_MLP*htKJzrGjiHd*a0_l|MeU6(+slDK)BK*M(VfPEg{=viV@qI0`8-Nfz2H^sA# z8R#FZZwjYZ<8?l;UXdCY-LyjOJiWel&74@LR!t1mpJ$S1U>v`rd?GwkINOe_Rf~~l z(eqdLsLccY=TF981MYjZ2&o8)@(QK7aR4kk;S% zrq~N2rI?xUWGvry76zZkpxagSe&qn(Ve687ter zpr_PI_M}YpfMogN9sU{PDY#ru|zSKfGTz@X6vve0~1%{#DnX_X25S+thfL z3+5H!llP(ZcD_D;xPQU-jgFXmX=ztQuj zGyJwVM{2|OH=2j*(hmR7*T*l+v5+TX53U$c_&dkyjmuT5Kj3e1viuK@_GZsEa*pS| zY-CU!{$Q&*`NfQyg8$Q7cKd*R9}nukt@rnMEPpKkzl3vxjpl!8tNxAc!mo4c(A%5U z@YtZqp+x`a_Ya1DyS0?JkLSB=@xfJUaHwC`Mz($={|2pm z{Y%!U*+%oX`qOk?qvSh9Xdf6C+HAM@(e zSF`Y2>K5-eT$+zPKl~dS*D+l#_2ufod0iYITCeC{ zV0*u=@b}ZT+lJM_w>IhDiHTp>_MAV|K$Iufq2y_DOw|&OFC89GQ%{bo=YQG_^#acc zf&O7WUF*+01O0hi@-Od?O|F3d0pG6wcbhp!a`E{C2VUPE@?lKiFZ&HSt{5FubMQat z=YHH}_Ac<3_0!q=W4VTM&z9Gik3X=qU#;JO&;Hf5`?ZYyU+Cli zU|=rh8u0q`%v8ox)>Y6CYNJ!ln|a8%{(5=70UvL!p?2eSo{!7*L+z7}Qu}tRC7+J!QX;LtaDBqyx;lwn4rb%sig}Cz2!l Nd(e%)8wzx+z&{fmGadi{ literal 0 HcmV?d00001 diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..77d90c0 --- /dev/null +++ b/public/index.html @@ -0,0 +1,71 @@ + + + + Hypermind + + + + + + +
+
{{COUNT}}
+
Active Nodes
+ +
+ ID: {{ID}}
+ Direct Connections: {{DIRECT}}
+ diagnostics +
+
+ + + + + + + diff --git a/public/style.css b/public/style.css new file mode 100644 index 0000000..cd016e1 --- /dev/null +++ b/public/style.css @@ -0,0 +1,87 @@ +* { margin: 0; padding: 0; box-sizing: border-box; } + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + display: flex; + justify-content: center; + align-items: center; + height: 100vh; + background: #111; + color: #eee; + margin: 0; +} + +.container { text-align: center; position: relative; z-index: 10; } +#network { position: fixed; top: 0; left: 0; width: 100%; height: 100%; z-index: 1; } +.count { font-size: 8rem; font-weight: bold; color: #4ade80; transition: color 0.2s; visibility: hidden; } +.count.loaded { visibility: visible; } +.label { font-size: 1.5rem; color: #9ca3af; margin-top: 1rem; } +.footer { margin-top: 2rem; font-size: 0.9rem; color: #4b5563; } +.debug { font-size: 0.8rem; color: #4b5563; margin-top: 1rem; } +.debug-link { color: #4b5563; border-bottom: 1px dotted #4b5563; cursor: pointer; } +.debug-link:hover { color: #9ca3af; } +a { color: #4b5563; text-decoration: none; border-bottom: 1px dotted #4b5563; } + +.pulse { animation: pulse 0.5s ease-in-out; } +@keyframes pulse { + 0% { transform: scale(1); } + 50% { transform: scale(1.1); color: #fff; } + 100% { transform: scale(1); } +} + +.modal { + display: none; + position: fixed; + z-index: 1000; + left: 0; + top: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.8); +} +.modal.active { display: flex; align-items: center; justify-content: center; } +.modal-content { + background: #111; + border: 1px solid #222; + padding: 2rem; + max-width: 500px; + width: 90%; + position: relative; +} +.modal-title { + font-size: 0.9rem; + color: #666; + margin-bottom: 1.5rem; + text-transform: uppercase; + letter-spacing: 1px; +} +.close-btn { + position: absolute; + top: 1.5rem; + right: 1.5rem; + background: none; + border: none; + color: #333; + font-size: 1.2rem; + cursor: pointer; +} +.close-btn:hover { color: #666; } +.stat-row { + display: flex; + justify-content: space-between; + padding: 0.5rem 0; + border-bottom: 1px solid #1a1a1a; + font-size: 0.85rem; +} +.stat-row:last-child { border-bottom: none; } +.stat-label { color: #4b5563; } +.stat-value { + color: #9ca3af; + font-variant-numeric: tabular-nums; +} +.update-time { + text-align: center; + font-size: 0.7rem; + color: #333; + margin-top: 1rem; +} diff --git a/server.js b/server.js index 65e7140..9c60c0c 100644 --- a/server.js +++ b/server.js @@ -1,439 +1,69 @@ -const express = require("express"); -const Hyperswarm = require("hyperswarm"); -const crypto = require("crypto"); +require('dotenv').config(); -const app = express(); -const PORT = process.env.PORT || 3000; +const { generateIdentity } = require("./src/core/identity"); +const { PeerManager } = require("./src/state/peers"); +const { DiagnosticsManager } = require("./src/state/diagnostics"); +const { MessageHandler } = require("./src/p2p/messaging"); +const { relayMessage } = require("./src/p2p/relay"); +const { SwarmManager } = require("./src/p2p/swarm"); +const { SSEManager } = require("./src/web/sse"); +const { createServer, startServer } = require("./src/web/server"); +const { DIAGNOSTICS_INTERVAL } = require("./src/config/constants"); -// --- CONFIGURATION --- -const TOPIC_NAME = "hypermind-lklynet-v1"; -const TOPIC = crypto.createHash("sha256").update(TOPIC_NAME).digest(); +const main = async () => { + const identity = generateIdentity(); + const peerManager = new PeerManager(); + const diagnostics = new DiagnosticsManager(); + const sseManager = new SSEManager(); -// --- SECURITY --- -// We use Ed25519 for signatures and a PoW puzzle to prevent Sybil attacks. -// Difficulty: Hash(ID + nonce) must start with '0000' -const POW_PREFIX = "0000"; + peerManager.addOrUpdatePeer(identity.id, peerManager.getSeq(), null); -console.log("[Security] Generating Identity & Solving PoW..."); -const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519"); -const MY_ID = publicKey.export({ type: "spki", format: "der" }).toString("hex"); -let MY_NONCE = 0; -while (true) { - const hash = crypto - .createHash("sha256") - .update(MY_ID + MY_NONCE) - .digest("hex"); - if (hash.startsWith(POW_PREFIX)) break; - MY_NONCE++; -} -console.log( - `[Security] Identity ready. ID: ${MY_ID.slice(0, 8)}... Nonce: ${MY_NONCE}` -); + const broadcastUpdate = () => { + sseManager.broadcastUpdate({ + count: peerManager.size, + direct: swarmManager.getSwarm().connections.size, + id: identity.id, + diagnostics: diagnostics.getStats(), + }); + }; -let mySeq = 0; + const messageHandler = new MessageHandler( + peerManager, + diagnostics, + (msg, sourceSocket) => relayMessage(msg, sourceSocket, swarmManager.getSwarm(), diagnostics), + broadcastUpdate + ); -const seenPeers = new Map(); -const MAX_PEERS = 10000; + const swarmManager = new SwarmManager( + identity, + peerManager, + diagnostics, + messageHandler, + (msg, sourceSocket) => relayMessage(msg, sourceSocket, swarmManager.getSwarm(), diagnostics), + broadcastUpdate + ); -const sseClients = new Set(); + await swarmManager.start(); -seenPeers.set(MY_ID, { seq: mySeq, lastSeen: Date.now() }); + diagnostics.startLogging( + () => peerManager.size, + () => swarmManager.getSwarm().connections.size + ); -// Throttle updates to once per second -let lastBroadcast = 0; -function broadcastUpdate() { - const now = Date.now(); - if (now - lastBroadcast < 1000) return; - lastBroadcast = now; - - const data = JSON.stringify({ - count: seenPeers.size, - direct: swarm.connections.size, - id: MY_ID, - }); - - for (const client of sseClients) { - client.write(`data: ${data}\n\n`); - } -} - -const swarm = new Hyperswarm(); - -swarm.on("connection", (socket) => { - const sig = crypto - .sign(null, Buffer.from(`seq:${mySeq}`), privateKey) - .toString("hex"); - const hello = JSON.stringify({ - type: "HEARTBEAT", - id: MY_ID, - seq: mySeq, - hops: 0, - nonce: MY_NONCE, - sig, - }); - socket.write(hello); - broadcastUpdate(); - - socket.on("data", (data) => { - try { - const msgs = data - .toString() - .split("\n") - .filter((x) => x.trim()); - for (const msgStr of msgs) { - const msg = JSON.parse(msgStr); - handleMessage(msg, socket); - } - } catch (e) { - // console.error('Invalid message', e); - } - }); - - socket.on("close", () => { - if (socket.peerId && seenPeers.has(socket.peerId)) { - seenPeers.delete(socket.peerId); - } + setInterval(() => { broadcastUpdate(); - }); + }, DIAGNOSTICS_INTERVAL); - socket.on("error", () => {}); -}); + const app = createServer(identity, peerManager, swarmManager, sseManager, diagnostics); + startServer(app, identity); -const discovery = swarm.join(TOPIC); -discovery.flushed().then(() => { - console.log("[P2P] Joined topic:", TOPIC_NAME); -}); + const handleShutdown = () => { + diagnostics.stopLogging(); + swarmManager.shutdown(); + }; -function handleMessage(msg, sourceSocket) { - if (msg.type === "HEARTBEAT") { - const { id, seq, hops, nonce, sig } = msg; - - // 1. Verify PoW - if (!nonce) return; - const powHash = crypto - .createHash("sha256") - .update(id + nonce) - .digest("hex"); - if (!powHash.startsWith(POW_PREFIX)) return; // Invalid PoW - - // 2. Check Sequence (Optimization: Drop duplicates before expensive verify) - const stored = seenPeers.get(id); - if (stored && seq <= stored.seq) return; // Ignore old/duplicate messages - - // 3. Verify Signature - if (!sig) return; - try { - let key; - if (stored && stored.key) { - key = stored.key; - } else { - // Enforce MAX_PEERS for new peers - if (!stored && seenPeers.size >= MAX_PEERS) return; - - key = crypto.createPublicKey({ - key: Buffer.from(id, "hex"), - format: "der", - type: "spki", - }); - } - - const verified = crypto.verify( - null, - Buffer.from(`seq:${seq}`), - key, - Buffer.from(sig, "hex") - ); - if (!verified) return; // Invalid Signature - - // Update Peer - if (hops === 0) { - sourceSocket.peerId = id; - } - - const now = Date.now(); - const wasNew = !stored; - - seenPeers.set(id, { seq, lastSeen: now, key }); - - if (wasNew) broadcastUpdate(); - - if (hops < 3) { - relayMessage({ ...msg, hops: hops + 1 }, sourceSocket); - } - } catch (e) { - return; - } - } else if (msg.type === "LEAVE") { - const { id, hops } = msg; - if (seenPeers.has(id)) { - seenPeers.delete(id); - broadcastUpdate(); - - if (hops < 3) { - relayMessage({ ...msg, hops: hops + 1 }, sourceSocket); - } - } - } + process.on("SIGINT", handleShutdown); + process.on("SIGTERM", handleShutdown); } -function relayMessage(msg, sourceSocket) { - const data = JSON.stringify(msg) + "\n"; - for (const socket of swarm.connections) { - if (socket !== sourceSocket) { - socket.write(data); - } - } -} - -// Periodic Heartbeat -setInterval(() => { - mySeq++; - - seenPeers.set(MY_ID, { seq: mySeq, lastSeen: Date.now() }); - - const sig = crypto - .sign(null, Buffer.from(`seq:${mySeq}`), privateKey) - .toString("hex"); - const heartbeat = - JSON.stringify({ - type: "HEARTBEAT", - id: MY_ID, - seq: mySeq, - hops: 0, - nonce: MY_NONCE, - sig, - }) + "\n"; - for (const socket of swarm.connections) { - socket.write(heartbeat); - } - - const now = Date.now(); - let changed = false; - for (const [id, data] of seenPeers) { - if (now - data.lastSeen > 15000) { - seenPeers.delete(id); - changed = true; - } - } - - if (changed) broadcastUpdate(); -}, 5000); - -// Graceful Shutdown -function handleShutdown() { - console.log("[P2P] Shutting down, sending goodbye..."); - const goodbye = JSON.stringify({ type: "LEAVE", id: MY_ID, hops: 0 }) + "\n"; - for (const socket of swarm.connections) { - socket.write(goodbye); - } - - setTimeout(() => { - process.exit(0); - }, 500); -} - -process.on("SIGINT", handleShutdown); -process.on("SIGTERM", handleShutdown); - -// --- WEB SERVER --- - -app.get("/", (req, res) => { - const count = seenPeers.size; - const directPeers = swarm.connections.size; - - res.send(` - - - - Hypermind - - - - - -
-
${count}
-
Active Nodes
- -
- ID: ${MY_ID.slice(0, 8)}...
- Direct Connections: ${directPeers} -
-
- - - - `); -}); - -// SSE Endpoint -app.get("/events", (req, res) => { - res.setHeader("Content-Type", "text/event-stream"); - res.setHeader("Cache-Control", "no-cache"); - res.setHeader("Connection", "keep-alive"); - res.flushHeaders(); - - sseClients.add(res); - - const data = JSON.stringify({ - count: seenPeers.size, - direct: swarm.connections.size, - id: MY_ID, - }); - res.write(`data: ${data}\n\n`); - - req.on("close", () => { - sseClients.delete(res); - }); -}); - -app.get("/api/stats", (req, res) => { - res.json({ - count: seenPeers.size, - direct: swarm.connections.size, - id: MY_ID, - }); -}); - -app.listen(PORT, () => { - console.log(`Hypermind Node running on port ${PORT}`); - console.log(`ID: ${MY_ID}`); -}); +main().catch(console.error); diff --git a/src/config/constants.js b/src/config/constants.js new file mode 100644 index 0000000..7ba02cc --- /dev/null +++ b/src/config/constants.js @@ -0,0 +1,38 @@ +const crypto = require("crypto"); + +const TOPIC_NAME = "hypermind-lklynet-v1"; +const TOPIC = crypto.createHash("sha256").update(TOPIC_NAME).digest(); + +/** + * fccview here, frankly I don't think I can make this more secure, we can change it to `00000` but + * that means until everyone upgrade there'll be a divide between nodes. + * + * I ran it that way and I was fairly isolated, with hundreds of failed POW, shame. + * adding an extra 0 makes it very expensive on attacker to make it worth the fun for them, so maybe consider it. + * + */ +const POW_PREFIX = "0000"; + +const MAX_PEERS = parseInt(process.env.MAX_PEERS) || 10000; +const MAX_MESSAGE_SIZE = 2048; +const MAX_RELAY_HOPS = 3; + +const HEARTBEAT_INTERVAL = 5000; +const PEER_TIMEOUT = 15000; +const BROADCAST_THROTTLE = 1000; +const DIAGNOSTICS_INTERVAL = 10000; +const PORT = process.env.PORT || 3000; + +module.exports = { + TOPIC_NAME, + TOPIC, + POW_PREFIX, + MAX_PEERS, + MAX_MESSAGE_SIZE, + MAX_RELAY_HOPS, + HEARTBEAT_INTERVAL, + PEER_TIMEOUT, + BROADCAST_THROTTLE, + DIAGNOSTICS_INTERVAL, + PORT, +}; diff --git a/src/core/identity.js b/src/core/identity.js new file mode 100644 index 0000000..0c64857 --- /dev/null +++ b/src/core/identity.js @@ -0,0 +1,21 @@ +const crypto = require("crypto"); +const { POW_PREFIX } = require("../config/constants"); + +const generateIdentity = () => { + const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519"); + const id = publicKey.export({ type: "spki", format: "der" }).toString("hex"); + + let nonce = 0; + while (true) { + const hash = crypto + .createHash("sha256") + .update(id + nonce) + .digest("hex"); + if (hash.startsWith(POW_PREFIX)) break; + nonce++; + } + + return { publicKey, privateKey, id, nonce }; +} + +module.exports = { generateIdentity }; diff --git a/src/core/security.js b/src/core/security.js new file mode 100644 index 0000000..9544583 --- /dev/null +++ b/src/core/security.js @@ -0,0 +1,43 @@ +const crypto = require("crypto"); +const { POW_PREFIX } = require("../config/constants"); + +const verifyPoW = (id, nonce) => { + if (!nonce) return false; + const powHash = crypto + .createHash("sha256") + .update(id + nonce) + .digest("hex"); + return powHash.startsWith(POW_PREFIX); +} + +const signMessage = (message, privateKey) => { + return crypto.sign(null, Buffer.from(message), privateKey).toString("hex"); +} + +const verifySignature = (message, signature, publicKey) => { + try { + return crypto.verify( + null, + Buffer.from(message), + publicKey, + Buffer.from(signature, "hex") + ); + } catch (e) { + return false; + } +} + +const createPublicKey = (id) => { + return crypto.createPublicKey({ + key: Buffer.from(id, "hex"), + format: "der", + type: "spki", + }); +} + +module.exports = { + verifyPoW, + signMessage, + verifySignature, + createPublicKey, +}; diff --git a/src/p2p/messaging.js b/src/p2p/messaging.js new file mode 100644 index 0000000..50853a3 --- /dev/null +++ b/src/p2p/messaging.js @@ -0,0 +1,125 @@ +const { verifyPoW, verifySignature, createPublicKey } = require("../core/security"); +const { MAX_RELAY_HOPS } = require("../config/constants"); + +class MessageHandler { + constructor(peerManager, diagnostics, relayCallback, broadcastCallback) { + this.peerManager = peerManager; + this.diagnostics = diagnostics; + this.relayCallback = relayCallback; + this.broadcastCallback = broadcastCallback; + } + + handleMessage(msg, sourceSocket) { + if (!validateMessage(msg)) { + return; + } + + if (msg.type === "HEARTBEAT") { + this.handleHeartbeat(msg, sourceSocket); + } else if (msg.type === "LEAVE") { + this.handleLeave(msg, sourceSocket); + } + } + + handleHeartbeat(msg, sourceSocket) { + this.diagnostics.increment("heartbeatsReceived"); + const { id, seq, hops, nonce, sig } = msg; + + if (!verifyPoW(id, nonce)) { + this.diagnostics.increment("invalidPoW"); + return; + } + + const stored = this.peerManager.getPeer(id); + if (stored && seq <= stored.seq) { + this.diagnostics.increment("duplicateSeq"); + return; + } + + if (!sig) return; + + try { + let key; + if (stored && stored.key) { + key = stored.key; + } else { + if (!this.peerManager.canAcceptPeer(id)) return; + key = createPublicKey(id); + } + + if (!verifySignature(`seq:${seq}`, sig, key)) { + this.diagnostics.increment("invalidSig"); + return; + } + + if (hops === 0) { + sourceSocket.peerId = id; + } + + const wasNew = this.peerManager.addOrUpdatePeer(id, seq, key); + + if (wasNew) { + this.diagnostics.increment("newPeersAdded"); + this.broadcastCallback(); + } + + if (hops < MAX_RELAY_HOPS) { + this.diagnostics.increment("heartbeatsRelayed"); + this.relayCallback({ ...msg, hops: hops + 1 }, sourceSocket); + } + } catch (e) { + return; + } + } + + handleLeave(msg, sourceSocket) { + this.diagnostics.increment("leaveMessages"); + const { id, hops, sig } = msg; + + if (!sig) return; + + const stored = this.peerManager.getPeer(id); + if (!stored || !stored.key) return; + + if (!verifySignature(`type:LEAVE:${id}`, sig, stored.key)) { + this.diagnostics.increment("invalidSig"); + return; + } + + if (this.peerManager.hasPeer(id)) { + this.peerManager.removePeer(id); + this.broadcastCallback(); + + if (hops < MAX_RELAY_HOPS) { + this.relayCallback({ ...msg, hops: hops + 1 }, sourceSocket); + } + } + } +} + +const validateMessage = (msg) => { + if (!msg || typeof msg !== 'object') return false; + if (!msg.type) return false; + + const msgSize = JSON.stringify(msg).length; + if (msgSize > require("../config/constants").MAX_MESSAGE_SIZE) return false; + + if (msg.type === "HEARTBEAT") { + const allowedFields = ['type', 'id', 'seq', 'hops', 'nonce', 'sig']; + const fields = Object.keys(msg); + return fields.every(f => allowedFields.includes(f)) && + msg.id && typeof msg.seq === 'number' && + typeof msg.hops === 'number' && msg.nonce && msg.sig; + } + + if (msg.type === "LEAVE") { + const allowedFields = ['type', 'id', 'hops', 'sig']; + const fields = Object.keys(msg); + return fields.every(f => allowedFields.includes(f)) && + msg.id && typeof msg.hops === 'number' && msg.sig; + } + + return false; +} + +module.exports = { MessageHandler, validateMessage }; diff --git a/src/p2p/relay.js b/src/p2p/relay.js new file mode 100644 index 0000000..1bc6745 --- /dev/null +++ b/src/p2p/relay.js @@ -0,0 +1,16 @@ +const relayMessage = (msg, sourceSocket, swarm, diagnostics) => { + const data = JSON.stringify(msg) + "\n"; + const relayCount = swarm.connections.size - 1; + + if (diagnostics) { + diagnostics.increment("bytesRelayed", data.length * relayCount); + } + + for (const socket of swarm.connections) { + if (socket !== sourceSocket) { + socket.write(data); + } + } +} + +module.exports = { relayMessage }; diff --git a/src/p2p/swarm.js b/src/p2p/swarm.js new file mode 100644 index 0000000..6c4292b --- /dev/null +++ b/src/p2p/swarm.js @@ -0,0 +1,118 @@ +const Hyperswarm = require("hyperswarm"); +const { signMessage } = require("../core/security"); +const { TOPIC, TOPIC_NAME, HEARTBEAT_INTERVAL } = require("../config/constants"); + +class SwarmManager { + constructor(identity, peerManager, diagnostics, messageHandler, relayFn, broadcastFn) { + this.identity = identity; + this.peerManager = peerManager; + this.diagnostics = diagnostics; + this.messageHandler = messageHandler; + this.relayFn = relayFn; + this.broadcastFn = broadcastFn; + + this.swarm = new Hyperswarm(); + this.heartbeatInterval = null; + } + + async start() { + this.swarm.on("connection", (socket) => this.handleConnection(socket)); + + const discovery = this.swarm.join(TOPIC); + await discovery.flushed(); + + this.startHeartbeat(); + } + + handleConnection(socket) { + const sig = signMessage(`seq:${this.peerManager.getSeq()}`, this.identity.privateKey); + const hello = JSON.stringify({ + type: "HEARTBEAT", + id: this.identity.id, + seq: this.peerManager.getSeq(), + hops: 0, + nonce: this.identity.nonce, + sig, + }); + socket.write(hello); + this.broadcastFn(); + + socket.on("data", (data) => { + this.diagnostics.increment("bytesReceived", data.length); + try { + const msgs = data + .toString() + .split("\n") + .filter((x) => x.trim()); + for (const msgStr of msgs) { + const msg = JSON.parse(msgStr); + this.messageHandler.handleMessage(msg, socket); + } + } catch (e) { + } + }); + + socket.on("close", () => { + if (socket.peerId && this.peerManager.hasPeer(socket.peerId)) { + this.peerManager.removePeer(socket.peerId); + } + this.broadcastFn(); + }); + + socket.on("error", () => { }); + } + + startHeartbeat() { + this.heartbeatInterval = setInterval(() => { + const seq = this.peerManager.incrementSeq(); + this.peerManager.addOrUpdatePeer(this.identity.id, seq, null); + + const sig = signMessage(`seq:${seq}`, this.identity.privateKey); + const heartbeat = JSON.stringify({ + type: "HEARTBEAT", + id: this.identity.id, + seq, + hops: 0, + nonce: this.identity.nonce, + sig, + }) + "\n"; + + for (const socket of this.swarm.connections) { + socket.write(heartbeat); + } + + const removed = this.peerManager.cleanupStalePeers(); + if (removed > 0) { + this.broadcastFn(); + } + }, HEARTBEAT_INTERVAL); + } + + shutdown() { + const sig = signMessage(`type:LEAVE:${this.identity.id}`, this.identity.privateKey); + const goodbye = JSON.stringify({ + type: "LEAVE", + id: this.identity.id, + hops: 0, + sig, + }) + "\n"; + + for (const socket of this.swarm.connections) { + socket.write(goodbye); + } + + if (this.heartbeatInterval) { + clearInterval(this.heartbeatInterval); + } + + setTimeout(() => { + process.exit(0); + }, 500); + } + + getSwarm() { + return this.swarm; + } +} + +module.exports = { SwarmManager }; diff --git a/src/state/diagnostics.js b/src/state/diagnostics.js new file mode 100644 index 0000000..659e406 --- /dev/null +++ b/src/state/diagnostics.js @@ -0,0 +1,48 @@ +const { DIAGNOSTICS_INTERVAL } = require("../config/constants"); + +class DiagnosticsManager { + constructor() { + this.stats = { + heartbeatsReceived: 0, + heartbeatsRelayed: 0, + invalidPoW: 0, + duplicateSeq: 0, + invalidSig: 0, + newPeersAdded: 0, + bytesReceived: 0, + bytesRelayed: 0, + leaveMessages: 0, + }; + + this.interval = null; + } + + increment(key, amount = 1) { + if (this.stats.hasOwnProperty(key)) { + this.stats[key] += amount; + } + } + + getStats() { + return { ...this.stats }; + } + + reset() { + Object.keys(this.stats).forEach(k => this.stats[k] = 0); + } + + startLogging(getPeerCount, getConnectionCount) { + this.interval = setInterval(() => { + this.reset(); + }, DIAGNOSTICS_INTERVAL); + } + + stopLogging() { + if (this.interval) { + clearInterval(this.interval); + this.interval = null; + } + } +} + +module.exports = { DiagnosticsManager }; diff --git a/src/state/peers.js b/src/state/peers.js new file mode 100644 index 0000000..f54b312 --- /dev/null +++ b/src/state/peers.js @@ -0,0 +1,66 @@ +const { MAX_PEERS, PEER_TIMEOUT } = require("../config/constants"); + +class PeerManager { + constructor() { + this.seenPeers = new Map(); + this.mySeq = 0; + } + + addOrUpdatePeer(id, seq, key) { + const stored = this.seenPeers.get(id); + const wasNew = !stored; + + this.seenPeers.set(id, { + seq, + lastSeen: Date.now(), + key, + }); + + return wasNew; + } + + canAcceptPeer(id) { + if (this.seenPeers.has(id)) return true; + return this.seenPeers.size < MAX_PEERS; + } + + getPeer(id) { + return this.seenPeers.get(id); + } + + removePeer(id) { + return this.seenPeers.delete(id); + } + + hasPeer(id) { + return this.seenPeers.has(id); + } + + cleanupStalePeers() { + const now = Date.now(); + let removed = 0; + + for (const [id, data] of this.seenPeers) { + if (now - data.lastSeen > PEER_TIMEOUT) { + this.seenPeers.delete(id); + removed++; + } + } + + return removed; + } + + get size() { + return this.seenPeers.size; + } + + incrementSeq() { + return ++this.mySeq; + } + + getSeq() { + return this.mySeq; + } +} + +module.exports = { PeerManager }; diff --git a/src/web/routes.js b/src/web/routes.js new file mode 100644 index 0000000..f5e5ab3 --- /dev/null +++ b/src/web/routes.js @@ -0,0 +1,56 @@ +const express = require("express"); +const fs = require("fs"); +const path = require("path"); + +const HTML_TEMPLATE = fs.readFileSync( + path.join(__dirname, "../../public/index.html"), + "utf-8" +); + +const setupRoutes = (app, identity, peerManager, swarm, sseManager, diagnostics) => { + app.get("/", (req, res) => { + const count = peerManager.size; + const directPeers = swarm.getSwarm().connections.size; + + const html = HTML_TEMPLATE + .replace(/\{\{COUNT\}\}/g, count) + .replace(/\{\{ID\}\}/g, identity.id.slice(0, 8) + "...") + .replace(/\{\{DIRECT\}\}/g, directPeers); + + res.send(html); + }); + + app.get("/events", (req, res) => { + res.setHeader("Content-Type", "text/event-stream"); + res.setHeader("Cache-Control", "no-cache"); + res.setHeader("Connection", "keep-alive"); + res.flushHeaders(); + + sseManager.addClient(res); + + const data = JSON.stringify({ + count: peerManager.size, + direct: swarm.getSwarm().connections.size, + id: identity.id, + diagnostics: diagnostics.getStats(), + }); + res.write(`data: ${data}\n\n`); + + req.on("close", () => { + sseManager.removeClient(res); + }); + }); + + app.get("/api/stats", (req, res) => { + res.json({ + count: peerManager.size, + direct: swarm.getSwarm().connections.size, + id: identity.id, + diagnostics: diagnostics.getStats(), + }); + }); + + app.use(express.static(path.join(__dirname, "../../public"))); +} + +module.exports = { setupRoutes }; diff --git a/src/web/server.js b/src/web/server.js new file mode 100644 index 0000000..00a4409 --- /dev/null +++ b/src/web/server.js @@ -0,0 +1,20 @@ +const express = require("express"); +const { PORT } = require("../config/constants"); +const { setupRoutes } = require("./routes"); + +const createServer = (identity, peerManager, swarm, sseManager, diagnostics) => { + const app = express(); + + setupRoutes(app, identity, peerManager, swarm, sseManager, diagnostics); + + return app; +} + +const startServer = (app, identity) => { + app.listen(PORT, () => { + console.log(`Hypermind Node running on port ${PORT}`); + console.log(`ID: ${identity.id}`); + }); +} + +module.exports = { createServer, startServer }; diff --git a/src/web/sse.js b/src/web/sse.js new file mode 100644 index 0000000..c80f3cc --- /dev/null +++ b/src/web/sse.js @@ -0,0 +1,33 @@ +const { BROADCAST_THROTTLE } = require("../config/constants"); + +class SSEManager { + constructor() { + this.clients = new Set(); + this.lastBroadcast = 0; + } + + addClient(res) { + this.clients.add(res); + } + + removeClient(res) { + this.clients.delete(res); + } + + broadcastUpdate(data) { + const now = Date.now(); + if (now - this.lastBroadcast < BROADCAST_THROTTLE) return; + this.lastBroadcast = now; + + const message = JSON.stringify(data); + for (const client of this.clients) { + client.write(`data: ${message}\n\n`); + } + } + + get size() { + return this.clients.size; + } +} + +module.exports = { SSEManager }; From 857055bc0c5cadc23abbda31d8c35af59c9e88ce Mon Sep 17 00:00:00 2001 From: Kilian Tyler Date: Fri, 2 Jan 2026 18:32:26 -0500 Subject: [PATCH 2/6] fix(docker): Include src and public directories --- Dockerfile | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Dockerfile b/Dockerfile index d2f5dfd..1ef67a0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,7 +6,9 @@ COPY package*.json ./ RUN npm ci --omit=dev +COPY public/ ./public/ COPY server.js hypermind2.svg LICENSE ./ +COPY src/ ./src/ ENV PORT=3000 ENV NODE_ENV=production From bcd6daf6b6645ed29c02bd0afb0e2721449b4f27 Mon Sep 17 00:00:00 2001 From: Kilian Tyler Date: Fri, 2 Jan 2026 18:45:26 -0500 Subject: [PATCH 3/6] ci(workflow): Integrate API readiness tests --- .github/workflows/publish.yml | 53 +++++++++++++++++++++++++++++++++-- 1 file changed, 50 insertions(+), 3 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index d800435..d484553 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -25,10 +25,13 @@ jobs: include: - platform: linux/amd64 runner: ubuntu-latest + test: true - platform: linux/arm64 runner: ubuntu-24.04-arm + test: true - platform: linux/arm/v7 runner: ubuntu-24.04-arm + test: false steps: - name: Checkout repository @@ -54,20 +57,64 @@ jobs: with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - - name: Build and push by digest + - name: Build Docker image id: build uses: docker/build-push-action@v6 with: context: . platforms: ${{ matrix.platform }} labels: ${{ steps.meta.outputs.labels }} - outputs: ${{ github.event_name != 'pull_request' && format('type=image,name={0}/{1},push-by-digest=true,name-canonical=true,push=true', env.REGISTRY, env.IMAGE_NAME) || 'type=docker' }} + load: ${{ matrix.test }} + tags: hypermind-test:${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Run container for testing + if: matrix.test + run: docker run -d --name hypermind-test -p 3000:3000 hypermind-test:${{ github.sha }} + + - name: Wait for server to be ready + if: matrix.test + run: | + for i in {1..30}; do + if curl -sf http://localhost:3000/api/stats; then + echo "Server is ready" + exit 0 + fi + echo "Waiting for server... ($i/30)" + sleep 1 + done + echo "Server failed to start" + docker logs hypermind-test + exit 1 + + - name: Verify API response + if: matrix.test + run: | + response=$(curl -sf http://localhost:3000/api/stats) + echo "Response: $response" + echo "$response" | jq -e '.count != null and .id != null' + + - name: Cleanup test container + if: always() && matrix.test + run: docker rm -f hypermind-test || true + + - name: Push by digest + if: github.event_name != 'pull_request' + id: push + uses: docker/build-push-action@v6 + with: + context: . + platforms: ${{ matrix.platform }} + labels: ${{ steps.meta.outputs.labels }} + outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true + cache-from: type=gha - name: Export digest if: github.event_name != 'pull_request' run: | mkdir -p ${{ runner.temp }}/digests - digest="${{ steps.build.outputs.digest }}" + digest="${{ steps.push.outputs.digest }}" touch "${{ runner.temp }}/digests/${digest#sha256:}" - name: Upload digest From 78e1b82815a5f62a280f655bd15c9e00bfc3096b Mon Sep 17 00:00:00 2001 From: Kilian Tyler Date: Fri, 2 Jan 2026 18:57:29 -0500 Subject: [PATCH 4/6] ci(workflows): Decouple image build & publish --- .github/workflows/build.yml | 131 ++++++++++++++++++++++++++++++++++ .github/workflows/ci.yml | 11 +++ .github/workflows/publish.yml | 116 +----------------------------- 3 files changed, 145 insertions(+), 113 deletions(-) create mode 100644 .github/workflows/build.yml create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..db872a4 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,131 @@ +name: Build + +on: + workflow_call: + inputs: + push-to-registry: + description: "Whether to push images to the container registry" + required: false + default: false + type: boolean + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build: + runs-on: ${{ matrix.runner }} + permissions: + contents: read + packages: write + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + arch: amd64 + runner: ubuntu-latest + test: true + - platform: linux/arm64 + arch: arm64 + runner: ubuntu-24.04-arm + test: true + - platform: linux/arm/v7 + arch: armv7 + runner: ubuntu-24.04-arm + test: false + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to the Container registry + if: inputs.push-to-registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker + if: inputs.push-to-registry + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + + - name: Build Docker image + id: build + uses: docker/build-push-action@v6 + with: + context: . + platforms: ${{ matrix.platform }} + labels: ${{ steps.meta.outputs.labels }} + load: ${{ matrix.test }} + tags: hypermind-test:${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Run container for testing + if: matrix.test + run: docker run -d --name hypermind-test -p 3000:3000 hypermind-test:${{ github.sha }} + + - name: Wait for server to be ready + if: matrix.test + run: | + for i in {1..30}; do + if curl -sf http://localhost:3000/api/stats; then + echo "Server is ready" + exit 0 + fi + echo "Waiting for server... ($i/30)" + sleep 1 + done + echo "Server failed to start" + docker logs hypermind-test + exit 1 + + - name: Verify API response + if: matrix.test + run: | + response=$(curl -sf http://localhost:3000/api/stats) + echo "Response: $response" + echo "$response" | jq -e '.count != null and .id != null' + + - name: Cleanup test container + if: always() && matrix.test + run: docker rm -f hypermind-test || true + + - name: Push by digest + if: inputs.push-to-registry + id: push + uses: docker/build-push-action@v6 + with: + context: . + platforms: ${{ matrix.platform }} + labels: ${{ steps.meta.outputs.labels }} + outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true + cache-from: type=gha + + - name: Export digest + if: inputs.push-to-registry + run: | + mkdir -p ${{ runner.temp }}/digests + digest="${{ steps.push.outputs.digest }}" + touch "${{ runner.temp }}/digests/${digest#sha256:}" + + - name: Upload digest + if: inputs.push-to-registry + uses: actions/upload-artifact@v4 + with: + name: digests-${{ matrix.arch }} + path: ${{ runner.temp }}/digests/* + if-no-files-found: error + retention-days: 1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5f81369 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,11 @@ +name: CI + +on: + pull_request: + branches: ["main"] + +jobs: + build: + uses: ./.github/workflows/build.yml + with: + push-to-registry: false diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index d484553..38e3475 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,8 +1,6 @@ name: Publish Docker Image on: - pull_request: - branches: ["main"] push: branches: ["main"] release: @@ -15,120 +13,12 @@ env: jobs: build: - runs-on: ${{ matrix.runner }} - permissions: - contents: read - packages: write - strategy: - fail-fast: false - matrix: - include: - - platform: linux/amd64 - runner: ubuntu-latest - test: true - - platform: linux/arm64 - runner: ubuntu-24.04-arm - test: true - - platform: linux/arm/v7 - runner: ubuntu-24.04-arm - test: false - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Log in to the Container registry - if: github.event_name != 'pull_request' - uses: docker/login-action@v3 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Extract metadata (tags, labels) for Docker - id: meta - uses: docker/metadata-action@v5 - with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - - - name: Build Docker image - id: build - uses: docker/build-push-action@v6 - with: - context: . - platforms: ${{ matrix.platform }} - labels: ${{ steps.meta.outputs.labels }} - load: ${{ matrix.test }} - tags: hypermind-test:${{ github.sha }} - cache-from: type=gha - cache-to: type=gha,mode=max - - - name: Run container for testing - if: matrix.test - run: docker run -d --name hypermind-test -p 3000:3000 hypermind-test:${{ github.sha }} - - - name: Wait for server to be ready - if: matrix.test - run: | - for i in {1..30}; do - if curl -sf http://localhost:3000/api/stats; then - echo "Server is ready" - exit 0 - fi - echo "Waiting for server... ($i/30)" - sleep 1 - done - echo "Server failed to start" - docker logs hypermind-test - exit 1 - - - name: Verify API response - if: matrix.test - run: | - response=$(curl -sf http://localhost:3000/api/stats) - echo "Response: $response" - echo "$response" | jq -e '.count != null and .id != null' - - - name: Cleanup test container - if: always() && matrix.test - run: docker rm -f hypermind-test || true - - - name: Push by digest - if: github.event_name != 'pull_request' - id: push - uses: docker/build-push-action@v6 - with: - context: . - platforms: ${{ matrix.platform }} - labels: ${{ steps.meta.outputs.labels }} - outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true - cache-from: type=gha - - - name: Export digest - if: github.event_name != 'pull_request' - run: | - mkdir -p ${{ runner.temp }}/digests - digest="${{ steps.push.outputs.digest }}" - touch "${{ runner.temp }}/digests/${digest#sha256:}" - - - name: Upload digest - if: github.event_name != 'pull_request' - uses: actions/upload-artifact@v4 - with: - name: digests-${{ matrix.platform == 'linux/amd64' && 'amd64' || matrix.platform == 'linux/arm64' && 'arm64' || 'armv7' }} - path: ${{ runner.temp }}/digests/* - if-no-files-found: error - retention-days: 1 + uses: ./.github/workflows/build.yml + with: + push-to-registry: true merge: runs-on: ubuntu-latest - if: github.event_name != 'pull_request' needs: build permissions: contents: read From 19b13b32389a8d3e10713e3d18a71a5b66c42013 Mon Sep 17 00:00:00 2001 From: Kilian Tyler Date: Fri, 2 Jan 2026 18:57:37 -0500 Subject: [PATCH 5/6] docs(readme): document environment variables --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index 227ec30..2103205 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,13 @@ services: ``` +## Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `PORT` | `3000` | The port the web dashboard listens on. Since `--network host` is used, this port opens directly on the host. | +| `MAX_PEERS` | `10000` | Maximum number of peers to track in the swarm. Unless you're expecting the entire internet to join, the default is probably fine. | + ## Usage Open your browser to: `http://localhost:3000` From e88f36412d3e14ac4a1dbbe45df4cc7fb639c012 Mon Sep 17 00:00:00 2001 From: Kilian Tyler Date: Fri, 2 Jan 2026 18:59:47 -0500 Subject: [PATCH 6/6] ci: Granular workflow permissions --- .github/workflows/build.yml | 3 --- .github/workflows/ci.yml | 2 ++ .github/workflows/publish.yml | 4 ++++ 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index db872a4..4fe22a9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -16,9 +16,6 @@ env: jobs: build: runs-on: ${{ matrix.runner }} - permissions: - contents: read - packages: write strategy: fail-fast: false matrix: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5f81369..061f9bc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,5 +7,7 @@ on: jobs: build: uses: ./.github/workflows/build.yml + permissions: + contents: read with: push-to-registry: false diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 38e3475..91976e7 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -14,6 +14,10 @@ env: jobs: build: uses: ./.github/workflows/build.yml + permissions: + contents: read + packages: write + secrets: inherit with: push-to-registry: true