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.
@@ -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
+ }
@@ -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
  --
@@ -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
- cookie: { secure: 'auto' } // TODO-N89 cant be secure: true while testing on HTTP
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
- can_OTAUPDATE,
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
- can_OTAUPDATE,
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
- can_ADMIN, // Gets org from URL - an admin sees only their own organization's
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
- mqttLogger.start();
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
+ }
@@ -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
+ }