mirror of
https://github.com/lklynet/hypermind.git
synced 2026-05-03 09:30:36 +00:00
0d589872cd
- Cache peer public keys to avoid repeated key creation - Increase peer cleanup interval from 2.5s to 15s to reduce overhead - Adjust cleanup check interval from 500ms to 5s
449 lines
13 KiB
JavaScript
449 lines
13 KiB
JavaScript
const express = require("express");
|
|
const Hyperswarm = require("hyperswarm");
|
|
const crypto = require("crypto");
|
|
|
|
const app = express();
|
|
const PORT = process.env.PORT || 3000;
|
|
|
|
// --- CONFIGURATION ---
|
|
const TOPIC_NAME = "hypermind-lklynet-v1";
|
|
const TOPIC = crypto.createHash("sha256").update(TOPIC_NAME).digest();
|
|
|
|
// --- 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";
|
|
|
|
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}`
|
|
);
|
|
|
|
let mySeq = 0;
|
|
|
|
const seenPeers = new Map();
|
|
|
|
const sseClients = new Set();
|
|
|
|
seenPeers.set(MY_ID, { seq: mySeq, lastSeen: Date.now() });
|
|
|
|
// 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);
|
|
}
|
|
broadcastUpdate();
|
|
});
|
|
|
|
socket.on("error", () => {});
|
|
});
|
|
|
|
const discovery = swarm.join(TOPIC);
|
|
discovery.flushed().then(() => {
|
|
console.log("[P2P] Joined topic:", TOPIC_NAME);
|
|
});
|
|
|
|
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. Verify Signature
|
|
if (!sig) return;
|
|
try {
|
|
let key;
|
|
const storedPeer = seenPeers.get(id);
|
|
if (storedPeer && storedPeer.key) {
|
|
key = storedPeer.key;
|
|
} else {
|
|
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
|
|
|
|
// Store key for future use if not already stored
|
|
if (!storedPeer || !storedPeer.key) {
|
|
// We'll attach it when we update seenPeers below
|
|
}
|
|
|
|
if (hops === 0) {
|
|
sourceSocket.peerId = id;
|
|
}
|
|
|
|
const now = Date.now();
|
|
const stored = seenPeers.get(id);
|
|
|
|
let shouldUpdate = false;
|
|
|
|
if (!stored) {
|
|
// New peer
|
|
shouldUpdate = true;
|
|
} else if (seq > stored.seq) {
|
|
shouldUpdate = true;
|
|
}
|
|
|
|
if (shouldUpdate) {
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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(`
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>Hypermind</title>
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<style>
|
|
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; }
|
|
.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: #333; margin-top: 1rem; }
|
|
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); }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<canvas id="network"></canvas>
|
|
<div class="container">
|
|
<div id="count" class="count">${count}</div>
|
|
<div class="label">Active Nodes</div>
|
|
<div class="footer">
|
|
powered by <a href="https://github.com/lklynet/hypermind" target="_blank">hypermind</a>
|
|
</div>
|
|
<div class="debug">
|
|
ID: ${MY_ID.slice(0, 8)}...<br>
|
|
Direct Connections: <span id="direct">${directPeers}</span>
|
|
</div>
|
|
</div>
|
|
<script>
|
|
const countEl = document.getElementById('count');
|
|
const directEl = document.getElementById('direct');
|
|
|
|
// Particle System
|
|
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();
|
|
}
|
|
}
|
|
|
|
function updateParticles(count) {
|
|
// Limit visual particles to 500 to prevent browser crash
|
|
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);
|
|
}
|
|
}
|
|
|
|
// Initialize with server-rendered count
|
|
updateParticles(${count});
|
|
|
|
function animate() {
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
|
|
// Draw connections
|
|
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);
|
|
}
|
|
|
|
animate();
|
|
|
|
// Use Server-Sent Events for realtime updates
|
|
const evtSource = new EventSource("/events");
|
|
|
|
evtSource.onmessage = (event) => {
|
|
const data = JSON.parse(event.data);
|
|
|
|
updateParticles(data.count);
|
|
|
|
// Only update and animate if changed
|
|
if (countEl.innerText != data.count) {
|
|
countEl.innerText = data.count;
|
|
countEl.classList.remove('pulse');
|
|
void countEl.offsetWidth; // trigger reflow
|
|
countEl.classList.add('pulse');
|
|
}
|
|
|
|
directEl.innerText = data.direct;
|
|
};
|
|
|
|
evtSource.onerror = (err) => {
|
|
console.error("EventSource failed:", err);
|
|
};
|
|
</script>
|
|
</body>
|
|
</html>
|
|
`);
|
|
});
|
|
|
|
// 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}`);
|
|
});
|