node-red-contrib-tdn-eventsub 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -3
- package/examples/rest-basic.json +15 -5
- package/lib/client.js +79 -14
- package/lib/credentials.js +42 -0
- package/nodes/rest-item-status.html +14 -2
- package/nodes/rest-item-status.js +17 -4
- package/nodes/rest-lists.html +31 -1
- package/nodes/rest-lists.js +9 -1
- package/nodes/rest-server.html +34 -5
- package/nodes/rest-server.js +33 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@ Node-RED nodes for an access-control **REST Server API**.
|
|
|
9
9
|
| `get items` | Downloads all items (optionally filtered by type/division), stores to global context | request/reply |
|
|
10
10
|
| `item status` | Subscribes to status of one or more item (source) ids and keeps waiting for updates indefinitely | continuous |
|
|
11
11
|
|
|
12
|
-
All share one **REST server** config node: URL/host, port (default 8904), API key
|
|
12
|
+
All share one **REST server** config node: URL/host, port (default 8904), API key (optional: can come from a credentials object instead), how to send it (Authorization or API key header), optional server certificate fingerprint, optional client certificate.
|
|
13
13
|
|
|
14
14
|
Every node has a trigger input and **three outputs**:
|
|
15
15
|
|
|
@@ -34,13 +34,30 @@ Restart Node-RED. Nodes appear under **TDN REST**. Import the example via *Impor
|
|
|
34
34
|
|---|---|
|
|
35
35
|
| URL / host | `rest-server`, `192.168.1.10` or `https://rest-server`. `/api` path assumed. |
|
|
36
36
|
| Port | Default `8904`. |
|
|
37
|
-
| API key | Stored as a Node-RED credential; treated as opaque. |
|
|
38
|
-
|
|
|
37
|
+
| API key | Optional. Stored as a Node-RED credential; treated as opaque. When set it is always used. When blank, each node reads a [credentials object](#credentials-object). |
|
|
38
|
+
| Send key as | **Authorization header** (default) or **API key header**. |
|
|
39
|
+
| Auth scheme | Authorization header only. Blank (default): `Authorization: Basic base64(":" + key)`, accepted by server v9.0+. For older servers enter the API-key scheme token from your server's REST API docs; the key is then sent as `Authorization: <scheme> <key>`. |
|
|
40
|
+
| Header name | API key header only. Sent as `<header name>: <key>`; default `X-API-Key`. |
|
|
39
41
|
| Cert fingerprint | Optional. SHA-256 (64 hex) or SHA-1 (40 hex) of the **server** certificate, colons optional. When set, the server's self-signed cert is accepted only if it matches; the check runs before any request is written, so the key is never sent to an impostor. A mismatch error prints the actual fingerprint. |
|
|
40
42
|
| Verify server certificate | Normal CA check when no fingerprint is set. |
|
|
41
43
|
| Force links onto this host:port | The server builds `href`s from its own machine name. With this on, followed links keep path + query but use your configured host/port. With it off, a link that would downgrade `https` to `http` is refused. |
|
|
42
44
|
| Client cert / key / PFX | Only if the REST Client item on the server has a thumbprint (pinned client certificate). |
|
|
43
45
|
|
|
46
|
+
## Credentials object
|
|
47
|
+
|
|
48
|
+
If the server config has **no API key stored** in Node-RED, every node takes the key from its **Credentials** property: a typedInput pointing at `msg`, `flow`, `global` or `env`, default `msg.credentials`. The value is either a bare key string or:
|
|
49
|
+
|
|
50
|
+
```js
|
|
51
|
+
{
|
|
52
|
+
apiKey: "XXXX-...", // required
|
|
53
|
+
authType: "authorization" | "header", // default: server config
|
|
54
|
+
authScheme: "", // authorization only; blank = Basic
|
|
55
|
+
headerName: "X-API-Key" // header only
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Missing fields fall back to the server config. A stored key always takes precedence, and the credentials object is then ignored. A msg-borne credentials property is deleted from the msg before it is passed on, so the key doesn't reach debug nodes or downstream flows. The item status node reads the credentials once per trigger and keeps them for that subscription.
|
|
60
|
+
|
|
44
61
|
Get the server fingerprint with:
|
|
45
62
|
|
|
46
63
|
```sh
|
package/examples/rest-basic.json
CHANGED
|
@@ -91,7 +91,9 @@
|
|
|
91
91
|
[
|
|
92
92
|
"dbg_err"
|
|
93
93
|
]
|
|
94
|
-
]
|
|
94
|
+
],
|
|
95
|
+
"creds": "credentials",
|
|
96
|
+
"credsType": "msg"
|
|
95
97
|
},
|
|
96
98
|
{
|
|
97
99
|
"z": "rest_tab",
|
|
@@ -141,7 +143,9 @@
|
|
|
141
143
|
[
|
|
142
144
|
"dbg_err"
|
|
143
145
|
]
|
|
144
|
-
]
|
|
146
|
+
],
|
|
147
|
+
"creds": "credentials",
|
|
148
|
+
"credsType": "msg"
|
|
145
149
|
},
|
|
146
150
|
{
|
|
147
151
|
"z": "rest_tab",
|
|
@@ -191,7 +195,9 @@
|
|
|
191
195
|
[
|
|
192
196
|
"dbg_err"
|
|
193
197
|
]
|
|
194
|
-
]
|
|
198
|
+
],
|
|
199
|
+
"creds": "credentials",
|
|
200
|
+
"credsType": "msg"
|
|
195
201
|
},
|
|
196
202
|
{
|
|
197
203
|
"z": "rest_tab",
|
|
@@ -257,7 +263,9 @@
|
|
|
257
263
|
[
|
|
258
264
|
"dbg_err"
|
|
259
265
|
]
|
|
260
|
-
]
|
|
266
|
+
],
|
|
267
|
+
"creds": "credentials",
|
|
268
|
+
"credsType": "msg"
|
|
261
269
|
},
|
|
262
270
|
{
|
|
263
271
|
"z": "rest_tab",
|
|
@@ -278,7 +286,9 @@
|
|
|
278
286
|
"name": "REST Server",
|
|
279
287
|
"url": "192.168.1.10",
|
|
280
288
|
"port": 8904,
|
|
289
|
+
"authType": "authorization",
|
|
281
290
|
"authScheme": "",
|
|
291
|
+
"headerName": "X-API-Key",
|
|
282
292
|
"fingerprint": "",
|
|
283
293
|
"rejectUnauthorized": true,
|
|
284
294
|
"rewriteHrefs": true,
|
|
@@ -287,4 +297,4 @@
|
|
|
287
297
|
"keyPath": "",
|
|
288
298
|
"pfxPath": ""
|
|
289
299
|
}
|
|
290
|
-
]
|
|
300
|
+
]
|
package/lib/client.js
CHANGED
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
* Minimal REST Server API client.
|
|
4
4
|
*
|
|
5
5
|
* - Auth: "Authorization: Basic base64(':' + key)" by default (server v9.0+),
|
|
6
|
-
*
|
|
6
|
+
* "Authorization: <scheme> <key>" when a custom scheme token is configured,
|
|
7
|
+
* or the key in its own header ("X-API-Key: <key>" or a configured name)
|
|
8
|
+
* - Credentials can be fixed at construction or supplied later (withCredentials)
|
|
7
9
|
* - HATEOAS: everything is discovered from GET /api (features.*.href)
|
|
8
10
|
* - Optional server certificate pinning by SHA-1 or SHA-256 fingerprint
|
|
9
11
|
* (lets you talk to a self-signed REST Server without disabling verification)
|
|
@@ -36,6 +38,52 @@ class RestError extends Error {
|
|
|
36
38
|
}
|
|
37
39
|
}
|
|
38
40
|
|
|
41
|
+
const AUTH_TYPES = ['authorization', 'header'];
|
|
42
|
+
const DEFAULT_KEY_HEADER = 'X-API-Key';
|
|
43
|
+
// headers we set ourselves; a key header must not replace them
|
|
44
|
+
const RESERVED_HEADERS = ['host', 'accept', 'connection', 'content-type', 'content-length', 'transfer-encoding'];
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Normalise a credentials object. Accepts a bare key string, or
|
|
48
|
+
* { apiKey | key, authType, authScheme, headerName }; missing fields come from `defaults`.
|
|
49
|
+
*/
|
|
50
|
+
function normaliseCredentials(cred, defaults) {
|
|
51
|
+
const d = defaults || {};
|
|
52
|
+
const c = typeof cred === 'string' ? { apiKey: cred } : Object.assign({}, cred || {});
|
|
53
|
+
const pick = (v, dv) => (v === undefined || v === null || v === '' ? dv : v);
|
|
54
|
+
return {
|
|
55
|
+
apiKey: String(pick(c.apiKey !== undefined ? c.apiKey : c.key, d.apiKey) || '').trim(),
|
|
56
|
+
authType: String(pick(c.authType, d.authType) || 'authorization').trim().toLowerCase(),
|
|
57
|
+
authScheme: String(pick(c.authScheme, d.authScheme) || '').trim(),
|
|
58
|
+
headerName: String(pick(c.headerName, d.headerName) || DEFAULT_KEY_HEADER).trim()
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Build the auth headers for one credential set:
|
|
64
|
+
* authType 'authorization': Authorization: Basic base64(':' + key) (blank scheme, server v9.0+)
|
|
65
|
+
* Authorization: <scheme> <key> (custom scheme token)
|
|
66
|
+
* authType 'header': <headerName>: <key> (default X-API-Key)
|
|
67
|
+
*/
|
|
68
|
+
function buildAuthHeaders(cred) {
|
|
69
|
+
const c = normaliseCredentials(cred);
|
|
70
|
+
if (!c.apiKey) throw new RestError('No API key: store one in the server config or pass a credentials object', { code: 'ENOCREDS' });
|
|
71
|
+
if (/[\r\n]/.test(c.apiKey)) throw new RestError('API key must not contain line breaks');
|
|
72
|
+
if (!AUTH_TYPES.includes(c.authType)) throw new RestError(`Auth type must be one of: ${AUTH_TYPES.join(', ')}`);
|
|
73
|
+
if (c.authType === 'header') {
|
|
74
|
+
if (!TOKEN_RE.test(c.headerName) || RESERVED_HEADERS.includes(c.headerName.toLowerCase())) {
|
|
75
|
+
throw new RestError(`Invalid API key header name "${c.headerName}"`);
|
|
76
|
+
}
|
|
77
|
+
return { [c.headerName]: c.apiKey };
|
|
78
|
+
}
|
|
79
|
+
if (c.authScheme && !TOKEN_RE.test(c.authScheme)) throw new RestError('Auth scheme must be a single token (letters, digits, - _ . etc., no spaces)');
|
|
80
|
+
return {
|
|
81
|
+
Authorization: c.authScheme
|
|
82
|
+
? c.authScheme + ' ' + c.apiKey
|
|
83
|
+
: 'Basic ' + Buffer.from(':' + c.apiKey, 'utf8').toString('base64')
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
39
87
|
function abortError() {
|
|
40
88
|
return new RestError('Request aborted', { code: 'ABORTED', aborted: true });
|
|
41
89
|
}
|
|
@@ -45,8 +93,10 @@ class RestClient {
|
|
|
45
93
|
* @param {object} o
|
|
46
94
|
* @param {string} o.url host, IP or URL (https:// assumed)
|
|
47
95
|
* @param {number} [o.port] default 8904
|
|
48
|
-
* @param {string} o.apiKey
|
|
49
|
-
* @param {string} [o.
|
|
96
|
+
* @param {string} [o.apiKey] optional; without it use withCredentials() per request source
|
|
97
|
+
* @param {string} [o.authType] 'authorization' (default) or 'header'
|
|
98
|
+
* @param {string} [o.authScheme] authorization: '' = Basic with ':<key>'; otherwise '<scheme> <key>'
|
|
99
|
+
* @param {string} [o.headerName] header: name of the key header (default X-API-Key)
|
|
50
100
|
* @param {string} [o.fingerprint] server cert SHA-1 (40 hex) or SHA-256 (64 hex), any separators
|
|
51
101
|
* @param {boolean} [o.rejectUnauthorized] used only when no fingerprint is set
|
|
52
102
|
* @param {string} [o.certPath] [o.keyPath] [o.pfxPath] [o.passphrase] client certificate
|
|
@@ -66,13 +116,12 @@ class RestClient {
|
|
|
66
116
|
this.base = u;
|
|
67
117
|
const path = u.pathname && u.pathname !== '/' ? u.pathname.replace(/\/+$/, '') : '/api';
|
|
68
118
|
this.apiRoot = u.origin + path;
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
: 'Basic ' + Buffer.from(':' + this.apiKey, 'utf8').toString('base64');
|
|
119
|
+
// no key here is fine: credentials can be supplied later with withCredentials().
|
|
120
|
+
// The auth settings are still checked now, so a bad scheme/header fails at deploy.
|
|
121
|
+
this.authDefaults = { authType: o.authType, authScheme: o.authScheme, headerName: o.headerName };
|
|
122
|
+
const hasKey = !!String(o.apiKey || '').trim();
|
|
123
|
+
const headers = buildAuthHeaders(Object.assign({}, o, { apiKey: hasKey ? o.apiKey : 'placeholder' }));
|
|
124
|
+
this.authHeaders = hasKey ? headers : null;
|
|
76
125
|
this.fingerprint = normFp(o.fingerprint);
|
|
77
126
|
if (this.fingerprint && this.fingerprint.length !== 40 && this.fingerprint.length !== 64) {
|
|
78
127
|
throw new RestError('Certificate fingerprint must be SHA-1 (40 hex) or SHA-256 (64 hex)');
|
|
@@ -95,6 +144,20 @@ class RestClient {
|
|
|
95
144
|
this._discoveryAt = 0;
|
|
96
145
|
}
|
|
97
146
|
|
|
147
|
+
/**
|
|
148
|
+
* A client for the same server using other credentials (bare key string or
|
|
149
|
+
* { apiKey, authType, authScheme, headerName }; missing fields fall back to this
|
|
150
|
+
* client's auth settings). Shares TLS settings; has its own discovery cache,
|
|
151
|
+
* because what /api lists depends on the operator behind the key.
|
|
152
|
+
*/
|
|
153
|
+
withCredentials(cred) {
|
|
154
|
+
const c = Object.create(this);
|
|
155
|
+
c.authHeaders = buildAuthHeaders(normaliseCredentials(cred, this.authDefaults));
|
|
156
|
+
c._discovery = null;
|
|
157
|
+
c._discoveryAt = 0;
|
|
158
|
+
return c;
|
|
159
|
+
}
|
|
160
|
+
|
|
98
161
|
/** Resolve an href (absolute or relative) and optionally pin it to the configured host:port. */
|
|
99
162
|
resolve(href) {
|
|
100
163
|
const t = new URL(href, this.base.origin);
|
|
@@ -195,13 +258,15 @@ class RestClient {
|
|
|
195
258
|
const signal = opts.signal;
|
|
196
259
|
// The socket is opened (and the server cert checked) before any header leaves,
|
|
197
260
|
// so the API key is never sent to a server that fails the fingerprint pin.
|
|
261
|
+
if (!this.authHeaders) {
|
|
262
|
+
throw new RestError('No API key: store one in the server config or pass a credentials object', { code: 'ENOCREDS' });
|
|
263
|
+
}
|
|
198
264
|
const sock = await this._connect(target, signal, timeoutMs);
|
|
199
265
|
const payload = body === undefined || body === null ? null : Buffer.from(JSON.stringify(body));
|
|
200
|
-
const headers = {
|
|
201
|
-
Authorization: this.authHeader,
|
|
266
|
+
const headers = Object.assign({
|
|
202
267
|
Accept: 'application/json',
|
|
203
268
|
Connection: 'close'
|
|
204
|
-
};
|
|
269
|
+
}, this.authHeaders);
|
|
205
270
|
if (payload) {
|
|
206
271
|
headers['Content-Type'] = 'application/json';
|
|
207
272
|
headers['Content-Length'] = payload.length;
|
|
@@ -319,4 +384,4 @@ class RestClient {
|
|
|
319
384
|
}
|
|
320
385
|
}
|
|
321
386
|
|
|
322
|
-
module.exports = { RestClient, RestError, normFp, fmtFp, DEFAULT_PORT };
|
|
387
|
+
module.exports = { RestClient, RestError, normaliseCredentials, buildAuthHeaders, normFp, fmtFp, DEFAULT_PORT, DEFAULT_KEY_HEADER };
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* Credentials object lookup for functional nodes.
|
|
4
|
+
*
|
|
5
|
+
* Used only when the server config node has no API key stored in Node-RED's
|
|
6
|
+
* credentials. The node's "Credentials" property (typedInput: msg / flow / global / env)
|
|
7
|
+
* points at either a bare key string or
|
|
8
|
+
* { apiKey, authType: 'authorization'|'header', authScheme, headerName }
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const CRED_TYPES = ['msg', 'flow', 'global', 'env'];
|
|
12
|
+
|
|
13
|
+
/** Read node.creds/node.credsType config into the node (default msg.credentials). */
|
|
14
|
+
function configureCreds(node, config) {
|
|
15
|
+
node.credsType = CRED_TYPES.includes(config.credsType) ? config.credsType : 'msg';
|
|
16
|
+
node.creds = config.creds === undefined || config.creds === null || config.creds === '' ? 'credentials' : String(config.creds).trim();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Resolve the credentials object for this message (undefined if absent). */
|
|
20
|
+
function resolveCreds(RED, node, msg) {
|
|
21
|
+
if (node.server && node.server.hasStoredKey) return Promise.resolve(undefined); // stored key wins; don't touch context
|
|
22
|
+
return new Promise((resolve, reject) => {
|
|
23
|
+
RED.util.evaluateNodeProperty(node.creds, node.credsType, node, msg, (err, value) => {
|
|
24
|
+
if (err) reject(err); else resolve(value);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Remove a msg-borne credentials object so the key doesn't travel downstream. */
|
|
30
|
+
function stripCreds(RED, node, msg) {
|
|
31
|
+
if (node.credsType !== 'msg' || !msg) return;
|
|
32
|
+
let parts;
|
|
33
|
+
try { parts = RED.util.normalisePropertyExpression(node.creds); } catch (e) { return; }
|
|
34
|
+
let obj = msg;
|
|
35
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
36
|
+
obj = obj[parts[i]];
|
|
37
|
+
if (!obj || typeof obj !== 'object') return;
|
|
38
|
+
}
|
|
39
|
+
delete obj[parts[parts.length - 1]];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
module.exports = { CRED_TYPES, configureCreds, resolveCreds, stripCreds };
|
|
@@ -10,7 +10,9 @@
|
|
|
10
10
|
maxRetryDelay: { value: 60000, validate: RED.validators.number() },
|
|
11
11
|
emitTimeouts: { value: false },
|
|
12
12
|
contextKey: { value: '' },
|
|
13
|
-
contextStore: { value: '' }
|
|
13
|
+
contextStore: { value: '' },
|
|
14
|
+
creds: { value: 'credentials' },
|
|
15
|
+
credsType: { value: 'msg' }
|
|
14
16
|
},
|
|
15
17
|
inputs: 1,
|
|
16
18
|
outputs: 3,
|
|
@@ -19,6 +21,7 @@
|
|
|
19
21
|
label: function () { return this.name || 'item status updates'; },
|
|
20
22
|
paletteLabel: 'item status',
|
|
21
23
|
oneditprepare: function () {
|
|
24
|
+
$('#node-input-creds').typedInput({ default: 'msg', types: ['msg', 'flow', 'global', 'env'], typeField: '#node-input-credsType' });
|
|
22
25
|
var sel = $('#node-input-contextStore');
|
|
23
26
|
var stores = (RED.settings.context && RED.settings.context.stores) || ['default'];
|
|
24
27
|
sel.empty();
|
|
@@ -38,6 +41,11 @@
|
|
|
38
41
|
<label for="node-input-server"><i class="fa fa-server"></i> Server</label>
|
|
39
42
|
<input type="text" id="node-input-server">
|
|
40
43
|
</div>
|
|
44
|
+
<div class="form-row">
|
|
45
|
+
<label for="node-input-creds"><i class="fa fa-key"></i> Credentials</label>
|
|
46
|
+
<input type="text" id="node-input-creds" style="width:70%">
|
|
47
|
+
<input type="hidden" id="node-input-credsType">
|
|
48
|
+
</div>
|
|
41
49
|
<div class="form-row">
|
|
42
50
|
<label for="node-input-pollDelay">Delay between waits (ms)</label>
|
|
43
51
|
<input type="text" id="node-input-pollDelay" style="width:100px"> <span>0–20000</span>
|
|
@@ -79,7 +87,11 @@
|
|
|
79
87
|
<dd>The source id(s): <code>"508"</code>, <code>"508,526"</code>, <code>["508","526"]</code>,
|
|
80
88
|
<code>{"itemIds":["508","526"]}</code>, or an array of item objects with an <code>id</code> (e.g. straight from <i>get items</i> results).
|
|
81
89
|
<code>msg.itemIds</code> takes precedence if present.</dd>
|
|
82
|
-
<
|
|
90
|
+
<dt class="optional">credentials <span class="property-type">string | object</span></dt><dd>Used only when the server config has no API key stored.
|
|
91
|
+
Read from the <i>Credentials</i> property (default <code>msg.credentials</code>; msg, flow, global or env): a bare key string, or
|
|
92
|
+
<code>{"apiKey":"...", "authType":"authorization"|"header", "authScheme":"...", "headerName":"X-API-Key"}</code>.
|
|
93
|
+
Missing fields fall back to the server config. A msg-borne credentials property is removed from the trigger msg.</dd>
|
|
94
|
+
<dd>Credentials are read once per trigger and kept for that subscription. A new trigger replaces the running subscription. <code>payload</code> = <code>"stop"</code> or <code>msg.stop = true</code> stops it.</dd>
|
|
83
95
|
</dl>
|
|
84
96
|
<h3>Outputs</h3>
|
|
85
97
|
<ol class="node-ports">
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
const { OUT_RESPONSE, send, setStatus, reportError, storeName, nowIso } = require('../lib/outputs');
|
|
3
|
+
const { configureCreds, resolveCreds, stripCreds } = require('../lib/credentials');
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Item status subscription (REST Server v8.30+, RESTStatus licence).
|
|
@@ -50,6 +51,7 @@ module.exports = function (RED) {
|
|
|
50
51
|
node.emitTimeouts = config.emitTimeouts === true;
|
|
51
52
|
node.contextKey = (config.contextKey || '').trim();
|
|
52
53
|
node.contextStore = config.contextStore || '';
|
|
54
|
+
configureCreds(node, config);
|
|
53
55
|
|
|
54
56
|
let run = null; // { ctrl, ids }
|
|
55
57
|
|
|
@@ -87,10 +89,11 @@ module.exports = function (RED) {
|
|
|
87
89
|
});
|
|
88
90
|
}
|
|
89
91
|
|
|
90
|
-
async function loop(ids, ctrl) {
|
|
92
|
+
async function loop(ids, ctrl, cred) {
|
|
91
93
|
const signal = ctrl.signal;
|
|
92
94
|
let client;
|
|
93
|
-
|
|
95
|
+
// credentials are taken once, from the trigger; the subscription keeps using them
|
|
96
|
+
try { client = node.server.getClient(cred); } catch (e) { reportError(node, e); return; }
|
|
94
97
|
|
|
95
98
|
let next = null;
|
|
96
99
|
let failures = 0;
|
|
@@ -161,13 +164,23 @@ module.exports = function (RED) {
|
|
|
161
164
|
if (!node.server) setStatus(node, 'error', 'no server config');
|
|
162
165
|
else node.status({ fill: 'grey', shape: 'ring', text: 'idle' });
|
|
163
166
|
|
|
164
|
-
node.on('input', function (msg, sendFn, done) {
|
|
167
|
+
node.on('input', async function (msg, sendFn, done) {
|
|
165
168
|
done = done || function () {};
|
|
166
169
|
if (isStop(msg)) {
|
|
170
|
+
stripCreds(RED, node, msg);
|
|
167
171
|
if (run) stop('stopped by input');
|
|
168
172
|
else setStatus(node, 'stopped', 'not running');
|
|
169
173
|
return done();
|
|
170
174
|
}
|
|
175
|
+
let cred;
|
|
176
|
+
try {
|
|
177
|
+
cred = await resolveCreds(RED, node, msg);
|
|
178
|
+
} catch (e) {
|
|
179
|
+
reportError(node, e);
|
|
180
|
+
return done();
|
|
181
|
+
} finally {
|
|
182
|
+
stripCreds(RED, node, msg);
|
|
183
|
+
}
|
|
171
184
|
if (!node.server) {
|
|
172
185
|
reportError(node, new Error('No REST server configured'));
|
|
173
186
|
return done();
|
|
@@ -180,7 +193,7 @@ module.exports = function (RED) {
|
|
|
180
193
|
if (run) stop('replaced by new subscription');
|
|
181
194
|
const ctrl = new AbortController();
|
|
182
195
|
run = { ctrl, ids };
|
|
183
|
-
loop(ids, ctrl).catch((e) => reportError(node, e)).finally(() => {
|
|
196
|
+
loop(ids, ctrl, cred).catch((e) => reportError(node, e)).finally(() => {
|
|
184
197
|
if (run && run.ctrl === ctrl) run = null;
|
|
185
198
|
});
|
|
186
199
|
done(); // the trigger message is handled; the subscription carries on by itself
|
package/nodes/rest-lists.html
CHANGED
|
@@ -15,7 +15,9 @@
|
|
|
15
15
|
fields: { value: '' },
|
|
16
16
|
query: { value: '' },
|
|
17
17
|
maxPages: { value: 0, validate: RED.validators.number() },
|
|
18
|
-
includePages: { value: true }
|
|
18
|
+
includePages: { value: true },
|
|
19
|
+
creds: { value: 'credentials' },
|
|
20
|
+
credsType: { value: 'msg' }
|
|
19
21
|
},
|
|
20
22
|
inputs: 1,
|
|
21
23
|
outputs: 3,
|
|
@@ -24,6 +26,7 @@
|
|
|
24
26
|
label: function () { return this.name || ('get ' + d.palette); },
|
|
25
27
|
paletteLabel: 'get ' + d.palette,
|
|
26
28
|
oneditprepare: function () {
|
|
29
|
+
$('#node-input-creds').typedInput({ default: 'msg', types: ['msg', 'flow', 'global', 'env'], typeField: '#node-input-credsType' });
|
|
27
30
|
var sel = $('#node-input-contextStore');
|
|
28
31
|
var stores = (RED.settings.context && RED.settings.context.stores) || ['default'];
|
|
29
32
|
sel.empty();
|
|
@@ -45,6 +48,11 @@
|
|
|
45
48
|
<label for="node-input-server"><i class="fa fa-server"></i> Server</label>
|
|
46
49
|
<input type="text" id="node-input-server">
|
|
47
50
|
</div>
|
|
51
|
+
<div class="form-row">
|
|
52
|
+
<label for="node-input-creds"><i class="fa fa-key"></i> Credentials</label>
|
|
53
|
+
<input type="text" id="node-input-creds" style="width:70%">
|
|
54
|
+
<input type="hidden" id="node-input-credsType">
|
|
55
|
+
</div>
|
|
48
56
|
<div class="form-row">
|
|
49
57
|
<label for="node-input-contextKey"><i class="fa fa-globe"></i> Global key</label>
|
|
50
58
|
<input type="text" id="node-input-contextKey" placeholder="rest_accessGroups">
|
|
@@ -94,6 +102,10 @@
|
|
|
94
102
|
<dt class="optional">fields <span class="property-type">string</span></dt><dd>Overrides the <i>Fields</i> setting.</dd>
|
|
95
103
|
<dt class="optional">top / maxPages <span class="property-type">number</span></dt><dd>Override page size / page limit.</dd>
|
|
96
104
|
<dt class="optional">contextKey <span class="property-type">string</span></dt><dd>Overrides the global key.</dd>
|
|
105
|
+
<dt class="optional">credentials <span class="property-type">string | object</span></dt><dd>Used only when the server config has no API key stored.
|
|
106
|
+
Read from the <i>Credentials</i> property (default <code>msg.credentials</code>; msg, flow, global or env): a bare key string, or
|
|
107
|
+
<code>{"apiKey":"...", "authType":"authorization"|"header", "authScheme":"...", "headerName":"X-API-Key"}</code>.
|
|
108
|
+
Missing fields fall back to the server config. A msg-borne credentials property is removed before the msg is passed on.</dd>
|
|
97
109
|
</dl>
|
|
98
110
|
<h3>Outputs</h3>
|
|
99
111
|
<ol class="node-ports">
|
|
@@ -122,6 +134,11 @@
|
|
|
122
134
|
<label for="node-input-server"><i class="fa fa-server"></i> Server</label>
|
|
123
135
|
<input type="text" id="node-input-server">
|
|
124
136
|
</div>
|
|
137
|
+
<div class="form-row">
|
|
138
|
+
<label for="node-input-creds"><i class="fa fa-key"></i> Credentials</label>
|
|
139
|
+
<input type="text" id="node-input-creds" style="width:70%">
|
|
140
|
+
<input type="hidden" id="node-input-credsType">
|
|
141
|
+
</div>
|
|
125
142
|
<div class="form-row">
|
|
126
143
|
<label for="node-input-contextKey"><i class="fa fa-globe"></i> Global key</label>
|
|
127
144
|
<input type="text" id="node-input-contextKey" placeholder="rest_cardholders">
|
|
@@ -171,6 +188,10 @@
|
|
|
171
188
|
<dt class="optional">fields <span class="property-type">string</span></dt><dd>Overrides the <i>Fields</i> setting.</dd>
|
|
172
189
|
<dt class="optional">top / maxPages <span class="property-type">number</span></dt><dd>Override page size / page limit.</dd>
|
|
173
190
|
<dt class="optional">contextKey <span class="property-type">string</span></dt><dd>Overrides the global key.</dd>
|
|
191
|
+
<dt class="optional">credentials <span class="property-type">string | object</span></dt><dd>Used only when the server config has no API key stored.
|
|
192
|
+
Read from the <i>Credentials</i> property (default <code>msg.credentials</code>; msg, flow, global or env): a bare key string, or
|
|
193
|
+
<code>{"apiKey":"...", "authType":"authorization"|"header", "authScheme":"...", "headerName":"X-API-Key"}</code>.
|
|
194
|
+
Missing fields fall back to the server config. A msg-borne credentials property is removed before the msg is passed on.</dd>
|
|
174
195
|
</dl>
|
|
175
196
|
<h3>Outputs</h3>
|
|
176
197
|
<ol class="node-ports">
|
|
@@ -199,6 +220,11 @@
|
|
|
199
220
|
<label for="node-input-server"><i class="fa fa-server"></i> Server</label>
|
|
200
221
|
<input type="text" id="node-input-server">
|
|
201
222
|
</div>
|
|
223
|
+
<div class="form-row">
|
|
224
|
+
<label for="node-input-creds"><i class="fa fa-key"></i> Credentials</label>
|
|
225
|
+
<input type="text" id="node-input-creds" style="width:70%">
|
|
226
|
+
<input type="hidden" id="node-input-credsType">
|
|
227
|
+
</div>
|
|
202
228
|
<div class="form-row">
|
|
203
229
|
<label for="node-input-contextKey"><i class="fa fa-globe"></i> Global key</label>
|
|
204
230
|
<input type="text" id="node-input-contextKey" placeholder="rest_items">
|
|
@@ -248,6 +274,10 @@
|
|
|
248
274
|
<dt class="optional">fields <span class="property-type">string</span></dt><dd>Overrides the <i>Fields</i> setting.</dd>
|
|
249
275
|
<dt class="optional">top / maxPages <span class="property-type">number</span></dt><dd>Override page size / page limit.</dd>
|
|
250
276
|
<dt class="optional">contextKey <span class="property-type">string</span></dt><dd>Overrides the global key.</dd>
|
|
277
|
+
<dt class="optional">credentials <span class="property-type">string | object</span></dt><dd>Used only when the server config has no API key stored.
|
|
278
|
+
Read from the <i>Credentials</i> property (default <code>msg.credentials</code>; msg, flow, global or env): a bare key string, or
|
|
279
|
+
<code>{"apiKey":"...", "authType":"authorization"|"header", "authScheme":"...", "headerName":"X-API-Key"}</code>.
|
|
280
|
+
Missing fields fall back to the server config. A msg-borne credentials property is removed before the msg is passed on.</dd>
|
|
251
281
|
</dl>
|
|
252
282
|
<h3>Outputs</h3>
|
|
253
283
|
<ol class="node-ports">
|
package/nodes/rest-lists.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
const { RestClient } = require('../lib/client');
|
|
3
3
|
const { OUT_RESPONSE, send, setStatus, reportError, storeName, nowIso } = require('../lib/outputs');
|
|
4
|
+
const { configureCreds, resolveCreds, stripCreds } = require('../lib/credentials');
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Request/reply "list" nodes. Each trigger:
|
|
@@ -28,6 +29,7 @@ module.exports = function (RED) {
|
|
|
28
29
|
node.query = (config.query || '').trim();
|
|
29
30
|
node.maxPages = parseInt(config.maxPages, 10) || 0;
|
|
30
31
|
node.includePages = config.includePages !== false;
|
|
32
|
+
configureCreds(node, config);
|
|
31
33
|
|
|
32
34
|
let running = null; // AbortController of in-flight run
|
|
33
35
|
|
|
@@ -48,7 +50,13 @@ module.exports = function (RED) {
|
|
|
48
50
|
running = ctrl;
|
|
49
51
|
const started = Date.now();
|
|
50
52
|
try {
|
|
51
|
-
|
|
53
|
+
let cred;
|
|
54
|
+
try {
|
|
55
|
+
cred = await resolveCreds(RED, node, msg);
|
|
56
|
+
} finally {
|
|
57
|
+
stripCreds(RED, node, msg); // never pass a key downstream, even on error
|
|
58
|
+
}
|
|
59
|
+
const client = node.server.getClient(cred);
|
|
52
60
|
setStatus(node, 'requesting', `requesting ${def.label}`);
|
|
53
61
|
const firstHref = await client.feature(def.feature, { signal: ctrl.signal });
|
|
54
62
|
|
package/nodes/rest-server.html
CHANGED
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
name: { value: '' },
|
|
6
6
|
url: { value: '', required: true },
|
|
7
7
|
port: { value: 8904, required: true, validate: RED.validators.number() },
|
|
8
|
+
authType: { value: 'authorization' },
|
|
8
9
|
authScheme: { value: '', validate: function (v) { return !v || /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(String(v).trim()); } },
|
|
10
|
+
headerName: { value: 'X-API-Key', validate: function (v) { return !v || /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(String(v).trim()); } },
|
|
9
11
|
fingerprint: {
|
|
10
12
|
value: '',
|
|
11
13
|
validate: function (v) {
|
|
@@ -22,11 +24,21 @@
|
|
|
22
24
|
pfxPath: { value: '' }
|
|
23
25
|
},
|
|
24
26
|
credentials: {
|
|
25
|
-
apiKey: { type: 'password'
|
|
27
|
+
apiKey: { type: 'password' },
|
|
26
28
|
passphrase: { type: 'password' }
|
|
27
29
|
},
|
|
28
30
|
label: function () {
|
|
29
31
|
return this.name || ((this.url || 'rest-server') + ':' + (this.port || 8904));
|
|
32
|
+
},
|
|
33
|
+
oneditprepare: function () {
|
|
34
|
+
var sync = function () {
|
|
35
|
+
var header = $('#node-config-input-authType').val() === 'header';
|
|
36
|
+
$('.tdn-rest-auth-scheme').toggle(!header);
|
|
37
|
+
$('.tdn-rest-auth-header').toggle(header);
|
|
38
|
+
};
|
|
39
|
+
if (!this.authType) $('#node-config-input-authType').val('authorization');
|
|
40
|
+
$('#node-config-input-authType').on('change', sync);
|
|
41
|
+
sync();
|
|
30
42
|
}
|
|
31
43
|
});
|
|
32
44
|
</script>
|
|
@@ -46,12 +58,23 @@
|
|
|
46
58
|
</div>
|
|
47
59
|
<div class="form-row">
|
|
48
60
|
<label for="node-config-input-apiKey"><i class="fa fa-key"></i> API key</label>
|
|
49
|
-
<input type="password" id="node-config-input-apiKey" placeholder="
|
|
61
|
+
<input type="password" id="node-config-input-apiKey" placeholder="blank = supplied per node (credentials object)">
|
|
50
62
|
</div>
|
|
51
63
|
<div class="form-row">
|
|
52
|
-
<label for="node-config-input-
|
|
64
|
+
<label for="node-config-input-authType"><i class="fa fa-lock"></i> Send key as</label>
|
|
65
|
+
<select id="node-config-input-authType" style="width:70%">
|
|
66
|
+
<option value="authorization">Authorization header</option>
|
|
67
|
+
<option value="header">API key header</option>
|
|
68
|
+
</select>
|
|
69
|
+
</div>
|
|
70
|
+
<div class="form-row tdn-rest-auth-scheme">
|
|
71
|
+
<label for="node-config-input-authScheme">Auth scheme</label>
|
|
53
72
|
<input type="text" id="node-config-input-authScheme" placeholder="blank = Basic (server v9.0+)">
|
|
54
73
|
</div>
|
|
74
|
+
<div class="form-row tdn-rest-auth-header">
|
|
75
|
+
<label for="node-config-input-headerName">Header name</label>
|
|
76
|
+
<input type="text" id="node-config-input-headerName" placeholder="X-API-Key">
|
|
77
|
+
</div>
|
|
55
78
|
<div class="form-row">
|
|
56
79
|
<label for="node-config-input-fingerprint"><i class="fa fa-certificate"></i> Cert fingerprint</label>
|
|
57
80
|
<input type="text" id="node-config-input-fingerprint" placeholder="optional SHA-256 or SHA-1 of server cert">
|
|
@@ -99,11 +122,17 @@
|
|
|
99
122
|
<dt>Port <span class="property-type">number</span></dt>
|
|
100
123
|
<dd>REST port, default <code>8904</code>.</dd>
|
|
101
124
|
<dt>API key <span class="property-type">credential</span></dt>
|
|
102
|
-
<dd>Stored as a Node-RED credential and treated as opaque
|
|
125
|
+
<dd>Optional. Stored as a Node-RED credential and treated as opaque. When set, it is always used.
|
|
126
|
+
When blank, each node takes the key from its <i>Credentials</i> object (default <code>msg.credentials</code>);
|
|
127
|
+
see the node's help.</dd>
|
|
128
|
+
<dt>Send key as</dt>
|
|
129
|
+
<dd><b>Authorization header</b> (default) or <b>API key header</b>. A credentials object can override this per message.</dd>
|
|
103
130
|
<dt class="optional">Auth scheme <span class="property-type">string</span></dt>
|
|
104
|
-
<dd>Blank (default):
|
|
131
|
+
<dd>Authorization header only. Blank (default): <code>Authorization: Basic base64(":" + key)</code>, accepted by server v9.0+.
|
|
105
132
|
For older servers, enter the API-key scheme token from your server's REST API documentation;
|
|
106
133
|
the key is then sent as <code>Authorization: <scheme> <key></code>.</dd>
|
|
134
|
+
<dt class="optional">Header name <span class="property-type">string</span></dt>
|
|
135
|
+
<dd>API key header only. The key is sent as <code><header name>: <key></code>; default <code>X-API-Key</code>.</dd>
|
|
107
136
|
<dt class="optional">Cert fingerprint <span class="property-type">string</span></dt>
|
|
108
137
|
<dd>SHA-256 (64 hex) or SHA-1 (40 hex) of the <b>server</b> certificate, colons optional.
|
|
109
138
|
When set, the connection is accepted only if the presented certificate matches, even if it is self-signed.
|
package/nodes/rest-server.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
'use strict';
|
|
2
|
-
const
|
|
2
|
+
const crypto = require('crypto');
|
|
3
|
+
const { RestClient, RestError, normaliseCredentials } = require('../lib/client');
|
|
4
|
+
|
|
5
|
+
const MAX_CRED_CLIENTS = 20; // per-credential clients kept (each holds its own discovery cache)
|
|
3
6
|
|
|
4
7
|
module.exports = function (RED) {
|
|
5
8
|
function RestServerNode(config) {
|
|
@@ -8,7 +11,9 @@ module.exports = function (RED) {
|
|
|
8
11
|
this.url = config.url;
|
|
9
12
|
this.port = config.port;
|
|
10
13
|
this.fingerprint = config.fingerprint;
|
|
14
|
+
this.authType = config.authType || 'authorization';
|
|
11
15
|
this.authScheme = (config.authScheme || '').trim();
|
|
16
|
+
this.headerName = (config.headerName || '').trim();
|
|
12
17
|
this.rejectUnauthorized = config.rejectUnauthorized !== false;
|
|
13
18
|
this.rewriteHrefs = config.rewriteHrefs !== false;
|
|
14
19
|
this.timeout = parseInt(config.timeout, 10) || 30000;
|
|
@@ -17,6 +22,7 @@ module.exports = function (RED) {
|
|
|
17
22
|
this.pfxPath = config.pfxPath;
|
|
18
23
|
|
|
19
24
|
const creds = this.credentials || {};
|
|
25
|
+
this.hasStoredKey = !!String(creds.apiKey || '').trim();
|
|
20
26
|
this.client = null;
|
|
21
27
|
this.clientError = null;
|
|
22
28
|
try {
|
|
@@ -24,7 +30,9 @@ module.exports = function (RED) {
|
|
|
24
30
|
url: this.url,
|
|
25
31
|
port: this.port,
|
|
26
32
|
apiKey: creds.apiKey,
|
|
33
|
+
authType: this.authType,
|
|
27
34
|
authScheme: this.authScheme,
|
|
35
|
+
headerName: this.headerName,
|
|
28
36
|
fingerprint: this.fingerprint,
|
|
29
37
|
rejectUnauthorized: this.rejectUnauthorized,
|
|
30
38
|
rewriteHrefs: this.rewriteHrefs,
|
|
@@ -34,18 +42,38 @@ module.exports = function (RED) {
|
|
|
34
42
|
pfxPath: this.pfxPath,
|
|
35
43
|
passphrase: creds.passphrase
|
|
36
44
|
});
|
|
37
|
-
if (!creds.apiKey) this.clientError = new Error('No API key configured');
|
|
38
45
|
if (this.client.base.protocol === 'http:') this.warn('REST server URL is http:// - the API key will be sent unencrypted');
|
|
39
46
|
} catch (e) {
|
|
40
47
|
this.clientError = e;
|
|
41
48
|
this.error('REST server config: ' + e.message);
|
|
42
49
|
}
|
|
43
50
|
|
|
44
|
-
|
|
45
|
-
|
|
51
|
+
const credClients = new Map(); // sha256(normalised cred) -> client
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Returns a client or throws.
|
|
55
|
+
* - API key stored in the Node-RED credentials: always used; `cred` is ignored.
|
|
56
|
+
* - Otherwise `cred` (bare key string or { apiKey, authType, authScheme, headerName })
|
|
57
|
+
* supplies the key; missing auth fields come from this config node.
|
|
58
|
+
*/
|
|
59
|
+
this.getClient = (cred) => {
|
|
46
60
|
if (this.clientError) throw this.clientError;
|
|
47
|
-
return this.client;
|
|
61
|
+
if (this.hasStoredKey) return this.client;
|
|
62
|
+
if (cred === undefined || cred === null || cred === '') {
|
|
63
|
+
throw new RestError('No API key: none stored in the server config and no credentials object supplied', { code: 'ENOCREDS' });
|
|
64
|
+
}
|
|
65
|
+
const norm = normaliseCredentials(cred, this.client.authDefaults);
|
|
66
|
+
const id = crypto.createHash('sha256').update(JSON.stringify(norm)).digest('hex');
|
|
67
|
+
let c = credClients.get(id);
|
|
68
|
+
if (!c) {
|
|
69
|
+
c = this.client.withCredentials(norm);
|
|
70
|
+
if (credClients.size >= MAX_CRED_CLIENTS) credClients.delete(credClients.keys().next().value);
|
|
71
|
+
credClients.set(id, c);
|
|
72
|
+
}
|
|
73
|
+
return c;
|
|
48
74
|
};
|
|
75
|
+
|
|
76
|
+
this.on('close', () => credClients.clear());
|
|
49
77
|
}
|
|
50
78
|
|
|
51
79
|
RED.nodes.registerType('tdn-rest-server', RestServerNode, {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "node-red-contrib-tdn-eventsub",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Node-RED nodes for an access-control REST Server API: access groups, cardholders and items lists to global context, plus continuous item status subscriptions.",
|
|
5
5
|
"main": "lib/client.js",
|
|
6
6
|
"scripts": {
|