pmtiles-swarm 0.58.1 → 0.59.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 CHANGED
@@ -7,6 +7,75 @@
7
7
  ### 🐞 Bug fixes
8
8
  - _...Add new stuff here..._
9
9
 
10
+ ## 0.59.0
11
+ ### ✨ Features and improvements
12
+ - **Requires pmtiles-torrent 0.10.2**, which is what actually ends the re-checking: a
13
+ `seedOnly` add now discards resume data that would cancel the claim, and the periodic save
14
+ leaves a hashing torrent alone. Everything below only stops the node making more of it.
15
+ - **`pmtiles-swarm init` writes a first configuration, and optionally the unit to run it.**
16
+ Every path it writes is absolute, which is the one mistake it exists to make impossible: a
17
+ relative path resolves against the config file, the documented layout puts that file in
18
+ `/etc`, and so `./data` there means a catalog, a resume directory and potentially a 700 GiB
19
+ archive on the configuration partition. State under `/etc` is refused rather than warned
20
+ about — at the moment a config is written there is nothing to migrate, and the same mistake
21
+ found later costs a stopped service and a careful move.
22
+
23
+ `--systemd` also writes `pmtiles-swarm.service` beside it, with `ReadWritePaths` **derived
24
+ from the configuration it just wrote**. That derivation is the point. A unit and a config
25
+ that disagree is every systemd failure this project has diagnosed, and none of them look
26
+ like what they are: a `savePath` missing from that line is refused inside the unit's
27
+ namespace before any permission bit is read, so the directory's owner and mode are both
28
+ perfect and the write still fails. Two files generated from one source cannot drift.
29
+
30
+ It installs nothing. The unit is written next to the configuration and the `cp` into
31
+ `/etc/systemd/system` is one of the commands it prints, alongside an `install -d` for every
32
+ directory involved with the right owner and mode.
33
+
34
+ `--password` is hashed before it is written, and left out entirely when none is given.
35
+ There is deliberately no placeholder: `auth.password` accepts plaintext, so `REPLACE-ME` in
36
+ that field is a working password until somebody notices — a credential that looks set and
37
+ is not.
38
+
39
+ - **Two scripts for diagnosing a library that re-checks on every start**, in `tools/`.
40
+ `resume-doctor.py` reads a node's real configuration, catalog, stored `.torrent` files and
41
+ resume directory and says, for each archive, what libtorrent will do on the next start and
42
+ why — applying libtorrent's own rules and citing the file and line each came from. It also
43
+ reads the unit through `systemctl show`, does the arithmetic on every deadline that can cut
44
+ a resume save short, and hashes pieces rather than trusting the catalog. `resume-experiment.py`
45
+ proves the five behaviours involved on whatever libtorrent is actually installed, because
46
+ 1.2, 2.0 and 2.1 differ enough that a claim verified on one is not a claim about the other.
47
+
48
+ ### 🐞 Bug fixes
49
+ - **A stop no longer abandons the sidecar mid-write, which is where resume data was going.**
50
+ Three separate bounds decided how long the engine step had, and the smallest won: eight
51
+ seconds for the step, fifteen for the whole shutdown, fifteen for the shutdown RPC. The
52
+ sidecar allows each torrent two seconds of its resume-save budget, so past four archives the
53
+ node gave up first and every torrent it had not persisted re-hashed its whole store on the
54
+ way back up. That is the state a library gets stuck in: checking, on every start, for hours.
55
+
56
+ The engine step is now worked out from the catalog — two seconds a torrent, over a floor —
57
+ and the shutdown watchdog is derived from the steps it is meant to contain rather than being
58
+ a second deadline kept in agreement with them by hand. It was not in agreement.
59
+
60
+ The same fixed 60s applied to the periodic save, so past thirty archives the call gave up
61
+ before the sidecar finished, and the written/asked counts never came back — which is why the
62
+ shortfall this reports could not be seen from outside. **`TimeoutStopSec` in the documented
63
+ unit rises from 45 to 300 seconds**, and existing installs need it raised by hand.
64
+
65
+ - **A `.torrent` that moved with `dataDir` is found again instead of silently becoming a
66
+ magnet.** `torrentPath` is recorded absolute, so moving state out of `/etc` — which
67
+ `docs/running-as-a-service.md` tells you to do — left every catalog entry naming a directory
68
+ that no longer existed. Nothing repointed them and nothing complained, because an unreadable
69
+ `.torrent` was treated as "use the magnet instead".
70
+
71
+ That fallback is the damage rather than a graceful degradation. A magnet carries no metadata
72
+ and neither does resume data, so the archive waits on BEP 9 for a file list that only a peer
73
+ can supply — and for an archive this node originated there is nobody to ask. Seen in the
74
+ field: twenty archives at 0% in `downloading_metadata`, indefinitely, after one documented
75
+ migration. The current `dataDir` is now tried second, the recorded path is corrected in
76
+ place so the warning is printed once rather than for ever, and falling back to a magnet at
77
+ all now says so.
78
+
10
79
  ## 0.58.1
11
80
  ### 🐞 Bug fixes
12
81
  - **The sample configuration put every piece of state under the config file.** `"dataDir": "./data"`,
@@ -151,8 +151,50 @@ sudo apt-get install -y python3-libtorrent
151
151
  sudo -u pmtiles-swarm python3 -c "import libtorrent; print(libtorrent.__version__)"
152
152
  ```
153
153
 
154
+ ## The configuration, and a unit for it
155
+
156
+ `init` writes both, and the unit's `ReadWritePaths` is derived from the
157
+ configuration it just wrote. That derivation is the reason to use it: a unit and
158
+ a configuration that disagree is the failure this whole document is arranged
159
+ around, and two files generated from one source cannot.
160
+
161
+ ```sh
162
+ sudo -u pmtiles-swarm -H /var/lib/pmtiles-swarm/node_modules/.bin/pmtiles-swarm init --config /etc/pmtiles-swarm/swarm.config.json --data-dir /var/lib/pmtiles-swarm/data --save-path /mnt/store/torrent-data --systemd --password 'the console password'
163
+ ```
164
+
165
+ That writes the configuration, generates `pmtiles-swarm.service` beside it, and
166
+ prints a command for every directory involved — each with the right owner and
167
+ mode. Nothing privileged happens and nothing is installed: the unit is written
168
+ next to the configuration, and copying it into `/etc/systemd/system` is one of
169
+ the lines it hands you.
170
+
171
+ Two refusals worth knowing before you type it. State under `/etc` is rejected
172
+ rather than warned about, because at the moment a configuration is written there
173
+ is nothing to migrate — the same mistake found six months later costs a stopped
174
+ service and a careful move. And a configuration that already exists is left
175
+ alone unless `--force` says otherwise, since the credentials in it are not
176
+ recoverable.
177
+
178
+ `--password` is hashed before it is written. Leave it off and there is no
179
+ console password at all: the generated API key is the way in until you set one.
180
+ There is deliberately no placeholder, because `auth.password` accepts plaintext
181
+ and a placeholder in that field is a working password until somebody notices.
182
+
183
+ **Re-run it after adding a watched folder**, or a subscription with a save path
184
+ of its own. Both are directories the node writes to, and neither reaches
185
+ `ReadWritePaths` by itself. `tools/resume-doctor.py` checks a running node's
186
+ unit against its live configuration and says which directories are missing.
187
+
188
+ The unit below is what it generates, with your paths in it. Worth reading
189
+ either way — a generated file you do not understand is a hand-written one you
190
+ have not written yet.
191
+
154
192
  ## The unit
155
193
 
194
+ Annotated, because every directive here has cost somebody a diagnosis.
195
+ `init --systemd` writes this file with your paths already in it; what
196
+ follows is what those lines are for.
197
+
156
198
  ```ini
157
199
  [Unit]
158
200
  Description=pmtiles-swarm
@@ -177,8 +219,12 @@ Restart=always
177
219
  RestartSec=5
178
220
 
179
221
  # Stopping announces "stopped" to every tracker, releases the data directory
180
- # lock and cancels downloads in flight. Worst case is about 20 seconds.
181
- TimeoutStopSec=45
222
+ # lock, cancels downloads in flight and writes resume data for every archive.
223
+ # That last part is what scales: the node allows its engine two seconds per
224
+ # torrent, so a library of fifty wants well over a minute and systemd has to
225
+ # outlast it. Too low here and the process is killed mid-save, which costs a
226
+ # re-hash of everything unwritten on the way back up.
227
+ TimeoutStopSec=300
182
228
 
183
229
  # The node stops the sidecar itself, and needs it alive to do so. The default
184
230
  # signals both at once, which kills the sidecar before it can write its resume
@@ -233,6 +279,17 @@ itself, in order, and waits for the resume data to be written. Nothing is left
233
279
  running: anything still alive when `TimeoutStopSec` expires is killed, sidecar
234
280
  included.
235
281
 
282
+ **`TimeoutStopSec`, and why it is not a round number you pick once.** Stopping
283
+ writes resume data for every archive, and the node allows its engine two seconds
284
+ per torrent to do it — so the figure that matters grows with the library. Three
285
+ hundred seconds covers a hundred and forty archives; a larger node needs more.
286
+ Set it too low and systemd kills the process mid-save, and every archive that
287
+ had not been written re-hashes its whole store on the way back up, which for a
288
+ 700 GiB archive is hours.
289
+
290
+ `tools/resume-doctor.py` prints the arithmetic for your library and says whether
291
+ the unit's value covers it.
292
+
236
293
  **No `ExecStop=`.** systemd already sends `SIGTERM`, and the node handles it
237
294
  from the moment it starts. `ExecStop=/bin/kill -15 $MAINPID` is redundant, and
238
295
  becomes wrong under `KillMode=process` — that one leaves the rest of the unit
@@ -779,3 +836,111 @@ once rather than diagnosing later:
779
836
  `journalctl -u pmtiles-swarm | grep -i onComplete` — and a hook redirecting its
780
837
  own output to a file will have nothing for the journal to show, which is not
781
838
  the same as not having run.
839
+
840
+ ## When archives re-check on every start
841
+
842
+ The symptom is always the same shape — a restart, and then a library sitting at
843
+ 0% or grinding through a re-hash of archives that are whole on the disk. The
844
+ causes are not the same shape at all, so guessing between them is expensive:
845
+ re-hashing a 700 GiB archive is half an hour during which it serves nobody.
846
+
847
+ Two scripts in `tools/` answer it without guessing. Neither starts anything,
848
+ neither writes anything, and both are safe against a running node.
849
+
850
+ ```sh
851
+ sudo -u pmtiles-swarm python3 tools/resume-doctor.py \
852
+ -c /etc/pmtiles-swarm/swarm.config.json
853
+ ```
854
+
855
+ **`resume-doctor.py`** reads your configuration, catalog, stored `.torrent`
856
+ files and resume directory, and says for each archive what libtorrent will do on
857
+ the next start and why — seed at once, stat its files, or re-hash the store. It
858
+ applies libtorrent's own rules rather than a guess at them, and cites the file
859
+ and line each one came from. It also reads the unit through `systemctl show`,
860
+ does the arithmetic on every deadline that can cut a resume save short, and
861
+ scans the journal for the failures that leave a trace.
862
+
863
+ ```sh
864
+ sudo -u pmtiles-swarm python3 tools/resume-experiment.py
865
+ ```
866
+
867
+ **`resume-experiment.py`** proves the behaviour on the libtorrent you actually
868
+ have, rather than the one the documentation was written against. It builds a
869
+ real torrent over a temporary file, saves real resume data, adds it again, and
870
+ reports what happened. 2.0 and 2.1 differ enough to be worth the thirty seconds.
871
+
872
+ Three things they exist to tell apart, because the first two look identical from
873
+ outside and want opposite fixes:
874
+
875
+ - **No resume data is not the problem.** An archive recorded complete is added
876
+ with `seed_mode`, and that claim stands on its own: libtorrent stats the files
877
+ and seeds, without hashing a byte. A missing resume file costs nothing.
878
+ - **Stale resume data is the problem, and it is worse than none.** A resume file
879
+ holding a _partial_ bitfield cancels the `seed_mode` claim — libtorrent drops
880
+ it if a single piece is unset. The archive then comes back as a downloader
881
+ with every byte already on disk, and fetches again what it has. Deleting that
882
+ one file is the repair; with none, it seeds at once.
883
+ - **A short or missing file is a genuine re-check.** `mismatching_file_size` is
884
+ the only thing here that really does mean re-hashing, and it is about the data
885
+ on disk rather than about resume data at all. libtorrent 2.x records no
886
+ mtimes, so nothing in this depends on a timestamp.
887
+
888
+ Which archives carry stale resume data is not random, and the pattern is worth
889
+ knowing before it looks like a second bug. An archive from a watched folder was
890
+ read end to end before it was ever registered: it is complete from its first
891
+ moment and no partial bitfield is ever written for it. An archive joined from a
892
+ feed, a magnet or a peer is genuinely partial for hours, with partial resume
893
+ data written for it every `resumeSaveIntervalSeconds` throughout. Only the
894
+ second kind has anything to leave behind, so trouble concentrating there is this
895
+ failure's ordinary shape rather than something the import path introduced.
896
+
897
+ Two more things the doctor looks at, because they are the ones a snapshot alone
898
+ gets wrong.
899
+
900
+ **Whether the catalog still points at its own `.torrent` files.** Each entry
901
+ records `torrentPath` as an absolute path, and restore reads that rather than
902
+ recomputing it — so moving `dataDir` out of `/etc`, which this guide tells you
903
+ to do, leaves every recorded path behind. Nothing warns, because a missing
904
+ `.torrent` is not treated as an error: restore quietly falls back to the magnet.
905
+ That fallback is the damage. A magnet carries no metadata, resume data does not
906
+ carry it either, and so the archive waits on BEP 9 for a file list that only a
907
+ peer can supply — from a swarm where this node is the origin. It sits at 0% in
908
+ `downloading_metadata` indefinitely, which no amount of re-checking will fix.
909
+
910
+ **Whether the periodic save gets through the whole library.** Every torrent is
911
+ asked to save in the same cycle, so a healthy `resumeDir` has all its files
912
+ within one interval of each other. Ages spread wider than that mean some cycles
913
+ are finishing early, and the ages alone cannot say whether the same archives
914
+ lose every time. `--watch` measures it rather than inferring it:
915
+
916
+ ```sh
917
+ sudo -u pmtiles-swarm python3 tools/resume-doctor.py \
918
+ -c /etc/pmtiles-swarm/swarm.config.json --watch 660
919
+ ```
920
+
921
+ It reports each save cycle as it happens, how many of them covered every
922
+ archive, and which archives were never written at all.
923
+
924
+ **A poisoned archive may heal itself, so run this twice.** The commonest way a
925
+ whole archive acquires a partial bitfield is a save taken while it was
926
+ re-checking: `write_resume_data` truncates `have_pieces` to the pieces checked
927
+ so far, and the five-minute timer lands inside that window every time a large
928
+ archive checks. That shortened bitfield is what cancels `seed_mode` on the next
929
+ start — so the archive comes back partial, checks again, and writes another one.
930
+ The loop needs no restart to keep itself going.
931
+
932
+ The doctor tells the two apart by length: a bitfield shorter than the torrent
933
+ was written mid-check, and one at full length is what a finished check
934
+ concluded. A check that completes rewrites its own resume file correctly, so an
935
+ archive poisoned in one run can read `seeds at once` in the next with nothing
936
+ done to it. **Only the archives that stay poisoned across two runs, several
937
+ minutes apart, are worth deleting a file for.**
938
+
939
+ Two things it deliberately does not treat as faults. An archive whose data sits
940
+ outside the `savePathLayout` shape is normal — one adopted from a file this node
941
+ already holds keeps that file, and a node may keep archives beside whatever
942
+ produced them. What matters is whether the data is where the entry says, which
943
+ is checked directly. And every directory an archive actually lives in has to
944
+ appear in `ReadWritePaths`, not just the `savePath` in the configuration; the
945
+ doctor checks each one in use, because a deliberately-placed archive is exactly
946
+ the case that gets left out of the unit.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmtiles-swarm",
3
- "version": "0.58.1",
3
+ "version": "0.59.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",
@@ -46,7 +46,7 @@
46
46
  "maplibre-gl": "^6.2.0",
47
47
  "parse-torrent": "^11.0.24",
48
48
  "pmtiles": "^4.4.1",
49
- "pmtiles-torrent": "^0.9.1",
49
+ "pmtiles-torrent": "^0.10.2",
50
50
  "webtorrent": "^3.0.21"
51
51
  },
52
52
  "engines": {
package/src/config.js CHANGED
@@ -507,12 +507,52 @@ function clone(value) {
507
507
  return value;
508
508
  }
509
509
 
510
+ /**
511
+ * Every directory this configuration means to write to.
512
+ *
513
+ * Which is exactly what `ReadWritePaths` has to name. Under
514
+ * `ProtectSystem=strict` a directory missing from that line is refused inside
515
+ * the unit's namespace, before any permission bit is consulted — so it fails
516
+ * with ownership and mode both perfect, which is a hard thing to go looking
517
+ * for. Deriving the list from the config rather than writing it out by hand is
518
+ * the only way the two cannot drift.
519
+ *
520
+ * See docs/running-as-a-service.md — "Where it writes".
521
+ * @param {object} config - A config whose paths have been resolved.
522
+ * @param {string} [configPath] - The config file, whose directory is written to.
523
+ * @returns {string[]} - Absolute directories, deduplicated and shortest-first.
524
+ */
525
+ export function writablePaths(config, configPath) {
526
+ const found = [
527
+ config?.dataDir,
528
+ config?.savePath,
529
+ config?.cacheSavePath,
530
+ config?.libtorrent?.resumeDir,
531
+ config?.torrentDropDir,
532
+ // The console rewrites the configuration when a token is minted, so the
533
+ // directory holding it is written to as surely as any of the above.
534
+ configPath ? path.dirname(path.resolve(configPath)) : undefined,
535
+ ...(config?.watch ?? []).map((entry) => entry?.path),
536
+ ...(config?.locations ?? []).map((entry) => entry?.path),
537
+ ...(config?.subscriptions ?? []).map((entry) => entry?.savePath),
538
+ ].filter((value) => typeof value === 'string' && value);
539
+
540
+ // Deduplicated but deliberately not collapsed into common ancestors. A
541
+ // shorter list would grant the same access — `ReadWritePaths` covers a
542
+ // directory and everything under it — but the two callers want different
543
+ // things from it, and only one of them would be served. Creating the
544
+ // directories needs each of them named; and collapsing quietly widens the
545
+ // grant to whatever ancestor happens to be shared, which for a config beside
546
+ // its data is the whole tree above both.
547
+ return [...new Set(found.map((value) => path.resolve(value)))].sort();
548
+ }
549
+
510
550
  /**
511
551
  * Complains about state that has landed in /etc.
512
552
  *
513
553
  * Nobody chooses this. The documented service layout puts the config file in
514
- * /etc, every path resolves relative to that file, and the sample reads
515
- * "./data" — so the catalog and the resume directory end up on the partition
554
+ * /etc, every path resolves relative to that file, and the sample read
555
+ * "./data" — so the catalog and the resume directory ended up on the partition
516
556
  * meant for configuration. Warned rather than corrected: it is a real path
517
557
  * that works, and moving a running node's data would be worse than saying so.
518
558
  *
@@ -433,7 +433,7 @@ export class CompositeEngine {
433
433
  * @returns {Promise<{written: number, asked: number}>} - Totals across every
434
434
  * engine that keeps resume data.
435
435
  */
436
- async saveResume(infoHash) {
436
+ async saveResume(infoHash, options = {}) {
437
437
  // Summed rather than dropped, so a caller can tell the difference between
438
438
  // "every torrent wrote" and "half of them will be re-hashed on the next
439
439
  // start". An engine that fails outright contributes nothing to either
@@ -444,7 +444,7 @@ export class CompositeEngine {
444
444
  // WebTorrent keeps none, and says so by not offering the method.
445
445
  if (!engine.saveResume) continue;
446
446
  try {
447
- const result = (await engine.saveResume(infoHash)) ?? {};
447
+ const result = (await engine.saveResume(infoHash, options)) ?? {};
448
448
  written += Number(result.written) || 0;
449
449
  asked += Number(result.asked) || 0;
450
450
  } catch (error) {
@@ -653,19 +653,21 @@ export class CompositeEngine {
653
653
  * Shuts every engine down.
654
654
  * @returns {Promise<void>} - Resolves once all are stopped.
655
655
  */
656
- async destroy() {
656
+ async destroy(options = {}) {
657
657
  this.#stopping = true;
658
658
  if (this.#timer) clearInterval(this.#timer);
659
659
  this.#timer = undefined;
660
660
 
661
661
  for (const engine of this.#secondaries) {
662
662
  await engine
663
- .destroy()
663
+ .destroy(options)
664
664
  .catch((error) =>
665
665
  console.error(`[engine] ${engine.name}: ${error.message}`),
666
666
  );
667
667
  }
668
- await this.#primary.destroy();
668
+ // Forwarded, because the primary is the one holding resume data and the
669
+ // budget was worked out from how much of it there is to write.
670
+ await this.#primary.destroy(options);
669
671
  }
670
672
  }
671
673
 
@@ -763,25 +763,40 @@ export class LibtorrentEngine {
763
763
  * were told to write resume data, and how many actually did before the
764
764
  * deadline.
765
765
  */
766
- async saveResume(infoHash) {
766
+ async saveResume(infoHash, options = {}) {
767
767
  // Returned rather than discarded. The sidecar reports both numbers, and
768
768
  // the gap between them is the thing worth knowing: a torrent that did not
769
769
  // write is one that gets re-hashed on the next start, which for a 700 GiB
770
770
  // archive is the difference between seeding in seconds and seeding in half
771
771
  // an hour. That answer was being thrown away here.
772
- return this.#call('save_resume', { infoHash });
772
+ //
773
+ // The default #call timeout is 60s and the sidecar's own budget is two
774
+ // seconds per torrent, so past thirty archives the call gave up first —
775
+ // and then the counts never came back, which is why the shortfall this
776
+ // reports could not be seen from outside.
777
+ return this.#call('save_resume', { infoHash }, options.timeoutMs);
773
778
  }
774
779
 
775
780
  /**
776
781
  * Saves resume data and stops the sidecar.
782
+ *
783
+ * The timeout is the caller's to set, because only the caller knows how many
784
+ * torrents are about to be written down and the sidecar spends two seconds
785
+ * per torrent. Fifteen seconds was the fixed value, which meant every
786
+ * library past seven archives had its resume save cut off — and each torrent
787
+ * that missed was re-hashed in full on the way back up.
788
+ * @param {object} [options] - Overrides.
789
+ * @param {number} [options.timeoutMs] - How long the sidecar gets to finish.
777
790
  * @returns {Promise<void>} - Resolves once stopped.
778
791
  */
779
- async destroy() {
792
+ async destroy(options = {}) {
780
793
  // Set before anything else, so the exit this is about to cause is
781
794
  // recognised as intended by the handler that sees it.
782
795
  this.#stopping = true;
783
796
  if (!this.#child) return;
784
- await this.#call('shutdown', {}, 15000).catch(() => {});
797
+ await this.#call('shutdown', {}, options.timeoutMs ?? 15000).catch(
798
+ () => {},
799
+ );
785
800
  this.#child?.kill();
786
801
  this.#child = null;
787
802
  this.#ready = null;
package/src/index.js CHANGED
@@ -16,7 +16,12 @@ import { assertPortsFree, claimDataDir } from './lock.js';
16
16
  import { ProgramHooks } from './hooks.js';
17
17
  import { SpeedLimits } from './rate-limits.js';
18
18
  import { SeedingLimits } from './seeding.js';
19
- import { closeServer, installSignalHandlers, runStoppers } from './shutdown.js';
19
+ import {
20
+ closeServer,
21
+ engineStopMs,
22
+ installSignalHandlers,
23
+ runStoppers,
24
+ } from './shutdown.js';
20
25
  import { ScheduledSourceManager } from './sources.js';
21
26
  import { SubscriptionManager } from './subscriptions.js';
22
27
  import { TileStats } from './tile-stats.js';
@@ -100,6 +105,12 @@ async function main() {
100
105
  port: { type: 'string', short: 'p' },
101
106
  help: { type: 'boolean', short: 'h' },
102
107
  json: { type: 'boolean' },
108
+ 'data-dir': { type: 'string' },
109
+ 'save-path': { type: 'string' },
110
+ force: { type: 'boolean' },
111
+ systemd: { type: 'boolean' },
112
+ user: { type: 'string' },
113
+ password: { type: 'string' },
103
114
  },
104
115
  allowPositionals: true,
105
116
  });
@@ -109,10 +120,17 @@ async function main() {
109
120
 
110
121
  Usage:
111
122
  pmtiles-swarm [--config FILE] start the node
123
+ pmtiles-swarm init [--config FILE] write a first configuration
112
124
  pmtiles-swarm status [--config FILE] ask a running node what it is doing
113
125
  pmtiles-swarm publisher-key print a new BEP 46 signing key
114
126
 
115
127
  --config, -c path to a JSON config file
128
+ --data-dir where state goes, for init. Absolute, and never under /etc
129
+ --save-path where archive data goes, for init
130
+ --systemd also write a unit file, for init
131
+ --user service account the unit runs as. Default pmtiles-swarm
132
+ --password console password, stored hashed. For init
133
+ --force let init replace a config that already exists
116
134
  --port, -p override the listen port
117
135
  --json machine-readable output, for the status command
118
136
  --help, -h this message
@@ -124,6 +142,21 @@ PMTILES_SWARM_PUBLIC_URL
124
142
  return;
125
143
  }
126
144
 
145
+ // Ahead of loadConfig, which is the point: there is no config to load yet.
146
+ if (positionals[0] === 'init') {
147
+ const { runInit } = await import('./init-command.js');
148
+ process.exitCode = await runInit({
149
+ config: values.config,
150
+ dataDir: values['data-dir'],
151
+ savePath: values['save-path'],
152
+ systemd: values.systemd,
153
+ user: values.user,
154
+ password: values.password,
155
+ force: values.force,
156
+ });
157
+ return;
158
+ }
159
+
127
160
  const config = await loadConfig(values.config);
128
161
  if (values.port) config.port = Number(values.port);
129
162
 
@@ -211,7 +244,19 @@ PMTILES_SWARM_PUBLIC_URL
211
244
  const engine = createEngine(config);
212
245
  // The slowest, because it announces "stopped" to every tracker, and an
213
246
  // unreachable one costs a timeout each. Registered first so it stops last.
214
- stoppers.unshift({ label: 'engine', stop: () => engine.destroy(), ms: 8000 });
247
+ //
248
+ // Scaled to the library, because what dominates this step is writing resume
249
+ // data and the sidecar gives each torrent two seconds of that budget. A flat
250
+ // eight seconds was under it for any node past four archives, so the node
251
+ // abandoned the sidecar mid-write on every stop and re-hashed on the way
252
+ // back up whatever had not been persisted — which is how a library ends up
253
+ // permanently checking rather than seeding.
254
+ const resumeStopMs = engineStopMs(catalog.list().length);
255
+ stoppers.unshift({
256
+ label: 'engine',
257
+ stop: () => engine.destroy({ timeoutMs: resumeStopMs }),
258
+ ms: resumeStopMs + 2000,
259
+ });
215
260
  try {
216
261
  await engine.connect();
217
262
  console.log(`[engine] ${engine.name} ready`);
@@ -528,7 +573,12 @@ PMTILES_SWARM_PUBLIC_URL
528
573
  if (resumeSeconds > 0 && engine.saveResume) {
529
574
  resumeTimer = setInterval(() => {
530
575
  engine
531
- .saveResume()
576
+ // Recomputed each tick rather than captured, because the library grows:
577
+ // a node that started with four archives and watched forty in would
578
+ // otherwise still be allowing the budget for four.
579
+ .saveResume(undefined, {
580
+ timeoutMs: engineStopMs(catalog.list().length),
581
+ })
532
582
  .then((result) => {
533
583
  // Said out loud, because the shortfall is the thing that costs.
534
584
  // A torrent that did not write inside the deadline is one that gets
@@ -0,0 +1,231 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { hashPassword } from './auth.js';
6
+ import { writablePaths } from './config.js';
7
+ import { directoryCommands, unitFor } from './systemd.js';
8
+
9
+ /**
10
+ * Where state goes when nobody said.
11
+ *
12
+ * Never beside the config file, which is the whole point of this command: a
13
+ * path relative to a config in /etc puts a catalog and an archive on the
14
+ * partition meant for configuration. See docs/running-as-a-service.md.
15
+ * @returns {string} - An absolute directory.
16
+ */
17
+ function defaultDataDir() {
18
+ const service =
19
+ process.platform !== 'win32' &&
20
+ typeof process.getuid === 'function' &&
21
+ process.getuid() === 0;
22
+ return service
23
+ ? '/var/lib/pmtiles-swarm/data'
24
+ : path.join(process.cwd(), 'data');
25
+ }
26
+
27
+ /**
28
+ * The configuration a new node starts from.
29
+ * @param {object} paths - Resolved dataDir, savePath and any password hash.
30
+ * @returns {object} - The config to write.
31
+ */
32
+ function firstConfig({ dataDir, savePath, passwordHash }) {
33
+ return {
34
+ port: 8090,
35
+ adminPort: 8091,
36
+ adminHost: '127.0.0.1',
37
+
38
+ dataDir,
39
+ savePath,
40
+ savePathLayout: 'infohash',
41
+
42
+ engine: 'libtorrent',
43
+ secondaryEngines: ['webtorrent'],
44
+ libtorrent: {
45
+ python: 'python3',
46
+ resumeDir: path.join(dataDir, 'resume'),
47
+ listen: '0.0.0.0:6881',
48
+ upnp: true,
49
+ natpmp: true,
50
+ },
51
+ webtorrent: { clientOptions: { torrentPort: 6882 } },
52
+
53
+ torrentFormat: 'hybrid',
54
+ pieceLength: 4194304,
55
+ maxConnections: 100,
56
+ trackers: [
57
+ 'udp://tracker.opentrackr.org:1337/announce',
58
+ 'udp://tracker.torrent.eu.org:451/announce',
59
+ 'udp://tracker-udp.gbitt.info:80/announce',
60
+ 'wss://tracker.openwebtorrent.com',
61
+ 'wss://tracker.webtorrent.dev',
62
+ ],
63
+
64
+ auth: {
65
+ username: 'admin',
66
+ // Generated rather than left as a placeholder. A sample saying
67
+ // REPLACE-ME is a sample that ships unreplaced.
68
+ apiKey: crypto.randomBytes(32).toString('hex'),
69
+ // Hashed here, never stored as the plaintext that was typed. A password
70
+ // key is written only when one was given: the same reasoning as the API
71
+ // key, one step further on. A placeholder would be a credential that
72
+ // looks set and is not, and `auth.password` accepts plaintext — so
73
+ // "REPLACE-ME" in that field is a working password until somebody
74
+ // notices.
75
+ ...(passwordHash ? { passwordHash } : {}),
76
+ tokens: [],
77
+ },
78
+ };
79
+ }
80
+
81
+ /**
82
+ * Writes a first configuration file.
83
+ *
84
+ * Everything it writes is absolute. A path that resolves against the config
85
+ * file is the one mistake this command exists to make impossible: the
86
+ * documented layout puts that file in /etc, so `./data` there means a catalog,
87
+ * a resume directory and potentially a 700 GiB archive on the configuration
88
+ * partition — and the person who followed both documents did nothing wrong.
89
+ *
90
+ * State under /etc is refused rather than warned about. At the moment a config
91
+ * is written there is nothing to preserve and nothing to migrate, so refusing
92
+ * costs a retyped flag; the same mistake found later costs a stopped service
93
+ * and a careful move.
94
+ * @param {object} [options] - Flags from the command line.
95
+ * @param {Function} [write] - Where to report, for tests.
96
+ * @returns {Promise<number>} - Exit code.
97
+ */
98
+ export async function runInit(options = {}, write = console.log) {
99
+ const configPath = path.resolve(
100
+ options.config ?? path.join(process.cwd(), 'swarm.config.json'),
101
+ );
102
+ const dataDir = path.resolve(options.dataDir ?? defaultDataDir());
103
+ const savePath = path.resolve(
104
+ options.savePath ?? path.join(dataDir, 'torrents-data'),
105
+ );
106
+
107
+ // Both what was typed and what it resolved to: on Windows the first is the
108
+ // only one that can be recognised, and on POSIX the second catches a
109
+ // relative path that lands there anyway.
110
+ for (const [name, ...values] of [
111
+ ['--data-dir', options.dataDir, dataDir],
112
+ ['--save-path', options.savePath, savePath],
113
+ ]) {
114
+ const value = values.find(
115
+ (candidate) =>
116
+ typeof candidate === 'string' && candidate.startsWith('/etc/'),
117
+ );
118
+ if (value) {
119
+ write(
120
+ `${name} is ${value}. /etc is for configuration; state belongs under ` +
121
+ '/var/lib or a data disk. Nothing has been written.',
122
+ );
123
+ return 1;
124
+ }
125
+ }
126
+
127
+ const exists = await fs
128
+ .access(configPath)
129
+ .then(() => true)
130
+ .catch(() => false);
131
+ if (exists && !options.force) {
132
+ write(
133
+ `${configPath} already exists. Pass --force to replace it — and take a ` +
134
+ 'copy first, since the tokens in it are not recoverable.',
135
+ );
136
+ return 1;
137
+ }
138
+
139
+ await fs.mkdir(path.dirname(configPath), { recursive: true });
140
+ const config = firstConfig({
141
+ dataDir,
142
+ savePath,
143
+ passwordHash: options.password ? hashPassword(options.password) : undefined,
144
+ });
145
+ await fs.writeFile(configPath, `${JSON.stringify(config, null, 2)}${os.EOL}`);
146
+
147
+ write(`Wrote ${configPath}`);
148
+ write('');
149
+ write(` dataDir ${dataDir}`);
150
+ write(` savePath ${savePath}`);
151
+ write(` resumeDir ${config.libtorrent.resumeDir}`);
152
+ write('');
153
+
154
+ const unitPath = path.join(path.dirname(configPath), 'pmtiles-swarm.service');
155
+ if (options.systemd) {
156
+ // Written beside the configuration rather than into /etc/systemd/system.
157
+ // Installing a unit is a privileged, system-changing act and this command
158
+ // may be run by anyone; the copy is one line and it is the caller's to
159
+ // make.
160
+ await fs.writeFile(
161
+ unitPath,
162
+ unitFor({
163
+ config,
164
+ configPath,
165
+ user: options.user,
166
+ execStart: options.execStart,
167
+ }),
168
+ );
169
+ write(`Wrote ${unitPath}`);
170
+ write('');
171
+ write(' Its ReadWritePaths was derived from the configuration above, so');
172
+ write(' the two cannot disagree. Adding a watched folder later means');
173
+ write(' re-running this, or adding the folder to that line by hand.');
174
+ if (path.sep !== '/') {
175
+ // Said rather than refused: writing the unit is still the fastest way to
176
+ // see its shape, and somebody may be preparing a config to carry across.
177
+ // But every path in it is this machine's, and systemd will not read them.
178
+ write('');
179
+ write(' Written on a platform that is not the one this runs on, so the');
180
+ write(' paths in it are this machine’s. Re-run init on the server, or');
181
+ write(' rewrite every path in the unit before installing it.');
182
+ }
183
+ write('');
184
+ }
185
+
186
+ write('Next:');
187
+ write('');
188
+ for (const command of directoryCommands({
189
+ config,
190
+ configPath,
191
+ user: options.user,
192
+ })) {
193
+ write(` ${command}`);
194
+ }
195
+ write('');
196
+
197
+ if (options.systemd) {
198
+ write(` sudo cp ${unitPath} /etc/systemd/system/`);
199
+ write(' sudo systemctl daemon-reload');
200
+ write(' sudo systemctl enable --now pmtiles-swarm');
201
+ write('');
202
+ }
203
+
204
+ if (!config.auth.passwordHash) {
205
+ write(' There is no console password. The API key in the file is the way');
206
+ write(' in until you set one: re-run with --password, or add a plaintext');
207
+ write(' auth.password, which is hashed the first time it is read.');
208
+ write('');
209
+ }
210
+
211
+ write(' Point a watch folder at where your archives are built, or add one');
212
+ write(' from the console. A watched folder is a directory this writes to,');
213
+ write(
214
+ options.systemd
215
+ ? ' so it has to go in ReadWritePaths as well.'
216
+ : ' so it has to go in the unit ReadWritePaths as well.',
217
+ );
218
+
219
+ if (!options.systemd) {
220
+ // Still printed for anyone writing the unit by hand, which is the case
221
+ // this was for before there was a generator. Derived from the same place
222
+ // the generated one is, so the advice cannot be worse than the file.
223
+ write('');
224
+ write('Under systemd every one of those has to be in ReadWritePaths:');
225
+ write('');
226
+ write(` ReadWritePaths=${writablePaths(config, configPath).join(' ')}`);
227
+ write('');
228
+ write('Re-run with --systemd to have that written for you, as a unit.');
229
+ }
230
+ return 0;
231
+ }
package/src/library.js CHANGED
@@ -667,16 +667,77 @@ export class Library {
667
667
  * @param {object} entry - Catalog entry.
668
668
  * @returns {Promise<void>} - Resolves once added.
669
669
  */
670
- async #readd(entry) {
671
- const torrentFile = entry.torrentPath
672
- ? await fs
673
- .readFile(entry.torrentPath)
674
- .then((buffer) => new Uint8Array(buffer))
675
- .catch(() => null)
670
+ /**
671
+ * An entry's metainfo, from wherever it actually is.
672
+ *
673
+ * `torrentPath` is recorded absolute, so moving `dataDir` — which the service
674
+ * guide tells you to do, out of `/etc` — leaves every entry in the catalog
675
+ * naming a directory that no longer exists. Nothing repoints them, and
676
+ * nothing complained, because an unreadable `.torrent` was simply treated as
677
+ * "use the magnet instead".
678
+ *
679
+ * So the recorded path is tried first and the current `dataDir` second. The
680
+ * file is stored under the infohash either way, which is what makes the
681
+ * second lookup possible at all.
682
+ * @param {object} entry - The catalog entry.
683
+ * @returns {Promise<Uint8Array | null>} - The metainfo, or null.
684
+ */
685
+ async #metainfoFor(entry) {
686
+ const read = (file) =>
687
+ file
688
+ ? fs
689
+ .readFile(file)
690
+ .then((buffer) => new Uint8Array(buffer))
691
+ .catch(() => null)
692
+ : Promise.resolve(null);
693
+
694
+ const recorded = await read(entry.torrentPath);
695
+ if (recorded) return recorded;
696
+
697
+ const beside = entry.infoHash
698
+ ? path.join(this.torrentDir, `${entry.infoHash}.torrent`)
676
699
  : null;
700
+ if (!beside || beside === entry.torrentPath) return null;
701
+
702
+ const found = await read(beside);
703
+ if (!found) return null;
704
+
705
+ console.warn(
706
+ `[restore] ${entry.name}: the catalog names ${entry.torrentPath}, which ` +
707
+ `is not there; using ${beside} instead. Its recorded path is being ` +
708
+ 'corrected.',
709
+ );
710
+ // Corrected in place, so the warning is printed once rather than on every
711
+ // start for the rest of the node's life.
712
+ await this.#catalog
713
+ .put({ infoHash: entry.infoHash, torrentPath: beside })
714
+ .catch(() => null);
715
+ return found;
716
+ }
717
+
718
+ async #readd(entry) {
719
+ const torrentFile = await this.#metainfoFor(entry);
677
720
  const magnet = torrentFile ? undefined : this.#withTrackers(entry);
678
721
  if (!torrentFile && !magnet) return;
679
722
 
723
+ // Falling back to the magnet is not a quiet equivalent, and it used to
724
+ // happen silently. A magnet carries no metadata and neither does resume
725
+ // data, so the archive waits on BEP 9 for a file list — which no peer can
726
+ // supply for an archive this node originated. It sits at 0% in
727
+ // downloading_metadata indefinitely, and no re-check will move it.
728
+ //
729
+ // Only where the entry claims a metainfo it cannot produce. An archive
730
+ // that never had one was joined by magnet and is waiting on
731
+ // captureMetadata in the ordinary way; saying this about it would be a
732
+ // warning on the normal path.
733
+ if (!torrentFile && entry.torrentPath) {
734
+ console.warn(
735
+ `[restore] ${entry.name}: no .torrent could be read, so it is being ` +
736
+ 'added as a magnet and must fetch its metadata from a peer. If this ' +
737
+ 'node is the only seeder, it will not finish.',
738
+ );
739
+ }
740
+
680
741
  await this.#engine.add({
681
742
  torrentFile: torrentFile ?? undefined,
682
743
  magnet,
package/src/shutdown.js CHANGED
@@ -18,8 +18,53 @@
18
18
  * every step is bounded, and the whole sequence is bounded again behind that.
19
19
  */
20
20
 
21
- /** How long the whole shutdown may take before it gives up on itself. */
22
- const WATCHDOG_MS = 15000;
21
+ /** Slack over the sum of the steps, for the shutdown's own bookkeeping. */
22
+ const WATCHDOG_SLACK_MS = 5000;
23
+
24
+ /** What a step is allowed when it does not say. */
25
+ const STEP_MS = 5000;
26
+
27
+ /**
28
+ * How long the whole shutdown may take before it gives up on itself.
29
+ *
30
+ * Derived from the steps rather than fixed, because a fixed bound is a second
31
+ * deadline that has to be kept in agreement with them by hand — and was not.
32
+ * Fifteen seconds sat under an engine step that legitimately wants two seconds
33
+ * per torrent, so on any library past seven archives the watchdog fired first
34
+ * and exited the process while the sidecar was still writing resume data. What
35
+ * that costs is a re-hash of everything that had not been written yet, on the
36
+ * way back up.
37
+ *
38
+ * Computed when the signal arrives, so a step registered later is still
39
+ * covered.
40
+ * @param {Array<{ms?: number}>} stoppers - The steps to be run.
41
+ * @returns {number} - The bound, in milliseconds.
42
+ */
43
+ export function watchdogFor(stoppers) {
44
+ const total = stoppers.reduce(
45
+ (sum, stopper) => sum + (stopper.ms ?? STEP_MS),
46
+ 0,
47
+ );
48
+ return total + WATCHDOG_SLACK_MS;
49
+ }
50
+
51
+ /**
52
+ * How long to let the engine stop, given how much it has to write down.
53
+ *
54
+ * The sidecar allows each torrent two seconds of its resume-save budget, so
55
+ * the only bound that does not eventually cut a library short is one that
56
+ * counts them. The floor covers a node with nothing in it, where what remains
57
+ * is announcing "stopped" to trackers that may not answer.
58
+ *
59
+ * Whatever this returns has to be under the unit's `TimeoutStopSec`, or
60
+ * systemd kills the process while it is still working. See
61
+ * docs/running-as-a-service.md.
62
+ * @param {number} archives - How many archives the catalog holds.
63
+ * @returns {number} - Milliseconds.
64
+ */
65
+ export function engineStopMs(archives) {
66
+ return Math.max(15000, archives * 2000 + 10000);
67
+ }
23
68
 
24
69
  /**
25
70
  * Runs one shutdown step, giving up on it rather than waiting for ever.
@@ -125,11 +170,14 @@ export function installSignalHandlers(stoppers, options = {}) {
125
170
  stopping = true;
126
171
  console.log(`\n[shutdown] ${signal}`);
127
172
 
128
- // A last resort, in case a step ignores its own bound.
173
+ // A last resort, in case a step ignores its own bound — never a bound in
174
+ // its own right, which is what it silently became when it was shorter than
175
+ // the steps it was meant to outlast.
176
+ const limitMs = watchdogFor(stoppers);
129
177
  const watchdog = setTimeout(() => {
130
- console.warn('[shutdown] took too long; exiting anyway');
178
+ console.warn(`[shutdown] took longer than ${limitMs}ms; exiting anyway`);
131
179
  exit(1);
132
- }, WATCHDOG_MS);
180
+ }, limitMs);
133
181
 
134
182
  await runStoppers(stoppers);
135
183
 
package/src/systemd.js ADDED
@@ -0,0 +1,158 @@
1
+ import path from 'node:path';
2
+ import { writablePaths } from './config.js';
3
+
4
+ /**
5
+ * A unit file written from the configuration it will run.
6
+ *
7
+ * Every failure this project has diagnosed under systemd came from the unit
8
+ * and the configuration disagreeing, and none of them looked like what they
9
+ * were:
10
+ *
11
+ * - a `savePath` missing from `ReadWritePaths` fails inside the unit's
12
+ * namespace, before any permission bit is read, so the directory's owner
13
+ * and mode are both perfect and the write is still refused
14
+ * - the default `KillMode` signals the Python sidecar at the same instant as
15
+ * the node, so the sidecar dies before it can write resume data and every
16
+ * archive returns at 0% to be re-checked
17
+ * - a `TimeoutStopSec` shorter than the resume save kills the process
18
+ * mid-write, which costs the same re-check by a different route
19
+ *
20
+ * None of that is discoverable from the symptom. All of it is decidable from
21
+ * the configuration, which is why this is generated rather than documented.
22
+ */
23
+
24
+ /** What the resume save can want, before the library has grown into it. */
25
+ const TIMEOUT_STOP_SECONDS = 300;
26
+
27
+ /**
28
+ * The unit file for a configuration.
29
+ * @param {object} options - What to write.
30
+ * @param {object} options.config - The resolved configuration.
31
+ * @param {string} options.configPath - Where that configuration lives.
32
+ * @param {string} [options.user] - Service account. Default 'pmtiles-swarm'.
33
+ * @param {string} [options.execStart] - The binary. Default the documented install.
34
+ * @param {string} [options.workingDirectory] - Default the binary's install root.
35
+ * @returns {string} - The unit, ready to install.
36
+ */
37
+ export function unitFor({
38
+ config,
39
+ configPath,
40
+ user = 'pmtiles-swarm',
41
+ execStart,
42
+ workingDirectory,
43
+ }) {
44
+ const home = workingDirectory ?? `/var/lib/${user}`;
45
+ const binary = execStart ?? `${home}/node_modules/.bin/pmtiles-swarm`;
46
+ const paths = writablePaths(config, configPath);
47
+
48
+ // Wrapped the way systemd's own examples are, because this list grows with
49
+ // every watched folder and a single line of them is unreadable in a diff.
50
+ const readWrite = paths.join(' \\\n ');
51
+
52
+ return `# pmtiles-swarm, generated by \`pmtiles-swarm init --systemd\`.
53
+ #
54
+ # Install it, then check what systemd actually merged — which is the only
55
+ # account of it worth trusting:
56
+ #
57
+ # sudo cp pmtiles-swarm.service /etc/systemd/system/
58
+ # sudo systemctl daemon-reload
59
+ # sudo systemctl enable --now pmtiles-swarm
60
+ # systemctl show -p ReadWritePaths -p KillMode -p TimeoutStopUSec pmtiles-swarm
61
+ #
62
+ # ReadWritePaths below was derived from ${path.basename(configPath)}. Adding a
63
+ # watched folder, a subscription with its own save path, or a cache directory
64
+ # to that file means adding it here too — or the write is refused with the
65
+ # directory's permissions perfect. Re-running init --systemd rewrites this.
66
+
67
+ [Unit]
68
+ Description=pmtiles-swarm
69
+ Documentation=https://github.com/TechIdiots-LLC/pmtiles-swarm
70
+ After=network-online.target
71
+ Wants=network-online.target
72
+
73
+ [Service]
74
+ Type=simple
75
+ User=${user}
76
+ Group=${user}
77
+
78
+ WorkingDirectory=${home}
79
+
80
+ # Absolute: systemd reads no shell profile.
81
+ ExecStart=${binary} \\
82
+ --config ${path.resolve(configPath)}
83
+
84
+ # Required, not a preference. The console's Save & Restart applies settings a
85
+ # running process cannot take, so it exits 0 and expects to be brought back;
86
+ # Restart=on-failure ignores an exit 0 and would leave the node stopped with
87
+ # the unit reporting success.
88
+ Restart=always
89
+ RestartSec=5
90
+
91
+ # Stopping writes resume data for every archive, and the node allows its engine
92
+ # two seconds per torrent to do it. This has to outlast that: killed mid-save,
93
+ # every archive that had not been written re-hashes its whole store on the way
94
+ # back up, which for a 700 GiB archive is hours. Raise it as the library grows
95
+ # — tools/resume-doctor.py prints the arithmetic for yours.
96
+ TimeoutStopSec=${TIMEOUT_STOP_SECONDS}
97
+
98
+ # The node stops the sidecar itself, and needs it alive to do so. The default,
99
+ # control-group, signals both at once and the sidecar dies before it can write
100
+ # anything. That is what a library returning at 0% after every restart is.
101
+ KillMode=mixed
102
+
103
+ # A seeding node holds a socket per peer, and the tile reader holds file
104
+ # descriptors of its own.
105
+ LimitNOFILE=65535
106
+
107
+ # Everything not named below is read-only inside this unit's namespace.
108
+ ProtectSystem=strict
109
+ ProtectHome=read-only
110
+ PrivateTmp=true
111
+ NoNewPrivileges=true
112
+
113
+ ReadWritePaths=${readWrite}
114
+
115
+ # Group-writable, so what this service creates in a folder shared with whatever
116
+ # builds the archives can still be modified by it.
117
+ UMask=0002
118
+
119
+ [Install]
120
+ WantedBy=multi-user.target
121
+ `;
122
+ }
123
+
124
+ /**
125
+ * The commands that make the directories this unit needs, owned correctly.
126
+ *
127
+ * Printed rather than run. init may be running as root or as a person, and a
128
+ * directory created by the wrong one is the same refused write as a missing
129
+ * `ReadWritePaths` entry — with even less to go on, because the unit is right.
130
+ * @param {object} options - What to write.
131
+ * @param {object} options.config - The resolved configuration.
132
+ * @param {string} options.configPath - Where that configuration lives.
133
+ * @param {string} [options.user] - Service account.
134
+ * @returns {string[]} - Shell commands, in the order they should be run.
135
+ */
136
+ export function directoryCommands({
137
+ config,
138
+ configPath,
139
+ user = 'pmtiles-swarm',
140
+ }) {
141
+ // The config's own directory is excluded: it exists already, since the file
142
+ // was just written into it, and it is usually /etc where this would be wrong.
143
+ const configDir = path.dirname(path.resolve(configPath));
144
+ const wanted = writablePaths(config, configPath).filter(
145
+ (value) => value !== configDir,
146
+ );
147
+
148
+ return [
149
+ `sudo useradd --system --home-dir /var/lib/${user} --shell /usr/sbin/nologin ${user}`,
150
+ ...wanted.map(
151
+ (value) => `sudo install -d -o ${user} -g ${user} -m 0775 ${value}`,
152
+ ),
153
+ // The directory as well as the file. The console rewrites the
154
+ // configuration by writing a temp file beside it and renaming, which needs
155
+ // write permission on the directory rather than on the file.
156
+ `sudo chown ${user} ${configDir} ${path.resolve(configPath)}`,
157
+ ];
158
+ }