mirror of
https://github.com/lklynet/hypermind.git
synced 2026-05-03 09:30:36 +00:00
Merge branch 'main' into custom-css-themes
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
#github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
||||
#patreon: # Replace with a single Patreon username
|
||||
#open_collective: # Replace with a single Open Collective username
|
||||
#ko_fi: # Replace with a single Ko-fi username
|
||||
#tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
#community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
#liberapay: # Replace with a single Liberapay username
|
||||
#issuehunt: # Replace with a single IssueHunt username
|
||||
#lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
||||
#polar: # Replace with a single Polar username
|
||||
#buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
|
||||
#thanks_dev: # Replace with a single thanks.dev username
|
||||
#custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||
|
||||
github: lklynet
|
||||
buy_me_a_coffee: lkly
|
||||
thanks_dev: lklynet
|
||||
@@ -81,6 +81,33 @@ kubectl expose deployment hypermind --type=LoadBalancer --port=3000 --target-por
|
||||
|
||||
```
|
||||
|
||||
## » Configuration
|
||||
|
||||
You can customize Hypermind's behavior using environment variables.
|
||||
|
||||
### Extras
|
||||
These features are disabled by default. Set them to `true` to enable.
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `ENABLE_CHAT` | `false` | Enables the decentralized chat system. |
|
||||
| `ENABLE_MAP` | `false` | Enables map visualization features. |
|
||||
|
||||
### Refinement
|
||||
Tune the network parameters to fit your system resources. The defaults are safe for most users. Don't change unless you know what you're doing.
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `MAX_PEERS` | `50000` | Maximum number of peers to track in memory. |
|
||||
| `MAX_MESSAGE_SIZE` | `2048` | Maximum size of a single message in bytes. |
|
||||
| `MAX_RELAY_HOPS` | `2` | Maximum number of times a message is relayed. |
|
||||
| `MAX_CONNECTIONS` | `15` | Maximum number of active P2P connections. |
|
||||
| `HEARTBEAT_INTERVAL` | `30000` | How often (ms) to send heartbeat messages. |
|
||||
| `CONNECTION_ROTATION_INTERVAL` | `300000` | How often (ms) to rotate connections. |
|
||||
| `PEER_TIMEOUT` | `45000` | Time (ms) before a silent peer is considered offline. |
|
||||
| `CHAT_RATE_LIMIT` | `5000` | Time window (ms) for chat rate limiting. |
|
||||
| `VISUAL_LIMIT` | `500` | Max number of particles to render on the dashboard. |
|
||||
|
||||
## » Ecosystem & Integrations
|
||||
|
||||
The community has bravely stepped up to integrate Hypermind into critical monitoring infrastructure.
|
||||
@@ -125,7 +152,29 @@ See detailed [instructions](https://gethomepage.dev/configs/services/#icons).
|
||||
| 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` | `1000000` | Maximum number of peers to track in the swarm. Unless you're expecting the entire internet to join, the default is probably fine. |
|
||||
| `MAX_PEERS` | `50000` | Maximum number of peers to track in the swarm. Unless you're expecting the entire internet to join, the default is probably fine. |
|
||||
| `ENABLE_CHAT` | `false` | Set to `true` to enable the ephemeral P2P chat terminal. |
|
||||
|
||||
## » Features
|
||||
|
||||
### 1. The Counter
|
||||
It counts. That's the main thing.
|
||||
|
||||
### 2. Ephemeral Chat
|
||||
**New:** A completely decentralized, ephemeral chat system built directly on top of the swarm topology.
|
||||
|
||||
* **Ephemeral:** No database. No history. If you refresh, it's gone.
|
||||
* **Restricted (Default):** You can only talk to your ~32 direct connections.
|
||||
* **Global Mode:** Use `/global your message` to broadcast messages to the entire swarm (relayed via gossip).
|
||||
* **Chaotic:** Every 30 seconds, the network rotates your connections. You might be mid-sentence and—*poof*—your audience changes.
|
||||
* **Anonymous:** You are identified only by the last 4 characters of your node ID.
|
||||
|
||||
To enable this feature, set `ENABLE_CHAT=true`.
|
||||
|
||||
**Commands:**
|
||||
* `/global on` - Enable global chat mode.
|
||||
* `/global off` - Disable global chat mode (local only).
|
||||
* `/global <message>` - Send a single message to the global swarm without switching modes.
|
||||
|
||||
## » Usage
|
||||
|
||||
|
||||
+15
-1
@@ -6,4 +6,18 @@ services:
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- PORT=3000
|
||||
- MAX_PEERS=10000
|
||||
|
||||
# --- Add-ons ---
|
||||
# - ENABLE_CHAT=false # Enable decentralized chat
|
||||
# - ENABLE_MAP=false # Enable peer map visualization
|
||||
|
||||
# --- Refinements ---
|
||||
# - MAX_PEERS=50000 # Max peers to track in memory
|
||||
# - MAX_CONNECTIONS=15 # Max active P2P connections
|
||||
# - HEARTBEAT_INTERVAL=30000 # Heartbeat frequency (ms)
|
||||
# - CONNECTION_ROTATION_INTERVAL=300000 # Connection rotation frequency (ms)
|
||||
# - PEER_TIMEOUT=45000 # Time before peer is considered offline (ms)
|
||||
# - MAX_MESSAGE_SIZE=2048 # Max message size (bytes)
|
||||
# - MAX_RELAY_HOPS=2 # Max message relay hops
|
||||
# - CHAT_RATE_LIMIT=5000 # Chat rate limit window (ms)
|
||||
# - VISUAL_LIMIT=500 # Max particles on dashboard
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"start:dev1": "PORT=3000 ENABLE_CHAT=true TOPIC_NAME=hypermind-dev node server.js",
|
||||
"start:dev2": "PORT=3001 ENABLE_CHAT=true TOPIC_NAME=hypermind-dev node server.js",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"repository": {
|
||||
|
||||
+336
-1
@@ -42,7 +42,8 @@ class Particle {
|
||||
}
|
||||
|
||||
const updateParticles = (count) => {
|
||||
const VISUAL_LIMIT = 500;
|
||||
const limitAttr = canvas.getAttribute('data-visual-limit');
|
||||
const VISUAL_LIMIT = limitAttr ? parseInt(limitAttr) : 500;
|
||||
const visualCount = Math.min(count, VISUAL_LIMIT);
|
||||
|
||||
const currentCount = particles.length;
|
||||
@@ -100,6 +101,295 @@ document.getElementById('diagnosticsModal').addEventListener('click', (e) => {
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
closeDiagnostics();
|
||||
closeMap();
|
||||
}
|
||||
});
|
||||
|
||||
// Map Logic
|
||||
let map = null;
|
||||
let mapInitialized = false;
|
||||
let peerMarkers = {}; // id -> marker
|
||||
let ipCache = {}; // ip -> { lat, lon }
|
||||
let lastPeerData = [];
|
||||
let myLocation = null;
|
||||
|
||||
const fetchMyLocation = async () => {
|
||||
if (myLocation) return;
|
||||
try {
|
||||
const res = await fetch('https://ipwho.is/');
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
myLocation = { lat: data.latitude, lon: data.longitude, city: data.city, country: data.country };
|
||||
updateMap(lastPeerData);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('My location fetch failed', e);
|
||||
}
|
||||
}
|
||||
|
||||
const openMap = () => {
|
||||
document.getElementById('mapModal').classList.add('active');
|
||||
if (!mapInitialized) {
|
||||
initMap();
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
map.invalidateSize();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
fetchMyLocation();
|
||||
|
||||
if (lastPeerData.length > 0) {
|
||||
updateMap(lastPeerData);
|
||||
}
|
||||
}
|
||||
|
||||
const closeMap = () => {
|
||||
document.getElementById('mapModal').classList.remove('active');
|
||||
}
|
||||
|
||||
document.getElementById('mapModal').addEventListener('click', (e) => {
|
||||
if (e.target.id === 'mapModal') {
|
||||
closeMap();
|
||||
}
|
||||
});
|
||||
|
||||
const initMap = () => {
|
||||
if (mapInitialized) return;
|
||||
|
||||
map = L.map('map').setView([20, 0], 2);
|
||||
|
||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>',
|
||||
subdomains: 'abcd',
|
||||
maxZoom: 19
|
||||
}).addTo(map);
|
||||
|
||||
mapInitialized = true;
|
||||
|
||||
setTimeout(() => {
|
||||
map.invalidateSize();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
const fetchLocation = async (ip) => {
|
||||
if (ipCache[ip]) return ipCache[ip];
|
||||
|
||||
// Skip local IPs
|
||||
if (ip === '127.0.0.1' || ip === '::1' || ip.startsWith('192.168.') || ip.startsWith('10.') || ip.startsWith('172.16.')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`https://ipwho.is/${ip}`);
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
const loc = { lat: data.latitude, lon: data.longitude, city: data.city, country: data.country };
|
||||
ipCache[ip] = loc;
|
||||
return loc;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Geo fetch failed', e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const updateMap = async (peers) => {
|
||||
if (!mapInitialized) return;
|
||||
if (!peers) peers = [];
|
||||
|
||||
const currentIds = new Set(peers.map(p => p.id));
|
||||
|
||||
// Remove old markers
|
||||
for (const id in peerMarkers) {
|
||||
if (id !== 'me' && !currentIds.has(id)) {
|
||||
map.removeLayer(peerMarkers[id]);
|
||||
delete peerMarkers[id];
|
||||
}
|
||||
}
|
||||
|
||||
// Add/Update markers
|
||||
for (const peer of peers) {
|
||||
if (!peer.ip) continue;
|
||||
|
||||
if (!peerMarkers[peer.id]) {
|
||||
const loc = await fetchLocation(peer.ip);
|
||||
if (loc) {
|
||||
const marker = L.circleMarker([loc.lat, loc.lon], {
|
||||
radius: 10,
|
||||
fillColor: "#4ade80",
|
||||
color: "transparent",
|
||||
weight: 0,
|
||||
opacity: 0,
|
||||
fillOpacity: 0.15
|
||||
}).addTo(map);
|
||||
|
||||
marker.bindPopup(`<b>Node</b> ${peer.id.slice(-8)}<br>${loc.city}, ${loc.country}`);
|
||||
peerMarkers[peer.id] = marker;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add My Location
|
||||
if (myLocation && !peerMarkers['me']) {
|
||||
const marker = L.circleMarker([myLocation.lat, myLocation.lon], {
|
||||
radius: 6,
|
||||
fillColor: "#ffffff",
|
||||
color: "#4ade80",
|
||||
weight: 2,
|
||||
opacity: 1,
|
||||
fillOpacity: 1
|
||||
}).addTo(map);
|
||||
|
||||
marker.bindPopup(`<b>This Node</b><br>${myLocation.city}, ${myLocation.country}`);
|
||||
peerMarkers['me'] = marker;
|
||||
}
|
||||
}
|
||||
|
||||
const terminal = document.getElementById('terminal');
|
||||
const terminalOutput = document.getElementById('terminal-output');
|
||||
const systemStatusBar = document.getElementById('system-status-bar');
|
||||
const terminalInput = document.getElementById('terminal-input');
|
||||
const terminalToggle = document.getElementById('terminal-toggle');
|
||||
const mapContainer = document.getElementById('map-container');
|
||||
const promptEl = document.querySelector('.prompt');
|
||||
let myId = null;
|
||||
let myChatHistory = [];
|
||||
let globalChatEnabled = true;
|
||||
|
||||
terminalToggle.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
toggleChat();
|
||||
});
|
||||
|
||||
// Initialize chat state from localStorage
|
||||
const initChatState = () => {
|
||||
const isCollapsed = localStorage.getItem('chatCollapsed') === 'true';
|
||||
if (isCollapsed) {
|
||||
terminal.classList.add('collapsed');
|
||||
terminalToggle.innerText = '▲';
|
||||
document.body.classList.remove('chat-active');
|
||||
document.body.classList.add('chat-collapsed');
|
||||
} else {
|
||||
terminal.classList.remove('collapsed');
|
||||
terminalToggle.innerText = '▼';
|
||||
document.body.classList.add('chat-active');
|
||||
document.body.classList.remove('chat-collapsed');
|
||||
}
|
||||
};
|
||||
|
||||
const toggleChat = () => {
|
||||
terminal.classList.toggle('collapsed');
|
||||
const isCollapsed = terminal.classList.contains('collapsed');
|
||||
terminalToggle.innerText = isCollapsed ? '▲' : '▼';
|
||||
|
||||
localStorage.setItem('chatCollapsed', isCollapsed);
|
||||
|
||||
if (isCollapsed) {
|
||||
document.body.classList.remove('chat-active');
|
||||
document.body.classList.add('chat-collapsed');
|
||||
} else {
|
||||
document.body.classList.add('chat-active');
|
||||
document.body.classList.remove('chat-collapsed');
|
||||
terminalOutput.scrollTop = terminalOutput.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
const updatePromptStatus = () => {
|
||||
const now = Date.now();
|
||||
myChatHistory = myChatHistory.filter(t => now - t < 10000);
|
||||
|
||||
if (myChatHistory.length >= 5) {
|
||||
promptEl.style.color = 'orange';
|
||||
} else {
|
||||
promptEl.style.color = '#4ade80';
|
||||
}
|
||||
};
|
||||
|
||||
setInterval(updatePromptStatus, 500);
|
||||
|
||||
const getColorFromId = (id) => {
|
||||
if (!id) return '#666';
|
||||
let hash = 0;
|
||||
for (let i = 0; i < id.length; i++) {
|
||||
hash = id.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
const c = (hash & 0x00FFFFFF).toString(16).toUpperCase();
|
||||
return '#' + "00000".substring(0, 6 - c.length) + c;
|
||||
}
|
||||
|
||||
const appendMessage = (msg) => {
|
||||
const div = document.createElement('div');
|
||||
|
||||
if (msg.type === 'CHAT') {
|
||||
const senderColor = getColorFromId(msg.sender);
|
||||
const senderName = msg.sender === myId ? 'You' : msg.sender.slice(-4);
|
||||
const scopeLabel = msg.scope === 'GLOBAL' ? '[GLOBAL] ' : '';
|
||||
|
||||
const senderSpan = document.createElement('span');
|
||||
senderSpan.className = 'msg-sender';
|
||||
senderSpan.style.color = senderColor;
|
||||
senderSpan.innerText = `${scopeLabel}[${senderName}]`;
|
||||
|
||||
const contentSpan = document.createElement('span');
|
||||
contentSpan.className = 'msg-content';
|
||||
contentSpan.innerText = ` > ${msg.content}`;
|
||||
|
||||
div.appendChild(senderSpan);
|
||||
div.appendChild(contentSpan);
|
||||
}
|
||||
|
||||
terminalOutput.appendChild(div);
|
||||
terminalOutput.scrollTop = terminalOutput.scrollHeight;
|
||||
}
|
||||
|
||||
terminalInput.addEventListener('keypress', async (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
let content = terminalInput.value.trim();
|
||||
if (!content) return;
|
||||
|
||||
terminalInput.value = '';
|
||||
|
||||
if (content === '/global on') {
|
||||
globalChatEnabled = true;
|
||||
systemStatusBar.innerText = `[SYSTEM] Global chat messages enabled.`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (content === '/global off') {
|
||||
globalChatEnabled = false;
|
||||
systemStatusBar.innerText = `[SYSTEM] Global chat messages disabled.`;
|
||||
return;
|
||||
}
|
||||
|
||||
let scope = 'LOCAL';
|
||||
if (content.startsWith('/global ')) {
|
||||
scope = 'GLOBAL';
|
||||
content = content.replace(/^\/global\s+/, '').trim();
|
||||
if (!content) return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content, scope })
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
myChatHistory.push(Date.now());
|
||||
updatePromptStatus();
|
||||
} else if (res.status === 429) {
|
||||
// Force update if we hit the limit unexpectedly
|
||||
// Add a dummy timestamp to force the limit state if not already there
|
||||
if (myChatHistory.length < 5) {
|
||||
myChatHistory.push(Date.now());
|
||||
}
|
||||
updatePromptStatus();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to send message', err);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -108,6 +398,51 @@ const evtSource = new EventSource("/events");
|
||||
evtSource.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
if (data.type === 'SYSTEM') {
|
||||
systemStatusBar.innerText = `[SYSTEM] ${data.content}`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.type === 'CHAT') {
|
||||
if (data.scope === 'GLOBAL' && !globalChatEnabled) {
|
||||
return;
|
||||
}
|
||||
appendMessage(data);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.chatEnabled) {
|
||||
terminal.classList.remove('hidden');
|
||||
|
||||
// Only initialize state once when chat becomes enabled
|
||||
if (!terminal.dataset.initialized) {
|
||||
initChatState();
|
||||
terminal.dataset.initialized = 'true';
|
||||
}
|
||||
} else {
|
||||
terminal.classList.add('hidden');
|
||||
document.body.classList.remove('chat-active');
|
||||
document.body.classList.remove('chat-collapsed');
|
||||
}
|
||||
|
||||
if (data.mapEnabled) {
|
||||
if (mapContainer) mapContainer.style.display = 'inline';
|
||||
} else {
|
||||
if (mapContainer) mapContainer.style.display = 'none';
|
||||
if (document.getElementById('mapModal').classList.contains('active')) {
|
||||
closeMap();
|
||||
}
|
||||
}
|
||||
|
||||
if (data.id) myId = data.id;
|
||||
|
||||
if (data.peers) {
|
||||
lastPeerData = data.peers;
|
||||
if (mapInitialized && document.getElementById('mapModal').classList.contains('active')) {
|
||||
updateMap(data.peers);
|
||||
}
|
||||
}
|
||||
|
||||
updateParticles(data.count);
|
||||
|
||||
if (countEl.innerText != data.count) {
|
||||
|
||||
+20
-1
@@ -6,9 +6,11 @@
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0,0&icon_names=palette" /> <link rel="stylesheet" href="/themes/tokyo-night.css" id="theme-css">
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="network"></canvas>
|
||||
<canvas id="network" data-visual-limit="{{VISUAL_LIMIT}}"></canvas>
|
||||
<div class="container">
|
||||
<div id="count" class="count" data-initial-count="{{COUNT}}">{{COUNT}}</div>
|
||||
<div class="label">Active Nodes</div>
|
||||
@@ -19,6 +21,14 @@
|
||||
ID: {{ID}}<br>
|
||||
Direct Connections: <span id="direct">{{DIRECT}}</span><br>
|
||||
<span class="debug-link" onclick="openDiagnostics()">diagnostics</span>
|
||||
<span id="map-container" class="{{MAP_CLASS}}"> | <span class="debug-link" id="map-link" onclick="openMap()">map</span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="mapModal" class="modal">
|
||||
<div class="modal-content map-content">
|
||||
<button class="close-btn" onclick="closeMap()">×</button>
|
||||
<div id="map"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -69,6 +79,15 @@
|
||||
<button id="theme-switcher" class="theme-btn" title="Cycle Themes">
|
||||
<span class="material-symbols-outlined">palette</span>
|
||||
</button>
|
||||
<div id="terminal" class="terminal hidden">
|
||||
<button id="terminal-toggle" class="terminal-toggle" title="Toggle Chat">▼</button>
|
||||
<div id="system-status-bar" class="system-status-bar"></div>
|
||||
<div id="terminal-output" class="terminal-output"></div>
|
||||
<div class="terminal-input-line">
|
||||
<span class="prompt">></span>
|
||||
<input type="text" id="terminal-input" maxlength="140" placeholder="Broadcast..." autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
|
||||
+184
-6
@@ -9,18 +9,36 @@ body {
|
||||
background: var(--color-bg-main);
|
||||
color: var(--color-text-default);
|
||||
margin: 0;
|
||||
transition: padding-bottom 0.3s ease;
|
||||
}
|
||||
|
||||
body.chat-active {
|
||||
padding-bottom: 250px;
|
||||
}
|
||||
|
||||
body.chat-collapsed {
|
||||
padding-bottom: 40px;
|
||||
}
|
||||
|
||||
.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: var(--color-count); transition: color 0.2s; visibility: hidden; }
|
||||
.count.loaded { visibility: visible; }
|
||||
.label { font-size: 1.5rem; color: var(--color-text-main-label); margin-top: 1rem; }
|
||||
.footer { margin-top: 2rem; font-size: 0.9rem; color: var(--color-text-footer); }
|
||||
.debug { font-size: 0.8rem; color: var(--color-text-debug); margin-top: 1rem; }
|
||||
.debug-link { color: var(--color-text-debug-link); border-bottom: 1px dotted; cursor: pointer; transition: color 0.2s; }
|
||||
.debug-link:hover { color: var(--color-text-debug-link-hover); }
|
||||
a { color: var(--color-text-secondary); text-decoration: none; border-bottom: 1px dotted var(--color-text-secondary); }
|
||||
.label { font-size: 1.5rem; color: #9ca3af; margin-top: 1rem; }
|
||||
.footer {
|
||||
margin: 2rem auto 0;
|
||||
font-size: 0.9rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
.debug {
|
||||
font-size: 0.8rem;
|
||||
color: #9ca3af;
|
||||
margin: 1rem auto 0;
|
||||
}
|
||||
.debug-link { color: #9ca3af; border-bottom: 1px dotted #9ca3af; cursor: pointer; }
|
||||
.debug-link:hover { color: #e5e7eb; }
|
||||
a { color: #9ca3af; text-decoration: none; border-bottom: 1px dotted #9ca3af; }
|
||||
|
||||
.pulse { animation: pulse 0.5s ease-in-out; }
|
||||
@keyframes pulse {
|
||||
0% { transform: scale(1); }
|
||||
@@ -56,6 +74,23 @@ a { color: var(--color-text-secondary); text-decoration: none; border-bottom: 1p
|
||||
width: 90%;
|
||||
position: relative;
|
||||
}
|
||||
.modal-content.map-content {
|
||||
max-width: 1000px;
|
||||
width: 100%;
|
||||
height: 500px;
|
||||
max-height: 80vh;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
#map {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #222;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-modal-title);
|
||||
@@ -63,6 +98,7 @@ a { color: var(--color-text-secondary); text-decoration: none; border-bottom: 1p
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
position: absolute;
|
||||
top: 1.5rem;
|
||||
@@ -75,6 +111,12 @@ a { color: var(--color-text-secondary); text-decoration: none; border-bottom: 1p
|
||||
transition: color 0.2s;
|
||||
}
|
||||
.close-btn:hover { color: var(--color-modal-close-btn-hover); }
|
||||
color: #9ca3af;
|
||||
font-size: 1.2rem;
|
||||
cursor: pointer;
|
||||
z-index: 1001;
|
||||
}
|
||||
.close-btn:hover { color: #fff; }
|
||||
.stat-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -118,4 +160,140 @@ a { color: var(--color-text-secondary); text-decoration: none; border-bottom: 1p
|
||||
|
||||
.theme-btn:hover .material-symbols-outlined {
|
||||
color: var(--color-theme-toggle-hover);
|
||||
|
||||
.terminal {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 800px;
|
||||
max-width: 100%;
|
||||
height: 250px;
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
border: 1px solid #333;
|
||||
border-bottom: none;
|
||||
border-radius: 8px 8px 0 0;
|
||||
z-index: 100;
|
||||
font-family: "Courier New", Courier, monospace;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 12px;
|
||||
color: #4ade80;
|
||||
font-size: 12px;
|
||||
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.3);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.terminal.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.terminal.collapsed {
|
||||
transform: translateX(-50%) translateY(100%);
|
||||
}
|
||||
|
||||
.terminal-toggle {
|
||||
position: absolute;
|
||||
top: -24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
border: 1px solid #333;
|
||||
border-bottom: none;
|
||||
border-radius: 8px 8px 0 0;
|
||||
color: #4ade80;
|
||||
cursor: pointer;
|
||||
font-family: monospace;
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
z-index: 101;
|
||||
padding: 2px 15px;
|
||||
height: 24px;
|
||||
line-height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.terminal-toggle:hover {
|
||||
color: #fff;
|
||||
background: #222;
|
||||
}
|
||||
|
||||
.system-status-bar {
|
||||
height: 20px;
|
||||
margin-bottom: 5px;
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.terminal-output {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
margin-bottom: 5px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #333 transparent;
|
||||
}
|
||||
|
||||
.terminal-output::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.terminal-output::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.terminal-output::-webkit-scrollbar-thumb {
|
||||
background-color: #333;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.terminal-output::-webkit-scrollbar-thumb:hover {
|
||||
background-color: #444;
|
||||
}
|
||||
|
||||
.terminal-input-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-top: 1px solid #333;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.prompt {
|
||||
margin-right: 10px;
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
#terminal-input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #fff;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.msg-system {
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.msg-sender {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.msg-content {
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ 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");
|
||||
const { DIAGNOSTICS_INTERVAL, ENABLE_CHAT, ENABLE_MAP } = require("./src/config/constants");
|
||||
|
||||
const main = async () => {
|
||||
const identity = generateIdentity();
|
||||
@@ -25,14 +25,27 @@ const main = async () => {
|
||||
direct: swarmManager.getSwarm().connections.size,
|
||||
id: identity.id,
|
||||
diagnostics: diagnostics.getStats(),
|
||||
chatEnabled: ENABLE_CHAT,
|
||||
mapEnabled: ENABLE_MAP,
|
||||
peers: peerManager.getPeersWithIps()
|
||||
});
|
||||
};
|
||||
|
||||
const chatCallback = (msg) => {
|
||||
sseManager.broadcast(msg);
|
||||
};
|
||||
|
||||
const chatSystemFn = (msg) => {
|
||||
sseManager.broadcast(msg);
|
||||
};
|
||||
|
||||
const messageHandler = new MessageHandler(
|
||||
peerManager,
|
||||
diagnostics,
|
||||
(msg, sourceSocket) => relayMessage(msg, sourceSocket, swarmManager.getSwarm(), diagnostics),
|
||||
broadcastUpdate
|
||||
broadcastUpdate,
|
||||
chatCallback,
|
||||
chatSystemFn
|
||||
);
|
||||
|
||||
const swarmManager = new SwarmManager(
|
||||
@@ -41,7 +54,8 @@ const main = async () => {
|
||||
diagnostics,
|
||||
messageHandler,
|
||||
(msg, sourceSocket) => relayMessage(msg, sourceSocket, swarmManager.getSwarm(), diagnostics),
|
||||
broadcastUpdate
|
||||
broadcastUpdate,
|
||||
chatSystemFn
|
||||
);
|
||||
|
||||
await swarmManager.start();
|
||||
|
||||
+36
-24
@@ -1,42 +1,54 @@
|
||||
const crypto = require("crypto");
|
||||
|
||||
const TOPIC_NAME = "hypermind-lklynet-v1";
|
||||
const TOPIC_NAME = process.env.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.
|
||||
*
|
||||
* ----
|
||||
* ricardoboss: added a way to get the best of both worlds: newer nodes will use a harder POW, making them compatible
|
||||
* with others who use a harder POW while still being able to accept "old" POWs from clients
|
||||
*/
|
||||
const POW_PREFIX = "0000";
|
||||
const MY_POW_PREFIX = "00000";
|
||||
const VERIFICATION_POW_PREFIX = "0000";
|
||||
|
||||
const MAX_PEERS = parseInt(process.env.MAX_PEERS) || 1000000;
|
||||
const MAX_MESSAGE_SIZE = 2048;
|
||||
const MAX_RELAY_HOPS = 2;
|
||||
const MAX_CONNECTIONS = 32;
|
||||
const MAX_PEERS = parseInt(process.env.MAX_PEERS) || 50000;
|
||||
const MAX_MESSAGE_SIZE = parseInt(process.env.MAX_MESSAGE_SIZE) || 2048;
|
||||
const MAX_RELAY_HOPS = parseInt(process.env.MAX_RELAY_HOPS) || 2;
|
||||
const MAX_CONNECTIONS = parseInt(process.env.MAX_CONNECTIONS) || 15;
|
||||
|
||||
const HEARTBEAT_INTERVAL = 5000;
|
||||
const CONNECTION_ROTATION_INTERVAL = 30000;
|
||||
const PEER_TIMEOUT = 15000;
|
||||
const HEARTBEAT_INTERVAL = parseInt(process.env.HEARTBEAT_INTERVAL) || 30000;
|
||||
const CONNECTION_ROTATION_INTERVAL = parseInt(process.env.CONNECTION_ROTATION_INTERVAL) || 300000;
|
||||
const PEER_TIMEOUT = parseInt(process.env.PEER_TIMEOUT) || 45000;
|
||||
const BROADCAST_THROTTLE = 1000;
|
||||
const DIAGNOSTICS_INTERVAL = 10000;
|
||||
const PORT = process.env.PORT || 3000;
|
||||
const ENABLE_CHAT = process.env.ENABLE_CHAT === "true";
|
||||
const ENABLE_MAP = process.env.ENABLE_MAP === 'true';
|
||||
const CHAT_RATE_LIMIT = parseInt(process.env.CHAT_RATE_LIMIT) || 5000;
|
||||
const VISUAL_LIMIT = parseInt(process.env.VISUAL_LIMIT) || 500;
|
||||
|
||||
module.exports = {
|
||||
TOPIC_NAME,
|
||||
TOPIC,
|
||||
POW_PREFIX,
|
||||
MAX_PEERS,
|
||||
MAX_MESSAGE_SIZE,
|
||||
MAX_RELAY_HOPS,
|
||||
MAX_CONNECTIONS,
|
||||
HEARTBEAT_INTERVAL,
|
||||
CONNECTION_ROTATION_INTERVAL,
|
||||
PEER_TIMEOUT,
|
||||
BROADCAST_THROTTLE,
|
||||
DIAGNOSTICS_INTERVAL,
|
||||
PORT,
|
||||
TOPIC_NAME,
|
||||
TOPIC,
|
||||
MY_POW_PREFIX,
|
||||
VERIFICATION_POW_PREFIX,
|
||||
MAX_PEERS,
|
||||
MAX_MESSAGE_SIZE,
|
||||
MAX_RELAY_HOPS,
|
||||
MAX_CONNECTIONS,
|
||||
HEARTBEAT_INTERVAL,
|
||||
CONNECTION_ROTATION_INTERVAL,
|
||||
PEER_TIMEOUT,
|
||||
BROADCAST_THROTTLE,
|
||||
DIAGNOSTICS_INTERVAL,
|
||||
PORT,
|
||||
ENABLE_CHAT,
|
||||
ENABLE_MAP,
|
||||
CHAT_RATE_LIMIT,
|
||||
VISUAL_LIMIT,
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const crypto = require("crypto");
|
||||
const { POW_PREFIX } = require("../config/constants");
|
||||
const { MY_POW_PREFIX } = require("../config/constants");
|
||||
|
||||
const generateIdentity = () => {
|
||||
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
|
||||
@@ -11,7 +11,7 @@ const generateIdentity = () => {
|
||||
.createHash("sha256")
|
||||
.update(id + nonce)
|
||||
.digest("hex");
|
||||
if (hash.startsWith(POW_PREFIX)) break;
|
||||
if (hash.startsWith(MY_POW_PREFIX)) break;
|
||||
nonce++;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const crypto = require("crypto");
|
||||
const { POW_PREFIX } = require("../config/constants");
|
||||
const { VERIFICATION_POW_PREFIX } = require("../config/constants");
|
||||
|
||||
const verifyPoW = (id, nonce) => {
|
||||
if (!nonce) return false;
|
||||
@@ -7,7 +7,7 @@ const verifyPoW = (id, nonce) => {
|
||||
.createHash("sha256")
|
||||
.update(id + nonce)
|
||||
.digest("hex");
|
||||
return powHash.startsWith(POW_PREFIX);
|
||||
return powHash.startsWith(VERIFICATION_POW_PREFIX);
|
||||
}
|
||||
|
||||
const signMessage = (message, privateKey) => {
|
||||
|
||||
+221
-103
@@ -1,133 +1,251 @@
|
||||
const { verifyPoW, verifySignature, createPublicKey } = require("../core/security");
|
||||
const { MAX_RELAY_HOPS } = require("../config/constants");
|
||||
const {
|
||||
verifyPoW,
|
||||
verifySignature,
|
||||
createPublicKey,
|
||||
} = require("../core/security");
|
||||
const { MAX_RELAY_HOPS, ENABLE_CHAT, CHAT_RATE_LIMIT } = require("../config/constants");
|
||||
const { BloomFilterManager } = require("../state/bloom");
|
||||
|
||||
class MessageHandler {
|
||||
constructor(peerManager, diagnostics, relayCallback, broadcastCallback) {
|
||||
this.peerManager = peerManager;
|
||||
this.diagnostics = diagnostics;
|
||||
this.relayCallback = relayCallback;
|
||||
this.broadcastCallback = broadcastCallback;
|
||||
this.bloomFilter = new BloomFilterManager();
|
||||
this.bloomFilter.start();
|
||||
constructor(
|
||||
peerManager,
|
||||
diagnostics,
|
||||
relayCallback,
|
||||
broadcastCallback,
|
||||
chatCallback,
|
||||
chatSystemFn
|
||||
) {
|
||||
this.peerManager = peerManager;
|
||||
this.diagnostics = diagnostics;
|
||||
this.relayCallback = relayCallback;
|
||||
this.broadcastCallback = broadcastCallback;
|
||||
this.chatCallback = chatCallback;
|
||||
this.chatSystemFn = chatSystemFn;
|
||||
this.bloomFilter = new BloomFilterManager();
|
||||
this.bloomFilter.start();
|
||||
this.chatRateLimits = new Map();
|
||||
}
|
||||
|
||||
handleMessage(msg, sourceSocket) {
|
||||
if (!validateMessage(msg)) {
|
||||
return;
|
||||
}
|
||||
|
||||
handleMessage(msg, sourceSocket) {
|
||||
if (!validateMessage(msg)) {
|
||||
return;
|
||||
}
|
||||
if (msg.type === "HEARTBEAT") {
|
||||
this.handleHeartbeat(msg, sourceSocket);
|
||||
} else if (msg.type === "LEAVE") {
|
||||
this.handleLeave(msg, sourceSocket);
|
||||
} else if (msg.type === "CHAT") {
|
||||
this.handleChat(msg, sourceSocket);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
// Optimization: Check for duplicates BEFORE verifyPoW (CPU intensive)
|
||||
const stored = this.peerManager.getPeer(id);
|
||||
if (stored && seq <= stored.seq) {
|
||||
this.diagnostics.increment("duplicateSeq");
|
||||
return;
|
||||
}
|
||||
|
||||
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 {
|
||||
// Check if we can accept new peers (only matters for new peers)
|
||||
if (!stored && !this.peerManager.canAcceptPeer(id)) return;
|
||||
|
||||
// Derive public key on-demand from peer ID
|
||||
const 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);
|
||||
|
||||
if (wasNew) {
|
||||
this.diagnostics.increment("newPeersAdded");
|
||||
this.broadcastCallback();
|
||||
}
|
||||
|
||||
// Only relay if we haven't already relayed this message (bloom filter check)
|
||||
if (hops < MAX_RELAY_HOPS && !this.bloomFilter.hasRelayed(id, seq)) {
|
||||
this.bloomFilter.markRelayed(id, seq);
|
||||
this.diagnostics.increment("heartbeatsRelayed");
|
||||
this.relayCallback({ ...msg, hops: hops + 1 }, sourceSocket);
|
||||
}
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
if (!verifyPoW(id, nonce)) {
|
||||
this.diagnostics.increment("invalidPoW");
|
||||
return;
|
||||
}
|
||||
|
||||
handleLeave(msg, sourceSocket) {
|
||||
this.diagnostics.increment("leaveMessages");
|
||||
const { id, hops, sig } = msg;
|
||||
if (!sig) return;
|
||||
|
||||
if (!sig) return;
|
||||
try {
|
||||
// Check if we can accept new peers (only matters for new peers)
|
||||
if (!stored && !this.peerManager.canAcceptPeer(id)) return;
|
||||
|
||||
// Only process leave messages for peers we know about
|
||||
if (!this.peerManager.hasPeer(id)) return;
|
||||
// Derive public key on-demand from peer ID
|
||||
const key = createPublicKey(id);
|
||||
|
||||
// Derive public key on-demand from peer ID
|
||||
const key = createPublicKey(id);
|
||||
if (!verifySignature(`seq:${seq}`, sig, key)) {
|
||||
this.diagnostics.increment("invalidSig");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!verifySignature(`type:LEAVE:${id}`, sig, key)) {
|
||||
if (hops === 0) {
|
||||
sourceSocket.peerId = id;
|
||||
}
|
||||
|
||||
const getIp = (sock) => {
|
||||
if (sock.remoteAddress) return sock.remoteAddress;
|
||||
if (sock.rawStream && sock.rawStream.remoteHost)
|
||||
return sock.rawStream.remoteHost;
|
||||
if (sock.rawStream && sock.rawStream.remoteAddress)
|
||||
return sock.rawStream.remoteAddress;
|
||||
return null;
|
||||
};
|
||||
|
||||
const ip = hops === 0 ? getIp(sourceSocket) : null;
|
||||
const wasNew = this.peerManager.addOrUpdatePeer(id, seq, ip);
|
||||
|
||||
if (wasNew) {
|
||||
this.diagnostics.increment("newPeersAdded");
|
||||
this.broadcastCallback();
|
||||
if (ENABLE_CHAT && this.chatSystemFn && hops === 0) {
|
||||
this.chatSystemFn({
|
||||
type: "SYSTEM",
|
||||
content: `Connection established with Node ...${id.slice(-8)}`,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Only relay if we haven't already relayed this message (bloom filter check)
|
||||
if (hops < MAX_RELAY_HOPS && !this.bloomFilter.hasRelayed(id, seq)) {
|
||||
this.bloomFilter.markRelayed(id, seq);
|
||||
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;
|
||||
|
||||
// Only process leave messages for peers we know about
|
||||
if (!this.peerManager.hasPeer(id)) return;
|
||||
|
||||
// Derive public key on-demand from peer ID
|
||||
const key = createPublicKey(id);
|
||||
|
||||
if (!verifySignature(`type:LEAVE:${id}`, sig, key)) {
|
||||
this.diagnostics.increment("invalidSig");
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.peerManager.hasPeer(id)) {
|
||||
this.peerManager.removePeer(id);
|
||||
this.broadcastCallback();
|
||||
|
||||
if (ENABLE_CHAT && this.chatSystemFn && hops === 0) {
|
||||
this.chatSystemFn({
|
||||
type: "SYSTEM",
|
||||
content: `Node ...${id.slice(-8)} disconnected.`,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
// Use id:leave as key for LEAVE messages
|
||||
if (hops < MAX_RELAY_HOPS && !this.bloomFilter.hasRelayed(id, "leave")) {
|
||||
this.bloomFilter.markRelayed(id, "leave");
|
||||
this.relayCallback({ ...msg, hops: hops + 1 }, sourceSocket);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleChat(msg, sourceSocket) {
|
||||
const { scope, sender, id, sig, hops } = msg;
|
||||
|
||||
// Rate Limiting (apply to all chat messages)
|
||||
const now = Date.now();
|
||||
let rateData = this.chatRateLimits.get(sender);
|
||||
|
||||
if (!rateData || now - rateData.windowStart > 10000) {
|
||||
// Reset window
|
||||
rateData = { count: 0, windowStart: now };
|
||||
}
|
||||
|
||||
if (rateData.count >= 5) {
|
||||
return; // Drop message
|
||||
}
|
||||
|
||||
if (!scope || scope === 'LOCAL') {
|
||||
// Identity Verification: Ensure the sender matches the authenticated socket
|
||||
if (!sourceSocket.peerId || sourceSocket.peerId !== sender) {
|
||||
return;
|
||||
}
|
||||
|
||||
rateData.count++;
|
||||
this.chatRateLimits.set(sender, rateData);
|
||||
|
||||
if (this.chatCallback) {
|
||||
this.chatCallback(msg);
|
||||
}
|
||||
} else if (scope === 'GLOBAL') {
|
||||
if (!sig || !id) return;
|
||||
|
||||
// Check signature
|
||||
const key = createPublicKey(sender);
|
||||
if (!verifySignature(`chat:${id}`, sig, key)) {
|
||||
this.diagnostics.increment("invalidSig");
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.peerManager.hasPeer(id)) {
|
||||
this.peerManager.removePeer(id);
|
||||
this.broadcastCallback();
|
||||
// Deduplication
|
||||
if (this.bloomFilter.hasRelayed(id, "chat")) {
|
||||
return;
|
||||
}
|
||||
this.bloomFilter.markRelayed(id, "chat");
|
||||
|
||||
// Use id:leave as key for LEAVE messages
|
||||
if (hops < MAX_RELAY_HOPS && !this.bloomFilter.hasRelayed(id, "leave")) {
|
||||
this.bloomFilter.markRelayed(id, "leave");
|
||||
this.relayCallback({ ...msg, hops: hops + 1 }, sourceSocket);
|
||||
}
|
||||
rateData.count++;
|
||||
this.chatRateLimits.set(sender, rateData);
|
||||
|
||||
if (this.chatCallback) {
|
||||
this.chatCallback(msg);
|
||||
}
|
||||
|
||||
// Relay
|
||||
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;
|
||||
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;
|
||||
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 === "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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
if (msg.type === "CHAT") {
|
||||
const allowedFields = ['type', 'sender', 'content', 'timestamp', 'scope', 'id', 'sig', 'hops'];
|
||||
const fields = Object.keys(msg);
|
||||
return fields.every(f => allowedFields.includes(f)) &&
|
||||
msg.sender &&
|
||||
msg.content && typeof msg.content === 'string' && msg.content.length <= 140 &&
|
||||
typeof msg.timestamp === 'number';
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
module.exports = { MessageHandler, validateMessage };
|
||||
|
||||
+27
-10
@@ -1,16 +1,33 @@
|
||||
const relayMessage = (msg, sourceSocket, swarm, diagnostics) => {
|
||||
const data = JSON.stringify(msg) + "\n";
|
||||
const relayCount = swarm.connections.size - 1;
|
||||
const data = JSON.stringify(msg) + "\n";
|
||||
|
||||
if (diagnostics) {
|
||||
diagnostics.increment("bytesRelayed", data.length * relayCount);
|
||||
}
|
||||
// Gossip Subsampling:
|
||||
// Instead of flooding everyone (which causes massive bandwidth usage with 50 connections),
|
||||
// we relay to a random subset of peers (e.g., 6).
|
||||
// This maintains "Epidemic" reach (O(log N)) while capping bandwidth.
|
||||
|
||||
for (const socket of swarm.connections) {
|
||||
if (socket !== sourceSocket) {
|
||||
socket.write(data);
|
||||
}
|
||||
const TARGET_GOSSIP_COUNT = 6;
|
||||
const allSockets = Array.from(swarm.connections);
|
||||
const eligible = allSockets.filter((s) => s !== sourceSocket);
|
||||
|
||||
let targets = eligible;
|
||||
|
||||
if (eligible.length > TARGET_GOSSIP_COUNT) {
|
||||
// Fisher-Yates shuffle (partial) to pick random peers
|
||||
for (let i = eligible.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[eligible[i], eligible[j]] = [eligible[j], eligible[i]];
|
||||
}
|
||||
}
|
||||
targets = eligible.slice(0, TARGET_GOSSIP_COUNT);
|
||||
}
|
||||
|
||||
if (diagnostics) {
|
||||
diagnostics.increment("bytesRelayed", data.length * targets.length);
|
||||
}
|
||||
|
||||
for (const socket of targets) {
|
||||
socket.write(data);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { relayMessage };
|
||||
|
||||
+18
-2
@@ -1,15 +1,16 @@
|
||||
const Hyperswarm = require("hyperswarm");
|
||||
const { signMessage } = require("../core/security");
|
||||
const { TOPIC, TOPIC_NAME, HEARTBEAT_INTERVAL, MAX_CONNECTIONS, CONNECTION_ROTATION_INTERVAL } = require("../config/constants");
|
||||
const { TOPIC, TOPIC_NAME, HEARTBEAT_INTERVAL, MAX_CONNECTIONS, CONNECTION_ROTATION_INTERVAL, ENABLE_CHAT } = require("../config/constants");
|
||||
|
||||
class SwarmManager {
|
||||
constructor(identity, peerManager, diagnostics, messageHandler, relayFn, broadcastFn) {
|
||||
constructor(identity, peerManager, diagnostics, messageHandler, relayFn, broadcastFn, chatSystemFn) {
|
||||
this.identity = identity;
|
||||
this.peerManager = peerManager;
|
||||
this.diagnostics = diagnostics;
|
||||
this.messageHandler = messageHandler;
|
||||
this.relayFn = relayFn;
|
||||
this.broadcastFn = broadcastFn;
|
||||
this.chatSystemFn = chatSystemFn;
|
||||
|
||||
this.swarm = new Hyperswarm();
|
||||
this.heartbeatInterval = null;
|
||||
@@ -109,6 +110,13 @@ class SwarmManager {
|
||||
}
|
||||
|
||||
if (oldest) {
|
||||
if (ENABLE_CHAT && this.chatSystemFn && oldest.peerId) {
|
||||
this.chatSystemFn({
|
||||
type: "SYSTEM",
|
||||
content: `Connection with Node ...${oldest.peerId.slice(-8)} severed (Rotation).`,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
oldest.destroy();
|
||||
}
|
||||
}, CONNECTION_ROTATION_INTERVAL);
|
||||
@@ -143,6 +151,14 @@ class SwarmManager {
|
||||
getSwarm() {
|
||||
return this.swarm;
|
||||
}
|
||||
|
||||
broadcastChat(msg) {
|
||||
if (!ENABLE_CHAT) return;
|
||||
const msgStr = JSON.stringify(msg) + "\n";
|
||||
for (const socket of this.swarm.connections) {
|
||||
socket.write(msgStr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { SwarmManager };
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
* Prevents re-relaying messages we've already seen
|
||||
*/
|
||||
class BloomFilter {
|
||||
constructor(size = 10000, hashCount = 3) {
|
||||
constructor(size = 200000, hashCount = 3) {
|
||||
this.size = size;
|
||||
this.hashCount = hashCount;
|
||||
this.bits = new Uint8Array(Math.ceil(size / 8));
|
||||
|
||||
+17
-1
@@ -9,7 +9,7 @@ class PeerManager {
|
||||
this.mySeq = 0;
|
||||
}
|
||||
|
||||
addOrUpdatePeer(id, seq) {
|
||||
addOrUpdatePeer(id, seq, ip = null) {
|
||||
const stored = this.seenPeers.get(id);
|
||||
const wasNew = !stored;
|
||||
|
||||
@@ -19,6 +19,7 @@ class PeerManager {
|
||||
this.seenPeers.set(id, {
|
||||
seq,
|
||||
lastSeen: Date.now(),
|
||||
ip: ip || (stored ? stored.ip : null),
|
||||
});
|
||||
|
||||
return wasNew;
|
||||
@@ -49,6 +50,11 @@ class PeerManager {
|
||||
if (now - data.lastSeen > PEER_TIMEOUT) {
|
||||
this.seenPeers.delete(id);
|
||||
removed++;
|
||||
} else {
|
||||
// Optimization: Since LRUCache maintains insertion order (updated on access),
|
||||
// the Map is sorted by lastSeen (ascending).
|
||||
// If we find a non-stale peer, all subsequent peers are also non-stale.
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +76,16 @@ class PeerManager {
|
||||
getSeq() {
|
||||
return this.mySeq;
|
||||
}
|
||||
|
||||
getPeersWithIps() {
|
||||
const peers = [];
|
||||
for (const [id, data] of this.seenPeers.entries()) {
|
||||
if (data.ip) {
|
||||
peers.push({ id, ip: data.ip });
|
||||
}
|
||||
}
|
||||
return peers;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { PeerManager };
|
||||
|
||||
+64
-2
@@ -1,6 +1,9 @@
|
||||
const express = require("express");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const crypto = require("crypto");
|
||||
const { signMessage } = require("../core/security");
|
||||
const { ENABLE_CHAT, ENABLE_MAP, CHAT_RATE_LIMIT, VISUAL_LIMIT } = require("../config/constants");
|
||||
|
||||
const HTML_TEMPLATE = fs.readFileSync(
|
||||
path.join(__dirname, "../../public/index.html"),
|
||||
@@ -8,14 +11,18 @@ const HTML_TEMPLATE = fs.readFileSync(
|
||||
);
|
||||
|
||||
const setupRoutes = (app, identity, peerManager, swarm, sseManager, diagnostics) => {
|
||||
app.use(express.json());
|
||||
|
||||
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);
|
||||
.replace(/\{\{ID\}\}/g, "..." + identity.id.slice(-8))
|
||||
.replace(/\{\{DIRECT\}\}/g, directPeers)
|
||||
.replace(/\{\{MAP_CLASS\}\}/g, ENABLE_MAP ? '' : 'hidden')
|
||||
.replace(/\{\{VISUAL_LIMIT\}\}/g, VISUAL_LIMIT);
|
||||
|
||||
res.send(html);
|
||||
});
|
||||
@@ -34,6 +41,8 @@ const setupRoutes = (app, identity, peerManager, swarm, sseManager, diagnostics)
|
||||
direct: swarm.getSwarm().connections.size,
|
||||
id: identity.id,
|
||||
diagnostics: diagnostics.getStats(),
|
||||
chatEnabled: ENABLE_CHAT,
|
||||
peers: peerManager.getPeersWithIps()
|
||||
});
|
||||
res.write(`data: ${data}\n\n`);
|
||||
|
||||
@@ -49,9 +58,62 @@ const setupRoutes = (app, identity, peerManager, swarm, sseManager, diagnostics)
|
||||
direct: swarm.getSwarm().connections.size,
|
||||
id: identity.id,
|
||||
diagnostics: diagnostics.getStats(),
|
||||
chatEnabled: ENABLE_CHAT,
|
||||
peers: peerManager.getPeersWithIps()
|
||||
});
|
||||
});
|
||||
|
||||
let chatHistory = []; // Store timestamps of recent messages
|
||||
|
||||
app.post("/api/chat", (req, res) => {
|
||||
if (!ENABLE_CHAT) {
|
||||
return res.status(403).json({ error: "Chat disabled" });
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
// Clean up old timestamps (older than CHAT_RATE_LIMIT)
|
||||
chatHistory = chatHistory.filter(time => now - time < CHAT_RATE_LIMIT);
|
||||
|
||||
if (chatHistory.length >= 5) {
|
||||
return res.status(429).json({ error: `Rate limit exceeded: Max 5 messages per ${CHAT_RATE_LIMIT / 1000} seconds` });
|
||||
}
|
||||
|
||||
chatHistory.push(now);
|
||||
|
||||
const { content, scope = 'LOCAL' } = req.body;
|
||||
if (!content || typeof content !== 'string' || content.length > 140) {
|
||||
return res.status(400).json({ error: "Invalid content" });
|
||||
}
|
||||
|
||||
if (scope !== 'LOCAL' && scope !== 'GLOBAL') {
|
||||
return res.status(400).json({ error: "Invalid scope" });
|
||||
}
|
||||
|
||||
const timestamp = Date.now();
|
||||
// Create a unique ID that depends on content to prevent replay/duplicates
|
||||
const idBase = identity.id + content + timestamp;
|
||||
const msgId = crypto.createHash('sha256').update(idBase).digest('hex');
|
||||
|
||||
const msg = {
|
||||
type: "CHAT",
|
||||
id: msgId,
|
||||
sender: identity.id,
|
||||
content: content,
|
||||
timestamp: timestamp,
|
||||
scope: scope,
|
||||
hops: 0
|
||||
};
|
||||
|
||||
if (scope === 'GLOBAL') {
|
||||
msg.sig = signMessage(`chat:${msgId}`, identity.privateKey);
|
||||
}
|
||||
|
||||
swarm.broadcastChat(msg);
|
||||
sseManager.broadcast(msg);
|
||||
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
app.use(express.static(path.join(__dirname, "../../public")));
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,10 @@ class SSEManager {
|
||||
if (now - this.lastBroadcast < BROADCAST_THROTTLE) return;
|
||||
this.lastBroadcast = now;
|
||||
|
||||
this.broadcast(data);
|
||||
}
|
||||
|
||||
broadcast(data) {
|
||||
const message = JSON.stringify(data);
|
||||
for (const client of this.clients) {
|
||||
client.write(`data: ${message}\n\n`);
|
||||
|
||||
Reference in New Issue
Block a user