create-wordjs 2.3.0 → 2.4.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/README.md +61 -5
- package/index.js +707 -77
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -25,7 +25,9 @@ npx create-wordjs@latest join frontend --gateway <ip> --token <t> --ca-hash <fp>
|
|
|
25
25
|
That single command takes you from nothing to the browser install wizard:
|
|
26
26
|
|
|
27
27
|
1. Downloads the latest **pre-compiled** WordJS release ZIP from GitHub — no build step,
|
|
28
|
-
no TypeScript compilation on your machine
|
|
28
|
+
no TypeScript compilation on your machine — and verifies its **SHA-256** against the
|
|
29
|
+
`wordjs-<tag>.zip.sha256` asset published with the release before extracting anything
|
|
30
|
+
(see [Integrity](#integrity)).
|
|
29
31
|
2. Extracts it into `my-site/` and installs the runtime dependencies (`npm run release:install`).
|
|
30
32
|
3. Generates a one-time install token and starts the server (`npm run start:mono`), printing a
|
|
31
33
|
clickable URL:
|
|
@@ -46,13 +48,17 @@ in CI — plus a pure-JS *SQLite (legacy / WASM)* fallback for hosts where the n
|
|
|
46
48
|
|
|
47
49
|
| Option | Description |
|
|
48
50
|
| --- | --- |
|
|
49
|
-
| `--zip <path-or-url>` | Use a local release ZIP (or a direct ZIP URL) instead of querying the GitHub API. Handy offline or when rate-limited. |
|
|
51
|
+
| `--zip <path-or-url>` | Use a local release ZIP (or a direct **`https://`** ZIP URL — plain `http://` is refused) instead of querying the GitHub API. Handy offline or when rate-limited. |
|
|
52
|
+
| `--sha256 <hex>` | Refuse the ZIP unless its SHA-256 is exactly this (the value in the release's `.sha256` asset). Use it to pin a `--zip` file or URL; with a GitHub download it is checked on top of the release checksum. |
|
|
50
53
|
| `--version <tag>` | Install a specific release (e.g. `--version v2.1.0`) instead of the latest. |
|
|
51
54
|
| `--http` | Serve plain HTTP instead of self-signed HTTPS (sets `WORDJS_HTTP=1`). |
|
|
52
55
|
| `--no-start` | Scaffold and install dependencies only — start the server yourself later. |
|
|
53
56
|
| `--yes`, `-y` | Skip the confirmation prompt (required when `upgrade` runs non-interactively). |
|
|
54
57
|
| `--force` | (`upgrade`) Re-apply even if the site is already on the target version. |
|
|
55
58
|
| `--no-install` | (`upgrade`) Swap the code only; skip `npm run release:install`. |
|
|
59
|
+
| `--systemd` | (create, `upgrade`; Linux) Also write a systemd unit, `wordjs.service`, to a private staging directory outside the site and print how to install it. The unit runs WordJS as a dedicated non-root account with **no Linux capabilities**. With create it implies `--no-start`. See [Run it as a service](#run-it-as-a-service-linux). |
|
|
60
|
+
| `--port <n>` | (with `--systemd`) Public port written into the unit (`PORT=<n>`); default: the site's configured port, else `3000`. Below 1024 it also stages `60-wordjs-ports.conf`. |
|
|
61
|
+
| `--service-user <name>` | (with `--systemd`) The account the service runs as (default `wordjs`; `root` is refused). |
|
|
56
62
|
| `-h`, `--help` | Show usage. |
|
|
57
63
|
|
|
58
64
|
Separate-mode options:
|
|
@@ -62,7 +68,7 @@ Separate-mode options:
|
|
|
62
68
|
| `--host <ip/dns>` | (`gateway`) The address the other machines dial to reach this gateway. |
|
|
63
69
|
| `--gateway <ip/dns>` | (`join`) The gateway's address. |
|
|
64
70
|
| `--token <join-token>` | (`join`) A single-use token minted on the gateway. |
|
|
65
|
-
| `--ca-hash <sha256>` | (`join`)
|
|
71
|
+
| `--ca-hash <sha256>` | (`join`) **Required.** The cluster-CA fingerprint the gateway printed. The gateway's TLS certificate must chain to exactly that CA before the join token is sent (MITM guard). |
|
|
66
72
|
| `--advertise <ip/dns>` | (`join`) This node's routable address the gateway will proxy to. |
|
|
67
73
|
| `--enroll-port <port>` | (`join`) Gateway token-enrollment port (default `3101`). |
|
|
68
74
|
|
|
@@ -78,6 +84,38 @@ directory (`backend/data`), `backend/uploads/`, `wordjs-config.json`, `.env`, ga
|
|
|
78
84
|
before touching an existing install — on a non-interactive shell it refuses unless you pass `--yes`.
|
|
79
85
|
Restart the server afterwards (schema migrations run automatically on the next start).
|
|
80
86
|
|
|
87
|
+
## Run it as a service (Linux)
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
npx create-wordjs@latest /srv/wordjs --systemd --port 443 # new site
|
|
91
|
+
npx create-wordjs@latest upgrade /srv/wordjs --systemd # existing site, even if already up to date
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
`--systemd` writes `wordjs.service` — `User=wordjs`, `NoNewPrivileges=yes`, an **empty**
|
|
95
|
+
`CapabilityBoundingSet=`, `ProtectSystem=strict` with the site directory as the only writable path,
|
|
96
|
+
`PrivateTmp=yes`, `ProtectHome=yes`, `Restart=on-failure`, `ExecStart` pointing at the node that ran the
|
|
97
|
+
command — to a fresh private staging directory (mode `0700`, **outside** the site, which the service
|
|
98
|
+
account will own: a file root installs into `/etc` must not be one the service can rewrite first), and
|
|
99
|
+
prints the steps to install it (create the account, `chown` the site to it, `install -o root -g root -m
|
|
100
|
+
0644` the unit into `/etc/systemd/system/`, `systemctl enable --now wordjs`, read
|
|
101
|
+
`backend/data/install-token`; on `upgrade`, `enable` plus `restart`, after stopping whatever served the
|
|
102
|
+
site before). It does not run anything as root itself, refuses to run as the service account, checks the
|
|
103
|
+
site and node paths before downloading anything, and with create it does not start the server: the first
|
|
104
|
+
boot must run as the service account so that account owns the database and certificates it creates.
|
|
105
|
+
|
|
106
|
+
**Ports below 1024 (80/443).** The unit never grants `CAP_NET_BIND_SERVICE`. For a port below 1024,
|
|
107
|
+
`--systemd` also stages `60-wordjs-ports.conf`, a `sysctl.d` drop-in that sets
|
|
108
|
+
`net.ipv4.ip_unprivileged_port_start` to that port. Read its trade-off before installing it: it lets
|
|
109
|
+
**every** unprivileged process on the host bind ports from that value up to 1023, not just WordJS. On a
|
|
110
|
+
shared host, prefer a reverse proxy (nginx, Caddy) on 80/443 forwarding to WordJS on a high port.
|
|
111
|
+
|
|
112
|
+
**Do not `setcap` node or add `AmbientCapabilities=`.** Earlier versions of this tool printed a `setcap`
|
|
113
|
+
command for port 25; it is withdrawn. An ambient capability is inherited by the plugin sandbox, which has to
|
|
114
|
+
strip it (a non-root service with `AmbientCapabilities=CAP_NET_BIND_SERVICE` once left every plugin
|
|
115
|
+
refused); a file capability on node applies to every node script on the machine, runs node in
|
|
116
|
+
secure-execution mode, and disappears silently on the next node upgrade. Details, measurements and how to
|
|
117
|
+
remove an existing grant: [documentation/deployment.md](https://github.com/jaimemartinez/wordjs/blob/main/documentation/deployment.md#-running-as-a-service-systemd-non-root).
|
|
118
|
+
|
|
81
119
|
## Separate mode (multi-machine)
|
|
82
120
|
|
|
83
121
|
Run the gateway, backend and frontend on **different machines**, joined into one mTLS cluster with
|
|
@@ -100,7 +138,8 @@ npx create-wordjs@latest join backend --gateway 10.0.0.1 --token <t> --ca-hash
|
|
|
100
138
|
npx create-wordjs@latest join frontend --gateway 10.0.0.1 --token <t> --ca-hash <fp> --advertise 10.0.0.3
|
|
101
139
|
```
|
|
102
140
|
|
|
103
|
-
|
|
141
|
+
`--ca-hash` is required: `join` refuses to enroll without it, and there is no trust-on-first-use
|
|
142
|
+
opt-out. Each `join` downloads the release, enrolls against the gateway (the token authorizes exactly one
|
|
104
143
|
certificate signing; it is burned afterwards, and the ones `gateway` printed also expire after 120
|
|
105
144
|
minutes — mint more on the gateway with `node scripts/cluster.js token <backend|frontend>`, which
|
|
106
145
|
defaults to a 60-minute TTL and takes `--ttl <minutes>`), then starts the service, which registers
|
|
@@ -123,10 +162,27 @@ need `openssl` on the PATH. Full details, port matrix and the manual (source-che
|
|
|
123
162
|
- **GitHub rate limit / offline**: the release lookup uses the unauthenticated GitHub API. If it
|
|
124
163
|
is rate-limited or you're offline, download `wordjs-v*.zip` from the
|
|
125
164
|
[releases page](https://github.com/jaimemartinez/wordjs/releases) and run
|
|
126
|
-
`npx create-wordjs@latest my-site --zip ./wordjs-v2.1.0.zip
|
|
165
|
+
`npx create-wordjs@latest my-site --zip ./wordjs-v2.1.0.zip` — add
|
|
166
|
+
`--sha256 <hex>` with the value from `wordjs-v2.1.0.zip.sha256` to have it verified.
|
|
127
167
|
- **Existing directories**: the target directory must not exist (or must be empty) — the tool
|
|
128
168
|
refuses to overwrite anything.
|
|
129
169
|
|
|
170
|
+
## Integrity
|
|
171
|
+
|
|
172
|
+
- **GitHub downloads are verified.** Every release publishes `wordjs-<tag>.zip.sha256`
|
|
173
|
+
(`sha256sum` format) next to the bundle. `create-wordjs` — including `upgrade`, `gateway` and
|
|
174
|
+
`join` — downloads it and refuses to extract a ZIP whose SHA-256 differs. Releases published
|
|
175
|
+
**before** the checksum asset was introduced have nothing to verify against: they still install
|
|
176
|
+
(so `--version <old-tag>` rollbacks keep working) but with a clear warning that the download was
|
|
177
|
+
not verified. The checksum is fetched from the same GitHub release over HTTPS, so it catches a
|
|
178
|
+
corrupted or swapped download, not a compromised release.
|
|
179
|
+
- **`--zip` sources**: a URL must be `https://` (plain `http://` and other schemes are refused); a
|
|
180
|
+
local path is accepted as is. Neither is verified unless you pass `--sha256 <hex>` (a URL
|
|
181
|
+
without it prints a warning).
|
|
182
|
+
- **Extraction is contained**: every ZIP entry is checked before anything is written. An archive
|
|
183
|
+
with an absolute path, a `..` segment, an entry resolving outside the target directory, or a
|
|
184
|
+
symbolic-link entry is refused as a whole.
|
|
185
|
+
|
|
130
186
|
## What gets created
|
|
131
187
|
|
|
132
188
|
A ready-to-run WordJS bundle: backend (pre-compiled to `dist/`), frontend (pre-built `.next`),
|
package/index.js
CHANGED
|
@@ -16,6 +16,10 @@
|
|
|
16
16
|
* With --no-start there is no token from here: the server mints its own on its first boot and
|
|
17
17
|
* prints it in its banner only when ITS stdout is a TTY (or WORDJS_PRINT_INSTALL_TOKEN=1);
|
|
18
18
|
* otherwise it writes it to <dir>/backend/data/install-token (mode 0600) and prints the path.
|
|
19
|
+
* With --systemd (Linux) it does not start anything: it stages wordjs.service, a unit that runs the
|
|
20
|
+
* site as a dedicated non-root account with no Linux capabilities (plus a sysctl.d drop-in when the
|
|
21
|
+
* port is below 1024), in a private directory OUTSIDE the site, and prints how to install it. See
|
|
22
|
+
* buildSystemdFiles for why no capability, and createSystemdStagingDir for why not in the site.
|
|
19
23
|
*
|
|
20
24
|
* Plain Node, no TypeScript. Only runtime dependency: adm-zip (ZIP extraction).
|
|
21
25
|
*/
|
|
@@ -52,7 +56,9 @@ Usage:
|
|
|
52
56
|
npx create-wordjs@latest join <role> [dir] [opts] Join this machine to a gateway as backend|frontend
|
|
53
57
|
|
|
54
58
|
Options:
|
|
55
|
-
--zip <path-or-url> Use a local release ZIP (or a direct ZIP URL) instead of asking GitHub.
|
|
59
|
+
--zip <path-or-url> Use a local release ZIP (or a direct https:// ZIP URL) instead of asking GitHub.
|
|
60
|
+
--sha256 <hex> Require the ZIP to have this SHA-256 (pin a checksum for --zip; also checked
|
|
61
|
+
on top of the release's own .sha256 asset).
|
|
56
62
|
--version <tag> Install/upgrade to a specific release tag (e.g. v2.1.0) instead of the latest.
|
|
57
63
|
--http Serve plain HTTP instead of self-signed HTTPS (sets WORDJS_HTTP=1). (create)
|
|
58
64
|
--no-start Scaffold + install dependencies only; don't start the server.
|
|
@@ -62,9 +68,19 @@ Options:
|
|
|
62
68
|
--host <ip/dns> (gateway) The address other machines dial to reach this gateway.
|
|
63
69
|
--gateway <ip/dns> (join) The gateway's address.
|
|
64
70
|
--token <join-token> (join) A single-use token minted on the gateway (cluster token <role>).
|
|
65
|
-
--ca-hash <sha256> (join)
|
|
71
|
+
--ca-hash <sha256> (join) REQUIRED. The cluster CA fingerprint the gateway prints; the gateway's
|
|
72
|
+
TLS certificate must chain to it before the token is sent (MITM guard).
|
|
66
73
|
--advertise <ip/dns> (join) This node's routable address the gateway will proxy to.
|
|
67
74
|
--enroll-port <port> (join) Gateway token-enrollment port (default 3101).
|
|
75
|
+
--systemd (create, upgrade; Linux) Also write a systemd unit, wordjs.service, to a
|
|
76
|
+
private staging directory (never the site, which the service will own) and
|
|
77
|
+
print how to install it. The unit runs WordJS as a dedicated non-root
|
|
78
|
+
account holding NO Linux capabilities. With create it implies --no-start:
|
|
79
|
+
the first boot must run as that account.
|
|
80
|
+
--port <n> (with --systemd) Public port, written into the unit as PORT=<n> (default:
|
|
81
|
+
the site's configured port, else 3000). Below 1024 it also stages
|
|
82
|
+
60-wordjs-ports.conf (net.ipv4.ip_unprivileged_port_start), never a capability.
|
|
83
|
+
--service-user <name> (with --systemd) The account the service runs as (default: wordjs). Not root.
|
|
68
84
|
-h, --help Show this help.
|
|
69
85
|
|
|
70
86
|
Examples:
|
|
@@ -72,6 +88,11 @@ Examples:
|
|
|
72
88
|
npx create-wordjs@latest my-site --version v2.1.0
|
|
73
89
|
npx create-wordjs@latest upgrade # from inside your site directory
|
|
74
90
|
npx create-wordjs@latest upgrade ./my-site --yes
|
|
91
|
+
npx create-wordjs@latest /srv/wordjs --systemd --port 443
|
|
92
|
+
|
|
93
|
+
Ports below 1024 (80/443) on Linux: put a reverse proxy (nginx, Caddy) in front of WordJS on a high
|
|
94
|
+
port, or lower net.ipv4.ip_unprivileged_port_start (--systemd writes that drop-in for you). Do not
|
|
95
|
+
give node a capability with setcap or AmbientCapabilities=. See documentation/deployment.md.
|
|
75
96
|
|
|
76
97
|
Separate mode (three machines) — run one command per machine:
|
|
77
98
|
# on the gateway machine (prints ready-to-paste join commands with fresh tokens):
|
|
@@ -99,6 +120,8 @@ function parseArgs(argv) {
|
|
|
99
120
|
const opts = {
|
|
100
121
|
mode: 'create', dir: null, zip: null, version: null, http: false, start: true, yes: false, force: false, install: true,
|
|
101
122
|
role: null, gateway: null, token: null, caHash: null, advertise: null, enrollPort: null, host: null,
|
|
123
|
+
systemd: false, port: null, serviceUser: null,
|
|
124
|
+
sha256: null,
|
|
102
125
|
};
|
|
103
126
|
// A leading subcommand selects the mode (default is the monolith create flow).
|
|
104
127
|
if (['upgrade', 'gateway', 'join'].includes(argv[0])) { opts.mode = argv[0]; argv = argv.slice(1); }
|
|
@@ -107,6 +130,13 @@ function parseArgs(argv) {
|
|
|
107
130
|
const a = argv[i];
|
|
108
131
|
if (a === '-h' || a === '--help') { console.log(HELP); process.exit(0); }
|
|
109
132
|
else if (a === '--zip') { opts.zip = argv[++i] || fail('--zip needs a value (path or URL to a wordjs-*.zip).'); }
|
|
133
|
+
else if (a === '--sha256') {
|
|
134
|
+
const v = argv[++i];
|
|
135
|
+
if (!v) fail('--sha256 needs a value (the 64-character hex SHA-256 of the ZIP).');
|
|
136
|
+
const hex = normalizeSha256(v);
|
|
137
|
+
if (!hex) fail(`--sha256 must be a 64-character hex SHA-256, got "${v}".`);
|
|
138
|
+
opts.sha256 = hex;
|
|
139
|
+
}
|
|
110
140
|
else if (a === '--version') { opts.version = argv[++i] || fail('--version needs a value (a release tag, e.g. v2.1.0).'); }
|
|
111
141
|
else if (a === '--http') opts.http = true;
|
|
112
142
|
else if (a === '--no-start') opts.start = false;
|
|
@@ -120,9 +150,28 @@ function parseArgs(argv) {
|
|
|
120
150
|
else if (a === '--advertise') opts.advertise = argv[++i] || fail('--advertise needs this node\'s ip/dns.');
|
|
121
151
|
else if (a === '--enroll-port') opts.enrollPort = argv[++i] || fail('--enroll-port needs a port.');
|
|
122
152
|
else if (a === '--host') opts.host = argv[++i] || fail('--host needs the gateway ip/dns.');
|
|
153
|
+
else if (a === '--systemd') opts.systemd = true;
|
|
154
|
+
else if (a === '--port') opts.port = argv[++i] || fail('--port needs a value (1-65535).');
|
|
155
|
+
else if (a === '--service-user') opts.serviceUser = argv[++i] || fail('--service-user needs an account name.');
|
|
123
156
|
else if (a.startsWith('-')) fail(`Unknown option: ${a}`, 'Run with --help to see the available options.');
|
|
124
157
|
else positionals.push(a);
|
|
125
158
|
}
|
|
159
|
+
if ((opts.port !== null || opts.serviceUser !== null) && !opts.systemd) {
|
|
160
|
+
fail('--port and --service-user only apply together with --systemd.',
|
|
161
|
+
'Without a unit, the port comes from the PORT environment variable or the site config (default 3000).');
|
|
162
|
+
}
|
|
163
|
+
if (opts.systemd) {
|
|
164
|
+
if (opts.mode !== 'create' && opts.mode !== 'upgrade') {
|
|
165
|
+
fail(`--systemd is not available for "${opts.mode}".`, 'Use it when creating or upgrading a single-machine site.');
|
|
166
|
+
}
|
|
167
|
+
if (opts.port !== null) {
|
|
168
|
+
const n = /^\d{1,5}$/.test(opts.port) ? Number(opts.port) : NaN;
|
|
169
|
+
if (!(n >= 1 && n <= 65535)) fail(`--port must be a whole number from 1 to 65535 (got "${opts.port}").`);
|
|
170
|
+
opts.port = n;
|
|
171
|
+
}
|
|
172
|
+
const problem = serviceUserProblem(opts.serviceUser === null ? DEFAULT_SERVICE_USER : opts.serviceUser);
|
|
173
|
+
if (problem) fail(problem.message, problem.hint);
|
|
174
|
+
}
|
|
126
175
|
// Positionals: `join <role> [dir]` takes the role first; every other mode takes just [dir].
|
|
127
176
|
if (opts.mode === 'join' && !opts.role) opts.role = positionals.shift() || null;
|
|
128
177
|
opts.dir = positionals.shift() || null;
|
|
@@ -145,7 +194,13 @@ function request(url, headers, redirectsLeft = 5) {
|
|
|
145
194
|
const req = https.get(url, { headers: { 'user-agent': 'create-wordjs', ...headers } }, (res) => {
|
|
146
195
|
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location && redirectsLeft > 0) {
|
|
147
196
|
res.resume(); // GitHub release assets redirect to objects.githubusercontent.com
|
|
148
|
-
|
|
197
|
+
const next = new URL(res.headers.location, url);
|
|
198
|
+
// Never let a redirect downgrade the transfer to plain HTTP (or any other scheme).
|
|
199
|
+
if (next.protocol !== 'https:') {
|
|
200
|
+
reject(new Error(`Refusing to follow a redirect from ${url} to a non-https URL (${next.protocol}//${next.host}).`));
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
resolve(request(next.toString(), headers, redirectsLeft - 1));
|
|
149
204
|
return;
|
|
150
205
|
}
|
|
151
206
|
resolve(res);
|
|
@@ -164,6 +219,62 @@ function readBody(res) {
|
|
|
164
219
|
});
|
|
165
220
|
}
|
|
166
221
|
|
|
222
|
+
// --- integrity: SHA-256 of the release ZIP --------------------------------------------------------
|
|
223
|
+
//
|
|
224
|
+
// The bundle we download becomes the site's code, so it is verified before a single entry is
|
|
225
|
+
// extracted. release.yml publishes `wordjs-<tag>.zip.sha256` (sha256sum format: `<hex> <name>`) next
|
|
226
|
+
// to every tag-named bundle; the GitHub flow downloads it and refuses a ZIP whose digest differs.
|
|
227
|
+
// Releases published before that asset existed have no checksum to check against — those install
|
|
228
|
+
// with a loud warning rather than not at all. `--sha256 <hex>` pins a digest the user obtained out of
|
|
229
|
+
// band, for `--zip` sources (and on top of the release checksum when both are present).
|
|
230
|
+
|
|
231
|
+
/** Lower-case 64-char hex, or null. Accepts the `AA:BB:…` colon form some tools print. */
|
|
232
|
+
function normalizeSha256(value) {
|
|
233
|
+
const hex = String(value == null ? '' : value).trim().replace(/:/g, '').toLowerCase();
|
|
234
|
+
return /^[0-9a-f]{64}$/.test(hex) ? hex : null;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Read the digest for `expectedName` out of a sha256sum-style checksum file. A line naming a
|
|
239
|
+
* DIFFERENT file is ignored (a checksum file for another asset must not vouch for this one); a bare
|
|
240
|
+
* digest with no name is accepted. Returns null when no usable line is found — the caller fails closed.
|
|
241
|
+
*/
|
|
242
|
+
function parseChecksumFile(text, expectedName) {
|
|
243
|
+
for (const raw of String(text || '').split(/\r?\n/)) {
|
|
244
|
+
// Split instead of one regex with `\s+…(.+)`: linear on any input (CodeQL js/polynomial-redos).
|
|
245
|
+
const line = raw.trim();
|
|
246
|
+
const sep = line.search(/\s/);
|
|
247
|
+
const digest = sep === -1 ? line : line.slice(0, sep);
|
|
248
|
+
if (!/^[0-9a-fA-F]{64}$/.test(digest)) continue;
|
|
249
|
+
const name = sep === -1 ? '' : line.slice(sep).trim().replace(/^\*/, '');
|
|
250
|
+
if (name && expectedName && path.basename(name).toLowerCase() !== String(expectedName).toLowerCase()) continue;
|
|
251
|
+
return digest.toLowerCase();
|
|
252
|
+
}
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function sha256File(file) {
|
|
257
|
+
return new Promise((resolve, reject) => {
|
|
258
|
+
const hash = crypto.createHash('sha256');
|
|
259
|
+
fs.createReadStream(file)
|
|
260
|
+
.on('error', reject)
|
|
261
|
+
.on('data', (c) => hash.update(c))
|
|
262
|
+
.on('end', () => resolve(hash.digest('hex')));
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* What a `--zip` value points at: an https:// URL, a local file, or something refused. Plain http://
|
|
268
|
+
* (and any other URL scheme) is refused — a bundle fetched over an unauthenticated channel can be
|
|
269
|
+
* swapped in transit, and it becomes the site's code. Windows paths (`C:\…`) are files, not URLs.
|
|
270
|
+
*/
|
|
271
|
+
function classifyZipSource(zip) {
|
|
272
|
+
const value = String(zip || '');
|
|
273
|
+
if (/^https:\/\//i.test(value)) return { kind: 'url', url: value };
|
|
274
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(value)) return { kind: 'refused', scheme: value.slice(0, value.indexOf(':')).toLowerCase() };
|
|
275
|
+
return { kind: 'file', path: value };
|
|
276
|
+
}
|
|
277
|
+
|
|
167
278
|
async function githubJson(url) {
|
|
168
279
|
let res;
|
|
169
280
|
try {
|
|
@@ -214,6 +325,12 @@ function pickBundleAsset(assets, tagName) {
|
|
|
214
325
|
return loose.length === 1 ? loose[0] : null;
|
|
215
326
|
}
|
|
216
327
|
|
|
328
|
+
/** The `<bundle>.sha256` asset for the bundle we picked — matched by EXACT name, never by shape. */
|
|
329
|
+
function pickChecksumAsset(assets, bundleName) {
|
|
330
|
+
const wanted = `${bundleName}.sha256`.toLowerCase();
|
|
331
|
+
return (Array.isArray(assets) ? assets : []).find((a) => String(a && a.name || '').toLowerCase() === wanted) || null;
|
|
332
|
+
}
|
|
333
|
+
|
|
217
334
|
/** Every asset matching the loose `wordjs-*.zip` shape — used to explain an ambiguous refusal. */
|
|
218
335
|
function looseBundleCandidates(assets) {
|
|
219
336
|
const list = Array.isArray(assets) ? assets : [];
|
|
@@ -240,7 +357,25 @@ async function resolveReleaseAsset(tag) {
|
|
|
240
357
|
}
|
|
241
358
|
fail(`Release ${release.tag_name} has no wordjs-*.zip asset.`, 'Pass --zip <path-or-url> instead.');
|
|
242
359
|
}
|
|
243
|
-
|
|
360
|
+
const sum = pickChecksumAsset(release.assets, asset.name);
|
|
361
|
+
return {
|
|
362
|
+
name: asset.name, url: asset.browser_download_url, tag: release.tag_name,
|
|
363
|
+
checksumName: sum ? sum.name : null, checksumUrl: sum ? sum.browser_download_url : null,
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** Download a small text asset (the checksum file). Anything but a 200 is fatal: fail closed. */
|
|
368
|
+
async function downloadText(url, maxBytes = 64 * 1024) {
|
|
369
|
+
let res;
|
|
370
|
+
try { res = await request(url, { accept: 'application/octet-stream' }); }
|
|
371
|
+
catch (e) { fail(`Could not download ${url} (${e.message}).`, 'Check your network and try again.'); }
|
|
372
|
+
if (res.statusCode !== 200) {
|
|
373
|
+
res.resume();
|
|
374
|
+
fail(`Download failed (HTTP ${res.statusCode}) for ${url}.`, 'The release lists a checksum file that could not be fetched; refusing to install unverified.');
|
|
375
|
+
}
|
|
376
|
+
const body = await readBody(res);
|
|
377
|
+
if (body.length > maxBytes) fail(`${url} is implausibly large for a checksum file.`);
|
|
378
|
+
return body;
|
|
244
379
|
}
|
|
245
380
|
|
|
246
381
|
async function download(url, dest, label) {
|
|
@@ -277,10 +412,84 @@ async function download(url, dest, label) {
|
|
|
277
412
|
|
|
278
413
|
// --- extraction + scaffolding ------------------------------------------------------------------
|
|
279
414
|
|
|
415
|
+
// S_IFMT / S_IFLNK out of the high 16 bits of a ZIP entry's external attributes (Unix creators).
|
|
416
|
+
const S_IFMT = 0o170000;
|
|
417
|
+
const S_IFLNK = 0o120000;
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Where `entryName` would land under `root` — or a thrown error when it must not be written at all.
|
|
421
|
+
* adm-zip's own extractAllTo already strips `../` and leading `/` (it rewrites rather than refuses),
|
|
422
|
+
* but a release bundle that contains such an entry is not a release bundle: refuse the whole archive
|
|
423
|
+
* explicitly instead of trusting a silent rewrite in a dependency (same rule as the backend's
|
|
424
|
+
* installPluginFromZip). Refused: absolute paths (POSIX or Windows drive/UNC), any `..` segment, and
|
|
425
|
+
* anything whose resolved path is not inside root.
|
|
426
|
+
*/
|
|
427
|
+
function containedEntryPath(root, entryName) {
|
|
428
|
+
const rel = String(entryName).replace(/\\/g, '/');
|
|
429
|
+
if (!rel || rel.includes('\0')) throw new Error(`Refusing ZIP entry with an invalid name: ${JSON.stringify(entryName)}`);
|
|
430
|
+
if (rel.startsWith('/') || /^[a-zA-Z]:/.test(rel) || path.isAbsolute(rel) || path.win32.isAbsolute(rel)) {
|
|
431
|
+
throw new Error(`Refusing ZIP entry with an absolute path: ${entryName}`);
|
|
432
|
+
}
|
|
433
|
+
if (rel.split('/').includes('..')) throw new Error(`Refusing ZIP entry that climbs out of the target (Zip Slip): ${entryName}`);
|
|
434
|
+
const dest = path.resolve(root, rel);
|
|
435
|
+
if (dest !== root && !dest.startsWith(root + path.sep)) {
|
|
436
|
+
throw new Error(`Refusing ZIP entry that resolves outside the target (Zip Slip): ${entryName}`);
|
|
437
|
+
}
|
|
438
|
+
return dest;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** A symlink entry (Unix mode stored in the external attributes). The bundle never contains one. */
|
|
442
|
+
function isSymlinkEntry(entry) {
|
|
443
|
+
const attr = Number(entry && entry.header && entry.header.attr) >>> 0;
|
|
444
|
+
return ((attr >>> 16) & S_IFMT) === S_IFLNK;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* The mode an installed file gets: 0755 when the archive marked it executable for anyone, else 0644.
|
|
449
|
+
* Never group- or world-writable, whatever the archive says — and an archive built on Windows carries
|
|
450
|
+
* no Unix mode at all, which must not be read as "anything goes".
|
|
451
|
+
*/
|
|
452
|
+
function installedFileMode(entry) {
|
|
453
|
+
const unix = ((Number(entry && entry.header && entry.header.attr) >>> 0) >>> 16) & 0o777;
|
|
454
|
+
return (unix & 0o111) ? 0o755 : 0o644;
|
|
455
|
+
}
|
|
456
|
+
|
|
280
457
|
function extractZip(zipPath, targetDir) {
|
|
281
458
|
const AdmZip = require('adm-zip'); // lazy so --help works even before deps are installed
|
|
282
459
|
const zip = new AdmZip(zipPath);
|
|
283
|
-
|
|
460
|
+
// Vet EVERY entry before writing ANY: a refused archive leaves nothing half-extracted behind.
|
|
461
|
+
const root = path.resolve(targetDir);
|
|
462
|
+
const entries = zip.getEntries();
|
|
463
|
+
const planned = entries.map((entry) => {
|
|
464
|
+
const dest = containedEntryPath(root, entry.entryName);
|
|
465
|
+
if (isSymlinkEntry(entry)) throw new Error(`Refusing ZIP entry that is a symbolic link: ${entry.entryName}`);
|
|
466
|
+
return { entry, dest };
|
|
467
|
+
});
|
|
468
|
+
// Written entry by entry, NOT with adm-zip's extractAllTo: its writeFileTo does chmod(path, attr ||
|
|
469
|
+
// 0o666), and chmod ignores the umask, so every installed file — code that may later run as root —
|
|
470
|
+
// came out world-writable, and an upgrade over an existing 0644 install turned it into 0666 as well.
|
|
471
|
+
for (const { entry, dest } of planned) {
|
|
472
|
+
if (entry.isDirectory) {
|
|
473
|
+
fs.mkdirSync(dest, { recursive: true, mode: 0o755 });
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true, mode: 0o755 });
|
|
477
|
+
const mode = installedFileMode(entry);
|
|
478
|
+
let existing = null;
|
|
479
|
+
try { existing = fs.lstatSync(dest); } catch { /* new file */ }
|
|
480
|
+
if (existing) {
|
|
481
|
+
// An upgrade writes over an existing tree: never through a link someone placed there, and
|
|
482
|
+
// tighten a file left writable by an older installer BEFORE putting new code into it.
|
|
483
|
+
if (existing.isSymbolicLink() || !existing.isFile()) {
|
|
484
|
+
throw new Error(`Refusing to overwrite ${path.relative(root, dest)}: it is not a regular file in the existing install`);
|
|
485
|
+
}
|
|
486
|
+
fs.chmodSync(dest, mode);
|
|
487
|
+
}
|
|
488
|
+
fs.writeFileSync(dest, entry.getData(), { mode });
|
|
489
|
+
// `mode` only applies on creation and is narrowed by the umask; restate it so the result is the
|
|
490
|
+
// same for every caller and every existing file.
|
|
491
|
+
fs.chmodSync(dest, mode);
|
|
492
|
+
}
|
|
284
493
|
// Official bundles put files at the ZIP root; tolerate a single wrapper folder too.
|
|
285
494
|
if (!fs.existsSync(path.join(targetDir, 'package.json'))) {
|
|
286
495
|
const entries = fs.readdirSync(targetDir);
|
|
@@ -296,6 +505,76 @@ function extractZip(zipPath, targetDir) {
|
|
|
296
505
|
}
|
|
297
506
|
}
|
|
298
507
|
|
|
508
|
+
/**
|
|
509
|
+
* Obtain the release ZIP — a local file, an https:// URL, or the GitHub release (latest or --version)
|
|
510
|
+
* — and verify its SHA-256 BEFORE anything is extracted. Shared by create, upgrade, gateway and join so
|
|
511
|
+
* every path that installs code applies the same rule. Returns { zipPath, tag, cleanup }.
|
|
512
|
+
*/
|
|
513
|
+
async function obtainBundleZip(opts) {
|
|
514
|
+
let tmpDir = null;
|
|
515
|
+
const cleanup = () => { if (tmpDir) { try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* best-effort */ } } };
|
|
516
|
+
let zipPath;
|
|
517
|
+
let tag = opts.version || null;
|
|
518
|
+
const checks = []; // [{ expected, source }] — every one must match
|
|
519
|
+
if (opts.sha256) checks.push({ expected: opts.sha256, source: '--sha256' });
|
|
520
|
+
|
|
521
|
+
if (opts.zip) {
|
|
522
|
+
const src = classifyZipSource(opts.zip);
|
|
523
|
+
if (src.kind === 'refused') {
|
|
524
|
+
fail(`Refusing --zip ${opts.zip}: only https:// URLs and local file paths are accepted (got ${src.scheme}://).`,
|
|
525
|
+
'A bundle fetched over plain http:// can be replaced in transit. Serve it over https, or download it yourself and pass the local path (with --sha256 <hex>).');
|
|
526
|
+
}
|
|
527
|
+
if (src.kind === 'file') {
|
|
528
|
+
zipPath = path.resolve(process.cwd(), src.path);
|
|
529
|
+
if (!fs.existsSync(zipPath)) fail(`ZIP not found: ${zipPath}`);
|
|
530
|
+
console.log(` Using local bundle: ${zipPath}`);
|
|
531
|
+
} else {
|
|
532
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'create-wordjs-'));
|
|
533
|
+
zipPath = path.join(tmpDir, 'wordjs.zip');
|
|
534
|
+
await download(src.url, zipPath, 'wordjs.zip');
|
|
535
|
+
if (!opts.sha256) {
|
|
536
|
+
console.warn(' ⚠️ No --sha256 given for this --zip URL — its integrity is NOT verified.');
|
|
537
|
+
console.warn(' Pass --sha256 <hex> (from the release\'s .sha256 asset) to pin it.');
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
} else {
|
|
541
|
+
console.log(opts.version ? ` Looking up release ${opts.version} of ${REPO}…` : ` Looking up the latest release of ${REPO}…`);
|
|
542
|
+
const asset = await resolveReleaseAsset(opts.version);
|
|
543
|
+
tag = asset.tag;
|
|
544
|
+
console.log(` Found ${asset.tag} → ${asset.name}`);
|
|
545
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'create-wordjs-'));
|
|
546
|
+
zipPath = path.join(tmpDir, asset.name);
|
|
547
|
+
await download(asset.url, zipPath, asset.name);
|
|
548
|
+
if (asset.checksumUrl) {
|
|
549
|
+
const expected = parseChecksumFile(await downloadText(asset.checksumUrl), asset.name);
|
|
550
|
+
if (!expected) {
|
|
551
|
+
cleanup();
|
|
552
|
+
fail(`${asset.checksumName} does not contain a SHA-256 for ${asset.name}.`, 'Refusing to install a bundle that cannot be verified.');
|
|
553
|
+
}
|
|
554
|
+
checks.push({ expected, source: asset.checksumName });
|
|
555
|
+
} else {
|
|
556
|
+
// Releases published before the checksum asset existed. Installing them still works (so
|
|
557
|
+
// `--version <old-tag>` rollbacks keep working), but say plainly that nothing was verified.
|
|
558
|
+
console.warn(` ⚠️ Release ${asset.tag} publishes no ${asset.name}.sha256 — the download's integrity`);
|
|
559
|
+
console.warn(' could NOT be verified (releases before the checksum asset was introduced).');
|
|
560
|
+
console.warn(' Pin one with --sha256 <hex> if you have it from a trusted source.');
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
if (checks.length) {
|
|
565
|
+
const actual = await sha256File(zipPath);
|
|
566
|
+
for (const { expected, source } of checks) {
|
|
567
|
+
if (actual !== expected) {
|
|
568
|
+
cleanup();
|
|
569
|
+
fail(`SHA-256 mismatch for the release ZIP (checked against ${source}).`,
|
|
570
|
+
`expected ${expected}\n got ${actual}\n Refusing to install — the download is corrupt or has been tampered with.`);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
console.log(` ✓ SHA-256 verified (${checks.map((c) => c.source).join(' + ')})`);
|
|
574
|
+
}
|
|
575
|
+
return { zipPath, tag, cleanup };
|
|
576
|
+
}
|
|
577
|
+
|
|
299
578
|
function runNpmScript(script, cwd, extraEnv) {
|
|
300
579
|
// A single command string with shell:true resolves npm/npm.cmd on every platform (and avoids
|
|
301
580
|
// Node's DEP0190 warning about args-array + shell). The string is fixed — no user input in it.
|
|
@@ -352,6 +631,360 @@ function ensureHttpsConfig(targetDir) {
|
|
|
352
631
|
}
|
|
353
632
|
}
|
|
354
633
|
|
|
634
|
+
// --- systemd service (--systemd) ---------------------------------------------------------------
|
|
635
|
+
//
|
|
636
|
+
// WHY THE UNIT GRANTS NOTHING, AND WHY THIS INSTALLER STOPPED PRINTING `setcap`.
|
|
637
|
+
//
|
|
638
|
+
// The familiar way to let a non-root Node service bind 80/443 (or 25) is CAP_NET_BIND_SERVICE: either a
|
|
639
|
+
// file capability set on the node binary with `setcap`, which this installer used to recommend, or
|
|
640
|
+
// `AmbientCapabilities=CAP_NET_BIND_SERVICE` in the unit. Both collide with the plugin sandbox. On Linux
|
|
641
|
+
// every isolated plugin starts through backend/scripts/landlock-seccomp-shim.pl, which must shed every
|
|
642
|
+
// capability it inherited before it confines the plugin. An AMBIENT capability is inherited by the shim
|
|
643
|
+
// across execve; the shim took it for root, ran a root-only drop (setgroups, securebits, the bounding set
|
|
644
|
+
// need CAP_SETGID/CAP_SETPCAP, which a non-root service does not hold), failed with EPERM and every plugin
|
|
645
|
+
// was refused: the production failure behind this option. (The shim now sheds a non-root service's
|
|
646
|
+
// capabilities on its own, but a service that needs none should hold none, and the core reports one that
|
|
647
|
+
// does.) A FILE capability on node fails differently, and it is worth
|
|
648
|
+
// being exact about how, because it does NOT reproduce that failure. Measured on Linux 7.0 / node 22
|
|
649
|
+
// (a capped copy of node, run as an unprivileged user):
|
|
650
|
+
// . it reaches no further than the binary: perl carries no file capability, so the shim it spawns
|
|
651
|
+
// starts with an empty permitted set, and the plugin's node, exec'd under no_new_privs, gets none;
|
|
652
|
+
// . it reaches EVERY script that binary runs, for every user on the machine: any `node -e` could
|
|
653
|
+
// bind port 81, not only WordJS;
|
|
654
|
+
// . it puts node in secure-execution mode (AT_SECURE=1): glibc strips TMPDIR and the LD_* family from
|
|
655
|
+
// its environment, and the confined plugin child, exec'd from the same binary, inherits AT_SECURE
|
|
656
|
+
// without the capability, so there Node also ignores NODE_OPTIONS and TMPDIR is gone;
|
|
657
|
+
// . it lives in an xattr on the binary's inode, so a node upgrade (a new file renamed over the old
|
|
658
|
+
// one) or any rewrite of the file silently removes it, and the next restart cannot bind its port.
|
|
659
|
+
//
|
|
660
|
+
// So the unit holds no capability at all, and a port below 1024 is reached the way the kernel offers an
|
|
661
|
+
// unprivileged service: net.ipv4.ip_unprivileged_port_start, written as a sysctl.d drop-in beside the
|
|
662
|
+
// unit. That has a cost the drop-in spells out (it lowers the floor for every unprivileged process in the
|
|
663
|
+
// network namespace, not for WordJS alone), which is why a reverse proxy on 80/443 in front of a high
|
|
664
|
+
// port is offered as the alternative everywhere this advice appears.
|
|
665
|
+
const DEFAULT_SERVICE_USER = 'wordjs';
|
|
666
|
+
const SYSTEMD_UNIT_FILE = 'wordjs.service';
|
|
667
|
+
const SYSCTL_DROPIN_FILE = '60-wordjs-ports.conf';
|
|
668
|
+
// The kernel default of net.ipv4.ip_unprivileged_port_start: binding a port below it needs privilege.
|
|
669
|
+
const PRIVILEGED_PORT_LIMIT = 1024;
|
|
670
|
+
// systemd's PATH for system services. WordJS runs `npm` BY NAME to install a plugin's declared
|
|
671
|
+
// dependencies at activation, and npm's own shebang finds node through PATH, so when node lives anywhere
|
|
672
|
+
// else its directory is prepended in the unit; otherwise activation would fail with ENOENT.
|
|
673
|
+
const SYSTEMD_DEFAULT_PATH = ['/usr/local/sbin', '/usr/local/bin', '/usr/sbin', '/usr/bin'];
|
|
674
|
+
|
|
675
|
+
/** Why `name` cannot be the service account, or null when it can. */
|
|
676
|
+
function serviceUserProblem(name) {
|
|
677
|
+
if (!/^[a-z_][a-z0-9_-]{0,31}$/.test(String(name))) {
|
|
678
|
+
return {
|
|
679
|
+
message: `"${name}" is not a usable account name for --service-user.`,
|
|
680
|
+
hint: 'Use lowercase letters, digits, "_" or "-", starting with a letter or "_" (for example: wordjs).',
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
if (name === 'root') {
|
|
684
|
+
return {
|
|
685
|
+
message: 'WordJS must not run as root.',
|
|
686
|
+
hint: 'Use a dedicated account (the default is wordjs); the steps printed with the unit create it.',
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
return null;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// A unit file is not a shell, but it is not inert either: `%` starts a specifier, `$` an environment
|
|
693
|
+
// expansion in ExecStart=, whitespace separates ExecStart= arguments and ReadWritePaths= entries, and
|
|
694
|
+
// quotes and backslashes are parsed. Rather than escape a path for three different parsers, accept only
|
|
695
|
+
// paths that need no escaping in any of them and ask for a plain one otherwise.
|
|
696
|
+
function unitSafePath(p) {
|
|
697
|
+
return typeof p === 'string' && /^\/[A-Za-z0-9._@+,/-]*$/.test(p) && !p.split('/').includes('..');
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
function underAny(p, roots) {
|
|
701
|
+
return roots.some((r) => p === r || p.startsWith(r + '/'));
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/**
|
|
705
|
+
* The sysctl.d drop-in that lets an unprivileged service bind from `start` up, with its trade-off
|
|
706
|
+
* written into the file itself: whoever later finds it in /etc/sysctl.d/ should not have to guess why
|
|
707
|
+
* it is there or what it costs.
|
|
708
|
+
*/
|
|
709
|
+
function sysctlDropIn(start, { http = false } = {}) {
|
|
710
|
+
const lines = [
|
|
711
|
+
`# WordJS: allow unprivileged processes to bind ports from ${start} up (written by create-wordjs --systemd).`,
|
|
712
|
+
'#',
|
|
713
|
+
'# This is NOT a grant to WordJS alone. It lowers the privileged-port floor for EVERY unprivileged',
|
|
714
|
+
`# process in this network namespace, so any local user or service may bind ports ${start}-1023 too, and`,
|
|
715
|
+
'# could take the port first while WordJS is stopped. Use it on a host you do not share with untrusted',
|
|
716
|
+
'# users; otherwise put a reverse proxy (nginx, Caddy) on 80/443 and keep WordJS on a high port.',
|
|
717
|
+
'# It covers IPv6 as well, despite its name. A container has its own network namespace and value.',
|
|
718
|
+
];
|
|
719
|
+
if (!http && start > 80) {
|
|
720
|
+
lines.push(`# Let's Encrypt HTTP-01 (acme.http01Port: 80 in wordjs-config.json) needs this lowered to 80.`);
|
|
721
|
+
}
|
|
722
|
+
lines.push(
|
|
723
|
+
'#',
|
|
724
|
+
`# Install: sudo install -o root -g root -m 0644 ${SYSCTL_DROPIN_FILE} /etc/sysctl.d/ && sudo sysctl --system`,
|
|
725
|
+
`# Withdraw: sudo rm /etc/sysctl.d/${SYSCTL_DROPIN_FILE} && sudo sysctl -w net.ipv4.ip_unprivileged_port_start=1024 && sudo sysctl --system`,
|
|
726
|
+
`net.ipv4.ip_unprivileged_port_start = ${start}`,
|
|
727
|
+
'',
|
|
728
|
+
);
|
|
729
|
+
return lines.join('\n');
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* Everything --systemd writes, as text: the unit, the sysctl drop-in when a bound port is below 1024, and
|
|
734
|
+
* the warnings worth printing. Pure (POSIX paths in, strings out) so it can be exercised directly.
|
|
735
|
+
*
|
|
736
|
+
* port the explicit --port, or null. Only an explicit port is written into the unit: an
|
|
737
|
+
* inherited value would silently override a port changed in the config later.
|
|
738
|
+
* configuredPort the port the site's config already sets (gatewayPort), or null.
|
|
739
|
+
* acmeHttp01Port acme.http01Port from the config, or null: monolith.js binds it beside an HTTPS
|
|
740
|
+
* listener, so it counts toward the privileged-port floor.
|
|
741
|
+
*/
|
|
742
|
+
function buildSystemdFiles({ installDir, nodePath, user = DEFAULT_SERVICE_USER, port = null, configuredPort = null, acmeHttp01Port = null, http = false }) {
|
|
743
|
+
for (const [what, p] of [['site directory', installDir], ['node binary', nodePath]]) {
|
|
744
|
+
if (!unitSafePath(p)) {
|
|
745
|
+
const err = new Error(`The ${what} path "${p}" cannot go into a systemd unit as is: it must be absolute, without spaces, quotes, backslashes, "%" or "$".`);
|
|
746
|
+
err.hint = what === 'site directory'
|
|
747
|
+
? 'Install the site under a plain path such as /srv/wordjs.'
|
|
748
|
+
: 'Install Node.js under a plain path such as /usr/bin/node or /usr/local/bin/node.';
|
|
749
|
+
throw err;
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
const problem = serviceUserProblem(user);
|
|
753
|
+
if (problem) { const err = new Error(problem.message); err.hint = problem.hint; throw err; }
|
|
754
|
+
|
|
755
|
+
const publicPort = port || configuredPort || 3000;
|
|
756
|
+
const bound = [publicPort];
|
|
757
|
+
if (!http && acmeHttp01Port) bound.push(acmeHttp01Port);
|
|
758
|
+
const privileged = bound.filter((p) => p < PRIVILEGED_PORT_LIMIT);
|
|
759
|
+
const sysctlStart = privileged.length ? Math.min(...privileged) : null;
|
|
760
|
+
|
|
761
|
+
// Two hardening directives depend on WHERE things are, and each is dropped (with a warning) rather
|
|
762
|
+
// than shipped in a shape that cannot start: ProtectHome= hides /home, /root and /run/user, which
|
|
763
|
+
// breaks a site or a node (nvm, fnm, asdf) living there; PrivateTmp= gives the service an empty
|
|
764
|
+
// /tmp and /var/tmp, which would hide a site installed there.
|
|
765
|
+
const homeRoots = ['/home', '/root', '/run/user'];
|
|
766
|
+
const tmpRoots = ['/tmp', '/var/tmp'];
|
|
767
|
+
const warnings = [];
|
|
768
|
+
if (underAny(installDir, homeRoots)) {
|
|
769
|
+
warnings.push(`The site is inside a home directory (${installDir}). A service account usually cannot reach it there, and the unit leaves out ProtectHome=. Prefer /srv/<name> or /opt/<name>.`);
|
|
770
|
+
}
|
|
771
|
+
if (underAny(nodePath, homeRoots)) {
|
|
772
|
+
warnings.push(`node runs from ${nodePath}, inside a home directory (nvm, fnm, asdf…). The service account usually cannot execute it there: install Node.js system-wide and point ExecStart= at it, or re-run this with that node.`);
|
|
773
|
+
}
|
|
774
|
+
if (underAny(installDir, tmpRoots)) {
|
|
775
|
+
warnings.push(`The site is under a temporary directory (${installDir}), which the system may clean. The unit leaves out PrivateTmp= because it would hide the site; move it to /srv or /opt.`);
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
const nodeDir = path.posix.dirname(nodePath);
|
|
779
|
+
const env = ['NODE_ENV=production'];
|
|
780
|
+
if (port) env.push(`PORT=${port}`);
|
|
781
|
+
if (http) env.push('WORDJS_HTTP=1');
|
|
782
|
+
// HOME pinned inside the one writable tree: npm keeps its cache and reads its config under $HOME
|
|
783
|
+
// when it installs plugin dependencies, and ProtectHome= would hide a home under /home.
|
|
784
|
+
env.push(`HOME=${installDir}`);
|
|
785
|
+
if (!SYSTEMD_DEFAULT_PATH.includes(nodeDir)) env.push(`PATH=${[nodeDir, ...SYSTEMD_DEFAULT_PATH].join(':')}`);
|
|
786
|
+
|
|
787
|
+
const unit = [
|
|
788
|
+
'# WordJS - generated by create-wordjs --systemd.',
|
|
789
|
+
'#',
|
|
790
|
+
`# Runs the site as the unprivileged account "${user}" and grants it NO Linux capabilities. Keep it`,
|
|
791
|
+
'# that way: do not add AmbientCapabilities=, widen CapabilityBoundingSet= or setcap the node binary',
|
|
792
|
+
'# to reach a port below 1024. On Linux every isolated plugin runs under a Landlock/seccomp sandbox',
|
|
793
|
+
'# that has to shed any capability the service holds before it can confine the plugin.',
|
|
794
|
+
sysctlStart
|
|
795
|
+
? `# Port ${publicPort} is below 1024: install ${SYSCTL_DROPIN_FILE} (next to this file) or use a reverse proxy.`
|
|
796
|
+
: '# Ports below 1024: lower net.ipv4.ip_unprivileged_port_start, or use a reverse proxy.',
|
|
797
|
+
'# See documentation/deployment.md, "Running as a service".',
|
|
798
|
+
'',
|
|
799
|
+
'[Unit]',
|
|
800
|
+
'Description=WordJS (monolith)',
|
|
801
|
+
'Documentation=https://github.com/jaimemartinez/wordjs/blob/main/documentation/deployment.md',
|
|
802
|
+
'After=network-online.target',
|
|
803
|
+
'Wants=network-online.target',
|
|
804
|
+
'',
|
|
805
|
+
'[Service]',
|
|
806
|
+
'Type=simple',
|
|
807
|
+
`User=${user}`,
|
|
808
|
+
`WorkingDirectory=${installDir}`,
|
|
809
|
+
`ExecStart=${nodePath} ${installDir}/monolith.js prod`,
|
|
810
|
+
...env.map((e) => `Environment=${e}`),
|
|
811
|
+
'Restart=on-failure',
|
|
812
|
+
'RestartSec=5',
|
|
813
|
+
'',
|
|
814
|
+
'# Hardening. Each line takes something away; none grants anything.',
|
|
815
|
+
'# NoNewPrivileges, empty CapabilityBoundingSet: nothing the service runs can gain privileges',
|
|
816
|
+
'# (no setuid binary, no file capability). The plugin sandbox sets no_new_privs itself and',
|
|
817
|
+
'# needs no capability, so it is unaffected.',
|
|
818
|
+
'# ProtectSystem=strict + ReadWritePaths: the filesystem is read-only except the site directory,',
|
|
819
|
+
'# where WordJS writes its database, uploads, backups, plugins, themes and build caches.',
|
|
820
|
+
'# PrivateTmp, ProtectHome: a private /tmp, and no view of /home, /root or /run/user.',
|
|
821
|
+
'NoNewPrivileges=yes',
|
|
822
|
+
'CapabilityBoundingSet=',
|
|
823
|
+
...(underAny(installDir, tmpRoots) ? [] : ['PrivateTmp=yes']),
|
|
824
|
+
'ProtectSystem=strict',
|
|
825
|
+
`ReadWritePaths=${installDir}`,
|
|
826
|
+
...((underAny(installDir, homeRoots) || underAny(nodePath, homeRoots)) ? [] : ['ProtectHome=yes']),
|
|
827
|
+
'',
|
|
828
|
+
'[Install]',
|
|
829
|
+
'WantedBy=multi-user.target',
|
|
830
|
+
'',
|
|
831
|
+
].join('\n');
|
|
832
|
+
|
|
833
|
+
return {
|
|
834
|
+
unit,
|
|
835
|
+
sysctl: sysctlStart ? { start: sysctlStart, content: sysctlDropIn(sysctlStart, { http }) } : null,
|
|
836
|
+
publicPort,
|
|
837
|
+
warnings,
|
|
838
|
+
};
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/**
|
|
842
|
+
* The steps to install what buildSystemdFiles produced, as printable lines (pure, for the same reason).
|
|
843
|
+
* `stageDir` is where writeSystemdFiles put the two files - never the site directory (see there).
|
|
844
|
+
*/
|
|
845
|
+
function systemdInstallSteps({ installDir, user = DEFAULT_SERVICE_USER, built, http = false, upgrade = false, nodePath = process.execPath, stageDir }) {
|
|
846
|
+
const staged = (f) => path.posix.join(stageDir, f);
|
|
847
|
+
const lines = [];
|
|
848
|
+
let n = 0;
|
|
849
|
+
const step = (title, ...cmds) => { lines.push(` ${++n}. ${title}`); for (const c of cmds) lines.push(` ${c}`); };
|
|
850
|
+
lines.push('To run it as a service (as root):');
|
|
851
|
+
step('Create the service account (skip if it exists):',
|
|
852
|
+
`sudo useradd --system --home-dir ${installDir} --shell /usr/sbin/nologin ${user}`);
|
|
853
|
+
step('Give it the site directory:', `sudo chown -R ${user}: ${installDir}`);
|
|
854
|
+
// `install -o root -g root -m 0644` and not `cp`: root takes the staged file's CONTENT into a file it
|
|
855
|
+
// creates itself, root-owned and not writable by anyone else, whatever the staged copy's mode was.
|
|
856
|
+
if (built.sysctl) {
|
|
857
|
+
step(`Let it bind port ${built.publicPort} without a capability (read the trade-off in the file first):`,
|
|
858
|
+
`sudo install -o root -g root -m 0644 ${staged(SYSCTL_DROPIN_FILE)} /etc/sysctl.d/${SYSCTL_DROPIN_FILE} && sudo sysctl --system`);
|
|
859
|
+
} else if (upgrade) {
|
|
860
|
+
// A site that moved to a high port (behind a proxy, say) no longer needs the floor lowered, and an
|
|
861
|
+
// installed drop-in would go on letting every unprivileged process bind below 1024 for no reason.
|
|
862
|
+
// Setting 1024 first and re-applying the rest leaves any OTHER drop-in's value in force.
|
|
863
|
+
step(`Port ${built.publicPort} needs no lowered port floor. If an earlier run installed /etc/sysctl.d/${SYSCTL_DROPIN_FILE}, withdraw it:`,
|
|
864
|
+
`sudo rm -f /etc/sysctl.d/${SYSCTL_DROPIN_FILE} && sudo sysctl -w net.ipv4.ip_unprivileged_port_start=1024 && sudo sysctl --system`);
|
|
865
|
+
}
|
|
866
|
+
step('Install the unit and start it:',
|
|
867
|
+
`sudo install -o root -g root -m 0644 ${staged(SYSTEMD_UNIT_FILE)} /etc/systemd/system/${SYSTEMD_UNIT_FILE}`,
|
|
868
|
+
// enable on upgrade too: a site started with `npm run start:mono`, pm2 or a unit of another name
|
|
869
|
+
// has no enabled wordjs.service, and a restart alone does not bring it back after a reboot.
|
|
870
|
+
upgrade ? 'sudo systemctl daemon-reload && sudo systemctl enable wordjs && sudo systemctl restart wordjs'
|
|
871
|
+
: 'sudo systemctl daemon-reload && sudo systemctl enable --now wordjs');
|
|
872
|
+
if (!upgrade) {
|
|
873
|
+
const proto = http ? 'http' : 'https';
|
|
874
|
+
const defaultPort = http ? 80 : 443;
|
|
875
|
+
const hostPort = built.publicPort === defaultPort ? '<your-host>' : `<your-host>:${built.publicPort}`;
|
|
876
|
+
step('Finish setup in the browser with the one-time install token the first boot writes:',
|
|
877
|
+
`sudo cat ${path.posix.join(installDir, 'backend', 'data', 'install-token')}`,
|
|
878
|
+
`then open ${proto}://${hostPort}/install#token=<that token>`);
|
|
879
|
+
} else {
|
|
880
|
+
lines.push(' Before step ' + n + ', stop and disable whatever served this site until now, or it and wordjs.service',
|
|
881
|
+
' will fight over the port: a unit with another name (sudo systemctl disable --now <name>), pm2',
|
|
882
|
+
' (pm2 delete <name> && pm2 save), or a foreground `npm run start:mono`. A unit that was also named',
|
|
883
|
+
' wordjs.service is replaced by step ' + n + ' - including an AmbientCapabilities= or capability-carrying',
|
|
884
|
+
` CapabilityBoundingSet= in it. If node carries a file capability (check with getcap ${nodePath}),`,
|
|
885
|
+
` remove it: sudo setcap -r ${nodePath}`);
|
|
886
|
+
}
|
|
887
|
+
lines.push(` The staged copies in ${stageDir} are not needed afterwards: rm -r ${stageDir}`);
|
|
888
|
+
lines.push(' Logs: journalctl -u wordjs -f');
|
|
889
|
+
return lines;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
/**
|
|
893
|
+
* A fresh, private directory to stage the files root will install.
|
|
894
|
+
*
|
|
895
|
+
* NEVER THE SITE DIRECTORY. These two files are read by root and installed into /etc - the unit decides
|
|
896
|
+
* which account the service runs as and what it may do; the drop-in is applied with `sysctl --system`.
|
|
897
|
+
* The site directory is handed to the service account by the very next printed step (`chown -R`), so a
|
|
898
|
+
* compromised WordJS process - or a plugin that got out of its sandbox - could rewrite either file
|
|
899
|
+
* between that step and root's copy (`User=root`, an ExecStartPre=; `kernel.modprobe = …`), or put a
|
|
900
|
+
* symlink in its place so this CLI, run with sudo, wrote through it into any file root can write.
|
|
901
|
+
* Measured: as the service account, a planted symlink made a root run of the old writer overwrite a
|
|
902
|
+
* root-only 0600 file, and a plain `mv` replaced the staged drop-in root was told to apply.
|
|
903
|
+
* mkdtemp creates a new directory, mode 0700, owned by whoever runs this CLI, under a name nobody could
|
|
904
|
+
* predict; the service account cannot enter it.
|
|
905
|
+
*/
|
|
906
|
+
function createSystemdStagingDir() {
|
|
907
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), 'wordjs-systemd-'));
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
/**
|
|
911
|
+
* Write the unit (and the sysctl drop-in when one is needed) into `stageDir`, a directory from
|
|
912
|
+
* createSystemdStagingDir(). Each file is created EXCLUSIVELY (`wx` = O_CREAT|O_EXCL): an existing file -
|
|
913
|
+
* or a symlink planted under the name - makes the write fail rather than go through it.
|
|
914
|
+
*/
|
|
915
|
+
function writeSystemdFiles(stageDir, built) {
|
|
916
|
+
const unitPath = path.join(stageDir, SYSTEMD_UNIT_FILE);
|
|
917
|
+
const sysctlPath = path.join(stageDir, SYSCTL_DROPIN_FILE);
|
|
918
|
+
fs.writeFileSync(unitPath, built.unit, { flag: 'wx', mode: 0o644 });
|
|
919
|
+
if (built.sysctl) fs.writeFileSync(sysctlPath, built.sysctl.content, { flag: 'wx', mode: 0o644 });
|
|
920
|
+
return { unitPath, sysctlPath: built.sysctl ? sysctlPath : null };
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
/**
|
|
924
|
+
* Why --systemd cannot run for this site, with this node and as this account - or null. Checked before
|
|
925
|
+
* anything is downloaded, extracted or installed: a create used to fetch and install the whole release
|
|
926
|
+
* and only THEN refuse the path, leaving the populated directory behind, and an upgrade applied the new
|
|
927
|
+
* version and then exited 1.
|
|
928
|
+
*/
|
|
929
|
+
function systemdPreflight({ installDir, nodePath = process.execPath, user = DEFAULT_SERVICE_USER, runningAs = null }) {
|
|
930
|
+
for (const [what, p] of [['site directory', installDir], ['node binary', nodePath]]) {
|
|
931
|
+
if (!unitSafePath(p)) {
|
|
932
|
+
return {
|
|
933
|
+
message: `The ${what} path "${p}" cannot go into a systemd unit as is: it must be absolute, without spaces, quotes, backslashes, "%" or "$".`,
|
|
934
|
+
hint: what === 'site directory'
|
|
935
|
+
? 'Install the site under a plain path such as /srv/wordjs.'
|
|
936
|
+
: 'Install Node.js under a plain path such as /usr/bin/node or /usr/local/bin/node.',
|
|
937
|
+
};
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
// The files root will install must not be writable by the service. Staged by the service account
|
|
941
|
+
// itself, they would be.
|
|
942
|
+
if (runningAs && runningAs === user) {
|
|
943
|
+
return {
|
|
944
|
+
message: `This is running as "${user}", the account the service will run as.`,
|
|
945
|
+
hint: 'Run create-wordjs --systemd as your own account or with sudo: the unit it stages for root must not be writable by the service.',
|
|
946
|
+
};
|
|
947
|
+
}
|
|
948
|
+
return null;
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
/** --systemd for a site on disk: read its config, stage the files, print the steps. Throws (with .hint) instead of exiting, so a caller's cleanup still runs. */
|
|
952
|
+
function emitSystemd(installDir, opts) {
|
|
953
|
+
const readJson = (p) => { try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return {}; } };
|
|
954
|
+
// The same precedence monolith.js applies (minus the PORT env var, which is what --port writes).
|
|
955
|
+
const appConfig = readJson(path.join(installDir, 'backend', 'wordjs-config.json'));
|
|
956
|
+
const gwConfig = readJson(path.join(installDir, 'gateway', 'gateway-config.json'));
|
|
957
|
+
const user = opts.serviceUser || DEFAULT_SERVICE_USER;
|
|
958
|
+
const built = buildSystemdFiles({
|
|
959
|
+
installDir,
|
|
960
|
+
nodePath: process.execPath,
|
|
961
|
+
user,
|
|
962
|
+
port: opts.port,
|
|
963
|
+
configuredPort: Number(appConfig.gatewayPort || gwConfig.gatewayPort) || null,
|
|
964
|
+
acmeHttp01Port: Number(appConfig.acme && appConfig.acme.http01Port) || null,
|
|
965
|
+
http: opts.http,
|
|
966
|
+
});
|
|
967
|
+
const stageDir = createSystemdStagingDir();
|
|
968
|
+
const written = writeSystemdFiles(stageDir, built);
|
|
969
|
+
console.log('');
|
|
970
|
+
console.log(` systemd unit staged: ${written.unitPath}`);
|
|
971
|
+
if (written.sysctlPath) console.log(` sysctl drop-in for port ${built.publicPort}: ${written.sysctlPath}`);
|
|
972
|
+
console.log(' (staged outside the site on purpose: the service account will own the site, and must not be able to edit what root installs)');
|
|
973
|
+
for (const w of built.warnings) console.log(` ⚠️ ${w}`);
|
|
974
|
+
console.log('');
|
|
975
|
+
for (const l of systemdInstallSteps({ installDir, user, built, http: opts.http, upgrade: opts.mode === 'upgrade', stageDir })) console.log(` ${l}`);
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
// Printed on Linux after a plain (non --systemd) create, in place of the `setcap` line this installer
|
|
979
|
+
// used to print. Why setcap is not the answer is explained above buildSystemdFiles.
|
|
980
|
+
const LOW_PORT_ADVICE = [
|
|
981
|
+
' • Serving on 80/443 later? A non-root process cannot bind a port below 1024 by default.',
|
|
982
|
+
' Put a reverse proxy (nginx, Caddy) on 80/443 in front of this port, or lower',
|
|
983
|
+
' net.ipv4.ip_unprivileged_port_start. Do not give node a capability (setcap): see',
|
|
984
|
+
' documentation/deployment.md. A service unit that does this right: re-run with --systemd',
|
|
985
|
+
' (for this site: npx create-wordjs@latest upgrade <dir> --systemd).',
|
|
986
|
+
];
|
|
987
|
+
|
|
355
988
|
// --- upgrade -----------------------------------------------------------------------------------
|
|
356
989
|
|
|
357
990
|
function confirm(question) {
|
|
@@ -399,30 +1032,16 @@ function copyMerge(src, dest, rel = '') {
|
|
|
399
1032
|
// Download (or use a local/URL) release ZIP and extract it into a fresh temp dir. Returns the
|
|
400
1033
|
// extracted app root + a cleanup fn. Reuses the same resolution the create flow uses.
|
|
401
1034
|
async function obtainReleaseToTemp(opts) {
|
|
402
|
-
|
|
403
|
-
|
|
1035
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wordjs-upgrade-'));
|
|
1036
|
+
let zipCleanup = () => {};
|
|
1037
|
+
const cleanup = () => { zipCleanup(); try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* best-effort */ } };
|
|
404
1038
|
try {
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
if (opts.zip && !/^https?:\/\//i.test(opts.zip)) {
|
|
408
|
-
zipPath = path.resolve(process.cwd(), opts.zip);
|
|
409
|
-
if (!fs.existsSync(zipPath)) fail(`ZIP not found: ${zipPath}`);
|
|
410
|
-
} else {
|
|
411
|
-
let url = opts.zip;
|
|
412
|
-
let name = 'wordjs.zip';
|
|
413
|
-
if (!url) {
|
|
414
|
-
console.log(opts.version ? ` Looking up release ${opts.version} of ${REPO}…` : ` Looking up the latest release of ${REPO}…`);
|
|
415
|
-
const asset = await resolveReleaseAsset(opts.version);
|
|
416
|
-
url = asset.url; name = asset.name; tag = asset.tag;
|
|
417
|
-
console.log(` Found ${asset.tag} → ${asset.name}`);
|
|
418
|
-
}
|
|
419
|
-
zipPath = path.join(tmpDir, name);
|
|
420
|
-
await download(url, zipPath, name);
|
|
421
|
-
}
|
|
1039
|
+
const obtained = await obtainBundleZip(opts);
|
|
1040
|
+
zipCleanup = obtained.cleanup;
|
|
422
1041
|
const extractDir = path.join(tmpDir, 'extracted');
|
|
423
1042
|
fs.mkdirSync(extractDir, { recursive: true });
|
|
424
|
-
extractZip(zipPath, extractDir);
|
|
425
|
-
return { extractDir, tag, cleanup };
|
|
1043
|
+
extractZip(obtained.zipPath, extractDir);
|
|
1044
|
+
return { extractDir, tag: obtained.tag, cleanup };
|
|
426
1045
|
} catch (e) {
|
|
427
1046
|
cleanup();
|
|
428
1047
|
throw e;
|
|
@@ -463,7 +1082,11 @@ async function upgrade(opts) {
|
|
|
463
1082
|
console.log(` Target version: v${newVersion}${tag ? ` (${tag})` : ''}`);
|
|
464
1083
|
|
|
465
1084
|
if (curVersion === newVersion && !opts.force) {
|
|
466
|
-
console.log(`\n✅ Already on v${curVersion}. Nothing to upgrade. (use --force to re-apply the same version)
|
|
1085
|
+
console.log(`\n✅ Already on v${curVersion}. Nothing to upgrade. (use --force to re-apply the same version)`);
|
|
1086
|
+
// --systemd on an up-to-date site is still a request for the unit: it is how an existing
|
|
1087
|
+
// install replaces a hand-written unit (one with AmbientCapabilities=, say) with this one.
|
|
1088
|
+
if (opts.systemd) emitSystemd(installDir, opts);
|
|
1089
|
+
console.log('');
|
|
467
1090
|
return;
|
|
468
1091
|
}
|
|
469
1092
|
|
|
@@ -527,6 +1150,7 @@ async function upgrade(opts) {
|
|
|
527
1150
|
console.log(` • otherwise: stop it, then cd ${opts.dir === '.' ? installDir : opts.dir} && npm run start:mono`);
|
|
528
1151
|
console.log('');
|
|
529
1152
|
console.log(' Rollback: re-run with --version <old-tag> (your data stays intact).');
|
|
1153
|
+
if (opts.systemd) emitSystemd(installDir, opts);
|
|
530
1154
|
console.log(line + '\n');
|
|
531
1155
|
} finally {
|
|
532
1156
|
cleanup();
|
|
@@ -545,27 +1169,11 @@ async function scaffoldBundle(opts, targetDir) {
|
|
|
545
1169
|
fs.mkdirSync(targetDir, { recursive: true });
|
|
546
1170
|
}
|
|
547
1171
|
|
|
548
|
-
|
|
549
|
-
if (opts.zip && !/^https?:\/\//i.test(opts.zip)) {
|
|
550
|
-
zipPath = path.resolve(process.cwd(), opts.zip);
|
|
551
|
-
if (!fs.existsSync(zipPath)) fail(`ZIP not found: ${zipPath}`);
|
|
552
|
-
console.log(` Using local bundle: ${zipPath}`);
|
|
553
|
-
} else {
|
|
554
|
-
let url = opts.zip, name = 'wordjs.zip';
|
|
555
|
-
if (!url) {
|
|
556
|
-
console.log(opts.version ? ` Looking up release ${opts.version} of ${REPO}…` : ` Looking up the latest release of ${REPO}…`);
|
|
557
|
-
const asset = await resolveReleaseAsset(opts.version);
|
|
558
|
-
url = asset.url; name = asset.name;
|
|
559
|
-
console.log(` Found ${asset.tag} → ${asset.name}`);
|
|
560
|
-
}
|
|
561
|
-
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'create-wordjs-'));
|
|
562
|
-
zipPath = path.join(tmpDir, name);
|
|
563
|
-
await download(url, zipPath, name);
|
|
564
|
-
}
|
|
1172
|
+
const { zipPath, cleanup } = await obtainBundleZip(opts);
|
|
565
1173
|
|
|
566
1174
|
console.log(` Extracting into ${targetDir}…`);
|
|
567
1175
|
try { extractZip(zipPath, targetDir); }
|
|
568
|
-
finally {
|
|
1176
|
+
finally { cleanup(); }
|
|
569
1177
|
|
|
570
1178
|
let pkg = {};
|
|
571
1179
|
try { pkg = JSON.parse(fs.readFileSync(path.join(targetDir, 'package.json'), 'utf8')); } catch { /* handled below */ }
|
|
@@ -593,7 +1201,10 @@ async function gateway(opts) {
|
|
|
593
1201
|
runNode('scripts/cluster.js', ['init', '--host', host], targetDir);
|
|
594
1202
|
|
|
595
1203
|
// Read the CA fingerprint and mint a token per role (capturing the raw token for the join command).
|
|
596
|
-
|
|
1204
|
+
// The join commands below are only safe with the real fingerprint in them (join requires --ca-hash),
|
|
1205
|
+
// so a missing one is fatal here rather than a '<fingerprint>' placeholder pasted onto the nodes.
|
|
1206
|
+
const fp = (runNodeCapture('scripts/cluster.js', ['info'], targetDir).match(/CA fingerprint:\s*([0-9a-f]{64})/) || [])[1];
|
|
1207
|
+
if (!fp) fail('Could not read the cluster CA fingerprint from "node scripts/cluster.js info".', `Run it yourself in ${opts.dir} and pass the value to join as --ca-hash.`);
|
|
597
1208
|
const mint = (role) => (runNodeCapture('scripts/cluster.js', ['token', role, '--ttl', '120'], targetDir)
|
|
598
1209
|
.match(new RegExp(`wjc\\.${role}\\.[A-Za-z0-9_-]+`)) || [])[0] || '<token>';
|
|
599
1210
|
const beTok = mint('backend'), feTok = mint('frontend');
|
|
@@ -633,7 +1244,13 @@ async function join(opts) {
|
|
|
633
1244
|
}
|
|
634
1245
|
if (!opts.gateway) fail('--gateway <gateway-ip/dns> is required for join.');
|
|
635
1246
|
if (!opts.token) fail('--token <join-token> is required for join.', `Mint one on the gateway: node scripts/cluster.js token ${opts.role}`);
|
|
636
|
-
|
|
1247
|
+
// The CA pin is what stops an on-path attacker from receiving the token, the cluster secret and a
|
|
1248
|
+
// CA-signed cert during enrollment, so it is required. Checked HERE, before the download + install.
|
|
1249
|
+
if (!opts.caHash) {
|
|
1250
|
+
fail('--ca-hash <sha256> is required for join.',
|
|
1251
|
+
'Use the fingerprint the gateway printed (node scripts/cluster.js info on the gateway). Enrolling without it\n would be trust-on-first-use, which is not supported.');
|
|
1252
|
+
}
|
|
1253
|
+
if (!normalizeSha256(opts.caHash)) fail(`--ca-hash must be the 64-character hex CA fingerprint, got "${opts.caHash}".`);
|
|
637
1254
|
|
|
638
1255
|
const targetDir = path.resolve(process.cwd(), opts.dir);
|
|
639
1256
|
console.log(`\n🚀 create-wordjs · join ${opts.role} (separate mode)\n`);
|
|
@@ -642,7 +1259,7 @@ async function join(opts) {
|
|
|
642
1259
|
const advertise = opts.advertise || firstLanIp();
|
|
643
1260
|
const args = ['--role', opts.role, '--gateway', opts.gateway, '--enroll-port', String(opts.enrollPort || 3101),
|
|
644
1261
|
'--token', opts.token, '--advertise', advertise];
|
|
645
|
-
|
|
1262
|
+
args.push('--ca-hash', opts.caHash);
|
|
646
1263
|
if (opts.start) args.push('--start');
|
|
647
1264
|
|
|
648
1265
|
console.log(`\n🎟️ Enrolling ${opts.role} with gateway ${opts.gateway} (advertise ${advertise})…\n`);
|
|
@@ -661,6 +1278,26 @@ async function join(opts) {
|
|
|
661
1278
|
|
|
662
1279
|
async function main() {
|
|
663
1280
|
const opts = parseArgs(process.argv.slice(2));
|
|
1281
|
+
// Checked before anything is downloaded: the unit names this machine's node and site paths, so it
|
|
1282
|
+
// can only be generated on the Linux host that will run it.
|
|
1283
|
+
if (opts.systemd && process.platform !== 'linux') {
|
|
1284
|
+
fail('--systemd writes a Linux systemd unit, and this machine is not running Linux.',
|
|
1285
|
+
'Run create-wordjs with --systemd on the Linux host that will run the site.');
|
|
1286
|
+
}
|
|
1287
|
+
// ...and so is everything else the unit depends on: the site path, the node path and who is running
|
|
1288
|
+
// this. Before, a path the unit cannot hold was found only after the whole release had been fetched
|
|
1289
|
+
// and installed (create) or applied (upgrade), which then exited 1 with the work done.
|
|
1290
|
+
if (opts.systemd) {
|
|
1291
|
+
let runningAs = null;
|
|
1292
|
+
try { runningAs = os.userInfo().username; } catch { /* unknown: the check below is skipped */ }
|
|
1293
|
+
const problem = systemdPreflight({
|
|
1294
|
+
installDir: path.resolve(process.cwd(), opts.dir),
|
|
1295
|
+
nodePath: process.execPath,
|
|
1296
|
+
user: opts.serviceUser || DEFAULT_SERVICE_USER,
|
|
1297
|
+
runningAs,
|
|
1298
|
+
});
|
|
1299
|
+
if (problem) fail(problem.message, problem.hint);
|
|
1300
|
+
}
|
|
664
1301
|
if (opts.mode === 'upgrade') return upgrade(opts);
|
|
665
1302
|
if (opts.mode === 'gateway') return gateway(opts);
|
|
666
1303
|
if (opts.mode === 'join') return join(opts);
|
|
@@ -678,34 +1315,16 @@ async function main() {
|
|
|
678
1315
|
|
|
679
1316
|
console.log('\n🚀 create-wordjs\n');
|
|
680
1317
|
|
|
681
|
-
// 1) Obtain the release ZIP (local path,
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
if (opts.zip && !/^https?:\/\//i.test(opts.zip)) {
|
|
685
|
-
zipPath = path.resolve(process.cwd(), opts.zip);
|
|
686
|
-
if (!fs.existsSync(zipPath)) fail(`ZIP not found: ${zipPath}`);
|
|
687
|
-
console.log(` Using local bundle: ${zipPath}`);
|
|
688
|
-
} else {
|
|
689
|
-
let url = opts.zip;
|
|
690
|
-
let name = 'wordjs.zip';
|
|
691
|
-
if (!url) {
|
|
692
|
-
console.log(opts.version ? ` Looking up release ${opts.version} of ${REPO}…` : ` Looking up the latest release of ${REPO}…`);
|
|
693
|
-
const asset = await resolveReleaseAsset(opts.version);
|
|
694
|
-
url = asset.url;
|
|
695
|
-
name = asset.name;
|
|
696
|
-
console.log(` Found ${asset.tag} → ${asset.name}`);
|
|
697
|
-
}
|
|
698
|
-
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'create-wordjs-'));
|
|
699
|
-
zipPath = path.join(tmpDir, name);
|
|
700
|
-
await download(url, zipPath, name);
|
|
701
|
-
}
|
|
1318
|
+
// 1) Obtain the release ZIP (local path, https URL, or GitHub latest/tagged release) and verify
|
|
1319
|
+
// its SHA-256 before anything is extracted.
|
|
1320
|
+
const { zipPath, cleanup } = await obtainBundleZip(opts);
|
|
702
1321
|
|
|
703
1322
|
// 2) Extract + sanity-check that this really is a WordJS release bundle.
|
|
704
1323
|
console.log(` Extracting into ${targetDir}…`);
|
|
705
1324
|
try {
|
|
706
1325
|
extractZip(zipPath, targetDir);
|
|
707
1326
|
} finally {
|
|
708
|
-
|
|
1327
|
+
cleanup();
|
|
709
1328
|
}
|
|
710
1329
|
const pkgPath = path.join(targetDir, 'package.json');
|
|
711
1330
|
let pkg = {};
|
|
@@ -725,6 +1344,17 @@ async function main() {
|
|
|
725
1344
|
const proto = opts.http ? 'http' : 'https';
|
|
726
1345
|
const line = '━'.repeat(64);
|
|
727
1346
|
|
|
1347
|
+
// --systemd implies --no-start: started from here, the first boot would run as whoever ran npx and
|
|
1348
|
+
// leave the database, install token and certificates owned by that account instead of the service's.
|
|
1349
|
+
if (opts.systemd) {
|
|
1350
|
+
console.log(`\n${line}`);
|
|
1351
|
+
console.log(`✅ WordJS scaffolded into ${targetDir} (dependencies installed). Not started: the first`);
|
|
1352
|
+
console.log(' boot must run as the service account, so that it owns the files it creates.');
|
|
1353
|
+
emitSystemd(targetDir, opts);
|
|
1354
|
+
console.log(line + '\n');
|
|
1355
|
+
return;
|
|
1356
|
+
}
|
|
1357
|
+
|
|
728
1358
|
if (!opts.start) {
|
|
729
1359
|
console.log(`\n${line}`);
|
|
730
1360
|
console.log(`✅ WordJS scaffolded into ${opts.dir} (dependencies installed).`);
|
|
@@ -772,11 +1402,7 @@ async function main() {
|
|
|
772
1402
|
console.log(' backend/data/install-token, mode 0600)');
|
|
773
1403
|
if (process.platform === 'linux') {
|
|
774
1404
|
console.log('');
|
|
775
|
-
|
|
776
|
-
console.log(' Binding a port below 1024 as a non-root user needs a one-time grant — run once:');
|
|
777
|
-
console.log(' sudo setcap cap_net_bind_service=+ep "$(readlink -f "$(command -v node)")"');
|
|
778
|
-
console.log(' Without it, inbound falls back to port 2525 (sending + local mail still work);');
|
|
779
|
-
console.log(' the admin Email → Server Admin screen shows the live listener status either way.');
|
|
1405
|
+
for (const l of LOW_PORT_ADVICE) console.log(l);
|
|
780
1406
|
}
|
|
781
1407
|
console.log(line + '\n');
|
|
782
1408
|
|
|
@@ -792,7 +1418,11 @@ async function main() {
|
|
|
792
1418
|
|
|
793
1419
|
// Run only when invoked as the CLI, so the pure helpers above can be required and exercised.
|
|
794
1420
|
if (require.main === module) {
|
|
795
|
-
main().catch((e) => fail(e && e.message ? e.message : String(e)));
|
|
1421
|
+
main().catch((e) => fail(e && e.message ? e.message : String(e), e && e.hint));
|
|
796
1422
|
}
|
|
797
1423
|
|
|
798
|
-
module.exports = {
|
|
1424
|
+
module.exports = {
|
|
1425
|
+
pickBundleAsset, pickChecksumAsset, extractZip, normalizeSha256, parseChecksumFile, classifyZipSource, sha256File,
|
|
1426
|
+
parseArgs, buildSystemdFiles, systemdInstallSteps, writeSystemdFiles,
|
|
1427
|
+
sysctlDropIn, serviceUserProblem, systemdPreflight, createSystemdStagingDir, LOW_PORT_ADVICE, HELP, installedFileMode,
|
|
1428
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-wordjs",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.0",
|
|
4
4
|
"description": "Create a WordJS site with one command — the self-hosted CMS where third-party plugins run in an OS-isolated process with per-capability permission grants. SSR/SEO out of the box, SQLite by default, no PHP.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Jaime Martinez (https://github.com/jaimemartinez)",
|