frugal-iot-server 2.0.6 → 2.0.8
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/GRAFANA.md +114 -0
- package/INSTALL-OPENSEARCH.md +415 -0
- package/INSTALLATION.md +3 -3
- package/README.md +1 -1
- package/config.d/opensearch.yaml +23 -0
- package/extras/grafana/frugaliot-readings.json +166 -0
- package/frugal-iot-createdb.sql +38 -0
- package/frugal-iot-server.js +47 -5
- package/lib/admin-guards.js +26 -0
- package/lib/config-for-user.js +10 -0
- package/lib/destinations.js +121 -0
- package/lib/forwarders.js +312 -0
- package/lib/grafana.js +420 -0
- package/lib/opensearch-access.js +245 -0
- package/lib/schema-columns.js +1 -0
- package/package.json +16 -9
- package/public/service-worker.js +1 -1
- package/scripts/backfill-opensearch.js +66 -0
- package/scripts/grafana-token.js +62 -0
- package/scripts/install-pi.sh +4 -3
- package/public/index-embedded.html +0 -54
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
{
|
|
2
|
+
"__inputs": [
|
|
3
|
+
{
|
|
4
|
+
"name": "DS_FRUGAL_IOT",
|
|
5
|
+
"label": "Frugal IoT data source",
|
|
6
|
+
"description": "The Infinity data source holding this organization's token",
|
|
7
|
+
"type": "datasource",
|
|
8
|
+
"pluginId": "yesoreyeram-infinity-datasource",
|
|
9
|
+
"pluginName": "Infinity"
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"name": "VAR_SERVER",
|
|
13
|
+
"type": "constant",
|
|
14
|
+
"label": "Server URL",
|
|
15
|
+
"description": "Where the Frugal IoT server is, no trailing slash",
|
|
16
|
+
"value": "https://frugaliot.naturalinnovation.org"
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"name": "VAR_ORG",
|
|
20
|
+
"type": "constant",
|
|
21
|
+
"label": "Organization",
|
|
22
|
+
"description": "The organization the token is for",
|
|
23
|
+
"value": "dev"
|
|
24
|
+
}
|
|
25
|
+
],
|
|
26
|
+
"__requires": [
|
|
27
|
+
{
|
|
28
|
+
"type": "datasource",
|
|
29
|
+
"id": "yesoreyeram-infinity-datasource",
|
|
30
|
+
"name": "Infinity",
|
|
31
|
+
"version": "4.0.0"
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"type": "panel",
|
|
35
|
+
"id": "timeseries",
|
|
36
|
+
"name": "Time series",
|
|
37
|
+
"version": ""
|
|
38
|
+
}
|
|
39
|
+
],
|
|
40
|
+
"title": "Frugal IoT readings",
|
|
41
|
+
"uid": "frugaliot-readings",
|
|
42
|
+
"tags": [
|
|
43
|
+
"frugal-iot"
|
|
44
|
+
],
|
|
45
|
+
"schemaVersion": 39,
|
|
46
|
+
"time": {
|
|
47
|
+
"from": "now-7d",
|
|
48
|
+
"to": "now"
|
|
49
|
+
},
|
|
50
|
+
"templating": {
|
|
51
|
+
"list": [
|
|
52
|
+
{
|
|
53
|
+
"name": "server",
|
|
54
|
+
"type": "constant",
|
|
55
|
+
"query": "${VAR_SERVER}",
|
|
56
|
+
"hide": 2,
|
|
57
|
+
"skipUrlSync": true
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
"name": "org",
|
|
61
|
+
"type": "constant",
|
|
62
|
+
"query": "${VAR_ORG}",
|
|
63
|
+
"hide": 2,
|
|
64
|
+
"skipUrlSync": true
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
"name": "topic",
|
|
68
|
+
"type": "query",
|
|
69
|
+
"label": "Topic",
|
|
70
|
+
"datasource": {
|
|
71
|
+
"type": "yesoreyeram-infinity-datasource",
|
|
72
|
+
"uid": "${DS_FRUGAL_IOT}"
|
|
73
|
+
},
|
|
74
|
+
"description": "Every topic this organization has readings for",
|
|
75
|
+
"query": {
|
|
76
|
+
"queryType": "infinity",
|
|
77
|
+
"refId": "variable",
|
|
78
|
+
"query": "",
|
|
79
|
+
"infinityQuery": {
|
|
80
|
+
"refId": "variable",
|
|
81
|
+
"type": "json",
|
|
82
|
+
"source": "url",
|
|
83
|
+
"format": "table",
|
|
84
|
+
"parser": "backend",
|
|
85
|
+
"url": "${server}/grafana/${org}/topics",
|
|
86
|
+
"url_options": {
|
|
87
|
+
"method": "GET"
|
|
88
|
+
},
|
|
89
|
+
"root_selector": "",
|
|
90
|
+
"columns": [
|
|
91
|
+
{
|
|
92
|
+
"selector": "topic",
|
|
93
|
+
"text": "topic",
|
|
94
|
+
"type": "string"
|
|
95
|
+
}
|
|
96
|
+
]
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
"refresh": 1,
|
|
100
|
+
"sort": 1,
|
|
101
|
+
"includeAll": false,
|
|
102
|
+
"multi": false
|
|
103
|
+
}
|
|
104
|
+
]
|
|
105
|
+
},
|
|
106
|
+
"panels": [
|
|
107
|
+
{
|
|
108
|
+
"id": 1,
|
|
109
|
+
"type": "timeseries",
|
|
110
|
+
"title": "$topic",
|
|
111
|
+
"datasource": {
|
|
112
|
+
"type": "yesoreyeram-infinity-datasource",
|
|
113
|
+
"uid": "${DS_FRUGAL_IOT}"
|
|
114
|
+
},
|
|
115
|
+
"gridPos": {
|
|
116
|
+
"h": 14,
|
|
117
|
+
"w": 24,
|
|
118
|
+
"x": 0,
|
|
119
|
+
"y": 0
|
|
120
|
+
},
|
|
121
|
+
"options": {
|
|
122
|
+
"legend": {
|
|
123
|
+
"showLegend": false
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
"fieldConfig": {
|
|
127
|
+
"defaults": {
|
|
128
|
+
"custom": {
|
|
129
|
+
"spanNulls": true
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
"overrides": []
|
|
133
|
+
},
|
|
134
|
+
"targets": [
|
|
135
|
+
{
|
|
136
|
+
"refId": "A",
|
|
137
|
+
"datasource": {
|
|
138
|
+
"type": "yesoreyeram-infinity-datasource",
|
|
139
|
+
"uid": "${DS_FRUGAL_IOT}"
|
|
140
|
+
},
|
|
141
|
+
"type": "json",
|
|
142
|
+
"source": "url",
|
|
143
|
+
"format": "timeseries",
|
|
144
|
+
"parser": "backend",
|
|
145
|
+
"url": "${server}/grafana/${org}/data?topic=${topic}&from=${__from}&to=${__to}&maxPoints=1000",
|
|
146
|
+
"url_options": {
|
|
147
|
+
"method": "GET"
|
|
148
|
+
},
|
|
149
|
+
"root_selector": "",
|
|
150
|
+
"columns": [
|
|
151
|
+
{
|
|
152
|
+
"selector": "time",
|
|
153
|
+
"text": "time",
|
|
154
|
+
"type": "timestamp"
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
"selector": "value",
|
|
158
|
+
"text": "value",
|
|
159
|
+
"type": "number"
|
|
160
|
+
}
|
|
161
|
+
]
|
|
162
|
+
}
|
|
163
|
+
]
|
|
164
|
+
}
|
|
165
|
+
]
|
|
166
|
+
}
|
package/frugal-iot-createdb.sql
CHANGED
|
@@ -88,6 +88,44 @@ CREATE TABLE IF NOT EXISTS `bridges` (
|
|
|
88
88
|
UNIQUE(`org`, `site`)
|
|
89
89
|
);
|
|
90
90
|
|
|
91
|
+
-- Where an organization's readings are forwarded to (OpenSearch, Google Sheets), added from the
|
|
92
|
+
-- dashboard's admin tab - see lib/forwarders.js. They are in addition to any "forwarders:" in the
|
|
93
|
+
-- organization's YAML file, which stays as it is and is shown there read-only.
|
|
94
|
+
--
|
|
95
|
+
-- `config` is JSON in the same form as a YAML entry, minus "type". It includes passwords and API
|
|
96
|
+
-- keys, in the clear, as the YAML file does: this database is not served and is backed up with the
|
|
97
|
+
-- rest of the server, and the dashboard is only ever told THAT a secret is set, never its value.
|
|
98
|
+
CREATE TABLE IF NOT EXISTS `forwarders` (
|
|
99
|
+
`id` INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
100
|
+
`org` TEXT NOT NULL,
|
|
101
|
+
`type` TEXT NOT NULL,
|
|
102
|
+
`name` TEXT NOT NULL,
|
|
103
|
+
`enabled` INTEGER NOT NULL DEFAULT 1,
|
|
104
|
+
`config` TEXT NOT NULL,
|
|
105
|
+
`created_at` INTEGER NOT NULL,
|
|
106
|
+
`updated_at` INTEGER NOT NULL
|
|
107
|
+
);
|
|
108
|
+
CREATE INDEX IF NOT EXISTS `forwarders_org` ON `forwarders` (`org`);
|
|
109
|
+
|
|
110
|
+
-- A credential for Grafana (or anything else that reads history over HTTP), see lib/grafana.js and
|
|
111
|
+
-- GRAFANA.md. Read-only, and scoped to one organization: it can fetch that organization's readings
|
|
112
|
+
-- through /grafana/:org/ and nothing else.
|
|
113
|
+
--
|
|
114
|
+
-- Only a SHA-256 of the token is kept, so a copy of this database does not hand out working
|
|
115
|
+
-- credentials - the token itself is shown once, when it is issued. It is 32 random bytes, which is
|
|
116
|
+
-- why a plain hash is enough and no salt or slow hash is needed. One token per name, so one
|
|
117
|
+
-- Grafana can be revoked without disturbing another; issuing a name again replaces its token.
|
|
118
|
+
CREATE TABLE IF NOT EXISTS `grafana_tokens` (
|
|
119
|
+
`org` TEXT NOT NULL,
|
|
120
|
+
`name` TEXT NOT NULL,
|
|
121
|
+
`token_hash` TEXT NOT NULL,
|
|
122
|
+
`created_at` INTEGER NOT NULL,
|
|
123
|
+
`last_used` INTEGER, -- Approximate - only rewritten if more than an hour old, to spare the SD card
|
|
124
|
+
`user_id` INTEGER, -- whose permissions the token acts with; NULL (from before this) is not honoured
|
|
125
|
+
UNIQUE(`org`, `name`)
|
|
126
|
+
);
|
|
127
|
+
CREATE INDEX IF NOT EXISTS `grafana_tokens_hash` ON `grafana_tokens` (`token_hash`);
|
|
128
|
+
|
|
91
129
|
-- An admin's decision about one node, for the Enrolled column on the dashboard's Nodes card
|
|
92
130
|
-- (SECURITY.md S12). Two states are worth storing:
|
|
93
131
|
--
|
package/frugal-iot-server.js
CHANGED
|
@@ -107,6 +107,9 @@ import { MqttLogger } from "frugal-iot-logger"; // https://github.com/mitra42/f
|
|
|
107
107
|
|
|
108
108
|
// API Integration - Farm IoT Interoperability Standard
|
|
109
109
|
import { createAPIRouter, createAPIErrorHandler } from './lib/api-routes.js';
|
|
110
|
+
import { createGrafanaRouters } from './lib/grafana.js';
|
|
111
|
+
import { createForwardersRouter, enabledConfigs } from './lib/forwarders.js';
|
|
112
|
+
import { createAccessRouter } from './lib/opensearch-access.js';
|
|
110
113
|
import { buildConfigFor, hasPermissions } from './lib/config-for-user.js';
|
|
111
114
|
import { ensureSecrets, addEnrolmentSecret, removeEnrolmentSecret } from './lib/secrets.js';
|
|
112
115
|
import { syncUser, syncUserById, syncLoggers, syncNode, dropNode, syncBridge } from './lib/dynsec-server.js';
|
|
@@ -665,6 +668,15 @@ function can_OTAUPDATE(req, res, next) {
|
|
|
665
668
|
res.sendStatus(401); // Just fail - shouldnt happen and anyway lost the file by now
|
|
666
669
|
}
|
|
667
670
|
}
|
|
671
|
+
// Listing and downloading firmware is for anyone who can push it or flash it; deleting stays OTAUPDATE.
|
|
672
|
+
function can_OTAUPDATE_or_OTAFLASH(req, res, next) {
|
|
673
|
+
if (req.isAuthenticated() && (hasPermissions(req.user, req.params.org, "OTAUPDATE")
|
|
674
|
+
|| hasPermissions(req.user, req.params.org, "OTAFLASH"))) {
|
|
675
|
+
next();
|
|
676
|
+
} else {
|
|
677
|
+
res.sendStatus(401);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
668
680
|
// Exported for reuse by lib/api-routes.js - reads org from req.params.org or res.locals.org, so callers must set one of those first.
|
|
669
681
|
export function can_READ(req, res, next) {
|
|
670
682
|
const org = req.params.org || res.locals.org;
|
|
@@ -699,6 +711,15 @@ export function can_ADMIN(req, res, next) {
|
|
|
699
711
|
res.sendStatus(401);
|
|
700
712
|
}
|
|
701
713
|
}
|
|
714
|
+
// Whoever can flash a board can enrol it, so reading the enrolment secret needs no more than OTAFLASH.
|
|
715
|
+
function can_ADMIN_or_OTAFLASH(req, res, next) {
|
|
716
|
+
if (req.isAuthenticated() && (hasPermissions(req.user, req.params.org, "ADMIN")
|
|
717
|
+
|| hasPermissions(req.user, req.params.org, "OTAFLASH"))) {
|
|
718
|
+
next();
|
|
719
|
+
} else {
|
|
720
|
+
res.sendStatus(401);
|
|
721
|
+
}
|
|
722
|
+
}
|
|
702
723
|
// Exported for reuse by lib/api-routes.js - for resources (like api_platforms) that aren't scoped to a
|
|
703
724
|
// single org, valid if the user has ADMIN on ANY org, rather than a specific one.
|
|
704
725
|
export function can_ADMIN_SOME(req, res, next) {
|
|
@@ -1132,7 +1153,13 @@ mqttLogger.readYamlConfig('.', (err, configobj) => {
|
|
|
1132
1153
|
secret: config.secrets.session_secret,
|
|
1133
1154
|
resave: false,
|
|
1134
1155
|
saveUninitialized: false,
|
|
1135
|
-
|
|
1156
|
+
// "lax": the browser does not send the session cookie on a request made by a page on ANOTHER site
|
|
1157
|
+
// (a form posted from there, a hidden image, a frame), only when the user themselves navigates
|
|
1158
|
+
// here, e.g. by following a link. That is what stops another site acting as a logged-in
|
|
1159
|
+
// administrator. Not "strict", which would also drop it on a link from an email or chat, so
|
|
1160
|
+
// the first click on one would arrive logged out. Chrome and Edge already treat a cookie with
|
|
1161
|
+
// no SameSite as lax; this makes it so for every browser.
|
|
1162
|
+
cookie: { secure: 'auto', sameSite: 'lax' } // TODO-N89 cant be secure: true while testing on HTTP
|
|
1136
1163
|
}));
|
|
1137
1164
|
// This defines he function that will be used to turn user data returned from database into object for the session
|
|
1138
1165
|
passport.serializeUser(function(user, cb) {
|
|
@@ -1214,6 +1241,16 @@ mqttLogger.readYamlConfig('.', (err, configobj) => {
|
|
|
1214
1241
|
app.use('/api', apiRouter);
|
|
1215
1242
|
console.log("Mounted API routes at /api");
|
|
1216
1243
|
|
|
1244
|
+
// History for Grafana, by bearer token - see lib/grafana.js and GRAFANA.md
|
|
1245
|
+
const grafana = createGrafanaRouters(db, config.server.datadir, loggerClient, { loggedInOrFail, can_READ });
|
|
1246
|
+
app.use('/grafana', grafana.data);
|
|
1247
|
+
app.use('/grafana_token', grafana.admin);
|
|
1248
|
+
|
|
1249
|
+
// Where each organization's readings are forwarded, edited from the admin tab - lib/forwarders.js
|
|
1250
|
+
app.use('/forwarders', createForwardersRouter(db, mqttLogger, () => config.organizations || {}, { loggedInOrFail, can_ADMIN }, { localOpenSearch: () => config.opensearch }));
|
|
1251
|
+
app.use('/opensearch_access', createAccessRouter(() => config.organizations || {}, { loggedInOrFail, can_READ }, () => config.opensearch));
|
|
1252
|
+
console.log("Mounted Grafana routes at /grafana");
|
|
1253
|
+
|
|
1217
1254
|
// Add API error handler
|
|
1218
1255
|
app.use(createAPIErrorHandler()); // createAPIErrorHandler returns a function to use as error handler
|
|
1219
1256
|
console.log("Added API error handler");
|
|
@@ -1383,7 +1420,7 @@ mqttLogger.readYamlConfig('.', (err, configobj) => {
|
|
|
1383
1420
|
);
|
|
1384
1421
|
app.get('/ota_list/:org',
|
|
1385
1422
|
loggedInOrFail,
|
|
1386
|
-
|
|
1423
|
+
can_OTAUPDATE_or_OTAFLASH,
|
|
1387
1424
|
(req,res) => {
|
|
1388
1425
|
// TODO-N89 list all OTA files for an organization
|
|
1389
1426
|
get_ota_dirs(req.params.org, (err, dirs) => {
|
|
@@ -1427,7 +1464,7 @@ mqttLogger.readYamlConfig('.', (err, configobj) => {
|
|
|
1427
1464
|
// and answers 304 rather than sending bytes.
|
|
1428
1465
|
app.get('/ota_get/:org/*remainingpath',
|
|
1429
1466
|
loggedInOrFail,
|
|
1430
|
-
|
|
1467
|
+
can_OTAUPDATE_or_OTAFLASH,
|
|
1431
1468
|
(req,res) => {
|
|
1432
1469
|
let filepath = safeJoin(config.server.otadir, req.params.org, ...[].concat(req.params.remainingpath), 'firmware.bin');
|
|
1433
1470
|
if (!filepath) {
|
|
@@ -1534,7 +1571,7 @@ mqttLogger.readYamlConfig('.', (err, configobj) => {
|
|
|
1534
1571
|
*/
|
|
1535
1572
|
app.get('/enrolment_secret/:org',
|
|
1536
1573
|
loggedInOrFail,
|
|
1537
|
-
|
|
1574
|
+
can_ADMIN_or_OTAFLASH, // Gets org from URL - sees only their own organization's; rotating stays ADMIN
|
|
1538
1575
|
(req, res) => {
|
|
1539
1576
|
const org = req.params.org;
|
|
1540
1577
|
// Worth a line: this is the one route that hands a secret to a browser.
|
|
@@ -1983,7 +2020,12 @@ mqttLogger.readYamlConfig('.', (err, configobj) => {
|
|
|
1983
2020
|
o.logger_userid = cred.username;
|
|
1984
2021
|
o.logger_password = cred.password;
|
|
1985
2022
|
});
|
|
1986
|
-
|
|
2023
|
+
// Forwarders kept in the database go to the logger before it starts, so they are running when
|
|
2024
|
+
// the broker connects. One that cannot be read is reported and the logger starts without it.
|
|
2025
|
+
Promise.all(Object.keys(config.organizations || {}).map(async (org) => {
|
|
2026
|
+
try { await mqttLogger.setForwarders(org, await enabledConfigs(db, org, config.opensearch)); }
|
|
2027
|
+
catch (ferr) { console.error(`Could not load the forwarders for ${org}:`, ferr.message); }
|
|
2028
|
+
})).then(() => mqttLogger.start());
|
|
1987
2029
|
});
|
|
1988
2030
|
|
|
1989
2031
|
// On a bridged Pi, keep production's logins and permissions in step (SECURITY.md S11).
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Checks shared by the routes an organization's administrator uses to hand out access (Grafana
|
|
3
|
+
* tokens) and to send readings elsewhere (forwarders). Both act on the WHOLE organization, so they ask
|
|
4
|
+
* for more than the ordinary ADMIN check does.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// A form POST needs no CORS preflight, so a page on another site can send one using an
|
|
8
|
+
// administrator's logged-in session; JSON it cannot send without the browser asking this server first,
|
|
9
|
+
// and the server does not agree. This server also parses form bodies for every route (login needs it),
|
|
10
|
+
// so what these routes must do is refuse them. Not a replacement for a SameSite session cookie.
|
|
11
|
+
export function jsonOnly(req, res, next) {
|
|
12
|
+
if (req.is('application/json')) return next();
|
|
13
|
+
const message = 'Send JSON (Content-Type: application/json)';
|
|
14
|
+
res.status(415).json({ error: message, message });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// ADMIN for the whole organization, not just for one project of it. can_ADMIN answers "anywhere in
|
|
18
|
+
// this organization", so an administrator of a single project passes it - and a token or a forwarder
|
|
19
|
+
// reaches every project's readings.
|
|
20
|
+
export function orgWideAdmin(req, res, next) {
|
|
21
|
+
const ok = req.user && Array.isArray(req.user.permissions)
|
|
22
|
+
&& req.user.permissions.some((p) => p.capability === 'ADMIN' && p.org === req.params.org && !p.project);
|
|
23
|
+
if (ok) return next();
|
|
24
|
+
const message = 'This needs ADMIN for the whole organization, not for one project of it';
|
|
25
|
+
res.status(403).json({ error: message, message });
|
|
26
|
+
}
|
package/lib/config-for-user.js
CHANGED
|
@@ -26,6 +26,9 @@ export const configSectionsNeverServed = [
|
|
|
26
26
|
// the client reads any of it: whether password reset is available is decided server-side by
|
|
27
27
|
// mailConfigured(). Found by the "nothing secret-looking" test below, on its first run.
|
|
28
28
|
'email',
|
|
29
|
+
// config.d/opensearch.yaml - the address and login of this server's own OpenSearch, including the
|
|
30
|
+
// account that can create logins on it
|
|
31
|
+
'opensearch',
|
|
29
32
|
];
|
|
30
33
|
|
|
31
34
|
// Fields within an organization's own config that are not sent.
|
|
@@ -43,6 +46,13 @@ export const orgFieldsNeverServed = [
|
|
|
43
46
|
// pick it up. It can read the whole organization, so it is at least as sensitive as the shared
|
|
44
47
|
// password was.
|
|
45
48
|
'logger_password',
|
|
49
|
+
// Where this organization's readings are forwarded to (OpenSearch, Google Sheets, Firebase), which
|
|
50
|
+
// is credentials: an OpenSearch password or API key, the Google Sheets webhook URL (anyone holding
|
|
51
|
+
// it can write to that sheet) and the path to a Firebase service account.
|
|
52
|
+
// The older per-type keys are withheld as well, for a config not yet moved to "forwarders:".
|
|
53
|
+
'forwarders',
|
|
54
|
+
'gsheets',
|
|
55
|
+
'firebase',
|
|
46
56
|
];
|
|
47
57
|
|
|
48
58
|
/*
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Where this server may be told to connect.
|
|
3
|
+
*
|
|
4
|
+
* An organization's administrator chooses the address a forwarder sends to, and it is this server
|
|
5
|
+
* that connects, from inside whatever network it sits in. So an address is judged by what it IS, not
|
|
6
|
+
* by how it is spelled: a list of bad names loses to the many ways of writing the same address
|
|
7
|
+
* (IPv4 inside IPv6, a trailing dot, a name that resolves somewhere private).
|
|
8
|
+
*
|
|
9
|
+
* private, link-local (where cloud metadata lives), shared, reserved, multicast - refused
|
|
10
|
+
* loopback - allowed, but only on a port the caller names (OpenSearch's 9200), not the server's own
|
|
11
|
+
* other services; not for Google Sheets at all
|
|
12
|
+
* public - allowed
|
|
13
|
+
*
|
|
14
|
+
* A name is resolved, and refused if ANY of its addresses is refused. This is checked when a forwarder
|
|
15
|
+
* is saved. A name that resolves somewhere else later (DNS rebinding) is not caught by it, and the
|
|
16
|
+
* reply to a forwarder's request is never shown to the browser - so this limits what an administrator
|
|
17
|
+
* can reach and probe, but is not a replacement for the machine's own firewall.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import net from 'net';
|
|
21
|
+
import dns from 'dns';
|
|
22
|
+
|
|
23
|
+
// Names that mean "the cloud's metadata service", whatever they resolve to
|
|
24
|
+
const METADATA_NAMES = new Set(['metadata', 'metadata.google.internal', 'instance-data', 'instance-data.ec2.internal']);
|
|
25
|
+
|
|
26
|
+
// 16 bytes of an IPv6 address, or null. Handles "::" and a trailing dotted IPv4.
|
|
27
|
+
function ipv6Bytes(ip) {
|
|
28
|
+
let s = ip;
|
|
29
|
+
const v4 = /(\d+\.\d+\.\d+\.\d+)$/.exec(s);
|
|
30
|
+
if (v4) {
|
|
31
|
+
const o = v4[1].split('.').map(Number);
|
|
32
|
+
s = s.slice(0, -v4[1].length) + ((o[0] << 8) | o[1]).toString(16) + ':' + ((o[2] << 8) | o[3]).toString(16);
|
|
33
|
+
}
|
|
34
|
+
const halves = s.split('::');
|
|
35
|
+
if (halves.length > 2) return null;
|
|
36
|
+
const head = halves[0] ? halves[0].split(':') : [];
|
|
37
|
+
const tail = halves.length === 2 && halves[1] ? halves[1].split(':') : [];
|
|
38
|
+
const missing = 8 - head.length - tail.length;
|
|
39
|
+
if (halves.length === 2 ? missing < 1 : missing !== 0) return null;
|
|
40
|
+
const groups = [...head, ...Array(halves.length === 2 ? missing : 0).fill('0'), ...tail];
|
|
41
|
+
if (groups.length !== 8 || !groups.every((g) => /^[0-9a-f]{1,4}$/i.test(g))) return null;
|
|
42
|
+
return groups.flatMap((g) => [parseInt(g, 16) >> 8, parseInt(g, 16) & 255]);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// 'public', 'loopback', or 'refused' for an IPv4 or IPv6 address (no brackets)
|
|
46
|
+
export function classifyAddress(address) {
|
|
47
|
+
let a = String(address).replace(/^\[|\]$/g, '');
|
|
48
|
+
let o;
|
|
49
|
+
if (net.isIPv4(a)) {
|
|
50
|
+
o = a.split('.').map(Number);
|
|
51
|
+
} else if (net.isIPv6(a)) {
|
|
52
|
+
const b = ipv6Bytes(a.split('%')[0]);
|
|
53
|
+
if (!b) return 'refused';
|
|
54
|
+
const mapped = b.slice(0, 10).every((x) => x === 0) && b[10] === 255 && b[11] === 255; // ::ffff:a.b.c.d
|
|
55
|
+
if (mapped) { o = b.slice(12); }
|
|
56
|
+
else {
|
|
57
|
+
if (b.every((x) => x === 0)) return 'refused'; // ::
|
|
58
|
+
if (b.slice(0, 15).every((x) => x === 0) && b[15] === 1) return 'loopback'; // ::1
|
|
59
|
+
if ((b[0] & 0xfe) === 0xfc) return 'refused'; // fc00::/7 unique local
|
|
60
|
+
if (b[0] === 0xfe && (b[1] & 0xc0) === 0x80) return 'refused'; // fe80::/10 link-local
|
|
61
|
+
if (b[0] === 0xff) return 'refused'; // multicast
|
|
62
|
+
if (b[0] === 0 && b[1] === 0x64 && b[2] === 0xff && b[3] === 0x9b) return 'refused'; // 64:ff9b::/96 NAT64
|
|
63
|
+
if (b[0] === 0x20 && b[1] === 0x01 && b[2] === 0x0d && b[3] === 0xb8) return 'refused'; // documentation
|
|
64
|
+
return 'public';
|
|
65
|
+
}
|
|
66
|
+
} else {
|
|
67
|
+
return 'refused'; // not an address at all
|
|
68
|
+
}
|
|
69
|
+
const [a0, a1] = o;
|
|
70
|
+
if (a0 === 127) return 'loopback';
|
|
71
|
+
if (a0 === 0 || a0 === 10) return 'refused';
|
|
72
|
+
if (a0 === 100 && a1 >= 64 && a1 <= 127) return 'refused'; // 100.64/10 shared
|
|
73
|
+
if (a0 === 169 && a1 === 254) return 'refused'; // link-local, cloud metadata
|
|
74
|
+
if (a0 === 172 && a1 >= 16 && a1 <= 31) return 'refused';
|
|
75
|
+
if (a0 === 192 && a1 === 168) return 'refused';
|
|
76
|
+
if (a0 === 192 && a1 === 0 && o[2] === 0) return 'refused'; // 192.0.0.0/24
|
|
77
|
+
if (a0 === 198 && (a1 === 18 || a1 === 19)) return 'refused'; // benchmarking
|
|
78
|
+
if (a0 >= 224) return 'refused'; // multicast and reserved
|
|
79
|
+
return 'public';
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// The host of a URL as it should be judged: lower case, no brackets, no trailing dot
|
|
83
|
+
export function hostOf(urlString) {
|
|
84
|
+
try { return new URL(urlString).hostname.replace(/^\[|\]$/g, '').replace(/\.+$/, '').toLowerCase(); }
|
|
85
|
+
catch { return ''; }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Is this URL's host an address on this machine? "localhost" or a loopback address, however written.
|
|
89
|
+
export function isLoopbackUrl(urlString) {
|
|
90
|
+
const h = hostOf(urlString);
|
|
91
|
+
return h === 'localhost' || (net.isIP(h) !== 0 && classifyAddress(h) === 'loopback');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// null if the URL may be connected to, otherwise why not.
|
|
95
|
+
// loopbackPorts ports on this machine that may be used, e.g. [9200]. Empty: nothing on this machine.
|
|
96
|
+
// lookup dns.promises.lookup, replaceable for tests
|
|
97
|
+
export async function checkDestination(urlString, { loopbackPorts = [], lookup = dns.promises.lookup } = {}) {
|
|
98
|
+
let u;
|
|
99
|
+
try { u = new URL(urlString); } catch { return 'is not a valid URL'; }
|
|
100
|
+
const host = hostOf(urlString);
|
|
101
|
+
if (!host) return 'has no host';
|
|
102
|
+
if (METADATA_NAMES.has(host)) return 'points somewhere that is not allowed';
|
|
103
|
+
const port = Number(u.port || (u.protocol === 'https:' ? 443 : 80));
|
|
104
|
+
let addresses;
|
|
105
|
+
if (net.isIP(host)) { addresses = [host]; }
|
|
106
|
+
else {
|
|
107
|
+
try { addresses = (await lookup(host, { all: true })).map((r) => r.address); }
|
|
108
|
+
catch { return 'is a host that could not be found'; }
|
|
109
|
+
if (!addresses.length) return 'is a host that could not be found';
|
|
110
|
+
}
|
|
111
|
+
for (const address of addresses) {
|
|
112
|
+
const kind = classifyAddress(address);
|
|
113
|
+
if (kind === 'refused') return 'points somewhere that is not allowed';
|
|
114
|
+
if (kind === 'loopback' && !loopbackPorts.includes(port)) {
|
|
115
|
+
return loopbackPorts.length
|
|
116
|
+
? `may only use this machine on port ${loopbackPorts.join(' or ')}`
|
|
117
|
+
: 'may not point at this machine';
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return null;
|
|
121
|
+
}
|