@lamalibre/create-lamaste 4.3.0 → 4.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/package.json +2 -2
  2. package/scripts/bundle-vendor.js +13 -8
  3. package/src/index.js +4 -0
  4. package/src/lib/panel-pin.js +41 -0
  5. package/vendor/docs/00-introduction/how-it-works.md +2 -2
  6. package/vendor/docs/00-introduction/quickstart.md +5 -5
  7. package/vendor/docs/00-introduction/what-is-lamaste.md +5 -5
  8. package/vendor/docs/01-concepts/certificates.md +3 -3
  9. package/vendor/docs/01-concepts/mtls.md +2 -2
  10. package/vendor/docs/01-concepts/security-model.md +13 -2
  11. package/vendor/docs/01-concepts/tickets.md +2 -2
  12. package/vendor/docs/01-concepts/tunneling.md +4 -4
  13. package/vendor/docs/02-guides/agent-setup.md +5 -5
  14. package/vendor/docs/02-guides/certificate-management.md +2 -2
  15. package/vendor/docs/02-guides/cloud-provisioning.md +4 -4
  16. package/vendor/docs/02-guides/desktop-app-setup.md +11 -10
  17. package/vendor/docs/02-guides/installation.md +3 -3
  18. package/vendor/docs/02-guides/onboarding.md +2 -2
  19. package/vendor/docs/02-guides/standalone-plugin-with-tickets.md +2 -2
  20. package/vendor/docs/03-architecture/installer-flow.md +2 -2
  21. package/vendor/docs/03-architecture/installer.md +4 -4
  22. package/vendor/docs/03-architecture/management-flow.md +1 -1
  23. package/vendor/docs/03-architecture/overview.md +5 -5
  24. package/vendor/docs/03-architecture/panel-client.md +6 -6
  25. package/vendor/docs/03-architecture/panel-server.md +2 -2
  26. package/vendor/docs/03-architecture/state-management.md +2 -2
  27. package/vendor/docs/03-architecture/system-overview.md +1 -1
  28. package/vendor/docs/04-api-reference/agent-hosted-plugins.md +2 -2
  29. package/vendor/docs/04-api-reference/app-host.md +3 -3
  30. package/vendor/docs/04-api-reference/lifecycle-hooks.md +4 -4
  31. package/vendor/docs/04-api-reference/onboarding.md +1 -1
  32. package/vendor/docs/04-api-reference/registry-and-oidc.md +1 -1
  33. package/vendor/docs/05-operations/backup-and-restore.md +3 -1
  34. package/vendor/docs/05-operations/uninstalling.md +1 -1
  35. package/vendor/docs/05-operations/upgrades.md +5 -5
  36. package/vendor/docs/06-reference/config-files.md +6 -1
  37. package/vendor/docs/06-reference/glossary.md +3 -3
  38. package/vendor/docs/06-reference/installer-flags.md +13 -13
  39. package/vendor/docs/06-reference/ports-and-services.md +1 -1
  40. package/vendor/docs/06-reference/troubleshooting.md +8 -8
  41. package/vendor/docs/gatekeeper-design.md +8 -8
  42. package/vendor/docs/identity-directory-design.md +2 -2
  43. package/vendor/docs/package.json +1 -1
  44. package/vendor/docs/todo/lib-cli-desktop-refactor.md +27 -27
  45. package/vendor/gatekeeper/dist/cli/index.js +0 -0
  46. package/vendor/gatekeeper/package.json +2 -2
  47. package/vendor/server/bin/lamaste-server.js +7 -0
  48. package/vendor/server/package.json +2 -2
  49. package/vendor/server/src/commands/panel-pin.js +40 -0
  50. package/vendor/server/src/panel-api.js +247 -32
  51. package/vendor/server/src/panel-identity.js +165 -0
  52. package/vendor/server-ui/dist/assets/index-BTlBt_cD.js +84 -0
  53. package/vendor/server-ui/dist/index.html +1 -1
  54. package/vendor/serverd/package.json +2 -2
  55. package/vendor/server-ui/dist/assets/index-DBp3cmaU.js +0 -84
@@ -4,35 +4,157 @@
4
4
  * Used by tunnel/site commands that need the full dependency stack
5
5
  * (nginx, certbot, chisel) which lives in the running panel server.
6
6
  * Authenticates via the admin mTLS client certificate.
7
+ *
8
+ * The panel is verified before anything is sent: the CLI talks to nginx's IP
9
+ * vhost on loopback only when it should be listening, and checks it presents
10
+ * the panel's own certificate, read from disk (see `panel-identity.js`) —
11
+ * port 9292 is one any local user could listen on when nginx does not.
12
+ *
13
+ * Nothing secret is ever an argument of curl: other local users can read a
14
+ * process's command line (`ps`, `/proc/<pid>/cmdline`). The P12 password is
15
+ * in a private `-K` config file, and a request body — which can carry a
16
+ * credential, such as the token of `registry-credential set --token-stdin`
17
+ * — reaches curl on stdin.
7
18
  */
8
19
 
9
- import { readFile, writeFile, unlink } from 'node:fs/promises';
10
- import crypto from 'node:crypto';
20
+ import { rmSync } from 'node:fs';
21
+ import { mkdtemp, open, readFile, rm } from 'node:fs/promises';
22
+ import { tmpdir } from 'node:os';
23
+ import { join } from 'node:path';
11
24
  import { execa } from 'execa';
12
- import { PKI_DIR } from './config.js';
25
+ import { PKI_DIR, readConfig } from './config.js';
26
+ import { PANEL_LOOPBACK, assertPanelListener, readPanelIdentity } from './panel-identity.js';
13
27
 
14
- const PANEL_URL = 'https://127.0.0.1:9292';
28
+ /**
29
+ * TLS 1.2 cipher suites the CLI offers: forward-secret ones only. With RSA
30
+ * key exchange a client sends its certificate before the server has proved
31
+ * it holds the key of the certificate it presented — and the panel's
32
+ * certificate is public — so a listener on the panel's port could collect
33
+ * the admin client certificate from the handshake. With ECDHE (and with
34
+ * TLS 1.3) the server signs first, and an impostor fails before the client
35
+ * presents anything. nginx's `HIGH` list offers these suites.
36
+ */
37
+ const FORWARD_SECRET_CIPHERS = 'ECDHE+AESGCM:ECDHE+CHACHA20';
38
+
39
+ /** The signals that remove a request's private curl config before the CLI dies. */
40
+ const CLEANUP_SIGNALS = ['SIGINT', 'SIGTERM'];
15
41
 
16
42
  /**
17
- * The curl argument vector for one panel request. Pure, exported for tests.
43
+ * One curl config directive, `name = "value"`, with backslashes and double
44
+ * quotes escaped for curl's quoted values. A value holding a line break or
45
+ * NUL is refused: curl reads its config line by line, so a line break would
46
+ * start a directive of the value's choosing. The error never repeats the
47
+ * value. Pure, exported for tests.
48
+ *
49
+ * @param {string} name
50
+ * @param {string} value
51
+ * @returns {string}
52
+ */
53
+ export function curlConfigLine(name, value) {
54
+ if (/[\r\n\0]/.test(value)) {
55
+ throw new Error(`The curl config value of "${name}" contains a line break or NUL`);
56
+ }
57
+ return `${name} = "${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"\n`;
58
+ }
59
+
60
+ /**
61
+ * Run `fn` with the path of a curl config file holding `content`. The file
62
+ * is created exclusively (`wx`, 0600) in a fresh private directory
63
+ * (`mkdtemp`, 0700): the CLI runs as root and the shared temporary
64
+ * directory belongs to every local user, none of whom may read the file or
65
+ * put a file or a link at its path first. The directory is removed on every
66
+ * path — also when the CLI is interrupted (Ctrl-C) or terminated while the
67
+ * request runs: SIGINT and SIGTERM handlers, installed for exactly that long,
68
+ * remove it and then let the signal end the process as it would have
69
+ * (status 130 / 143 to the shell). The CLI has no other use for them.
70
+ *
71
+ * @template T
72
+ * @param {string} content
73
+ * @param {(configPath: string) => Promise<T>} fn
74
+ * @returns {Promise<T>}
75
+ */
76
+ export async function withCurlConfig(content, fn) {
77
+ /** @type {string | undefined} */
78
+ let dir;
79
+ const onSignal = (signal) => {
80
+ if (dir !== undefined) {
81
+ try {
82
+ rmSync(dir, { recursive: true, force: true });
83
+ } catch {
84
+ // Nothing more can be done on the way out.
85
+ }
86
+ }
87
+ for (const sig of CLEANUP_SIGNALS) process.off(sig, onSignal);
88
+ // Without listeners the signal's default action applies again: re-raised,
89
+ // it ends the process. (A listener still installed — execa's, killing
90
+ // curl — re-raises it in turn once it is the last one.)
91
+ process.kill(process.pid, signal);
92
+ };
93
+ for (const sig of CLEANUP_SIGNALS) process.on(sig, onSignal);
94
+ try {
95
+ dir = await mkdtemp(join(tmpdir(), 'lamalibre-lamaste-curl-'));
96
+ const configPath = join(dir, 'curl.conf');
97
+ const fh = await open(configPath, 'wx', 0o600);
98
+ try {
99
+ await fh.writeFile(content, 'utf-8');
100
+ } finally {
101
+ await fh.close();
102
+ }
103
+ return await fn(configPath);
104
+ } finally {
105
+ for (const sig of CLEANUP_SIGNALS) process.off(sig, onSignal);
106
+ if (dir !== undefined) await rm(dir, { recursive: true, force: true });
107
+ }
108
+ }
109
+
110
+ /**
111
+ * The curl invocation for one panel request: its argument vector, and the
112
+ * JSON body curl reads on stdin (`--data-binary @-`; `undefined` without a
113
+ * body). The body is never an argument. Pure, exported for tests.
114
+ *
115
+ * The panel is verified as `identity` says (`readPanelIdentity`): curl
116
+ * trusts nothing but its certificate (`--cacert`), checks it is issued for
117
+ * the address in the request's URL, and connects to that address on
118
+ * loopback (`--connect-to`) — the certificate names the server's public
119
+ * address, and the CLI never leaves the machine. curl reads no `.curlrc`
120
+ * (`-q`, which must come first), which could switch verification off, and
121
+ * uses no proxy. Never `-k`.
18
122
  *
19
123
  * The JSON content type and the body travel together: a `Content-Type:
20
124
  * application/json` header on a body-less DELETE made Fastify answer 400
21
125
  * ("Body cannot be empty when content-type is set"), so `registry-credential
22
126
  * clear`, `tunnels delete` and `sites delete` never reached their routes.
23
127
  *
24
- * @param {{ method: string, path: string, body?: Record<string, unknown> | null, p12Path: string, configPath: string }} request
25
- * @returns {string[]}
128
+ * @param {{ identity: { certPath: string, address: string, port: number }, method: string, path: string, body?: Record<string, unknown> | null, p12Path: string, configPath: string }} request
129
+ * @returns {{ args: string[], input: string | undefined }}
26
130
  */
27
- export function buildPanelCurlArgs({ method, path, body = null, p12Path, configPath }) {
131
+ export function buildPanelCurlRequest({
132
+ identity,
133
+ method,
134
+ path,
135
+ body = null,
136
+ p12Path,
137
+ configPath,
138
+ }) {
139
+ const { certPath, address, port } = identity;
28
140
  const args = [
141
+ '-q', // No .curlrc — it must be the first argument
29
142
  '-K',
30
143
  configPath,
31
144
  '--cert-type',
32
145
  'P12',
33
146
  '--cert',
34
147
  p12Path,
35
- '-k', // Server uses self-signed cert on localhost
148
+ '--cacert',
149
+ certPath,
150
+ '--connect-to',
151
+ `${address}:${port}:${PANEL_LOOPBACK}:${port}`,
152
+ '--ciphers',
153
+ FORWARD_SECRET_CIPHERS,
154
+ '--proto',
155
+ '=https',
156
+ '--noproxy',
157
+ '*',
36
158
  '-s', // Silent
37
159
  '-S', // Show errors
38
160
  '-X',
@@ -40,13 +162,66 @@ export function buildPanelCurlArgs({ method, path, body = null, p12Path, configP
40
162
  '-H',
41
163
  'Accept: application/json',
42
164
  ];
165
+ let input;
43
166
  if (body !== null && body !== undefined) {
44
- args.push('-H', 'Content-Type: application/json', '-d', JSON.stringify(body));
167
+ args.push('-H', 'Content-Type: application/json', '--data-binary', '@-');
168
+ input = JSON.stringify(body);
45
169
  }
46
170
  // Append the HTTP status on its own line so an error response is not
47
171
  // mistaken for a result — the panel answers errors with a JSON body.
48
- args.push('-w', '\n%{http_code}', `${PANEL_URL}${path}`);
49
- return args;
172
+ args.push('-w', '\n%{http_code}', `https://${address}:${port}${path}`);
173
+ return { args, input };
174
+ }
175
+
176
+ /**
177
+ * The error for a curl run that did not complete, saying what went wrong in
178
+ * the panel's terms: curl's own last line, never its command line. Pure,
179
+ * exported for tests.
180
+ *
181
+ * @param {{ exitCode?: number, stderr?: string, timedOut?: boolean, code?: string }} failure
182
+ * @param {{ certPath: string, port: number }} identity
183
+ * @param {{ p12Path: string, timeoutMs: number }} request
184
+ * @returns {Error}
185
+ */
186
+ export function curlFailure(failure, identity, { p12Path, timeoutMs }) {
187
+ const reason =
188
+ String(failure.stderr ?? '')
189
+ .split('\n')
190
+ .map((line) => line.trim())
191
+ .filter(Boolean)
192
+ .pop() ?? '';
193
+ const listener = `${PANEL_LOOPBACK}:${identity.port}`;
194
+ let message;
195
+ if (failure.timedOut) {
196
+ message = `The panel did not answer within ${Math.round(timeoutMs / 1000)} s`;
197
+ } else if (failure.code === 'ENOENT') {
198
+ message = 'curl is not installed';
199
+ } else {
200
+ switch (failure.exitCode) {
201
+ case 7:
202
+ message = `Nothing listens on ${listener} — is nginx running? (systemctl status nginx)`;
203
+ break;
204
+ // The TLS handshake failed: certificate not trusted, issued for another
205
+ // address, another key — or no suite in common with an impostor.
206
+ case 35:
207
+ case 51:
208
+ case 60:
209
+ case 90:
210
+ message =
211
+ `The listener on ${listener} did not prove it is the panel: it must present ` +
212
+ `${identity.certPath}. Nothing was sent to it. If the certificate was regenerated, ` +
213
+ `reload nginx (systemctl reload nginx).`;
214
+ break;
215
+ case 58:
216
+ message = `curl could not use the admin client certificate ${p12Path}`;
217
+ break;
218
+ default:
219
+ message = `curl failed${failure.exitCode === undefined ? '' : ` (exit ${failure.exitCode})`}`;
220
+ }
221
+ }
222
+ const err = new Error(reason ? `${message} (${reason})` : message);
223
+ if (typeof failure.exitCode === 'number') err.curlExitCode = failure.exitCode;
224
+ return err;
50
225
  }
51
226
 
52
227
  /**
@@ -113,14 +288,57 @@ export function parsePanelResponse(stdout, method, path) {
113
288
  return parsed;
114
289
  }
115
290
 
291
+ /**
292
+ * Send one request to the panel with curl: the panel verified as
293
+ * `identity` says, the client certificate, its password in a private `-K`
294
+ * config file, and the body on stdin. Resolves and rejects as
295
+ * `panelRequest` does. `identity` is `readPanelIdentity()`'s (tests
296
+ * describe a local server).
297
+ *
298
+ * @param {{ identity: { certPath: string, address: string, port: number }, method: string, path: string, body?: Record<string, unknown> | null, p12Path: string, p12Password: string, timeoutMs?: number }} request
299
+ * @returns {Promise<Record<string, unknown>>}
300
+ */
301
+ export async function curlPanelRequest({
302
+ identity,
303
+ method,
304
+ path,
305
+ body = null,
306
+ p12Path,
307
+ p12Password,
308
+ timeoutMs = 30000,
309
+ }) {
310
+ return withCurlConfig(curlConfigLine('pass', p12Password), async (configPath) => {
311
+ const { args, input } = buildPanelCurlRequest({
312
+ identity,
313
+ method,
314
+ path,
315
+ body,
316
+ p12Path,
317
+ configPath,
318
+ });
319
+ const result = await execa('curl', args, {
320
+ timeout: timeoutMs,
321
+ reject: false,
322
+ ...(input === undefined ? {} : { input }),
323
+ });
324
+ if (result.failed) {
325
+ throw curlFailure(result, identity, { p12Path, timeoutMs });
326
+ }
327
+ return parsePanelResponse(result.stdout, method, path);
328
+ });
329
+ }
330
+
116
331
  /**
117
332
  * Make an authenticated API request to the local panel server.
118
333
  *
119
- * Uses curl with the admin client certificate (mTLS) and a temporary
120
- * config file for the P12 password (never in process args). Resolves with
121
- * the JSON body; rejects with an Error (`statusCode`, the panel's `error`
122
- * in the message, a refusal's `code`, `reasons` and `operationId`) for any
123
- * non-2xx answer.
334
+ * Refuses before connecting when nginx does not listen on the panel's port
335
+ * (panel 2FA on, IP vhost off), and verifies the listener against the
336
+ * panel's certificate (see the top of this file). Uses curl with the admin
337
+ * client certificate (mTLS); neither the P12 password nor the body is ever
338
+ * in curl's arguments. Resolves with the JSON body; rejects with an Error
339
+ * (`statusCode`, the panel's `error` in the message, a refusal's `code`,
340
+ * `reasons` and `operationId`) for any non-2xx answer, and with one that
341
+ * says why when the request could not be made.
124
342
  *
125
343
  * @param {string} method
126
344
  * @param {string} path e.g. '/api/tunnels'
@@ -130,6 +348,17 @@ export function parsePanelResponse(stdout, method, path) {
130
348
  * @returns {Promise<Record<string, unknown>>}
131
349
  */
132
350
  export async function panelRequest(method, path, body = null, { timeoutMs = 30000 } = {}) {
351
+ let config;
352
+ try {
353
+ config = await readConfig();
354
+ } catch {
355
+ throw new Error(
356
+ 'Cannot read panel.json. Ensure the server is properly installed and /etc/lamalibre/lamaste/panel.json exists.',
357
+ );
358
+ }
359
+ await assertPanelListener(config);
360
+ const identity = await readPanelIdentity();
361
+
133
362
  const p12Path = `${PKI_DIR}/client.p12`;
134
363
 
135
364
  // Read p12 password from the stored file
@@ -142,19 +371,5 @@ export async function panelRequest(method, path, body = null, { timeoutMs = 3000
142
371
  );
143
372
  }
144
373
 
145
- // Write password to a temporary config file (never in process args)
146
- const tmpConfigPath = `/tmp/lamalibre-lamaste-curl-${crypto.randomBytes(8).toString('hex')}`;
147
- try {
148
- await writeFile(tmpConfigPath, `--pass "${p12Password}"\n`, { mode: 0o600 });
149
-
150
- const { stdout } = await execa(
151
- 'curl',
152
- buildPanelCurlArgs({ method, path, body, p12Path, configPath: tmpConfigPath }),
153
- { timeout: timeoutMs },
154
- );
155
-
156
- return parsePanelResponse(stdout, method, path);
157
- } finally {
158
- await unlink(tmpConfigPath).catch(() => {});
159
- }
374
+ return curlPanelRequest({ identity, method, path, body, p12Path, p12Password, timeoutMs });
160
375
  }
@@ -0,0 +1,165 @@
1
+ /**
2
+ * The panel's IP listener, as `lamaste-server` verifies it.
3
+ *
4
+ * The CLI runs as root on the relay and sends the panel requests that carry
5
+ * the registry token, plugin settings and secrets, and the admin client
6
+ * certificate. It reaches the panel through nginx's IP vhost on port 9292
7
+ * (`lamalibre-lamaste-panel-ip`), which presents the self-signed certificate
8
+ * create-lamaste generated for the server's address
9
+ * (`<PKI_DIR>/self-signed.pem`; its key is 0600). Port 9292 is not a
10
+ * privileged port: whenever nginx does not hold it — panel 2FA switches the
11
+ * IP vhost off, nginx is stopped or failed to start — any local user can
12
+ * listen there. So the CLI never trusts whatever answers on it:
13
+ *
14
+ * - before connecting, it refuses when panel 2FA is on or the IP vhost is not
15
+ * enabled, since nginx then does not listen on 9292 (`assertPanelListener`);
16
+ * - every request verifies the listener against exactly that certificate,
17
+ * read from disk as root (`readPanelIdentity`, curl `--cacert`), never
18
+ * `-k`. The TLS handshake fails before the CLI presents its client
19
+ * certificate or sends a byte of the request (see `buildPanelCurlRequest`).
20
+ */
21
+
22
+ import { X509Certificate, createHash } from 'node:crypto';
23
+ import { lstat, readFile } from 'node:fs/promises';
24
+ import net from 'node:net';
25
+ import path from 'node:path';
26
+ import { PKI_DIR } from './config.js';
27
+
28
+ /** The port of nginx's IP vhost for the panel. */
29
+ export const PANEL_IP_PORT = 9292;
30
+
31
+ /** The certificate the IP vhost presents (create-lamaste, `tasks/nginx.js`). */
32
+ export const PANEL_IP_CERT_PATH = path.join(PKI_DIR, 'self-signed.pem');
33
+
34
+ /** The IP vhost's link in nginx's enabled sites (panel 2FA removes it). */
35
+ export const PANEL_IP_SITE_ENABLED = '/etc/nginx/sites-enabled/lamalibre-lamaste-panel-ip';
36
+
37
+ /** Where the CLI connects: the panel's own machine. */
38
+ export const PANEL_LOOPBACK = '127.0.0.1';
39
+
40
+ /**
41
+ * The IPv4 address a certificate is issued for: its first `IP Address`
42
+ * subject alternative name. create-lamaste (and the restore procedure in the
43
+ * docs) issue the IP vhost's certificate with `subjectAltName=IP:<address>`;
44
+ * curl checks the request's host against it. Pure, exported for tests.
45
+ *
46
+ * @param {X509Certificate} cert
47
+ * @returns {string}
48
+ */
49
+ export function certifiedAddress(cert) {
50
+ const names = (cert.subjectAltName ?? '').split(', ');
51
+ for (const name of names) {
52
+ if (!name.startsWith('IP Address:')) continue;
53
+ const address = name.slice('IP Address:'.length).trim();
54
+ if (net.isIPv4(address)) return address;
55
+ }
56
+ throw new Error(
57
+ 'The panel certificate names no IPv4 address (subjectAltName IP:<address>), so it cannot be verified',
58
+ );
59
+ }
60
+
61
+ /**
62
+ * The certificate's public-key pin: base64 SHA-256 of its SubjectPublicKeyInfo
63
+ * — curl's `--pinnedpubkey sha256//<pin>` form, the one agents and the
64
+ * desktop app store for the panel. Pure, exported for tests.
65
+ *
66
+ * @param {X509Certificate} cert
67
+ * @returns {string}
68
+ */
69
+ export function publicKeyPin(cert) {
70
+ const spki = cert.publicKey.export({ type: 'spki', format: 'der' });
71
+ return createHash('sha256').update(spki).digest('base64');
72
+ }
73
+
74
+ /**
75
+ * Read the IP vhost's certificate: the identity every panel request is
76
+ * verified against. A link or a file others could write is refused — the
77
+ * CLI only trusts a certificate as much as the file that holds it. The file
78
+ * is read on every request, so a certificate regenerated by a full
79
+ * reinstall (or the restore procedure) is picked up as soon as nginx serves
80
+ * it.
81
+ *
82
+ * @param {string} [certPath]
83
+ * @returns {Promise<{ certPath: string, address: string, port: number, pin: string }>}
84
+ */
85
+ export async function readPanelIdentity(certPath = PANEL_IP_CERT_PATH) {
86
+ let st;
87
+ try {
88
+ st = await lstat(certPath);
89
+ } catch (err) {
90
+ throw new Error(
91
+ `Cannot read the panel's TLS certificate ${certPath} (${err.code ?? err.message}); ` +
92
+ 'lamaste-server verifies the panel against it and sends nothing without it',
93
+ );
94
+ }
95
+ if (!st.isFile()) {
96
+ throw new Error(`The panel's TLS certificate ${certPath} is not a regular file`);
97
+ }
98
+ if ((st.mode & 0o022) !== 0) {
99
+ throw new Error(
100
+ `The panel's TLS certificate ${certPath} is writable by group or others; refusing to trust it`,
101
+ );
102
+ }
103
+ let cert;
104
+ try {
105
+ cert = new X509Certificate(await readFile(certPath));
106
+ } catch {
107
+ throw new Error(`${certPath} does not hold a certificate`);
108
+ }
109
+ return {
110
+ certPath,
111
+ address: certifiedAddress(cert),
112
+ port: PANEL_IP_PORT,
113
+ pin: publicKeyPin(cert),
114
+ };
115
+ }
116
+
117
+ /**
118
+ * Why nothing verified listens on the IP vhost's port, or null when nginx
119
+ * should hold it. Pure, exported for tests.
120
+ *
121
+ * @param {{ panel2faEnabled: boolean, siteEnabled: boolean }} state
122
+ * @returns {string | null}
123
+ */
124
+ export function panelListenerRefusal({ panel2faEnabled, siteEnabled }) {
125
+ if (panel2faEnabled) {
126
+ return (
127
+ 'Panel 2FA is enabled: it switches off the panel listener on port 9292 that lamaste-server ' +
128
+ 'talks to, and every admin request then needs a 2FA session the CLI cannot hold. Nothing was ' +
129
+ 'sent. Use the panel in the browser, or turn 2FA off in its Settings to use this command.'
130
+ );
131
+ }
132
+ if (!siteEnabled) {
133
+ return (
134
+ 'The panel listener on port 9292 is not enabled (nginx site lamalibre-lamaste-panel-ip), so ' +
135
+ 'nginx does not hold the port and nothing was sent to it. Re-enable the site or re-run ' +
136
+ 'create-lamaste.'
137
+ );
138
+ }
139
+ return null;
140
+ }
141
+
142
+ /**
143
+ * Refuse before connecting when nginx does not listen on the IP vhost's
144
+ * port: whatever answered there could only be someone else's listener.
145
+ *
146
+ * @param {Record<string, unknown> | null} config panel.json
147
+ * @param {string} [siteEnabledPath]
148
+ * @returns {Promise<void>}
149
+ */
150
+ export async function assertPanelListener(config, siteEnabledPath = PANEL_IP_SITE_ENABLED) {
151
+ let siteEnabled = true;
152
+ try {
153
+ // The link itself, not what it points to: nginx follows it.
154
+ await lstat(siteEnabledPath);
155
+ } catch (err) {
156
+ if (err.code !== 'ENOENT') throw err;
157
+ siteEnabled = false;
158
+ }
159
+ const twofa = config?.panel2fa;
160
+ const refusal = panelListenerRefusal({
161
+ panel2faEnabled: typeof twofa === 'object' && twofa !== null && twofa.enabled === true,
162
+ siteEnabled,
163
+ });
164
+ if (refusal) throw new Error(refusal);
165
+ }