pmtiles-swarm 0.8.0 → 0.9.0
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/CHANGELOG.md +39 -0
- package/README.md +32 -0
- package/docs/haproxy.md +41 -0
- package/docs/running-as-a-service.md +15 -1
- package/package.json +1 -1
- package/src/engines/libtorrent.js +6 -0
- package/src/index.js +25 -2
- package/src/status-command.js +233 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,45 @@
|
|
|
7
7
|
### 🐞 Bug fixes
|
|
8
8
|
- _...Add new stuff here..._
|
|
9
9
|
|
|
10
|
+
## 0.9.0
|
|
11
|
+
### ✨ Features and improvements
|
|
12
|
+
- **`pmtiles-swarm status`**, which asks a running node what it is doing and reads the answer out
|
|
13
|
+
loud. It takes the same config file the node runs with, so the address, the port and the
|
|
14
|
+
credential come from one place rather than being remembered and retyped. That is the whole
|
|
15
|
+
point of it: the API is on `adminPort` rather than the public port, the node binds where `host`
|
|
16
|
+
says and that is usually not loopback, and it accepts `authorization: Bearer` and not
|
|
17
|
+
`x-api-key`. Get any one of those wrong by hand and the answer is a refused connection or a 401,
|
|
18
|
+
both of which read as a broken node rather than as a mistyped command — which is exactly how
|
|
19
|
+
they were read while diagnosing the archive fixed below.
|
|
20
|
+
|
|
21
|
+
It names the case that is otherwise silent: an archive the catalog holds and the engine does
|
|
22
|
+
not, which through `curl` is a row of empty columns and looks like a corrupt archive. Just after
|
|
23
|
+
a start it is normal and passes; persisting, the engine refused it and the log says why. Exits
|
|
24
|
+
non-zero when the node does not answer or its engine is down, so it can be the last step of a
|
|
25
|
+
deployment script, and `--json` hands back the raw replies for anything that would rather parse.
|
|
26
|
+
|
|
27
|
+
Also warns when `--config` names a file that is not there. Startup ignores that on purpose, so
|
|
28
|
+
a first run can write one — but for a question about a running node the silence is
|
|
29
|
+
misleading, since the answer then describes the default address and looks entirely real.
|
|
30
|
+
|
|
31
|
+
- **[docs/haproxy.md](docs/haproxy.md) now covers the backend pool**: why round robin rather than
|
|
32
|
+
the plugin's default of Source-IP Hash, which fails quietly behind a CDN by pinning nearly all
|
|
33
|
+
traffic to one node while the rest sit idle and healthy; when least-connections or URI hash are
|
|
34
|
+
worth having instead; and what HTTP/2 on the frontend does and does not change about balancing.
|
|
35
|
+
|
|
36
|
+
### 🐞 Bug fixes
|
|
37
|
+
- **An archive built from a watched folder no longer sits at 0%, seeding nobody, for a quarter of
|
|
38
|
+
an hour.** The libtorrent engine dropped `seedOnly` on its way to the sidecar, so libtorrent
|
|
39
|
+
re-hashed an 81 GiB archive that had been read end to end moments earlier to produce its
|
|
40
|
+
torrent. Everything else already handled it — the library sets it in five places, the
|
|
41
|
+
composite engine checks it against what the primary reports, qBittorrent has its own flag for
|
|
42
|
+
it — and this one engine silently did not pass it on. Needs pmtiles-torrent 0.4.1, which the
|
|
43
|
+
existing dependency range picks up on a fresh install.
|
|
44
|
+
- **`docs/running-as-a-service.md` no longer suggests checking a node with `curl localhost:8091`.**
|
|
45
|
+
It names loopback and sends no credential, so on a node bound to its LAN address with a key
|
|
46
|
+
configured it fails twice over, in the two ways that look most like a broken node. It now uses
|
|
47
|
+
the status command.
|
|
48
|
+
|
|
10
49
|
## 0.8.0
|
|
11
50
|
### ✨ Features and improvements
|
|
12
51
|
- **`GET /health`**, for a load balancer: 200 when this node can serve, 503 when its engine
|
package/README.md
CHANGED
|
@@ -589,6 +589,38 @@ network equipment: peers request 16 KiB blocks whatever the piece size. The sett
|
|
|
589
589
|
matters there is `maxConnections`, since every peer holds a NAT table entry. See
|
|
590
590
|
[docs/publishing.md](docs/publishing.md).
|
|
591
591
|
|
|
592
|
+
## Asking a running node what it is doing
|
|
593
|
+
|
|
594
|
+
```sh
|
|
595
|
+
node src/index.js status --config /etc/pmtiles-swarm/swarm.config.json
|
|
596
|
+
```
|
|
597
|
+
|
|
598
|
+
```
|
|
599
|
+
engine libtorrent ready
|
|
600
|
+
version 0.8.0
|
|
601
|
+
17 archives, 1 the engine does not know about
|
|
602
|
+
|
|
603
|
+
NAME SIZE STATE PROGRESS
|
|
604
|
+
planetiler-openmaptiles-260803.pmtiles 81 GiB seeding 100%
|
|
605
|
+
planet-260803.osm.pbf 94 GiB downloading 37%
|
|
606
|
+
planetiler-openmaptiles-260810.pmtiles 83 GiB — —
|
|
607
|
+
```
|
|
608
|
+
|
|
609
|
+
It reads the same config file the node runs with, so the address, the port and the credential come
|
|
610
|
+
from one place rather than being remembered and retyped. That matters more than it sounds: the API
|
|
611
|
+
is on `adminPort`, not the public port; the node binds where `host` says, which is usually not
|
|
612
|
+
loopback; and it accepts `authorization: Bearer`, not `x-api-key`. Get any one of those wrong with
|
|
613
|
+
`curl` and the answer is a refused connection or a 401 — both of which read as a broken node rather
|
|
614
|
+
than a mistyped command.
|
|
615
|
+
|
|
616
|
+
An archive with a state of `—` is one the catalog holds and the engine is not. Directly after a
|
|
617
|
+
restart that is normal and passes. Persisting, it means the engine refused it, and the log says
|
|
618
|
+
why.
|
|
619
|
+
|
|
620
|
+
Exit status is 0 when the node answered and its engine is up, 1 when it did not or is not — so it
|
|
621
|
+
works in a deployment script. `--json` gives the raw `/api/status` and `/api/torrents` replies for
|
|
622
|
+
anything that wants to parse rather than read.
|
|
623
|
+
|
|
592
624
|
## API
|
|
593
625
|
|
|
594
626
|
| Method | Path | Purpose |
|
package/docs/haproxy.md
CHANGED
|
@@ -81,6 +81,47 @@ more often.
|
|
|
81
81
|
Note also that the node comes *back* on `rise` successful checks — 2 by
|
|
82
82
|
default — so a flapping node re-enters rotation quickly whichever you choose.
|
|
83
83
|
|
|
84
|
+
## The backend pool
|
|
85
|
+
|
|
86
|
+
**Settings → Backend Pools → Add**, mode **HTTP (Layer 7)**.
|
|
87
|
+
|
|
88
|
+
### Balancing algorithm
|
|
89
|
+
|
|
90
|
+
**Round Robin.** Tile requests are stateless, numerous and roughly the same
|
|
91
|
+
size, which is the case round robin is for.
|
|
92
|
+
|
|
93
|
+
**Not Source-IP Hash, even though it is the default here.** It exists for
|
|
94
|
+
sticky sessions, and tiles have no session to be sticky about. Worse, it fails
|
|
95
|
+
quietly in exactly the setup this document assumes: behind a CDN every request
|
|
96
|
+
arrives from a handful of edge addresses, so hashing on the source pins almost
|
|
97
|
+
all traffic to one node while the others sit idle and healthy. A NAT'd office
|
|
98
|
+
or a mobile carrier does the same thing on a smaller scale.
|
|
99
|
+
|
|
100
|
+
Two others are worth knowing about, for arrangements this is not:
|
|
101
|
+
|
|
102
|
+
**Least Connections**, when long transfers share the backend with short ones.
|
|
103
|
+
A web-seed range request can run for minutes while a tile takes milliseconds,
|
|
104
|
+
and round robin will happily queue tiles behind a transfer. If web seeds are
|
|
105
|
+
served from a different host — an ordinary web server in front of the published
|
|
106
|
+
directory, which is the usual arrangement — that variance is not here and round
|
|
107
|
+
robin is simpler.
|
|
108
|
+
|
|
109
|
+
**URI Hash**, for a tier of **cache-mode** nodes. Such a node holds only the
|
|
110
|
+
pieces it has read, so sending the same region to different nodes makes each of
|
|
111
|
+
them pay the cold read separately — the one real cost of scaling a cache-mode
|
|
112
|
+
tier horizontally. `balance uri depth 2` hashes on `/archives/<infohash>` and
|
|
113
|
+
gives archive affinity, at the price of concentrating one archive on one node.
|
|
114
|
+
For nodes holding complete copies there is no cold read to avoid, so this is a
|
|
115
|
+
cost with no benefit.
|
|
116
|
+
|
|
117
|
+
### HTTP/2
|
|
118
|
+
|
|
119
|
+
Enable it on the frontend and leave *HTTP/2 without TLS* unchecked: the client
|
|
120
|
+
gets HTTP/2, the node is spoken to over HTTP/1.1, which is what it speaks.
|
|
121
|
+
Balancing in HTTP mode is per request rather than per connection, so a client
|
|
122
|
+
multiplexing a hundred tile requests over one HTTP/2 connection still has them
|
|
123
|
+
spread across the pool.
|
|
124
|
+
|
|
84
125
|
## Real servers
|
|
85
126
|
|
|
86
127
|
**Settings → Real Servers → Add**, one per node, port **8090**.
|
|
@@ -441,9 +441,23 @@ Then check it is actually serving:
|
|
|
441
441
|
|
|
442
442
|
```sh
|
|
443
443
|
curl -fsS localhost:8090/feed.xml >/dev/null && echo "public surface ok"
|
|
444
|
-
|
|
444
|
+
node /opt/pmtiles-swarm/src/index.js status \
|
|
445
|
+
--config /etc/pmtiles-swarm/swarm.config.json
|
|
445
446
|
```
|
|
446
447
|
|
|
448
|
+
Ask through the status command rather than with `curl`. Reaching the API by hand
|
|
449
|
+
means getting the bind address, the admin port and the credential right in one
|
|
450
|
+
go, and each of them fails in a way that looks like a broken node: a node bound
|
|
451
|
+
to its LAN address refuses a request to `localhost`, and the header it accepts
|
|
452
|
+
is `authorization: Bearer`, so anything else is a 401. The status command reads
|
|
453
|
+
all three out of the config file the service is running with. It exits non-zero
|
|
454
|
+
when the node does not answer or its engine is down, so it also works as the
|
|
455
|
+
last step of a deployment script.
|
|
456
|
+
|
|
457
|
+
An archive listed with a state of `—` is one the catalog holds and the engine is
|
|
458
|
+
not. Just after a start that is normal and passes within a minute or so. If it
|
|
459
|
+
persists, the engine refused it, and the journal says why.
|
|
460
|
+
|
|
447
461
|
And that it can write where it is supposed to, which nothing above proves:
|
|
448
462
|
|
|
449
463
|
```sh
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmtiles-swarm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "BitTorrent distribution for PMTiles map archives: create torrents, watch folders, publish and subscribe to RSS feeds, and seed through qBittorrent or an embedded client",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -254,6 +254,12 @@ export class LibtorrentEngine {
|
|
|
254
254
|
savePath: request.savePath ?? this.#options.savePath,
|
|
255
255
|
mode: request.mode,
|
|
256
256
|
paused: request.paused,
|
|
257
|
+
// The caller's claim that the data is already on disk, which for an
|
|
258
|
+
// archive created here it is: the file was read end to end a moment ago
|
|
259
|
+
// to produce the torrent. Without passing it on, libtorrent hashes the
|
|
260
|
+
// whole archive again before seeding a byte — a quarter of an hour for
|
|
261
|
+
// an 81 GiB build, during which it reads as 0% and serves nobody.
|
|
262
|
+
seedOnly: request.seedOnly,
|
|
257
263
|
});
|
|
258
264
|
return result.infoHash;
|
|
259
265
|
}
|
package/src/index.js
CHANGED
|
@@ -92,20 +92,26 @@ function createOneEngine(name, config) {
|
|
|
92
92
|
* @returns {Promise<void>} - Resolves once listening.
|
|
93
93
|
*/
|
|
94
94
|
async function main() {
|
|
95
|
-
const { values } = parseArgs({
|
|
95
|
+
const { values, positionals } = parseArgs({
|
|
96
96
|
options: {
|
|
97
97
|
config: { type: 'string', short: 'c' },
|
|
98
98
|
port: { type: 'string', short: 'p' },
|
|
99
99
|
help: { type: 'boolean', short: 'h' },
|
|
100
|
+
json: { type: 'boolean' },
|
|
100
101
|
},
|
|
101
|
-
allowPositionals:
|
|
102
|
+
allowPositionals: true,
|
|
102
103
|
});
|
|
103
104
|
|
|
104
105
|
if (values.help) {
|
|
105
106
|
console.log(`pmtiles-swarm — BitTorrent distribution for PMTiles archives
|
|
106
107
|
|
|
108
|
+
Usage:
|
|
109
|
+
pmtiles-swarm [--config FILE] start the node
|
|
110
|
+
pmtiles-swarm status [--config FILE] ask a running node what it is doing
|
|
111
|
+
|
|
107
112
|
--config, -c path to a JSON config file
|
|
108
113
|
--port, -p override the listen port
|
|
114
|
+
--json machine-readable output, for the status command
|
|
109
115
|
--help, -h this message
|
|
110
116
|
|
|
111
117
|
Environment: PMTILES_SWARM_PORT, PMTILES_SWARM_DATA_DIR, PMTILES_SWARM_ENGINE,
|
|
@@ -118,6 +124,23 @@ PMTILES_SWARM_PUBLIC_URL
|
|
|
118
124
|
const config = await loadConfig(values.config);
|
|
119
125
|
if (values.port) config.port = Number(values.port);
|
|
120
126
|
|
|
127
|
+
// Asking rather than starting. Everything it needs — which address the admin
|
|
128
|
+
// listener is on, which port, and the credential — comes from the same
|
|
129
|
+
// configuration the node runs with, so there is nothing to pass and nothing
|
|
130
|
+
// to get wrong.
|
|
131
|
+
if (positionals[0] === 'status') {
|
|
132
|
+
const { runStatus } = await import('./status-command.js');
|
|
133
|
+
process.exitCode = await runStatus(config, { json: values.json });
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (positionals.length > 0) {
|
|
138
|
+
console.error(`unknown command: ${positionals[0]}`);
|
|
139
|
+
console.error('try: pmtiles-swarm status');
|
|
140
|
+
process.exitCode = 2;
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
121
144
|
// Everything that has to be stopped, in the order it should be stopped,
|
|
122
145
|
// filled in as startup proceeds.
|
|
123
146
|
//
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `pmtiles-swarm status` — asking a running node what it is doing.
|
|
3
|
+
*
|
|
4
|
+
* This exists because interrogating a node meant getting four separate things
|
|
5
|
+
* right at once: which address the admin listener is bound to, which port,
|
|
6
|
+
* which header carries the credential, and where in the JSON the answer lives.
|
|
7
|
+
* Getting any one wrong produces something that looks like a broken archive —
|
|
8
|
+
* a refused connection, a 401, or a row of nulls — rather than like a mistyped
|
|
9
|
+
* command. Every one of those is derivable from the configuration file the
|
|
10
|
+
* node is already running with, so nothing here needs to be passed or
|
|
11
|
+
* remembered.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { access } from 'node:fs/promises';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Whether a path can be read.
|
|
18
|
+
* @param {string} path - The path.
|
|
19
|
+
* @returns {Promise<boolean>} - True when it is there.
|
|
20
|
+
*/
|
|
21
|
+
async function readable(path) {
|
|
22
|
+
try {
|
|
23
|
+
await access(path);
|
|
24
|
+
return true;
|
|
25
|
+
} catch {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Columns, and how wide the name column may grow before it is cut. */
|
|
31
|
+
const NAME_WIDTH = 44;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A size in bytes, as a person would write it.
|
|
35
|
+
* @param {number} value - Bytes.
|
|
36
|
+
* @returns {string} - e.g. "81 GiB".
|
|
37
|
+
*/
|
|
38
|
+
export function bytes(value) {
|
|
39
|
+
if (!Number.isFinite(value) || value <= 0) return '—';
|
|
40
|
+
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];
|
|
41
|
+
let size = value;
|
|
42
|
+
let unit = 0;
|
|
43
|
+
while (size >= 1024 && unit < units.length - 1) {
|
|
44
|
+
size /= 1024;
|
|
45
|
+
unit += 1;
|
|
46
|
+
}
|
|
47
|
+
return `${size < 10 && unit > 0 ? size.toFixed(1) : Math.round(size)} ${units[unit]}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Where this node's API is, according to its own configuration.
|
|
52
|
+
*
|
|
53
|
+
* The admin listener where there is one, since that is where the API lives on
|
|
54
|
+
* a node that separates them. A wildcard bind is reported as loopback: `::` is
|
|
55
|
+
* what the node listens on, not an address anything can connect to.
|
|
56
|
+
* @param {object} config - Resolved configuration.
|
|
57
|
+
* @returns {string} - An origin, e.g. "http://172.16.1.49:8091".
|
|
58
|
+
*/
|
|
59
|
+
export function adminUrl(config) {
|
|
60
|
+
const host = config.adminHost ?? config.host ?? '127.0.0.1';
|
|
61
|
+
const port = config.adminPort ?? config.port ?? 8090;
|
|
62
|
+
const reachable =
|
|
63
|
+
host === '0.0.0.0' || host === '::' || host === '' ? '127.0.0.1' : host;
|
|
64
|
+
// A bare IPv6 address needs brackets before it is a URL.
|
|
65
|
+
const bracketed =
|
|
66
|
+
reachable.includes(':') && !reachable.startsWith('[')
|
|
67
|
+
? `[${reachable}]`
|
|
68
|
+
: reachable;
|
|
69
|
+
return `http://${bracketed}:${port}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The header a request to this node's API needs, if any.
|
|
74
|
+
*
|
|
75
|
+
* `authorization: Bearer`, which is the only form the node accepts — not
|
|
76
|
+
* `x-api-key`, whatever the convention elsewhere.
|
|
77
|
+
* @param {object} config - Resolved configuration.
|
|
78
|
+
* @returns {object} - Headers to send.
|
|
79
|
+
*/
|
|
80
|
+
export function authHeaders(config) {
|
|
81
|
+
const key = config.auth?.apiKey;
|
|
82
|
+
return key ? { authorization: `Bearer ${key}` } : {};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* One line per archive, plus what the engine says about the node.
|
|
87
|
+
* @param {object} answer - `{ status, torrents }` as the API returned them.
|
|
88
|
+
* @returns {string} - The report.
|
|
89
|
+
*/
|
|
90
|
+
export function formatStatus({ status, torrents }) {
|
|
91
|
+
const lines = [];
|
|
92
|
+
const engine = status?.engine;
|
|
93
|
+
lines.push(
|
|
94
|
+
`engine ${engine?.name ?? 'unknown'}` +
|
|
95
|
+
(engine?.ok === false ? ` UNAVAILABLE — ${engine.error ?? ''}` : ' ready'),
|
|
96
|
+
);
|
|
97
|
+
if (status?.version) lines.push(`version ${status.version}`);
|
|
98
|
+
|
|
99
|
+
const rows = torrents ?? [];
|
|
100
|
+
// An archive the engine has never heard of is the case worth naming. It is
|
|
101
|
+
// in the catalog, it has a size, and every live column is empty — which
|
|
102
|
+
// reads as a broken archive and is usually a node that has not finished
|
|
103
|
+
// starting, or one that could not add it.
|
|
104
|
+
const unknown = rows.filter((row) => !row.status).length;
|
|
105
|
+
lines.push(
|
|
106
|
+
`${rows.length} archive${rows.length === 1 ? '' : 's'}` +
|
|
107
|
+
(unknown > 0 ? `, ${unknown} the engine does not know about` : ''),
|
|
108
|
+
);
|
|
109
|
+
lines.push('');
|
|
110
|
+
|
|
111
|
+
if (rows.length === 0) return `${lines.join('\n')}\n`;
|
|
112
|
+
|
|
113
|
+
const head =
|
|
114
|
+
'NAME'.padEnd(NAME_WIDTH) +
|
|
115
|
+
'SIZE'.padStart(9) +
|
|
116
|
+
' ' +
|
|
117
|
+
'STATE'.padEnd(12) +
|
|
118
|
+
'PROGRESS'.padStart(8);
|
|
119
|
+
lines.push(head);
|
|
120
|
+
|
|
121
|
+
for (const row of rows) {
|
|
122
|
+
const name =
|
|
123
|
+
row.name.length > NAME_WIDTH - 1
|
|
124
|
+
? `${row.name.slice(0, NAME_WIDTH - 2)}…`
|
|
125
|
+
: row.name;
|
|
126
|
+
const state = row.status?.state ?? (row.paused ? 'paused' : '—');
|
|
127
|
+
const progress =
|
|
128
|
+
typeof row.status?.progress === 'number'
|
|
129
|
+
? `${Math.round(row.status.progress * 100)}%`
|
|
130
|
+
: '—';
|
|
131
|
+
lines.push(
|
|
132
|
+
name.padEnd(NAME_WIDTH) +
|
|
133
|
+
bytes(row.size).padStart(9) +
|
|
134
|
+
' ' +
|
|
135
|
+
String(state).padEnd(12) +
|
|
136
|
+
progress.padStart(8),
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (unknown > 0) {
|
|
141
|
+
lines.push('');
|
|
142
|
+
lines.push(
|
|
143
|
+
'An archive with no state is one the engine is not holding. If the node',
|
|
144
|
+
);
|
|
145
|
+
lines.push(
|
|
146
|
+
'has just started it may still be handing them back; if it persists, the',
|
|
147
|
+
);
|
|
148
|
+
lines.push('log will say why it could not be added.');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return `${lines.join('\n')}\n`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Asks a running node for its status and prints it.
|
|
156
|
+
* @param {object} config - Resolved configuration.
|
|
157
|
+
* @param {object} [options] - Injectable fetch and output, for testing.
|
|
158
|
+
* @returns {Promise<number>} - The exit code.
|
|
159
|
+
*/
|
|
160
|
+
export async function runStatus(config, options = {}) {
|
|
161
|
+
const {
|
|
162
|
+
fetch: get = globalThis.fetch,
|
|
163
|
+
out = (text) => process.stdout.write(text),
|
|
164
|
+
err = (text) => process.stderr.write(text),
|
|
165
|
+
json = false,
|
|
166
|
+
} = options;
|
|
167
|
+
|
|
168
|
+
// A named config file that is not there is silently ignored on startup, so
|
|
169
|
+
// that a first run can write one. Here that silence is misleading: a typo in
|
|
170
|
+
// --config means this reports on the default address with no key, which is a
|
|
171
|
+
// different node from the one that was asked about, and the answer looks
|
|
172
|
+
// real. Say it, rather than letting it be discovered later.
|
|
173
|
+
if (config.configPath && !(await readable(config.configPath))) {
|
|
174
|
+
err(
|
|
175
|
+
`no config file at ${config.configPath} — using defaults, ` +
|
|
176
|
+
'which is probably not the node you meant.\n',
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const base = adminUrl(config);
|
|
181
|
+
const headers = authHeaders(config);
|
|
182
|
+
|
|
183
|
+
let status;
|
|
184
|
+
let torrents;
|
|
185
|
+
try {
|
|
186
|
+
const [statusReply, torrentsReply] = await Promise.all([
|
|
187
|
+
get(`${base}/api/status`, { headers }),
|
|
188
|
+
get(`${base}/api/torrents`, { headers }),
|
|
189
|
+
]);
|
|
190
|
+
|
|
191
|
+
// Said plainly, because a 401 here means the key in this configuration is
|
|
192
|
+
// not the key the node is running with — which is a different problem from
|
|
193
|
+
// the node being down, and looks identical without being told.
|
|
194
|
+
if (statusReply.status === 401 || statusReply.status === 403) {
|
|
195
|
+
err(
|
|
196
|
+
`${base} refused the credential in this configuration file.\n` +
|
|
197
|
+
'The node is running, but with a different auth.apiKey.\n',
|
|
198
|
+
);
|
|
199
|
+
return 1;
|
|
200
|
+
}
|
|
201
|
+
if (!statusReply.ok) {
|
|
202
|
+
err(`${base}/api/status answered ${statusReply.status}\n`);
|
|
203
|
+
return 1;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
status = await statusReply.json();
|
|
207
|
+
torrents = torrentsReply.ok ? await torrentsReply.json() : [];
|
|
208
|
+
} catch (error) {
|
|
209
|
+
// A refused connection is the commonest failure and the least obvious: the
|
|
210
|
+
// node binds where the configuration says, which is often not loopback.
|
|
211
|
+
// Node's fetch reports every one of them as "fetch failed" and puts the
|
|
212
|
+
// part worth reading — refused, timed out, no such host — in `cause`.
|
|
213
|
+
const reason = error.cause?.code
|
|
214
|
+
? `${error.message} (${error.cause.code})`
|
|
215
|
+
: error.message;
|
|
216
|
+
err(
|
|
217
|
+
`could not reach ${base}: ${reason}\n` +
|
|
218
|
+
'That address comes from adminHost and adminPort in this configuration ' +
|
|
219
|
+
'file.\nIs the node running, and bound where this says?\n',
|
|
220
|
+
);
|
|
221
|
+
return 1;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (json) {
|
|
225
|
+
out(`${JSON.stringify({ status, torrents }, null, 2)}\n`);
|
|
226
|
+
} else {
|
|
227
|
+
out(formatStatus({ status, torrents }));
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Usable from a script: the engine being unreachable is the thing worth
|
|
231
|
+
// failing on, and it is what /health reports to a load balancer.
|
|
232
|
+
return status?.engine?.ok === false ? 1 : 0;
|
|
233
|
+
}
|