Front-end with live stats

fantomcoin_support
Matt 2014-05-13 18:41:28 -06:00
parent b1bb3108ff
commit c027c2b8f9
12 changed files with 589 additions and 30 deletions

View File

@ -3,11 +3,15 @@ node-cryptonote-pool
Mining pool for CryptoNote based coins such as Bytecoin and Monero
#### TODO
#### Features
* Flooding detection
* IP banning for low-diff shares (prevent CPU overload with low-diff share attacks)
* Collecting stats and exposing via ajax/rest API
* Variable difficulty / share limiter
* IP banning to prevent low-diff share attacks
* Socket flooding detection
* Payment processing
* Detailed logging
* Clustering for vertical scaling
* Live stats API
* Currency network/block difficulty
* Current block height
* Network hashrate
@ -15,11 +19,11 @@ Mining pool for CryptoNote based coins such as Bytecoin and Monero
* Each miners' hashrate
* Blocks found (pending, confirmed, and orphaned)
* Total paid out
* Worker login validation (make sure miners are uing proper wallet addresses for mining)
#### TODO
* Worker login validation (make sure miners are using proper wallet addresses for mining)
* Light-weight front-end using API to display pool data
* Sending payments
* Add pool fee percent to config
* Use redis data and wallet API to send out payments
Usage
@ -60,6 +64,12 @@ Explanation for each field:
/* Port that simpleminer is pointed to. */
"poolPort": 5555,
/* Host that simpleminer is pointed to. */
"poolHost": "example.com",
/* Contact email address. */
"email": "support@cryppit.com",
/* Address where block rewards go, and miner payments come from. */
"poolAddress": "4AsBy39rpUMTmgTUARGq2bFQWhDhdQNekK5v4uaLU699NPAnx9CubEJ82AkvD5ScoAZNYRwBxybayainhyThHAZWCdKmPYn"
@ -82,8 +92,7 @@ Explanation for each field:
"enabled": true,
"time": 600, //How many seconds to ban worker for
"invalidPercent": 50, //What percent of invalid shares triggers ban
"checkThreshold": 30, //Perform check when this many shares have been submitted
"purgeInterval": 300 //Every this many seconds clear out the list of old bans
"checkThreshold": 30 //Perform check when this many shares have been submitted
},
/* Set to "auto" by default which will spawn one process/fork/worker for each CPU
@ -118,6 +127,14 @@ Explanation for each field:
/* Poll RPC daemons for new blocks every this many milliseconds. */
"blockRefreshInterval": 1000,
/* REST API used for front-end website. */
"api": {
"enabled": true,
"hashrateWindow": 15,
"updateInterval": 0.5,
"port": 8117
},
/* Coin daemon connection details. */
"daemon": {
"host": "127.0.0.1",

0
api.js
View File

View File

@ -6,15 +6,18 @@
"transferFee": 1000000,
"poolPort": 5555,
"poolHost": "cryppit.com",
"poolAddress": "KdvRKTrXzyC8eW76eCzcWNWdYBnpCs6bAZeheF2LdJj5bBLry4zs8MoV6uqMLBYPZPSJPU3RtZMjHgWn5qWwvYdTHKVxLKw",
"email": "support@cryppit.com",
"difficulty": 10,
"poolAddress": "4AsBy39rpUMTmgTUARGq2bFQWhDhdQNekK5v4uaLU699NPAnx9CubEJ82AkvD5ScoAZNYRwBxybayainhyThHAZWCdKmPYn",
"difficulty": 200,
"varDiff": {
"minDiff": 2,
"maxDiff": 512,
"targetTime": 15,
"retargetTime": 30,
"targetTime": 60,
"retargetTime": 10,
"variancePercent": 30
},
@ -22,8 +25,7 @@
"enabled": true,
"time": 600,
"invalidPercent": 50,
"checkThreshold": 30,
"purgeInterval": 300
"checkThreshold": 30
},
"clusterForks": "auto",
@ -45,6 +47,13 @@
"blockRefreshInterval": 1000,
"api": {
"enabled": true,
"hashrateWindow": 300,
"updateInterval": 3.5,
"port": 8117
},
"daemon": {
"host": "127.0.0.1",
"port": 18081

217
index.html 100644
View File

@ -0,0 +1,217 @@
<!DOCTYPE html>
<html>
<head lang="en">
<meta charset="UTF-8">
<title>Cryptonote Mining Pool</title>
<script src="//ajax.googleapis.com/ajax/libs/jquery/1.11.1/jquery.min.js"></script>
<link href="//netdna.bootstrapcdn.com/bootstrap/3.1.1/css/bootstrap.min.css" rel="stylesheet">
<script src="//netdna.bootstrapcdn.com/bootstrap/3.1.1/js/bootstrap.min.js"></script>
<link href="//netdna.bootstrapcdn.com/font-awesome/4.0.3/css/font-awesome.min.css" rel="stylesheet">
<style>
body {
padding-top: 90px;
}
.container{
font-size: 1.2em;
}
.page{
display: none;
}
.stats {
display: inline-block;
width: 40%;
margin-bottom: 40px;
}
.stats:last-child{
width: auto;
}
.stats > div{
padding: 5px 0;
}
.stats > div > span{
font-weight: bold;
}
#yourStatsInput{
width: 550px;
}
#yourHashrateHolder, #yourAddressDisplay{
display: none;
}
#addressError{
color: red;
}
</style>
</head>
<body>
<script>
var api = 'http://cryppit.com:8117';
$(function(){
$.get(api + '/stats', function(data){
renderStats(data);
});
var source = new EventSource(api + '/live_stats');
source.addEventListener('message', function(e){
var data = JSON.parse(e.data);
renderStats(data);
});
window.onhashchange = function(){
pageRouter();
};
pageRouter();
var addressEventSource;
$('#lookUp').click(function(){
$(this).text('Searching...');
var address = $('#yourStatsInput').val().trim();
if (!address) return;
if (addressEventSource) addressEventSource.close();
addressEventSource = new EventSource(api + '/stats_address?address=' + address);
addressEventSource.addEventListener('message', function(e){
$('#lookUp').text('Lookup');
var data = JSON.parse(e.data);
if (data.stats){
$('#addressError').hide();
$('#yourHashrateHolder').show().children(":first").text(data.stats + '/sec');
$('#yourAddressDisplay').show().children(":first").text(address);
return;
}
$('#yourHashrateHolder').hide();
$('#yourAddressDisplay').hide();
$('#addressError').text(data.error).show();
});
addressEventSource.addEventListener('open', function(e){
$('#lookUp').text('Lookup');
});
addressEventSource.addEventListener('error', function(e){
$('#lookUp').text('Lookup');
$('#addressError').text('Connection error').show();
});
addressEventSource.addEventListener('close', function(e){
$('#lookUp').text('Lookup');
$('#addressError').text('Connection closed').show();
})
});
});
function getReadableHashRateString(hashrate){
var i = 0;
var byteUnits = [' H', ' KH', ' MH', ' GH', ' TH', ' PH' ];
while (hashrate > 1024){
hashrate = hashrate / 1024;
i++;
}
return hashrate.toFixed(2) + byteUnits[i];
}
function renderStats(stats){
$('#networkHashrate').text(getReadableHashRateString(stats.network.difficulty / 60) + '/sec');
$('#networkDifficulty').text(stats.network.difficulty);
$('#blockchainHeight').text(stats.network.height);
$('#poolHashrate').text(stats.pool.hashrate + '/sec');
$('#poolMiners').text(stats.pool.miners);
$('#poolFee').text(stats.config.fee + '%');
$('#simpleminer_code').text('simpleminer --pool-addr=' + stats.config.poolHost + ':' + stats.config.poolPort + ' --login=address --pass x');
$('#emailLink').attr('href', 'mailto:' + stats.config.email).text(stats.config.email);
}
function pageRouter(){
$('.page').hide();
$('.hot_link').parent().removeClass('active');
var $link = $('a.hot_link[href="' + (window.location.hash || '#') + '"]');
$link.parent().addClass('active');
var page = $link.data('page');
$('#' + page).show();
}
</script>
<div class="navbar navbar-inverse navbar-fixed-top" role="navigation">
<div class="container">
<div class="navbar-header">
<button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".navbar-collapse">
<span class="sr-only">Toggle navigation</span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
<a class="navbar-brand">CryptoNote Mining Pool</a>
</div>
<div class="collapse navbar-collapse">
<ul class="nav navbar-nav">
<li class="active"><a class="hot_link" data-page="page_home" href="#"><i class="fa fa-home"></i> Home</a></li>
<li><a class="hot_link" data-page="page_getting_started" href="#getting_started"><i class="fa fa-rocket"></i> Getting Started</a></li>
<li><a class="hot_link" data-page="page_contact" href="#contact"><i class="fa fa-pencil"></i> Contact</a></li>
</ul>
</div>
</div>
</div>
<div class="container">
<div class="page" id="page_home">
<div class="stats">
<h3>Network</h3>
<div>Hash Rate: <span id="networkHashrate"></span></div>
<div>Difficulty: <span id="networkDifficulty"></span></div>
<div>Blockchain Height: <span id="blockchainHeight"></span></div>
</div>
<div class="stats">
<h3>Pool</h3>
<div>Hash Rate: <span id="poolHashrate"></span></div>
<div>Connected Miners: <span id="poolMiners"></span></div>
<div>Mining Fee: <span id="poolFee"></span></div>
</div>
<div class="stats">
<h3>Your Stats</h3>
<div>Address: <input id="yourStatsInput" type="text"> <button id="lookUp">Lookup</button></div>
<div id="addressError"></div>
<div id="yourAddressDisplay">Address: <span></span></div>
<div id="yourHashrateHolder">Hash Rate: <span></span></div>
</div>
</div>
<div class="page" id="page_getting_started">
<h1>Getting Started</h1>
<br>
<ol>
<li><a target="_blank" href="http://bit.ly/monerostarterpack">Download simpleminer and simplewallet</a> for your system<br><br></li>
<li>Run simplewallet to generate your public address<br><br></li>
<li>Run simpleminer pointed to our pool with your address:
<br><br>
<code id="simpleminer_code"></code>
</li>
</ol>
</div>
<div class="page" id="page_contact">
<h1>Contact</h1>
<p>Email pool support at <a id="emailLink" href=""></a></p>
</div>
</div>
</body>
</html>

27
init.js
View File

@ -12,16 +12,16 @@ var redis = require('redis');
if (cluster.isWorker){
switch(process.env.workerType){
case 'pool':
require('./pool.js');
require('./lib/pool.js');
break;
case 'paymentProcessor':
require('./paymentProcessor.js');
require('./lib/paymentProcessor.js');
break;
case 'api':
require('./api.js');
require('./lib/api.js');
break;
case 'cli':
require('./cli.js');
require('./lib/cli.js');
break
}
return;
@ -29,7 +29,7 @@ if (cluster.isWorker){
var config = JSON.parse(fs.readFileSync('config.json'));
var logger = require('./logUtil.js')({
var logger = require('./lib/logUtil.js')({
logLevel: config.logLevel,
logColors: config.logColors
});
@ -111,7 +111,12 @@ function spawnPoolWorkers(){
}, 2000);
}).on('message', function(msg){
switch(msg.type){
case 'none':
case 'banIP':
Object.keys(cluster.workers).forEach(function(id) {
if (cluster.workers[id].type === 'pool'){
cluster.workers[id].send({type: 'banIP', ip: msg.ip});
}
});
break;
}
});
@ -144,7 +149,17 @@ function spawnPaymentProcessor(){
}
function spawnApi(){
if (!config.api || !config.api.enabled) return;
var worker = cluster.fork({
workerType: 'api'
});
worker.on('exit', function(code, signal){
logger.error(logSystem, logSubsystem, 'API', 'API died, spawning replacement...');
setTimeout(function(){
spawnApi();
}, 2000);
});
}
function spawnCli(){

236
lib/api.js 100644
View File

@ -0,0 +1,236 @@
var fs = require('fs');
var http = require('http');
var url = require("url");
var async = require('async');
var redis = require('redis');
var config = JSON.parse(fs.readFileSync('config.json'));
var logger = require('./logUtil.js')({
logLevel: config.logLevel,
logColors: config.logColors
});
function log(severity, message){
logger[severity]('API', null, null, message);
}
var apiInterfaces = require('./apiInterfaces.js')(config.daemon, config.wallet);
var redisClient = redis.createClient(config.redis.port, config.redis.host);
var redisCommands = [
['zremrangebyscore', config.coin + ':hashrate', '-inf', ''],
['zrangebyscore', config.coin + ':hashrate', '', '+inf'],
['hgetall', config.coin + ':stats'],
['smembers', config.coin + ':blocksPending'],
['smembers', config.coin + ':blocksUnlocked'],
['smembers', config.coin + ':blocksOrphaned']
];
var currentStats = "";
var minerStats = {};
var liveConnections = {};
var addressConnections = {};
function collectStats(){
var windowTime = (((Date.now() / 1000) - config.api.hashrateWindow) | 0).toString();
redisCommands[0][3] = '(' + windowTime;
redisCommands[1][2] = windowTime;
async.parallel({
pool: function(callback){
redisClient.multi(redisCommands).exec(function(error, replies){
if (error){
log('error', 'Error getting redis data ' + JSON.stringify(error));
callback(true);
return;
}
var data = {
stats: replies[2],
blocks: {
pending: replies[3],
unlocked: replies[4],
orphaned: replies[5]
}
};
var hashrates = replies[1];
minerStats = {};
for (var i = 0; i < hashrates.length; i++){
var hashParts = hashrates[i].split(':');
minerStats[hashParts[1]] = (minerStats[hashParts[1]] || 0) + parseInt(hashParts[0]);
}
var totalShares = 0;
for (var miner in minerStats){
var shares = minerStats[miner];
totalShares += shares;
minerStats[miner] = getReadableHashRateString(shares / config.api.hashrateWindow);
}
data.miners = Object.keys(minerStats).length;
data.hashrate = getReadableHashRateString(totalShares / config.api.hashrateWindow);
callback(null, data);
});
},
network: function(callback){
apiInterfaces.rpcDaemon('getlastblockheader', {}, function(error, reply){
if (error){
log('error', 'Error getting daemon data ' + JSON.stringify(error));
callback(true);
return;
}
var blockHeader = reply.block_header;
callback(null, {
difficulty: blockHeader.difficulty,
height: blockHeader.height
});
});
},
config: function(callback){
callback(null, {
poolPort: config.poolPort,
poolHost: config.poolHost,
hashrateWindow: config.api.hashrateWindow,
fee: config.payments.poolFee,
email: config.email
});
}
}, function(error, results){
if (!error){
currentStats = JSON.stringify(results);
broadcastLiveStats();
}
setTimeout(collectStats, config.api.updateInterval * 1000);
});
}
function getReadableHashRateString(hashrate){
var i = 0;
var byteUnits = [' H', ' KH', ' MH', ' GH', ' TH', ' PH' ];
while (hashrate > 1024){
hashrate = hashrate / 1024;
i++;
}
return hashrate.toFixed(2) + byteUnits[i];
}
function broadcastLiveStats(){
var statData = 'data: ' + currentStats + '\n\n';
for (var uid in liveConnections){
var res = liveConnections[uid];
res.write(statData);
}
for (var address in addressConnections){
var res = addressConnections[address];
var stats = minerStats[address];
if (!stats) res.end();
else res.write('data: ' + JSON.stringify({stats: stats}) + '\n\n');
}
}
collectStats();
var server = http.createServer(function(request, response){
var origin = (request.headers.origin || "*");
if (request.method.toUpperCase() === "OPTIONS"){
response.writeHead("204", "No Content", {
"access-control-allow-origin": origin,
"access-control-allow-methods": "GET, POST, PUT, DELETE, OPTIONS",
"access-control-allow-headers": "content-type, accept",
"access-control-max-age": 10, // Seconds.
"content-length": 0
});
return(response.end());
}
var urlParts = url.parse(request.url, true);
switch(urlParts.pathname){
case '/stats':
var reply = currentStats;
response.writeHead("200", {
'Access-Control-Allow-Origin': origin,
'Content-Type': 'application/json',
'Content-Length': reply.length
});
response.end(reply);
break;
case '/live_stats':
response.writeHead(200, {
'Access-Control-Allow-Origin': origin,
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
});
response.write('\n');
var uid = Math.random().toString();
liveConnections[uid] = response;
response.on("close", function() {
delete liveConnections[uid];
});
break;
case '/stats_address':
response.writeHead(200, {
'Access-Control-Allow-Origin': origin,
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
});
response.write('\n');
var address = urlParts.query.address;
var stats = minerStats[address];
if (!stats){
var error = JSON.stringify({error: 'not found'});
var statData = 'data: ' + error + '\n\n';
response.end(statData);
return;
}
response.write('data: ' + JSON.stringify({stats:stats}) + '\n\n');
addressConnections[address] = response;
response.on("close", function() {
delete addressConnections[address];
});
break;
default:
response.writeHead(404, {
'Access-Control-Allow-Origin': origin
});
response.end('Invalid API call');
break;
}
});
server.listen(config.api.port, function(){
log('debug', 'API listening on port ' + config.api.port);
});

View File

View File

@ -16,7 +16,6 @@ var logger = require('./logUtil.js')({
var logSubSystem = 'Thread ' + (parseInt(process.env.forkId) + 1);
var instanceId = crypto.randomBytes(4);
function log(severity, component, message){
logger[severity]('Pool', logSubSystem, component, message);
@ -33,12 +32,16 @@ var cryptoNight = multiHashing['cryptonight'];
var diff1 = bignum('FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF', 16);
var instanceId = crypto.randomBytes(4);
var validBlockTemplates = [];
var currentBlockTemplate;
var connectedMiners = {};
/* Every 10 seconds clear out timed-out miners */
var bannedIPs = {};
/* Every 10 seconds clear out timed-out miners and old bans */
setInterval(function(){
var now = Date.now();
var timeout = config.minerTimeout * 1000;
@ -49,8 +52,44 @@ setInterval(function(){
delete connectedMiners[minerId];
}
}
}, 10000);
if (config.banning && config.banning.enabled){
for (ip in bannedIPs){
var banTime = bannedIPs[ip];
if (now - banTime > config.banning.time * 1000) {
delete bannedIPs[ip];
log('debug', 'Ban Hammer', 'Ban dropped for ' + ip);
}
}
}
}, 30000);
process.on('message', function(message) {
switch (message.type) {
case 'banIP':
bannedIPs[message.ip] = Date.now();
break;
}
});
function IsBannedIp(ip){
if (!config.banning || !config.banning.enabled || !bannedIPs[ip]) return false;
var bannedTime = bannedIPs[ip];
var bannedTimeAgo = Date.now() - bannedTime;
var timeLeft = config.banning.time * 1000 - bannedTimeAgo;
if (timeLeft > 0){
return true;
}
else {
delete bannedIPs[ip];
log('debug', 'Ban Hammer', 'Ban dropped for ' + ip);
return false;
}
}
function BlockTemplate(template){
@ -95,6 +134,7 @@ function jobRefresh(callback){
}
function processBlockTemplate(template){
if (currentBlockTemplate)
@ -150,6 +190,9 @@ function Miner(id, login, pass, ip){
this.heartbeat();
this.difficulty = config.difficulty;
this.validJobs = [];
this.validShares = 0;
this.invalidShares = 0;
}
Miner.prototype = {
heartbeat: function(){
@ -201,6 +244,22 @@ Miner.prototype = {
};
},
checkBan: function(validShare){
if (!config.banning || !config.banning.enabled) return;
validShare ? this.validShares++ : this.invalidShares++;
if (this.validShares + this.invalidShares >= config.banning.checkThreshold){
if (this.invalidShares / this.validShares >= config.banning.invalidPercent / 100){
log('warn', 'Ban Hammer', 'Banned ' + this.login + '@' + this.ip);
bannedIPs[this.ip] = Date.now();
delete connectedMiners[this.id];
process.send({type: 'banIP', ip: this.ip});
}
else{
this.invalidShares = 0;
this.validShares = 0;
}
}
},
retarget: function(){
var options = config.varDiff;
@ -352,14 +411,14 @@ function handleMinerMethod(id, method, params, req, res){
sendReply('missing login');
return;
}
if (!params.pass){
sendReply('missing pass');
return;
}
if (!utils.isValidAddress(params.login)){
sendReply('invalid address used for login');
return;
}
if (IsBannedIp(req.connection.remoteAddress)){
sendReply('your IP is banned');
return;
}
var minerId = utils.uid();
miner = new Miner(minerId, params.login, params.pass, req.connection.remoteAddress);
connectedMiners[minerId] = miner;
@ -397,7 +456,6 @@ function handleMinerMethod(id, method, params, req, res){
return;
}
miner.heartbeat();
miner.retarget();
var job = miner.validJobs.filter(function(job){
return job.id === params.job_id;
@ -418,10 +476,12 @@ function handleMinerMethod(id, method, params, req, res){
}
var shareAccepted = processShare(miner, job, blockTemplate, params.nonce, params.result);
miner.checkBan(shareAccepted);
if (!shareAccepted){
sendReply('Low difficulty share');
return;
}
miner.retarget();
sendReply(null, {status: 'OK'});
break;
default:
@ -438,6 +498,11 @@ var getworkServer = http.createServer(function(req, res){
req.setEncoding('utf8');
req.on('data', function(chunk){
data += chunk;
if (Buffer.byteLength(data, 'utf8') > 10240){ //10KB
data = null;
log.warn('Server', 'Socket flooding detected and prevented from ' + req.connection.remoteAddress);
req.connection.destroy();
}
});
req.on('end', function(){
var jsonData;