Skip to content

Commit 0ca6b93

Browse files
committed
Completely rewrites API to use express. Adds authentication.
1 parent 3812e71 commit 0ca6b93

22 files changed

Lines changed: 892 additions & 234 deletions

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
66

77
## [Unreleased]
88

9+
### Added
10+
- API basic HTTP auth
11+
912
### Changed
1013
- Fixed orphaned neighbors check.
1114
- Fixed API security bug.

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,7 @@ You can provide one or more of the following options in your ini file. Example:
200200
name = My Nelson Node
201201
cycleInterval = 60
202202
epochInterval = 300
203+
apiAuth = username:password
203204
apiPort = 18600
204205
apiHostname = 127.0.0.1
205206
port = 16600
@@ -244,6 +245,7 @@ Some have additional short versions.
244245
| --name | Name your node. This identifier will appear in API/webhooks and for your neighbors ||
245246
| --neighbors, -n | space-separated list of entry Nelson neighbors ||
246247
| --getNeighbors | Downloads a list of entry Nelson neighbors. If no URL is provided, will use a default URL (https://raw.githubusercontent.com/SemkoDev/nelson.cli/master/ENTRYNODES). If this option is not set, no neighbors will be downloaded. This option can be used together with ````--neighbors`` |false|
248+
| --apiAuth| Add basic HTTP auth to API. Provide username and password in `user:pass` format||
247249
| --apiPort, -a | Nelson API port to request current node status data|18600|
248250
| --apiHostname, -o | Nelson API hostname to request current node status data. Default value will only listen to local connections|127.0.0.1|
249251
| --port, -p | TCP port, on which to start your Nelson instance|16600|
@@ -394,6 +396,13 @@ curl http://localhost:18600/peer-stats
394396
}
395397
```
396398

399+
if you use `apiAuth` option to protect your API, you will need to provide the authentication details
400+
in your requests:
401+
402+
```
403+
curl -u username:password http://localhost:18600
404+
```
405+
397406
### Webhooks
398407

399408
You can provide Nelson a list of webhook URLs that have to be regularly called back with all the node stats data.

package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,13 @@
4141
"dependencies": {
4242
"blessed": "^0.1.81",
4343
"blessed-contrib": "^4.8.5",
44+
"body-parser": "^1.18.2",
4445
"colors": "^1.1.2",
4546
"commander": "^2.11.0",
47+
"express": "^4.16.2",
48+
"express-basic-auth": "^1.1.3",
4649
"external-ip": "^1.3.1",
50+
"helmet": "^3.10.0",
4751
"httpdispatcher": "^2.1.1",
4852
"ini": "^1.3.5",
4953
"iota.lib.js": "0.4.7",

src/api/__tests__/api-test.js

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
const request = require('request');
2+
const { Node } = require('../../node/node');
3+
const { createAPI } = require('../index');
4+
5+
jest.mock('../../node/iri');
6+
7+
const API_DATA = [
8+
'config', 'connectedPeers', 'heart', 'iriStats', 'isIRIHealthy', 'name', 'peerStats', 'ready',
9+
'totalPeers','version'
10+
];
11+
12+
describe('API', () => {
13+
it('should get node info correctly', (done) => {
14+
const node = new Node({ silent: true, temporary: true, port: 16601 });
15+
const server = createAPI({
16+
node,
17+
apiPort: 12345,
18+
apiHostname: 'localhost'
19+
});
20+
node.start().then(() => {
21+
request.get('http://localhost:12345/', (err, resp, body) => {
22+
const answer = JSON.parse(body);
23+
const keys = Object.keys(answer);
24+
keys.sort();
25+
expect(keys).toEqual(API_DATA);
26+
server.close();
27+
node.end().then(done);
28+
});
29+
})
30+
});
31+
32+
it('should deny public access to protected when no password set', (done) => {
33+
const node = new Node({ silent: true, temporary: true, port: 16602 });
34+
const server = createAPI({
35+
node,
36+
apiPort: 12345,
37+
apiHostname: 'localhost',
38+
username: 'pass',
39+
password: 'pass'
40+
});
41+
node.start().then(() => {
42+
request.get('http://localhost:12345/', (err, resp, body) => {
43+
expect(resp.statusCode).toEqual(401);
44+
expect(body).toBeFalsy;
45+
server.close();
46+
node.end().then(done);
47+
});
48+
})
49+
});
50+
51+
it('should deny public access to protected when wrong pass', (done) => {
52+
const node = new Node({ silent: true, temporary: true, port: 16603 });
53+
const server = createAPI({
54+
node,
55+
apiPort: 12345,
56+
apiHostname: 'localhost',
57+
username: 'pass',
58+
password: 'pass'
59+
});
60+
node.start().then(() => {
61+
request.get({
62+
url: 'http://localhost:12345/',
63+
auth: {
64+
user: 'pass',
65+
pass: 'nopass'
66+
}
67+
}, (err, resp, body) => {
68+
expect(resp.statusCode).toEqual(401);
69+
expect(body).toBeFalsy;
70+
server.close();
71+
node.end().then(done);
72+
});
73+
})
74+
});
75+
76+
it('should allow access to protected when auth ok', (done) => {
77+
const node = new Node({ silent: true, temporary: true, port: 16604 });
78+
const server = createAPI({
79+
node,
80+
apiPort: 12345,
81+
apiHostname: 'localhost',
82+
username: 'pass',
83+
password: 'pass'
84+
});
85+
node.start().then(() => {
86+
request.get({
87+
url: 'http://localhost:12345/',
88+
auth: {
89+
user: 'pass',
90+
pass: 'pass'
91+
}
92+
}, (err, resp, body) => {
93+
const answer = JSON.parse(body);
94+
const keys = Object.keys(answer);
95+
keys.sort();
96+
expect(keys).toEqual(API_DATA);
97+
server.close();
98+
node.end().then(done);
99+
});
100+
})
101+
});
102+
103+
it('should get peer stats info correctly', (done) => {
104+
const node = new Node({ silent: true, temporary: true, port: 16605 });
105+
const server = createAPI({
106+
node,
107+
apiPort: 12346,
108+
apiHostname: 'localhost'
109+
});
110+
node.start().then(() => {
111+
request.get('http://localhost:12346/peer-stats', (err, resp, body) => {
112+
const summary = JSON.parse(body);
113+
expect(summary.newNodes).toBeTruthy;
114+
expect(summary.activeNodes).toBeTruthy;
115+
server.close();
116+
node.end().then(done);
117+
});
118+
})
119+
});
120+
121+
it('should get peers info correctly', (done) => {
122+
const node = new Node({ silent: true, temporary: true, port: 16606 });
123+
const server = createAPI({
124+
node,
125+
apiPort: 12347,
126+
apiHostname: 'localhost'
127+
});
128+
node.start().then(() => {
129+
request.get('http://localhost:12347/peers', (err, resp, body) => {
130+
const peers = JSON.parse(body);
131+
expect(Array.isArray(peers)).toBeTruthy;
132+
server.close();
133+
node.end().then(done);
134+
});
135+
})
136+
});
137+
});

src/api/__tests__/node-test.js

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
const { Node } = require('../../node/node');
2+
const { getSummary, getNodeStats } = require('../node');
3+
4+
jest.mock('../../node/iri');
5+
6+
const ALLOWED_DATA = [
7+
'config', 'connectedPeers', 'heart', 'iriStats', 'isIRIHealthy',
8+
'name', 'peerStats', 'ready', 'totalPeers', 'version'
9+
];
10+
11+
describe('API Node utils', () => {
12+
it('should correctly return summary', (done) => {
13+
const node = new Node({ silent: true, temporary: true, port: 16607 });
14+
node.start().then(() => {
15+
const summary = getSummary(node);
16+
expect(summary.newNodes).toBeTruthy;
17+
expect(summary.activeNodes).toBeTruthy;
18+
node.end().then(done)
19+
})
20+
});
21+
22+
it('should correctly return node stats', (done) => {
23+
const node = new Node({ silent: true, temporary: true, port: 16608 });
24+
node.start().then(() => {
25+
const stats = getNodeStats(node);
26+
const keys = Object.keys(stats);
27+
keys.sort();
28+
expect(keys).toEqual(ALLOWED_DATA);
29+
node.end().then(done)
30+
})
31+
})
32+
});

src/api/__tests__/peer-test.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
const {Peer} = require('../../node/peer');
2+
const {getPeerStats} = require('../peer');
3+
4+
const ALLOWED_DATA = [
5+
'IRIProtocol', 'TCPPort', 'UDPPort', 'connected', 'dateCreated', 'dateLastConnected', 'dateTried',
6+
'hostname', 'ip', 'isTrusted', 'lastConnections', 'name', 'port', 'protocol', 'seen', 'tried', 'weight'
7+
];
8+
9+
describe('API Peer utils', () => {
10+
it('should display only public data', () => {
11+
const stats = getPeerStats(new Peer());
12+
const keys = Object.keys(stats);
13+
keys.sort();
14+
expect(keys).toEqual(ALLOWED_DATA);
15+
})
16+
});

src/api/__tests__/webhooks-test.js

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
const express = require('express');
2+
const bodyParser = require('body-parser');
3+
const { Node } = require('../../node/node');
4+
const { startWebhooks } = require('../webhooks');
5+
6+
jest.mock('../../node/iri');
7+
8+
const API_DATA = [
9+
'config', 'connectedPeers', 'heart', 'iriStats', 'isIRIHealthy', 'name', 'peerStats', 'ready',
10+
'totalPeers','version'
11+
];
12+
13+
describe('API Webhooks', () => {
14+
it('should start webhooks correctly', (done) => {
15+
const node = new Node({ silent: true, temporary: true, port: 16609 });
16+
node.start().then(() => {
17+
const startDate = new Date();
18+
const hook = startWebhooks(node, [ 'http://localhost:12348' ], 2);
19+
const app = express();
20+
app.use(bodyParser.urlencoded({ extended: false }));
21+
app.use(bodyParser.json());
22+
23+
const server = app.listen(12348);
24+
app.post('/', (req) => {
25+
const timePassed = (new Date()) - startDate;
26+
const keys = Object.keys(req.body);
27+
keys.sort();
28+
expect(keys).toEqual(API_DATA);
29+
expect(timePassed).toBeGreaterThanOrEqual(2000);
30+
expect(timePassed).toBeLessThanOrEqual(2100);
31+
server.close();
32+
hook.stop();
33+
node.end().then(done);
34+
});
35+
})
36+
});
37+
});

src/api/index.js

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
const express = require('express');
2+
const helmet = require('helmet');
3+
const bodyParser = require('body-parser');
4+
const basicAuth = require('express-basic-auth');
5+
6+
const { getNodeStats, getSummary } = require('./node');
7+
const { getPeerStats } = require('./peer');
8+
const { startWebhooks } = require('./webhooks');
9+
10+
const DEFAULT_OPTIONS = {
11+
node: null,
12+
webhooks: [],
13+
webhookInterval: 30,
14+
username: null,
15+
password: null,
16+
apiPort: 18600,
17+
apiHostname: '127.0.0.1'
18+
};
19+
20+
/**
21+
* Creates an Express APP instance, also starts regular webhooks callbacks.
22+
* @param options
23+
* @returns {*|Function}
24+
*/
25+
function createAPI (options) {
26+
const opts = { ...DEFAULT_OPTIONS, ...options };
27+
28+
// Start webhook callbacks
29+
if (opts.webhooks && opts.webhooks.length) {
30+
startWebhooks(opts.node, opts.webhooks, opts.webhookInterval)
31+
}
32+
33+
// Start API server
34+
const app = express();
35+
app.set('node', opts.node);
36+
37+
// Basic app protection
38+
app.use(helmet());
39+
40+
// Enable basic HTTP Auth
41+
if (opts.username && opts.password) {
42+
app.use(basicAuth({
43+
users: { [opts.username]: opts.password }
44+
}))
45+
}
46+
47+
// parse application/x-www-form-urlencoded
48+
app.use(bodyParser.urlencoded({ extended: false }));
49+
50+
// parse application/json
51+
app.use(bodyParser.json());
52+
53+
//////////////////////// ENDPOINTS ////////////////////////
54+
55+
app.get('/', (req, res) => {
56+
res.json(getNodeStats(opts.node))
57+
});
58+
59+
app.get('/peer-stats', (req, res) => {
60+
res.json(getSummary(opts.node))
61+
});
62+
63+
app.get('/peers', (req, res) => {
64+
res.json(opts.node.list.all().map(getPeerStats))
65+
});
66+
67+
return app.listen(opts.apiPort, opts.apiHostname);
68+
}
69+
70+
module.exports = {
71+
createAPI,
72+
DEFAULT_OPTIONS
73+
};

0 commit comments

Comments
 (0)