pinnace 0.0.0 → 0.2.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.
Files changed (83) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +164 -0
  3. package/dist/car/car-build.d.ts +26 -0
  4. package/dist/car/car-build.d.ts.map +1 -0
  5. package/dist/car/car-build.js +113 -0
  6. package/dist/car/car-build.js.map +1 -0
  7. package/dist/ci/ci-emit.d.ts +116 -0
  8. package/dist/ci/ci-emit.d.ts.map +1 -0
  9. package/dist/ci/ci-emit.js +190 -0
  10. package/dist/ci/ci-emit.js.map +1 -0
  11. package/dist/cli/bin.d.ts +3 -0
  12. package/dist/cli/bin.d.ts.map +1 -0
  13. package/dist/cli/bin.js +17 -0
  14. package/dist/cli/bin.js.map +1 -0
  15. package/dist/cli/run.d.ts +94 -0
  16. package/dist/cli/run.d.ts.map +1 -0
  17. package/dist/cli/run.js +731 -0
  18. package/dist/cli/run.js.map +1 -0
  19. package/dist/config/config-resolution.d.ts +186 -0
  20. package/dist/config/config-resolution.d.ts.map +1 -0
  21. package/dist/config/config-resolution.js +137 -0
  22. package/dist/config/config-resolution.js.map +1 -0
  23. package/dist/deploy/deploy.d.ts +121 -0
  24. package/dist/deploy/deploy.d.ts.map +1 -0
  25. package/dist/deploy/deploy.js +150 -0
  26. package/dist/deploy/deploy.js.map +1 -0
  27. package/dist/derive/ipns-key-derivation.d.ts +37 -0
  28. package/dist/derive/ipns-key-derivation.d.ts.map +1 -0
  29. package/dist/derive/ipns-key-derivation.js +130 -0
  30. package/dist/derive/ipns-key-derivation.js.map +1 -0
  31. package/dist/index.d.ts +27 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +29 -0
  34. package/dist/index.js.map +1 -0
  35. package/dist/node/node-commands.d.ts +115 -0
  36. package/dist/node/node-commands.d.ts.map +1 -0
  37. package/dist/node/node-commands.js +231 -0
  38. package/dist/node/node-commands.js.map +1 -0
  39. package/dist/provision/cloud-init.d.ts +162 -0
  40. package/dist/provision/cloud-init.d.ts.map +1 -0
  41. package/dist/provision/cloud-init.js +460 -0
  42. package/dist/provision/cloud-init.js.map +1 -0
  43. package/dist/publisher/key-import.d.ts +105 -0
  44. package/dist/publisher/key-import.d.ts.map +1 -0
  45. package/dist/publisher/key-import.js +76 -0
  46. package/dist/publisher/key-import.js.map +1 -0
  47. package/dist/publisher/record-sequence.d.ts +90 -0
  48. package/dist/publisher/record-sequence.d.ts.map +1 -0
  49. package/dist/publisher/record-sequence.js +250 -0
  50. package/dist/publisher/record-sequence.js.map +1 -0
  51. package/dist/rpc/kubo-rpc-client.d.ts +154 -0
  52. package/dist/rpc/kubo-rpc-client.d.ts.map +1 -0
  53. package/dist/rpc/kubo-rpc-client.js +207 -0
  54. package/dist/rpc/kubo-rpc-client.js.map +1 -0
  55. package/dist/rpc/mock-kubo.d.ts +87 -0
  56. package/dist/rpc/mock-kubo.d.ts.map +1 -0
  57. package/dist/rpc/mock-kubo.js +129 -0
  58. package/dist/rpc/mock-kubo.js.map +1 -0
  59. package/dist/site/site-management.d.ts +126 -0
  60. package/dist/site/site-management.d.ts.map +1 -0
  61. package/dist/site/site-management.js +123 -0
  62. package/dist/site/site-management.js.map +1 -0
  63. package/dist/status/status-report.d.ts +145 -0
  64. package/dist/status/status-report.d.ts.map +1 -0
  65. package/dist/status/status-report.js +192 -0
  66. package/dist/status/status-report.js.map +1 -0
  67. package/package.json +45 -2
  68. package/src/car/car-build.ts +135 -0
  69. package/src/ci/ci-emit.ts +284 -0
  70. package/src/cli/bin.ts +20 -0
  71. package/src/cli/run.ts +970 -0
  72. package/src/config/config-resolution.ts +264 -0
  73. package/src/deploy/deploy.ts +250 -0
  74. package/src/derive/ipns-key-derivation.ts +173 -0
  75. package/src/index.ts +146 -0
  76. package/src/node/node-commands.ts +395 -0
  77. package/src/provision/cloud-init.ts +646 -0
  78. package/src/publisher/key-import.ts +141 -0
  79. package/src/publisher/record-sequence.ts +336 -0
  80. package/src/rpc/kubo-rpc-client.ts +281 -0
  81. package/src/rpc/mock-kubo.ts +194 -0
  82. package/src/site/site-management.ts +241 -0
  83. package/src/status/status-report.ts +291 -0
@@ -0,0 +1,646 @@
1
+ /**
2
+ * The **cloud-init generator** behind the `HostProvider` seam (CONTEXT.md `host
3
+ * provider seam`). `provision` asks the core for a ready-to-paste cloud-init:
4
+ * the host-specific YAML that stands up a hardened Kubo node. Hetzner is the
5
+ * FIRST (and, in v1, only) implementation; other hosts are Out of Scope but
6
+ * slot in behind this same seam later (spec "Out of Scope"; user stories 1, 21).
7
+ *
8
+ * This module is PURE: {@link provision} takes a plain host/site/role config
9
+ * and returns the cloud-init file (path + contents) as data. It does NOT touch
10
+ * the filesystem, the network, or SSH. The CLI wrapper (`provision` command)
11
+ * is what hands the YAML to the operator. Keeping the emit pure lets it be
12
+ * snapshot-tested as a string with no fixtures (test-first policy) and reused
13
+ * as a TypeScript API (CONTEXT.md `core vs cli`).
14
+ *
15
+ * SEAM DISCIPLINE (user story 21): only host-specific provisioning lives behind
16
+ * `HostProvider`. Deploy/publish/status speak ONLY Kubo RPC and must not import
17
+ * this module. Adding a host later touches nothing but this file + the
18
+ * {@link HOST_PROVIDERS} registry. (The dependency direction is one-way: this
19
+ * module may reference the shared `HostRole` type, but nothing in deploy/
20
+ * publish/status references this module.)
21
+ *
22
+ * KEY CHANGE FROM THE REFERENCE (docs/adr/0002-on-box-agent-boundary.md): the
23
+ * emitted cloud-init does NOT bake the reference's four bash scripts
24
+ * (`ipfs-warm.sh`, `ipfs-ipns-publish.sh`, `ipfs-ipns-mirror.sh`,
25
+ * `ipfs-status.sh`). Instead it INSTALLS the `pinnace` binary on the box and
26
+ * schedules the on-box subcommands (`pinnace node republish|mirror|warm|
27
+ * status`) on role-gated systemd timers. One codebase runs both as the
28
+ * operator's client and as the box's periodic agent, so the record/warm/mirror/
29
+ * status logic has a single implementation (no bash/TS drift). Kubo still owns
30
+ * pinning (`dag/import --pin-roots`) + provider-record freshness
31
+ * (`Provide.Interval`); the pinnace timers own ONLY IPNS republish/export,
32
+ * replica mirror/fallback, gateway warm, and status. The reference bash is used
33
+ * here as the BEHAVIOURAL SPEC of what each timer must do, not as code to emit.
34
+ *
35
+ * INVARIANTS ENCODED (ported, not copied, from `~/searches/ipfs-hetzner/`):
36
+ * - Kubo as a hardened systemd unit (dedicated `ipfs` user, sandboxing).
37
+ * - ufw opens 4001 TCP+UDP (swarm, so gateways can dial) + 80/443 (Caddy/ACME);
38
+ * the RPC API (5001) and gateway (8080) are bound to localhost and NEVER
39
+ * opened in the firewall.
40
+ * - discoverability: AcceleratedDHTClient + `Routing.Type auto` + reprovide
41
+ * (interval well under the record-expiry window, strategy `all`).
42
+ * - Caddy HTTPS reverse proxy for the RPC API (auto TLS), which forwards the
43
+ * operator's bearer header straight through to the token-guarded Kubo API.
44
+ *
45
+ * INSTALL CHANNEL + BOOT-SAFETY (task `cloud-init-pinnace-install-channel`):
46
+ * - The box installs a PINNED `pinnace` ({@link DEFAULT_PINNACE_VERSION}) on a
47
+ * named current Node LTS ({@link DEFAULT_NODE_MAJOR}), both overridable per
48
+ * box. Pinned (never floating `latest`) so a boot is reproducible.
49
+ * - The install is BOOT-SAFE: `pinnace-setup.sh` is invoked with `|| true`, so
50
+ * a transient npm/registry failure can NOT abort the boot. Kubo, the
51
+ * firewall and Caddy come up regardless of the agent install.
52
+ * - The dedicated `ipfs` service user is created by cloud-init's `users:`
53
+ * module, which runs BEFORE `runcmd`, so no boot step can hit "invalid user
54
+ * 'ipfs'" (closes the first-boot race in
55
+ * work/notes/observations/cloud-init-first-boot-ipfs-user-race-and-set-e-abort.md).
56
+ */
57
+ import type {HostRole} from '../config/config-resolution.js';
58
+
59
+ /** The hosts pinnace can provision for. v1 = Hetzner ONLY. */
60
+ export type HostName = 'hetzner';
61
+
62
+ /** The hosts, in a stable order (help text / iteration / validation). */
63
+ export const HOST_PROVIDERS: readonly HostName[] = ['hetzner'];
64
+
65
+ /**
66
+ * The per-box inputs the generator injects into the cloud-init (the values
67
+ * `make-cloud-init.sh` used to `sed` in). Everything that varies per box lives
68
+ * here; the hardened invariants are baked into the template.
69
+ */
70
+ export interface ProvisionInput {
71
+ /** Which host to provision for (v1: `hetzner`). */
72
+ host: HostName;
73
+ /** DNS A-record pointed at this box, used for the HTTPS RPC API vhost. */
74
+ apiDomain: string;
75
+ /** Optional read-only status dashboard vhost (also serves exported records). */
76
+ dashboardDomain?: string;
77
+ /** Email for Let's Encrypt (ACME) certificate issuance. */
78
+ acmeEmail: string;
79
+ /**
80
+ * The long random bearer token guarding the RPC API. Kubo enforces it via
81
+ * `API.Authorizations` even on localhost; Caddy forwards the header through.
82
+ */
83
+ bearerToken: string;
84
+ /**
85
+ * This box's role. `publisher` holds IPNS keys and signs/exports records;
86
+ * `replica` is keyless and mirrors the publisher's records. Written to the
87
+ * env file as `NODE_ROLE`, which the `pinnace node` timers self-gate on
88
+ * (republish only on publisher, mirror only on replica), so scheduling all
89
+ * timers on every box is safe.
90
+ */
91
+ role: HostRole;
92
+ /**
93
+ * REPLICA ONLY: base URL to fetch the publisher's exported signed records
94
+ * from (the publisher's dashboard vhost). Ignored for publishers.
95
+ */
96
+ publisherEndpoint?: string;
97
+ /**
98
+ * Browser origins allowed to call the RPC API (CORS). Space-separated in the
99
+ * env file. Defaults to the dashboard domain's URL when a dashboard is set.
100
+ */
101
+ corsOrigins?: string;
102
+ /**
103
+ * Public gateways to warm through, each a template containing `{cid}`.
104
+ * Defaults to a sensible dweb.link/ipfs.io set. `.eth` MFS names are ALSO
105
+ * warmed via eth.limo automatically by `pinnace node warm`.
106
+ */
107
+ gateways?: string[];
108
+ /** The Kubo version to install. Defaults to a pinned known-good release. */
109
+ kuboVersion?: string;
110
+ /**
111
+ * The `pinnace` version to install on the box (`npm install -g
112
+ * pinnace@<this>`). Defaults to {@link DEFAULT_PINNACE_VERSION} (the current
113
+ * published release). PINNED, never floating `latest`, so a box boot is
114
+ * reproducible. Overridable per-box (e.g. to roll a box onto a newer agent).
115
+ */
116
+ pinnaceVersion?: string;
117
+ /**
118
+ * The Node.js major version to install via NodeSource (`setup_<this>.x`).
119
+ * Defaults to {@link DEFAULT_NODE_MAJOR} (a current active LTS). A named knob
120
+ * so the LTS bump is one obvious edit, not a literal buried in a shell line.
121
+ */
122
+ nodeMajor?: string;
123
+ /** The MFS directory sites live under. Defaults to `/sites`. */
124
+ sitesDir?: string;
125
+ }
126
+
127
+ /** A single emitted file: where it goes + what it contains. */
128
+ export interface EmittedFile {
129
+ /** The path to write the file at (relative; the operator pastes/uploads it). */
130
+ path: string;
131
+ /** The full file contents. */
132
+ contents: string;
133
+ }
134
+
135
+ /** What a host provider returns: the ready-to-paste cloud-init file. */
136
+ export interface ProvisionResult {
137
+ /** Which host this was provisioned for (echoed for the caller). */
138
+ host: HostName;
139
+ /** The cloud-init file (path + contents). */
140
+ cloudInit: EmittedFile;
141
+ }
142
+
143
+ /**
144
+ * The `HostProvider` seam: one method that generates a host's provisioning
145
+ * artifact (cloud-init) for a given input. v1 has a single implementation
146
+ * ({@link hetznerHostProvider}); adding DigitalOcean / others later means
147
+ * adding another provider here and an entry in {@link HOST_PROVIDERS}; deploy/
148
+ * publish/status logic is untouched (spec user story 21).
149
+ */
150
+ export interface HostProvider {
151
+ /** The host this provider provisions for. */
152
+ readonly host: HostName;
153
+ /** Generate the cloud-init (+ any host artifacts) for the given input. */
154
+ provision(input: ProvisionInput): ProvisionResult;
155
+ }
156
+
157
+ /** Defaults kept in one place so the template + docs never drift. */
158
+ const DEFAULT_KUBO_VERSION = 'v0.38.1';
159
+ /**
160
+ * The pinned `pinnace` version the box installs (`npm install -g
161
+ * pinnace@<this>`). Mirrors {@link DEFAULT_KUBO_VERSION}: a NAMED knob, not a
162
+ * literal, so a release bump is one obvious edit here. PINNED (never floating
163
+ * `latest`) so a box boot is reproducible: the same cloud-init always installs
164
+ * the same agent. Overridable per-box via {@link ProvisionInput.pinnaceVersion}.
165
+ * `pinnace@0.1.0` is the first published release (npm, public, OIDC provenance).
166
+ */
167
+ const DEFAULT_PINNACE_VERSION = '0.2.0';
168
+ /**
169
+ * The pinned Node.js major the box installs via NodeSource (`setup_<this>.x`).
170
+ * Node 22 is a current active LTS; Node 20 (the old literal) is the OLDEST LTS
171
+ * (EOL ~2026-04) and incoherent with the repo's own Node 24 toolchain. A NAMED
172
+ * knob (mirrors {@link DEFAULT_KUBO_VERSION}) so the LTS bump is one edit.
173
+ * Overridable per-box via {@link ProvisionInput.nodeMajor}.
174
+ */
175
+ const DEFAULT_NODE_MAJOR = '22';
176
+ const DEFAULT_SITES_DIR = '/sites';
177
+ const DEFAULT_GATEWAYS: readonly string[] = [
178
+ 'https://{cid}.ipfs.dweb.link/',
179
+ 'https://{cid}.ipfs.cf-ipfs.com/',
180
+ 'https://ipfs.io/ipfs/{cid}',
181
+ ];
182
+
183
+ /**
184
+ * One `pinnace node` verb scheduled on a systemd timer. The verb self-gates on
185
+ * `NODE_ROLE` (republish -> publisher, mirror -> replica) or is role-agnostic
186
+ * (warm/status), so scheduling ALL of them on EVERY box is safe: the
187
+ * wrong-role verb is a clean no-op (ADR-0002).
188
+ */
189
+ interface TimerSpec {
190
+ /** The `pinnace node <verb>` this timer runs. */
191
+ verb: 'republish' | 'mirror' | 'warm' | 'status';
192
+ /** Human description for the unit files. */
193
+ description: string;
194
+ /** `OnBootSec` for the timer (staggered so timers don't all fire at once). */
195
+ onBootSec: string;
196
+ /** `OnUnitActiveSec`, the recurring cadence. */
197
+ onUnitActiveSec: string;
198
+ }
199
+
200
+ /**
201
+ * The four timers, ported from the reference bash-unit cadences but pointed at
202
+ * `pinnace node <verb>` instead of the bash scripts (ADR-0002). Order is stable
203
+ * (deterministic output). Cadences mirror the reference: IPNS republish/mirror
204
+ * well under record expiry, warm/status more frequent.
205
+ */
206
+ const TIMERS: readonly TimerSpec[] = [
207
+ {
208
+ verb: 'republish',
209
+ description: 'Republish + export IPNS records (publisher role)',
210
+ onBootSec: '8min',
211
+ onUnitActiveSec: '6h',
212
+ },
213
+ {
214
+ verb: 'mirror',
215
+ description: 'Mirror publisher IPNS records (replica role)',
216
+ onBootSec: '9min',
217
+ onUnitActiveSec: '3h',
218
+ },
219
+ {
220
+ verb: 'warm',
221
+ description: 'Warm public IPFS gateway caches for our CIDs',
222
+ onBootSec: '5min',
223
+ onUnitActiveSec: '30min',
224
+ },
225
+ {
226
+ verb: 'status',
227
+ description: 'Regenerate the per-site status JSON for the dashboard',
228
+ onBootSec: '6min',
229
+ onUnitActiveSec: '15min',
230
+ },
231
+ ];
232
+
233
+ /** Render the `pinnace-<verb>` systemd service + timer unit pair. */
234
+ function renderTimerUnits(t: TimerSpec): string {
235
+ return ` - path: /etc/systemd/system/pinnace-${t.verb}.service
236
+ permissions: "0644"
237
+ owner: root:root
238
+ content: |
239
+ [Unit]
240
+ Description=${t.description}
241
+ After=ipfs.service
242
+
243
+ [Service]
244
+ Type=oneshot
245
+ User=ipfs
246
+ Group=ipfs
247
+ EnvironmentFile=/etc/pinnace-node.env
248
+ Environment=IPFS_PATH=/var/lib/ipfs/.ipfs
249
+ ExecStart=/usr/local/bin/pinnace node ${t.verb}
250
+
251
+ - path: /etc/systemd/system/pinnace-${t.verb}.timer
252
+ permissions: "0644"
253
+ owner: root:root
254
+ content: |
255
+ [Unit]
256
+ Description=Schedule pinnace node ${t.verb}
257
+
258
+ [Timer]
259
+ OnBootSec=${t.onBootSec}
260
+ OnUnitActiveSec=${t.onUnitActiveSec}
261
+ Persistent=true
262
+
263
+ [Install]
264
+ WantedBy=timers.target
265
+ `;
266
+ }
267
+
268
+ /**
269
+ * Render the full Hetzner cloud-init. Deterministic: same input -> byte-
270
+ * identical output (snapshot-locked). Encodes the hardened-node invariants
271
+ * (ufw 4001 TCP+UDP + 443, never 5001 raw; localhost-bound API/gateway;
272
+ * AcceleratedDHTClient + reprovide + `Routing.Type auto`; hardened systemd
273
+ * Kubo unit; Caddy HTTPS + bearer proxy) and schedules the `pinnace node`
274
+ * timers (NOT bash).
275
+ */
276
+ function renderHetznerCloudInit(input: ProvisionInput): string {
277
+ const dashboardDomain = input.dashboardDomain ?? '';
278
+ const corsOrigins =
279
+ input.corsOrigins ?? (dashboardDomain ? `https://${dashboardDomain}` : '');
280
+ const publisherEndpoint =
281
+ input.role === 'replica' ? (input.publisherEndpoint ?? '') : '';
282
+ const gateways = input.gateways ?? DEFAULT_GATEWAYS;
283
+ const kuboVersion = input.kuboVersion ?? DEFAULT_KUBO_VERSION;
284
+ const pinnaceVersion = input.pinnaceVersion ?? DEFAULT_PINNACE_VERSION;
285
+ const nodeMajor = input.nodeMajor ?? DEFAULT_NODE_MAJOR;
286
+ const sitesDir = input.sitesDir ?? DEFAULT_SITES_DIR;
287
+ const warmGateways = gateways.join(' ');
288
+
289
+ const timerUnits = TIMERS.map(renderTimerUnits).join('\n');
290
+ const enableTimers = TIMERS.map(
291
+ (t) => ` - systemctl enable --now pinnace-${t.verb}.timer`,
292
+ ).join('\n');
293
+
294
+ return `#cloud-config
295
+ # =============================================================================
296
+ # ${input.host} Cloud -> self-hosted IPFS (Kubo) node for a static website.
297
+ #
298
+ # Generated by \`pinnace provision --host ${input.host}\`. This is the programmatic
299
+ # successor to the shell \`sed\`-template prototype (superseded).
300
+ #
301
+ # What this gives you:
302
+ # - Kubo running as a hardened systemd service (user: ipfs)
303
+ # - Swarm port 4001 (TCP+UDP) open so public gateways can dial you
304
+ # - RPC API (5001) bound to localhost, exposed ONLY via Caddy HTTPS + bearer
305
+ # - AcceleratedDHTClient + reprovide tuned so gateways can DISCOVER your node
306
+ # - The \`pinnace\` binary installed + its on-box subcommands
307
+ # (\`pinnace node republish|mirror|warm|status\`) scheduled on role-gated
308
+ # systemd timers. Kubo owns pinning + reprovide; pinnace owns IPNS
309
+ # republish/export, replica mirror/fallback, gateway warm, and status.
310
+ # (See docs/adr/0002-on-box-agent-boundary.md.)
311
+ # =============================================================================
312
+
313
+ package_update: true
314
+ package_upgrade: true
315
+
316
+ packages:
317
+ - curl
318
+ - ca-certificates
319
+ - ufw
320
+ - jq
321
+ - debian-keyring
322
+ - debian-archive-keyring
323
+ - apt-transport-https
324
+
325
+ # ---------------------------------------------------------------------------
326
+ # Service users. cloud-init's \`users:\` module runs BEFORE \`runcmd\`, so the
327
+ # dedicated \`ipfs\` user is GUARANTEED to exist before any boot step uses it
328
+ # (e.g. \`install -o ipfs ...\`). Creating it here (not mid-\`ipfs-setup.sh\`,
329
+ # which is a \`set -e\` block that could abort before its \`useradd\`) closes the
330
+ # first-boot race where a later step hit "invalid user 'ipfs'".
331
+ # \`default\` keeps cloud-init's normal login user; we only ADD \`ipfs\`.
332
+ # ---------------------------------------------------------------------------
333
+ users:
334
+ - default
335
+ - name: ipfs
336
+ system: true
337
+ home: /var/lib/ipfs
338
+ shell: /usr/sbin/nologin
339
+ lock_passwd: true
340
+
341
+ # ---------------------------------------------------------------------------
342
+ # On-box environment consumed by the pinnace node timers + the setup scripts.
343
+ # ---------------------------------------------------------------------------
344
+ write_files:
345
+ - path: /etc/pinnace-node.env
346
+ permissions: "0600"
347
+ owner: root:root
348
+ content: |
349
+ API_DOMAIN="${input.apiDomain}"
350
+ DASH_DOMAIN="${dashboardDomain}"
351
+ API_CORS_ORIGINS="${corsOrigins}"
352
+ ACME_EMAIL="${input.acmeEmail}"
353
+ RPC_BEARER_TOKEN="${input.bearerToken}"
354
+
355
+ # Gateways to warm through. {cid} is replaced with each site's current CID
356
+ # by \`pinnace node warm\`, which AUTO-DISCOVERS sites from MFS (\${SITES_DIR}).
357
+ # Any MFS entry whose name ends in .eth is ALSO warmed via eth.limo.
358
+ WARM_GATEWAYS="${warmGateways}"
359
+
360
+ # MFS directory that holds your sites (one entry per site).
361
+ SITES_DIR="${sitesDir}"
362
+
363
+ # On-box PATHS the \`pinnace node\` verbs read (they assemble their
364
+ # NodeCommandContext from these keys):
365
+ # DASHBOARD_DIR - where \`node status\` writes status.json (the dashboard
366
+ # vhost \${DASH_DOMAIN} serves this dir).
367
+ # RECORDS_DIR - where \`node republish\` (publisher) EXPORTS the signed
368
+ # record; UNDER the dashboard dir so it is served at
369
+ # \${DASH_DOMAIN}/records/<id>.ipns-record, exactly where a
370
+ # replica's PUBLISHER_ENDPOINT + /records/ fetch looks.
371
+ # CACHE_DIR - where \`node mirror\` (replica) CACHES the last good
372
+ # record for the publisher-outage fallback (under the
373
+ # ipfs user's writable home).
374
+ DASHBOARD_DIR="/var/www/ipfs-dash"
375
+ RECORDS_DIR="/var/www/ipfs-dash/records"
376
+ CACHE_DIR="/var/lib/ipfs/records-cache"
377
+
378
+ # Role: "publisher" holds IPNS keys and signs/exports records; "replica"
379
+ # holds NO key and MIRRORS the publisher's signed records. The pinnace node
380
+ # timers self-gate on this, so scheduling all timers on every box is safe.
381
+ NODE_ROLE="${input.role}"
382
+
383
+ # REPLICA ONLY: where to fetch the publisher's exported signed records.
384
+ PUBLISHER_ENDPOINT="${publisherEndpoint}"
385
+
386
+ KUBO_VERSION="${kuboVersion}"
387
+
388
+ # Node.js major installed via NodeSource (setup_<major>.x) and the PINNED
389
+ # pinnace version installed on the box. Both are reproducible: the same
390
+ # cloud-init always installs the same agent on the same runtime.
391
+ NODE_MAJOR="${nodeMajor}"
392
+ PINNACE_VERSION="${pinnaceVersion}"
393
+
394
+ # -- Kubo installer / initializer -----------------------------------------
395
+ - path: /usr/local/sbin/ipfs-setup.sh
396
+ permissions: "0755"
397
+ owner: root:root
398
+ content: |
399
+ #!/usr/bin/env bash
400
+ set -euo pipefail
401
+ source /etc/pinnace-node.env
402
+
403
+ ARCH="$(dpkg --print-architecture)" # amd64 / arm64
404
+ TARBALL="kubo_\${KUBO_VERSION}_linux-\${ARCH}.tar.gz"
405
+ URL="https://dist.ipfs.tech/kubo/\${KUBO_VERSION}/\${TARBALL}"
406
+
407
+ cd /tmp
408
+ curl -fsSL "$URL" -o "$TARBALL"
409
+ tar -xzf "$TARBALL"
410
+ bash kubo/install.sh
411
+ ipfs --version
412
+
413
+ if ! id ipfs >/dev/null 2>&1; then
414
+ useradd --system --create-home --home-dir /var/lib/ipfs --shell /usr/sbin/nologin ipfs
415
+ fi
416
+ install -d -o ipfs -g ipfs /var/lib/ipfs
417
+
418
+ export IPFS_PATH=/var/lib/ipfs/.ipfs
419
+ if [ ! -f "$IPFS_PATH/config" ]; then
420
+ # 'server' profile disables local-network announce (good for a datacenter box)
421
+ sudo -u ipfs env IPFS_PATH="$IPFS_PATH" ipfs init --profile server
422
+ fi
423
+
424
+ cfg() { sudo -u ipfs env IPFS_PATH="$IPFS_PATH" ipfs config "$@"; }
425
+
426
+ # --- Reachability: bind API + gateway to localhost only (NEVER public) ---
427
+ cfg Addresses.API "/ip4/127.0.0.1/tcp/5001"
428
+ cfg Addresses.Gateway "/ip4/127.0.0.1/tcp/8080"
429
+
430
+ # --- Discoverability: keep provider records fresh so gateways find us ---
431
+ cfg --json Routing.AcceleratedDHTClient true
432
+ cfg Routing.Type "auto"
433
+ # Re-announce everything we serve; interval well under the record expiry.
434
+ # (Kubo 0.38 uses Provide.*; the pre-0.38 keys FATAL the daemon at boot.)
435
+ cfg Provide.Interval "12h"
436
+ cfg Provide.Strategy "all"
437
+
438
+ # --- Resource hygiene for a small box ---
439
+ cfg Datastore.StorageMax "40GB"
440
+ cfg --json Swarm.ConnMgr.HighWater 200
441
+ cfg --json Swarm.ConnMgr.LowWater 100
442
+
443
+ # --- RPC API auth: require a bearer token even on localhost ---
444
+ cfg --json API.Authorizations "{
445
+ \\"uploader\\": {
446
+ \\"AuthSecret\\": \\"bearer:\${RPC_BEARER_TOKEN}\\",
447
+ \\"AllowedPaths\\": [\\"/api/v0\\"]
448
+ }
449
+ }"
450
+
451
+ cfg --json API.HTTPHeaders.Access-Control-Allow-Origin "[\\"*\\"]"
452
+ cfg --json API.HTTPHeaders.Access-Control-Allow-Methods "[\\"POST\\"]"
453
+ cfg --json API.HTTPHeaders.Access-Control-Allow-Headers "[\\"Authorization\\"]"
454
+
455
+ chown -R ipfs:ipfs /var/lib/ipfs
456
+
457
+ # -- systemd unit for the daemon (hardened) -------------------------------
458
+ - path: /etc/systemd/system/ipfs.service
459
+ permissions: "0644"
460
+ owner: root:root
461
+ content: |
462
+ [Unit]
463
+ Description=IPFS Kubo daemon
464
+ After=network-online.target
465
+ Wants=network-online.target
466
+
467
+ [Service]
468
+ User=ipfs
469
+ Group=ipfs
470
+ Environment=IPFS_PATH=/var/lib/ipfs/.ipfs
471
+ ExecStart=/usr/local/bin/ipfs daemon --migrate=true --enable-gc
472
+ Restart=on-failure
473
+ RestartSec=5
474
+ LimitNOFILE=65536
475
+ # hardening
476
+ NoNewPrivileges=true
477
+ ProtectSystem=strict
478
+ ProtectHome=true
479
+ ReadWritePaths=/var/lib/ipfs
480
+ PrivateTmp=true
481
+
482
+ [Install]
483
+ WantedBy=multi-user.target
484
+
485
+ # -- pinnace installer: the box runs the SAME binary as the client --------
486
+ - path: /usr/local/sbin/pinnace-setup.sh
487
+ permissions: "0755"
488
+ owner: root:root
489
+ content: |
490
+ #!/usr/bin/env bash
491
+ set -euo pipefail
492
+ source /etc/pinnace-node.env
493
+ # Install Node.js (for npm) then the PINNED pinnace binary globally. The
494
+ # on-box timers invoke \`pinnace node <verb>\`: one codebase, client +
495
+ # on-box. NODE_MAJOR / PINNACE_VERSION come from /etc/pinnace-node.env so
496
+ # the runtime + agent version are reproducible and one-edit overridable.
497
+ # This script is invoked NON-FATALLY at boot (\`|| true\`): a transient
498
+ # npm/registry hiccup must NOT abort provisioning (Kubo, the firewall and
499
+ # Caddy are already up by the time this runs). Re-run it manually to retry.
500
+ if ! command -v npm >/dev/null 2>&1; then
501
+ curl -fsSL "https://deb.nodesource.com/setup_\${NODE_MAJOR}.x" | bash -
502
+ apt-get install -y nodejs
503
+ fi
504
+ npm install -g "pinnace@\${PINNACE_VERSION}"
505
+ pinnace version
506
+
507
+ # -- Caddy reverse proxy for the HTTPS API (auto TLS) ---------------------
508
+ - path: /usr/local/sbin/write-caddyfile.sh
509
+ permissions: "0755"
510
+ owner: root:root
511
+ content: |
512
+ #!/usr/bin/env bash
513
+ set -euo pipefail
514
+ source /etc/pinnace-node.env
515
+ if [ -z "\${API_DOMAIN}" ]; then
516
+ echo "API_DOMAIN not set; skipping Caddy config (API stays localhost-only)."
517
+ exit 0
518
+ fi
519
+ cat > /etc/caddy/Caddyfile <<EOF
520
+ {
521
+ email \${ACME_EMAIL}
522
+ }
523
+
524
+ \${API_DOMAIN} {
525
+ @cors_preflight method OPTIONS
526
+ header {
527
+ Access-Control-Allow-Origin "\${API_CORS_ORIGINS}"
528
+ Access-Control-Allow-Methods "POST, OPTIONS"
529
+ Access-Control-Allow-Headers "Authorization, Content-Type"
530
+ Vary Origin
531
+ }
532
+ respond @cors_preflight 204
533
+
534
+ # Only the Kubo RPC API. Kubo enforces the bearer token via
535
+ # API.Authorizations; Caddy just forwards the header.
536
+ reverse_proxy 127.0.0.1:5001
537
+ }
538
+ EOF
539
+
540
+ if [ -n "\${DASH_DOMAIN}" ]; then
541
+ cat >> /etc/caddy/Caddyfile <<EOF
542
+
543
+ \${DASH_DOMAIN} {
544
+ root * /var/www/ipfs-dash
545
+ file_server
546
+ }
547
+ EOF
548
+ fi
549
+ systemctl restart caddy
550
+
551
+ ${timerUnits}
552
+ # ---------------------------------------------------------------------------
553
+ # Boot sequence
554
+ # ---------------------------------------------------------------------------
555
+ runcmd:
556
+ # Install Caddy (official repo)
557
+ - curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
558
+ - curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | tee /etc/apt/sources.list.d/caddy-stable.list
559
+ - apt-get update
560
+ - apt-get install -y caddy
561
+
562
+ # Firewall: SSH + IPFS swarm + HTTP/HTTPS (for the API). NOT 5001 raw, NOT 8080.
563
+ - ufw default deny incoming
564
+ - ufw default allow outgoing
565
+ - ufw allow 22/tcp
566
+ - ufw allow 4001/tcp
567
+ - ufw allow 4001/udp
568
+ - ufw allow 80/tcp
569
+ - ufw allow 443/tcp
570
+ - ufw --force enable
571
+
572
+ # Install + start Kubo
573
+ - /usr/local/sbin/ipfs-setup.sh
574
+ - systemctl daemon-reload
575
+ - systemctl enable --now ipfs.service
576
+
577
+ # Configure the HTTPS API proxy (no-op if API_DOMAIN unset)
578
+ - /usr/local/sbin/write-caddyfile.sh
579
+
580
+ # Dashboard dir, owned by the ipfs service user (guaranteed to exist: created
581
+ # by the \`users:\` module above, BEFORE runcmd). Done BEFORE the pinnace
582
+ # install so a transient install failure can never skip it.
583
+ - install -d -o ipfs -g ipfs /var/www/ipfs-dash
584
+
585
+ # Install the pinned pinnace binary (the box runs the same CLI as the client).
586
+ # BOOT-SAFE: \`|| true\` so a transient npm/registry failure does NOT abort the
587
+ # boot (Kubo, the firewall and Caddy are already up). Re-run
588
+ # /usr/local/sbin/pinnace-setup.sh manually to retry; the timers below pick it
589
+ # up on their next tick once the binary is present.
590
+ - /usr/local/sbin/pinnace-setup.sh || true
591
+
592
+ # Enable the pinnace node timers. republish self-gates to publisher, mirror to
593
+ # replica, so enabling all of them on every box is safe (ADR-0002).
594
+ ${enableTimers}
595
+
596
+ # =============================================================================
597
+ # NOTES
598
+ # -----
599
+ # 1. DNS: point an A record (API_DOMAIN) at this box's IPv4 BEFORE first boot,
600
+ # or re-run /usr/local/sbin/write-caddyfile.sh after DNS propagates.
601
+ # 2. Verify discoverability from OUTSIDE the box after ~15 min:
602
+ # https://delegated-ipfs.dev/routing/v1/providers/<CID>
603
+ # Your PeerID should appear in the providers list.
604
+ # =============================================================================
605
+ `;
606
+ }
607
+
608
+ /**
609
+ * The Hetzner provider: the first implementation of {@link HostProvider}.
610
+ * Emits the hardened-node cloud-init to a conventional filename.
611
+ */
612
+ export const hetznerHostProvider: HostProvider = {
613
+ host: 'hetzner',
614
+ provision(input: ProvisionInput): ProvisionResult {
615
+ return {
616
+ host: 'hetzner',
617
+ cloudInit: {
618
+ path: 'cloud-init.yaml',
619
+ contents: renderHetznerCloudInit(input),
620
+ },
621
+ };
622
+ },
623
+ };
624
+
625
+ /** The provider registry (host -> provider). v1 has a single entry. */
626
+ const PROVIDERS: Record<HostName, HostProvider> = {
627
+ hetzner: hetznerHostProvider,
628
+ };
629
+
630
+ /**
631
+ * Generate the provisioning cloud-init for the requested host: dispatches to
632
+ * the matching {@link HostProvider} and returns its cloud-init file. Throws
633
+ * LOUDLY on an unknown/unimplemented host (the seam exists so callers can add
634
+ * hosts later, but v1 only ships `hetzner`): never a silent no-op.
635
+ */
636
+ export function provision(input: ProvisionInput): ProvisionResult {
637
+ const provider = PROVIDERS[input.host];
638
+ if (!provider) {
639
+ throw new Error(
640
+ `unsupported host '${input.host}'; v1 provisions only ${HOST_PROVIDERS.join(
641
+ ', ',
642
+ )} (other hosts are Out of Scope but the HostProvider seam exists)`,
643
+ );
644
+ }
645
+ return provider.provision(input);
646
+ }