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.
- package/LICENSE +661 -0
- package/README.md +164 -0
- package/dist/car/car-build.d.ts +26 -0
- package/dist/car/car-build.d.ts.map +1 -0
- package/dist/car/car-build.js +113 -0
- package/dist/car/car-build.js.map +1 -0
- package/dist/ci/ci-emit.d.ts +116 -0
- package/dist/ci/ci-emit.d.ts.map +1 -0
- package/dist/ci/ci-emit.js +190 -0
- package/dist/ci/ci-emit.js.map +1 -0
- package/dist/cli/bin.d.ts +3 -0
- package/dist/cli/bin.d.ts.map +1 -0
- package/dist/cli/bin.js +17 -0
- package/dist/cli/bin.js.map +1 -0
- package/dist/cli/run.d.ts +94 -0
- package/dist/cli/run.d.ts.map +1 -0
- package/dist/cli/run.js +731 -0
- package/dist/cli/run.js.map +1 -0
- package/dist/config/config-resolution.d.ts +186 -0
- package/dist/config/config-resolution.d.ts.map +1 -0
- package/dist/config/config-resolution.js +137 -0
- package/dist/config/config-resolution.js.map +1 -0
- package/dist/deploy/deploy.d.ts +121 -0
- package/dist/deploy/deploy.d.ts.map +1 -0
- package/dist/deploy/deploy.js +150 -0
- package/dist/deploy/deploy.js.map +1 -0
- package/dist/derive/ipns-key-derivation.d.ts +37 -0
- package/dist/derive/ipns-key-derivation.d.ts.map +1 -0
- package/dist/derive/ipns-key-derivation.js +130 -0
- package/dist/derive/ipns-key-derivation.js.map +1 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +29 -0
- package/dist/index.js.map +1 -0
- package/dist/node/node-commands.d.ts +115 -0
- package/dist/node/node-commands.d.ts.map +1 -0
- package/dist/node/node-commands.js +231 -0
- package/dist/node/node-commands.js.map +1 -0
- package/dist/provision/cloud-init.d.ts +162 -0
- package/dist/provision/cloud-init.d.ts.map +1 -0
- package/dist/provision/cloud-init.js +460 -0
- package/dist/provision/cloud-init.js.map +1 -0
- package/dist/publisher/key-import.d.ts +105 -0
- package/dist/publisher/key-import.d.ts.map +1 -0
- package/dist/publisher/key-import.js +76 -0
- package/dist/publisher/key-import.js.map +1 -0
- package/dist/publisher/record-sequence.d.ts +90 -0
- package/dist/publisher/record-sequence.d.ts.map +1 -0
- package/dist/publisher/record-sequence.js +250 -0
- package/dist/publisher/record-sequence.js.map +1 -0
- package/dist/rpc/kubo-rpc-client.d.ts +154 -0
- package/dist/rpc/kubo-rpc-client.d.ts.map +1 -0
- package/dist/rpc/kubo-rpc-client.js +207 -0
- package/dist/rpc/kubo-rpc-client.js.map +1 -0
- package/dist/rpc/mock-kubo.d.ts +87 -0
- package/dist/rpc/mock-kubo.d.ts.map +1 -0
- package/dist/rpc/mock-kubo.js +129 -0
- package/dist/rpc/mock-kubo.js.map +1 -0
- package/dist/site/site-management.d.ts +126 -0
- package/dist/site/site-management.d.ts.map +1 -0
- package/dist/site/site-management.js +123 -0
- package/dist/site/site-management.js.map +1 -0
- package/dist/status/status-report.d.ts +145 -0
- package/dist/status/status-report.d.ts.map +1 -0
- package/dist/status/status-report.js +192 -0
- package/dist/status/status-report.js.map +1 -0
- package/package.json +45 -2
- package/src/car/car-build.ts +135 -0
- package/src/ci/ci-emit.ts +284 -0
- package/src/cli/bin.ts +20 -0
- package/src/cli/run.ts +970 -0
- package/src/config/config-resolution.ts +264 -0
- package/src/deploy/deploy.ts +250 -0
- package/src/derive/ipns-key-derivation.ts +173 -0
- package/src/index.ts +146 -0
- package/src/node/node-commands.ts +395 -0
- package/src/provision/cloud-init.ts +646 -0
- package/src/publisher/key-import.ts +141 -0
- package/src/publisher/record-sequence.ts +336 -0
- package/src/rpc/kubo-rpc-client.ts +281 -0
- package/src/rpc/mock-kubo.ts +194 -0
- package/src/site/site-management.ts +241 -0
- 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
|
+
}
|