diff --git a/README.md b/README.md index ef501e9..d9a992f 100644 --- a/README.md +++ b/README.md @@ -53,9 +53,12 @@ BTCEXP_BITCOIND_PORT = 8332 BTCEXP_BITCOIND_USER = username BTCEXP_BITCOIND_PASS = password BTCEXP_IPSTACK_KEY = 0000aaaafffffgggggg -BTCEXP_COOKIEPASSWORD = 0x000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f +BTCEXP_COOKIE_SECRET = 0x000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f ``` +You may enable password protection by setting `BTCEXP_LOGIN=`. +Authenticating is done with http basic auth, using the selected password and an empty (or any) username. + ## Run via Docker 1. `docker build -t btc-rpc-explorer .` diff --git a/app.js b/app.js index fa1a3f5..56192aa 100755 --- a/app.js +++ b/app.js @@ -11,6 +11,7 @@ var logger = require('morgan'); var cookieParser = require('cookie-parser'); var bodyParser = require('body-parser'); var session = require("express-session"); +var csurf = require("csurf"); var config = require("./app/config.js"); var simpleGit = require('simple-git'); var utils = require("./app/utils.js"); @@ -27,6 +28,7 @@ var fs = require('fs'); var electrumApi = require("./app/api/electrumApi.js"); var Influx = require("influx"); var coreApi = require("./app/api/coreApi.js"); +var auth = require('./app/auth.js'); var crawlerBotUserAgentStrings = [ "Googlebot", "Bingbot", "Slurp", "DuckDuckBot", "Baiduspider", "YandexBot", "Sogou", "Exabot", "facebot", "ia_archiver" ]; @@ -46,6 +48,12 @@ app.engine('pug', (path, options, fn) => { app.set('view engine', 'pug'); +// basic http authentication +if (process.env.BTCEXP_LOGIN) { + app.disable('x-powered-by'); + app.use(auth(process.env.BTCEXP_LOGIN)); +} + // uncomment after placing your favicon in /public //app.use(favicon(__dirname + '/public/favicon.ico')); app.use(logger('dev')); @@ -53,7 +61,7 @@ app.use(bodyParser.json()); app.use(bodyParser.urlencoded({ extended: false })); app.use(cookieParser()); app.use(session({ - secret: config.cookiePassword, + secret: config.cookieSecret, resave: false, saveUninitialized: false })); @@ -447,6 +455,11 @@ app.use(function(req, res, next) { next(); }); +app.use(csurf(), (req, res, next) => { + res.locals.csrfToken = req.csrfToken(); + next(); +}); + app.use('/', baseActionsRouter); /// catch 404 and forwarding to error handler diff --git a/app/auth.js b/app/auth.js new file mode 100644 index 0000000..4442f89 --- /dev/null +++ b/app/auth.js @@ -0,0 +1,13 @@ +var basicAuth = require('basic-auth'); + +module.exports = pass => (req, res, next) => { + var cred = basicAuth(req); + + if (cred && cred.pass === pass) { + req.authenticated = true; + return next(); + } + + res.set('WWW-Authenticate', `Basic realm="Private Area"`) + .sendStatus(401); +} diff --git a/app/config.js b/app/config.js index de6e94c..101bb08 100644 --- a/app/config.js +++ b/app/config.js @@ -1,3 +1,5 @@ +var fs = require('fs'); +var crypto = require('crypto'); var coins = require("./coins.js"); var currentCoin = process.env.BTCEXP_COIN || "BTC"; @@ -7,12 +9,27 @@ try { Object.assign(credentials, require("./credentials.js")) } catch (err) {} +var rpcCred = credentials.rpc; + +if (rpcCred.cookie && !rpcCred.username && !rpcCred.password && fs.existsSync(rpcCred.cookie)) { + [ rpcCred.username, rpcCred.password ] = fs.readFileSync(rpcCred.cookie).toString().split(':', 2); + if (!rpcCred.password) throw new Error('Cookie file '+rpcCred.cookie+' in unexpected format'); +} + +var cookieSecret = process.env.BTCEXP_COOKIE_SECRET + || (rpcCred.password && crypto.createHmac('sha256', JSON.stringify(rpcCred)) + .update('btc-rpc-explorer-cookie-secret').digest('hex')) + || "0x000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f"; + module.exports = { - cookiePassword: process.env.BTCEXP_COOKIE_PASSWORD || "0x000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f", + cookieSecret: cookieSecret, demoSite: !!process.env.BTCEXP_DEMO, coin: currentCoin, - rpcBlacklist:[ + rpcBlacklist: + process.env.BTCEXP_RPC_ALLOWALL ? [] + : process.env.BTCEXP_RPC_BLACKLIST ? process.env.BTCEXP_RPC_BLACKLIST.split(',').filter(Boolean) + : [ "addnode", "backupwallet", "bumpfee", @@ -92,9 +109,6 @@ module.exports = { credentials: credentials, - // Edit "ipWhitelistForRpcCommands" regex to limit access to RPC Browser / Terminal to matching IPs - ipWhitelistForRpcCommands:/^(127\.0\.0\.1)?(\:\:1)?$/, - siteTools:[ {name:"Node Status", url:"/node-status", desc:"Summary of this node: version, network, uptime, etc.", fontawesome:"fas fa-broadcast-tower"}, {name:"Peers", url:"/peers", desc:"Detailed info about the peers connected to this node.", fontawesome:"fas fa-sitemap"}, @@ -107,7 +121,7 @@ module.exports = { {name:"RPC Browser", url:"/rpc-browser", desc:"Browse the RPC functionality of this node. See docs and execute commands.", fontawesome:"fas fa-book"}, {name:"RPC Terminal", url:"/rpc-terminal", desc:"Directly execute RPCs against this node.", fontawesome:"fas fa-terminal"}, - + {name:(coins[currentCoin].name + " Fun"), url:"/fun", desc:"See fun/interesting historical blockchain data.", fontawesome:"fas fa-certificate"} ], diff --git a/app/defaultCredentials.js b/app/defaultCredentials.js index 633fadf..664dacd 100644 --- a/app/defaultCredentials.js +++ b/app/defaultCredentials.js @@ -1,18 +1,30 @@ +var os = require('os'); +var path = require('path'); +var url = require('url'); + +var btcUri = process.env.BTCEXP_BITCOIND_URI ? url.parse(process.env.BTCEXP_BITCOIND_URI, true) : { query: { } }; +var btcAuth = btcUri.auth ? btcUri.auth.split(':') : []; + +var ifxUri = process.env.BTCEXP_INFLUXDB_URI ? url.parse(process.env.BTCEXP_INFLUXDB_URI, true) : { query: { } }; +var ifxAuth = ifxUri.auth ? ifxUri.auth.split(':') : []; +var ifxActive = !!process.env.BTCEXP_ENABLE_INFLUXDB || Object.keys(process.env).some(k => k.startsWith('BTCEXP_INFLUXDB_')); + module.exports = { rpc: { - host: process.env.BTCEXP_BITCOIND_HOST || "127.0.0.1", - port: process.env.BTCEXP_BITCOIND_PORT || 8332, - username: process.env.BTCEXP_BITCOIND_USER, - password: process.env.BTCEXP_BITCOIND_PASS, + host: btcUri.hostname || process.env.BTCEXP_BITCOIND_HOST || "127.0.0.1", + port: btcUri.port || process.env.BTCEXP_BITCOIND_PORT || 8332, + username: btcAuth[0] || process.env.BTCEXP_BITCOIND_USER, + password: btcAuth[1] || process.env.BTCEXP_BITCOIND_PASS, + cookie: btcUri.query.cookie || process.env.BTCEXP_BITCOIND_COOKIE || path.join(os.homedir(), '.bitcoin', '.cookie'), }, influxdb:{ - active: !!process.env.BTCEXP_INFLUXDB_ENABLED, - host: process.env.BTCEXP_INFLUXDB_HOST || "127.0.0.1", - port: process.env.BTCEXP_INFLUXDB_PORT || 8086, - database: process.env.BTCEXP_INFLUXDB_DB || "influxdb", - username: process.env.BTCEXP_INFLUXDB_USER || "admin", - password: process.env.BTCEXP_INFLUXDB_PASS || "admin" + active: ifxActive, + host: ifxUri.hostname || process.env.BTCEXP_INFLUXDB_HOST || "127.0.0.1", + port: ifxUri.port || process.env.BTCEXP_INFLUXDB_PORT || 8086, + database: ifxUri.pathname && ifxUri.pathname.substr(1) || process.env.BTCEXP_INFLUXDB_DBNAME || "influxdb", + username: ifxAuth[0] || process.env.BTCEXP_INFLUXDB_USER || "admin", + password: ifxAuth[1] || process.env.BTCEXP_INFLUXDB_PASS || "admin" }, // optional: enter your api access key from ipstack.com below diff --git a/app/utils.js b/app/utils.js index 9b8130f..68924fb 100644 --- a/app/utils.js +++ b/app/utils.js @@ -276,6 +276,8 @@ function getBlockTotalFeesFromCoinbaseTxAndBlockHeight(coinbaseTx, blockHeight) } function refreshExchangeRates() { + if (process.env.BTCEXP_NO_RATES) return; + if (coins[config.coin].exchangeRateData) { request(coins[config.coin].exchangeRateData.jsonUrl, function(error, response, body) { if (!error && response && response.statusCode && response.statusCode == 200) { diff --git a/bin/cli.js b/bin/cli.js new file mode 100755 index 0000000..62c64f7 --- /dev/null +++ b/bin/cli.js @@ -0,0 +1,69 @@ +#!/usr/bin/env node + +const args = require('meow')(` + Usage + $ btc-rpc-explorer [options] + + Options + -p, --port port to bind http server [default: 3002] + -l, --login protect web interface with a password [default: no password] + --coin crypto-coin to enable [default: BTC] + + -b, --bitcoind-uri connection URI for bitcoind rpc (overrides the options below) + -H, --bitcoind-host hostname for bitcoind rpc [default: 127.0.0.1] + -P, --bitcoind-port port for bitcoind rpc [default: 8332] + -c, --bitcoind-cookie path to bitcoind cookie file [default: 8332] + -u, --bitcoind-user username for bitcoind rpc [default: none] + -w, --bitcoind-pass password for bitcoind rpc [default: none] + + --rpc-allowall allow all rpc commands [default: false] + --rpc-blacklist comma separated list of rpc commands to block [default: see in config.js] + --cookie-secret secret key for signed cookie hmac generation [default: hmac derive from bitcoind pass] + --demo enable demoSite mode [default: disabled] + --no-rates disable fetching of currency exchange rates [default: enabled] + + --ipstack-key api access key for ipstack (for geoip) [default: disabled] + --ganalytics-tracking tracking id for google analytics [default: disabled] + --sentry-url sentry url [default: disabled] + + --enable-influxdb enable influxdb for logging network stats [default: false] + --influxdb-uri connection URI for influxdb (overrides the options below) + --influxdb-host hostname for influxdb [default: 127.0.0.1] + --influxdb-port port for influxdb [default: 8086] + --influxdb-user username for influxdb [default: admin] + --influxdb-pass password for influxdb [default: admin] + --influxdb-dbname database name for influxdb [default: influxdb] + + -e, --node-env nodejs environment mode [default: production] + -h, --help output usage information + -v, --version output version number + + Examples + $ btc-rpc-explorer --port 8080 --bitcoind-port 18443 --bitcoind-cookie ~/.bitcoin/regtest/.cookie + $ btc-rpc-explorer -p 8080 -P 18443 -c ~/.bitcoin/regtest.cookie + + Or using connection URIs + $ btc-rpc-explorer -b bitcoin://bob:myPassword@127.0.0.1:18443/ + $ btc-rpc-explorer -b bitcoin://127.0.0.1:18443/?cookie=$HOME/.bitcoin/regtest/.cookie + $ btc-rpc-explorer --influxdb-uri influx://bob:myPassword@127.0.0.1:8086/dbName + + All options may also be specified as environment variables + $ BTCEXP_PORT=8080 BTCEXP_BITCOIND_PORT=18443 BTCEXP_BITCOIND_COOKIE=~/.bitcoin/regtest/.cookie btc-rpc-explorer + + +`, { flags: { port: {alias:'p'}, login: {alias:'l'} + , bitcoindUri: {alias:'b'}, bitcoindHost: {alias:'H'}, bitcoindPort: {alias:'P'} + , bitcoindCookie: {alias:'c'}, bitcoindUser: {alias:'u'}, bitcoindPass: {alias:'w'} + , demo: {type:'boolean'}, rpcAllowall: {type:'boolean'} + , enableInfluxdb: {type:'boolean'}, nodeEnv: {alias:'e', default:'production'} + } } +).flags; + +const envify = k => k.replace(/([A-Z])/g, '_$1').toUpperCase(); + +Object.keys(args).filter(k => k.length > 1).forEach(k => { + if (args[k] === false) process.env[`BTCEXP_NO_${envify(k)}`] = true; + else process.env[`BTCEXP_${envify(k)}`] = args[k]; +}) + +require('./www'); diff --git a/bin/www b/bin/www index 9f93c1b..c46aed3 100644 --- a/bin/www +++ b/bin/www @@ -2,7 +2,7 @@ var debug = require('debug')('my-application'); var app = require('../app'); -app.set('port', process.env.PORT || 3002); +app.set('port', process.env.PORT || process.env.BTCEXP_PORT || 3002); var server = app.listen(app.get('port'), function() { debug('Express server listening on port ' + server.address().port); diff --git a/package.json b/package.json index 677af28..eac251e 100644 --- a/package.json +++ b/package.json @@ -3,10 +3,12 @@ "version": "1.0.0", "description": "Explorer for Bitcoin and RPC-compatible blockchains", "private": false, + "bin": "bin/cli.js", "scripts": { "start": "node ./bin/www", "build": "npm-run-all build:*", - "build:less": "lessc ./public/css/radial-progress.less ./public/css/radial-progress.css" + "build:less": "lessc ./public/css/radial-progress.less ./public/css/radial-progress.css", + "prepare": "npm run build" }, "keywords": [ "bitcoin", @@ -20,11 +22,13 @@ "url": "git+https://github.com/janoside/btc-rpc-explorer.git" }, "dependencies": { + "basic-auth": "^2.0.1", "bitcoin-core": "2.0.0", "bitcoinjs-lib": "3.3.2", "body-parser": "~1.18.2", "cookie-parser": "~1.4.3", "crypto-js": "3.1.9-1", + "csurf": "^1.9.0", "debug": "~2.6.0", "decimal.js": "7.2.3", "dotenv": "^6.2.0", @@ -34,6 +38,7 @@ "influx": "5.0.7", "jstransformer-markdown-it": "^2.0.0", "lru-cache": "4.1.3", + "meow": "^5.0.0", "moment": "^2.24.0", "moment-duration-format": "2.2.2", "morgan": "^1.9.1", diff --git a/routes/baseActionsRouter.js b/routes/baseActionsRouter.js index 9efe2e8..96da05c 100644 --- a/routes/baseActionsRouter.js +++ b/routes/baseActionsRouter.js @@ -1,4 +1,5 @@ var express = require('express'); +var csurf = require('csurf'); var router = express.Router(); var util = require('util'); var moment = require('moment'); @@ -14,6 +15,8 @@ var coins = require("./../app/coins.js"); var config = require("./../app/config.js"); var coreApi = require("./../app/api/coreApi.js"); +const forceCsrf = csurf({ ignoreMethods: [] }); + router.get("/", function(req, res) { if (req.session.host == null || req.session.host.trim() == "") { if (req.cookies['rpc-host']) { @@ -41,7 +44,7 @@ router.get("/", function(req, res) { promises.push(coreApi.getMempoolInfo()); promises.push(coreApi.getMiningInfo()); - var chainTxStatsIntervals = [ 144, 144 * 7, 144 * 30, 144 * 265 ]; + var chainTxStatsIntervals = [ 144, 144 * 7, 144 * 30, 144 * 365 ]; res.locals.chainTxStatsLabels = [ "24 hours", "1 week", "1 month", "1 year", "All time" ]; for (var i = 0; i < chainTxStatsIntervals.length; i++) { promises.push(coreApi.getChainTxStats(chainTxStatsIntervals[i])); @@ -743,30 +746,18 @@ router.get("/address/:address", function(req, res) { }); router.get("/rpc-terminal", function(req, res) { - if (!config.demoSite) { - var ip = req.headers['x-forwarded-for'] || req.connection.remoteAddress; - var match = config.ipWhitelistForRpcCommands.exec(ip); - - if (!match) { - res.send("RPC Terminal / Browser may not be accessed from '" + ip + "'. This restriction can be modified in your config.js file."); - - return; - } + if (!config.demoSite && !req.authenticated) { + res.send("RPC Terminal / Browser may not be accessed without logging-in. This restriction can be modified in your config.js file."); + return; } res.render("terminal"); }); router.post("/rpc-terminal", function(req, res) { - if (!config.demoSite) { - var ip = req.headers['x-forwarded-for'] || req.connection.remoteAddress; - var match = config.ipWhitelistForRpcCommands.exec(ip); - - if (!match) { - res.send("RPC Terminal / Browser may not be accessed from '" + ip + "'. This restriction can be modified in your config.js file."); - - return; - } + if (!config.demoSite && !req.authenticated) { + res.send("RPC Terminal / Browser may not be accessed without logging-in. This restriction can be modified in your config.js file."); + return; } var params = req.body.cmd.trim().split(/\s+/); @@ -815,16 +806,10 @@ router.post("/rpc-terminal", function(req, res) { }); }); -router.get("/rpc-browser", function(req, res) { - if (!config.demoSite) { - var ip = req.headers['x-forwarded-for'] || req.connection.remoteAddress; - var match = config.ipWhitelistForRpcCommands.exec(ip); - - if (!match) { - res.send("RPC Terminal / Browser may not be accessed from '" + ip + "'. This restriction can be modified in your config.js file."); - - return; - } +router.get("/rpc-browser", function(req, res, next) { + if (!config.demoSite && !req.authenticated) { + res.send("RPC Terminal / Browser may not be accessed without logging-in. This restriction can be modified in your config.js file."); + return; } coreApi.getHelp().then(function(result) { @@ -883,26 +868,30 @@ router.get("/rpc-browser", function(req, res) { return; } - console.log("Executing RPC '" + req.query.method + "' with params: [" + argValues + "]"); + forceCsrf(req, res, err => { + if (err) return next(err); + + console.log("Executing RPC '" + req.query.method + "' with params: [" + argValues + "]"); + + client.command([{method:req.query.method, parameters:argValues}], function(err3, result3, resHeaders3) { + console.log("RPC Response: err=" + err3 + ", result=" + result3 + ", headers=" + resHeaders3); - client.command([{method:req.query.method, parameters:argValues}], function(err3, result3, resHeaders3) { - console.log("RPC Response: err=" + err3 + ", result=" + result3 + ", headers=" + resHeaders3); + if (err3) { + if (result3) { + res.locals.methodResult = {error:("" + err3), result:result3}; + + } else { + res.locals.methodResult = {error:("" + err3)}; + } + } else if (result3) { + res.locals.methodResult = result3; - if (err3) { - if (result3) { - res.locals.methodResult = {error:("" + err3), result:result3}; - } else { - res.locals.methodResult = {error:("" + err3)}; + res.locals.methodResult = {"Error":"No response from node."}; } - } else if (result3) { - res.locals.methodResult = result3; - } else { - res.locals.methodResult = {"Error":"No response from node."}; - } - - res.render("browser"); + res.render("browser"); + }); }); } else { res.render("browser"); diff --git a/views/browser.pug b/views/browser.pug index ec14bd5..7e72ef7 100644 --- a/views/browser.pug +++ b/views/browser.pug @@ -46,6 +46,7 @@ block content hr form(method="get") + input(type="hidden", name="_csrf", value=csrfToken) input(type="hidden", name="method", value=method) div(class="h5 mb-3") Arguments diff --git a/views/connect.pug b/views/connect.pug index b6a37c7..1cd8aa5 100644 --- a/views/connect.pug +++ b/views/connect.pug @@ -5,6 +5,8 @@ block content hr form(method="post", action="/connect") + input(type="hidden", name="_csrf", value=csrfToken) + div(class="form-group") label(for="input-host") Host / IP input(id="input-host", type="text", name="host", class="form-control", placeholder="Host / IP", value=host) diff --git a/views/layout.pug b/views/layout.pug index 601688f..39e54b2 100644 --- a/views/layout.pug +++ b/views/layout.pug @@ -2,6 +2,7 @@ doctype html html(lang="en") head meta(charset="utf-8") + meta(name="csrf-token", content=csrfToken) meta(name="viewport", content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0, shrink-to-fit=no") if (session.uiTheme && session.uiTheme == "dark") @@ -86,6 +87,7 @@ html(lang="en") span Dark form(method="post", action="/search", class="form-inline") + input(type="hidden", name="_csrf", value=csrfToken) div(class="input-group input-group-sm") input(type="text", class="form-control form-control-sm", name="query", placeholder="block height/hash, txid, address", value=(query), style="width: 300px;") div(class="input-group-append") diff --git a/views/search.pug b/views/search.pug index febec71..d74e024 100644 --- a/views/search.pug +++ b/views/search.pug @@ -9,6 +9,7 @@ block content div(class="mb-5") form(method="post", action="/search", class="form") + input(type="hidden", name="_csrf", value=csrfToken) div(class="input-group input-group-lg") input(type="text", class="form-control form-control-sm", name="query", placeholder="block height/hash, txid, address", value=(query), style="width: 300px;") div(class="input-group-append") diff --git a/views/terminal.pug b/views/terminal.pug index fe5c669..1299933 100644 --- a/views/terminal.pug +++ b/views/terminal.pug @@ -33,6 +33,8 @@ block content block endOfBody script. + var csrfToken = $('meta[name=csrf-token]').attr('content'); + $(document).ready(function() { $("#terminal-form").submit(function(e) { e.preventDefault(); @@ -41,6 +43,7 @@ block endOfBody var postData = {}; postData.cmd = cmd; + postData._csrf = csrfToken; $.post( "/rpc-terminal",