frugal-iot-server 2.0.10 → 2.0.12

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/lib/tiles.js ADDED
@@ -0,0 +1,271 @@
1
+ /*
2
+ * Map tiles kept on this server (frugal-iot-client docs/MAPPING_PLAN.md, M-19 to M-30).
3
+ *
4
+ * The map asks for /tiles/<source>/z/x/y. A tile kept here is served from disk; one that is not is
5
+ * fetched from that source's upstream while it is reachable, and is a 404 at once while it is not.
6
+ * So a Pi that goes offline still shows whatever was looked at while it was online.
7
+ *
8
+ * A file's mtime is when the tile was last known to be current, so a check that finds it unchanged
9
+ * costs one metadata write, not a rewrite. Last use is kept in memory, never read from atime: a Pi
10
+ * is often mounted noatime to spare its card, and where it is not, atime is a write per view.
11
+ */
12
+
13
+ import fs from 'fs';
14
+ import path from 'path';
15
+ import { priorityQueue } from 'async';
16
+
17
+ const DAY_MS = 24 * 60 * 60 * 1000;
18
+ // Waiting on a timeout for every tile would freeze the map, so one failure stands for a minute
19
+ const OFFLINE_RETRY_MS = 60 * 1000;
20
+ const FETCH_TIMEOUT_MS = 15 * 1000;
21
+ const CONCURRENT_FETCHES = 2; // OSM's tile usage policy
22
+ const VIEW = 0; // someone is looking - ahead of any prefetch
23
+ const PREFETCH = 1;
24
+ const MIN_REFRESH_DAYS = 7; // OSM's tile usage policy: never re-ask sooner than this
25
+ const EXTENSIONS = { 'image/png': '.png', 'image/jpeg': '.jpg', 'image/webp': '.webp' };
26
+ const { version } = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
27
+
28
+ export const CACHE_DEFAULTS = {
29
+ dir: './tiles',
30
+ refreshdays: 30,
31
+ maxmb: 200,
32
+ userAgent: `frugal-iot-server/${version} (+https://github.com/mitra42/frugal-iot-server)`,
33
+ };
34
+ // What a mapping.yaml with no sources gets: the map as it always was
35
+ export const DEFAULT_SOURCES = {
36
+ osm: {
37
+ name: 'Map',
38
+ enabled: true,
39
+ upstream: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
40
+ attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
41
+ maxzoom: 19,
42
+ },
43
+ };
44
+
45
+ // The enabled sources, by id. The id becomes a directory and a URL segment, so it is held to letters
46
+ // and digits.
47
+ export function enabledSources(mapping) {
48
+ const sources = (mapping && mapping.sources) || DEFAULT_SOURCES;
49
+ const out = {};
50
+ Object.entries(sources).forEach(([id, src]) => {
51
+ if (!src || (src.enabled === false) || !/^[a-z0-9_-]{1,32}$/i.test(id) || !src.upstream) return;
52
+ out[id] = { id, name: src.name || id, minzoom: 0, maxzoom: 19, ...src };
53
+ });
54
+ return out;
55
+ }
56
+
57
+ // What the map is told about each source (M-27): through this server while it keeps tiles,
58
+ // straight from upstream while it does not
59
+ export function sourcesForBrowser(mapping) {
60
+ const cached = !!(mapping && mapping.cache && mapping.cache.enabled);
61
+ return Object.values(enabledSources(mapping)).map((s) => ({
62
+ id: s.id, name: s.name, attribution: s.attribution, minzoom: s.minzoom, maxzoom: s.maxzoom,
63
+ url: cached ? `/tiles/${s.id}/{z}/{x}/{y}` : s.upstream,
64
+ ...(s.bounds ? { bounds: s.bounds } : {}),
65
+ }));
66
+ }
67
+
68
+ const clamp = (v, z) => Math.min(Math.max(v, 0), (2 ** z) - 1);
69
+ export function tileX(lon, z) {
70
+ return clamp(Math.floor(((lon + 180) / 360) * (2 ** z)), z);
71
+ }
72
+ export function tileY(lat, z) {
73
+ const r = (lat * Math.PI) / 180;
74
+ return clamp(Math.floor(((1 - (Math.log(Math.tan(r) + (1 / Math.cos(r))) / Math.PI)) / 2) * (2 ** z)), z);
75
+ }
76
+
77
+ // Within the source's zooms, and touching its bounds (bounds: [[south, west], [north, east]])
78
+ export function tileInSource(t, src) {
79
+ if ((t.z < src.minzoom) || (t.z > src.maxzoom)) return false;
80
+ if (!src.bounds) return true;
81
+ const [[south, west], [north, east]] = src.bounds;
82
+ return (t.x >= tileX(west, t.z)) && (t.x <= tileX(east, t.z))
83
+ && (t.y >= tileY(north, t.z)) && (t.y <= tileY(south, t.z));
84
+ }
85
+
86
+ // z, x and y straight from the URL. Digits only, so none of it can name a path elsewhere.
87
+ export function parseTile(z, x, y, maxzoom = 22) {
88
+ const m = /^(\d{1,7})(\.(png|jpg|jpeg|webp))?$/.exec(String(y));
89
+ if (!m || !/^\d{1,2}$/.test(String(z)) || !/^\d{1,7}$/.test(String(x))) return null;
90
+ const t = { z: Number(z), x: Number(x), y: Number(m[1]) };
91
+ const n = 2 ** t.z;
92
+ return ((t.z <= maxzoom) && (t.x < n) && (t.y < n)) ? t : null;
93
+ }
94
+
95
+ export const tileKey = (id, t) => `${id}/${t.z}/${t.x}/${t.y}`;
96
+
97
+ // The network edge, replaced in tests. cb(err) only when upstream could not be reached at all.
98
+ function fetchUpstream(url, { since, userAgent }, cb) {
99
+ const headers = { 'User-Agent': userAgent };
100
+ if (since) headers['If-Modified-Since'] = since.toUTCString();
101
+ fetch(url, { headers, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) })
102
+ .then((r) => (r.ok
103
+ ? r.arrayBuffer().then((b) => ({ status: r.status, type: r.headers.get('content-type'), body: Buffer.from(b) }))
104
+ : { status: r.status }))
105
+ .then((res) => cb(null, res), (err) => cb(err));
106
+ }
107
+
108
+ export function createTileCache(options = {}) {
109
+ const o = { ...CACHE_DEFAULTS, ...options };
110
+ const sources = o.sources ? enabledSources({ sources: o.sources }) : enabledSources(null);
111
+ const fetchTile = o.fetchTile || fetchUpstream;
112
+ const now = o.now || Date.now;
113
+ const refreshMs = Math.max(o.refreshdays, MIN_REFRESH_DAYS) * DAY_MS;
114
+ let keep = o.keep || (() => false); // keys the prefetch still wants are never evicted
115
+ const lastUsed = new Map(); // file -> ms
116
+ const waiting = new Map(); // key -> [cb], so two views of one tile fetch it once
117
+ const offlineUntil = {}; // by source: NSW being down does not stop OSM
118
+ const lastExt = {}; // by source, so a kept tile is usually found in one stat
119
+
120
+ const base = (id, t) => path.join(o.dir, id, String(t.z), String(t.x), String(t.y));
121
+ const urlOf = (src, t) => src.upstream.replace('{z}', t.z).replace('{x}', t.x).replace('{y}', t.y);
122
+ const online = (id) => now() >= (offlineUntil[id] || 0);
123
+
124
+ const fetches = priorityQueue((job, cb) => fetchTile(job.url, { since: job.since, userAgent: o.userAgent }, cb),
125
+ CONCURRENT_FETCHES);
126
+
127
+ // cb(null, {file, st}) for the kept file, whichever type it was stored as, or cb(null, null)
128
+ function find(id, t, cb) {
129
+ const exts = [...new Set([lastExt[id] || '.png', ...Object.values(EXTENSIONS)])];
130
+ let i = 0;
131
+ const next = () => {
132
+ if (i >= exts.length) return cb(null, null);
133
+ const file = base(id, t) + exts[i++];
134
+ fs.stat(file, (err, st) => {
135
+ if (err) return next();
136
+ lastExt[id] = path.extname(file);
137
+ cb(null, { file, st });
138
+ });
139
+ };
140
+ next();
141
+ }
142
+
143
+ function store(id, t, res, cb) {
144
+ const ext = EXTENSIONS[String(res.type).split(';')[0].trim()] || '.png';
145
+ const file = base(id, t) + ext;
146
+ const tmp = `${file}.tmp`; // renamed into place, so a half-written tile is never served
147
+ fs.mkdir(path.dirname(file), { recursive: true }, (err) => {
148
+ if (err) return cb(err);
149
+ fs.writeFile(tmp, res.body, (err1) => (err1 ? cb(err1) : fs.rename(tmp, file, (err2) => {
150
+ if (!err2) lastExt[id] = ext;
151
+ cb(err2, file);
152
+ })));
153
+ });
154
+ }
155
+
156
+ // cb(null, file) when the tile is now current, cb(null, null) when it is not
157
+ function refresh(src, t, kept, priority, cb) {
158
+ const key = tileKey(src.id, t);
159
+ if (!online(src.id)) return cb(null, null);
160
+ if (waiting.has(key)) { waiting.get(key).push(cb); return; }
161
+ waiting.set(key, [cb]);
162
+ const done = (err, file) => {
163
+ const cbs = waiting.get(key);
164
+ waiting.delete(key);
165
+ if (err) console.log('Tile', key, 'not kept:', err.message);
166
+ cbs.forEach((c) => c(null, err ? null : file));
167
+ };
168
+ fetches.push({ url: urlOf(src, t), since: kept && kept.st.mtime }, priority, (err, res) => {
169
+ if (err || (res.status === 429) || (res.status >= 500)) {
170
+ offlineUntil[src.id] = now() + OFFLINE_RETRY_MS;
171
+ return done(null, null);
172
+ }
173
+ if ((res.status === 304) && kept) {
174
+ const d = new Date(now());
175
+ return fs.utimes(kept.file, d, d, (err1) => done(err1, kept.file));
176
+ }
177
+ if (res.status === 200) return store(src.id, t, res, done);
178
+ done(null, null); // e.g. 404: upstream has no such tile
179
+ });
180
+ }
181
+
182
+ // cb(null, file) to serve, or cb(null, null) for a 404. A kept tile is served at once even when
183
+ // due a check - the check runs behind it, and offline a stale tile beats none.
184
+ function get(id, t, cb) {
185
+ const src = sources[id];
186
+ if (!src || !tileInSource(t, src)) return cb(null, null);
187
+ find(id, t, (err, kept) => {
188
+ if (kept) {
189
+ lastUsed.set(kept.file, now());
190
+ if ((now() - kept.st.mtimeMs) > refreshMs) refresh(src, t, kept, VIEW, () => {});
191
+ return cb(null, kept.file);
192
+ }
193
+ refresh(src, t, null, VIEW, (err1, file) => {
194
+ if (file) lastUsed.set(file, now());
195
+ cb(null, file);
196
+ });
197
+ });
198
+ }
199
+
200
+ // Fetch a tile nobody is looking at yet, if it is missing or due. cb(null, 'kept' | 'fetched' | 'failed')
201
+ function prefetch(id, t, cb) {
202
+ const src = sources[id];
203
+ if (!src || !tileInSource(t, src)) return cb(null, 'failed');
204
+ find(id, t, (err, kept) => {
205
+ if (kept && ((now() - kept.st.mtimeMs) <= refreshMs)) return cb(null, 'kept');
206
+ refresh(src, t, kept, PREFETCH, (err1, file) => cb(null, file ? 'fetched' : 'failed'));
207
+ });
208
+ }
209
+
210
+ function listFiles(cb) {
211
+ const found = [];
212
+ const walk = (d, cb1) => fs.readdir(d, { withFileTypes: true }, (err, entries) => {
213
+ if (err) return cb1(err.code === 'ENOENT' ? null : err);
214
+ let i = 0;
215
+ const next = (err1) => {
216
+ if (err1 || (i >= entries.length)) return cb1(err1);
217
+ const e = entries[i++];
218
+ const full = path.join(d, e.name);
219
+ if (e.isDirectory()) return walk(full, next);
220
+ fs.stat(full, (err2, st) => {
221
+ if (!err2) found.push({ file: full, size: st.size, used: lastUsed.get(full) || st.mtimeMs });
222
+ next();
223
+ });
224
+ };
225
+ next();
226
+ });
227
+ walk(o.dir, (err) => cb(err, found));
228
+ }
229
+ // dir/osm/15/26000/16900.png -> osm/15/26000/16900
230
+ const keyOfFile = (file) => path.relative(o.dir, file).split(path.sep).join('/').replace(/\.[a-z]+$/, '');
231
+
232
+ // Least recently used first, until under maxmb, across every source. After a restart, a tile not
233
+ // yet viewed counts as last used when it was last known current.
234
+ function evict(cb) {
235
+ listFiles((err, files) => {
236
+ if (err) return cb(err);
237
+ let total = files.reduce((s, f) => s + f.size, 0);
238
+ const limit = o.maxmb * 1024 * 1024;
239
+ const victims = [];
240
+ files.sort((a, b) => a.used - b.used);
241
+ for (const f of files) {
242
+ if (total <= limit) break;
243
+ if (keep(keyOfFile(f.file))) continue;
244
+ victims.push(f.file);
245
+ total -= f.size;
246
+ }
247
+ let i = 0;
248
+ const next = () => {
249
+ if (i >= victims.length) return cb(null, { removed: victims.length, bytes: total });
250
+ const file = victims[i++];
251
+ lastUsed.delete(file);
252
+ fs.unlink(file, () => next());
253
+ };
254
+ next();
255
+ });
256
+ }
257
+
258
+ return { get, prefetch, evict, online, sources, setKeep: (fn) => { keep = fn; }, dir: o.dir };
259
+ }
260
+
261
+ // Once a day, like the readings' housekeeping - first pass delayed past the busy startup
262
+ export function startTileHousekeeping(cache) {
263
+ const run = () => cache.evict((err, r) => {
264
+ if (err) console.error('Tile housekeeping:', err.message);
265
+ else if (r.removed) console.log('Tile housekeeping:', r.removed, 'tiles dropped');
266
+ });
267
+ setTimeout(run, 5 * 60 * 1000).unref();
268
+ const timer = setInterval(run, DAY_MS);
269
+ timer.unref();
270
+ return timer;
271
+ }
package/package.json CHANGED
@@ -12,8 +12,8 @@
12
12
  "cookie-parser": "^1.4.7",
13
13
  "express": "^5.2.1",
14
14
  "express-session": "^1.19.0",
15
- "frugal-iot-client": "^2.0.10",
16
- "frugal-iot-logger": "^2.0.10",
15
+ "frugal-iot-client": "^2.0.12",
16
+ "frugal-iot-logger": "^2.0.12",
17
17
  "hash-wasm": "^4.12.0",
18
18
  "js-yaml": "^4.2.0",
19
19
  "morgan": "^1.11.0",
@@ -28,6 +28,7 @@
28
28
  "frugal-iot-server": "frugal-iot-server.js",
29
29
  "frugal-iot-init": "scripts/init.zsh",
30
30
  "frugal-iot-addorganization": "scripts/addorganization.zsh",
31
+ "frugal-iot-backup": "scripts/backup.zsh",
31
32
  "frugal-iot-setpassword": "scripts/setpassword.zsh",
32
33
  "frugal-iot-diagnostic": "scripts/diagnostic.zsh",
33
34
  "frugal-iot-clearretained": "scripts/clearretained.js",
@@ -36,6 +37,7 @@
36
37
  "frugal-iot-rebuild-dynsec": "scripts/rebuild-dynsec.js",
37
38
  "frugal-iot-dynsec-init": "scripts/dynsec-init.js",
38
39
  "frugal-iot-resetnode": "scripts/resetnode.js",
40
+ "frugal-iot-restore": "scripts/restore.zsh",
39
41
  "frugal-iot-grafana-token": "scripts/grafana-token.js",
40
42
  "frugal-iot-backfill-opensearch": "scripts/backfill-opensearch.js"
41
43
  },
@@ -47,6 +49,7 @@
47
49
  "config.yaml",
48
50
  "config.d/email.yaml",
49
51
  "config.d/logger.yaml",
52
+ "config.d/mapping.yaml",
50
53
  "config.d/mqtt.yaml",
51
54
  "config.d/opensearch.yaml",
52
55
  "config.d/server.yaml",
@@ -94,7 +97,7 @@
94
97
  "check-files": "node scripts/check-published-files.js"
95
98
  },
96
99
  "type": "module",
97
- "version": "2.0.10",
100
+ "version": "2.0.12",
98
101
  "overrides": {
99
102
  "uuid": "^11.1.1"
100
103
  }
@@ -6,7 +6,7 @@
6
6
  // it, an installed browser keeps serving the files it already cached - and since the list now covers
7
7
  // the dashboard's own code, that means old JS behind freshly fetched HTML. Any edit to the list
8
8
  // needs this bumped, or it does not reach anyone who already has the old one.
9
- const CACHE_NAME = 'frugal-iot-cache-2.0.10';
9
+ const CACHE_NAME = 'frugal-iot-cache-2.0.12';
10
10
  /*
11
11
  * Everything the app needs, so that a phone on a poor or expensive link fetches it once.
12
12
  *
@@ -95,27 +95,23 @@ if [[ "$BRIDGE_CREDENTIAL" == derived ]]; then
95
95
  exit 1
96
96
  fi
97
97
 
98
- # Which files the running broker actually uses, rather than where they usually are. Only ".conf"
99
- # files in conf.d are read, and the last setting wins.
100
- MOSQ_CONFS=(/etc/mosquitto/mosquitto.conf(N) /etc/mosquitto/conf.d/*.conf(N))
101
- if (( ${#MOSQ_CONFS} == 0 )); then
102
- echo "Error: no mosquitto configuration found in /etc/mosquitto" >&2
98
+ # Which files the running broker actually uses, rather than where they usually are - production's
99
+ # configuration is several hand-made files in conf.d. scripts/broker-config.zsh reads each file with
100
+ # sudo when it is root-only; this script needs sudo below anyway, so it may prompt.
101
+ BROKER_SUDO=(sudo)
102
+ source "${0:A:h}/broker-config.zsh"
103
+ broker_conf_read
104
+ if (( ${#BROKER_CONF_FILES} == 0 )); then
105
+ echo "Error: no mosquitto configuration found at $(broker_conf_main)" >&2
103
106
  exit 1
104
107
  fi
105
- # Read one setting out of the broker's configuration. Two things to be careful of: a conf.d file
106
- # can be root-only (a bridge configuration holds a password, so it should be), which makes grep
107
- # fail rather than return nothing - and under "set -e" a failing command substitution would end
108
- # this script silently, before it had printed anything at all. Hence "|| true" and the sudo retry.
109
- mosq_setting() {
110
- local out
111
- out=$(grep -hE "^[[:space:]]*$1[[:space:]]" "${MOSQ_CONFS[@]}" 2>/dev/null | tail -1 | awk '{print $2}') || true
112
- if [[ -z "$out" ]] && command -v sudo >/dev/null; then
113
- out=$(sudo grep -hE "^[[:space:]]*$1[[:space:]]" "${MOSQ_CONFS[@]}" 2>/dev/null | tail -1 | awk '{print $2}') || true
114
- fi
115
- print -r -- "$out"
116
- }
117
- PWFILE=$(mosq_setting password_file)
118
- ACLFILE=$(mosq_setting acl_file)
108
+ if (( ${#BROKER_CONF_UNREADABLE} )); then
109
+ echo "Error: could not read ${(j:, :)BROKER_CONF_UNREADABLE}, so cannot tell where the broker" >&2
110
+ echo "keeps its password and ACL files. Re-run with sudo." >&2
111
+ exit 1
112
+ fi
113
+ broker_setting password_file; PWFILE="$BROKER_SETTING"
114
+ broker_setting acl_file; ACLFILE="$BROKER_SETTING"
119
115
 
120
116
  if [[ -z "$PWFILE" ]]; then
121
117
  echo "Error: no password_file in the mosquitto configuration - this broker is not using accounts." >&2
@@ -77,9 +77,15 @@ fi
77
77
 
78
78
  # Ask the broker's own configuration where its password file is, rather than guessing - that way
79
79
  # this works whatever layout a machine uses, including installations older than this script.
80
- # Mosquitto reads /etc/mosquitto/mosquitto.conf and then the .conf files in conf.d, last one wins.
81
- MOSQUITTO_PASSWD_FILE=$(grep -hE '^[[:space:]]*password_file[[:space:]]' \
82
- /etc/mosquitto/mosquitto.conf /etc/mosquitto/conf.d/*.conf(N) 2>/dev/null | tail -1 | awk '{print $2}')
80
+ # sudo may prompt: a Pi's bridge file is root-only, and the password file needs sudo below anyway.
81
+ BROKER_SUDO=(sudo)
82
+ source "${0:A:h}/broker-config.zsh"
83
+ broker_conf_read
84
+ broker_setting password_file; MOSQUITTO_PASSWD_FILE="$BROKER_SETTING"
85
+ if (( ${#BROKER_CONF_UNREADABLE} )); then
86
+ echo "Warning: could not read ${(j:, :)BROKER_CONF_UNREADABLE} - if the password file is named there," >&2
87
+ echo " the one used below may be the wrong one." >&2
88
+ fi
83
89
  USED_LOCAL_MOSQUITTO_FILE=0
84
90
  if [[ -n "$MOSQUITTO_PASSWD_FILE" ]]; then
85
91
  MOSQUITTO_PASSWD_FROM_CONFIG=1